From 5042ec9d4f1566f8fefff80a4fbc9997e06aa808 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 24 May 2026 18:54:34 -0400 Subject: [PATCH] fix(11): support cross-name hand-sync pair detection --- scripts/lint-shared-module-handsync.cjs | 56 +++++++++++++++---- scripts/shared-module-handsync-allowlist.json | 10 +++- tests/lint-shared-module-handsync.test.cjs | 41 ++++++++++++++ 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/scripts/lint-shared-module-handsync.cjs b/scripts/lint-shared-module-handsync.cjs index 174bde6d9..cadef0549 100644 --- a/scripts/lint-shared-module-handsync.cjs +++ b/scripts/lint-shared-module-handsync.cjs @@ -12,6 +12,10 @@ * `bin/lib/foo.cjs` and ts `sdk/src/foo.ts` only allow-throughs that exact * pair — a sibling at `sdk/src/query/foo.ts` is still flagged. * + * Cross-name pairs are also supported when declared in the allowlist + * (for example `verify.cjs` <-> `validate.ts`), so cooperating siblings are + * observable even when basenames differ. + * * If a pair is found: * - cooperatingSiblings (matching cjs + ts): accepted silently (exit 0). * - migrateMeBacklog (matching cjs + ts): emits a WARNING only when @@ -96,6 +100,17 @@ const migrateMap = new Map( (allowlist.migrateMeBacklog || []).map((e) => [`${e.cjs}::${e.ts}`, e]) ); +/** @type {Map>} cjs rel path -> explicit declared ts rel paths */ +const explicitTsByCjs = new Map(); +for (const entry of [ + ...(allowlist.cooperatingSiblings || []), + ...(allowlist.migrateMeBacklog || []), +]) { + if (!entry || typeof entry.cjs !== 'string' || typeof entry.ts !== 'string') continue; + if (!explicitTsByCjs.has(entry.cjs)) explicitTsByCjs.set(entry.cjs, new Set()); + explicitTsByCjs.get(entry.cjs).add(entry.ts); +} + // --------------------------------------------------------------------------- // Build SDK name index: name -> array of absolute TS paths // (excludes *.generated.ts and *.test.ts) @@ -212,13 +227,36 @@ function main() { const errors = []; const warnings = []; - for (const { name, absPath } of cjsFiles) { - // Is there a matching TS file? - if (!sdkIndex.has(name)) continue; + function candidateTsPathsFor(relCjs, name) { + const byName = sdkIndex.has(name) + ? sdkIndex + .get(name) + .map((p) => path.relative(ROOT, p).replace(/\\/g, '/')) + : []; - // Compute the relative paths the allowlist uses + const declared = explicitTsByCjs.has(relCjs) + ? Array.from(explicitTsByCjs.get(relCjs)) + : []; + + const declaredExisting = declared.filter((relTs) => { + const abs = path.join(ROOT, relTs); + return ( + relTs.endsWith('.ts') && + !relTs.endsWith('.generated.ts') && + !relTs.endsWith('.test.ts') && + fs.existsSync(abs) && + fs.statSync(abs).isFile() + ); + }); + + return Array.from(new Set([...byName, ...declaredExisting])); + } + + for (const { name, absPath } of cjsFiles) { + // Compute relative CJS path and all declared/discovered TS candidates. const relCjs = path.relative(ROOT, absPath).replace(/\\/g, '/'); - const tsPaths = sdkIndex.get(name).map((p) => path.relative(ROOT, p).replace(/\\/g, '/')); + const tsPaths = candidateTsPathsFor(relCjs, name); + if (tsPaths.length === 0) continue; // Pair-aware matching, per ts sibling. Each ts candidate is classified // independently against the allowlist so a partially-allowlisted set of @@ -247,12 +285,10 @@ function main() { // Count cjs files whose pair identity (cjs+ts) is on cooperatingSiblings. // A file with multiple ts candidates is counted once if any pair matches. const cooperatingCount = cjsFiles.filter((f) => { - if (!sdkIndex.has(f.name)) return false; const relCjs = path.relative(ROOT, f.absPath).replace(/\\/g, '/'); - return sdkIndex.get(f.name).some((tsAbs) => { - const relTs = path.relative(ROOT, tsAbs).replace(/\\/g, '/'); - return cooperatingPairs.has(`${relCjs}::${relTs}`); - }); + return candidateTsPathsFor(relCjs, f.name).some((relTs) => + cooperatingPairs.has(`${relCjs}::${relTs}`) + ); }).length; // ------------------------------------------------------------------------- diff --git a/scripts/shared-module-handsync-allowlist.json b/scripts/shared-module-handsync-allowlist.json index 830339c83..73982bca4 100644 --- a/scripts/shared-module-handsync-allowlist.json +++ b/scripts/shared-module-handsync-allowlist.json @@ -147,5 +147,13 @@ "justification": "CJS prompt-budget.cjs provides the applyBudget implementation used by gsd-tools.cjs case 'prompt-budget'. SDK prompt-budget.ts is the native QueryHandler port for gsd-sdk query dispatch (#3081). The SDK handler ports the pure budget logic and adds CLI arg parsing / file I/O directly, satisfying the registry-integration drift-guard without duplicating shared state." } ], - "migrateMeBacklog": [] + "migrateMeBacklog": [ + { + "cjs": "get-shit-done/bin/lib/verify.cjs", + "ts": "sdk/src/query/validate.ts", + "classification": "drift-anti-pattern", + "justification": "Cross-name cooperating pair where validate policy changes can drift from verify checks; keep explicitly visible until Shared Module extraction removes hand-sync risk.", + "trackedIn": "#11" + } + ] } diff --git a/tests/lint-shared-module-handsync.test.cjs b/tests/lint-shared-module-handsync.test.cjs index 497159499..c7140e129 100644 --- a/tests/lint-shared-module-handsync.test.cjs +++ b/tests/lint-shared-module-handsync.test.cjs @@ -367,3 +367,44 @@ describe('lint-shared-module-handsync: allowlist entry honored', () => { } }); }); + +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); + } + }); +});