Merge origin/main into feat/3597-split-suites-node-matrix

Resolves conflict in .github/workflows/test.yml: keep the 6 new drift-check
steps from main (plan-scan, secrets, schema-detect, decisions,
workstream-name-policy, Shared Module hand-sync) before the split-lane test
runs from this PR. PR's dedicated `coverage` job replaces main's per-matrix
`Run tests with coverage` step.

Other conflicting files (CONTRIBUTING.md, get-shit-done/bin/lib/init.cjs,
package.json) auto-merged cleanly. Changeset files (.changeset/*) brought in
from main as adds.
This commit is contained in:
Tom Boucher
2026-05-16 13:22:07 -04:00
132 changed files with 9379 additions and 1405 deletions

View File

@@ -36,9 +36,18 @@ const HOOKS_TO_COPY = [
// Community hooks (bash, opt-in via .planning/config.json hooks.community)
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh'
'gsd-phase-boundary.sh',
// Graphify auto-update hook (#3347 / PR #3557 / #3579). Opt-in via
// .planning/config.json graphify.auto_update; off by default.
'gsd-graphify-update.sh'
];
// Subdirectories under hooks/ whose contents must also ship to dist. Each
// entry is copied as `hooks/<dir>/*` → `hooks/dist/<dir>/*` so detached
// helpers (e.g. hooks/lib/gsd-graphify-rebuild.sh) resolve from the hook's
// installed runtime path. See #3579.
const HOOKS_SUBDIRS_TO_COPY = ['lib'];
// Sync millisecond sleep using Atomics.wait on a throwaway SharedArrayBuffer.
// Used between Windows rename retries; this script is sync end-to-end so
// setTimeout would not work. Total worst-case backoff across MAX_ATTEMPTS
@@ -169,6 +178,37 @@ function build() {
renameAtomicWithRetry(stagedDest, dest, hook);
}
// Copy whitelisted hook subdirectories (e.g. hooks/lib/) into dist so the
// installer's readdir-and-isFile loop in bin/install.js sees them and
// detached hook helpers resolve from the installed runtime path (#3579).
for (const subdir of HOOKS_SUBDIRS_TO_COPY) {
const srcDir = path.join(HOOKS_DIR, subdir);
if (!fs.existsSync(srcDir)) continue;
const destDir = path.join(DIST_DIR, subdir);
fs.mkdirSync(destDir, { recursive: true });
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const ent of entries) {
if (!ent.isFile()) continue;
const srcFile = path.join(srcDir, ent.name);
const destFile = path.join(destDir, ent.name);
if (ent.name.endsWith('.js')) {
const syntaxError = validateSyntax(srcFile);
if (syntaxError) {
console.error(`\x1b[31m✗ ${subdir}/${ent.name}: SyntaxError — ${syntaxError}\x1b[0m`);
hasErrors = true;
continue;
}
}
console.log(`\x1b[32m✓\x1b[0m Copying ${subdir}/${ent.name}...`);
const stagedDest = path.join(STAGE_DIR, `${subdir}__${ent.name}.${Date.now()}`);
fs.copyFileSync(srcFile, stagedDest);
if (ent.name.endsWith('.sh')) {
try { fs.chmodSync(stagedDest, 0o755); } catch (e) { /* Windows */ }
}
renameAtomicWithRetry(stagedDest, destFile, `${subdir}/${ent.name}`);
}
}
// Best-effort cleanup of this process's own staging dir. Since STAGE_DIR
// is per-PID (`.dist-staging-<pid>/`), no other builder touches it — so
// rmSync with recursive:true is safe and leaves no race window.

View File

