Files
get-shit-done/scripts/audit-workflow-script-paths.cjs
Tom Boucher c2ada7e799 feat(#2995): post-install path audit for workflow-invoked scripts (#2996)
* feat(#2995): post-install path audit for workflow-invoked scripts

Catches the gap class surfaced by #2994: a workflow references a script
via ${GSD_HOME}/<path> that ships in the npm tarball but is not copied
to the user's config dir at install time. Unit tests don't catch it
because they resolve the script via path.join(__dirname, '..', 'scripts',
…) — the source layout, not the deployed layout.

Implementation built TDD per #2995, vertical slices with structured-IR
assertions:

  scripts/audit-workflow-script-paths.cjs
    - Pure auditWorkflowScriptPaths({ workflowsDir, repoRoot,
      installedPrefixes }) returns { ok, findings: [{ workflow, path,
      kind }] } via the AUDIT_FINDING enum.
    - Two finding kinds: MISSING_FROM_REPO (typo / file deleted) and
      NOT_INSTALLED (#2994 class — first segment outside installed
      prefixes).
    - Tolerates ${GSD_HOME:-...} default-fallback syntax.

  tests/bug-2995-post-install-script-paths.test.cjs
    - 9 tests across 3 suites:
      • Pure-function pass and per-finding-kind detection (5 tests on
        synthetic fixtures).
      • Real workflow audit (2 tests asserting the actual repo's
        get-shit-done/workflows/ has no NEW gaps and KNOWN_GAPS stays
        consistent with audit findings).
      • Enum shape lock + extractReferences edge cases.
    - All assertions on typed AUDIT_FINDING enum / structured records;
      zero raw text matching.
    - KNOWN_GAPS is a Set keyed on `workflow|path|kind` strings;
      currently contains the #2994 entry. The companion test fails if
      a KNOWN_GAPS entry no longer matches a real finding (forces the
      allow-list to shrink as gaps fix).

The audit immediately catches #2994's gap on `reapply-patches.md`. The
allow-list contains exactly that entry; new gaps fail CI; #2994's fix
will remove the entry as part of the same PR.

Closes #2995
Refs #2994

* chore(#2995): add changeset fragment for PR #2996

* chore(#2995): add changeset fragment for PR #2996

* fix(#2995): emit both NOT_INSTALLED + MISSING_FROM_REPO; clean up fixture leak (CR)

CodeRabbit on PR #2996 found two issues:

1. (Low value) auditWorkflowScriptPaths short-circuited on NOT_INSTALLED,
   masking MISSING_FROM_REPO for the same ref. Removed the `continue` so
   both findings emit in one run; added a regression test.

2. (Low value) bug-2995 test created tmpRoot in before() but never wrote
   into it; per-fixture mkdtempSync dirs leaked. Rooted fixture repos
   under tmpRoot so the after() cleanup actually frees them.
2026-05-01 21:13:45 -04:00

74 lines
2.6 KiB
JavaScript

'use strict';
/**
* Post-install path audit for workflow-invoked scripts (#2995).
*
* Walks workflowsDir, extracts every `${GSD_HOME[...]}/<path>.<cjs|js|sh>`
* token, and asserts:
* 1. the file exists in the repo at that <path> (catches typos)
* 2. <path>'s first segment is in installedPrefixes (catches the
* #2994 class: source-vs-deployed-path mismatches)
*
* Pure function over (workflowsDir, repoRoot, installedPrefixes); no
* filesystem mutation. Tests assert on the typed AUDIT_FINDING enum.
*/
const fs = require('node:fs');
const path = require('node:path');
const AUDIT_FINDING = Object.freeze({
MISSING_FROM_REPO: 'missing_from_repo',
NOT_INSTALLED: 'not_installed',
});
// Match `${GSD_HOME}` or `${GSD_HOME:-...}` followed by a /-rooted path
// ending in .cjs/.js/.sh. The path is captured verbatim (relative to
// the install root).
const REF_RE = /\$\{GSD_HOME(?::-[^}]*)?\}\/([A-Za-z0-9_./-]+\.(?:cjs|js|sh))/g;
function listWorkflowFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isFile() && e.name.endsWith('.md'))
.map((e) => path.join(dir, e.name));
}
function extractReferences(content) {
const out = [];
let m;
// RegExp objects with /g state must be reset per call.
const re = new RegExp(REF_RE.source, 'g');
while ((m = re.exec(content)) !== null) {
out.push(m[1]);
}
return out;
}
function auditWorkflowScriptPaths({ workflowsDir, repoRoot, installedPrefixes }) {
const findings = [];
const installedSet = new Set(installedPrefixes);
for (const file of listWorkflowFiles(workflowsDir)) {
const content = fs.readFileSync(file, 'utf8');
const workflow = path.basename(file);
for (const ref of extractReferences(content)) {
const firstSegment = ref.split('/')[0];
// #2996 CR: emit BOTH findings simultaneously when a reference is
// both outside an installed prefix AND missing from the repo. The
// earlier `continue` short-circuited MISSING_FROM_REPO, so a
// developer who moved a missing reference to an installed prefix
// would only discover the second issue on a subsequent CI run.
if (!installedSet.has(firstSegment)) {
findings.push({ workflow, path: ref, kind: AUDIT_FINDING.NOT_INSTALLED });
}
const sourceFile = path.join(repoRoot, ref);
if (!fs.existsSync(sourceFile)) {
findings.push({ workflow, path: ref, kind: AUDIT_FINDING.MISSING_FROM_REPO });
}
}
}
return { ok: findings.length === 0, findings };
}
module.exports = { auditWorkflowScriptPaths, AUDIT_FINDING, extractReferences };