The @opengsd/gsd-sdk package boundary was retired (ADR-0174, #191/#192) and the sdk/ tree is no longer tracked. ADR-0174's Supersedes table explicitly records that the generator-based Shared-Module hand-sync lint is deleted as part of that collapse. This removes the now-orphaned machinery it left behind: - scripts/lint-shared-module-handsync.cjs — paired bin/lib/*.cjs files with sdk/src/**/*.ts sources that no longer exist; wired into no CI workflow or npm script (dead). - scripts/shared-module-handsync-allowlist.json — the lint's allowlist; every entry pointed at a non-existent sdk/src source / generated artifact / freshness check. - tests/lint-shared-module-handsync.test.cjs — tested the deleted lint. Docs corrected to match: - CONTRIBUTING.md — removed the "CJS↔SDK seam" instruction (it linked the already-deleted docs/agents/cjs-sdk-seam.md and told contributors to maintain the allowlist under a retired generator pattern). - docs/prd/3524-cjs-sdk-hard-seam.md + docs/prd/README.md — marked the PRD Superseded by ADR-0174, matching the already-superseded ADR-3524. Added tests/no-cjs-sdk-handsync-tooling.test.cjs as a regression guard so the retired tooling stays removed and is not silently re-wired into package.json. ADR-3524 is left in place (already Superseded by ADR-0174); runtime modules and regression tests that cite it in comments keep resolving. No user-facing change. Closes #556 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -179,8 +179,6 @@ Contributor requirements (summary):
|
||||
- Do not rewrite maintainer intent in `CONTEXT.md`/ADRs as part of drive-by cleanup; propose focused updates tied to approved scope.
|
||||
- If using an AI assistant, prompt it to read `CONTEXT.md` and the relevant ADRs before writing any code or docs, and verify it used the correct vocabulary before opening the PR.
|
||||
|
||||
**CJS↔SDK seam.** When working on `bin/lib/*.cjs` or `sdk/src/**`, read [`docs/agents/cjs-sdk-seam.md`](docs/agents/cjs-sdk-seam.md). It documents the canonical pattern for Shared Modules (data manifest + source-of-truth file + generator + freshness check + Adapters) and the hand-sync pair lint that blocks new drift. New `<name>.cjs` ↔ `<name>.ts` pairs require either migration to a Shared Module or an explicit allowlist entry with justification in `scripts/shared-module-handsync-allowlist.json`. Adding an allowlist entry requires maintainer review via CODEOWNERS.
|
||||
|
||||
**Every PR must link to an approved issue.** PRs without a linked issue are closed without review, no exceptions.
|
||||
|
||||
- **No draft PRs** — draft PRs are automatically closed. Only open a PR when it is complete, tested, and ready for review. If your work is not finished, keep it on your local branch until it is.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD: CJS↔SDK hard seam — Shared-Module migration
|
||||
|
||||
- **Status:** Reference
|
||||
- **Status:** Superseded by [ADR-0174](../adr/0174-retire-gsd-sdk-package-boundary.md) (2026-05-23) — historical migration plan; the CJS↔SDK seam and its hand-sync tooling were retired with the `@opengsd/gsd-sdk` package boundary
|
||||
- **Date:** 2026-05-14
|
||||
- **Tracking issue:** [#3524](https://github.com/open-gsd/gsd-core/issues/3524)
|
||||
- **Related ADR:** [`docs/adr/3524-cjs-sdk-hard-seam.md`](../adr/3524-cjs-sdk-hard-seam.md)
|
||||
|
||||
@@ -24,4 +24,4 @@ The GitHub-assigned issue number is the prefix. Do not compute a sequential numb
|
||||
|
||||
| PRD | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| [3524-cjs-sdk-hard-seam.md](3524-cjs-sdk-hard-seam.md) | CJS↔SDK hard seam — phased migration (#3524) | Reference |
|
||||
| [3524-cjs-sdk-hard-seam.md](3524-cjs-sdk-hard-seam.md) | CJS↔SDK hard seam — phased migration (#3524) | Superseded by ADR-0174 |
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared Module hand-sync drift lint — Phase 6 of #3524 (#3575).
|
||||
*
|
||||
* Scans get-shit-done/bin/lib/ for .cjs files and checks whether a matching
|
||||
* TypeScript file exists in sdk/src/<name>.ts, sdk/src/query/<name>.ts, or
|
||||
* sdk/src/<name>/index.ts (excluding *.generated.ts and *.test.ts).
|
||||
*
|
||||
* Allowlist entries are keyed by the (cjs, ts) PAIR. An entry with cjs
|
||||
* `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
|
||||
* --warn-all is set; otherwise the pair passes silently. Backlog
|
||||
* pairs never fail CI.
|
||||
* - Unlisted pairs (cjs or ts not on either list): ERROR — exit 1.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/lint-shared-module-handsync.cjs
|
||||
* node scripts/lint-shared-module-handsync.cjs --root /path/to/repo
|
||||
* node scripts/lint-shared-module-handsync.cjs --warn-all
|
||||
* node scripts/lint-shared-module-handsync.cjs --cjs-dir custom/bin/lib --sdk-src custom/sdk/src
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argument parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
const args = process.argv.slice(2);
|
||||
let ROOT = path.resolve(__dirname, '..');
|
||||
let CJS_DIR = null; // resolved below
|
||||
let SDK_SRC = null; // resolved below
|
||||
let SDK_SRC_EXPLICIT = false;
|
||||
let ALLOWLIST_OVERRIDE = null; // resolved below
|
||||
let WARN_ALL = false;
|
||||
let JSON_OUTPUT = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--root' && args[i + 1]) {
|
||||
ROOT = path.resolve(args[++i]);
|
||||
} else if (args[i] === '--cjs-dir' && args[i + 1]) {
|
||||
CJS_DIR = path.resolve(args[++i]);
|
||||
} else if (args[i] === '--sdk-src' && args[i + 1]) {
|
||||
SDK_SRC = path.resolve(args[++i]);
|
||||
SDK_SRC_EXPLICIT = true;
|
||||
} else if (args[i] === '--allowlist' && args[i + 1]) {
|
||||
ALLOWLIST_OVERRIDE = path.resolve(args[++i]);
|
||||
} else if (args[i] === '--warn-all') {
|
||||
WARN_ALL = true;
|
||||
} else if (args[i] === '--json') {
|
||||
JSON_OUTPUT = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!CJS_DIR) CJS_DIR = path.join(ROOT, 'get-shit-done', 'bin', 'lib');
|
||||
if (!SDK_SRC) SDK_SRC = path.join(ROOT, 'sdk', 'src');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load allowlist
|
||||
// When --root is given (e.g. in tests), prefer <ROOT>/scripts/allowlist.json
|
||||
// so fixture trees can supply their own allowlist. Fall back to the copy
|
||||
// co-located with this script (default production path).
|
||||
// ---------------------------------------------------------------------------
|
||||
const ALLOWLIST_PATH = ALLOWLIST_OVERRIDE
|
||||
? ALLOWLIST_OVERRIDE
|
||||
: fs.existsSync(path.join(ROOT, 'scripts', 'shared-module-handsync-allowlist.json'))
|
||||
? path.join(ROOT, 'scripts', 'shared-module-handsync-allowlist.json')
|
||||
: path.join(__dirname, 'shared-module-handsync-allowlist.json');
|
||||
let allowlist;
|
||||
try {
|
||||
allowlist = JSON.parse(fs.readFileSync(ALLOWLIST_PATH, 'utf8'));
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`lint-shared-module-handsync: failed to read allowlist at ${ALLOWLIST_PATH}: ${err.message}\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair identity = `${cjs}::${ts}`. Keying on the pair (not just cjs)
|
||||
* prevents an allowlisted entry from silently passing an unintended
|
||||
* sibling at a different ts path with the same basename.
|
||||
*
|
||||
* @type {Set<string>} pair identities in cooperatingSiblings
|
||||
*/
|
||||
const cooperatingPairs = new Set(
|
||||
(allowlist.cooperatingSiblings || []).map((e) => `${e.cjs}::${e.ts}`)
|
||||
);
|
||||
|
||||
/** @type {Map<string, object>} pair identity -> entry for migrateMeBacklog */
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildSdkIndex(sdkSrc) {
|
||||
const index = new Map(); // name -> [absPath, ...]
|
||||
|
||||
function addEntry(name, absPath) {
|
||||
if (!index.has(name)) index.set(name, []);
|
||||
index.get(name).push(absPath);
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const abs = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
walk(abs);
|
||||
} else if (ent.isFile() && ent.name.endsWith('.ts') &&
|
||||
!ent.name.endsWith('.generated.ts') &&
|
||||
!ent.name.endsWith('.test.ts')) {
|
||||
const rel = path.relative(sdkSrc, abs);
|
||||
const parts = rel.split(path.sep);
|
||||
|
||||
// sdk/src/<name>.ts (direct child, not in a subdir)
|
||||
if (parts.length === 1) {
|
||||
const name = parts[0].slice(0, -3); // strip .ts
|
||||
addEntry(name, abs);
|
||||
}
|
||||
// sdk/src/<name>/index.ts (one subdir deep, file is index.ts)
|
||||
else if (parts.length === 2 && parts[1] === 'index.ts') {
|
||||
const name = parts[0];
|
||||
addEntry(name, abs);
|
||||
}
|
||||
// sdk/src/query/<name>.ts (exactly: query/<something>.ts)
|
||||
else if (parts.length === 2 && parts[0] === 'query' && parts[1] !== 'index.ts') {
|
||||
const name = parts[1].slice(0, -3); // strip .ts
|
||||
addEntry(name, abs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(sdkSrc);
|
||||
return index;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan CJS files (direct children only; exclude *.generated.cjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
function scanCjsFiles(cjsDir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(cjsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`lint-shared-module-handsync: cannot read CJS dir ${cjsDir}: ${err.message}\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return entries
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isFile() &&
|
||||
e.name.endsWith('.cjs') &&
|
||||
!e.name.endsWith('.generated.cjs')
|
||||
)
|
||||
.map((e) => ({
|
||||
name: e.name.slice(0, -4), // strip .cjs
|
||||
absPath: path.join(cjsDir, e.name),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function emitJson(payload) {
|
||||
process.stdout.write(JSON.stringify(payload) + '\n');
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Check that the directories exist
|
||||
if (!fs.existsSync(CJS_DIR)) {
|
||||
if (JSON_OUTPUT) {
|
||||
emitJson({ ok: false, reason: 'cjs_dir_missing', path: CJS_DIR });
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`lint-shared-module-handsync: CJS dir not found: ${CJS_DIR}\n` +
|
||||
` Pass --root <repo-root> or --cjs-dir <path> to override.\n`
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(SDK_SRC)) {
|
||||
// SDK tree was retired in ADR-0174 Phase 5.2. In default repo mode this
|
||||
// lint becomes a no-op success; explicit --sdk-src remains fail-loud.
|
||||
if (SDK_SRC_EXPLICIT) {
|
||||
if (JSON_OUTPUT) {
|
||||
emitJson({ ok: false, reason: 'sdk_src_missing', path: SDK_SRC });
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`lint-shared-module-handsync: SDK src dir not found: ${SDK_SRC}\n` +
|
||||
` Pass --root <repo-root> or --sdk-src <path> to override.\n`
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
emitJson({
|
||||
ok: true,
|
||||
reason: 'sdk_retired',
|
||||
cooperatingCount: 0,
|
||||
backlogCount: 0,
|
||||
warnings: [],
|
||||
});
|
||||
} else {
|
||||
process.stdout.write(
|
||||
'ok lint-shared-module-handsync: SDK source tree retired; no hand-sync pairs to lint.\n'
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const sdkIndex = buildSdkIndex(SDK_SRC);
|
||||
const cjsFiles = scanCjsFiles(CJS_DIR);
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
function candidateTsPathsFor(relCjs, name) {
|
||||
const byName = sdkIndex.has(name)
|
||||
? sdkIndex
|
||||
.get(name)
|
||||
.map((p) => path.relative(ROOT, p).replace(/\\/g, '/'))
|
||||
: [];
|
||||
|
||||
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 = 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
|
||||
// siblings still surfaces the unauthorized ones. See #3632.
|
||||
const unauthorizedTs = [];
|
||||
const backlogTsForCjs = [];
|
||||
for (const relTs of tsPaths) {
|
||||
const pairKey = `${relCjs}::${relTs}`;
|
||||
if (cooperatingPairs.has(pairKey)) continue;
|
||||
if (migrateMap.has(pairKey)) {
|
||||
backlogTsForCjs.push(relTs);
|
||||
continue;
|
||||
}
|
||||
unauthorizedTs.push(relTs);
|
||||
}
|
||||
|
||||
if (unauthorizedTs.length > 0) {
|
||||
errors.push({ relCjs, tsPaths: unauthorizedTs });
|
||||
}
|
||||
if (backlogTsForCjs.length > 0) {
|
||||
const entry = migrateMap.get(`${relCjs}::${backlogTsForCjs[0]}`);
|
||||
warnings.push({ relCjs, tsPaths: backlogTsForCjs, entry });
|
||||
}
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const relCjs = path.relative(ROOT, f.absPath).replace(/\\/g, '/');
|
||||
return candidateTsPathsFor(relCjs, f.name).some((relTs) =>
|
||||
cooperatingPairs.has(`${relCjs}::${relTs}`)
|
||||
);
|
||||
}).length;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Report errors (exit 1)
|
||||
// -------------------------------------------------------------------------
|
||||
if (errors.length > 0) {
|
||||
if (JSON_OUTPUT) {
|
||||
emitJson({
|
||||
ok: false,
|
||||
reason: 'unauthorized_pairs',
|
||||
errors,
|
||||
warnings,
|
||||
cooperatingCount,
|
||||
});
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`\nERROR lint-shared-module-handsync: ${errors.length} unauthorized hand-sync pair(s) found.\n\n`
|
||||
);
|
||||
for (const { relCjs, tsPaths } of errors) {
|
||||
process.stderr.write(` CJS: ${relCjs}\n`);
|
||||
for (const ts of tsPaths) {
|
||||
process.stderr.write(` TS: ${ts}\n`);
|
||||
}
|
||||
process.stderr.write('\n');
|
||||
}
|
||||
process.stderr.write(
|
||||
'To resolve, choose one of:\n' +
|
||||
' 1. Migrate to a Shared Module (preferred): create sdk/src/<name>/index.ts as the\n' +
|
||||
' source-of-truth, write a generator script (sdk/scripts/gen-<name>.mjs), add a\n' +
|
||||
' freshness check, and update CI. See docs/agents/cjs-sdk-seam.md for the pattern.\n' +
|
||||
' 2. Add an explicit allowlist entry to scripts/shared-module-handsync-allowlist.json\n' +
|
||||
' with a justification explaining why this pair is a legitimate cooperating sibling\n' +
|
||||
' rather than a drift anti-pattern. Requires maintainer review via CODEOWNERS.\n\n'
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Report warnings (no exit code change)
|
||||
// -------------------------------------------------------------------------
|
||||
if (warnings.length > 0 && WARN_ALL && !JSON_OUTPUT) {
|
||||
process.stderr.write(
|
||||
`\nWARNING lint-shared-module-handsync: ${warnings.length} known drift anti-pattern pair(s) in migrateMeBacklog.\n` +
|
||||
`These are tracked for future Shared Module migration but do not block CI.\n\n`
|
||||
);
|
||||
for (const { relCjs, tsPaths, entry } of warnings) {
|
||||
process.stderr.write(` CJS: ${relCjs}\n`);
|
||||
for (const ts of tsPaths) {
|
||||
process.stderr.write(` TS: ${ts}\n`);
|
||||
}
|
||||
process.stderr.write(` Tracked: ${entry.trackedIn}\n`);
|
||||
process.stderr.write(` Hint: ${entry.justification}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Success
|
||||
// -------------------------------------------------------------------------
|
||||
if (JSON_OUTPUT) {
|
||||
emitJson({
|
||||
ok: true,
|
||||
cooperatingCount,
|
||||
backlogCount: warnings.length,
|
||||
warnings,
|
||||
});
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`ok lint-shared-module-handsync: no unauthorized hand-sync pairs found` +
|
||||
` (${cooperatingCount} cooperating sibling(s), ${warnings.length} backlog pair(s))\n`
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,183 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"_comment": "Allowlist for scripts/lint-shared-module-handsync.cjs — Phase 6 of #3524 (#3575). Two categories: cooperatingSiblings (legitimate pairs, lint accepts silently) and migrateMeBacklog (known drift anti-patterns, lint warns but does not fail). All entries require cjs + ts path + classification + justification.",
|
||||
"cooperatingSiblings": [
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/active-workstream-store.cjs",
|
||||
"ts": "sdk/src/query/active-workstream-store.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS manages filesystem-backed workstream store; SDK layer wraps via Adapter for query dispatch. Different responsibilities, not drift."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/config-schema.cjs",
|
||||
"ts": "sdk/src/query/config-schema.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "SDK config-schema.ts is the generated source-of-truth derived from sdk/shared/config-schema.manifest.json (Phase 2/#3540). CJS config-schema.cjs is the Adapter that reads from that manifest. Not a hand-sync pair; freshness check enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/frontmatter.cjs",
|
||||
"ts": "sdk/src/query/frontmatter.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS implements full frontmatter parsing/mutation; SDK frontmatter.ts is the native SDK query handler delegating to the CJS runtime via the seam bridge. Not duplicating logic."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/init.cjs",
|
||||
"ts": "sdk/src/query/init.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS init.cjs is the authoritative initializer; SDK init.ts provides the native handler layer for the SDK query seam. Phase 5.2+ will migrate remaining subcommands, but current architecture is intentional."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/phase.cjs",
|
||||
"ts": "sdk/src/query/phase.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS phase.cjs implements sync mutation handlers (phaseAdd, phaseInsert, phaseRemove, phaseComplete) and query handlers with sync I/O; SDK phase.ts provides the native async query handler. The async mutation handlers (phase-lifecycle.ts) and pure policy helpers (phase-lifecycle-policy.ts) are I/O-bound or pure and remain per-side (ADR-3524 §4). Issue #4 fix: phase.cjs cmdPhaseComplete now consumes pure helpers from phase-lifecycle.generated.cjs (generated from sdk/src/query/phase-lifecycle.ts) for idempotent Completed Phases counting and clamped percent. *.generated.cjs files are excluded from lint scanner; see sdk/scripts/check-phase-lifecycle-fresh.mjs for freshness enforcement."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/phase-lifecycle.cjs",
|
||||
"ts": "sdk/src/query/phase-lifecycle.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "CJS phase-lifecycle.cjs is the pure helper module consumed by phase.cjs; sdk/src/query/phase-lifecycle.ts remains the typed source counterpart. This pair is intentionally tracked as adapter-over-module rather than an unauthorized hand-sync pair."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/profile-output.cjs",
|
||||
"ts": "sdk/src/query/profile-output.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS profile-output.cjs handles profiling output rendering; SDK profile-output.ts is the corresponding SDK query handler. Separate responsibilities across the seam."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/roadmap.cjs",
|
||||
"ts": "sdk/src/query/roadmap.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS roadmap.cjs is the full roadmap implementation; SDK roadmap.ts provides the native handler for SDK query dispatch. Phase 5.2+ candidate."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/state.cjs",
|
||||
"ts": "sdk/src/query/state.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS state.cjs is the full state implementation; SDK state.ts routes known subcommands via executeForCjs (Phase 5.0/#3558, Phase 5.1/#3574). Intentional seam delegation pattern."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/state-document.cjs",
|
||||
"ts": "sdk/src/state/index.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "CJS state-document.cjs is a thin Adapter over get-shit-done/bin/lib/state-document.generated.cjs. That generated artifact is emitted from sdk/src/state/index.ts by sdk/scripts/gen-state-document.ts; freshness is enforced by sdk/scripts/check-state-document-fresh.mjs."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/template.cjs",
|
||||
"ts": "sdk/src/query/template.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS template.cjs handles template operations; SDK template.ts is the corresponding SDK native handler. Separate responsibilities across the seam."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/uat.cjs",
|
||||
"ts": "sdk/src/query/uat.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS uat.cjs implements UAT workflows; SDK uat.ts provides the SDK query handler layer. Separate responsibilities."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/verify.cjs",
|
||||
"ts": "sdk/src/query/verify.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS verify.cjs is the full verify implementation; SDK verify.ts provides the native handler. Phase 5.2+ candidate for further delegation. Check 8 (W006/W007) helpers now generated from sdk/src/query/validate.ts via sdk/scripts/gen-validate.mjs (issue #6); freshness check: sdk/scripts/check-validate-fresh.mjs."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/validate.cjs",
|
||||
"ts": "sdk/src/query/validate.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "CJS validate.cjs provides the pure validation helpers consumed by verify.cjs while sdk/src/query/validate.ts is the SDK handler-side counterpart. Keep this explicit adapter pair allowlisted to avoid false-positive hand-sync lint failures."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/workstream.cjs",
|
||||
"ts": "sdk/src/query/workstream.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS workstream.cjs handles workstream management; SDK workstream.ts provides the SDK query handler. Workstream support inside sync bridge is an open follow-up item."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/workstream-inventory.cjs",
|
||||
"ts": "sdk/src/query/workstream-inventory.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS workstream-inventory.cjs is the generated Adapter for the workstream-inventory Shared Module (Phase 3/#3548). SDK workstream-inventory.ts is the source-of-truth query handler. Freshness check enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/config.cjs",
|
||||
"ts": "sdk/src/config.ts",
|
||||
"classification": "CJS-CLI-ONLY",
|
||||
"justification": "Phase 2 (#3536) already migrated CONFIG_DEFAULTS and loadConfig/mergeDefaults to the Configuration Module and sdk/src/config.ts. What remains in config.cjs is exclusively CLI command handlers (cmdConfigGet, cmdConfigSet, cmdConfigNewProject, cmdConfigEnsureSection, cmdConfigSetModelProfile, cmdConfigPath, cmdMigrateConfig, buildNewProjectConfig, setConfigValue, ensureConfigFile) that depend on CJS-only APIs (withPlanningLock, platformWriteSync/ReadSync/EnsureDir, sync fs ops, process.exit). sdk/src/config.ts provides only the async loadConfig/mergeDefaults SDK layer. The two files serve disjoint surfaces with no logical overlap — not a hand-sync drift anti-pattern."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/config.cjs",
|
||||
"ts": "sdk/src/config/index.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "sdk/src/config/index.ts is the Configuration Module source-of-truth; CJS config.cjs consumes its generated Adapter through core/config-schema integration. This is an intentional seam relationship, not manual hand-sync."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/state.cjs",
|
||||
"ts": "sdk/src/state/index.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "sdk/src/state/index.ts is the STATE.md Document Module source-of-truth that emits get-shit-done/bin/lib/state-document.generated.cjs. state.cjs consumes that generated module as an Adapter dependency; basename overlap is intentional and not a duplicated implementation."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/intel.cjs",
|
||||
"ts": "sdk/src/query/intel.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"justification": "CJS intel.cjs is the synchronous runtime implementation used by gsd-tools.cjs; sdk/src/query/intel.ts is the async QueryHandler port for the SDK query seam (explicitly documented as a port in its file header). The two files intentionally diverge on INTEL_FILES naming (CJS: file-roles.json/api-map.json/dependency-graph.json/arch-decisions.json; SDK: files.json/apis.json/deps.json/arch.md) — existing CJS tests are locked to the old naming. Not a hand-sync drift pattern; separate runtime responsibilities across the seam."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/model-catalog.cjs",
|
||||
"ts": "sdk/src/model-catalog.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "Both files read from sdk/shared/model-catalog.json (ADR-0003 precedent) as independent consumers of the shared manifest. CJS exposes VALID_AGENT_TIERS, MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, nextTier, formatAgentToModelMapAsTable for core.cjs and model-profiles.cjs consumers. SDK exposes resolveRuntimeTierDefault, runtimesWithReasoningEffort for session-runner.ts and query handlers. The shared JSON is the single source-of-truth; both adapters derive their exports from it without duplicating any logic between themselves."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/plan-scan.cjs",
|
||||
"ts": "sdk/src/query/plan-scan.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "CJS plan-scan.cjs is the generated Adapter reading from sdk/src/query/plan-scan.ts Shared Module (Phase 6/#3575). SDK plan-scan.ts is the source-of-truth. Freshness check (check-plan-scan-fresh.mjs) enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/secrets.cjs",
|
||||
"ts": "sdk/src/query/secrets.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "CJS secrets.cjs is the generated Adapter reading from sdk/src/query/secrets.ts Shared Module (Phase 6/#3575). SDK secrets.ts is the source-of-truth. Freshness check (check-secrets-fresh.mjs) enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/schema-detect.cjs",
|
||||
"ts": "sdk/src/query/schema-detect.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "CJS schema-detect.cjs is the generated Adapter reading from sdk/src/query/schema-detect.ts Shared Module (Phase 6/#3575). SDK schema-detect.ts is the source-of-truth. Generated CJS adds detectSchemaOrm compat export (not in SDK) and exports SCHEMA_PATTERNS/ORM_INFO for backward compatibility. Freshness check (check-schema-detect-fresh.mjs) enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/decisions.cjs",
|
||||
"ts": "sdk/src/query/decisions.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "Phase 6 (#3575): CJS decisions.cjs is the generated Adapter reading from sdk/src/query/decisions.ts Shared Module. SDK source-of-truth; regex aligned to accept alphanumeric IDs (D-INFRA-01). CJS callers (gap-checker.cjs) use {id, text} subset; extra fields {category, tags, trackable} are present but ignored. Freshness check (check-decisions-fresh.mjs) enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/workstream-name-policy.cjs",
|
||||
"ts": "sdk/src/workstream-name-policy.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "Phase 6 (#3575): CJS workstream-name-policy.cjs is the generated Adapter reading from sdk/src/workstream-name-policy.ts Shared Module. SDK source-of-truth now exports all three functions used by CJS callers (toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName) plus validateWorkstreamName alias. Freshness check (check-workstream-name-policy-fresh.mjs) enforces alignment."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/runtime-name-policy.cjs",
|
||||
"ts": "sdk/src/runtime-name-policy.ts",
|
||||
"classification": "ADAPTER-OVER-MODULE",
|
||||
"justification": "Both CJS and SDK runtime-name adapters consume sdk/shared/runtime-aliases.manifest.json as the single source of truth for alias canonicalization. This pair is an intentional seam-level adapter split, not a drift-prone hand-sync anti-pattern."
|
||||
},
|
||||
{
|
||||
"cjs": "get-shit-done/bin/lib/prompt-budget.cjs",
|
||||
"ts": "sdk/src/query/prompt-budget.ts",
|
||||
"classification": "cooperating-sibling",
|
||||
"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": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
'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:
|
||||
// <tmpDir>/
|
||||
// get-shit-done/bin/lib/<cjsName>.cjs
|
||||
// sdk/src/query/<tsName>.ts (if tsInQuery === true)
|
||||
// sdk/src/<tsName>.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/<name>.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/<name>.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/<name>.ts pair. The sdk/src/query/<name>.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/<name>.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);
|
||||
}
|
||||
});
|
||||
});
|
||||
53
tests/no-cjs-sdk-handsync-tooling.test.cjs
Normal file
53
tests/no-cjs-sdk-handsync-tooling.test.cjs
Normal file
@@ -0,0 +1,53 @@
|
||||
// allow-test-rule: architectural-invariant
|
||||
// Guards a removal mandated by ADR-0174 (retire @opengsd/gsd-sdk package
|
||||
// boundary), which explicitly deletes the generator-based CJS↔SDK hand-sync
|
||||
// tooling. These assertions check repo structure (file absence, package.json
|
||||
// wiring) — they are not source-text inspection of any .cjs module — and exist
|
||||
// to prevent silent re-introduction of the retired seam tooling.
|
||||
|
||||
/**
|
||||
* Regression guard for issue #556 — retire orphaned CJS↔SDK hand-sync tooling.
|
||||
*
|
||||
* The @opengsd/gsd-sdk package boundary was retired (ADR-0174, #191/#192) and
|
||||
* the `sdk/` tree is no longer tracked. The generator-based hand-sync lint that
|
||||
* policed CJS-vs-SDK TypeScript drift is therefore dead infrastructure: it
|
||||
* referenced an `sdk/src` tree that no longer exists and was wired into no CI
|
||||
* workflow or npm script. This guard asserts it stays removed.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
describe('retired CJS↔SDK hand-sync tooling (#556 / ADR-0174)', () => {
|
||||
test('the hand-sync pair lint script is absent', () => {
|
||||
const lintScript = path.join(REPO_ROOT, 'scripts', 'lint-shared-module-handsync.cjs');
|
||||
assert.ok(
|
||||
!fs.existsSync(lintScript),
|
||||
'scripts/lint-shared-module-handsync.cjs should be removed — the CJS↔SDK seam it policed was retired by ADR-0174',
|
||||
);
|
||||
});
|
||||
|
||||
test('the hand-sync allowlist is absent', () => {
|
||||
const allowlist = path.join(REPO_ROOT, 'scripts', 'shared-module-handsync-allowlist.json');
|
||||
assert.ok(
|
||||
!fs.existsSync(allowlist),
|
||||
'scripts/shared-module-handsync-allowlist.json should be removed — it paired bin/lib/*.cjs files with sdk/src sources that no longer exist',
|
||||
);
|
||||
});
|
||||
|
||||
test('no npm script re-wires the retired hand-sync lint', () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
|
||||
const offenders = Object.entries(pkg.scripts || {})
|
||||
.filter(([, cmd]) => cmd.includes('lint-shared-module-handsync'))
|
||||
.map(([name]) => name);
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
`package.json scripts must not invoke the retired hand-sync lint; found: ${offenders.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user