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:
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
222
scripts/lint-docs-required.cjs
Executable 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,
|
||||
};
|
||||
331
scripts/lint-shared-module-handsync.cjs
Normal file
331
scripts/lint-shared-module-handsync.cjs
Normal 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();
|
||||
139
scripts/shared-module-handsync-allowlist.json
Normal file
139
scripts/shared-module-handsync-allowlist.json
Normal 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": []
|
||||
}
|
||||
Reference in New Issue
Block a user