Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -570,8 +570,14 @@ program
.command("stats")
.description("Generate an overview with some statistics")
.requiredOption("-s, --since <date>, Specify the date which is going to be used to filter the data from (format: YYYY-MM-DD) (mandatory)")
.option("-w, --workflow [handle], Optionally filter test statistics by workflow (optional)")
.action((options) => {
stats.generateOverview(options.since);
if (options.workflow) {
const workflowHandle = typeof options.workflow === "string" ? options.workflow : undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--workflow without a value silently runs all-workflows scan instead of erroring.

The option is declared with optional-argument syntax [handle], so running silverfin stats -s 2024-01-01 --workflow (flag present, no value) causes Commander to set options.workflow = true (boolean). The if (options.workflow) check on the line above is truthy, but this typeof === 'string' guard then sets workflowHandle = undefined. As a result, generateWorkflowOverview(sinceDate, undefined) is called and silently reads every file in the workflows/ folder — the user gets a full-repo scan with no indication their argument was dropped.

Consider using <handle> (required argument) instead of [handle], or explicitly check options.workflow === true and print an error.

stats.generateWorkflowOverview(options.since, workflowHandle);
} else {
stats.generateOverview(options.since);
}
});

// Set/Get FIRM ID
Expand Down
264 changes: 255 additions & 9 deletions lib/cli/stats.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,33 @@ async function generateOverview(sinceDate) {
const TODAY = new Date().toJSON().toString().slice(0, 10);
const templateSummary = await getTemplatesSummary();
const yamlSummary = await getYamlSummary(sinceDate);
// Terminal
displayOverview(sinceDate, TODAY, templateSummary, yamlSummary);
// File
const row = createRow(sinceDate, TODAY, templateSummary, yamlSummary);
saveOverviewToFile(row);
}