@@ -9,9 +9,15 @@
* ---
* <markdown body>
*
* Returns { ok: true, fragment: { type, pr, body } } on success,
* Returns { ok: true, fragment: { type, pr, body, docsExempt } } on success,
* { ok: false, reason: FRAGMENT_ERROR.X, detail } on failure.
*
* `docsExempt` is `null` when the body contains no docs-exempt marker, or the
* trimmed reason string when the body contains `<!-- docs-exempt: <reason> -->`
* (#3213). The marker is stripped from `body` at parse time so it never bleeds
* into the CHANGELOG.md or GitHub release-notes serializers, which append the
* `(#NNNN)` PR suffix verbatim to the body's last line.
*
* The reason field is a frozen enum so tests assert on stable codes,
* not free-text error messages (CONTRIBUTING.md: "Prohibited: Raw
* Text Matching on Test Outputs").
@@ -27,6 +33,46 @@ const FRAGMENT_ERROR = Object.freeze({
const ALLOWED_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']);
// HTML comment marking a fragment as exempt from the docs-required lint (#3213).
// Form: `<!-- docs-exempt: <reason> -->`. The reason is the *required* human
// audit trail — without it the exemption has no paper-trail value, so a bare
// `<!-- docs-exempt -->` or empty `<!-- docs-exempt: -->` is intentionally
// rejected (the colon and a non-whitespace first reason char are mandatory).
//
// Anchored with `^...$` + `m` flag so the marker only counts when it occupies
// its own line. Inline mentions inside paragraphs (e.g. backtick-wrapped
// syntax examples in documentation) are not matched — they cannot
// accidentally exempt a fragment.
//
// The trailing `\r?` consumes the CR character of a CRLF line terminator,
// which the `$` boundary (multiline mode) does not — so Windows-authored
// fragments produce the same `body` shape as LF-authored ones. The reason
// character class `[^\r\n>]` excludes `\r` for the same reason: a CRLF
// fragment's reason text never carries a trailing `\r`.
//
// Bounded character class `[^\r\n>]` keeps the regex linear-time — no
// catastrophic backtracking on adversarial input. The leading `\S` anchor
// inside the capture group forces at least one non-whitespace character in
// the reason; trailing whitespace before `-->` is consumed by the outer
// `[ \t]*-->` and is not part of the captured reason.
const DOCS_EXEMPT_RE = /^[ \t]*<!--[ \t]*docs-exempt[ \t]*:[ \t]*(\S[^\r\n>]*?)[ \t]*-->[ \t]*\r?$/im;
function extractDocsExempt(body) {
const m = body.match(DOCS_EXEMPT_RE);
if (!m) return { docsExempt: null, body };
const reason = (m[1] || '').trim();
// Strip the marker line and tidy up the surrounding whitespace. The cleanup
// is CRLF-aware so Windows-authored fragments don't leave residual `\r`
// characters that would shift the `(#NNNN)` PR suffix to a blank line in
// the rendered CHANGELOG.md / GitHub release-notes bullet.
const cleaned = body
.replace(DOCS_EXEMPT_RE, '')
.replace(/[ \t\r]+$/gm, '') // strip trailing \r/spaces on each line
.replace(/(?:\r?\n){3,}/g, '\n\n') // collapse 3+ blank lines (CRLF-aware)
.replace(/[\r\n]+$/, ''); // strip every trailing line terminator
return { docsExempt: reason, body: cleaned };
}
function parseFragment(src) {
const fmMatch = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!fmMatch) return { ok: false, reason: FRAGMENT_ERROR.MISSING_FRONTMATTER };
@@ -49,12 +95,20 @@ function parseFragment(src) {
}
// Use trim() only for the emptiness check; preserve the body verbatim
// (including significant leading/trailing whitespace, code blocks, etc.)
// so render → serialize round-trips exactly. Strip only a single trailing
// newline added by editors so byte-equality holds for typical fragments.
// so render → serialize round-trips exactly. Strip the single trailing
// line terminator added by editors so byte-equality holds for typical
// fragments. CRLF-aware: a Windows-authored fragment trims `\r\n` so the
// marker line in extractDocsExempt does not leave residual `\r` characters
// for downstream serializers to attach `(#NNNN)` to (#3213).
if (!body.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY };
const verbatimBody = body.endsWith('\n') ? body.slice(0, -1) : body;
let verbatimBody;
if (body.endsWith('\r\n')) verbatimBody = body.slice(0, -2);
else if (body.endsWith('\n')) verbatimBody = body.slice(0, -1);
else verbatimBody = body;
const { docsExempt, body: visibleBody } = extractDocsExempt(verbatimBody);
if (!visibleBody.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY };
return { ok: true, fragment: { type: fields.type, pr, body: verbatimBody } };
return { ok: true, fragment: { type: fields.type, pr, body: visibleBody, docsExempt } };
}
module.exports = { parseFragment, FRAGMENT_ERROR, ALLOWED_TYPES };
module.exports = { parseFragment, extractDocsExempt, FRAGMENT_ERROR, ALLOWED_TYPES, DOCS_EXEMPT_RE };

View File

@@ -1,10 +1,18 @@
'use strict';
/**
* One-shot script: replace retired /gsd-<cmd> with /gsd:<cmd> for known command names.
* Only replaces when followed by a word boundary (space, newline, quote, backtick, ), end).
* One-shot script + library: bidirectional GSD slash-command namespace normalizer.
*
* The transform is exported as a pure function so it can be unit-tested directly
* (see tests/bug-2543-gsd-slash-namespace.test.cjs) without needing fixture files.
* - Default direction (transformContent): retired /gsd-<cmd> → /gsd:<cmd>
* (keeps monorepo sources, docs, and workflows in the active colon form).
* - Reverse direction (transformContentToHyphen): /gsd:<cmd> / gsd:<cmd> → gsd-<cmd>
* (used during skill installation for runtimes that register skills under the
* canonical hyphen form established in #2808).
*
* Both directions only rewrite known commands from `commands/gsd/*.md` (longest-first
* matching + word-boundary safety). Non-commands (gsd-sdk, gsd-tools, etc.) are
* intentionally left untouched.
*
* The transforms are pure and exported for use by the installer and tests.
*/
const fs = require('node:fs');
@@ -57,6 +65,32 @@ function transformContent(src, cmdNames) {
return src.replace(pattern, (_, cmd) => `/gsd:${cmd}`);
}
/**
* Build regex for the reverse direction (colon form → hyphen form).
* Matches both "gsd:cmd" and "/gsd:cmd" (the leading / is preserved automatically
* because it is not part of the match). Uses longest-first ordering plus
* bidirectional word-boundary safety (negative lookbehind on the left, lookahead
* on the right) so matches only occur at token boundaries.
*/
function buildColonPattern(cmdNames) {
if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
return new RegExp(`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
}
/**
* Pure transform (reverse): rewrite `/gsd:<cmd>` / `gsd:<cmd>` to hyphen form
* for known GSD commands.
*
* Non-command identifiers (e.g. gsd-sdk, gsd-tools) are left untouched, matching
* the safety contract of the forward transform.
*/
function transformContentToHyphen(src, cmdNames) {
const pattern = buildColonPattern(cmdNames);
if (!pattern) return src;
return src.replace(pattern, (_, cmd) => `gsd-${cmd}`);
}
function readCmdNames() {
return fs.readdirSync(COMMANDS_DIR)
.filter(f => f.endsWith('.md'))
@@ -103,4 +137,11 @@ if (require.main === module) {
console.log('Done.');
}
module.exports = { transformContent, buildPattern, SKIP_DIRS };
module.exports = {
transformContent,
transformContentToHyphen,
buildPattern,
buildColonPattern,
readCmdNames,
SKIP_DIRS
};

222
scripts/lint-docs-required.cjs Executable file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env node
'use strict';
/**
* Docs-required lint (#3213).
*
* Mirrors scripts/changeset/lint.cjs. Pure verdict function
* evaluateLint({ changedFiles, fragments, labels, malformed }) returns
* { ok, reason, triggering } using the LINT_REASON enum. The CLI wrapper
* reads the PR diff (`git diff --name-only origin/${base}...HEAD`), parses
* each touched `.changeset/*.md` fragment, then calls evaluateLint.
*
* Tests assert on the structured verdict, never on free text.
*/
const { parseFragment, FRAGMENT_ERROR } = require('./changeset/parse.cjs');
const LINT_REASON = Object.freeze({
OK_NO_TRIGGERING_FRAGMENTS: 'ok_no_triggering_fragments',
OK_DOCS_UPDATED: 'ok_docs_updated',
OK_OPT_OUT_LABEL: 'ok_opt_out_label',
OK_FRAGMENTS_EXEMPT: 'ok_fragments_exempt',
FAIL_DOCS_MISSING: 'fail_docs_missing',
FAIL_MALFORMED_FRAGMENT: 'fail_malformed_fragment',
});
const OPT_OUT_LABEL = 'no-docs';
// Fragment types that require a docs update. `Fixed` and `Security` are
// bug-class — they describe regressions or vulnerabilities, not new
// behavior to document.
const TRIGGERING_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed']);
const DOCS_PREFIX = 'docs/';
function isFragmentPath(file) {
return /^\.changeset\/[^/]+\.md$/.test(file) && !file.endsWith('/README.md');
}
function isDocsFile(file) {
return file.startsWith(DOCS_PREFIX);
}
// Per-fragment escape hatch: parse.cjs extracts `<!-- docs-exempt: <reason> -->`
// from the body into `fragment.docsExempt` (a non-empty reason string when the
// marker was present and well-formed; `null` otherwise). A non-empty audit
// trail is required — the lint defends in depth here too: even if a caller
// constructs a fragment with `docsExempt: ''`, that does not count as exempt.
function isExemptFragment(fragment) {
return typeof fragment.docsExempt === 'string' && fragment.docsExempt.trim().length > 0;
}
/**
* Pure verdict — no fs, no git.
*
* Malformed fragments fail closed: a triggering fragment with bad frontmatter
* cannot silently bypass docs enforcement. The changeset-required lint only
* checks fragment _presence_, not _validity_, so docs lint takes responsibility
* for any fragment it tries to consume.
*
* @param {object} args
* @param {string[]} args.changedFiles - file paths changed in the PR
* @param {Array<{ path: string, type: string, body: string, docsExempt: string|null }>} args.fragments
* - parsed records for well-formed `.changeset/*.md` files in `changedFiles`
* @param {Array<{ path: string, reason: string }>} [args.malformed]
* - records for `.changeset/*.md` files that failed `parseFragment`
* @param {string[]} args.labels - PR labels
* @returns {{ ok: boolean, reason: string, triggering: string[], malformed?: Array<{path:string,reason:string}> }}
*/
function evaluateLint({ changedFiles, fragments, labels, malformed = [] }) {
if (malformed.length > 0) {
return {
ok: false,
reason: LINT_REASON.FAIL_MALFORMED_FRAGMENT,
triggering: [],
malformed,
};
}
const triggering = fragments.filter((f) => TRIGGERING_TYPES.has(f.type));
const triggeringPaths = triggering.map((f) => f.path);
if (triggering.length === 0) {
return { ok: true, reason: LINT_REASON.OK_NO_TRIGGERING_FRAGMENTS, triggering: [] };
}
// Per-fragment exempt path: every triggering fragment must carry the marker.
// Partial exemption fails closed — one un-marked Added fragment still requires docs.
if (triggering.every(isExemptFragment)) {
return { ok: true, reason: LINT_REASON.OK_FRAGMENTS_EXEMPT, triggering: triggeringPaths };
}
if (labels.includes(OPT_OUT_LABEL)) {
return { ok: true, reason: LINT_REASON.OK_OPT_OUT_LABEL, triggering: triggeringPaths };
}
if (changedFiles.some(isDocsFile)) {
return { ok: true, reason: LINT_REASON.OK_DOCS_UPDATED, triggering: triggeringPaths };
}
return { ok: false, reason: LINT_REASON.FAIL_DOCS_MISSING, triggering: triggeringPaths };
}
function readFragmentsFromDisk(changedFiles, rootDir) {
const fs = require('node:fs');
const path = require('node:path');
const fragments = [];
const malformed = [];
for (const rel of changedFiles) {
if (!isFragmentPath(rel)) continue;
const abs = path.join(rootDir, rel);
if (!fs.existsSync(abs)) continue; // fragment deleted in PR — skip
let src;
try {
src = fs.readFileSync(abs, 'utf8');
} catch (e) {
malformed.push({ path: rel, reason: 'read_error', detail: e.code || e.message });
continue;
}
const parsed = parseFragment(src);
if (!parsed.ok) {
malformed.push({ path: rel, reason: parsed.reason, detail: parsed.detail || null });
continue;
}
fragments.push({
path: rel,
type: parsed.fragment.type,
body: parsed.fragment.body,
docsExempt: parsed.fragment.docsExempt,
});
}
return { fragments, malformed };
}
function main() {
const fs = require('node:fs');
const cp = require('node:child_process');
const path = require('node:path');
const rootDir = path.join(__dirname, '..');
const eventPath = process.env.GITHUB_EVENT_PATH;
let labels = [];
if (eventPath && fs.existsSync(eventPath)) {
try {
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
labels = (event.pull_request?.labels || []).map((l) => l.name);
} catch { /* fall through */ }
}
const base = process.env.GITHUB_BASE_REF || 'main';
let changedFiles = [];
try {
// execFileSync with argv — no shell, so a malicious GITHUB_BASE_REF
// cannot inject shell syntax. Git's own ref-name validator rejects
// any metacharacters it would otherwise interpret.
const out = cp.execFileSync(
'git',
['diff', '--name-only', `origin/${base}...HEAD`],
{ encoding: 'utf8', cwd: rootDir },
);
changedFiles = out.split('\n').filter(Boolean);
} catch (e) {
process.stderr.write(`could not compute diff: ${e.message}\n`);
process.exit(2);
}
const { fragments, malformed } = readFragmentsFromDisk(changedFiles, rootDir);
const verdict = evaluateLint({ changedFiles, fragments, labels, malformed });
if (process.argv.includes('--json')) {
process.stdout.write(
JSON.stringify({ ...verdict, changedFiles, fragments, malformed, labels }, null, 2) + '\n',
);
} else if (verdict.ok) {
process.stdout.write(`ok docs-lint: ${verdict.reason}\n`);
} else if (verdict.reason === LINT_REASON.FAIL_MALFORMED_FRAGMENT) {
process.stderr.write(`\nERROR docs-lint: ${verdict.reason}\n`);
process.stderr.write(
`${malformed.length} changeset fragment(s) failed to parse — docs lint cannot consume them:\n`,
);
for (const m of malformed) {
process.stderr.write(` ${m.path} (reason: ${m.reason}${m.detail ? `, detail: ${m.detail}` : ''})\n`);
}
process.stderr.write(
`\nFix the fragment frontmatter (\`type:\` + \`pr:\`) before this PR can pass.\n`,
);
} else {
process.stderr.write(`\nERROR docs-lint: ${verdict.reason}\n`);
process.stderr.write(
`${verdict.triggering.length} changeset fragment(s) require documentation updates:\n`,
);
for (const f of fragments.filter((f) => TRIGGERING_TYPES.has(f.type))) {
process.stderr.write(` ${f.path} (type: ${f.type})\n`);
}
process.stderr.write(`\nNo files under docs/ were modified in this PR.\n\n`);
process.stderr.write(
`Update the relevant docs/ file(s), or add the \`${OPT_OUT_LABEL}\` label if this change\n`,
);
process.stderr.write(
`is genuinely internal-only (infrastructure, refactor, test-only). Per-fragment\n`,
);
process.stderr.write(
`exemption via \`<!-- docs-exempt: <reason> -->\` inside the fragment body also works.\n`,
);
}
process.exit(verdict.ok ? 0 : 1);
}
if (require.main === module) main();
module.exports = {
evaluateLint,
readFragmentsFromDisk,
LINT_REASON,
OPT_OUT_LABEL,
TRIGGERING_TYPES,
FRAGMENT_ERROR,
isFragmentPath,
isDocsFile,
isExemptFragment,
};

View File

@@ -0,0 +1,331 @@
#!/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.
*
* 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 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]);
} 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])
);
// ---------------------------------------------------------------------------
// 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)) {
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);
}
const sdkIndex = buildSdkIndex(SDK_SRC);
const cjsFiles = scanCjsFiles(CJS_DIR);
const errors = [];
const warnings = [];
for (const { name, absPath } of cjsFiles) {
// Is there a matching TS file?
if (!sdkIndex.has(name)) continue;
// Compute the relative paths the allowlist uses
const relCjs = path.relative(ROOT, absPath).replace(/\\/g, '/');
const tsPaths = sdkIndex.get(name).map((p) => path.relative(ROOT, p).replace(/\\/g, '/'));
// 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) => {
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}`);
});
}).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();

View File

@@ -0,0 +1,139 @@
{
"$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 is the full phase lifecycle implementation; SDK phase.ts provides the native query handler. The SDK delegates to CJS for most subcommands. Phase 5.2+ candidate for further migration."
},
{
"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/query/state-document.ts",
"classification": "cooperating-sibling",
"justification": "CJS state-document.cjs is the generated Adapter reading from sdk/src/state-document/ Shared Module (Phase 1/#3531). SDK state-document.ts is the corresponding source-of-truth query handler. Freshness check enforces alignment."
},
{
"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."
},
{
"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/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."
}
],
"migrateMeBacklog": []
}