'use strict'; /** * Tests for scripts/lint-shared-module-handsync.cjs — Phase 6 of #3524 (#3575). * * Three cases: * 1. No new drift pair: lint exits 0 on the current repo tree (all cooperating * siblings on the allowlist; migrateMeBacklog pairs do not fail). * 2. Intentional new drift: synthesize a fixture tree with an unlisted * foo-test.cjs / foo-test.ts pair, assert exit 1 + typed error JSON. * 3. Allowlist entry honored: same pair as case 2, but with a cooperatingSiblings * allowlist entry present, assert exit 0. * * Assertions use the lint's --json mode: the production code emits a typed IR * (ok / reason / errors / warnings / counts), and tests parse and assert on * structured fields rather than substring-matching stderr/stdout (per * CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs"). */ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { spawnSync } = require('node:child_process'); const LINT_SCRIPT = path.join(__dirname, '..', 'scripts', 'lint-shared-module-handsync.cjs'); const ALLOWLIST_PATH = path.join(__dirname, '..', 'scripts', 'shared-module-handsync-allowlist.json'); const REPO_ROOT = path.join(__dirname, '..'); // --------------------------------------------------------------------------- // Helper: run the lint script in --json mode and parse the result. // Returns { status, payload } where payload is the parsed JSON IR (or null // if the lint emitted no JSON, which would be a test-infrastructure bug). // --------------------------------------------------------------------------- function runLintJson(extraArgs = []) { const result = spawnSync(process.execPath, [LINT_SCRIPT, '--json', ...extraArgs], { encoding: 'utf8', cwd: REPO_ROOT, }); let payload = null; try { payload = JSON.parse(result.stdout.trim()); } catch { // Leave payload as null; tests assert on payload presence. } return { status: result.status, payload }; } // --------------------------------------------------------------------------- // Helper: create an isolated fixture tree for testing // // Layout: // / // get-shit-done/bin/lib/.cjs // sdk/src/query/.ts (if tsInQuery === true) // sdk/src/.ts (if tsInQuery === false) // scripts/shared-module-handsync-allowlist.json (custom allowlist) // --------------------------------------------------------------------------- function createFixture({ cjsName, tsName, tsInQuery = true, allowlistExtra = {} }) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-handsync-')); const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib'); fs.mkdirSync(cjsDir, { recursive: true }); const tsDir = tsInQuery ? path.join(tmpDir, 'sdk', 'src', 'query') : path.join(tmpDir, 'sdk', 'src'); fs.mkdirSync(tsDir, { recursive: true }); const scriptsDir = path.join(tmpDir, 'scripts'); fs.mkdirSync(scriptsDir, { recursive: true }); fs.writeFileSync(path.join(cjsDir, `${cjsName}.cjs`), `'use strict';\n// fixture cjs\n`); fs.writeFileSync(path.join(tsDir, `${tsName}.ts`), `// fixture ts\nexport {};\n`); const realAllowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8')); const fixtureAllowlist = { cooperatingSiblings: [ ...(realAllowlist.cooperatingSiblings || []), ...(allowlistExtra.cooperatingSiblings || []), ], migrateMeBacklog: [ ...(realAllowlist.migrateMeBacklog || []), ...(allowlistExtra.migrateMeBacklog || []), ], }; fs.writeFileSync( path.join(scriptsDir, 'shared-module-handsync-allowlist.json'), JSON.stringify(fixtureAllowlist, null, 2) ); return tmpDir; } function cleanupFixture(dir) { fs.rmSync(dir, { recursive: true, force: true }); } // --------------------------------------------------------------------------- // Case 1: No new drift pair — exits 0 on current repo tree // --------------------------------------------------------------------------- describe('lint-shared-module-handsync: current repo tree', () => { test('exits 0 with the real allowlist and current repo tree', () => { const { status, payload } = runLintJson(); assert.strictEqual(status, 0); assert.ok(payload, 'expected JSON payload on stdout'); assert.strictEqual(payload.ok, true); assert.ok( payload.reason === undefined || payload.reason === 'sdk_retired', `unexpected success reason: ${payload.reason}` ); }); test('reports numeric counts when lint runs or short-circuits on retired SDK tree', () => { const { payload } = runLintJson(); assert.ok(payload); assert.strictEqual(typeof payload.cooperatingCount, 'number'); assert.strictEqual(typeof payload.backlogCount, 'number'); if (payload.reason !== 'sdk_retired') { assert.ok(payload.cooperatingCount > 0, 'expected at least one cooperating sibling'); } // No errors field on success — only warnings (backlog) may be present assert.strictEqual(payload.ok, true); }); test('script has no syntax errors', () => { const result = spawnSync(process.execPath, ['--check', LINT_SCRIPT], { encoding: 'utf8' }); assert.strictEqual(result.status, 0); }); }); // --------------------------------------------------------------------------- // Case 2: Intentional new drift — exits 1 with informative typed error // --------------------------------------------------------------------------- describe('lint-shared-module-handsync: intentional new drift pair', () => { test('exits 1 when an unlisted cjs/ts pair exists', () => { const tmpDir = createFixture({ cjsName: 'foo-test', tsName: 'foo-test', tsInQuery: true }); try { const { status, payload } = runLintJson(['--root', tmpDir]); assert.strictEqual(status, 1); assert.ok(payload); assert.strictEqual(payload.ok, false); assert.strictEqual(payload.reason, 'unauthorized_pairs'); } finally { cleanupFixture(tmpDir); } }); test('typed error payload names the unauthorized pair', () => { const tmpDir = createFixture({ cjsName: 'foo-test', tsName: 'foo-test', tsInQuery: true }); try { const { payload } = runLintJson(['--root', tmpDir]); assert.ok(payload && Array.isArray(payload.errors)); assert.strictEqual(payload.errors.length, 1); const [entry] = payload.errors; assert.match(entry.relCjs, /foo-test\.cjs$/); assert.ok(Array.isArray(entry.tsPaths)); assert.ok(entry.tsPaths.some((p) => /foo-test\.ts$/.test(p))); } finally { cleanupFixture(tmpDir); } }); test('exits 1 for unlisted pair in sdk/src/.ts (non-query) position', () => { const tmpDir = createFixture({ cjsName: 'bar-test', tsName: 'bar-test', tsInQuery: false }); try { const { status, payload } = runLintJson(['--root', tmpDir]); assert.strictEqual(status, 1); assert.ok(payload); assert.strictEqual(payload.ok, false); assert.strictEqual(payload.reason, 'unauthorized_pairs'); } finally { cleanupFixture(tmpDir); } }); }); // --------------------------------------------------------------------------- // Case 3: Allowlist entry honored — exits 0 when pair IS on cooperatingSiblings // --------------------------------------------------------------------------- describe('lint-shared-module-handsync: allowlist entry honored', () => { test('exits 0 when pair is in cooperatingSiblings allowlist', () => { const cjsName = 'baz-cooperating'; const tsName = 'baz-cooperating'; const tmpDir = createFixture({ cjsName, tsName, tsInQuery: true, allowlistExtra: { cooperatingSiblings: [ { cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, ts: `sdk/src/query/${tsName}.ts`, classification: 'cooperating-sibling', justification: 'Test fixture: synthetic cooperating sibling for lint test.', }, ], }, }); try { const { status, payload } = runLintJson(['--root', tmpDir]); assert.strictEqual(status, 0); assert.ok(payload); assert.strictEqual(payload.ok, true); } finally { cleanupFixture(tmpDir); } }); // Regression guard: the lint matches on the (cjs, ts) PAIR, not on the // cjs path alone. An allowlist entry whose ts points to a different path // than the actual ts sibling on disk must NOT silently pass the pair. test('rejects pair when TS path differs from allowlist entry', () => { const cjsName = 'foo-wrong-ts'; const tsName = 'foo-wrong-ts'; const tmpDir = createFixture({ cjsName, tsName, tsInQuery: true, // creates sdk/src/query/foo-wrong-ts.ts on disk allowlistExtra: { cooperatingSiblings: [ { cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, // Allowlist points at sdk/src/.ts — different location. // Lint must reject because the on-disk pair is unauthorized. ts: `sdk/src/${tsName}.ts`, classification: 'cooperating-sibling', justification: 'Test: validates pair-aware matching enforces ts path.', }, ], }, }); try { const { status, payload } = runLintJson(['--root', tmpDir]); assert.strictEqual(status, 1, 'must fail when ts path mismatches'); assert.ok(payload); assert.strictEqual(payload.ok, false); assert.strictEqual(payload.reason, 'unauthorized_pairs'); } finally { cleanupFixture(tmpDir); } }); test('exits 0 (no error) when pair is in migrateMeBacklog allowlist', () => { const cjsName = 'qux-backlog'; const tsName = 'qux-backlog'; const tmpDir = createFixture({ cjsName, tsName, tsInQuery: true, allowlistExtra: { migrateMeBacklog: [ { cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, ts: `sdk/src/query/${tsName}.ts`, classification: 'drift-anti-pattern', justification: 'Test fixture: synthetic backlog pair for lint test.', trackedIn: 'test only', }, ], }, }); try { const { status, payload } = runLintJson(['--root', tmpDir]); assert.strictEqual(status, 0); assert.ok(payload); assert.strictEqual(payload.ok, true); // The backlog pair should be reported in warnings (not errors) assert.ok(Array.isArray(payload.warnings)); assert.ok( payload.warnings.some((w) => /qux-backlog\.cjs$/.test(w.relCjs)), 'expected qux-backlog in warnings' ); } finally { cleanupFixture(tmpDir); } }); // Regression guard for #3632: when a cjs has TWO ts siblings sharing the // same basename (e.g. sdk/src/foo.ts AND sdk/src/query/foo.ts) and only // ONE pair is allowlisted, the unallowlisted sibling must still be reported. // Prior bug: .some() at the cjs level returned true on the allowlisted // pair, short-circuiting and silently dropping the unallowlisted sibling. test('reports unallowlisted ts sibling when another ts sibling for the same cjs IS allowlisted (#3632)', () => { const cjsName = 'multi-sibling'; const tsName = 'multi-sibling'; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-multi-')); try { const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib'); fs.mkdirSync(cjsDir, { recursive: true }); const tsDirRoot = path.join(tmpDir, 'sdk', 'src'); const tsDirQuery = path.join(tmpDir, 'sdk', 'src', 'query'); fs.mkdirSync(tsDirQuery, { recursive: true }); const scriptsDir = path.join(tmpDir, 'scripts'); fs.mkdirSync(scriptsDir, { recursive: true }); // One cjs, two ts siblings on disk (same basename, different paths). fs.writeFileSync(path.join(cjsDir, `${cjsName}.cjs`), `'use strict';\n`); fs.writeFileSync(path.join(tsDirRoot, `${tsName}.ts`), `export {};\n`); fs.writeFileSync(path.join(tsDirQuery, `${tsName}.ts`), `export {};\n`); // Allowlist ONLY the sdk/src/.ts pair. The sdk/src/query/.ts // sibling is intentionally NOT allowlisted and must be reported. fs.writeFileSync( path.join(scriptsDir, 'shared-module-handsync-allowlist.json'), JSON.stringify( { cooperatingSiblings: [ { cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, ts: `sdk/src/${tsName}.ts`, classification: 'cooperating-sibling', justification: 'Test: only the non-query sibling is allowlisted.', }, ], migrateMeBacklog: [], }, null, 2 ) ); const { status, payload } = runLintJson(['--root', tmpDir]); assert.strictEqual( status, 1, 'must fail: the sdk/src/query/.ts sibling is not allowlisted' ); assert.ok(payload); assert.strictEqual(payload.ok, false); assert.strictEqual(payload.reason, 'unauthorized_pairs'); assert.ok(Array.isArray(payload.errors) && payload.errors.length >= 1); const reportedTs = payload.errors.flatMap((e) => e.tsPaths); assert.ok( reportedTs.some((p) => /sdk\/src\/query\/multi-sibling\.ts$/.test(p)), `expected query sibling in errors, got: ${JSON.stringify(reportedTs)}` ); // The allowlisted sibling must NOT appear in errors. assert.ok( !reportedTs.some((p) => /^sdk\/src\/multi-sibling\.ts$/.test(p)), `allowlisted sibling sdk/src/multi-sibling.ts must not be flagged, got: ${JSON.stringify(reportedTs)}` ); } finally { cleanupFixture(tmpDir); } }); test('generated .cjs files are excluded from pair detection', () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-gen-')); try { const cjsDir = path.join(tmpDir, 'get-shit-done', 'bin', 'lib'); fs.mkdirSync(cjsDir, { recursive: true }); const tsDir = path.join(tmpDir, 'sdk', 'src', 'query'); fs.mkdirSync(tsDir, { recursive: true }); const scriptsDir = path.join(tmpDir, 'scripts'); fs.mkdirSync(scriptsDir, { recursive: true }); // A .generated.cjs file + matching TS — should NOT trigger lint error fs.writeFileSync(path.join(cjsDir, 'my-module.generated.cjs'), `'use strict';\n`); fs.writeFileSync(path.join(tsDir, 'my-module.ts'), `export {};\n`); fs.writeFileSync( path.join(scriptsDir, 'shared-module-handsync-allowlist.json'), JSON.stringify({ cooperatingSiblings: [], migrateMeBacklog: [] }, null, 2) ); const { status, payload } = runLintJson(['--root', tmpDir]); assert.strictEqual(status, 0); assert.ok(payload); assert.strictEqual(payload.ok, true); } finally { cleanupFixture(tmpDir); } }); }); describe('lint-shared-module-handsync: cross-name pair support', () => { test('surfaces declared cross-name migrateMeBacklog pair in warnings', () => { const cjsName = 'verify'; const tsName = 'validate'; const tmpDir = createFixture({ cjsName, tsName, tsInQuery: true, allowlistExtra: { migrateMeBacklog: [ { cjs: `get-shit-done/bin/lib/${cjsName}.cjs`, ts: `sdk/src/query/${tsName}.ts`, classification: 'drift-anti-pattern', justification: 'Test fixture: cross-name pair should be observable by lint.', trackedIn: 'issue-11-test', }, ], }, }); try { const { status, payload } = runLintJson(['--root', tmpDir]); assert.strictEqual(status, 0); assert.ok(payload); assert.strictEqual(payload.ok, true); assert.ok(Array.isArray(payload.warnings)); assert.ok( payload.warnings.some((w) => /verify\.cjs$/.test(w.relCjs) && Array.isArray(w.tsPaths) && w.tsPaths.some((p) => /sdk\/src\/query\/validate\.ts$/.test(p)) ), `expected cross-name verify.cjs <-> validate.ts warning, got: ${JSON.stringify(payload.warnings)}` ); } finally { cleanupFixture(tmpDir); } }); });