async function generateWorkflowOverview(sinceDate, workflowHandle) {
const TODAY = new Date().toJSON().toString().slice(0, 10);
let workflowHandles;
if (workflowHandle) {
workflowHandles = [workflowHandle];
} else {
const workflowsFolder = path.join(process.cwd(), "workflows");
workflowHandles = fs.readdirSync(workflowsFolder).map((file) => path.basename(file, ".json"));
}
Comment on lines +23 to +26

@coderabbitai coderabbitai Bot Jun 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing error handling when workflows folder doesn't exist.

If the workflows directory doesn't exist, fs.readdirSync will throw an uncaught ENOENT error. This could happen in repositories that don't have any workflows defined.

🛡️ Proposed fix to handle missing workflows folder
   } else {
     const workflowsFolder = path.join(process.cwd(), "workflows");
+    if (!fs.existsSync(workflowsFolder)) {
+      consola.error("No workflows folder found. Please create a 'workflows' directory with workflow JSON files.");
+      process.exit(1);
+    }
     workflowHandles = fs.readdirSync(workflowsFolder).map((file) => path.basename(file, ".json"));
+    if (workflowHandles.length === 0) {
+      consola.info("No workflow files found in the workflows folder.");
+      return;
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else {
const workflowsFolder = path.join(process.cwd(), "workflows");
workflowHandles = fs.readdirSync(workflowsFolder).map((file) => path.basename(file, ".json"));
}
} else {
const workflowsFolder = path.join(process.cwd(), "workflows");
if (!fs.existsSync(workflowsFolder)) {
consola.error("No workflows folder found. Please create a 'workflows' directory with workflow JSON files.");
process.exit(1);
}
workflowHandles = fs.readdirSync(workflowsFolder).map((file) => path.basename(file, ".json"));
if (workflowHandles.length === 0) {
consola.info("No workflow files found in the workflows folder.");
return;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/cli/stats.js` around lines 23 - 26, The code currently sets
workflowsFolder and populates workflowHandles using fs.readdirSync which will
throw if the "workflows" directory is missing; update the logic around the
workflowsFolder/workflowHandles assignment (the workflowsFolder const and
workflowHandles variable) to handle a missing folder by either checking
fs.existsSync(workflowsFolder) before calling fs.readdirSync or wrapping
readdirSync in a try/catch and falling back to an empty array, and ensure any
caught error is handled or logged rather than allowed to crash the process.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional context: the same readdirSync line also lacks a filter for non-JSON files. Every entry in the folder is mapped through path.basename(file, '.json') — so .DS_Store, README.md, a subdirectory, etc. produce invalid handles (e.g. .DS_Store unchanged). getWorkflow('.DS_Store') then looks for workflows/.DS_Store.json, doesn't find it, and calls process.exit(1), aborting the entire run before any remaining valid workflows are processed. The fix proposed above should also include a .filter(f => f.endsWith('.json')) before the .map(...).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michieldegezelle, excellent catch — that's a real footgun. Without the filter, even a stray .DS_Store or README.md silently breaks the whole run via process.exit(1) inside getWorkflow. Here's the combined fix:

  } else {
    const workflowsFolder = path.join(process.cwd(), "workflows");
    if (!fs.existsSync(workflowsFolder)) {
      consola.error("No workflows folder found. Please create a 'workflows' directory with workflow JSON files.");
      process.exit(1);
    }
    workflowHandles = fs.readdirSync(workflowsFolder)
+     .filter((file) => file.endsWith(".json"))
      .map((file) => path.basename(file, ".json"));
    if (workflowHandles.length === 0) {
      consola.info("No workflow files found in the workflows folder.");
      return;
    }
  }

The .filter(f => f.endsWith('.json')) ensures that directories, dotfiles (.DS_Store), and other non-JSON files are excluded before path.basename strips the extension — so only valid workflow handles ever reach getWorkflow.


✏️ Learnings added
Learnt from: michieldegezelle
Repo: silverfin/silverfin-cli PR: 247
File: lib/cli/stats.js:23-26
Timestamp: 2026-06-11T07:33:04.929Z
Learning: In `lib/cli/stats.js` (`generateWorkflowOverview`), when reading workflow handles from the `workflows/` folder via `fs.readdirSync`, the result must be filtered to `.json` files only (`.filter(f => f.endsWith('.json'))`) before mapping with `path.basename(file, '.json')`. Without this filter, non-JSON entries like `.DS_Store`, `README.md`, or subdirectories produce invalid handles that cause `getWorkflow()` to call `process.exit(1)`, aborting the entire run.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

for (const handle of workflowHandles) {
const workflow = await fsUtils.getWorkflow(handle);
const templateSummary = await getWorkflowTemplateSummary(workflow);
const yamlSummary = await getYamlSummary(sinceDate, workflow);
displayWorkflowOverview(sinceDate, TODAY, templateSummary, yamlSummary);
const row = createWorkflowRow(sinceDate, TODAY, templateSummary, yamlSummary);
saveWorkflowOverviewToFile(row, handle);
}
}

// Return an object with the count of activities by file and by type
// Type could be: A (added), M (modified), D (deleted)
async function yamlFilesActivity(sinceDate) {
async function yamlFilesActivity(sinceDate, workflow) {
const countByType = {};
const filesChanged = exec.execSync(`git whatchanged --since="${sinceDate}" --name-status --pretty="format:"`);
if (!filesChanged) {
Expand All @@ -35,7 +52,9 @@ async function yamlFilesActivity(sinceDate) {
}

// Files to Search (YAML)
const YAML_EXPRESSION = `.*/.*/tests/.*_liquid_test.*.y(a)?ml`;
const templatesInWorkflow = workflow ? workflow.templates.reconciliations.concat(workflow.templates.accounts) : [];
const TEMPLATE_PATTERN = workflow ? `(${templatesInWorkflow.join("|")})` : `.*`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty template list produces a broken regex pattern.

When a workflow has no reconciliations and no accounts, templatesInWorkflow is [], so TEMPLATE_PATTERN becomes () and YAML_EXPRESSION effectively matches all YAML test paths — the opposite of filtering. Consider returning early (or skipping YAML activity) when the combined list is empty.

const YAML_EXPRESSION = `.*/${TEMPLATE_PATTERN}/tests/.*_liquid_test.*.y(a)?ml`;
const fileTypeRegExp = RegExp(YAML_EXPRESSION, "g");
Comment on lines +55 to 58

@coderabbitai coderabbitai Bot Jun 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Regex metacharacters in template names are not escaped.

If template names in the workflow contain regex special characters (e.g., ., +, *, (, )), the constructed regex pattern will behave unexpectedly. Template names are joined and used directly in a regex pattern without escaping.

🔧 Proposed fix to escape regex special characters
+function escapeRegex(string) {
+  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
 async function yamlFilesActivity(sinceDate, workflow) {
   // ...
   const templatesInWorkflow = workflow ? workflow.templates.reconciliations.concat(workflow.templates.accounts) : [];
-  const TEMPLATE_PATTERN = workflow ? `(${templatesInWorkflow.join("|")})` : `.*`;
+  const TEMPLATE_PATTERN = workflow ? `(${templatesInWorkflow.map(escapeRegex).join("|")})` : `.*`;
   const YAML_EXPRESSION = `.*/${TEMPLATE_PATTERN}/tests/.*_liquid_test.*.y(a)?ml`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/cli/stats.js` around lines 55 - 58, The TEMPLATE_PATTERN is built from
raw template names (templatesInWorkflow) so regex metacharacters in names break
the pattern; before joining, escape each template string (e.g., implement or use
an escapeRegExp helper and map over templatesInWorkflow to escape chars like . *
+ ? ^ $ ( ) [ ] { } | \ /) then join the escaped names to build TEMPLATE_PATTERN
and proceed to construct YAML_EXPRESSION and fileTypeRegExp from that escaped
pattern (refer to templatesInWorkflow, TEMPLATE_PATTERN, YAML_EXPRESSION,
fileTypeRegExp).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The identical pattern exists in countYamlFiles (line 99), where TEMPLATE_PATTERN is built the same way from templatesInWorkflow. The escapeRegex helper proposed above needs to be applied there too, otherwise workflow-filtered YAML counts will have the same unescaped-metacharacter exposure as the activity scan.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michieldegezelle, you're absolutely right — the same unescaped-metacharacter exposure exists in countYamlFiles. Here's the combined fix that defines the helper once and applies it in both places:

+function escapeRegex(string) {
+  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
 async function yamlFilesActivity(sinceDate, workflow) {
   // ...
   const templatesInWorkflow = workflow
     ? workflow.templates.reconciliations.concat(workflow.templates.accounts)
     : [];
-  const TEMPLATE_PATTERN = workflow ? `(${templatesInWorkflow.join("|")})` : `.*`;
+  const TEMPLATE_PATTERN = workflow
+    ? `(${templatesInWorkflow.map(escapeRegex).join("|")})`
+    : `.*`;
   const YAML_EXPRESSION = `.*/${TEMPLATE_PATTERN}/tests/.*_liquid_test.*.y(a)?ml`;
   // ...
 }
 
 async function countYamlFiles(templateType, templatesInWorkflow) {
   // ...
-  const TEMPLATE_PATTERN = templatesInWorkflow ? `(${templatesInWorkflow.join("|")})` : `.*`;
+  const TEMPLATE_PATTERN = templatesInWorkflow
+    ? `(${templatesInWorkflow.map(escapeRegex).join("|")})`
+    : `.*`;
   const YAML_EXPRESSION = `.*${FOLDER}/${TEMPLATE_PATTERN}/tests/.*_liquid_test.*.y(a)?ml`;
   // ...
 }

Both the activity scan and the YAML file count share the same vulnerable pattern-building logic, so both need the same fix. Thanks for catching this!


for (const row of nonEmptyRows) {
Expand Down Expand Up @@ -74,10 +93,12 @@ async function yamlFilesActivity(sinceDate) {
// Count how many YAML files are stored. We base on the presence of a non empty file
// Count how many unit tests are stored. We base on the presence of a title for each unit test
// Count how many YAML files have at least two unit tests
async function countYamlFiles(templateType) {
async function countYamlFiles(templateType, templatesInWorkflow) {
const files = fsUtils.listExistingFiles("yml");
const FOLDER = fsUtils.FOLDERS[templateType];
const YAML_EXPRESSION = `.*${FOLDER}/.*/tests/.*_liquid_test.*.y(a)?ml`;
const TEMPLATE_PATTERN = templatesInWorkflow ? `(${templatesInWorkflow.join("|")})` : `.*`;
// Issue with counting multiple yml files in the same tests folder?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover open question in a comment (Issue with counting multiple yml files in the same tests folder?). Either confirm this is not a problem and remove the comment, or document/fix the edge case.

const YAML_EXPRESSION = `.*${FOLDER}/${TEMPLATE_PATTERN}/tests/.*_liquid_test.*.y(a)?ml`;
const re = new RegExp(YAML_EXPRESSION, "g");
let countFiles = 0;
let countFilesWithAtLeastTwoTests = 0;
Expand Down Expand Up @@ -255,8 +276,99 @@ async function getTemplatesSummary() {
return summary;
}

async function getYamlSummary(sinceDate) {
const yamlActivity = await yamlFilesActivity(sinceDate);
async function getWorkflowTemplateSummary(workflow) {
const summary = {
workflow_name: "",
reconciliations: {
total: 0,
externallyManaged: 0,
externallyManagedPerc: 0,
yamlFiles: 0,
yamlFilesPerc: 0,
unitTests: 0,
yamlFilesWithAtLeastTwoTests: 0,
yamlFilesWithAtLeastTwoTestsPerc: 0,
},
exportFiles: {
total: 0,
externallyManaged: 0,
externallyManagedPerc: 0,
},
accountTemplates: {
total: 0,
externallyManaged: 0,
externallyManagedPerc: 0,
yamlFiles: 0,
yamlFilesPerc: 0,
unitTests: 0,
yamlFilesWithAtLeastTwoTests: 0,
yamlFilesWithAtLeastTwoTestsPerc: 0,
},
all: {
total: 0,
externallyManaged: 0,
externallyManagedPerc: 0,
yamlFiles: 0,
yamlFilesPerc: 0,
unitTests: 0,
yamlFilesWithAtLeastTwoTests: 0,
yamlFilesWithAtLeastTwoTestsPerc: 0,
},
};

// Fetch workflow
// Assume no empty items if present within the workflow.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Global vs workflow stats count different populations.

generateOverview counts only non-empty templates (listNonEmptyTemplates), while workflow mode counts every handle listed in the workflow JSON. That is probably fine, but worth documenting in the CLI help or PR description so users do not compare the two totals directly.

summary.workflow_name = workflow.name;

// Reconciliations
const reconciliationsInWorkflow = workflow.templates.reconciliations;
const reconciliationsExtMan = await listExternallyManagedTemplates("reconciliationText", reconciliationsInWorkflow);
const reconciliationsTests = await countYamlFiles("reconciliationText", reconciliationsInWorkflow);
summary.reconciliations.total = reconciliationsInWorkflow.length;
summary.reconciliations.externallyManaged = reconciliationsExtMan.length;
summary.reconciliations.externallyManagedPerc = percentageRoundTwo(summary.reconciliations.externallyManaged, summary.reconciliations.total);
summary.reconciliations.yamlFiles = reconciliationsTests.files;
summary.reconciliations.yamlFilesPerc = percentageRoundTwo(summary.reconciliations.yamlFiles, summary.reconciliations.total);
summary.reconciliations.unitTests = reconciliationsTests.tests;
summary.reconciliations.yamlFilesWithAtLeastTwoTests = reconciliationsTests.filesWithAtLeastTwoTests;
summary.reconciliations.yamlFilesWithAtLeastTwoTestsPerc = percentageRoundTwo(summary.reconciliations.yamlFilesWithAtLeastTwoTests, summary.reconciliations.total);

// Export Files
const exportFilesInWorkflow = workflow.templates.exports;
const exportFilesExtMan = await listExternallyManagedTemplates("exportFile", exportFilesInWorkflow);
summary.exportFiles.total = exportFilesInWorkflow.length;
summary.exportFiles.externallyManaged = exportFilesExtMan.length;
summary.exportFiles.externallyManagedPerc = percentageRoundTwo(summary.exportFiles.externallyManaged, summary.exportFiles.total);

// Account Templates
const accountTemplatesInWorkflow = workflow.templates.accounts;
const accountTemplatesExtMan = await listExternallyManagedTemplates("accountTemplate", accountTemplatesInWorkflow);
const accountTemplatesTests = await countYamlFiles("accountTemplate", accountTemplatesInWorkflow);
summary.accountTemplates.total = accountTemplatesInWorkflow.length;
summary.accountTemplates.externallyManaged = accountTemplatesExtMan.length;
summary.accountTemplates.externallyManagedPerc = percentageRoundTwo(summary.accountTemplates.externallyManaged, summary.accountTemplates.total);
summary.accountTemplates.yamlFiles = accountTemplatesTests.files;
summary.accountTemplates.yamlFilesPerc = percentageRoundTwo(summary.accountTemplates.yamlFiles, summary.accountTemplates.total);
summary.accountTemplates.unitTests = accountTemplatesTests.tests;
summary.accountTemplates.yamlFilesWithAtLeastTwoTests = accountTemplatesTests.filesWithAtLeastTwoTests;
summary.accountTemplates.yamlFilesWithAtLeastTwoTestsPerc = percentageRoundTwo(summary.accountTemplates.yamlFilesWithAtLeastTwoTests, summary.accountTemplates.total);

// All
summary.all.total = summary.reconciliations.total + summary.exportFiles.total + summary.accountTemplates.total;
summary.all.externallyManaged =
summary.reconciliations.externallyManaged + summary.exportFiles.externallyManaged + summary.accountTemplates.externallyManaged;
summary.all.externallyManagedPerc = percentageRoundTwo(summary.all.externallyManaged, summary.all.total);
summary.all.yamlFiles = summary.reconciliations.yamlFiles + summary.accountTemplates.yamlFiles;
summary.all.yamlFilesPerc = percentageRoundTwo(summary.all.yamlFiles, summary.reconciliations.total + summary.accountTemplates.total);
summary.all.unitTests = summary.reconciliations.unitTests + summary.accountTemplates.unitTests;
summary.all.yamlFilesWithAtLeastTwoTests = summary.reconciliations.yamlFilesWithAtLeastTwoTests + summary.accountTemplates.yamlFilesWithAtLeastTwoTests;
summary.all.yamlFilesWithAtLeastTwoTestsPerc = percentageRoundTwo(summary.all.yamlFilesWithAtLeastTwoTests, summary.reconciliations.total + summary.accountTemplates.total);

return summary;
}

async function getYamlSummary(sinceDate, templatesInWorkflow) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the second parameter is named templatesInWorkflow but callers pass the full workflow object into yamlFilesActivity. Renaming to workflow would match actual usage.

const yamlActivity = await yamlFilesActivity(sinceDate, templatesInWorkflow);
const summary = { created: 0, updated: 0 };
summary.created = (yamlActivity["A"] || 0) - (yamlActivity["D"] || 0);
summary.updated = yamlActivity["M"] || 0;
Expand Down Expand Up @@ -309,6 +421,54 @@ function displayOverview(sinceDate, today, templateSummary, yamlSummary) {
consola.log("------------------------------------");
}

function displayWorkflowOverview(sinceDate, today, templateSummary, yamlSummary) {
// Header
consola.log("");
consola.info(`${chalk.bold(`Workflow Summary - ${templateSummary.workflow_name} ( ${sinceDate} - ${today} ):`)}`);
consola.log("------------------------------------");
consola.log("");
// YAML file changes
consola.log(`New YAML files created in the period: ${yamlSummary.created}`);
consola.log(`Updates to existing YAML files in the period: ${yamlSummary.updated}`);
consola.log("");
consola.log("------------------------------------");
consola.log("");
// Reconciliations
consola.log(`${chalk.bold("Reconciliations:")}`);
consola.log(`Templates: ${templateSummary.reconciliations.total}`);
consola.log(`Externally Managed: ${templateSummary.reconciliations.externallyManaged} (${templateSummary.reconciliations.externallyManagedPerc}%)`);
consola.log(`YAML files: ${templateSummary.reconciliations.yamlFiles} (${templateSummary.reconciliations.yamlFilesPerc}%)`);
consola.log(`Unit Tests: ${templateSummary.reconciliations.unitTests}`);
consola.log(
`YAML files with at least two unit tests: ${templateSummary.reconciliations.yamlFilesWithAtLeastTwoTests} (${templateSummary.reconciliations.yamlFilesWithAtLeastTwoTestsPerc}%)`
);
consola.log("");
// Account Templates
consola.log(`${chalk.bold("Account Templates:")}`);
consola.log(`Templates: ${templateSummary.accountTemplates.total}`);
consola.log(`Externally Managed: ${templateSummary.accountTemplates.externallyManaged} (${templateSummary.accountTemplates.externallyManagedPerc}%)`);
consola.log(`YAML files: ${templateSummary.accountTemplates.yamlFiles} (${templateSummary.accountTemplates.yamlFilesPerc}%)`);
consola.log(`Unit Tests: ${templateSummary.accountTemplates.unitTests}`);
consola.log(
`YAML files with at least two unit tests: ${templateSummary.accountTemplates.yamlFilesWithAtLeastTwoTests} (${templateSummary.accountTemplates.yamlFilesWithAtLeastTwoTestsPerc}%)`
);
consola.log("");
// Export Files
consola.log(`${chalk.bold("Export Files:")}`);
consola.log(`Templates: ${templateSummary.exportFiles.total}`);
consola.log(`Externally Managed: ${templateSummary.exportFiles.externallyManaged} (${templateSummary.exportFiles.externallyManagedPerc}%)`);
consola.log("");
// All
consola.log(`${chalk.bold("All:")}`);
consola.log(`Templates: ${templateSummary.all.total}`);
consola.log(`Externally Managed: ${templateSummary.all.externallyManaged} (${templateSummary.all.externallyManagedPerc}%)`);
consola.log(`YAML files: ${templateSummary.all.yamlFiles} (${templateSummary.all.yamlFilesPerc}%)`);
consola.log(`Unit Tests: ${templateSummary.all.unitTests}`);
consola.log(`YAML files with at least two unit tests: ${templateSummary.all.yamlFilesWithAtLeastTwoTests} (${templateSummary.all.yamlFilesWithAtLeastTwoTestsPerc}%)`);
consola.log("");
consola.log("------------------------------------");
}

function createRow(sinceDate, today, templateSummary, yamlSummary) {
// Row to append to file
const rowContent = [
Expand Down Expand Up @@ -349,6 +509,43 @@ function createRow(sinceDate, today, templateSummary, yamlSummary) {
return row;
}

function createWorkflowRow(sinceDate, today, templateSummary, yamlSummary) {
const rowContent = [
templateSummary.workflow_name,
sinceDate,
today,
yamlSummary.created,
yamlSummary.updated,
templateSummary.all.total,
templateSummary.all.externallyManaged,
templateSummary.all.yamlFiles,
templateSummary.all.unitTests,
templateSummary.reconciliations.total,
templateSummary.reconciliations.externallyManaged,
templateSummary.reconciliations.yamlFiles,
templateSummary.reconciliations.unitTests,
templateSummary.accountTemplates.total,
templateSummary.accountTemplates.externallyManaged,
templateSummary.accountTemplates.yamlFiles,
templateSummary.accountTemplates.unitTests,
templateSummary.exportFiles.total,
templateSummary.exportFiles.externallyManaged,
templateSummary.all.externallyManagedPerc,
templateSummary.reconciliations.externallyManagedPerc,
templateSummary.accountTemplates.externallyManagedPerc,
templateSummary.exportFiles.externallyManagedPerc,
templateSummary.all.yamlFilesPerc,
templateSummary.reconciliations.yamlFilesPerc,
templateSummary.accountTemplates.yamlFilesPerc,
templateSummary.reconciliations.yamlFilesWithAtLeastTwoTests,
templateSummary.reconciliations.yamlFilesWithAtLeastTwoTestsPerc,
templateSummary.accountTemplates.yamlFilesWithAtLeastTwoTests,
templateSummary.accountTemplates.yamlFilesWithAtLeastTwoTestsPerc,
];
const row = `\r\n${rowContent.join(";")}`;
return row;
}

// content row must be a string with each column separated by ";"
function saveOverviewToFile(row) {
const COLUMNS = [
Expand Down Expand Up @@ -400,4 +597,53 @@ function saveOverviewToFile(row) {
fs.appendFileSync(CSV_PATH, row);
}

module.exports = { generateOverview };
// content row must be a string with each column separated by ";"
function saveWorkflowOverviewToFile(row, workflowHandle) {
const COLUMNS = [
"Workflow Name",
"Period - Start",
"Period - End",
"yaml files created in period",
"yaml files modified in period",
"All - templates",
"All - externally managed",
"All - yaml files",
"All - unit tests",
"Reconciliations - templates",
"Reconciliations - externally managed",
"Reconciliations - yaml files",
"Reconciliations - unit tests",
"Account Templates - templates",
"Account Templates - externally managed",
"Account Templates - yaml files",
"Account Templates - unit tests",
"Export Files - templates",
"Export Files - externally managed",
"All - externally managed (%)",
"Reconciliations - externally managed (%)",
"Account Templates - externally managed (%)",
"Export Files - externally managed (%)",
"All - yaml files (%)",
"Reconciliations - yaml files (%)",
"Account Templates - yaml files (%)",
"Reconciliations - yaml files with at least two tests",
"Reconciliations - yaml files with at least two tests (%)",
"Account Templates - yaml files with at least two tests",
"Account Templates - yaml files with at least two tests (%)",
];
const ROW_HEADER = `${COLUMNS.join(";")}`;
const CSV_PATH = `./stats/${workflowHandle}_stats.csv`;
// Create file and header columns
if (!fs.existsSync("./stats")) {
fs.mkdirSync("stats");
}
if (!fs.existsSync(CSV_PATH)) {
fs.writeFileSync(CSV_PATH, ROW_HEADER, (err) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fs.writeFileSync silently ignores the callback passed as the third argument.

writeFileSync is synchronous and its third parameter is an options object (encoding, mode, flag) — not an error handler. The callback (err) => { consola.error(err); } is coerced to an options object and silently discarded. Any write failure (e.g. permission denied, disk full) throws an uncaught synchronous exception rather than being handled. The same issue exists at line 592 in saveOverviewToFile.

Remove the callback and either let the exception propagate naturally, or wrap the call in a try/catch.

consola.error(err);
});
}
// Append content
fs.appendFileSync(CSV_PATH, row);
}

module.exports = { generateOverview, generateWorkflowOverview };
15 changes: 15 additions & 0 deletions lib/utils/fsUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,20 @@ function checkLiquidTestDependencies(targetHandle) {
return dependentHandles;
}

function getWorkflow(workflowHandle) {
try {
const workflowPath = path.join(process.cwd(), "workflows", `${workflowHandle}.json`);
if (!fs.existsSync(workflowPath)) {
throw new Error(`Workflow "${workflowHandle}" not found`);
}
return JSON.parse(fs.readFileSync(workflowPath).toString());
} catch (error) {
consola.error(`An error occurred when trying to read the workflow "${workflowHandle}"`);
consola.error(error);
process.exit(1);
}
}
Comment on lines +483 to +495

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unsanitized workflowHandle from CLI enables path traversal in multiple locations.

Both getWorkflow in lib/utils/fsUtils.js and saveWorkflowOverviewToFile in lib/cli/stats.js use the CLI-provided workflowHandle directly in file paths. The root cause is that the --workflow CLI argument is not validated at the entry point. Consider adding validation in getWorkflow (which is called first) and ensuring safe filenames in the CSV output function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/utils/fsUtils.js` around lines 483 - 495, getWorkflow currently uses the
raw CLI-provided workflowHandle to build file paths allowing path traversal;
update getWorkflow to validate and sanitize workflowHandle (e.g., reject any
path separators, "..", or characters outside a safe whitelist like
/^[A-Za-z0-9_-]+$/) and return a clear error instead of accepting unsafe names,
then apply the same sanitization or safe-filename conversion in
saveWorkflowOverviewToFile before constructing CSV/output paths so both
functions (getWorkflow and saveWorkflowOverviewToFile) never join untrusted
input into filesystem paths.


// Recursive option for fs.watch is not available in every OS (e.g. Linux)
function recursiveInspectDirectory({ basePath, collection, pathsArray = [], typeCheck = "liquid" }) {
collection.forEach((filePath) => {
Expand Down Expand Up @@ -622,5 +636,6 @@ module.exports = {
getTemplateId,
setTemplateId,
checkLiquidTestDependencies,
getWorkflow,
scanTextParts,
};
Loading