fix(#2657): untrack the nine ADR-457 migration-gap compiled artifacts (#3011)

* test(#2657): add failing-first regression for tracked bin/lib compiled artifacts

Nine gsd-core/bin/lib/*.cjs artifacts are tracked in git despite having
src/*.cts sources, violating ADR-457's build-at-publish contract. This
regression test asserts the ADR-457 end state (untracked, gitignored,
empty-set reported by the #2656 sync guard) and fails until the tracking
is fixed.

* fix(#2657): untrack the nine ADR-457 migration-gap compiled artifacts

Nine gsd-core/bin/lib/*.cjs artifacts (api-coverage, assumption-delta,
claude-orchestration, claude-orchestration-command-router, external-job,
markdown-table, runtime-artifact-install-plan, state-transition,
write-set) were tracked in git despite each having a matching src/*.cts
source, letting the committed bytes drift silently from source (#2653
demonstrated this for api-coverage.cjs).

Seven had no .gitignore entry at all; two (markdown-table.cjs,
write-set.cjs) had a pattern added by #2248 but were never
git rm --cached. Both gaps produce the same tracked-file symptom.

Untracks all nine and adds the seven missing .gitignore entries next to
their two siblings, reaching ADR-457's end state: bin/lib/*.cjs is a
gitignored build artifact built via prepare/pretest/prepublishOnly, never
checked-in source of truth. The #2656 artifact-sync guard is
regime-agnostic by design and needed no code change; it now reports the
empty-set end state.

* test(#2657): consolidate repeated still-tracked/unmatched assertion shape

Code-review finding (Standards axis, Duplicated Code): the three
'none of the nine should still be in bad state X' checks shared an
identical filter-then-assert-empty shape. Extracted assertNoneStillBad()
as a shared helper; behavior is unchanged.

* fix(#2657): make the .gitignore-match assertion existence-independent

The regression test's check-ignore assertion used --no-index, which
locally exercises the pattern correctly but was reported failing on
gsd-test's fresh shallow clone. Switched to plain 'git check-ignore -q'
(no --no-index): verified via a real git worktree checkout at both
origin/next (fails: all nine report not-ignored, since check-ignore
correctly special-cases the still-tracked pre-fix state) and this
branch's tip (passes: all nine report ignored). Plain check-ignore is
also semantically stronger than --no-index here, since it honors the
'a tracked path is never reported ignored' rule that --no-index
bypasses -- exactly the property under test for the two paths whose
.gitignore pattern predates this fix (#2248) but were never untracked.

Also reconciled the .gitignore comment: it previously read 'these
seven' beside seven new lines with no indication of the other two (of
nine total) that already had a pattern from #2248. Annotated both
groups so the count is unambiguous at the point of the diff.

* test(#2657): make every git invocation self-diagnosing

b6f915bc0 failed in the runner with a shape that turned out not to be
about .gitignore content or the merge: two of the five failures in this
file were 'Command failed' / 'Got unwanted exception' -- git itself
erroring, not answering. The old code used execFileSync + try/catch,
which conflates 'git said no' with 'git could not run' -- both looked
like the same negative result to the test, exactly the failure mode
that produced 'unmatched: <all nine>' twice on two different
assertions for two different reasons.

Switched every git invocation to spawnSync (never throws) and made
every assertion check the exit status explicitly before interpreting
output:
  - git ls-files: must exit 0, or the assertion fails loud with cwd,
    exit status, and stderr instead of silently reading an error as
    'nothing tracked'.
  - git check-ignore -q: only exit 0 (ignored) and exit 1 (not
    ignored) are legitimate answers per check-ignore(1); any other
    status is now a thrown infrastructure failure, never read as
    'not ignored'.
  - trackedCompiledArtifacts(): a thrown error is now reported as
    what it is (its internal git call failed), not swallowed into a
    'still tracked' verdict.
  - the sync-guard subprocess check now reports cwd/stderr/stdout on
    a non-zero exit instead of a bare doesNotThrow.

This is a genuine, independent test defect (a test that reads a
failed command's empty output as a meaningful answer can pass or fail
for the wrong reason) as well as the mechanism for finally surfacing
why b6f915bc0 failed in the runner: the next run's assertion messages
will show the resolved cwd and git's actual stderr instead of an
opaque 'unmatched: <all nine>'.

* fix(#2657): trust the repo root for git calls under dubious-ownership

Root cause of the failing runner verdict, harvested from the
diagnostics commit: every git invocation in the container exits 128
with 'fatal: detected dubious ownership in repository at /work' --
the checkout there is owned by a different uid than the process
running the tests, and git refuses to operate at all. The old
assertions read that hard failure as 'not ignored' / 'still tracked',
producing the all-nine symptom seen on both b6f915bc0 (plain
check-ignore) and 34052f836 (--no-index). Nothing was ever wrong with
the untracking, the .gitignore content, or the merge -- confirmed by
exhaustive local reproduction (git worktree, real shallow clone, the
runner's exact clone+checkout+merge sequence from its own Go source)
that could never surface the bug because this machine owns its own
checkouts.

Fixed at both git() call sites in this exact seam by passing
'-c safe.directory=<repo root>' per-invocation (never written to any
config file, so trust is scoped to the single call):
  - tests/fix-2657-untrack-compiled-artifacts.test.cjs
  - scripts/lint-compiled-artifact-sync.cjs -- a SHIPPED script with
    the identical defect (its own git ls-files failed the same way in
    the same run), which would fail identically for any containerized
    CI lane whose checkout uid differs from the running user, not just
    this branch. Folded in under the no-defer rule rather than filed
    separately, since it sits in the exact tracked-compiled-artifact
    guard this issue is about.

The status-code guards added in 31858818e stay in place -- they are
what turned an unexplainable 'unmatched: <all nine>' into a one-line
diagnosis, and they must keep any future infrastructure fault from
silently reading as a substantive result.

* chore(#2657): backfill changeset PR number to 3011

* chore(#2657): backfill changeset PR number to 3011

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-02 21:22:43 -04:00
committed by GitHub
parent de78f2eef2
commit 07de60523c
14 changed files with 214 additions and 4748 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3011
---
**Nine compiled `.cjs` runtime artifacts under `gsd-core/bin/lib/` are no longer tracked in git** — they are ADR-457 build outputs of `src/*.cts` sources and were missing from `.gitignore`, letting the committed bytes silently drift from source (as happened to `api-coverage.cjs` in #2653). They now build fresh from source like their ~160 already-gitignored siblings. (#2657)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3011
---
**`scripts/lint-compiled-artifact-sync.cjs` no longer fails on containerized checkouts owned by a different uid** — its internal `git` calls now scope `safe.directory` to the repo root per-invocation, so the guard runs instead of erroring with "detected dubious ownership" in any CI lane where the checkout owner differs from the running user. (#2657)

13
.gitignore vendored
View File

@@ -97,8 +97,21 @@ build/
/gsd-core/bin/lib/capability-consent.cjs
/gsd-core/bin/lib/capability-lock.cjs
/gsd-core/bin/lib/markdown-sectionizer.cjs
# #2657: these two (markdown-table.cjs, write-set.cjs) already had the
# pattern below since #2248, but stayed tracked because `git rm --cached`
# was never run — same untracking fix as the seven lines that follow.
/gsd-core/bin/lib/markdown-table.cjs
/gsd-core/bin/lib/write-set.cjs
# #2657: ADR-457 migration gap — these seven (of nine total; the two above
# are the other two) never got a .gitignore entry when their modules moved
# into src/*.cts, and were also never untracked.
/gsd-core/bin/lib/api-coverage.cjs
/gsd-core/bin/lib/assumption-delta.cjs
/gsd-core/bin/lib/claude-orchestration.cjs
/gsd-core/bin/lib/claude-orchestration-command-router.cjs
/gsd-core/bin/lib/external-job.cjs
/gsd-core/bin/lib/runtime-artifact-install-plan.cjs
/gsd-core/bin/lib/state-transition.cjs
/gsd-core/bin/lib/resolution.cjs
/gsd-core/bin/lib/research-store.cjs
/gsd-core/bin/lib/research-provider.cjs

View File

@@ -1,772 +0,0 @@
"use strict";
/**
* API-Coverage detector + matrix validator (#1562).
*
* The enforcement half of "Full API Coverage by Default — Opt Out, Never Opt In."
* When a phase integrates an external API/service/SDK, the planner must produce a
* coverage matrix (COVERAGE.md) enumerating the API's capability surface; every
* non-integrated capability is an explicit, reasoned opt-out. The seal-time gate
* (capabilities/ai-integration, verify:pre) consumes this module to (a) detect
* whether a phase integrates an external API and (b) validate the produced matrix.
*
* Design notes (rubber-duck'd):
* - DETERMINISTIC + TYPED IR. Both the "does this phase integrate an external
* API?" decision and the "is this matrix complete?" decision are pure
* functions returning typed IR, not LLM judgments — so the low-false-positive
* guarantee (acceptance criterion #4) and the completeness guarantee
* (acceptance #2) are testable. Mirrors assumption-delta.cts (#1561).
* - COMPOUND SIGNAL for low false positives. A bare word like "api" appears in
* countless non-integration phases ("the public API of UserController"). The
* detector requires an INTEGRATION VERB and an EXTERNAL-API NOUN in the SAME
* CLAUSE (#2365 — same-line co-occurrence across unrelated clauses over-fired;
* the clause boundary, not a word-gap cap, is the relationship test), or an
* explicit "<Service> API/SDK" phrase naming a real service. Single weak
* tokens do not fire. This is the issue's "low false-positive trigger" made
* mechanical.
* - CODE AND PATHS ARE NOT PROSE. Fenced code blocks and inline code spans are
* stripped first (markdown-sectionizer seam), and path-shaped tokens
* (`src/app/api/...`, URLs) are masked, so a trigger term inside code or a
* first-party route path does not fire (#2365).
* - NO-INTEGRATION DECLARATION (#2365 acceptance #5). A COVERAGE.md consisting
* of `No external API integration: <reason>` is a valid, reasoned way for a
* phase to state that no external surface exists — the alternative to
* fabricating a matrix row when the detector is overruled by a human.
* - THE DETECTOR IS A FALLBACK. The primary path is the plan:pre contribution
* prompting COVERAGE.md creation. The detector runs only when COVERAGE.md is
* ABSENT, to catch the "nobody decided" case (acceptance #1). Its precision
* therefore matters but is not the only line of defense.
* - MATRIX FORMAT. The matrix is a markdown table (human-editable, diff-friendly)
* with a header row `| capability | decision | reason |` and one row per
* capability. decision ∈ {INTEGRATE, OPT-OUT}. An OPT-OUT row MUST carry a
* non-empty reason. A fenced ```coverage JSON block is also accepted for
* machine-generated matrices. This dual shape is bijective (parse/render
* round-trip) and covered by a fast-check property test.
* - ADDITIVE-ONLY VOCABULARY (Hyrum's Law). Once shipped, the verb/noun sets
* are depended-upon interfaces; they only grow. Tunable via the `terms`
* parameter so teams can widen them without forking.
*
* Public API:
* detectApiIntegration(text, terms?) -> { detected, signals, terms }
* parseCoverageMatrix(text) -> { rows, errors, format }
* validateCoverageMatrix(text) -> { valid, errors, counts }
* renderCoverageMatrix(rows) -> string
* DEFAULT_API_COVERAGE_TERMS
*
* CLI:
* echo "$SCOPE" | node gsd-core/bin/lib/api-coverage.cjs [--json]
* exit 0 = integration detected, 1 = none, 2 = startup error
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_API_COVERAGE_TERMS = void 0;
exports.detectApiIntegration = detectApiIntegration;
exports.parseCoverageMatrix = parseCoverageMatrix;
exports.validateCoverageMatrix = validateCoverageMatrix;
exports.renderCoverageMatrix = renderCoverageMatrix;
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
/**
* Curated default trigger vocabulary. ADDITIVE-ONLY (Hyrum's Law). Tunable via
* the `terms` parameter.
*
* VERBS are deliberately conservative: common verbs like "add", "use", "call",
* "implement" are EXCLUDED because they appear in nearly every phase and would
* make the gate fire on prose that has nothing to do with an external API. The
* verbs kept all connote BRINGING IN an external surface.
*
* NOUNS name an external-API surface. Bare "client" is excluded — too ambiguous
* (client-side UI vs API client). "service" alone is excluded (internal
* services); a phase integrating an external service virtually always pairs it
* with "API"/"SDK"/"REST"/etc., which the compound verb+noun rule captures.
*/
exports.DEFAULT_API_COVERAGE_TERMS = {
verbs: [
'integrate',
'integrates',
'integrating',
'integration',
'wrap',
'wraps',
'wrapping',
'connect',
'connects',
'connecting',
'consume',
'consumes',
'consuming',
'wire',
'wires',
'wiring',
'onboard',
'onboarding',
'adopt',
'adopts',
'adopting',
],
nouns: [
'api',
'apis',
'sdk',
'sdks',
'rest',
'graphql',
'grpc',
'endpoint',
'endpoints',
'oauth',
'oauth2',
'webhook',
'webhooks',
'mcp',
],
};
/** Hardening caps for the tunable vocabulary (hostile `--terms` defense). */
const MAX_TERMS_PER_KIND = 200;
const MAX_TERM_LEN = 32;
/**
* Field-length caps for matrix cell values. Cell content flows from a
* semi-trusted COVERAGE.md into the gate `message` that the orchestrator LLM
* reads, so it is bounded to keep the prompt-injection surface small and to
* document the format contract (short, single-line prose — not paragraphs).
*/
const CAPABILITY_MAX_LEN = 80;
const REASON_MAX_LEN = 200;
function normalizeTerms(list) {
if (!Array.isArray(list))
return [];
const seen = new Set();
const out = [];
for (const raw of list) {
if (typeof raw !== 'string')
continue;
const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN);
if (!t || !/[a-z0-9]/.test(t))
continue;
if (seen.has(t))
continue;
seen.add(t);
out.push(t);
if (out.length >= MAX_TERMS_PER_KIND)
break;
}
return out;
}
function resolveTerms(terms) {
const merge = (key) => {
const t = terms && terms[key];
return Array.isArray(t) ? normalizeTerms(t) : [...exports.DEFAULT_API_COVERAGE_TERMS[key]];
};
return { verbs: merge('verbs'), nouns: merge('nouns') };
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function makeSnippet(line, anchor) {
const cleaned = line.replace(/\s+/g, ' ').trim();
if (cleaned.length <= 120)
return cleaned;
const idx = cleaned.toLowerCase().indexOf(anchor);
if (idx < 0)
return cleaned.slice(0, 120);
const start = Math.max(0, idx - 50);
const end = Math.min(cleaned.length, idx + anchor.length + 50);
const prefix = start > 0 ? '…' : '';
const suffix = end < cleaned.length ? '…' : '';
return `${prefix}${cleaned.slice(start, end)}${suffix}`;
}
/** `<Service> API` / `<Service> SDK` — a capitalized proper noun immediately
* followed by API/SDK. Strong signal on its own (no verb required).
*
* STOPWORDS guard against the false positive where an ordinary capitalized
* sentence starter ("The API …", "An SDK …", "Our REST …") matches the
* `[A-Z]\w+ API` shape. Those are common English, not a service name, so they
* are rejected before counting as a surface signal (acceptance #4 — low false
* positives). */
// Service-name length is bounded ({1,40}) so a hostile "A-A-A-…-A-x" run cannot
// drive the greedy group into O(n^2) backtracking (#2365 review). Nearly all
// vendor names fit; a >41-char service token before API/SDK would be missed by
// this surface path (it would still fire via the compound verb+noun rule) —
// an accepted bound.
const SERVICE_SURFACE_API_RE = /\b([A-Z][A-Za-z0-9_-]{1,40})\s+(API|SDK|REST|GraphQL)\b/;
const SERVICE_STOPWORDS = new Set([
'the', 'an', 'a', 'our', 'this', 'these', 'that', 'those', 'new', 'add',
'use', 'your', 'my', 'no', 'some', 'any', 'all', 'each', 'every', 'both',
'if', 'when', 'while', 'with', 'via', 'using', 'into', 'its', 'their',
'we', 'you', 'they', 'it',
]);
/** #2365 — the detector is FAIL-CLOSED: it leans toward detecting, because a
* false positive is cheaply dismissed by a one-line COVERAGE.md "no external
* API integration" declaration, whereas a false NEGATIVE silently lets a real
* external-API phase past a BLOCKING gate. So the only prose the detector
* actively suppresses is the classes that are unambiguously NOT external
* integration: first-party route paths, verb/noun in unrelated clauses, and
* descriptive/protocol "<Word> API" prose with no named service.
*
* CLAUSE_BOUNDARY_RE: a verb and a noun form ONE compound action only inside
* one grammatical clause — sentence punctuation and table-cell walls (`|`)
* end a clause. `-` is deliberately absent (it would split hyphenated words).
* There is deliberately NO word-gap cap inside a clause: a cap cannot separate
* a genuine long integration clause (F4, 21 words) from a long internal-UI
* clause (18 words) — the clause boundary is the only sound signal, and the
* declaration handles the residual false positives. */
const CLAUSE_BOUNDARY_RE = /[,;:.!?|()—–]/;
/** Same character class as CLAUSE_BOUNDARY_RE, as a set — for scanning a token's
* trailing punctuation without an unanchored `[…]+$` regex, whose backtracking
* is O(n^2) on a long punctuation run (#2365 review). */
const CLAUSE_BOUNDARY_CHARS = new Set([',', ';', ':', '.', '!', '?', '|', '(', ')', '—', '–']);
/* DELIBERATELY NO cross-clause binding. Detection is same-clause only. Binding
* a verb in one clause to a noun in another ("Integrate Stripe, exposing its
* endpoints"; "Integrate Stripe; use its endpoints") requires knowing "Stripe"
* is a vendor and "its" refers to it — a vendor dictionary + coreference, which
* trek-e's brief rules out in principle. Every lexical cross-clause rule tried
* (word-gap cap, participle continuation) traded a false negative for a false
* positive across four review rounds. So a service named ONLY in a clause
* separate from its API noun, with no explicit `<Service> API` surface, is a
* DOCUMENTED fail-open limitation — cheaply covered by the COVERAGE.md
* declaration and rare in real phase prose, which says "integrate the X API". */
/** In the `<Service> API|SDK` surface position, these capture words are NOT a
* named third-party service: locality/scope descriptors ("Internal API",
* "Public API") and bare protocol names ("REST API", "GraphQL API"). A real
* vendor name (Stripe, Shopify) is none of these, so rejecting them costs no
* true positives while killing the descriptive-prose false positives (#2365
* acceptance #3, review F8). */
const SURFACE_DESCRIPTOR_WORDS = new Set([
'internal', 'external', 'public', 'private', 'local', 'in-house', 'first-party',
'generic', 'shared', 'common', 'legacy', 'rest', 'restful', 'graphql', 'grpc',
'soap', 'rpc', 'http', 'https', 'json', 'xml',
]);
/** Locality qualifiers that, when they immediately precede a `<Service> API`,
* mark it as first-party ("internal Payments API") — negative evidence for an
* EXTERNAL-API surface signal. Only unambiguously-internal words: "external"
* is deliberately absent (an external API IS external). */
const INTERNAL_DESCRIPTORS = new Set(['internal', 'in-house', 'local', 'first-party', 'private']);
/** A capitalized compound modifier ("Resolver-only", "Read-only", "E-commerce"
* — lowercase letter right after the hyphen) is an adjective phrase, not a
* service name. Real hyphenated services capitalize the second segment
* ("T-Mobile"). */
const COMPOUND_MODIFIER_RE = /^[A-Z][A-Za-z0-9]*-[a-z]/;
const URL_TOKEN_RE = /^[([<"'`]*[a-z][a-z0-9+.-]*:\/\//i;
const LOCAL_URL_RE = /^[([<"'`]*[a-z][a-z0-9+.-]*:\/\/(?:localhost|127(?:\.\d{1,3}){1,3}|0\.0\.0\.0|\[::1\])(?=[:/?#]|$)/i;
/** A scheme-less token that STARTS with a dotted hostname whose final label is
* alphabetic ("api.stripe.com/v1") — a bare external API host. A first-party
* route path ("src/app/api/…") has no dotted head, and an IP host ("127.1/…")
* has a numeric final label, so neither matches (#2365 review F2). */
const DOMAIN_HEAD_RE = /^[([<"'`]*(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?=[:/?#]|$)/i;
/** Mask whitespace-delimited tokens with an interior `/` — file paths, framework
* routes (`src/app/api/...`), URLs. They are references, not integration prose
* (#2365 root cause 2: `/` counted as a word boundary, so first-party route
* paths matched the noun vocabulary). Two carve-outs keep genuine signals:
* - a slashed token whose segments are ALL noun-vocabulary words ("API/SDK",
* "REST/GraphQL") is prose shorthand, not a path — left unmasked;
* - a non-local URL is masked, but noun terms inside it are collected as
* compound-rule evidence (the old detector caught "connect to
* https://api.stripe.com" via the `api` segment; losing that would
* fail-open). */
function scanLineTokens(line, nounRe, nounSet) {
const urlNouns = [];
let masked = '';
const tokenRe = /\S+/g;
let last = 0;
let m;
while ((m = tokenRe.exec(line)) !== null) {
const rawTok = m[0];
masked += line.slice(last, m.index);
last = m.index + rawTok.length;
// Peel trailing clause-boundary punctuation off the token and keep it
// LITERAL in `masked` — masking it away would erase a clause split and pair
// unrelated verb/noun across it (#2365 review F6: "…example.com, document…").
// A backward char scan (not a `[…]+$` regex) keeps this linear.
let trailLen = 0;
while (trailLen < rawTok.length && CLAUSE_BOUNDARY_CHARS.has(rawTok[rawTok.length - 1 - trailLen])) {
trailLen++;
}
const trail = trailLen ? rawTok.slice(rawTok.length - trailLen) : '';
const tok = trailLen ? rawTok.slice(0, rawTok.length - trailLen) : rawTok;
if (!/\S[\\/]\S/.test(tok)) {
masked += rawTok;
continue;
}
const segments = tok.split(/[\\/]/).map((s) => s.replace(/[^A-Za-z0-9]/g, ''));
if (segments.every((s) => s.length > 0 && (nounSet.has(s.toLowerCase()) || /^v\d+$/i.test(s))) &&
segments.some((s) => nounSet.has(s.toLowerCase()))) {
masked += rawTok; // "API/SDK", "API/v2" — noun shorthand, not a path
continue;
}
// A scheme URL or a bare external hostname is an external dependency
// reference: mask it from prose but keep it as compound-rule evidence. A
// first-party route path has neither a scheme nor a dotted host, so it is
// masked WITHOUT contributing nouns (#2365 root cause 2).
// A non-local URL that NAMES an API vocabulary word ("api.stripe.com/v1")
// is external-dependency evidence, so its vocab nouns feed the compound
// rule. We deliberately do NOT treat every path-bearing URL as an endpoint:
// that fired on ordinary asset/link URLs ("…/theme.css", "…?next=/x") and
// recreated routine UI-phase false positives (#2365 review). A bare external
// host that names no vocabulary word ("graph.microsoft.com") and is not
// written as "<Service> API" is therefore a DOCUMENTED fail-open limitation.
const isSchemeUrl = URL_TOKEN_RE.test(tok) && !LOCAL_URL_RE.test(tok);
const isDomainUrl = !URL_TOKEN_RE.test(tok) && DOMAIN_HEAD_RE.test(tok);
if (nounRe && (isSchemeUrl || isDomainUrl)) {
for (const f of collectTermMatches(nounRe, tok)) {
urlNouns.push({ term: f.term, start: m.index, end: m.index + tok.length });
}
}
masked += ' '.repeat(tok.length) + trail;
}
masked += line.slice(last);
return { masked, urlNouns };
}
/** All term matches in a clause, with offsets. `re` must be global with the
* term in group 2 and a consumed leading boundary in group 1. */
function collectTermMatches(re, clause) {
const out = [];
re.lastIndex = 0;
let m;
while ((m = re.exec(clause)) !== null) {
const start = m.index + (m[1] || '').length;
out.push({ term: (m[2] || '').toLowerCase(), start, end: start + (m[2] || '').length });
if (m[0].length === 0)
re.lastIndex++;
}
return out;
}
/** Split a line into clause segments, keeping each segment's start offset so
* line-level spans (masked URL tokens) can be mapped into their clause. */
function splitClauses(masked) {
const out = [];
let start = 0;
for (let i = 0; i <= masked.length; i++) {
if (i === masked.length || CLAUSE_BOUNDARY_RE.test(masked[i])) {
out.push({ text: masked.slice(start, i), start });
start = i + 1;
}
}
return out;
}
/**
* Detect whether phase-scope prose describes integrating an external API/SDK.
*
* FAIL-CLOSED: it leans toward detecting, because a false positive is dismissed
* by a one-line COVERAGE.md declaration while a false negative silently slips a
* real external-API phase past a blocking gate. It fires when EITHER:
* (a) an integration VERB and an API NOUN share one CLAUSE ("integrate the
* Stripe API", "Connect … to api.stripe.com") — the clause boundary is the
* whole relationship test, so verb/noun in DIFFERENT clauses do not pair
* (#2365 acceptance #2). There is NO cross-clause binding: a service named
* only in a clause separate from its API noun is a documented limitation.
* (b) an explicit `<Service> API|SDK|REST|GraphQL` surface names a service
* that is not a stopword, a locality/protocol descriptor, a compound
* modifier, or first-party-qualified ("Stripe API", "Spotify SDK").
*
* Fenced code, inline code spans, and path-shaped tokens are excluded before
* matching. A package-shaped inline span (`@stripe/stripe-js`, `stripe-sdk`)
* and a URL that NAMES an API vocab word ("api.stripe.com/v1") still count as
* noun/dependency evidence; a bare host that names none does not.
*
* Non-string inputs degrade to `{ detected: false }` without throwing.
*/
function detectApiIntegration(text, terms) {
const effective = resolveTerms(terms);
if (typeof text !== 'string') {
return { detected: false, signals: [], terms: effective };
}
const stripped = (0, markdown_sectionizer_cjs_1.stripFencedCode)(text.replace(/\r\n/g, '\n')).text;
if (stripped.trim().length === 0) {
return { detected: false, signals: [], terms: effective };
}
const signals = [];
const seen = new Set();
const lines = stripped.split('\n');
const hasCompoundTerms = effective.verbs.length > 0 && effective.nouns.length > 0;
// Trailing boundary is a LOOKAHEAD (not consumed) so back-to-back terms
// separated by one boundary char are both found.
const verbRe = hasCompoundTerms
? new RegExp('(^|[^a-zA-Z0-9])(' + effective.verbs.map(escapeRegex).join('|') + ')(?=[^a-zA-Z0-9]|$)', 'gi')
: null;
const nounRe = hasCompoundTerms
? new RegExp('(^|[^a-zA-Z0-9])(' + effective.nouns.map(escapeRegex).join('|') + ')(?=[^a-zA-Z0-9]|$)', 'gi')
: null;
const surfaceRe = new RegExp(SERVICE_SURFACE_API_RE.source, 'g');
const nounSet = new Set(effective.nouns);
const emitPair = (vTerm, nTerm, snippetLine) => {
const key = `${vTerm}+${nTerm}`;
if (seen.has(key))
return;
seen.add(key);
signals.push({ verb: vTerm, noun: nTerm, snippet: makeSnippet(snippetLine, nTerm) });
};
for (const rawLine of lines) {
// Inline code spans are code, not prose — mask them (length-preserving so
// offsets keep lining up), but keep package-shaped span content as noun
// evidence (#2365 review FN-4: `stripe-sdk` names a dependency).
const inlineSpans = (0, markdown_sectionizer_cjs_1.scanInlineCodeSpans)(rawLine);
let line = rawLine;
const spanNouns = [];
for (const s of inlineSpans) {
line = line.slice(0, s.start) + ' '.repeat(s.end - s.start) + line.slice(s.end);
const content = s.content.trim();
if (content.length === 0 || /\s/.test(content))
continue;
const segs = content.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (segs.length < 2)
continue; // a bare `api` span is a code identifier
const hit = segs.find((seg) => nounSet.has(seg));
if (hit)
spanNouns.push({ term: hit, start: s.start, end: s.end });
}
// Path-shaped tokens (routes, file names, URLs) are references, not prose.
const { masked, urlNouns } = scanLineTokens(line, nounRe, nounSet);
const clauses = splitClauses(masked);
const extraNouns = urlNouns.concat(spanNouns);
// (a) compound verb+noun — SAME CLAUSE ONLY. There is no word-gap cap (a cap
// cannot tell a long genuine clause from a long internal one) and no
// cross-clause binding (see the note by CLAUSE_BOUNDARY_CHARS): the clause
// boundary is the whole relationship test. Nouns are NOT filtered on
// "internal" qualification here — "integrate the internal API" is a
// fail-closed positive; the declaration dismisses it if wrong.
if (verbRe && nounRe) {
for (const clause of clauses) {
const verbs = collectTermMatches(verbRe, clause.text);
if (verbs.length === 0)
continue;
const nouns = collectTermMatches(nounRe, clause.text);
const nounTerms = new Set(nouns.map((t) => t.term));
for (const u of extraNouns) {
if (u.start >= clause.start && u.end <= clause.start + clause.text.length) {
nounTerms.add(u.term);
}
}
if (nounTerms.size === 0)
continue;
for (const vTerm of new Set(verbs.map((t) => t.term))) {
for (const nTerm of nounTerms)
emitPair(vTerm, nTerm, rawLine);
}
}
}
// (b) explicit <Service> API|SDK|REST|GraphQL surface — scan every candidate
// in every clause (a rejected first candidate must not shadow a later
// genuine service; #2365 review C-1).
for (const clause of clauses) {
surfaceRe.lastIndex = 0;
let m;
while ((m = surfaceRe.exec(clause.text)) !== null) {
const svc = m[1] || '';
const svcLower = svc.toLowerCase();
// Reject capitalized sentence starters ("The API"), locality/protocol
// descriptors ("Internal API", "REST API"), compound modifiers
// ("Resolver-only API"), and services qualified first-party
// ("internal Payments API"). A real vendor name is none of these.
if (SERVICE_STOPWORDS.has(svcLower))
continue;
if (SURFACE_DESCRIPTOR_WORDS.has(svcLower))
continue;
if (COMPOUND_MODIFIER_RE.test(svc))
continue;
if (isInternallyQualified(masked, clause.start + m.index))
continue;
const noun = (m[2] || '').toLowerCase();
const key = `surface+${noun}`;
if (seen.has(key))
continue;
seen.add(key);
signals.push({ verb: '(surface)', noun, snippet: makeSnippet(rawLine, svc) });
}
}
}
return { detected: signals.length > 0, signals, terms: effective };
}
/** True when the word IMMEDIATELY ADJACENT before `offset` is a locality
* descriptor ("internal Payments API") — first-party qualification is negative
* evidence for an EXTERNAL-API signal. Only plain spaces/tabs may separate the
* descriptor from the service: any intervening punctuation means the descriptor
* belongs to a prior clause/sentence and must NOT qualify ("The cache is
* private. Stripe API …" — `private` is a different sentence; #2365 review).
* Looks back through a BOUNDED window, not the whole prefix, to stay linear. */
const QUALIFIER_LOOKBACK = 24; // longest descriptor ("first-party") + separators
function isInternallyQualified(masked, offset) {
const from = offset > QUALIFIER_LOOKBACK ? offset - QUALIFIER_LOOKBACK : 0;
const window = masked.slice(from, offset);
// Only whitespace and markdown emphasis/wrapper markers (`*_~\`) may separate
// the descriptor from the service, so "The **internal** Payments API" still
// qualifies — but NOT a clause/sentence boundary, so "…is private. Stripe API"
// does not (the descriptor is a different sentence; #2365 review).
const m = /([A-Za-z0-9'-]+)[\s*_~`]*$/.exec(window);
if (!m)
return false;
// A word truncated by the window start is not a descriptor match (its real
// start lies before the window) — fail toward detection.
if (from > 0 && m.index === 0 && /[A-Za-z0-9'-]/.test(masked[from - 1]))
return false;
return INTERNAL_DESCRIPTORS.has(m[1].toLowerCase());
}
/** Matches a declaration line such as
* `No external API integration: <reason>` (also `**bold**` and em-dash
* separators). The reason is REQUIRED — a bare declaration does not parse.
* Deliberately NOT matched: blockquoted lines (`> No external …` is quoted
* text, not a declaration) and anything inside fenced code or HTML comments
* (both stripped before the scan; #2365 review C-3). */
const NO_INTEGRATION_DECLARATION_RE = /^\s*(?:\*\*)?no external api integration(?:\*\*)?\s*(?:[:—–-]|--)\s*(\S[^\n]*)$/im;
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
const VALID_DECISIONS = new Set(['INTEGRATE', 'OPT-OUT']);
/**
* Parse a coverage matrix from COVERAGE.md. Accepts two bijective formats:
*
* 1. Markdown table (canonical, human-editable):
* | capability | decision | reason |
* |---|---|---|
* | search | INTEGRATE | |
* | playlists | OPT-OUT | not needed yet |
*
* 2. Fenced ```coverage JSON block (machine-generated):
* ```coverage
* [ {"capability":"search","decision":"INTEGRATE","reason":""}, ... ]
* ```
*
* Rows are trimmed; decisions upper-cased; missing reason → "". Returns
* `{ rows: [], errors: [], format: 'none' }` for empty/non-matrix input.
*/
function parseCoverageMatrix(text) {
const out = { rows: [], errors: [], format: 'none', declaration: null };
if (typeof text !== 'string')
return out;
const src = text.replace(/\r\n/g, '\n');
// #2365 acceptance #5: a "no external API integration" declaration. Scanned
// on fence-stripped, comment-stripped text so an example inside a code block
// or an HTML comment does not count.
const declMatch = NO_INTEGRATION_DECLARATION_RE.exec((0, markdown_sectionizer_cjs_1.stripFencedCode)(src).text.replace(HTML_COMMENT_RE, ''));
if (declMatch) {
out.declaration = { none: true, reason: (declMatch[1] || '').trim() };
}
// (1) fenced ```coverage JSON block takes precedence if present.
// Case-insensitive info string (```coverage and ```Coverage are both legal CommonMark).
const fenceBody = (0, markdown_sectionizer_cjs_1.extractFencedBlock)(src, 'coverage');
if (fenceBody) {
out.format = 'json';
let parsed;
try {
parsed = JSON.parse(fenceBody);
}
catch {
out.errors.push('fenced ```coverage block is not valid JSON');
return out;
}
if (!Array.isArray(parsed)) {
out.errors.push('fenced ```coverage block must be a JSON array');
return out;
}
for (let i = 0; i < parsed.length; i++) {
const row = rowFromJson(parsed[i]);
if ('error' in row) {
out.errors.push(`row[${i}]: ${row.error}`);
continue;
}
out.rows.push(row);
}
return out;
}
// (2) markdown table — collect rows from coverage matrix tables only (#2366).
// Track whether we are inside a recognized coverage matrix (after a header
// row, before a non-pipe line ends the table). This prevents summary tables
// elsewhere in the file from being parsed as data (#2366 bug 1) and allows
// multi-section matrices with repeated headers (#2366 bug 2).
const lines = src.split('\n');
let inMatrix = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('|')) {
inMatrix = false;
continue;
}
const cells = trimmed.slice(1, trimmed.endsWith('|') ? -1 : trimmed.length).split('|');
if (cells.length < 2)
continue;
const cleaned = cells.map((c) => c.trim());
// skip separator rows (|---|---|); require ≥3 dashes so a literal "-" cell
// is not mistaken for a separator.
if (cleaned.every((c) => /^:?-{3,}:?$/.test(c)))
continue;
// Strip markdown emphasis (**, *, __, _, `) from the decision cell before
// comparison so **OPT-OUT** parses correctly (#2366 bug 3).
const decisionCell = (cleaned[1] || '').replace(/[*_`]/g, '').trim().toUpperCase();
// header detection — recognized by 'capability' in column 0; allows multiple
// headers for multi-section matrices (#2366 bug 2).
if (cleaned[0].toLowerCase() === 'capability') {
inMatrix = true;
if (out.format === 'none')
out.format = 'table';
continue;
}
// Only parse data rows from inside a recognized coverage matrix table.
// A pipe-table outside the matrix (e.g., a summary table) is ignored (#2366 bug 1).
if (!inMatrix)
continue;
if (!VALID_DECISIONS.has(decisionCell)) {
// A row that otherwise looks like data (≥3 cells, non-empty capability)
// but carries a malformed decision is a real error, not a row to skip
// silently — otherwise a single typo'd row collapses the matrix to
// "empty" and the user sees a confusing message.
if (cleaned.length >= 3 && cleaned[0]) {
out.errors.push(`row: decision "${decisionCell}" not in {INTEGRATE, OPT-OUT}`);
}
continue;
}
if (out.format === 'none')
out.format = 'table';
// A coverage row has exactly 3 cells. Extra cells mean an unescaped pipe in
// a value silently corrupted the row — surface it rather than parse garbage.
if (cleaned.length > 3) {
out.errors.push(`row: ${cleaned.length} columns (expected 3 — unescaped pipe in a cell?)`);
}
out.rows.push({
capability: cleaned[0] || '',
decision: decisionCell,
reason: (cleaned[2] ?? '').trim(),
});
}
return out;
}
function rowFromJson(v) {
if (!v || typeof v !== 'object' || Array.isArray(v))
return { error: 'not an object' };
const o = v;
const capability = typeof o['capability'] === 'string' ? o['capability'].trim() : '';
if (!capability)
return { error: 'missing/empty "capability"' };
const dRaw = typeof o['decision'] === 'string' ? o['decision'].trim().toUpperCase() : '';
if (!VALID_DECISIONS.has(dRaw)) {
return { error: `decision "${dRaw}" not in {INTEGRATE, OPT-OUT}` };
}
const reason = typeof o['reason'] === 'string' ? o['reason'].trim() : '';
return { capability, decision: dRaw, reason };
}
/**
* Validate a parsed matrix. A matrix is valid when:
* - it is non-empty (acceptance #1: "enumerating the API surface"),
* - every capability name is non-empty,
* - every decision is INTEGRATE or OPT-OUT (enforced by parser, re-checked
* here for defense-in-depth),
* - every OPT-OUT row carries a non-empty reason (acceptance #2).
*
* Un-enumerated remainder is not representable in the format — the gate blocks
* when an integration is detected and NO matrix exists. This validator catches
* a malformed/partial matrix that does exist.
*/
function validateCoverageMatrix(text) {
const parsed = parseCoverageMatrix(text);
const errors = [...parsed.errors];
const rows = parsed.rows;
// #2365 acceptance #5: a reasoned no-integration declaration with no rows
// satisfies the gate. A declaration ALONGSIDE rows is contradictory — the
// file must say one thing.
if (parsed.declaration) {
if (rows.length > 0) {
errors.push('declares "no external API integration" but also contains coverage rows — remove the declaration or the rows');
}
else {
if (parsed.declaration.reason.length > REASON_MAX_LEN) {
errors.push(`declaration reason exceeds ${REASON_MAX_LEN} chars`);
}
const valid = errors.length === 0;
return {
valid,
errors,
counts: { surface: 0, integrate: 0, optout: 0 },
none_declared: valid,
};
}
}
if (rows.length === 0) {
if (errors.length === 0)
errors.push('matrix is empty — no capabilities enumerated');
return { valid: false, errors, counts: { surface: 0, integrate: 0, optout: 0 } };
}
const seen = new Set();
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row.capability) {
errors.push(`row[${i}]: empty capability name`);
}
else {
// Format contract + prompt-injection bound: cell values must be short,
// single-line, pipe-free prose (the matrix is a markdown table whose
// content flows into the gate message). Pipes/newlines would corrupt the
// table and let a COVERAGE.md inject unbounded text into the seal message.
if (/[|\n\r]/.test(row.capability)) {
errors.push(`row[${i}]: capability contains a pipe or newline (unsupported in a table cell)`);
}
if (row.capability.length > CAPABILITY_MAX_LEN) {
errors.push(`row[${i}]: capability exceeds ${CAPABILITY_MAX_LEN} chars`);
}
}
if (row.reason && /[|\n\r]/.test(row.reason)) {
errors.push(`row[${i}]: reason contains a pipe or newline (unsupported in a table cell)`);
}
if (row.reason.length > REASON_MAX_LEN) {
errors.push(`row[${i}]: reason exceeds ${REASON_MAX_LEN} chars`);
}
const key = row.capability.toLowerCase();
if (key && seen.has(key))
errors.push(`row[${i}]: duplicate capability`);
if (key)
seen.add(key);
if (!VALID_DECISIONS.has(row.decision)) {
errors.push(`row[${i}]: decision not in {INTEGRATE, OPT-OUT}`);
}
if (row.decision === 'OPT-OUT' && !row.reason) {
errors.push(`row[${i}]: OPT-OUT missing reason`);
}
}
const counts = {
surface: rows.length,
integrate: rows.filter((r) => r.decision === 'INTEGRATE').length,
optout: rows.filter((r) => r.decision === 'OPT-OUT').length,
};
return { valid: errors.length === 0, errors, counts };
}
/** Render rows back to the canonical markdown-table format (bijective with parse). */
function renderCoverageMatrix(rows) {
const body = rows
.map((r) => `| ${r.capability} | ${r.decision} | ${r.reason} |`)
.join('\n');
return `| capability | decision | reason |\n|---|---|---|\n${body}`;
}
// ── CLI entry point ──────────────────────────────────────────────────────────
// Reads phase-scope text from STDIN (not argv) to avoid OS ARG_MAX limits.
// Invoked by workflow bash as: echo "$SCOPE" | node .../api-coverage.cjs [--json]
// Exit 0 = integration detected, 1 = none, 2 = startup error. Mirrors
// assumption-delta.cjs / ui-safety-gate.cjs.
if (require.main === module) {
const argv = process.argv.slice(2);
const wantJson = argv.includes('--json');
let termsOverride;
const verbsIdx = argv.indexOf('--verbs');
const verbsVal = verbsIdx !== -1 ? argv[verbsIdx + 1] : undefined;
const nounsIdx = argv.indexOf('--nouns');
const nounsVal = nounsIdx !== -1 ? argv[nounsIdx + 1] : undefined;
// A non-empty, non-flag value is an override. An EMPTY value ("") restores
// the curated defaults (does NOT silently zero the vocabulary).
const verbsOverride = typeof verbsVal === 'string' && verbsVal.length > 0 && !verbsVal.startsWith('-');
const nounsOverride = typeof nounsVal === 'string' && nounsVal.length > 0 && !nounsVal.startsWith('-');
if (verbsOverride || nounsOverride) {
termsOverride = {};
if (verbsOverride) {
termsOverride.verbs = verbsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
}
if (nounsOverride) {
termsOverride.nouns = nounsVal.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
}
}
const chunks = [];
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', () => {
const input = chunks.join('');
const result = detectApiIntegration(input, termsOverride);
if (wantJson) {
process.stdout.write(JSON.stringify(result) + '\n');
}
process.exit(result.detected ? 0 : 1);
});
process.stdin.on('error', (err) => {
process.stderr.write(`ERROR: api-coverage.cjs stdin read failed: ${err.message}\n`);
process.exit(2);
});
}

View File

@@ -1,231 +0,0 @@
"use strict";
/**
* Assumption-Delta detector (#1561).
*
* A rarely-firing, advisory architecture checkpoint. When a phase makes
* something PLURAL / OPTIONAL / CHOSEN that used to be SINGULAR / REQUIRED /
* DERIVED, the primary key / identity model may silently stop matching the
* generalized intent. This detector scans phase-scope prose for the linguistic
* signals of that transition so the plan:pre capability hook (see
* capabilities/assumption-delta/) can surface ONE identity-model question.
*
* Design notes (rubber-duck'd):
* - DETERMINISTIC + TYPED IR. The "does it fire?" decision is a pure function
* returning { detected, signals, terms }, not an LLM judgment — so the
* low-false-positive guarantee (acceptance criterion #2) is testable.
* - BARE "or" IS INTENTIONALLY EXCLUDED from the default pluralization cues.
* The issue lists "or" as a tell, but bare "or" is extremely common in
* English prose and would make the gate fire on nearly every phase
* description. Pluralization requires a stronger second-case cue
* (second / alternative / fallback / additional / ...). The vocabulary is
* tunable (config + the `terms` parameter) so teams can widen it.
* - FENCED CODE BLOCKS ARE STRIPPED first (via the markdown-sectionizer seam)
* so a trigger term that appears only inside a code snippet does not fire.
* - Mirrors ui-safety-gate.cts: a pure function + a STDIN-reading CLI whose
* exit codes mirror grep (0 = signal found, 1 = none, 2 = usage error).
*
* Public API:
* detectAssumptionDelta(text, terms?) -> { detected, signals, terms }
* DEFAULT_ASSUMPTION_DELTA_TERMS
*
* CLI:
* echo "$PHASE_SECTION" | node gsd-core/bin/lib/assumption-delta.cjs [--json]
* exit 0 = signal detected, 1 = none, 2 = startup error
* --json additionally prints the typed IR on stdout
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_ASSUMPTION_DELTA_TERMS = void 0;
exports.detectAssumptionDelta = detectAssumptionDelta;
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
/**
* Curated default trigger vocabulary. Each kind lists cue terms that signal a
* core-assumption monopoly has been lost. ADDITIVE-ONLY (Hyrum's Law: once
* shipped, this set is a depended-upon interface). Tunable via the `terms`
* parameter or the capability's config slice.
*/
exports.DEFAULT_ASSUMPTION_DELTA_TERMS = {
// Primary trigger — a second X where there was one.
// Bare "or" excluded (prose-frequency false positives).
pluralization: [
'second',
'alternative',
'alternate',
'fallback',
'also',
'additional',
'another',
'supplementary',
'alongside',
'multiple',
'plural',
'2nd',
],
// required / `only` -> optional
optional: ['optional', 'optionally'],
// derived -> chosen / constant -> parameter
chosen: [
'chosen',
'choose',
'selectable',
'configurable',
'parameterized',
'parameterised',
'parameterize',
'parameterise',
'custom',
],
};
/** Hardening caps for the tunable term vocabulary (Codex review finding). */
const MAX_TERMS_PER_KIND = 200;
const MAX_TERM_LEN = 32;
/**
* Normalize a caller-provided term list: trim, lowercase, reject empties and
* punctuation-only terms (e.g. "-"), dedupe (preserve order), and cap the
* count/length so a huge or hostile `--terms` value cannot build a giant
* alternation regex or echo a massive payload. Defaults are already clean, so
* this is a no-op on them.
*/
function normalizeTerms(list) {
if (!Array.isArray(list))
return [];
const seen = new Set();
const out = [];
for (const raw of list) {
if (typeof raw !== 'string')
continue;
const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN);
// Require at least one alphanumeric char so punctuation-only terms like
// "-" cannot match prose punctuation as a "signal".
if (!t || !/[a-z0-9]/.test(t))
continue;
if (seen.has(t))
continue;
seen.add(t);
out.push(t);
if (out.length >= MAX_TERMS_PER_KIND)
break;
}
return out;
}
/**
* Resolve the effective term set: per-kind override. An explicitly-provided
* non-empty array for a kind REPLACES that kind's defaults (then normalized);
* an absent kind KEEPS its defaults. An explicitly-empty array disables that
* kind (override present, normalized to []). This lets a caller narrow one axis
* without re-declaring the others.
*/
function resolveTerms(terms) {
const merge = (key) => {
const t = terms && terms[key];
return Array.isArray(t) ? normalizeTerms(t) : [...exports.DEFAULT_ASSUMPTION_DELTA_TERMS[key]];
};
return {
pluralization: merge('pluralization'),
optional: merge('optional'),
chosen: merge('chosen'),
};
}
/** Trim + collapse + truncate a context window around a match for the snippet. */
function makeSnippet(line, term) {
const cleaned = line.replace(/\s+/g, ' ').trim();
if (cleaned.length <= 120)
return cleaned;
// Centre the window on the matched term when the line is long.
const idx = cleaned.toLowerCase().indexOf(term);
if (idx < 0)
return cleaned.slice(0, 120);
const start = Math.max(0, idx - 50);
const end = Math.min(cleaned.length, idx + term.length + 50);
const prefix = start > 0 ? '…' : '';
const suffix = end < cleaned.length ? '…' : '';
return `${prefix}${cleaned.slice(start, end)}${suffix}`;
}
/**
* Detect assumption-delta signals in phase-scope prose.
*
* @param text - Roadmap phase section / scope prose. Non-string inputs degrade
* to `{ detected: false }` without throwing.
* @param terms - Optional per-kind override (see resolveTerms).
* @returns typed IR: { detected, signals[], terms }. `terms` is the effective
* (merged) set actually used, so callers/tests can audit what fired.
*/
function detectAssumptionDelta(text, terms) {
if (typeof text !== 'string') {
return { detected: false, signals: [], terms: resolveTerms(terms) };
}
const effective = resolveTerms(terms);
// Strip fenced code blocks so trigger terms inside code snippets do not fire.
// stripFencedCode is CommonMark-correct and CRLF-safe.
const stripped = (0, markdown_sectionizer_cjs_1.stripFencedCode)(text.replace(/\r\n/g, '\n')).text;
if (stripped.trim().length === 0) {
return { detected: false, signals: [], terms: effective };
}
const signals = [];
const kinds = ['pluralization', 'optional', 'chosen'];
for (const kind of kinds) {
const cueTerms = effective[kind];
if (cueTerms.length === 0)
continue;
// Word-boundary anchored, case-insensitive — same shape as ui-safety-gate.
// (^|[^a-zA-Z0-9])(TERM)([^a-zA-Z0-9]|$) prevents interior-substring matches.
const escaped = cueTerms.map(escapeRegex).join('|');
const pattern = new RegExp('(^|[^a-zA-Z0-9])(' + escaped + ')([^a-zA-Z0-9]|$)', 'gi');
const seen = new Set();
for (const line of stripped.split('\n')) {
pattern.lastIndex = 0;
for (const m of line.matchAll(pattern)) {
const raw = m[2];
if (!raw)
continue;
const matched = raw.toLowerCase();
const key = `${kind}:${matched}`;
if (seen.has(key))
continue;
seen.add(key);
signals.push({ kind, term: matched, snippet: makeSnippet(line, matched) });
}
}
}
return { detected: signals.length > 0, signals, terms: effective };
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ── CLI entry point ──────────────────────────────────────────────────────────
// Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits.
// Invoked by workflow bash as: echo "$PHASE_SECTION" | node .../assumption-delta.cjs [--json]
// Exit 0 = signal detected, 1 = none, 2 = startup error. Mirrors ui-safety-gate.
if (require.main === module) {
const argv = process.argv.slice(2);
const wantJson = argv.includes('--json');
// --terms <csv>: config-tunable vocabulary override. Replaces the
// pluralization cues (the primary trigger); optional/chosen keep defaults.
// An EMPTY value ("") or a flag-shaped value restores the curated defaults
// (does NOT disable pluralization). Terms are normalized (deduped, etc.) by
// detectAssumptionDelta's resolveTerms.
let termsOverride;
const termsIdx = argv.indexOf('--terms');
const termsVal = termsIdx !== -1 ? argv[termsIdx + 1] : undefined;
if (typeof termsVal === 'string' && !termsVal.startsWith('-')) {
const list = termsVal
.split(',')
.map((t) => t.trim().toLowerCase())
.filter((t) => t.length > 0);
termsOverride = list.length > 0 ? { pluralization: list } : undefined;
}
const chunks = [];
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => chunks.push(chunk));
process.stdin.on('end', () => {
const input = chunks.join('');
const result = detectAssumptionDelta(input, termsOverride);
if (wantJson) {
process.stdout.write(JSON.stringify(result) + '\n');
}
process.exit(result.detected ? 0 : 1);
});
process.stdin.on('error', (err) => {
process.stderr.write(`ERROR: assumption-delta.cjs stdin read failed: ${err.message}\n`);
process.exit(2);
});
}

View File

@@ -1,314 +0,0 @@
"use strict";
/**
* Claude orchestration command router — CLI dispatcher for
* `gsd-tools claude-orchestration <subcommand>`.
*
* #1143 — thin CLI adapter over the pure `claude-orchestration.cjs` module.
* Lets execute-phase (or any orchestrator) invoke the Workflow-backend
* detection and the Workflow-script emitter through the standard capability
* command surface (ADR-959) instead of a bare `require()`.
*
* Router signature: { args, cwd, raw, error } — identical to the other host
* routers; discovered by dispatchCapabilityCommand via the registry's
* commandFamilies index.
*
* Subcommands:
* detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]
* Resolves whether the Workflow backend should activate. Both flags are
* OPTIONAL (#2590): `--runtime` falls back to the canonical
* `GSD_RUNTIME > config.runtime > 'claude'` chain, and
* `--agent-sdk-version` to `GSD_AGENT_SDK_VERSION` then the installed
* @anthropic-ai/claude-agent-sdk version. Reads the
* `claude_orchestration.*` keys from .planning/config.json. Emits
* { available, backend, reason }.
*
* emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]
* Reads a wave/plan manifest JSON file and emits the generated Workflow
* script + summary. The manifest shape matches emitWorkflowScript's input:
* { waves: [{ id, plans: [{ id, brief, files_modified: string[], use_worktree?: boolean }] }] }.
* `use_worktree` defaults to true; pass `false` for a plan the inline path
* (execute-phase.md step 2.5) would also keep out of worktree isolation
* (submodule-touching plans — #2772 / #2285 finding 1).
*
* resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>]
* [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>]
* [--budget <n>] [--executor-model <id>]
* #2285 — the single composed seam a PRE-wave dispatch-backend selector
* (`execute:wave:pre`) uses: resolves detect-backend + emit-workflow in
* ONE call. Emits { backend: 'inline'|'workflow', reason, script?, summary? }.
* Fail-closed identically to detect-backend/emit-workflow individually —
* any gate miss, or an emit failure on a malformed --waves manifest,
* resolves to 'inline' with no script.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const node_fs_1 = __importDefault(require("node:fs"));
const node_path_1 = __importDefault(require("node:path"));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const io = require("./io.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const core = require("./claude-orchestration.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const configLoader = require("./config-loader.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports
const runtimeSlash = require("./runtime-slash.cjs");
// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
const modelResolver = require("./model-resolver.cjs");
const { output } = io;
const { detectWorkflowBackend, emitWorkflowScript, resolveWaveDispatch } = core;
const CAPABLE_HOST = { dispatch: { nested: true, background: true } };
function usage(error) {
error('Usage: gsd-tools claude-orchestration <detect-backend|emit-workflow|resolve-wave-dispatch> [...]\n' +
' detect-backend [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch]\n' +
' emit-workflow --waves <path> --run-id <id> [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]\n' +
' resolve-wave-dispatch --waves <path> --run-id <id> [--runtime <id>] [--agent-sdk-version <ver>] [--no-nested-dispatch] [--phase-dir <dir>] [--budget <n>] [--executor-model <id>]');
}
function argValue(args, flag) {
const i = args.indexOf(flag);
return i !== -1 && i + 1 < args.length ? args[i + 1] : undefined;
}
/**
* Resolve the `claude_orchestration.*` config slice from the project config
* (federated keys are merged by loadConfig as a nested object), flattened into
* the dotted-key shape `detectWorkflowBackend`/`resolveWaveDispatch` expect. A
* config read failure degrades to an empty slice — it must not break the core
* loop. Shared by `detect-backend` and `resolve-wave-dispatch`.
*/
function resolveFlatClaudeOrchestrationConfig(cwd) {
let claudeSlice = {};
try {
const loaded = configLoader.loadConfig(cwd);
const slice = loaded['claude_orchestration'];
if (slice && typeof slice === 'object' && !Array.isArray(slice)) {
claudeSlice = slice;
}
}
catch {
claudeSlice = {};
}
const flatConfig = {};
for (const k of Object.keys(claudeSlice)) {
flatConfig['claude_orchestration.' + k] = claudeSlice[k];
}
return flatConfig;
}
/**
* Resolve the installed Agent SDK version (#2590).
*
* The `execute:wave:pre` fragment claimed the orchestrator "has no scriptable
* way to introspect the live Agent SDK version" and told callers to omit the
* flag — so gate 5 returned `agent_sdk_version_unknown` on every automated run
* and the Workflow backend never activated, while `capability state` still
* reported it `active: true`. That claim is true for BASH, but this router runs
* in Node: the installed package's own package.json is authoritative and
* requires no flag at all.
*
* Resolution is side-effect-free and fails closed to undefined (gate 5 then
* declines, exactly as before) rather than guessing a version.
*/
const AGENT_SDK_PKG = node_path_1.default.join('@anthropic-ai', 'claude-agent-sdk', 'package.json');
function resolveInstalledAgentSdkVersion(cwd) {
// Walk node_modules up the tree by hand rather than require.resolve: the SDK's
// `exports` map does not expose './package.json', so require.resolve throws
// ERR_PACKAGE_PATH_NOT_EXPORTED. Reading the file directly is exports-map
// independent and cannot execute package code.
for (const start of [cwd, __dirname]) {
let dir;
try {
dir = node_path_1.default.resolve(start);
}
catch {
continue;
}
for (;;) {
try {
const pkgPath = node_path_1.default.join(dir, 'node_modules', AGENT_SDK_PKG);
if (node_fs_1.default.existsSync(pkgPath)) {
const parsed = JSON.parse(node_fs_1.default.readFileSync(pkgPath, 'utf8'));
if (typeof parsed.version === 'string' && parsed.version.length > 0)
return parsed.version;
}
}
catch { /* unreadable/malformed — keep walking */ }
const parent = node_path_1.default.dirname(dir);
if (parent === dir)
break;
dir = parent;
}
}
return undefined;
}
/**
* Resolve `--runtime`/`--agent-sdk-version`/`--no-nested-dispatch` into the
* `{ runtimeId, hostIntegration, agentSdkVersion }` triple both `detect-backend`
* and `resolve-wave-dispatch` pass to the pure detection seam.
*/
function resolveDetectionArgs(args, cwd) {
// #2590: the old fallback chain was `--runtime > GSD_RUNTIME > 'unknown'`,
// diverging from the canonical `GSD_RUNTIME > config.runtime > 'claude'` used
// by runtime-slash.resolveRuntime — so ANY manual invocation without
// --runtime reported `runtime_not_claude` on a perfectly ordinary Claude
// project. Delegate to the canonical resolver instead of re-deriving it.
const runtimeId = argValue(args, '--runtime') || runtimeSlash.resolveRuntime(cwd || null);
// Explicit flag wins (lets a caller pin a version); then the environment;
// then the actually-installed SDK.
const agentSdkVersion = argValue(args, '--agent-sdk-version')
|| process.env['GSD_AGENT_SDK_VERSION']
|| resolveInstalledAgentSdkVersion(cwd || process.cwd());
const noNested = args.includes('--no-nested-dispatch');
const hostIntegration = noNested ? { dispatch: { nested: false, background: true } } : CAPABLE_HOST;
return { runtimeId, hostIntegration, agentSdkVersion };
}
/**
* #2686 — resolve the `gsd-executor` model this dispatch should carry.
*
* Defaults from the project config rather than requiring a flag. The Workflow
* backend previously emitted no model at all, so `model_overrides` /
* `model_policy` / `model_profile` were silently inert on that path while the
* inline path honored them. Reading the same source the inline path reads is
* what makes the two backends agree by construction: an orchestrator that never
* learns about a new flag would otherwise silently keep the old bug.
*
* `--executor-model` exists only to pin/override. Resolution is side-effect-free
* and fails closed to `undefined` (emission then omits the key, i.e. exactly the
* pre-#2686 output) rather than guessing a model.
*/
function resolveExecutorModel(args, cwd) {
const pinned = argValue(args, '--executor-model');
if (pinned !== undefined)
return pinned;
try {
const resolved = modelResolver.resolveModelInternal(cwd, 'gsd-executor');
return typeof resolved === 'string' ? resolved : undefined;
}
catch {
return undefined;
}
}
/**
* Read and parse a `--waves <path>` manifest file.
*
* #2285 finding 2: a real read/parse failure (`ok:false`) is DISTINCT from a
* manifest that parsed fine but has no top-level `waves` key (`ok:true, waves:
* undefined`) — collapsing both into the same sentinel made the missing-key
* case exit 0 with ZERO output (fail-silent), breaking the "exit 0 => parseable
* JSON verdict" contract callers rely on. Only the `ok:false` (read/parse threw)
* case calls `error(...)` and should short-circuit the caller; `ok:true` with a
* missing/malformed `waves` value must flow through to `emitWorkflowScript`'s
* own validation (matching how `{"waves": null}` already behaves) so the caller
* emits an explicit, non-empty verdict instead of silently doing nothing.
*/
function readWavesManifest(wavesPath, error) {
try {
const content = node_fs_1.default.readFileSync(node_path_1.default.resolve(wavesPath), 'utf8');
const parsed = JSON.parse(content);
return { ok: true, waves: parsed['waves'] };
}
catch (e) {
error('could not read/parse --waves file "' + wavesPath + '": ' + (e instanceof Error ? e.message : String(e)));
return { ok: false };
}
}
/**
* Detect whether the Workflow backend should activate for the current/given
* runtime. Reads `claude_orchestration.*` from the project config; runtime and
* SDK version come from flags (the orchestrator already knows these) or env.
*/
function cmdDetectBackend(args, cwd, raw) {
const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args, cwd);
const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
const result = detectWorkflowBackend({ runtimeId, hostIntegration, config: flatConfig, agentSdkVersion });
output(result, raw);
}
/**
* Emit a Workflow script from a wave/plan manifest file.
*/
function cmdEmitWorkflow(args, cwd, raw, error) {
const wavesPath = argValue(args, '--waves');
const runId = argValue(args, '--run-id');
const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
const budgetRaw = argValue(args, '--budget');
if (!wavesPath) {
error('emit-workflow requires --waves <path>');
return;
}
if (!runId) {
error('emit-workflow requires --run-id <id>');
return;
}
const read = readWavesManifest(wavesPath, (msg) => error('emit-workflow: ' + msg));
if (!read.ok)
return; // read/parse failure — error() already surfaced it loudly above
const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
const result = emitWorkflowScript({
phaseDir,
runId,
waves: read.waves,
budgetTokens: budget,
executorModel: resolveExecutorModel(args, cwd),
});
if (!result.ok) {
error('emit-workflow: ' + result.reason);
return;
}
output({ script: result.script, summary: result.summary }, raw);
}
/**
* #2285 — the single composed seam a PRE-wave dispatch-backend selector
* (`execute:wave:pre`) uses: resolves `detect-backend` + `emit-workflow` in
* ONE call via `resolveWaveDispatch`. Emits
* `{ backend: 'inline'|'workflow', reason, script?, summary? }`.
*/
function cmdResolveWaveDispatch(args, cwd, raw, error) {
const wavesPath = argValue(args, '--waves');
const runId = argValue(args, '--run-id');
const phaseDir = argValue(args, '--phase-dir') || '.planning/phases/current';
const budgetRaw = argValue(args, '--budget');
if (!wavesPath) {
error('resolve-wave-dispatch requires --waves <path>');
return;
}
if (!runId) {
error('resolve-wave-dispatch requires --run-id <id>');
return;
}
const read = readWavesManifest(wavesPath, (msg) => error('resolve-wave-dispatch: ' + msg));
if (!read.ok)
return; // read/parse failure — error() already surfaced it loudly above
const { runtimeId, hostIntegration, agentSdkVersion } = resolveDetectionArgs(args, cwd);
const flatConfig = resolveFlatClaudeOrchestrationConfig(cwd);
const budgetTokens = budgetRaw !== undefined ? parseInt(budgetRaw, 10) : undefined;
const budget = (typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens)) ? budgetTokens : undefined;
const result = resolveWaveDispatch({
runtimeId,
hostIntegration,
config: flatConfig,
agentSdkVersion,
phaseDir,
runId,
waves: read.waves,
budgetTokens: budget,
executorModel: resolveExecutorModel(args, cwd),
});
output(result, raw);
}
function routeClaudeOrchestrationCommand(opts) {
const { args, cwd, raw, error } = opts;
// args[0] is the family ('claude-orchestration'); the subcommand is args[1].
const subcommand = args[1];
if (subcommand === 'detect-backend') {
cmdDetectBackend(args, cwd, raw);
}
else if (subcommand === 'emit-workflow') {
cmdEmitWorkflow(args, cwd, raw, error);
}
else if (subcommand === 'resolve-wave-dispatch') {
cmdResolveWaveDispatch(args, cwd, raw, error);
}
else {
usage(error);
}
}
module.exports = { routeClaudeOrchestrationCommand };

View File

@@ -1,582 +0,0 @@
"use strict";
/**
* Claude Orchestration Capability — Workflow-tool backend detection + emitter
*
* #1143 — adopts Claude Code's Workflow tool (the engine behind `/effort ultracode`)
* as an optional, runtime-gated parallel-execution backend for the GSD loop.
*
* This module is the pure, testable core of the capability. It owns two seams:
*
* detectWorkflowBackend({ runtimeId, hostIntegration, config, agentSdkVersion })
* → { available: boolean, backend: 'workflow'|'inline', reason: string }
* Fail-closed: every miss degrades to `inline` (today's behaviour), so the
* core loop is byte-identical unless every gate opens. This is criteria 3 + 6.
*
* emitWorkflowScript({ phaseDir, waves, runId, budgetTokens? })
* → { ok:true, script, summary } | { ok:false, reason }
* Maps GSD's wave/plan model 1:1 onto Workflow primitives:
* wave → sequential `parallel()` stage barriers,
* plan → `agent(brief, { agentType:'gsd-executor', isolation:'worktree' })`
* — UNLESS the plan's `use_worktree` is explicitly `false`, in which case
* `isolation` is omitted entirely for that plan (#2772 / #2285 finding 1:
* a submodule-touching plan must never be forced into worktree isolation
* the inline path (execute-phase.md step 2.5) would keep it out of),
* files_modified overlap → forces plans into separate sequential stages
* (the same overlap rule execute-phase already applies inline),
* resumeFromRunId → wired to the phase run id,
* budgetTokens → a shared token pool.
* The emitted script composes the SAME gsd-executor agent the inline path
* uses, with per-plan worktree isolation mirroring the inline path's own
* per-plan decision, so it produces the same artifacts/commits (criterion 2).
* It is a generated string consumed by the orchestrator; this module never
* invokes the Workflow tool itself.
*
* Design laws:
* - Gall's Law: ship a small working slice that composes existing primitives
* (gsd-executor + worktree isolation) rather than reinventing them.
* - Greenspun's Tenth Rule (cited in #1143): adopt the Workflow tool's
* barrier/pipeline/budget/resume semantics instead of hand-rolling them.
* - Postel's Law: liberal in input (missing fields → inline), conservative in
* output (workflow only when every gate opens).
* - Fail-closed: an unknown version, a missing descriptor, or a disabled
* toggle all resolve to `inline`, never to `workflow`.
*
* Zero external dependencies. Pure functions. Never throws on bad input.
*/
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* The Agent SDK version that introduced the Workflow tool (#1143 prior art).
* Used as the default floor when config does not override it. A runtime reporting
* an agentSdkVersion below this cannot host the Workflow backend.
*/
const WORKFLOW_TOOL_FLOOR_VERSION = '0.3.149';
/** Closed enum for the `claude_orchestration.execution_backend` config key. */
const BACKEND_VALUES = new Set(['auto', 'workflow', 'inline']);
/** Only this runtime can host the Workflow tool (Claude Code / Agent SDK). */
const WORKFLOW_RUNTIME = 'claude';
// ─── Semver helpers ───────────────────────────────────────────────────────────
/** Official-ish strict SemVer 2.0.0 numeric triple (+ optional pre/build). */
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
/** True for a syntactically valid semver string. */
function isValidSemver(s) {
return typeof s === 'string' && SEMVER_RE.test(s);
}
/**
* Compare two semver strings.
* Returns -1/0/1 in the usual sense. Garbage in either position → -1 (fail-closed:
* an unparseable version is treated as "less than" any real floor, so detection
* never accidentally enables the preview backend on an unknown SDK).
*
* Pre-release/build metadata are ignored for the comparison — only the numeric
* major.minor.patch triple participates, matching how the Workflow-tool floor is
* specified (a plain "0.3.149").
*/
function compareSemver(a, b) {
if (!isValidSemver(a) || !isValidSemver(b))
return -1;
// Split numeric triple from pre-release/build metadata.
const parseTriple = (s) => {
const core = s.split('-')[0].split('+')[0].split('.');
return [parseInt(core[0], 10), parseInt(core[1], 10), parseInt(core[2], 10)];
};
const hasPre = (s) => s.indexOf('-') !== -1;
const preIdentifiers = (s) => (s.split('-')[1] || '').split('+')[0].split('.').filter((x) => x.length > 0);
const am = parseTriple(a);
const bm = parseTriple(b);
for (let i = 0; i < 3; i++) {
if (am[i] < bm[i])
return -1;
if (am[i] > bm[i])
return 1;
}
// Numeric triple is equal. SemVer 2.0.0 §11 precedence:
// - a version WITH a pre-release tag is LOWER than the same triple WITHOUT one
// (keeps the floor fail-closed for pre-release builds of the GA floor);
// - two pre-releases of the same triple are ordered by their dot-separated
// identifiers (numeric < alphanumeric; numeric compared numerically,
// alphanumeric lexically; fewer identifiers < more).
const aPre = hasPre(a);
const bPre = hasPre(b);
if (aPre && !bPre)
return -1;
if (!aPre && bPre)
return 1;
if (aPre && bPre) {
const ai = preIdentifiers(a);
const bi = preIdentifiers(b);
const len = Math.min(ai.length, bi.length);
for (let i = 0; i < len; i++) {
const ax = ai[i];
const bx = bi[i];
const aNum = /^\d+$/.test(ax);
const bNum = /^\d+$/.test(bx);
if (aNum && bNum) {
const an = parseInt(ax, 10);
const bn = parseInt(bx, 10);
if (an < bn)
return -1;
if (an > bn)
return 1;
}
else if (aNum && !bNum) {
return -1; // numeric identifiers always lower than alphanumeric
}
else if (!aNum && bNum) {
return 1;
}
else {
if (ax < bx)
return -1;
if (ax > bx)
return 1;
}
}
if (ai.length < bi.length)
return -1;
if (ai.length > bi.length)
return 1;
}
return 0;
}
/** Inline result shorthand. */
function inline(reason, available = false) {
return { available, backend: 'inline', reason };
}
/**
* Resolve whether the Workflow-tool backend should activate.
*
* Gate ladder (all must pass for `workflow`; first miss wins, fail-closed):
* 1. capability enabled (claude_orchestration.enabled truthy)
* 2. runtime is Claude (the only runtime that exposes the Workflow tool)
* 3. execution_backend !== 'inline'
* 4. host descriptor signals nested+background dispatch (Workflow-tool capable)
* 5. agentSdkVersion is a known, valid semver
* 6. agentSdkVersion >= the configured floor (default WORKFLOW_TOOL_FLOOR_VERSION)
* 7. execution_backend === 'workflow' OR 'auto' (both reach here; 'inline' exited at 3)
*
* Never throws. Destructures defensively.
*/
function detectWorkflowBackend(input) {
if (input === null || input === undefined || typeof input !== 'object') {
return inline('capability_disabled');
}
const cfg = (input.config !== null && input.config !== undefined && typeof input.config === 'object')
? input.config
: {};
// 1. capability must be opted in (default-off — ships disabled).
if (!cfg['claude_orchestration.enabled']) {
return inline('capability_disabled');
}
// 2. only Claude can host the Workflow tool.
if (input.runtimeId !== WORKFLOW_RUNTIME) {
return inline('runtime_not_claude');
}
// 3. explicit inline opt-out short-circuits.
let backendRaw = cfg['claude_orchestration.execution_backend'];
if (typeof backendRaw !== 'string' || !BACKEND_VALUES.has(backendRaw)) {
backendRaw = 'auto';
}
if (backendRaw === 'inline') {
return inline('backend_inline');
}
// 4. the host dispatch descriptor must be the nesting-capable Claude-Code shape
// (a proxy for Workflow-tool presence). This is Claude-specific and already
// gated at step 2; `background:true` alone is true on several non-Claude hosts,
// so the proxy is only meaningful after the runtime check above. Note: this is
// NOT the canonical `shouldFlattenDispatch` rule (which keys on
// `backgroundDispatch`); the Workflow backend works precisely because a single
// tool-call orchestrates internally, sidestepping the backgroundDispatch:false
// limitation. Missing/false/foreign descriptor → fail-closed.
const hi = input.hostIntegration;
if (hi === null || hi === undefined || typeof hi !== 'object' || Array.isArray(hi)) {
return inline('workflow_tool_unavailable');
}
const dispatch = hi.dispatch;
if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(dispatch)) {
return inline('workflow_tool_unavailable');
}
const nested = dispatch['nested'];
const background = dispatch['background'];
if (nested !== true || background !== true) {
return inline('workflow_tool_unavailable');
}
// 5. an unknown agentSdkVersion cannot be trusted to meet the floor.
if (!isValidSemver(input.agentSdkVersion)) {
return inline('agent_sdk_version_unknown');
}
// 6. version floor (config override > default constant).
const floorRaw = cfg['claude_orchestration.min_agent_sdk_version'];
const floor = typeof floorRaw === 'string' && isValidSemver(floorRaw) ? floorRaw : WORKFLOW_TOOL_FLOOR_VERSION;
if (compareSemver(input.agentSdkVersion, floor) < 0) {
return inline('agent_sdk_version_below_floor');
}
// 7. auto/workflow both reach the workflow backend once every gate passes.
return { available: true, backend: 'workflow', reason: 'workflow_backend_active' };
}
/**
* Partition a wave's plans into a near-minimal number of sequential stages (via
* greedy first-fit — not guaranteed optimal for arbitrary overlap graphs, but
* correct: no two plans sharing a file ever cohabit a stage) such that no two
* plans in the same stage share a modified file. Each plan goes into the earliest
* stage where it does not overlap any plan already there.
*
* A plan with an EMPTY files_modified set declares no files; it overlaps nothing
* and coalesces into stage 0 (same behavior as the inline path, which also cannot
* guard against undeclared concurrent writes — declare filesModified accurately).
*
* This is the same overlap rule execute-phase applies inline — the only difference
* is the execution vehicle (Workflow `parallel()` vs one-agent-per-message).
*/
function partitionStages(plans) {
const stages = [];
for (const plan of plans) {
const fileSet = new Set(plan.files_modified);
let placed = false;
for (const stage of stages) {
let overlap = false;
for (const f of fileSet) {
if (stage.files.has(f)) {
overlap = true;
break;
}
}
if (!overlap) {
stage.plans.push(plan);
for (const f of fileSet)
stage.files.add(f);
placed = true;
break;
}
}
if (!placed) {
stages.push({ plans: [plan], files: new Set(fileSet) });
}
}
return stages.map((s) => s.plans.map((p) => p.id));
}
/**
* Quote a free-text value for safe embedding as a JavaScript/Workflow double-quoted
* string literal. Uses JSON.stringify so every JS-relevant escape (backslash, quote,
* newline, tab, NUL, U+2028/U+2029, all control chars) is handled by the language
* itself — there is no hand-rolled escape table to drift. Returns the value already
* wrapped in its surrounding quotes.
*/
function quoteString(s) {
return JSON.stringify(s);
}
/**
* #2686 — the single decision of whether a resolved executor model is emittable,
* and what to emit. Shared by `agentOptions` (the emission) and the provenance
* comment (the claim about it) so the two can never disagree — a generated
* comment asserting something the generator does not actually do is the exact
* failure #2686 was filed for.
*
* Returns the model to emit, or `undefined` for "emit nothing":
* - non-string → malformed config; omit rather than throw, matching the
* defensive typeof guard `mapClaudeOverrideForRuntime`
* already carries in model-resolver for the same reason.
* - empty/whitespace → #2517: emitting `model: ""` 404s on runtimes without
* native tier aliases. Trimmed, so `" "` is also "none".
* - "inherit" → same rule; matched case-insensitively after trimming,
* since config is user-authored free text.
*
* NOTE it does NOT reject unscriptable characters — that is a hard input error,
* not a silent omission, and is rejected up front by `emitWorkflowScript` so the
* caller sees a reason instead of quietly losing their model routing.
*/
function emittableModel(executorModel) {
if (typeof executorModel !== 'string')
return undefined;
const trimmed = executorModel.trim();
if (trimmed.length === 0)
return undefined;
if (trimmed.toLowerCase() === 'inherit')
return undefined;
return trimmed;
}
/**
* Render the `agent()` options object for a single plan — `isolation: "worktree"`
* ONLY when the plan's `use_worktree` is not explicitly `false` (#2772 / #2285
* finding 1). This is the single place that decides worktree isolation for the
* Workflow backend; it must never diverge from the inline path's per-plan gate.
*/
function agentOptions(p, executorModel) {
const parts = ['agentType: "gsd-executor"'];
if (p.use_worktree !== false)
parts.push('isolation: "worktree"');
// #2686: carry the resolved executor model so this backend honors
// model_overrides / model_policy / model_profile exactly as the inline path.
const model = emittableModel(executorModel);
if (model !== undefined)
parts.push('model: ' + quoteString(model));
return '{ ' + parts.join(', ') + ' }';
}
/**
* True if `s` is a safe identifier/path token to interpolate into the generated
* script WITHOUT requiring a string-literal context — i.e. it contains no
* character that could terminate a comment line (`\n`/`\r`), break out of a
* string literal (`"` / `\`), or smuggle a NUL/control sequence. Used for
* `phaseDir`, `runId`, `wave.id`, and `plan.id`, which are identifiers/paths and
* must never legitimately contain such characters. Rejecting them at validation
* (rather than silently flattening) keeps the emitted script faithful to input.
*/
const UNSCRIPTABLE_CHAR_RE = /[\r\n"\\\x00-\x1f\x7f\u2028\u2029]/;
function isScriptableIdentifier(s) {
if (typeof s !== 'string' || s.length === 0)
return false;
return !UNSCRIPTABLE_CHAR_RE.test(s);
}
/**
* Emit a Workflow script mapping the phase's wave/plan model onto Workflow
* primitives. Pure and deterministic: identical input yields an identical string.
*
* Returns ok:false (never throws) on invalid input — empty waves, missing runId,
* a wave with no plans, etc.
*/
function emitWorkflowScript(input) {
if (input === null || input === undefined || typeof input !== 'object') {
return { ok: false, reason: 'invalid_input' };
}
const { phaseDir, waves, runId, executorModel } = input;
// Identifiers/paths interpolated into the generated script must be free of any
// character that could terminate a comment, break out of a string literal, or
// smuggle control bytes — reject up front (security: #1143 review Finding 1).
if (!isScriptableIdentifier(phaseDir)) {
return { ok: false, reason: 'phaseDir must be a non-empty string without newlines/quotes/backslash/control chars' };
}
if (!isScriptableIdentifier(runId)) {
return { ok: false, reason: 'runId must be a non-empty string without newlines/quotes/backslash/control chars' };
}
// #2686 security: the resolved model is interpolated into BOTH an object
// literal (safe under quoteString) and a `//` provenance comment (NOT safe
// under quoteString — U+2028/U+2029 are LineTerminators that end a single-line
// comment in every engine, so a hostile model id would make the rest of the
// line live code). Reject the whole emission rather than silently dropping the
// model: an unscriptable id is malformed input, and `resolveWaveDispatch` maps
// an emit failure to the inline backend WITH a reason, so the user sees it.
// Only a STRING carrying such a character is rejected. A non-string is a
// malformed config rather than an injection attempt, and stays on the existing
// defensive path: `emittableModel` omits it and emission proceeds.
if (typeof executorModel === 'string' && UNSCRIPTABLE_CHAR_RE.test(executorModel)) {
return { ok: false, reason: 'executorModel must not contain newlines/quotes/backslash/control/line-separator chars' };
}
if (!Array.isArray(waves) || waves.length === 0) {
return { ok: false, reason: 'waves must be a non-empty array' };
}
// Wave ids must be unique ACROSS waves, not just plan ids within one (#2590).
// Each wave emits a `phase("Wave <id>")` call plus a matching meta.phases
// entry, and the Workflow tool matches phase titles by exact string — two
// waves sharing an id would collapse into one progress group and misattribute
// every agent in the second wave to the first.
const seenWaveIds = new Set();
for (let i = 0; i < waves.length; i++) {
const w = waves[i];
if (w === null || typeof w !== 'object' || typeof w.id !== 'string') {
return { ok: false, reason: 'waves[' + i + '] must be { id, plans: non-empty[] }' };
}
if (!isScriptableIdentifier(w.id)) {
return { ok: false, reason: 'waves[' + i + '].id must not contain newlines/quotes/backslash/control chars' };
}
if (seenWaveIds.has(w.id)) {
return { ok: false, reason: 'duplicate wave id "' + w.id + '" — wave ids must be unique (phase titles must map 1:1)' };
}
seenWaveIds.add(w.id);
if (!Array.isArray(w.plans) || w.plans.length === 0) {
return { ok: false, reason: 'waves[' + i + '] must have a non-empty plans array' };
}
const seenIds = new Set();
for (let j = 0; j < w.plans.length; j++) {
const p = w.plans[j];
if (p === null || typeof p !== 'object' || typeof p.id !== 'string' || typeof p.brief !== 'string' || !Array.isArray(p.files_modified)) {
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '] must be { id, brief, files_modified[] }' };
}
if (!isScriptableIdentifier(p.id)) {
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].id must not contain newlines/quotes/backslash/control chars' };
}
if (p.use_worktree !== undefined && typeof p.use_worktree !== 'boolean') {
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].use_worktree must be a boolean if present' };
}
if (seenIds.has(p.id)) {
return { ok: false, reason: 'waves[' + i + '] has duplicate plan id "' + p.id + '"' };
}
seenIds.add(p.id);
for (const f of p.files_modified) {
if (typeof f !== 'string' || f.length === 0) {
return { ok: false, reason: 'waves[' + i + '].plans[' + j + '].files_modified entries must be non-empty strings' };
}
}
}
}
const budgetTokens = (typeof input.budgetTokens === 'number' && Number.isFinite(input.budgetTokens) && input.budgetTokens > 0)
? Math.floor(input.budgetTokens)
: null;
const lines = [];
// `export const meta = {…}` MUST be the first statement in the script — the
// Workflow tool rejects the whole script otherwise (#2590). Leading comments
// are not statements, but the meta block is emitted first regardless so the
// contract holds under the strictest reading of "first statement".
//
// meta.phases must be a PURE LITERAL (no variables, calls, spreads, or
// template interpolation), and its titles are matched EXACTLY against the
// phase() calls emitted below.
lines.push('export const meta = {');
lines.push(' name: ' + quoteString('gsd-execute-' + runId) + ',');
lines.push(' description: ' + quoteString('GSD wave dispatch for ' + phaseDir) + ',');
lines.push(' phases: [');
for (const w of waves) {
lines.push(' { title: ' + quoteString('Wave ' + w.id) + ', detail: '
+ quoteString(w.plans.length + ' plan(s)') + ' },');
}
lines.push(' ],');
lines.push('}');
lines.push('');
lines.push('// GSD Workflow script — generated by the claude-orchestration capability (#1143)');
lines.push('// phase: ' + phaseDir);
lines.push('// BETA: preview-grade; on any failure the orchestrator falls back to inline dispatch.');
lines.push('// Composes the SAME gsd-executor agent as the inline path, so artifacts (SUMMARY.md)');
lines.push('// and commits are produced identically. Worktree isolation is per-plan (use_worktree)');
lines.push('// and mirrors execute-phase.md step 2.5\'s submodule gate exactly (#2772 / #2285).');
// #2686 / ADR-1411: state which model was applied — or that none resolved —
// so an opted-in user can SEE the routing decision instead of having to read
// the emitted options. A fallback must be a visible value, never silent.
//
// SECURITY: this is a `//` comment, and U+2028/U+2029 are ECMAScript
// LineTerminators that END a single-line comment in every engine — the ES2019
// change legalized them inside string LITERALS only, so `quoteString` alone is
// NOT sufficient here even though it is sufficient in the object literal
// above. An unscriptable model id would otherwise close the comment and make
// the remainder live top-level code. `emitWorkflowScript` rejects such ids
// before reaching this point (see the validation above), which is what makes
// interpolating here safe.
const provenanceModel = emittableModel(executorModel);
if (provenanceModel !== undefined) {
lines.push('// model: ' + quoteString(provenanceModel) + ' (resolved for gsd-executor, same source as the inline path)');
}
else {
lines.push('// model: none applied — resolved to "inherit"/empty, so each agent inherits the');
lines.push('// orchestrator model (#2517: emitting an empty model 404s on some runtimes).');
}
lines.push('//');
// resumeFromRunId is a Workflow TOOL INPUT parameter, not a script function —
// calling it threw "resumeFromRunId is not defined" (#2590). The run id is
// carried in summary.resumeRunId for the caller to pass as that input.
lines.push('// resume: pass ' + quoteString(runId) + ' as the Workflow tool\'s resumeFromRunId input');
lines.push('// (it is a tool parameter, NOT a script function).');
if (budgetTokens !== null) {
// `budget` is a read-only object ({ total, spent(), remaining() }) supplied
// by the caller's token directive — a script cannot SET it, and `budget(n)`
// threw "budget is not a function" (#2590). Recorded as intent only.
lines.push('// budget: ' + budgetTokens + ' output tokens intended for this run; `budget` is');
lines.push('// read-only in a Workflow script — set it via the caller\'s token directive.');
}
lines.push('');
const stagesByWave = [];
let totalPlans = 0;
for (let wi = 0; wi < waves.length; wi++) {
const wave = waves[wi];
const stages = partitionStages(wave.plans);
stagesByWave.push(stages);
totalPlans += wave.plans.length;
lines.push('// Wave ' + wave.id);
// Title must match this wave's meta.phases entry EXACTLY.
lines.push('phase(' + quoteString('Wave ' + wave.id) + ')');
for (let si = 0; si < stages.length; si++) {
const stagePlanIds = stages[si];
// Resolve back to plan objects for briefs (ids are unique within a wave — validated above).
const stagePlans = stagePlanIds.map((id) => wave.plans.find((p) => p.id === id));
if (stages.length > 1) {
lines.push('// Stage ' + si + (si > 0 ? ' (sequential — files_modified overlap)' : ''));
}
// parallel() takes an ARRAY OF THUNKS — `parallel(agent(…), agent(…))`
// threw "parallel() expects an array of functions" (#2590). Passing
// agent() results directly would also start every agent eagerly, before
// parallel() could bound concurrency.
lines.push('await parallel([');
for (const p of stagePlans) {
lines.push(' () => agent(' + quoteString(p.brief) + ', ' + agentOptions(p, executorModel) + '),');
}
lines.push('])');
}
if (wi < waves.length - 1)
lines.push('');
}
lines.push('// Each agent writes SUMMARY.md on its worktree branch; commits land there');
lines.push('// and are merged by the orchestrator exactly as in inline wave dispatch.');
const script = lines.join('\n');
return {
ok: true,
script,
summary: {
waves: waves.length,
plans: totalPlans,
stagesByWave,
resumeRunId: runId,
budgetTokens,
},
};
}
/**
* #2285 — single composed decision seam for a PRE-wave dispatch-backend selector
* (e.g. the `execute:wave:pre` claude-orchestration contribution). Composes
* `detectWorkflowBackend` (gate ladder) with `emitWorkflowScript` (wave→plan
* mapping) into ONE call so the orchestrator (and its CLI wrapper,
* `claude-orchestration resolve-wave-dispatch`) never has to re-implement the
* two-step "detect, then maybe emit" sequencing.
*
* Fail-closed at every layer, matching the two composed functions:
* - `detectWorkflowBackend` resolving anything other than `'workflow'` →
* `inline` immediately; `emitWorkflowScript` is never invoked (no wasted
* work, no risk of a bad emit masking a correct inline fallback).
* - `detectWorkflowBackend` resolves `'workflow'` but `emitWorkflowScript`
* fails (`ok:false` — e.g. a malformed wave manifest) → `inline`, carrying
* the emit failure reason so the caller can surface it. Never a partial or
* broken script.
*
* This is the designated non-CLI-router, non-test caller of
* `detectWorkflowBackend` and `emitWorkflowScript` — the standalone CLI
* subcommands (`detect-backend`, `emit-workflow`) remain for inspection/
* debugging, but the orchestrator's real per-wave dispatch decision goes
* through this seam.
*
* Never throws on bad input.
*/
function resolveWaveDispatch(input) {
if (input === null || input === undefined || typeof input !== 'object') {
return { backend: 'inline', reason: 'invalid_input' };
}
const detected = detectWorkflowBackend({
runtimeId: input.runtimeId,
hostIntegration: input.hostIntegration,
config: input.config,
agentSdkVersion: input.agentSdkVersion,
});
if (detected.backend !== 'workflow') {
return { backend: 'inline', reason: detected.reason };
}
const emitted = emitWorkflowScript({
phaseDir: input.phaseDir,
waves: input.waves,
runId: input.runId,
budgetTokens: input.budgetTokens,
executorModel: input.executorModel,
});
if (!emitted.ok) {
return { backend: 'inline', reason: 'emit_failed: ' + emitted.reason };
}
return {
backend: 'workflow',
reason: detected.reason,
script: emitted.script,
summary: emitted.summary,
};
}
module.exports = {
detectWorkflowBackend,
emitWorkflowScript,
resolveWaveDispatch,
compareSemver,
isValidSemver,
WORKFLOW_TOOL_FLOOR_VERSION,
BACKEND_VALUES,
WORKFLOW_RUNTIME,
};

View File

@@ -1,287 +0,0 @@
"use strict";
/**
* external-job.cts — scheduler-adapter producer module for the async
* external-job contract (#1164 / #1105).
*
* The CORE loop CONSUMES manifests at `.planning/async-jobs/<job>.json`
* (#1165 — external_job_waiting half-state); this module is the Capability
* half that PRODUCES them. SLURM is the first backend; the design stays
* scheduler-pluggable via the `backend` field (planning-artifacts.md).
*
* Pure helpers (state map, build, validate, parsers) take no I/O; the writer
* takes injected `fs` and `clock` seams so tests drive it without touching disk
* or wall-clock time (CLAUDE.md clock-seam + injectable-deps conventions).
*
* Build: `src/*.cts` -> `gsd-core/bin/lib/*.cjs` (ADR-457 build-at-publish).
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const node_path_1 = __importDefault(require("node:path"));
const node_fs_1 = __importDefault(require("node:fs"));
// ─── Closed status enum (stability contract — Hyrum's Law) ────────────────────
const MANIFEST_VERSION = '1.0';
const MANIFEST_STATUS = [
'submitted',
'running',
'completed-unverified',
'failed',
'cancelled',
'timeout',
];
const NON_TERMINAL_STATUSES = ['submitted', 'running'];
const TERMINAL_FAILURE_STATUSES = ['failed', 'cancelled', 'timeout'];
// ─── SLURM state -> manifest status ───────────────────────────────────────────
//
// Source: SLURM job state codes (squeue/sacct State column). Producers for
// other backends map their own states onto the closed enum above; this table
// is SLURM-specific and lives behind the `backend: 'slurm'` field.
const SLURM_STATE_MAP = {
PENDING: 'submitted',
CONFIGURING: 'submitted',
RUNNING: 'running',
COMPLETING: 'running',
COMPLETED: 'completed-unverified',
FAILED: 'failed',
CANCELLED: 'cancelled',
TIMEOUT: 'timeout',
OUT_OF_MEMORY: 'failed',
BOOT_FAIL: 'failed',
NODE_FAIL: 'failed',
PREEMPTED: 'failed',
};
/**
* Map a raw SLURM state string to the closed, scheduler-agnostic manifest
* status. Case-insensitive; trims whitespace; strips a trailing by-part
* ("CANCELLED by 1001" -> "CANCELLED"). Returns `null` for any unknown
* state so the caller can decide whether to surface or fail — never guesses
* (CLAUDE.md anti-guessing).
*/
function mapSlurmState(raw) {
if (typeof raw !== 'string')
return null;
const key = raw.trim().toUpperCase();
const head = key.split(/\s+/)[0];
if (head && Object.prototype.hasOwnProperty.call(SLURM_STATE_MAP, head)) {
return SLURM_STATE_MAP[head];
}
return null;
}
const REQUIRED_FIELDS = [
'plan_id',
'phase',
'job_id',
'backend',
'submit_command',
'status',
'expected_artifacts',
'verification_command',
'resume_command',
];
function assertString(v, key) {
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`buildManifest: field "${key}" must be a non-empty string`);
}
}
const STATUS_LIST = MANIFEST_STATUS;
/**
* Build a versioned, frozen manifest. Stamps `version` and `submitted_at`
* (via the injected clock seam) and normalises `terminal_details`:
* `null` unless the status is a terminal failure AND details were supplied.
* Throws on missing required fields or an out-of-enum status.
*/
function buildManifest(input, opts = {}) {
const inputRecord = input;
for (const key of REQUIRED_FIELDS) {
const v = inputRecord[key];
if (key === 'expected_artifacts') {
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === 'string')) {
throw new Error('buildManifest: field "expected_artifacts" must be a non-empty string[]');
}
continue;
}
assertString(v, key);
}
if (!STATUS_LIST.includes(input.status)) {
throw new Error(`buildManifest: field "status" must be one of ${MANIFEST_STATUS.join(', ')}`);
}
const clock = opts.clock ?? { nowIso: () => new Date().toISOString() };
const isTerminalFailure = TERMINAL_FAILURE_STATUSES.includes(input.status);
const terminal_details = isTerminalFailure && input.terminal_details ? input.terminal_details : null;
return Object.freeze({
version: MANIFEST_VERSION,
job_id: input.job_id,
plan_id: input.plan_id,
phase: input.phase,
backend: input.backend,
submit_command: input.submit_command,
status: input.status,
expected_artifacts: [...input.expected_artifacts],
verification_command: input.verification_command,
resume_command: input.resume_command,
submitted_at: clock.nowIso(),
terminal_details,
});
}
/**
* Producer-side schema validator — the mirror of the consumer trust boundary
* (planning-artifacts.md). Producers MUST emit a manifest this accepts; the
* core loop re-validates defensively on read.
*/
function validateManifest(value) {
if (!value || typeof value !== 'object') {
return { ok: false, errors: ['manifest must be an object'] };
}
const m = value;
const errors = [];
if (m.version !== MANIFEST_VERSION)
errors.push(`version must be "${MANIFEST_VERSION}"`);
for (const f of ['job_id', 'plan_id', 'phase', 'backend', 'submit_command', 'verification_command', 'resume_command', 'submitted_at']) {
if (typeof m[f] !== 'string' || m[f].length === 0) {
errors.push(`field "${f}" must be a non-empty string`);
}
}
if (typeof m.status !== 'string' || !STATUS_LIST.includes(m.status)) {
errors.push(`status must be one of ${MANIFEST_STATUS.join(', ')}`);
}
if (!Array.isArray(m.expected_artifacts) || !m.expected_artifacts.every((x) => typeof x === 'string')) {
errors.push('expected_artifacts must be a string[]');
}
if (m.terminal_details !== null && typeof m.terminal_details !== 'object') {
errors.push('terminal_details must be null or an object');
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}
/**
* Parse `sbatch --parsable` output. Accepts either a bare job id
* (`"12345"`) or the `"12345;clustername"` form. Rejects prose like
* `"Submitted batch job 12345"` (that is the non-parsable default format).
*/
function parseSbatchParsable(stdout) {
if (typeof stdout !== 'string')
return { ok: false, kind: 'non_string', raw: String(stdout) };
const trimmed = stdout.trim();
if (!trimmed)
return { ok: false, kind: 'empty', raw: stdout };
const head = trimmed.split(';')[0].split(/\s+/)[0];
if (!/^\d+$/.test(head))
return { ok: false, kind: 'not_numeric', raw: trimmed };
return { ok: true, job_id: head };
}
/**
* Parse a single `squeue` line of the form `"<jobid> <state>"`. Returns `null`
* for a header or any row that does not have at least two tokens.
*/
function parseSqueueLine(line) {
if (typeof line !== 'string')
return null;
const parts = line.trim().split(/\s+/);
if (parts.length < 2)
return null;
const [job_id, state] = parts;
if (!/^\d+$/.test(job_id))
return null;
return { job_id, state };
}
/**
* Parse a `sacct -P` row given as pre-split columns where index 0 is the job
* id and index 1 is the state. Returns `null` for malformed rows.
*/
function parseSacctRow(cols) {
if (cols.length < 2)
return null;
const job_id = cols[0];
const state = cols[1];
if (typeof job_id !== 'string' || typeof state !== 'string')
return null;
if (!/^\d+$/.test(job_id))
return null;
return { job_id, state };
}
/**
* Pure path projection: `.planning/async-jobs/<job_id>.json`.
*/
function manifestPath(planningDir, jobId) {
return node_path_1.default.join(planningDir, 'async-jobs', `${jobId}.json`);
}
function _isNonTerminal(status) {
return typeof status === 'string' && NON_TERMINAL_STATUSES.includes(status);
}
/**
* Write a manifest to `.planning/async-jobs/<job_id>.json`.
*
* Fail-closed rules (mirror of the consumer contract, planning-artifacts.md):
* - If any existing manifest in the dir shares `plan_id` but has a different
* `job_id` AND is non-terminal -> refuse (`duplicate_plan_id`); dispatching
* again would duplicate the external job.
* - If the target file exists but is not valid JSON -> refuse
* (`malformed_existing`); never silently clobber.
* - Same `job_id` for the same `plan_id` -> allowed (status progression).
* - A prior job for the same `plan_id` that is already terminal -> allowed
* (the duplicate guard only protects against re-dispatching live work).
*/
function writeManifest(manifest, planningDir, opts = {}) {
const fs = opts.fs ?? node_fs_1.default;
const dir = node_path_1.default.join(planningDir, 'async-jobs');
const target = manifestPath(planningDir, manifest.job_id);
let names;
try {
fs.mkdirSync(dir, { recursive: true });
names = fs.readdirSync(dir);
}
catch (e) {
return { ok: false, kind: 'io_error', message: e.message };
}
for (const name of names) {
if (!name.endsWith('.json'))
continue;
const p = node_path_1.default.join(dir, name);
let raw;
try {
raw = String(fs.readFileSync(p));
}
catch {
continue;
}
let existing;
try {
existing = JSON.parse(raw);
}
catch {
if (p === target) {
return { ok: false, kind: 'malformed_existing', message: `target manifest ${p} is not valid JSON` };
}
continue;
}
const samePlan = existing.plan_id === manifest.plan_id;
const sameJob = existing.job_id === manifest.job_id;
if (samePlan && !sameJob && _isNonTerminal(existing.status)) {
return {
ok: false,
kind: 'duplicate_plan_id',
message: `plan_id "${manifest.plan_id}" already has non-terminal job "${String(existing.job_id)}" at ${p}; dispatching again would duplicate the external job`,
};
}
}
try {
fs.writeFileSync(target, JSON.stringify(manifest, null, 2) + '\n');
}
catch (e) {
return { ok: false, kind: 'io_error', message: e.message };
}
return { ok: true, path: target };
}
module.exports = {
MANIFEST_VERSION,
MANIFEST_STATUS,
NON_TERMINAL_STATUSES,
TERMINAL_FAILURE_STATUSES,
mapSlurmState,
buildManifest,
validateManifest,
parseSbatchParsable,
parseSqueueLine,
parseSacctRow,
writeManifest,
manifestPath,
};

View File

@@ -1,698 +0,0 @@
"use strict";
/**
* Markdown Table Model — canonical GFM table parsing + schema registry seam
* (ADR-2143, epic #2143). Pure functions, Node built-ins only, string-in/value-out,
* no I/O. Compiled by tsc to gsd-core/bin/lib/markdown-table.cjs.
*
* NOTE: the `Result<T>` here is the ADR-2143 §5 parse-result shape {ok,value|reason},
* now defined once in `./write-set.cjs` (the shared fail-loud + write-set seam) and
* re-exported here so existing importers of `Result` from this module keep working
* unchanged — deliberately distinct from command-routing-hub's dispatch `Result`
* {ok,data|kind}; the two never mix (different modules).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.TABLE_SCHEMAS = void 0;
exports.matchTableSchema = matchTableSchema;
exports.splitTableRow = splitTableRow;
exports.isDelimiterRow = isDelimiterRow;
exports.parseMarkdownTable = parseMarkdownTable;
exports.updateTableCell = updateTableCell;
exports.deleteTableRow = deleteTableRow;
exports.insertTableRow = insertTableRow;
exports.findTableBySchema = findTableBySchema;
exports.findTableWithColumns = findTableWithColumns;
exports.escapeCell = escapeCell;
exports.appendQuickTaskRow = appendQuickTaskRow;
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
// ─── Schema registry ──────────────────────────────────────────────────────────
/**
* Canonical column-header shapes for every GFM table GSD parses or generates.
* Each entry in `TABLE_SCHEMAS[id]` is one accepted variant (exact column names,
* in order); `matchTableSchema` resolves a parsed header back to `{id, label}`.
*
* This registry is the single source of truth — a parity test
* (tests/markdown-table.test.cjs) asserts every variant's header appears
* verbatim in the template/workflow file that generates it, so the registry
* and the templates can never silently drift (ADR-2143 §3 Generative-Fix-
* Divergence guard).
*/
exports.TABLE_SCHEMAS = {
RoadmapProgress: [
{ label: 'flat', columns: ['Phase', 'Plans Complete', 'Status', 'Completed'] },
{
label: 'milestone-grouped',
columns: ['Phase', 'Milestone', 'Plans Complete', 'Status', 'Completed'],
},
],
RequirementsTraceability: [
{ label: 'default', columns: ['Requirement', 'Phase', 'Status'] },
],
QuickTasks: [
{ label: 'no-status', columns: ['#', 'Description', 'Date', 'Commit', 'Directory'] },
{
label: 'with-status',
columns: ['#', 'Description', 'Date', 'Commit', 'Status', 'Directory'],
},
],
Security: [
{ label: 'trust-boundaries', columns: ['Boundary', 'Description', 'Data Crossing'] },
{
label: 'threat-register',
columns: [
'Threat ID',
'Category',
'Component',
'Severity',
'Disposition',
'Mitigation',
'Status',
],
},
{
label: 'accepted-risks',
columns: ['Risk ID', 'Threat Ref', 'Rationale', 'Accepted By', 'Date'],
},
{
label: 'audit-trail',
columns: ['Audit Date', 'Threats Total', 'Closed', 'Open', 'Run By'],
},
],
};
/**
* Resolve a parsed table's header columns to the canonical schema it matches
* (exact column names, same length, same order), else `null`.
*/
function matchTableSchema(columns) {
for (const [id, variants] of Object.entries(exports.TABLE_SCHEMAS)) {
for (const variant of variants) {
if (variant.columns.length === columns.length
&& variant.columns.every((col, idx) => col === columns[idx])) {
return { id, label: variant.label };
}
}
}
return null;
}
// ─── Parsing ──────────────────────────────────────────────────────────────────
/**
* Split one GFM table row line into trimmed cell strings.
* Strips one leading and one trailing `|`, splits on unescaped `|`, trims
* each cell, and unescapes `\\` back to `\` and `\|` back to `|` (the exact
* reverse of `escapeCell`'s `\`->`\\` then `|`->`\|` order below), so cell
* values round-trip exactly — including literal backslashes.
*/
function splitTableRow(line) {
let stripped = line.trim();
if (stripped.startsWith('|'))
stripped = stripped.slice(1);
if (stripped.endsWith('|'))
stripped = stripped.slice(0, -1);
return stripped.split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\([\\|])/g, '$1'));
}
/**
* True when every delimiter cell matches GFM's `:?-{1,}:?` shape (spaces
* removed). Exported (alongside `splitTableRow`) so callers that need their
* own ragged-tolerant header/delimiter detection — e.g. state.cts's
* `cmdStateRecordMetric` row-append, which must recognize an existing table
* without requiring every DATA row to also parse cleanly (#2245 Blocker 2) —
* reuse the exact same header/delimiter-shape check `parseMarkdownTable` uses,
* instead of re-deriving it and risking divergence.
*/
function isDelimiterRow(cells) {
return cells.every((cell) => /^:?-{1,}:?$/.test(cell.replace(/\s+/g, '')));
}
/**
* Parse the FIRST GFM pipe table found in `sectionText`.
*
* Defensive by design: never throws — every malformed shape (no table,
* missing/misaligned delimiter row, ragged data row) returns a typed
* `{ok:false, reason}` instead of silently coercing or dropping data
* (ADR-2143 §3 — ragged rows are errors, not silent).
*
* Scope note: GSD planning tables (STATE.md/ROADMAP.md/requirements.md/
* SECURITY.md) are always fully-piped (leading + trailing `|` on every row)
* and non-indented — this parser targets THAT shape, not arbitrary
* CommonMark (which also allows non-piped rows and up to 3 leading spaces).
*/
function parseMarkdownTable(sectionText) {
if (typeof sectionText !== 'string' || sectionText.trim() === '') {
return { ok: false, reason: 'empty or non-string input' };
}
const lines = sectionText.split(/\r?\n/);
let headerIdx = -1;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
headerIdx = i;
break;
}
}
if (headerIdx === -1) {
return { ok: false, reason: 'no table found' };
}
const columns = splitTableRow(lines[headerIdx]);
const delimiterLine = lines[headerIdx + 1];
if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) {
return { ok: false, reason: 'missing delimiter row' };
}
const delimiterCells = splitTableRow(delimiterLine);
if (!isDelimiterRow(delimiterCells)) {
return { ok: false, reason: 'missing delimiter row' };
}
if (delimiterCells.length !== columns.length) {
return { ok: false, reason: 'delimiter/header column count mismatch' };
}
const rows = [];
let rowNum = 0;
for (let i = headerIdx + 2; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (!trimmed.startsWith('|'))
break;
rowNum += 1;
const cells = splitTableRow(lines[i]);
if (cells.length !== columns.length) {
return {
ok: false,
reason: `row ${rowNum} has ${cells.length} cells, expected ${columns.length}`,
};
}
const row = {};
columns.forEach((col, idx) => {
row[col] = cells[idx];
});
rows.push(row);
}
return { ok: true, value: { columns, rows } };
}
/**
* Split `text` into lines exactly like `.split(/\r?\n/)` (bare `\r` is NOT a
* line break, matching `parseMarkdownTable`), tracking each line's absolute
* start offset in `text` so cell ranges can be computed relative to the
* ORIGINAL string, not the trimmed/relative line.
*/
function splitLinesWithOffsets(text) {
const result = [];
let start = 0;
const re = /\r\n|\n/g;
let m;
while ((m = re.exec(text)) !== null) {
result.push({ line: text.slice(start, m.index), start });
start = m.index + m[0].length;
}
result.push({ line: text.slice(start), start });
return result;
}
/**
* Split one GFM table row LINE into raw cell ranges, absolute to the original
* `text` the line was sliced from (`lineStart` = that line's start offset).
* Mirrors `splitTableRow`'s trim + strip-leading/trailing-pipe + unescaped-pipe
* split EXACTLY, but returns character ranges instead of trimmed values, so a
* caller can splice a replacement into the original string byte-for-byte.
*/
function splitTableRowRanges(line, lineStart) {
const leftTrim = /^\s*/.exec(line)[0].length;
const rightTrim = /\s*$/.exec(line)[0].length;
let stripped = line.slice(leftTrim, line.length - rightTrim);
let strippedStart = lineStart + leftTrim;
if (stripped.startsWith('|')) {
stripped = stripped.slice(1);
strippedStart += 1;
}
if (stripped.endsWith('|')) {
stripped = stripped.slice(0, -1);
}
const cells = [];
const re = /(?<!\\)\|/g;
let cellStartRel = 0;
let m;
while ((m = re.exec(stripped)) !== null) {
cells.push({ start: strippedStart + cellStartRel, end: strippedStart + m.index });
cellStartRel = m.index + 1;
}
cells.push({ start: strippedStart + cellStartRel, end: strippedStart + stripped.length });
return cells;
}
/** Unescape one raw (still-`\`-escaped) cell/column-name span exactly like
* `splitTableRow`: trim, then reverse `\\` -> `\` and `\|` -> `|`. */
function unescapeCellText(raw) {
return raw.trim().replace(/\\([\\|])/g, '$1');
}
/**
* Surgically edit ONE table cell while preserving the table's exact byte
* formatting (ADR-2143 §7). Locates the first GFM table's header + delimiter
* row in `tableText` (own header/delimiter detection — deliberately does NOT
* gate on `parseMarkdownTable(tableText).ok`), finds the first DATA row where
* `match(row, index)` is true, and replaces ONLY that row's `column` cell's
* raw inner text (the span between its two delimiting `|` characters) — every
* other byte of `tableText` (other cells, padding, alignment, EOL style) is
* left BYTE-IDENTICAL. This is deliberately NOT a parse-then-render: a
* render pass would reformat padding/alignment/dates that mutation sites
* (e.g. `status.padEnd(11)`) depend on staying pinned.
*
* Ragged-tolerant by design (#2245 review Fix 2): each data row's
* `{colName:cellText}` record is built ONLY from the columns physically
* present in THAT row — a short row simply omits its trailing column names;
* an over-long row's extra trailing cells are ignored — so `match` is called
* with whatever partial record a ragged row yields. A single sibling row
* whose cell count doesn't match the header must never silently no-op the
* whole write (the prior `parseMarkdownTable(tableText).ok` gate failed the
* ENTIRE table — including an otherwise-well-formed target row — the moment
* ANY other row in the same table was ragged). A row that matches on content
* but is too short to physically contain `column` has no cell to splice
* into, so it cannot be selected; the scan continues past it.
*
* `newValue` is spliced in VERBATIM as the new raw cell span — it is the
* caller's responsibility to supply the fully-formatted text (including any
* leading/trailing padding needed to reproduce the table's existing column
* alignment, and to escape a literal `|` or `\` the value might contain via
* the same convention `splitTableRow`/`escapeCell` use elsewhere in this
* module). When `newValue` is a function, it receives the CURRENT (trimmed,
* unescaped) cell value — the same value that appears in `match`'s `row`
* argument — and must return the full literal replacement text. Returning
* the current value unchanged is a supported no-op-probe pattern for callers
* that need to know whether (and to what current value) a row matched
* without necessarily writing a new value.
*
* Returns `{ok:false, reason}` only for a genuinely absent/malformed table
* (no header line, or no valid delimiter row immediately below it), an
* unknown `column`, or zero rows satisfying `match` while physically
* containing `column` — never for a ragged sibling row.
*/
function updateTableCell(tableText, match, column, newValue) {
const lines = splitLinesWithOffsets(tableText);
let headerIdx = -1;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].line.trim();
if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
headerIdx = i;
break;
}
}
if (headerIdx === -1) {
return { ok: false, reason: 'no table found' };
}
const delimiterLine = lines[headerIdx + 1]?.line;
if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) {
return { ok: false, reason: 'missing delimiter row' };
}
const headerRanges = splitTableRowRanges(lines[headerIdx].line, lines[headerIdx].start);
const columns = headerRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end)));
const delimiterCells = splitTableRow(delimiterLine);
if (!isDelimiterRow(delimiterCells)) {
return { ok: false, reason: 'missing delimiter row' };
}
if (delimiterCells.length !== columns.length) {
return { ok: false, reason: 'delimiter/header column count mismatch' };
}
if (!columns.includes(column)) {
return { ok: false, reason: `unknown column: ${column}` };
}
const targetColIdx = columns.indexOf(column);
let selectedRange;
let dataRowIndex = 0;
for (let i = headerIdx + 2; i < lines.length; i++) {
const trimmed = lines[i].line.trim();
if (!trimmed.startsWith('|'))
break;
const cellRanges = splitTableRowRanges(lines[i].line, lines[i].start);
const record = {};
const presentCount = Math.min(cellRanges.length, columns.length);
for (let c = 0; c < presentCount; c++) {
record[columns[c]] = unescapeCellText(tableText.slice(cellRanges[c].start, cellRanges[c].end));
}
if (targetColIdx < cellRanges.length && match(record, dataRowIndex)) {
selectedRange = cellRanges[targetColIdx];
break;
}
dataRowIndex += 1;
}
if (!selectedRange) {
return { ok: false, reason: 'no matching row' };
}
const currentValue = unescapeCellText(tableText.slice(selectedRange.start, selectedRange.end));
const replacement = typeof newValue === 'function' ? newValue(currentValue) : newValue;
// True no-op guard: a function `newValue` that returns `current` UNCHANGED
// (the documented no-op-probe pattern) must leave `tableText` genuinely
// byte-identical, padding included. `current` is already trimmed/unescaped,
// so naively splicing it back in would strip the raw cell's original
// leading/trailing padding — this returns the ORIGINAL text untouched
// instead whenever the callback's answer is "no change".
if (typeof newValue === 'function' && replacement === currentValue) {
return { ok: true, value: tableText };
}
return {
ok: true,
value: tableText.slice(0, selectedRange.start) + replacement + tableText.slice(selectedRange.end),
};
}
// ─── deleteTableRow (ADR-2143 §7 row-removal sibling of updateTableCell) ─────
/**
* Surgically delete ONE whole table row while preserving every other byte of
* `tableText` (ADR-2143 §7, row-removal sibling of `updateTableCell`). Locates
* the first GFM table's header + delimiter row in `tableText` using the exact
* same self-contained, ragged-tolerant scan `updateTableCell` uses (own
* header/delimiter detection — does NOT gate on `parseMarkdownTable(tableText).ok`),
* finds the FIRST data row where `match(row, index)` is true, and splices out
* that row's entire LINE — including its trailing newline (`\r\n` or `\n`,
* whichever terminates it) — from `tableText`. Every other byte (header,
* delimiter, other rows, surrounding prose before/after the table, EOL style)
* is left BYTE-IDENTICAL.
*
* Ragged-tolerant by design, mirroring `updateTableCell` (#2245 review Fix 2):
* each data row's `{colName:cellText}` record is built ONLY from the columns
* physically present in THAT row — a sibling row whose cell count doesn't
* match the header must never abort the whole scan; `match` is simply called
* with whatever partial record a ragged row yields.
*
* Returns `{ok:false, reason}` for a genuinely absent/malformed table (no
* header line, or no valid delimiter row immediately below it) or zero rows
* satisfying `match` — never for a ragged sibling row.
*/
function deleteTableRow(tableText, match) {
const lines = splitLinesWithOffsets(tableText);
let headerIdx = -1;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].line.trim();
if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
headerIdx = i;
break;
}
}
if (headerIdx === -1) {
return { ok: false, reason: 'no table found' };
}
const delimiterLine = lines[headerIdx + 1]?.line;
if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) {
return { ok: false, reason: 'missing delimiter row' };
}
const headerRanges = splitTableRowRanges(lines[headerIdx].line, lines[headerIdx].start);
const columns = headerRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end)));
const delimiterCells = splitTableRow(delimiterLine);
if (!isDelimiterRow(delimiterCells)) {
return { ok: false, reason: 'missing delimiter row' };
}
if (delimiterCells.length !== columns.length) {
return { ok: false, reason: 'delimiter/header column count mismatch' };
}
let selectedLineIdx = -1;
let dataRowIndex = 0;
for (let i = headerIdx + 2; i < lines.length; i++) {
const trimmed = lines[i].line.trim();
if (!trimmed.startsWith('|'))
break;
const cellRanges = splitTableRowRanges(lines[i].line, lines[i].start);
const record = {};
const presentCount = Math.min(cellRanges.length, columns.length);
for (let c = 0; c < presentCount; c++) {
record[columns[c]] = unescapeCellText(tableText.slice(cellRanges[c].start, cellRanges[c].end));
}
if (match(record, dataRowIndex)) {
selectedLineIdx = i;
break;
}
dataRowIndex += 1;
}
if (selectedLineIdx === -1) {
return { ok: false, reason: 'no matching row' };
}
// Splice out the whole LINE including its trailing EOL: the next line's
// recorded `start` offset is already positioned right after whatever EOL
// (`\r\n` or `\n`) terminated the selected line (see `splitLinesWithOffsets`
// above) — when the selected row is the LAST line in `tableText` (no
// trailing EOL to preserve), fall back to the end of the string.
let rowStart = lines[selectedLineIdx].start;
let rowEnd;
if (selectedLineIdx + 1 < lines.length) {
rowEnd = lines[selectedLineIdx + 1].start;
}
else {
// The selected row is the LAST line and has no trailing EOL: deleting from
// its `start` to end-of-string would strand the EOL that terminated the
// PREVIOUS line as a dangling newline. Back `rowStart` up over that
// preceding `\n` (and its `\r`, if any) so the table ends cleanly after the
// new last row.
rowEnd = tableText.length;
if (rowStart > 0 && tableText[rowStart - 1] === '\n') {
rowStart -= 1;
if (rowStart > 0 && tableText[rowStart - 1] === '\r')
rowStart -= 1;
}
}
return {
ok: true,
value: tableText.slice(0, rowStart) + tableText.slice(rowEnd),
};
}
// ─── insertTableRow (ADR-2143 §7 row-insertion sibling of updateTableCell) ───
/**
* Insert ONE new row into a GFM table while preserving every other byte of
* `tableText` (ADR-2143 §7, row-insertion sibling of `updateTableCell` /
* `deleteTableRow`). Locates the first table's header + delimiter row using
* the exact same self-contained, ragged-tolerant scan the other two use (own
* header/delimiter detection — does NOT gate on `parseMarkdownTable(tableText).ok`),
* builds the new row's cells in the table's ACTUAL header order — each column
* name is passed through `valueFor(column)`; a column for which `valueFor`
* returns `undefined` gets `fallback` (default `'-'`) — and splices it in
* immediately after the table's LAST existing data row (or immediately after
* the delimiter row when the table has zero data rows).
*
* Name-addressed and header-order-agnostic by construction: unlike a
* hardcoded positional literal (`| ${a} | ${b} | - | - |`), this never
* silently no-ops or mis-maps a value onto the wrong column when the header
* is reordered or a superset of the columns `valueFor` knows about (#2245
* audit sibling finding — the bug this helper replaces).
*
* EOL-preserving: the new row reuses whatever exact EOL bytes (`\r\n` or
* `\n`) already terminate the line it's inserted after, so a CRLF document
* stays CRLF and an LF document stays LF — never guessed or hardcoded. When
* the insertion point is at the very end of `tableText` with no following
* line (the table's last row has no trailing EOL of its own), the existing
* last row is terminated with the header/delimiter boundary's own EOL (so it
* gains a terminator, since it is no longer the last line) and the new row
* becomes the new EOL-less tail — mirroring `tableText`'s own convention of
* not forcing a trailing newline that wasn't already there.
*
* Escaping (F4 #2245 review): unlike `updateTableCell`, whose `newValue` is
* spliced in VERBATIM (caller-must-escape — see its doc comment above), every
* value returned by `valueFor` (and `fallback`) IS escaped internally here via
* `escapeCell` before being joined into the new row, exactly like
* `appendQuickTaskRow` below — a caller-supplied name containing a literal
* `|` or `\` cannot silently split the new row into extra columns. Callers do
* NOT need to pre-escape their values.
*
* Returns `{ok:false, reason}` only for a genuinely absent/malformed table
* (no header line, or no valid delimiter row immediately below it) — never
* for a ragged data row (mirrors `updateTableCell`/`deleteTableRow`).
*/
function insertTableRow(tableText, valueFor, fallback = '-') {
const lines = splitLinesWithOffsets(tableText);
let headerIdx = -1;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].line.trim();
if (trimmed.startsWith('|') && trimmed.indexOf('|', 1) !== -1) {
headerIdx = i;
break;
}
}
if (headerIdx === -1) {
return { ok: false, reason: 'no table found' };
}
const delimiterLine = lines[headerIdx + 1]?.line;
if (delimiterLine === undefined || !delimiterLine.trim().startsWith('|')) {
return { ok: false, reason: 'missing delimiter row' };
}
const delimiterCells = splitTableRow(delimiterLine);
if (!isDelimiterRow(delimiterCells)) {
return { ok: false, reason: 'missing delimiter row' };
}
const headerRanges = splitTableRowRanges(lines[headerIdx].line, lines[headerIdx].start);
const columns = headerRanges.map((r) => unescapeCellText(tableText.slice(r.start, r.end)));
// Header -> delimiter EOL, reused as the fallback terminator for the "insert
// point is at the absolute end of tableText" edge case below.
const headerToDelimiterEol = tableText.slice(lines[headerIdx].start + lines[headerIdx].line.length, lines[headerIdx + 1].start) || '\n';
let lastLineIdx = headerIdx + 1; // delimiter row, when the table has zero data rows
for (let i = headerIdx + 2; i < lines.length; i++) {
if (!lines[i].line.trim().startsWith('|'))
break;
lastLineIdx = i;
}
const newRow = `| ${columns.map((col) => escapeCell(valueFor(col) ?? fallback)).join(' | ')} |`;
if (lastLineIdx + 1 < lines.length) {
// A following line exists — insert the new row, reusing the EXACT EOL
// that already terminates the current last table line, so every other
// byte (including everything after the table) stays untouched.
const insertAt = lines[lastLineIdx + 1].start;
const eol = tableText.slice(lines[lastLineIdx].start + lines[lastLineIdx].line.length, insertAt);
return { ok: true, value: tableText.slice(0, insertAt) + newRow + eol + tableText.slice(insertAt) };
}
// The table's last row is also the last line of `tableText` (no trailing
// EOL). Terminate it now — it needs one, since it is no longer last — and
// append the new row as the new EOL-less tail.
return { ok: true, value: tableText + headerToDelimiterEol + newRow };
}
/**
* Find the first table in `text` whose header matches `TABLE_SCHEMAS[schemaId]`,
* scanning the WHOLE document (not just a named section). Returns `null` when
* no table with that schema is found.
*
* Fixes the regression where callers first located a named heading (e.g.
* `## Progress`) via `collectSection` and only then parsed a table inside it —
* a schema-matching table that lives under a differently-named heading (or no
* heading at all), or that isn't the first table in the document, was
* invisible to that approach. Scanning the whole document by schema restores
* the old "find the progress table anywhere" behaviour while staying
* seam-based (ADR-2143).
*/
function findTableBySchema(text, schemaId) {
if (typeof text !== 'string')
return null;
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!t.startsWith('|') || t.indexOf('|', 1) === -1)
continue;
const cols = splitTableRow(lines[i]);
const m = matchTableSchema(cols);
if (m && m.id === schemaId) {
const parsed = parseMarkdownTable(lines.slice(i).join('\n'));
if (parsed.ok)
return parsed.value;
}
}
return null;
}
/**
* Find the first GFM table in `text` whose header contains ALL of `required`
* column names (order-independent; extra/injected columns allowed). Returns
* the parsed `MarkdownTable`, or `null` when no table's header is a superset
* of `required`.
*
* Column-NAME/order/count-invariant counterpart to `findTableBySchema` (ADR-2143
* §3 "addressed by NAME, never ordinal"): where `findTableBySchema` requires an
* EXACT canonical column set+order registered in `TABLE_SCHEMAS`, this scans
* for any header that names the required columns, in any order, tolerating
* extra/unrelated injected columns. Cells remain addressable by column NAME
* via the returned `MarkdownTable`.
*/
function findTableWithColumns(text, required) {
if (typeof text !== 'string')
return null;
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!t.startsWith('|') || t.indexOf('|', 1) === -1)
continue;
const cols = splitTableRow(lines[i]);
if (required.every((rq) => cols.includes(rq))) {
const parsed = parseMarkdownTable(lines.slice(i).join('\n'));
if (parsed.ok)
return parsed.value;
}
}
return null;
}
// ─── Quick Tasks row append (#2133) ────────────────────────────────────────────
/**
* Escape one dynamic cell value for insertion into a GFM pipe-table row.
*
* Escapes `\` -> `\\` FIRST, then `|` -> `\|` (in that order, so a literal
* backslash already in the value is never mistaken for part of an escape
* sequence introduced by this function — CodeQL js/incomplete-sanitization).
* `splitTableRow` reverses both in the opposite order (`\\` -> `\` then
* `\|` -> `|`, see line ~114 above), so escaping/unescaping round-trips
* exactly, including literal backslashes. Newlines are collapsed to a
* single space — a raw `|` or embedded newline in a cell value (e.g. a task
* `description`) would otherwise corrupt the table (extra column / a fake
* extra row) and get rejected by the now-fail-loud `parseMarkdownTable` as a
* ragged row.
*
* Exported (F3/#2245 review) so callers of `updateTableCell` that build a
* replacement value by transforming the CURRENT (already-unescaped) cell
* text — e.g. phase.cts's Progress-ordinal renumber, which decrements the
* leading digit of a `Phase` cell like `3. Parser | Lexer` and splices the
* rest of the cell text back verbatim — can re-escape that value before
* returning it from the `newValue` callback, honoring `updateTableCell`'s
* caller-must-re-escape contract (see its doc comment above) instead of
* spliceing a raw, unescaped `|` back into the table and silently splitting
* the cell.
*/
function escapeCell(value) {
return String(value)
.replace(/\r?\n+/g, ' ')
.replace(/\\/g, '\\\\') // escape the escape char FIRST (CodeQL js/incomplete-sanitization)
.replace(/\|/g, '\\|')
.trim();
}
/**
* Append one row to STATE.md's "Quick Tasks Completed" table.
*
* Pure, schema-driven replacement for fast.md's inline `awk NF-2` column-count
* guess (#2133, ADR-2143 §3 schema registry / §7 fail-loud unrecognized-schema
* guard). Never touches disk, git, or the clock — callers (the `gsd-tools
* quick-tasks-append` subcommand) compute `date`/`commit` and pass them in.
*
* Fails loud (`{ok:false, reason}`, never a silent skip) when:
* - no "Quick Tasks Completed" heading exists in `stateContent`
* - the section's body doesn't parse as a GFM table (parseMarkdownTable failure)
* - the table's header doesn't match a known `TABLE_SCHEMAS.QuickTasks` variant
* (the old awk arithmetic silently skipped here instead — that silent-skip
* branch is the bug this replaces).
*
* The new row is inserted immediately after the LAST existing table row line
* (or immediately after the header/delimiter when the table has zero data
* rows), preserving any surrounding blank lines/trailing content in the section.
*/
function appendQuickTaskRow(stateContent, fields) {
const section = (0, markdown_sectionizer_cjs_1.collectSection)(stateContent, (h) => /^quick tasks completed$/i.test(h.text.trim()));
if (!section) {
return { ok: false, reason: 'no Quick Tasks Completed section' };
}
const parsed = parseMarkdownTable(section.body);
if (!parsed.ok) {
return { ok: false, reason: `quick-tasks table: ${parsed.reason}` };
}
const match = matchTableSchema(parsed.value.columns);
if (!match || match.id !== 'QuickTasks') {
return {
ok: false,
reason: `unrecognized Quick Tasks schema (columns: ${parsed.value.columns.join(' | ')})`,
};
}
const variant = exports.TABLE_SCHEMAS.QuickTasks.find((v) => v.label === match.label);
const columns = variant ? variant.columns : parsed.value.columns;
const rowNumber = parsed.value.rows.length + 1;
const cellFor = (col) => {
switch (col) {
case '#': return escapeCell(String(rowNumber));
case 'Description': return escapeCell(fields.description);
case 'Date': return escapeCell(fields.date);
case 'Commit': return escapeCell(fields.commit);
case 'Status': return escapeCell(fields.status ?? '—');
case 'Directory': return escapeCell(fields.directory ?? '—');
default: return '—';
}
};
const row = `| ${columns.map(cellFor).join(' | ')} |`;
// Detect the section's EOL BEFORE splitting on /\r?\n/ (which discards it) so
// the rejoin below preserves CRLF instead of downgrading a CRLF section to
// mixed EOL (the inserted `row` itself never contains a newline).
const eol = /\r\n/.test(section.body) ? '\r\n' : '\n';
const lines = section.body.split(/\r?\n/);
let lastTableLineIdx = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith('|'))
lastTableLineIdx = i;
}
// lastTableLineIdx is always >= 0 here — parseMarkdownTable already
// confirmed a header + delimiter row exist in this same `section.body`.
const newLines = [
...lines.slice(0, lastTableLineIdx + 1),
row,
...lines.slice(lastTableLineIdx + 1),
];
const newBody = newLines.join(eol);
const content = (0, markdown_sectionizer_cjs_1.replaceSection)(stateContent, section, newBody);
return { ok: true, value: { content, row, variant: match.label } };
}
// Consumers: require('../gsd-core/bin/lib/markdown-table.cjs')
// Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish).

