Merge pull request #238 from open-gsd/fix/11-bug-hand-sync-allowlist-lint-misses-cros

fix(11): detect cross-name hand-sync pairs from allowlist
This commit is contained in:
Tom Boucher
2026-05-24 19:01:14 -04:00
committed by GitHub
3 changed files with 96 additions and 11 deletions

View File

@@ -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<string, Set<string>>} 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;
// -------------------------------------------------------------------------

View File

@@ -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"
}
]
}

View File

@@ -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);
}
});
});