* docs(test-standards): enforce no-source-grep rule with CI linter + update CONTRIBUTING.md
Adds scripts/lint-no-source-grep.cjs — a static linter that detects readFileSync
on .cjs source files in tests without an allow-test-rule annotation. Wires it
into CI as a new lint-tests job in test.yml and as npm run lint:tests.
Resolves all 9 existing violations across the test suite:
- Rewrites workspace routing tests (3) as behavioral runGsdTools calls that
verify each command is router-recognized (exit != "Unknown init workflow")
- Adds allow-test-rule annotations with explanatory comments to 7 legitimate
structural tests: architectural invariants (locking, orphan-worktree),
structural regression guards (milestone-regex-global), docs-parity
(config-field-docs), integration-test-input (copilot-install), and
structural-implementation-guards (bug-1891, discuss-mode)
Updates CONTRIBUTING.md Testing Standards section with:
- "Prohibited: Source-Grep Tests" section with the before/after pattern,
root cause analysis of why it breaks (commit 990c3e64), and CI reference
- allow-test-rule exemption table (6 recognized categories with when-to-use)
- "CI Test Quality Checks" table showing lint-tests job and local run command
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: resolve CodeRabbit findings on PR #2700
- CONTRIBUTING.md: "four recognized categories" → "six" (table has 6 rows)
- workspace.test.cjs: use positional args in routing tests (no --name flag)
- lint-no-source-grep.cjs: add source-dir guard to READ_WITH_INLINE_CJS_RE
(mirrors CJS_PATH_CONST_RE's protection against false positives on temp files)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(lint): tighten allow-test-rule and add recursive test discovery
- ALLOW_ANNOTATION now requires at least one non-whitespace char after the
colon so bare '// allow-test-rule:' cannot bypass the lint gate
- findTestFiles() recurses into subdirectories so nested *.test.cjs files
are covered if the tests/ tree ever grows subdirs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
113 lines
4.2 KiB
JavaScript
113 lines
4.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* lint-no-source-grep.cjs
|
|
*
|
|
* Enforces the "no source-grep tests" rule:
|
|
* Tests must NOT read source-code .cjs files with readFileSync to assert string
|
|
* presence. That pattern (source-grep theater) proves a literal exists in source,
|
|
* not that the runtime behavior is correct.
|
|
*
|
|
* ALLOWED:
|
|
* - require('../get-shit-done/bin/lib/foo.cjs') -- runs the module, not text inspection
|
|
* - readFileSync on .md / .json / .txt files -- product-content or config output
|
|
* - Files annotated: // allow-test-rule: <reason>
|
|
*
|
|
* DISALLOWED (without allow-test-rule):
|
|
* - readFileSync where the path argument ends in a .cjs filename literal
|
|
* - A path constant (e.g. CONFIG_PATH) assigned to a .cjs lib file, used in readFileSync
|
|
*
|
|
* Exit 0 = clean. Exit 1 = violations found (with diagnostics).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const TESTS_DIR = path.join(__dirname, '..', 'tests');
|
|
const ALLOW_ANNOTATION = /\/\/\s*allow-test-rule:\s*\S/;
|
|
|
|
// Matches constant definitions that hold a .cjs path in a SOURCE directory.
|
|
// Requires a source-dir indicator ('bin', 'lib', 'get-shit-done') to avoid
|
|
// flagging temp files like path.join(tmpDir, 'example.cjs').
|
|
// const CONFIG_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'config-schema.cjs');
|
|
const CJS_PATH_CONST_RE = /(?:const|let|var)\s+(\w+)\s*=\s*path\.join\s*\([^)]*(?:'bin'|"bin"|'lib'|"lib"|'get-shit-done'|"get-shit-done")[^)]*['"][^'"]*\.cjs['"]/gm;
|
|
|
|
// Matches readFileSync with a named variable as first arg
|
|
const READ_WITH_CONST_RE = /readFileSync\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,/gm;
|
|
|
|
// Matches readFileSync with an inline path.join(.cjs) as first arg
|
|
const READ_WITH_INLINE_CJS_RE = /readFileSync\s*\([^,)]*path\.join\s*\([^)]*(?:'bin'|"bin"|'lib'|"lib"|'get-shit-done'|"get-shit-done")[^)]*['"][^'"]*\.cjs['"]/;
|
|
|
|
function setFromMatches(content, re) {
|
|
const found = new Set();
|
|
let m;
|
|
const cloned = new RegExp(re.source, re.flags);
|
|
while ((m = cloned.exec(content)) !== null) found.add(m[1]);
|
|
return found;
|
|
}
|
|
|
|
function check(filepath) {
|
|
const content = fs.readFileSync(filepath, 'utf-8');
|
|
const rel = path.relative(path.join(__dirname, '..'), filepath);
|
|
|
|
if (ALLOW_ANNOTATION.test(content)) return null;
|
|
|
|
// Pattern A: readFileSync(path.join(..., 'foo.cjs'), ...)
|
|
if (READ_WITH_INLINE_CJS_RE.test(content)) {
|
|
return {
|
|
file: rel,
|
|
reason: 'readFileSync with inline .cjs path literal',
|
|
fix: 'Replace with runGsdTools() behavioral test, or add // allow-test-rule: <reason>',
|
|
};
|
|
}
|
|
|
|
// Pattern B: const FOO_PATH = path.join(..., 'foo.cjs') + readFileSync(FOO_PATH, ...)
|
|
const cjsConsts = setFromMatches(content, CJS_PATH_CONST_RE);
|
|
if (cjsConsts.size > 0) {
|
|
const readConsts = setFromMatches(content, READ_WITH_CONST_RE);
|
|
const overlap = [...cjsConsts].filter(c => readConsts.has(c));
|
|
if (overlap.length > 0) {
|
|
return {
|
|
file: rel,
|
|
reason: `source .cjs path constant(s) used in readFileSync: ${overlap.join(', ')}`,
|
|
fix: 'Replace with runGsdTools() behavioral test, or add // allow-test-rule: <reason>',
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findTestFiles(dir) {
|
|
const results = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
results.push(...findTestFiles(full));
|
|
} else if (entry.name.endsWith('.test.cjs')) {
|
|
results.push(full);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
const testFiles = findTestFiles(TESTS_DIR);
|
|
|
|
const violations = testFiles.map(check).filter(Boolean);
|
|
|
|
if (violations.length === 0) {
|
|
console.log(`ok lint-no-source-grep: ${testFiles.length} test files checked, 0 violations`);
|
|
process.exit(0);
|
|
}
|
|
|
|
process.stderr.write(`\nERROR lint-no-source-grep: ${violations.length} violation(s) found\n\n`);
|
|
for (const v of violations) {
|
|
process.stderr.write(` ${v.file}\n`);
|
|
process.stderr.write(` Problem : ${v.reason}\n`);
|
|
process.stderr.write(` Fix : ${v.fix}\n\n`);
|
|
}
|
|
process.stderr.write('See CONTRIBUTING.md "Prohibited: Source-Grep Tests" for guidance.\n');
|
|
process.stderr.write('Structural tests that legitimately read source files: add // allow-test-rule: <reason>\n\n');
|
|
process.exit(1);
|