Files
msd-core/tests/lint-workflow-shellcheck.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

278 lines
11 KiB
JavaScript

'use strict';
/**
* lint-workflow-shellcheck.test.cjs — unit + property coverage for the
* hand-rolled shell-text parser/logic functions exported by
* scripts/lint-workflow-shellcheck.cjs (#4109 follow-up).
*
* Per this repo's CLAUDE.md: "Parsers, budget limits, and bijective
* contracts must include at least one fast-check (`fc`) property test."
* These functions ARE parsers (shell-text extraction/transformation over
* workflow-authored markdown), so the property-test requirement below is a
* binding gate, not optional polish.
*
* Note: scripts/lint-workflow-shellcheck.cjs guards its CLI entry point
* with `if (require.main === module) runMain(main);`, so requiring it here
* for its exported pure functions does not also trigger a live ShellCheck
* run against the real msd-core/workflows/ tree.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fc = require('./helpers/fast-check-setup.cjs');
const {
substitutePlaceholders,
stripCommandSubstitutions,
stripShellComments,
extractForLoops,
findBareForLoopSplits,
findingKey,
partitionAgainstBaseline,
} = require(path.join(__dirname, '..', 'scripts', 'lint-workflow-shellcheck.cjs'));
/** Minimal block shape findBareForLoopSplits expects (see the source's own
* extractBashBlocks for the real shape this stands in for). */
function mkBlock(body, overrides = {}) {
return { file: 'w.md', blockIdx: 0, firstBodyLine: 1, body, ...overrides };
}
describe('stripCommandSubstitutions', () => {
test('empty string', () => {
assert.equal(stripCommandSubstitutions(''), '');
});
test('no substitution present leaves the string intact', () => {
assert.equal(stripCommandSubstitutions('echo hello'), 'echo hello');
});
test('substitution at the start', () => {
assert.equal(stripCommandSubstitutions('$(echo hi) world'), ' world');
});
test('substitution in the middle', () => {
assert.equal(stripCommandSubstitutions('a $(echo hi) b'), 'a b');
});
test('substitution at the end', () => {
assert.equal(stripCommandSubstitutions('a $(echo hi)'), 'a ');
});
test('nested command substitution — $(echo $(nested))', () => {
assert.equal(stripCommandSubstitutions('pre $(echo $(nested)) post'), 'pre post');
});
test('unterminated substitution does not crash or hang, consumes to end of string', () => {
assert.equal(stripCommandSubstitutions('a $(echo hi'), 'a ');
});
test('malformed nested-unterminated input does not crash or hang', () => {
assert.equal(stripCommandSubstitutions('a $(echo $(nested'), 'a ');
});
});
describe('stripShellComments', () => {
test('strips a full-line comment to spaces, preserving the newline', () => {
assert.equal(stripShellComments('# a comment\necho hi'), ' \necho hi');
});
test('strips a trailing comment after code, preserving preceding code', () => {
assert.equal(stripShellComments('echo hi # trailing'), 'echo hi ');
});
test('does NOT strip ${VAR#pattern} parameter-expansion # (the false-positive this pass exists to avoid)', () => {
assert.equal(stripShellComments('sm_raw=${sm_raw#./}'), 'sm_raw=${sm_raw#./}');
});
test('does not strip a # immediately following a non-whitespace identifier character', () => {
assert.equal(stripShellComments('x=${foo#bar} y=1'), 'x=${foo#bar} y=1');
});
test('a # inside single quotes is preserved literally, not treated as a comment start', () => {
assert.equal(stripShellComments("echo 'a # b'"), "echo 'a # b'");
});
});
describe('substitutePlaceholders', () => {
test('substitutes a single-token placeholder ({run_dir}) to a shell-safe bareword', () => {
assert.equal(substitutePlaceholders('cd {run_dir}'), 'cd PLACEHOLDER_run_dir');
});
test('substitutes a multi-word prose placeholder ({discovered test command})', () => {
assert.equal(
substitutePlaceholders('run {discovered test command}'),
'run PLACEHOLDER_discovered_test_command',
);
});
test('does NOT substitute real ${VAR} parameter expansion', () => {
assert.equal(substitutePlaceholders('echo ${VAR}'), 'echo ${VAR}');
});
test('does NOT clobber {1..5} POSIX numeric brace expansion (pinning the corrected behavior)', () => {
assert.equal(substitutePlaceholders('echo {1..5}'), 'echo {1..5}');
});
test('does NOT clobber {a,b,c} POSIX brace-expansion list (pinning the corrected behavior)', () => {
assert.equal(substitutePlaceholders('echo {a,b,c}'), 'echo {a,b,c}');
});
test('does NOT clobber { cmd1; cmd2; } compound-command grouping (pinning the corrected behavior)', () => {
const input = '{ echo hi; echo bye; }';
assert.equal(substitutePlaceholders(input), input);
});
});
describe('extractForLoops', () => {
test('captures loopVar and listExpr for a simple for-header', () => {
const [loop] = extractForLoops('for x in $VAR; do\n echo "$x"\ndone\n');
assert.equal(loop.loopVar, 'x');
assert.equal(loop.listExpr, '$VAR');
});
test('does not truncate the list-expr at a `;` nested inside $( ... )', () => {
const [loop] = extractForLoops('for x in $(echo a; echo b); do\n echo "$x"\ndone\n');
assert.equal(loop.listExpr, '$(echo a; echo b)');
});
test('finds multiple for-loops in one body', () => {
const loops = extractForLoops('for a in 1 2; do :; done\nfor b in $Y; do :; done\n');
assert.equal(loops.length, 2);
assert.equal(loops[0].loopVar, 'a');
assert.equal(loops[1].loopVar, 'b');
});
});
describe('findBareForLoopSplits — the core #4109 detector', () => {
test('POSITIVE: bare unquoted `for x in $VAR; do ... done` is flagged', () => {
const findings = findBareForLoopSplits([mkBlock('for x in $VAR; do\n echo "$x"\ndone\n')]);
assert.equal(findings.length, 1);
assert.equal(findings[0].loopVar, 'x');
assert.equal(findings[0].varName, 'VAR');
});
test('POSITIVE: bare unquoted `${VAR}` braced form is also flagged', () => {
const findings = findBareForLoopSplits([mkBlock('for x in ${VAR}; do\n echo "$x"\ndone\n')]);
assert.equal(findings.length, 1);
assert.equal(findings[0].varName, 'VAR');
});
test('NEGATIVE: command-substitution-wrapped form (the actual #4109 fix pattern) is NOT flagged', () => {
const findings = findBareForLoopSplits([
mkBlock('for x in $(printf \'%s\' "$VAR"); do\n echo "$x"\ndone\n'),
]);
assert.equal(findings.length, 0);
});
test('NEGATIVE: quoted `"$VAR"` form is NOT flagged', () => {
const findings = findBareForLoopSplits([mkBlock('for x in "$VAR"; do\n echo "$x"\ndone\n')]);
assert.equal(findings.length, 0);
});
test('NEGATIVE: literal words (no variable at all) are NOT flagged', () => {
const findings = findBareForLoopSplits([mkBlock('for x in a b c; do\n echo "$x"\ndone\n')]);
assert.equal(findings.length, 0);
});
test('a for-loop shape quoted inside a `#` comment (prose referencing a past bug) is NOT flagged', () => {
const findings = findBareForLoopSplits([
mkBlock('# old bug: for x in $VAR; do ... done\necho ok\n'),
]);
assert.equal(findings.length, 0);
});
// --- fast-check property test (CLAUDE.md-mandated for parsers) ---
//
// Property: for any generated loop-var/var-name pair and any of the SAFE
// forms (quoted, command-substitution-wrapped, literal-words), the
// detector reports zero findings; for either UNSAFE bare form (bare $VAR
// or braced ${VAR}), it reports exactly one finding naming that variable.
const identArb = fc.stringMatching(/^[A-Za-z_][A-Za-z0-9_]{0,6}$/);
const formArb = fc.constantFrom('bare', 'braced', 'quoted', 'substituted', 'literal');
function buildLoopBody(loopVar, varName, form) {
let listExpr;
switch (form) {
case 'bare':
listExpr = `$${varName}`;
break;
case 'braced':
listExpr = `\${${varName}}`;
break;
case 'quoted':
listExpr = `"$${varName}"`;
break;
case 'substituted':
listExpr = `$(printf '%s' "$${varName}")`;
break;
case 'literal':
listExpr = 'a b c';
break;
default:
throw new Error(`unreachable form: ${form}`);
}
return `for ${loopVar} in ${listExpr}; do\n echo "$${loopVar}"\ndone\n`;
}
test('property: bare/braced forms are flagged naming the variable; quoted/substituted/literal forms never are', () => {
fc.assert(
fc.property(identArb, identArb, formArb, (loopVar, varName, form) => {
const body = buildLoopBody(loopVar, varName, form);
const findings = findBareForLoopSplits([mkBlock(body)]);
if (form === 'bare' || form === 'braced') {
assert.equal(findings.length, 1);
assert.equal(findings[0].varName, varName);
} else {
assert.equal(findings.length, 0);
}
}),
);
});
});
describe('findingKey / partitionAgainstBaseline', () => {
test('findingKey matches on {file, code, message} only — ignores line number', () => {
const a = { file: 'w.md', code: '2086', message: 'msg', line: 10 };
const b = { file: 'w.md', code: '2086', message: 'msg', line: 999 };
assert.equal(findingKey(a), findingKey(b));
});
test('a finding shifted to a different line, same file/code/message, still matches the baseline (drift tolerance)', () => {
const baseline = [{ file: 'w.md', code: '2086', message: 'msg' }];
const current = [{ file: 'w.md', code: '2086', message: 'msg', line: 999 }];
const { newFindings, baselinedFindings } = partitionAgainstBaseline(current, baseline);
assert.equal(newFindings.length, 0);
assert.equal(baselinedFindings.length, 1);
});
test('a THIRD occurrence of a message with only two accepted baseline instances is reported new (multiset semantics)', () => {
const baseline = [
{ file: 'w.md', code: '2086', message: 'msg' },
{ file: 'w.md', code: '2086', message: 'msg' },
];
const current = [
{ file: 'w.md', code: '2086', message: 'msg', line: 1 },
{ file: 'w.md', code: '2086', message: 'msg', line: 2 },
{ file: 'w.md', code: '2086', message: 'msg', line: 3 },
];
const { newFindings, baselinedFindings } = partitionAgainstBaseline(current, baseline);
assert.equal(newFindings.length, 1);
assert.equal(baselinedFindings.length, 2);
});
test('a finding with a different code (same file/message) is NOT matched against the baseline', () => {
const baseline = [{ file: 'w.md', code: '2086', message: 'msg' }];
const current = [{ file: 'w.md', code: '2046', message: 'msg', line: 1 }];
const { newFindings } = partitionAgainstBaseline(current, baseline);
assert.equal(newFindings.length, 1);
});
test('an empty baseline reports every current finding as new', () => {
const current = [{ file: 'w.md', code: '2086', message: 'msg', line: 1 }];
const { newFindings, baselinedFindings } = partitionAgainstBaseline(current, []);
assert.equal(newFindings.length, 1);
assert.equal(baselinedFindings.length, 0);
});
});