View File

@@ -1,128 +0,0 @@
'use strict';
/**
* Runtime Artifact Install Plan Module.
*
* Turns a pre-resolved runtime artifact layout into staged copy inputs. The
* installer adapter still owns pruning, copying, migrations, output, and final
* cleanup execution.
*/
// In .cts (CommonJS output) files, `require` is available as a global.
const _require = require;
const path = _require('node:path');
/**
* Asserts that `destSubpath` resolves to a path inside `configDir`.
*
* Rejects any path that escapes the configDir root (e.g. "../../etc") and any
* path containing a NUL byte. This is a security gate for Phase B of
* ADR-1239: third-party descriptors must never be able to write outside the
* designated config home directory.
*
* @param configDir - The root config directory (e.g. ~/.claude).
* @param destSubpath - The relative path declared by the runtime descriptor.
* @returns The resolved absolute path under configDir.
* @throws {Error} if destSubpath escapes configDir or contains a NUL byte.
*/
function assertDestWithinConfigHome(configDir, destSubpath) {
if (destSubpath.includes('\0')) {
throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`);
}
const root = path.resolve(configDir);
const resolved = path.resolve(configDir, destSubpath);
if (resolved === root || !resolved.startsWith(root + path.sep)) {
throw new Error(`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`);
}
return resolved;
}
function errorMessage(err) {
if (err instanceof Error)
return err.message;
return String(err);
}
function addCleanupDir(cleanupDirs, stagedDir, rewrittenDir) {
const sourceDir = rewrittenDir ?? stagedDir;
if (sourceDir !== stagedDir)
cleanupDirs.push(sourceDir);
return sourceDir;
}
function createRuntimeArtifactInstallPlan(args) {
const { layout, resolvedProfile, homedir, platform, resolveAttribution, deps = {}, } = args;
const conversionExports = _require('./runtime-artifact-conversion.cjs');
const rewriteStagedSkillBodies = deps.rewriteStagedSkillBodies ?? conversionExports.rewriteStagedSkillBodies;
const rewriteStagedCommandBodies = deps.rewriteStagedCommandBodies ?? conversionExports.rewriteStagedCommandBodies;
const cleanupDirs = [];
const items = [];
const scope = layout.scope ?? 'global';
const rewriteOpts = {
runtime: layout.runtime,
configDir: layout.configDir,
scope,
homedir,
platform,
resolveAttribution,
};
// ADR-1235 §1: build agentCtx once per plan so agents kind entries can apply
// the CORRECT pre-converter cross-cutting (path rewrites → attribution → converter
// → normalize). This mirrors the exact per-file order in the inline agent loop
// in bin/install.js (lines 9330-9415). agentCtx is passed as the second arg
// to kind.stage() for agents kind entries with a converter (convertedAgentsKind).
// NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
const os = _require('node:os');
const { posixNormalize } = _require('./shell-command-projection.cjs');
const homedirFn = homedir ?? (() => os.homedir());
const resolvedTarget = posixNormalize(path.resolve(layout.configDir));
const homeDir = posixNormalize(homedirFn());
const isGlobal = scope === 'global';
const isOpencode = layout.runtime === 'opencode';
const isWindowsHost = (platform ?? process.platform) === 'win32';
const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined;
const agentCtx = { runtime: layout.runtime, pathPrefix, attribution };
for (const kind of layout.kinds) {
let stagedDir;
try {
if (kind.kind === 'agents') {
// ADR-1235 §1: pass agentCtx so stageAgentsForRuntimeWithConverter applies
// the full inline-loop order: pathRewrites → attribution → converter → normalize.
// The cross-cutting is now PRE-converter (inside staging), not POST.
stagedDir = kind.stage(resolvedProfile, agentCtx);
}
else {
stagedDir = kind.stage(resolvedProfile);
}
}
catch (err) {
return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
let sourceDir = stagedDir;
try {
if (kind.kind === 'commands') {
const rewrittenDir = rewriteStagedCommandBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
}
else if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
}
// agents kind: cross-cutting already applied INSIDE kind.stage() via agentCtx.
// No POST-step needed. sourceDir stays as stagedDir.
}
catch (err) {
return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
}
items.push({
kind: kind.kind,
sourceDir,
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
});
}
return { ok: true, plan: { items, cleanupDirs } };
}
function createRuntimeArtifactUninstallPlan(layout) {
return {
items: layout.kinds.map((kind) => ({
kind: kind.kind,
destDir: assertDestWithinConfigHome(kind.home ?? layout.configDir, kind.destSubpath),
})),
};
}
module.exports = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +0,0 @@
"use strict";
/**
* Write-Set — shared fail-loud parse `Result` and per-surface write-set
* contracts (ADR-2143, epic #2143). Pure, Node built-ins only, no I/O.
* Compiled by tsc to gsd-core/bin/lib/write-set.cjs.
*
* ADR-2143 §5 (fail-loud parsing, no null-swallow): seam parse operations
* and document-model accessors return a typed `Result<T>` — never a bare
* `null` a caller can mistake for "empty but fine." This is the same
* `{ ok: true; value: T } | { ok: false; reason: string }` shape
* `markdown-table.cts` already defined for `parseMarkdownTable` /
* `appendQuickTaskRow`; this module is now the single source of truth for
* it and `markdown-table.cjs` re-exports the type so existing importers of
* `Result` from that module keep working unchanged.
*
* NOTE: deliberately distinct from command-routing-hub's dispatch `Result`
* (`{ok,data}|{ok:false,kind}`) — the two never mix (different modules,
* different shapes, different purposes).
*
* ADR-2143 §6 (write-set results for multi-surface commands, no
* OR-into-one-flag): a command that mutates more than one surface returns
* an explicit per-surface write-set — `{ surface, applied }` outcomes — and
* its top-level "did this fully succeed" signal is true only if EVERY
* surface in the set applied. ORing independent surfaces into a single
* boolean is the direct anti-pattern that let a checkbox-only partial
* write (#2140) report full success.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.writeSetComplete = writeSetComplete;
/**
* True only if the write-set is non-empty AND every surface in it applied.
* An empty write-set is never "complete" — there is nothing to be complete
* about, so treating it as vacuously true would let a no-op masquerade as
* a full success (the same OR-into-one-flag class ADR-2143 §6 prohibits).
*/
function writeSetComplete(ws) {
return ws.length > 0 && ws.every((o) => o.applied);
}

View File

@@ -43,7 +43,12 @@ const REASON = Object.freeze({
});
function git(args) {
return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' });
// -c safe.directory=REPO_ROOT: containerized CI checkouts are frequently
// owned by a different uid than the one running the test process, and git
// refuses to operate at all on such a repo ("detected dubious ownership")
// unless explicitly trusted. Scoped per-invocation (not written to any
// config file) so this never widens trust beyond this one call.
return execFileSync('git', ['-c', `safe.directory=${REPO_ROOT}`, ...args], { cwd: REPO_ROOT, encoding: 'utf8' });
}
/**

View File

@@ -0,0 +1,185 @@
'use strict';
/**
* Regression test for #2657.
*
* Nine compiled `.cjs` artifacts under gsd-core/bin/lib/ were tracked in git
* despite each having a matching src/*.cts source, violating ADR-457's
* build-at-publish contract ("bin/lib/*.cjs" must be a gitignored build
* artifact, never checked-in source of truth). A tracked compiled artifact
* can silently drift from its source without anyone noticing — #2653
* demonstrated exactly this for api-coverage.cjs, which shipped four days
* behind its .cts with CI green throughout.
*
* This asserts the ADR-457 end state for all nine: none tracked, all
* gitignored, and the regime-agnostic sync guard (added in #2656,
* scripts/lint-compiled-artifact-sync.cjs) reports the empty tracked set.
*
* Two of the nine (markdown-table.cjs, write-set.cjs) already had a
* .gitignore pattern before this fix (added by #2248) but were never
* `git rm --cached`; the other seven had no .gitignore pattern at all. Both
* gaps produce the same `git ls-files` symptom, so both are covered by the
* same assertions here.
*
* ── Diagnostics discipline ────────────────────────────────────────────────
* Every git invocation below uses `spawnSync` (never throws) and every
* assertion explicitly checks the exit status BEFORE interpreting output.
* A git command that errors (bad cwd, dubious-ownership refusal, missing
* binary, anything) must never be silently read as a legitimate "not
* ignored" / "still tracked" answer — that conflates "the property does not
* hold" with "I could not determine whether the property holds", which is a
* distinct defect from the bug this file guards against. On any failure,
* the assertion message includes the resolved cwd, exit status, and stderr,
* so a red run is self-diagnosing without a second round-trip.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { trackedCompiledArtifacts } = require('../scripts/lint-compiled-artifact-sync.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const LIB_DIR = 'gsd-core/bin/lib';
const NINE_ARTIFACTS = [
'api-coverage.cjs',
'assumption-delta.cjs',
'claude-orchestration-command-router.cjs',
'claude-orchestration.cjs',
'external-job.cjs',
'markdown-table.cjs',
'runtime-artifact-install-plan.cjs',
'state-transition.cjs',
'write-set.cjs',
].map((name) => `${LIB_DIR}/${name}`);
/**
* Run a command via spawnSync (never throws) and return the raw result.
* Throws immediately, with full context, only on a genuine spawn failure
* (binary not found, etc.) — a condition no caller here can meaningfully
* interpret as a match/no-match answer.
*/
function run(cmd, args, opts) {
const result = spawnSync(cmd, args, { cwd: REPO_ROOT, encoding: 'utf8', ...opts });
if (result.error) {
throw new Error(
`${cmd} ${args.join(' ')} failed to spawn (cwd=${REPO_ROOT}): ${result.error.message}`,
);
}
return result;
}
/** Render a failed command's full context for an assertion message. */
function describeFailure(cmd, args, result) {
return (
`${cmd} ${args.join(' ')} (cwd=${REPO_ROOT}) exited ${result.status}` +
(result.signal ? ` (signal ${result.signal})` : '') +
`\n stderr: ${(result.stderr || '(empty)').trim()}` +
`\n stdout: ${(result.stdout || '(empty)').trim()}`
);
}
function git(args) {
// -c safe.directory=REPO_ROOT: containerized CI checkouts are frequently
// owned by a different uid than the one running node --test, and git
// refuses to operate at all on such a repo ("detected dubious ownership")
// unless explicitly trusted. Scoped per-invocation (not written to any
// config file), matching the same fix applied to
// scripts/lint-compiled-artifact-sync.cjs's own git() helper, which has
// the identical defect (#2657 diagnostic run: `git ls-files` there failed
// with the same "dubious ownership" fatal in the runner).
return run('git', ['-c', `safe.directory=${REPO_ROOT}`, ...args]);
}
/** `git ls-files <LIB_DIR>`, asserting success before trusting the output. */
function trackedLibFiles() {
const args = ['ls-files', LIB_DIR];
const result = git(args);
assert.equal(
result.status,
0,
`git ls-files must exit 0 before its output can be trusted as "nothing tracked":\n${describeFailure('git', args, result)}`,
);
return new Set(result.stdout.split('\n').filter(Boolean));
}
/**
* `git check-ignore -q <path>` has exactly two legitimate outcomes: exit 0
* (ignored) and exit 1 (not ignored) — check-ignore(1). Any other exit code
* or a signal is an infrastructure failure, not a "not ignored" answer, and
* must not be conflated with one.
*/
function isIgnored(artifactPath) {
const args = ['check-ignore', '-q', artifactPath];
const result = git(args);
if (result.status === 0) return true;
if (result.status === 1) return false;
throw new Error(
`git check-ignore for ${artifactPath} returned neither a match (0) nor a legitimate ` +
`no-match (1) exit code — this is an infrastructure failure, not evidence the path ` +
`is unignored:\n${describeFailure('git', args, result)}`,
);
}
// Shared shape for both "none of the nine should still be in state X" checks
// below: derive the still-bad subset via `isBad`, then assert it's empty.
function assertNoneStillBad(isBad, failureLabel) {
const stillBad = NINE_ARTIFACTS.filter(isBad);
assert.deepEqual(
stillBad,
[],
`expected none of the nine ${failureLabel}; still: ${stillBad.join(', ') || '(none)'}`,
);
}
describe('fix-2657: compiled .cjs artifacts are gitignored, not tracked (ADR-457)', () => {
test('none of the nine ADR-457 migration-gap artifacts are tracked by git', () => {
const tracked = trackedLibFiles();
assertNoneStillBad((p) => tracked.has(p), 'to be tracked');
});
test('every one of the nine paths is ignored per git', () => {
// Deliberately WITHOUT --no-index: git-check-ignore(1) operates on the
// pathname alone and does not require the file to exist on disk (true
// both with and without --no-index — this repo's gsd-test runner checks
// out a fresh shallow clone per sha, where an untracked, gitignored path
// exists as a pattern match only, never as a file on disk). Plain
// check-ignore is preferred here over --no-index specifically because it
// also honors git's "a still-TRACKED path is never reported ignored"
// rule (check-ignore(1)) — which is exactly the property under test: a
// path that still matches a .gitignore pattern while ALSO remaining
// tracked (the pre-fix state for two of the nine, whose pattern
// predates this fix per #2248) must still read as "not ignored," the
// same as the seven with no pattern at all. --no-index would blur that
// distinction by reporting the two as ignored regardless of tracking.
assertNoneStillBad((p) => !isIgnored(p), 'to be reported not-ignored by git');
});
test('trackedCompiledArtifacts() reports the ADR-457 empty-set end state for the nine', () => {
let pairs;
try {
pairs = trackedCompiledArtifacts();
} catch (err) {
// trackedCompiledArtifacts() (scripts/lint-compiled-artifact-sync.cjs)
// wraps its OWN internal `git ls-files gsd-core/bin/lib` call, with cwd
// resolved from that script's own __dirname (should equal REPO_ROOT
// here regardless of caller). A throw means THAT invocation failed —
// not that any artifact is still tracked. Surface it, don't mask it.
assert.fail(
`trackedCompiledArtifacts() threw instead of returning a result — this indicates its ` +
`internal git invocation failed, not that any of the nine is still tracked:\n` +
`${err && err.stack ? err.stack : err}`,
);
}
const stillPresentArtifacts = new Set(pairs.map((p) => p.artifact));
assertNoneStillBad((p) => stillPresentArtifacts.has(p), 'to appear in trackedCompiledArtifacts()');
});
test('lint-compiled-artifact-sync exits 0 with nothing left to check', () => {
const args = [path.join(REPO_ROOT, 'scripts', 'lint-compiled-artifact-sync.cjs')];
const result = run(process.execPath, args);
assert.equal(result.status, 0, describeFailure(process.execPath, args, result));
});
});