Files
msd-core/scripts/gen-capability-registry.cjs
Tom Boucher 0624c5da6f chore(#3212): src/text-lines.cts is the sole owner of line-terminator handling — Phase 2 (#3420)
* test(#3413): failing-first suite for the line-terminator seam

Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Tests only — src/text-lines.cts
does not exist yet, so tests/text-lines.test.cjs fails with MODULE_NOT_FOUND
at its require line, which is the intended RED.

The frontmatter.test.cjs additions drive #3360 (confirmed-bug) fail-first:
parseMustHavesBlock currently returns [] for every must_haves block on a
CRLF-authored plan file, because \r is its own LineTerminator in ECMAScript
and two /m-anchored \s* patterns can absorb it, inflating a captured indent
by one character and tripping the "not nested under must_haves" guard.
Verified locally against the current (unfixed) compiled module: both the
direct repro and the silent-exit "blank line before must_haves:" variant
return [] today. A parity property test (crlf vs lf must deep-equal for
every block name) matches a pattern this maintainer has required repeatedly
for prior CRLF fixes in this codebase (Cortex-recorded, verify_intent=held).

The no-crlf-fragile-split.rule.test.cjs additions lock the eslint rule's
future fix-hint text (pointing at splitLines()) and its self-reference
non-violation (the seam's own correct \r?\n split must never flag itself).

Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md
Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md

* chore(#3413): src/text-lines.cts owns line-terminator handling

Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Adds splitLines/normalizeEol/
detectEol/joinLines and migrates frontmatter.cts onto it.

parseMustHavesBlock (#3360, confirmed-bug) returned [] for every
must_haves block on a CRLF plan file. Root cause: \r is its own
LineTerminator in ECMAScript, so under /m two \s*-anchored indentation
lookups could match at the position INSIDE a \r\n pair and absorb the
terminator, inflating the captured indent by one character and tripping
the "not nested under must_haves" guard. Two silent exits, one with a
diagnostic and one without (a blank line before must_haves: hits the
silent path). Fixed by converting both lookups from a whole-string /m
match to split-then-scan — splitLines first, then a per-line, non-/m
match — the same structural pattern parseYamlRegion (30 lines away in
the same file) already used safely. Nothing downstream of the two
lookups changed; blockLines is now sliced from the already-split array
instead of re-splitting a substring, but its contents are unchanged for
LF input, and the per-line dash/kv parsing loop is untouched.

A parity property test (CRLF and LF plans parse to identical must_haves
for every block name) matches a pattern this maintainer has required
repeatedly for prior CRLF fixes in this file's neighborhood (Cortex:
7 recorded decisions, verify_intent -> held).

frontmatter.cts's other .split(/\r?\n/) call sites (parseYamlRegion,
isFrontmatterShaped, sliceTopLevelFrontmatterSegments, spliceFrontmatter)
are rerouted onto splitLines — a literal 1:1 substitution, zero behavior
change, since splitLines IS that same regex plus a type guard.

The 4 scripts/normalizeLineEndings copies (gen-registry, gen-loop-host-
contract, gen-capability-registry, gen-context-index) are deleted and
rerouted onto normalizeEol, which strips a bare unpaired \r exactly like
the deleted copies did (not just \r\n pairs) -- verified against each
script's own --check mode against its real generated output.

local/no-crlf-fragile-split widens from tests/ to src/**/*.cts, with its
fix-hint message now naming splitLines() instead of the raw regex --
the prohibition finally has a primitive to point at. Detection logic
unchanged in this phase (deliberate scope limit, see design doc Known
limits: the rule doesn't yet recognize safeReadFile/platformReadSync as
a content source, and has no detector for the \s-adjacent-to-anchor
shape that is #3360's actual mechanism -- the CLASS is converged by the
direct fix + regression test regardless).

joinLines/detectEol are NOT wired into frontmatter.cts's own write path
(cmdFrontmatterSet/Merge -> platformWriteSync) -- verified that
platformWriteSync already, unconditionally converts CRLF->LF on every
.md write today as a pre-existing policy owned by a different module,
and ADR-3212's backward-compatibility clause rules out a file-format
change in any phase. Stated explicitly in Known limits rather than left
for a reader to discover.

Six-gate ripple: .gitignore, eslint.config.mjs (src/**/*.cts block),
docs/INVENTORY.md + INVENTORY-MANIFEST.json (regenerated), CONTEXT.md
glossary (Text Lines Module, mirroring Phase 1's Pattern Module entry).

Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md
Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md

* fix(#3413): fix 13 pre-existing CRLF-fragile splits the widened rule found

Widening local/no-crlf-fragile-split from tests/ to src/**/*.cts (the
previous commit) immediately surfaced 13 real, pre-existing violations
across 10 files -- undetected until now because the rule never scanned
src/. This is the exact defect class ADR-3212 exists to close, playing
out again one phase after Phase 1 hit the same shape ("the new lint
rule -- once live -- found 27 more"). Per CLAUDE.md's no-defer rule,
fixed inline rather than deferred or suppressed; there is no
established suppression convention for this rule in src/ and inventing
one now would undermine the point of widening it.

audit.cts, broken-windows.cts, core-utils.cts, init.cts, milestone.cts,
phase.cts (x3), profile-output.cts, roadmap.cts (x2): bare-\n splits or
regex character classes widened to \r?\n / [^\r\n], each following the
same pattern already established migrating frontmatter.cts.

phase-estimation.cts: `\r?(?:\n|$)` restructured to `(?:\r?\n|\r?$)` --
already semantically CRLF-safe, but the rule's lexical scanner doesn't
recognize \r? guarding a group (only \r? immediately before a literal
\n). Verified the two forms are equivalent across all four EOL/EOF
cases before restructuring, not assumed.

roadmap-upgrade.cts needed two coupled sites, not the one flagged line:
computeMigrationPlan and applyMigration must agree on line
representation for the lines[edit.lineIndex] === edit.from equality
check to hold, and the write-back needed joinLines + detectEol -- a
plain lines.join('\n') was silently flattening a CRLF ROADMAP.md to LF
wholesale on every migration. This is the first real production
consumer of joinLines/detectEol in this epic (frontmatter.cts's own
write path doesn't use them -- see the previous commit's Known limits).

Fixing the 13 flagged sites surfaced 4 more adjacent same-shape sites
the rule doesn't track (.search() and new RegExp(dynamicString) aren't
in its tracked call/construction set). Investigated each empirically --
hand-tracing this exact bug class already produced one wrong conclusion
earlier in this phase (a detectEol design-doc arithmetic error), so
these were verified with real CRLF fixtures rather than reasoned about
on paper:

  - audit.cts (scanTodos): REAL bug, fixed. `bodyMatch.trim().split
    ('\n')[0]` leaked a trailing \r into a user-visible todo summary on
    CRLF input -- .trim() only strips the string's outer edges, not a
    \r sitting mid-string before the first bare \n. Now splitLines(...)
    [0].
  - phase.cts (cmdPhaseInsert, bullet-style branch): REAL bug, fixed.
    [^\n]* in targetBulletPattern swallowed a line's trailing \r on
    CRLF input, shifting the computed insert position to land INSIDE
    the \r\n pair; combined with a hardcoded '\n' bullet separator, a
    CRLF ROADMAP.md ended up with a mixed CRLF/LF result after an
    insert. Fixed with two coupled changes (either alone still
    corrupts, verified both ways): [^\r\n]* in the pattern, and the new
    bullet's leading terminator now comes from detectEol(rawContent).
  - roadmap.cts (cmdRoadmapAnnotateDependencies phase-boundary scan):
    investigated, genuinely safe, left untouched. The .search(/\n#{2,4}
    .../) boundary-finder and the [^\n]*-based heading match were
    empirically verified on a 3-phase CRLF fixture -- the only stray \r
    ends up at the tail of an intermediate phaseSection string that is
    only ever used for .test()-based idempotency checks, never for an
    exact-match comparison or written back to disk. No corruption on
    round-trip.

Every fix re-verified: npm run build:lib clean, npx eslint
'src/**/*.cts' --no-cache reports 0 problems (was 13), and each
fixed function's existing LF-input tests were spot-checked unchanged.

* fix(#3413): apply orthogonal review findings

Two isolated review engines (correctness + security) ran against the
full diff and found three majors, one real security issue, and several
disclosure-worthy minors. All fixed or explicitly disclosed with
evidence; nothing deferred.

MAJOR — detectEol's tie-break contradicted its own documented contract.
Code returned '\n' on a 1:1 crlf/bare-LF tie; every doc (design doc,
CONTEXT.md, the function's own comment) says ties resolve to '\r\n'.
The existing test masked this by reusing the same tie fixture the
buggy code happened to satisfy, rather than a genuine LF-majority
case. Root cause: an Edit attempted earlier in this phase to fix this
exact arithmetic error was blocked by the tier guard, and a later
dispatch was incorrectly told it had already landed. Fixed: condition
is now crlfCount >= bareLfCount; the test fixture corrected to a
genuine 2:1 majority, with a new explicit tie-case test.

MAJOR — phase.cts's cmdPhaseInsert built an EOL-aware bulletEntry via
detectEol(rawContent), justified by a comment claiming a hardcoded
'\n' corrupts a CRLF ROADMAP.md. False: this write goes through
platformWriteSync, whose normalizeContent/_normalizeMd unconditionally
converts CRLF->LF for any .md target — the templating was inert dead
code, erased before the file is ever written. Reverted to hardcoded
'\n', comment corrected to state the true reasoning. The separate
[^\n]* -> [^\r\n]* widening one function up (a real splice-position
fix, independent of final EOL) was kept.

MAJOR — roadmap-upgrade.cts's stated rationale for switching onto
splitLines/joinLines was wrong (both functions always agreed on line
representation, before and after — the claimed equality-check risk
never existed), and the change it justified introduced a real
regression: forcing every line onto one dominant terminator silently
rewrites untouched lines' EOL on a mixed-CRLF/LF ROADMAP.md. This
write path uses raw fs.writeFileSync, not platformWriteSync, so unlike
the phase.cts case above the regression is genuinely live.

Fixing this took two attempts. The first attempt (revert to
split('\n')/join('\n') plus a suppression comment) was correctly
blocked by an agent that discovered local/no-crlf-fragile-split is a
PROTECTED_RULES entry in tests/portability-rule-disable-ban.test.cjs —
a hard, out-of-band, ADR-1703-governed guardrail banning any
eslint-disable of this rule anywhere in src/**/*.cts. That agent also
detected and correctly disregarded an injected instruction that
appeared in tool output during a git operation, per this session's
untrusted-content policy. The actual fix: computeMigrationPlan
reverted to roadmapContent.split('\n') (confirmed lint-clean — the
rule's data-flow tracking only follows a variable's initializer, and
this one is declared empty then reassigned in a try block).
applyMigration's write-back now splices edits against the ORIGINAL
content string via indexOf('\n', pos) boundary-walking instead of a
full split/rejoin, so every untouched character — including every
line's own terminator — is copied byte-for-byte. A capture-group split
(/(\r\n|\n)/, preserving terminators inline) was tried first and
empirically confirmed to still trip the rule before this approach was
chosen instead.

MINOR (security) — roadmap.cts's cmdRoadmapAnnotateDependencies used
the STRING form of String#replace, so $&, $`, $', $1-$9 inside
must_haves.truths content (author-controlled) were interpreted as
replacement directives, splicing unrelated ROADMAP.md text into the
result. Fixed with the function-replacement form, which is never
pattern-interpreted. Verified before/after with the reviewer's exact
repro.

Also disclosed rather than silently left: test matrix row 31 (four
planned CRLF-materialized regression tests) was never implemented as
separate files — corrected to record the actual verification (a
manual --check run plus incidental existing coverage via each script's
normalizeLineEndings: normalizeEol alias). parseMustHavesBlock's LF
behavior was claimed byte-for-byte unchanged but the old
yaml.indexOf(blockMatch[0]) substring search could match an unrelated
earlier occurrence of the header text (e.g. inside a quoted value) —
the split-then-scan fix incidentally also closes this, a strict
improvement now recorded in the design doc rather than left implicit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3413): checkpoint 2 red — missing eslint ignore entry, RuleTester config error

Checkpoint 2 came back red with 5 failures on the reviewed sha, both
gaps genuinely undetectable by any local gate.

eslint.config.mjs was missing the 'gsd-core/bin/lib/text-lines.cjs'
ignores-list entry (ADR-457: generated .cjs artifacts are excluded from
direct type-aware linting). Phase 1's sibling entry (pattern.cjs) sits
two lines above it and was the exact precedent read while researching
the six-gate ripple for this module -- missed anyway. Caught by
tests/repo-invariants.test.cjs's bin/lib coverage-tracking test, which
only runs on the remote suite.

tests/no-crlf-fragile-split.rule.test.cjs's row-32 case specified both
`messageId` and `message` on the same RuleTester error assertion --
ESLint's RuleTester rejects that combination outright. This existed
since the test was first authored and was never caught locally: `npx
eslint` only lints the file's syntax, it does not execute RuleTester,
and local `node --test` is hard-blocked in this repo -- the assertion
had never actually RUN before this checkpoint. It was even present in
checkpoint 1's failure list, listed there as one of the "expected RED"
tests; I matched it against my expected-failures list by test NAME
only and never inspected the actual failure detail closely enough to
notice it was failing for the wrong reason (a RuleTester config error,
not the intended message-text mismatch). Fixed by keeping `message`
(the exact-text assertion the test exists to make) and dropping
`messageId`. Verified the crlfFragileSplit message string in
eslint-rules/no-crlf-fragile-split.cjs matches this assertion
character-for-character, and swept every other invalid case in the
file for the same double-specification bug (none found -- all
pre-existing cases use messageId alone).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#3413): add Fixed changeset for the #3360 CRLF parsing fix

The sole user-visible effect of this phase. No breaking-change label
or Changed fragment needed — ADR-3212's Backward Compatibility section
names the Node floor (Phase 1, already shipped) as the epic's only
breaking change; Phase 2 has none.

* chore(#3413): backfill changeset pr number to 3420

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:27:48 -04:00

973 lines
41 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* gen-capability-registry.cjs — generates gsd-core/bin/lib/capability-registry.cjs
* from every capabilities/<id>/capability.json declaration.
*
* Usage:
* node scripts/gen-capability-registry.cjs # print to stdout
* node scripts/gen-capability-registry.cjs --write # write capability-registry.cjs
* node scripts/gen-capability-registry.cjs --check # exit 1 if committed registry is stale
*
* ADR-894 phase 3a-impl. Validates each capability against the schema, enforces
* cross-capability invariants, materializes hook ordering, and emits a role-
* partitioned CommonJS registry module.
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const { normalizeEol } = require('../gsd-core/bin/lib/text-lines.cjs');
const ROOT = path.resolve(__dirname, '..');
const CAPABILITIES_DIR = path.join(ROOT, 'capabilities');
const REGISTRY_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs');
const CONFIG_SCHEMA_PATH = path.join(ROOT, 'gsd-core', 'bin', 'shared', 'config-schema.manifest.json');
// ─── Loop Host Contract ───────────────────────────────────────────────────────
//
// Generated from workflow markers by scripts/gen-loop-host-contract.cjs (ADR-894 §3).
// Require the committed gsd-core/bin/lib/loop-host-contract.cjs artifact so the
// registry generator and the loop-host-contract generator share one source of truth.
const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs');
// Wired-points helper — tells us which points actually have render-hooks call sites.
const { getWiredLoopPoints } = require('./gen-loop-host-contract.cjs');
// Capability validator — shared runtime-callable module extracted per ADR-1244 D2.
const capValidator = require('../gsd-core/bin/lib/capability-validator.cjs');
// Destructure only what the generator's own function bodies reference directly.
// Everything else is re-exported from capValidator in module.exports below.
const {
POINT_ORDER,
HOST_ARTIFACT_EARLIEST_POINT_IDX,
VALID_LOOP_POINTS,
POINT_TO_CONTRACT,
VALID_CONFIG_SLICE_TYPES,
VALID_TIERS,
SEMVER_RE,
SEMVER_RANGE_RE,
SHA512_INTEGRITY_RE,
VALID_CONVERTER_NAMES,
VALID_CONFIG_HOME_KINDS,
VALID_COMMAND_STYLES,
VALID_HOOKS_SURFACES,
VALID_HOOK_EVENTS,
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,
VALID_INSTALL_SURFACES,
VALID_PERMISSION_WRITERS,
VALID_EXTENDED_HOOK_EVENTS,
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES,
INSTALL_SURFACE_TO_CONFIG_FORMAT,
SCHEMA_VERSION,
validateVersionEnvelope,
validateCapability,
validateCommandEntry,
validateRuntimeCompat,
validateConfigHome,
validateArtifactKindEntry,
validateArtifactLayout,
validateRuntimeBody,
collectReviewerWarnings,
materializeHookFragments,
validateAgainstContract,
validateConsumesGlobal,
validateCrossCapability,
computeRequiresClosure,
topoSortSteps,
topoSortContributions,
validateHooksWired,
validateConfigSliceEntry,
classifyCrossErrors,
runConfigFormatParityGate,
} = capValidator;
// ─── Central config-schema loader ────────────────────────────────────────────
/**
* Loads the set of keys from the central config-schema manifest.
* Returns a Set<string>. Used for collision detection.
*
* Contract:
* - ENOENT (file not found): returns empty Set silently — legitimate absent case.
* - Any other read error OR JSON parse error: writes a prominent warning to stderr
* naming the schema path and the underlying error, then throws ExitError(1).
* A parse error clearly states the schema is broken (not merely absent).
*
* @param {string} [schemaPath] Path to the config-schema manifest. Defaults to
* CONFIG_SCHEMA_PATH (the real production path).
* Overridable for unit testing with fixture paths.
* @returns {Set<string>}
*/
function loadCentralConfigKeys(schemaPath = CONFIG_SCHEMA_PATH) {
let raw;
try {
raw = fs.readFileSync(schemaPath, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') {
return new Set();
}
process.stderr.write(
' ERROR Failed to read config-schema manifest at ' + schemaPath + ': ' + err.message + '\n',
);
throw new ExitError(1, 'could not read config-schema manifest');
}
let manifest;
try {
manifest = JSON.parse(raw);
} catch (err) {
process.stderr.write(
' ERROR Config-schema manifest at ' + schemaPath + ' is broken (JSON parse error): ' + err.message + '\n',
);
throw new ExitError(1, 'config-schema manifest JSON is malformed');
}
return new Set(Array.isArray(manifest.validKeys) ? manifest.validKeys : []);
}
/**
* Loads the central config-schema's DYNAMIC key patterns (#2797).
*
* `loadCentralConfigKeys` above reads `validKeys` only, so a federated key
* claimed by a central *pattern* was invisible to the exclusivity check. That is
* not a cosmetic gap: `isCentralConfigKey` consults these same patterns, and
* `mergeFederatedConfig` skips every key for which it returns true — so an
* overlapping slice is inert while the build stays green.
*
* The patterns are read from the SAME manifest the runtime reads and compiled
* with the same `source`, rather than re-implementing a matcher here, so the two
* cannot drift.
*
* Failure contract MATCHES `loadCentralConfigKeys` above deliberately — the two
* read the same file and must not disagree about what a broken one means:
* - ENOENT → empty list. Legitimately absent.
* - Any other read error, or a JSON parse error → prominent stderr + throw.
*
* An earlier revision swallowed the parse error and returned []. That is
* fail-OPEN on the gate this function exists to feed: with zero patterns,
* `validateCrossCapability`'s pattern-collision check silently passes and an
* inert federated slice ships green. It was masked in the one production call
* site only because `loadCentralConfigKeys` runs first against the same path and
* throws — a coincidence of ordering, not a guarantee, and this function is
* exported and called standalone.
*
* A single unparseable PATTERN is still skipped rather than fatal: that is a
* per-entry defect the central schema's own tests own, and skipping one pattern
* degrades to "checked less" rather than blocking every build.
*/
function loadCentralConfigPatterns(schemaPath = CONFIG_SCHEMA_PATH) {
let raw;
try {
raw = fs.readFileSync(schemaPath, 'utf8');
} catch (err) {
if (err && err.code === 'ENOENT') return [];
process.stderr.write(
' ERROR Failed to read config-schema manifest at ' + schemaPath + ': ' + err.message + '\n',
);
throw new ExitError(1, 'could not read config-schema manifest');
}
let manifest;
try {
manifest = JSON.parse(raw);
} catch (err) {
process.stderr.write(
' ERROR Config-schema manifest at ' + schemaPath + ' is broken (JSON parse error): ' + err.message + '\n',
);
throw new ExitError(1, 'config-schema manifest JSON is malformed');
}
const declared = Array.isArray(manifest.dynamicKeyPatterns) ? manifest.dynamicKeyPatterns : [];
const out = [];
for (const entry of declared) {
const src = entry && typeof entry.source === 'string' ? entry.source : null;
if (!src) continue;
try {
out.push(new RegExp(src));
} catch {
// Unparseable pattern — the central schema's own tests own that failure.
}
}
return out;
}
// ─── ADR-857 Phase 4a: Derived views ─────────────────────────────────────────
// (Config-slice validation, per-capability validators, contract validators,
// cross-capability validators, topo-sort helpers, and classifyCrossErrors have
// been moved to gsd-core/bin/lib/capability-validator.cjs per ADR-1244 D2.)
const INSTALL_PROFILES_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs');
const CLUSTERS_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'clusters.cjs');
let _installProfilesMod = null;
let _clustersMod = null;
function getInstallProfiles() {
if (!_installProfilesMod) _installProfilesMod = require(INSTALL_PROFILES_PATH);
return _installProfilesMod;
}
function getClusters() {
if (!_clustersMod) _clustersMod = require(CLUSTERS_PATH);
return _clustersMod;
}
/**
* Derive capabilityClusters: { <capId>: [<skill stems>] }
* Each capability's own skills array, sorted for determinism.
*
* FIX 3: scope rule = "capabilities that own skills" (non-empty skills array).
* Both capabilityClusters and profileMembership use this same predicate so a
* future non-feature role carrying skills is treated identically in both, and a
* feature cap with no skills appears in neither.
*
* @param {Map<string, object>} capMap
* @returns {object} Object.create(null) — prototype-pollution safe
*/
function deriveCapabilityClusters(capMap) {
const result = Object.create(null);
for (const [capId, cap] of capMap) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
// FIX 3: include any cap that owns skills (non-empty skills array), regardless of role
if (!Array.isArray(cap.skills) || cap.skills.length === 0) continue;
// Sort for determinism
const sorted = [...cap.skills].sort();
result[capId] = sorted;
}
return result;
}
/**
* Derive profileMembership: { <capId>: { tier: <t>, profiles: [<names>] } }
* profiles = suffix of PROFILE_RANK starting at the capability's tier index.
* tier 'core' → ['core', 'standard', 'full']
* tier 'standard' → ['standard', 'full']
* tier 'full' → ['full']
*
* FIX 3: scope rule = "capabilities that own skills" (non-empty skills array),
* consistent with deriveCapabilityClusters. Both derived views cover the same set.
*
* FIX 5: tierIdx === -1 means VALID_TIERS and PROFILE_RANK have drifted; throw
* loudly instead of silently producing ['full'] for the affected capability.
*
* @param {Map<string, object>} capMap
* @returns {object} Object.create(null) — prototype-pollution safe
*/
function deriveProfileMembership(capMap) {
const { PROFILE_RANK } = getInstallProfiles();
const result = Object.create(null);
for (const [capId, cap] of capMap) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
if (!VALID_TIERS.has(cap.tier)) continue;
// FIX 3: consistent scope — only capabilities that own skills (non-empty skills array)
if (!Array.isArray(cap.skills) || cap.skills.length === 0) continue;
const tierIdx = PROFILE_RANK.indexOf(cap.tier);
// FIX 5: throw loudly on VALID_TIERS/PROFILE_RANK drift (was silent continue)
if (tierIdx === -1) {
throw new Error(
'deriveProfileMembership: capability "' + capId + '" tier "' + cap.tier +
'" is in VALID_TIERS but not in PROFILE_RANK — VALID_TIERS/PROFILE_RANK drift detected',
);
}
const profiles = PROFILE_RANK.slice(tierIdx);
result[capId] = { tier: cap.tier, profiles: [...profiles] };
}
return result;
}
/**
* Run consistency gates:
* - HARD: for each capId that matches a CLUSTERS key, derived skills must match
* the hand-authored CLUSTERS[capId] set (order-insensitive). Throws on mismatch.
* - SOFT: for each capability, for each skill not yet in all non-full profiles it
* belongs to (closure-resolved), emit ONE pending-reconciliation warning listing
* the missing profiles together. Warnings are collected and returned — NOT thrown.
*
* FIX 1: load the REAL skills manifest (same as bin/install.js) so resolveProfile
* expands requires:-closure. Loaded once and reused across all capabilities.
*
* FIX 3: iterate capabilityClusters (which already covers "capabilities that own
* skills") rather than profileMembership, so both derived views share one scope.
*
* FIX 4: one warning per (capability, skill) gap, listing all missing non-full
* profiles together, instead of one warning per (capability, skill, profile).
*
* @param {object} capabilityClusters From deriveCapabilityClusters()
* @param {object} profileMembership From deriveProfileMembership()
* @param {Map<string, object>} capMap Original capMap for skill lists
* @returns {string[]} Array of pending-reconciliation warning strings
*/
function runConsistencyGate(capabilityClusters, profileMembership, capMap) {
const { CLUSTERS: clustersObj } = getClusters();
const { resolveProfile, loadSkillsManifest } = getInstallProfiles();
// ── HARD gate: cluster set comparison ──────────────────────────────────────
for (const capId of Object.keys(capabilityClusters)) {
// S2b: inline literal guard (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
// Only check if a CLUSTERS entry with the same name exists
if (!Object.prototype.hasOwnProperty.call(clustersObj, capId)) continue;
const derivedSet = new Set(capabilityClusters[capId]);
const handAuthored = clustersObj[capId];
const handAuthoredSet = new Set(handAuthored);
// Compare sets (order-insensitive)
let mismatch = derivedSet.size !== handAuthoredSet.size;
if (!mismatch) {
for (const s of derivedSet) {
if (!handAuthoredSet.has(s)) { mismatch = true; break; }
}
}
if (mismatch) {
throw new Error(
'capability-cluster consistency gate FAILED for capId "' + capId + '":\n' +
' derived set: [' + [...derivedSet].sort().join(', ') + ']\n' +
' hand-authored set: [' + [...handAuthoredSet].sort().join(', ') + ']\n' +
'The capability\'s skills array must match the hand-authored CLUSTERS["' + capId + '"] at cutover.',
);
}
}
// ── SOFT gate: profile reconciliation warnings ─────────────────────────────
// FIX 1: load the REAL skills manifest once (same path as bin/install.js uses),
// so resolveProfile expands requires:-closure and the effective set is accurate.
const commandsGsdDir = path.join(ROOT, 'commands', 'gsd');
const skillsManifest = loadSkillsManifest(commandsGsdDir);
// FIX 1: resolve each profile's effective set once and cache — don't reload per-capability.
const profileEffectiveSetCache = Object.create(null);
function getEffectiveSet(profileName) {
if (profileName in profileEffectiveSetCache) return profileEffectiveSetCache[profileName];
const resolved = resolveProfile({ modes: [profileName], manifest: skillsManifest });
const effectiveSet = resolved.skills === '*' ? null : resolved.skills;
profileEffectiveSetCache[profileName] = effectiveSet;
return effectiveSet;
}
const warnings = [];
// FIX 3: iterate capabilityClusters (same set as profileMembership after FIX 3 scoping).
for (const capId of Object.keys(capabilityClusters)) {
// S2b: inline literal guard (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
const membership = profileMembership[capId];
if (!membership) continue; // no profile membership (e.g. cap has skills but invalid tier)
const cap = capMap.get(capId);
if (!cap || !Array.isArray(cap.skills)) continue;
// Collect the non-full profiles for this capability
const nonFullProfiles = membership.profiles.filter((p) => p !== 'full');
// FIX 4: one warning per (capability, skill) gap — list all missing profiles together
for (const skill of cap.skills) {
// S2b: inline literal guard (CodeQL barrier)
if (skill === '__proto__' || skill === 'constructor' || skill === 'prototype') continue;
const missingProfiles = [];
for (const profileName of nonFullProfiles) {
const effectiveSet = getEffectiveSet(profileName);
if (effectiveSet === null) continue; // profile resolved to full (unexpected but safe)
if (!effectiveSet.has(skill)) {
missingProfiles.push(profileName);
}
}
if (missingProfiles.length > 0) {
warnings.push(
'⚠ pending-reconciliation: capability \'' + capId + '\' (tier ' + membership.tier + ')' +
' skill \'' + skill + '\' not yet in hand-authored profile(s): <' + missingProfiles.join(', ') +
'>; add at cutover',
);
}
}
}
return warnings;
}
/**
* Read + validate all capabilities/<id>/capability.json files.
* Returns { capMap, errors } where capMap is Map<id, cap>.
*
* @param {Set<string>} [centralKeys] Keys in central config-schema for collision detection.
* If omitted, reads from disk. Pass new Set() to skip central-collision checks
* (used during 3a-impl while migration is in-progress).
* @param {string} [capabilitiesDir] Override capabilities dir (for testing with fixtures).
*/
function loadAndValidate(centralKeys, capabilitiesDir, centralPatterns) {
const resolvedCentralKeys = centralKeys !== undefined ? centralKeys : loadCentralConfigKeys();
// #2797: patterns default to the real manifest unless a caller passes its own
// (tests pass [] to isolate the exact-key path).
const resolvedCentralPatterns = centralPatterns !== undefined ? centralPatterns : loadCentralConfigPatterns();
const resolvedCapDir = capabilitiesDir !== undefined ? capabilitiesDir : CAPABILITIES_DIR;
const errors = [];
const capMap = new Map();
// ADR-2782 D4 — non-fatal diagnostics (e.g. an unknown field inside a reviewer
// body). These NEVER fail the build; they surface on stderr so a forward-built
// manifest degrades visibly instead of silently.
const warnings = [];
if (!fs.existsSync(resolvedCapDir)) {
return { capMap, errors, warnings };
}
// Compute wired points ONCE before iterating capabilities so the filesystem
// scan is not repeated per-capability. ROOT is the repo root (defined at top of file).
const wiredSet = getWiredLoopPoints(ROOT);
const folderEntries = fs.readdirSync(resolvedCapDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
for (const folderId of folderEntries) {
const capPath = path.join(resolvedCapDir, folderId, 'capability.json');
if (!fs.existsSync(capPath)) continue;
let cap;
try {
cap = JSON.parse(fs.readFileSync(capPath, 'utf8'));
} catch (err) {
errors.push(folderId + '/capability.json: JSON parse error: ' + String(err.message));
continue;
}
// Collected BEFORE the error short-circuit below so a manifest that is both
// forward-built and invalid still reports why it looked unfamiliar.
for (const w of collectReviewerWarnings(cap)) warnings.push(folderId + '/capability.json: ' + w);
const capErrors = validateCapability(cap, folderId);
if (capErrors.length > 0) {
for (const e of capErrors) errors.push(folderId + '/capability.json: ' + e);
continue; // skip cross-validation if basic schema fails
}
const contractErrors = validateAgainstContract(cap, cap.id);
if (contractErrors.length > 0) {
for (const e of contractErrors) errors.push(folderId + '/capability.json: ' + e);
// Fix #6: do NOT add contract-invalid caps to capMap — validateCrossCapability should
// only see fully-valid capabilities so its invariants are meaningful.
continue;
}
// Gen-time wired guard: reject hooks that declare a valid point with no call site.
const wiredErrors = validateHooksWired(cap, wiredSet);
if (wiredErrors.length > 0) {
for (const e of wiredErrors) errors.push(folderId + '/capability.json: ' + e);
continue;
}
const fragmentErrors = materializeHookFragments(cap, path.dirname(capPath));
if (fragmentErrors.length > 0) {
for (const e of fragmentErrors) errors.push(folderId + '/capability.json: ' + e);
continue;
}
capMap.set(cap.id, cap);
}
// Cross-capability invariants — capMap contains only fully-valid capabilities at this point.
const crossErrors = validateCrossCapability(capMap, resolvedCentralKeys, resolvedCentralPatterns);
errors.push(...crossErrors);
// C2: Global consumes-satisfiability — runs after capMap is fully built so cross-capability
// produces are visible. A capability with consumes errors is kept in capMap (it passed per-cap
// validation) but the errors are surfaced so the build fails.
const consumesErrors = validateConsumesGlobal(capMap);
errors.push(...consumesErrors);
return { capMap, errors, warnings };
}
/**
* Build the registry object from a validated capMap.
*
* @param {Map<string, object>} capMap
*/
function buildRegistry(capMap) {
// S2b: Use Object.create(null) for all accumulator maps so prototype-pollution
// can't touch Object.prototype even if a reserved name slips through validation.
const capabilities = Object.create(null);
const bySkill = Object.create(null);
const byAgent = Object.create(null);
const byLoopPoint = Object.create(null);
const configKeys = Object.create(null);
const configSchema = Object.create(null);
const runtimes = Object.create(null);
// Initialize byLoopPoint for all valid points
for (const point of VALID_LOOP_POINTS) {
byLoopPoint[point] = { steps: [], contributions: [], gates: [] };
}
// Phase 1: collect per-point entries grouped by point
const pointSteps = new Map(); // point → [{ capId, step }]
const pointContribs = new Map(); // point → [{ capId, contrib }]
const pointGates = new Map(); // point → [{ capId, gate }]
for (const point of VALID_LOOP_POINTS) {
pointSteps.set(point, []);
pointContribs.set(point, []);
pointGates.set(point, []);
}
for (const [capId, cap] of capMap) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
capabilities[capId] = cap;
// Federated config slice — harvested from ANY role that declares one.
//
// ADR-2782 D1/D9: this loop was nested inside the `role === 'feature'` branch,
// so a `role: "runtime"` capability's `config` was read by nothing and dropped
// in silence — the actual reason reviewer config keys are stranded in the
// central schema. (The often-cited reason, that the runtime body forbids
// feature-only fields, does not apply: `config` is NOT in
// FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME.) Owning a config slice is a property of
// DECLARING one, not of being a feature. Verified inert at introduction — no
// shipped capability declares `config` on a non-feature role — so this changes
// no existing key; it stops a latent silent drop and unblocks Phase 4 (#2797).
for (const key of Object.keys(cap.config || {})) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
configKeys[key] = capId;
// Build configSchema entry — validate the slice first (throw on violation)
const slice = (cap.config || {})[key];
const sliceErrors = validateConfigSliceEntry(capId, key, slice);
if (sliceErrors.length > 0) {
throw new Error(
'configSchema validation failed during registry build:\n' +
sliceErrors.map((e) => ' ' + e).join('\n'),
);
}
// S2b: inline literal guard for configSchema write site
if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
configSchema[key] = {
owner: capId,
type: slice.type,
default: slice.default,
description: slice.description,
};
// Preserve values array for enum types if present
if (slice.type === 'enum' && Array.isArray(slice.values)) {
configSchema[key].values = slice.values;
}
}
}
if (cap.role === 'feature') {
for (const skill of (cap.skills || [])) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (skill === '__proto__' || skill === 'constructor' || skill === 'prototype') continue;
bySkill[skill] = capId;
}
for (const agent of (cap.agents || [])) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (agent === '__proto__' || agent === 'constructor' || agent === 'prototype') continue;
byAgent[agent] = capId;
}
for (const step of (cap.steps || [])) {
if (VALID_LOOP_POINTS.has(step.point)) {
pointSteps.get(step.point).push({ capId, step });
}
}
for (const contrib of (cap.contributions || [])) {
if (VALID_LOOP_POINTS.has(contrib.point)) {
// Group contributions by into, then cap-id order
pointContribs.get(contrib.point).push({ capId, contrib });
}
}
for (const gate of (cap.gates || [])) {
if (VALID_LOOP_POINTS.has(gate.point)) {
pointGates.get(gate.point).push({ capId, gate });
}
}
} else if (cap.role === 'runtime') {
// S2b: inline literal guard at each write site (CodeQL barrier) — capId already guarded above
runtimes[capId] = cap;
}
}
// Phase 2: materialize ordering
for (const point of VALID_LOOP_POINTS) {
// Steps: topological sort by produces/consumes, cap-id tiebreak
const sortedSteps = topoSortSteps(pointSteps.get(point));
byLoopPoint[point].steps = sortedSteps.map((e) => ({
capId: e.capId,
...e.step,
}));
// Contributions: topological sort by produces/consumes, cap-id tiebreak
const sortedContribs = topoSortContributions(pointContribs.get(point));
byLoopPoint[point].contributions = sortedContribs.map((e) => ({
capId: e.capId,
...e.contrib,
}));
// Gates: as declared (stable by capId order)
const gates = pointGates.get(point);
gates.sort((a, b) => a.capId.localeCompare(b.capId));
byLoopPoint[point].gates = gates.map((e) => ({
capId: e.capId,
...e.gate,
}));
}
// ── ADR-959: commandFamilies index ─────────────────────────────────────────
// family → { capId, module, router }
// Built from all feature capabilities' commands arrays.
const commandFamilies = Object.create(null);
for (const [capId, cap] of capMap) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
if (cap.role !== 'feature' || !Array.isArray(cap.commands)) continue;
for (const cmd of cap.commands) {
if (typeof cmd.family !== 'string' || cmd.family.length === 0) continue;
// S2b: inline literal guard at family key write site (CodeQL barrier)
if (cmd.family === '__proto__' || cmd.family === 'constructor' || cmd.family === 'prototype') continue;
if (typeof cmd.module !== 'string' || cmd.module.length === 0) continue;
if (typeof cmd.router !== 'string' || cmd.router.length === 0) continue;
commandFamilies[cmd.family] = { capId, module: cmd.module, router: cmd.router };
}
}
// ── ADR-857 phase 4a: derived views ────────────────────────────────────────
const capabilityClusters = deriveCapabilityClusters(capMap);
const profileMembership = deriveProfileMembership(capMap);
// runConsistencyGate: hard gate throws on mismatch; returns soft warning strings.
// Warnings are returned in the registry object so callers can emit them to stderr
// without affecting the serialized file content (determinism gate stays clean).
const reconciliationWarnings = runConsistencyGate(capabilityClusters, profileMembership, capMap);
// ADR-857 phase 5e: configFormat ↔ installSurface parity gate.
// HARD gate — throws on mismatch; SOFT skip if adapter module not loadable.
runConfigFormatParityGate(capMap);
return {
version: SCHEMA_VERSION,
capabilities,
bySkill,
byAgent,
byLoopPoint,
configKeys,
configSchema,
runtimes,
commandFamilies,
capabilityClusters,
profileMembership,
// warnings are NOT serialized — returned only for caller consumption via stderr
_reconciliationWarnings: reconciliationWarnings,
};
}
// ─── Registry serialization ───────────────────────────────────────────────────
/**
* Serialize the registry to a CommonJS module string.
*
* @param {object} registry The registry object from buildRegistry()
* @param {Map<string, object>} capMap Used for requiresClosure()
*/
function serializeRegistry(registry, capMap) {
const lines = [];
lines.push("'use strict';");
lines.push('');
lines.push('/**');
lines.push(' * capability-registry.cjs — generated by scripts/gen-capability-registry.cjs');
lines.push(' * DO NOT EDIT BY HAND. Run: node scripts/gen-capability-registry.cjs --write');
lines.push(' * ADR-894 §5 — role-partitioned Capability Registry.');
lines.push(' */');
lines.push('');
// Serialize each section as a variable to keep the file readable
lines.push('const capabilities = ' + JSON.stringify(registry.capabilities, null, 2) + ';');
lines.push('');
lines.push('const bySkill = ' + JSON.stringify(registry.bySkill, null, 2) + ';');
lines.push('');
lines.push('const byAgent = ' + JSON.stringify(registry.byAgent, null, 2) + ';');
lines.push('');
lines.push('const byLoopPoint = ' + JSON.stringify(registry.byLoopPoint, null, 2) + ';');
lines.push('');
lines.push('const configKeys = ' + JSON.stringify(registry.configKeys, null, 2) + ';');
lines.push('');
lines.push('const configSchema = ' + JSON.stringify(registry.configSchema, null, 2) + ';');
lines.push('');
lines.push('const runtimes = ' + JSON.stringify(registry.runtimes, null, 2) + ';');
lines.push('');
// ADR-959: commandFamilies index — sort family keys for determinism.
const sortedCommandFamilies = Object.create(null);
const commandFamilyKeys = Object.keys(registry.commandFamilies || {}).sort();
for (const family of commandFamilyKeys) {
// S2b: inline literal guard at write site (CodeQL barrier)
if (family === '__proto__' || family === 'constructor' || family === 'prototype') continue;
sortedCommandFamilies[family] = registry.commandFamilies[family];
}
lines.push('const commandFamilies = ' + JSON.stringify(sortedCommandFamilies, null, 2) + ';');
lines.push('');
// ADR-857 phase 4a: derived views — globally sorted capIds for determinism.
// FIX 2: collect ALL capIds across both views and sort globally so feature + runtime
// capIds interleave correctly when both are present (phase 5 readiness).
const allClusterCapIds = new Set(Object.keys(registry.capabilityClusters));
const allProfileCapIds = new Set(Object.keys(registry.profileMembership));
const allCapIds = new Set([...allClusterCapIds, ...allProfileCapIds]);
// FIX 5: inline literal guard at write sites (CodeQL barrier)
allCapIds.delete('__proto__');
allCapIds.delete('constructor');
allCapIds.delete('prototype');
const globalSortedCapIds = [...allCapIds].sort();
const sortedCapabilityClusters = Object.create(null);
for (const capId of globalSortedCapIds) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
if (registry.capabilityClusters[capId] !== undefined) {
sortedCapabilityClusters[capId] = registry.capabilityClusters[capId];
}
}
lines.push('const capabilityClusters = ' + JSON.stringify(sortedCapabilityClusters, null, 2) + ';');
lines.push('');
const sortedProfileMembership = Object.create(null);
for (const capId of globalSortedCapIds) {
// S2b: inline literal guard at each write site (CodeQL barrier)
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
if (registry.profileMembership[capId] !== undefined) {
sortedProfileMembership[capId] = registry.profileMembership[capId];
}
}
lines.push('const profileMembership = ' + JSON.stringify(sortedProfileMembership, null, 2) + ';');
lines.push('');
// Inline the requires graph so requiresClosure() works without re-reading files
const requiresGraph = {};
for (const [id, cap] of capMap) {
requiresGraph[id] = Array.isArray(cap.requires) ? cap.requires : [];
}
lines.push('const _requiresGraph = ' + JSON.stringify(requiresGraph, null, 2) + ';');
lines.push('');
// requiresClosure function
lines.push('function requiresClosure(id) {');
lines.push(' const visited = new Set();');
lines.push(' const queue = [id];');
lines.push(' while (queue.length > 0) {');
lines.push(' const current = queue.shift();');
lines.push(' const reqs = _requiresGraph[current] || [];');
lines.push(' for (const req of reqs) {');
lines.push(' if (!visited.has(req)) {');
lines.push(' visited.add(req);');
lines.push(' queue.push(req);');
lines.push(' }');
lines.push(' }');
lines.push(' }');
lines.push(' return visited;');
lines.push('}');
lines.push('');
lines.push('module.exports = {');
lines.push(" version: '" + registry.version + "',");
lines.push(' capabilities,');
lines.push(' bySkill,');
lines.push(' byAgent,');
lines.push(' byLoopPoint,');
lines.push(' configKeys,');
lines.push(' configSchema,');
lines.push(' runtimes,');
lines.push(' commandFamilies,');
lines.push(' capabilityClusters,');
lines.push(' profileMembership,');
lines.push(' requiresClosure,');
lines.push('};');
lines.push('');
return lines.join('\n');
}
// ─── --check diff helper ──────────────────────────────────────────────────────
/**
* Compare committed registry with live registry (for --check).
* Strips the generated comment line for comparison.
*/
function stripGeneratedComment(content) {
return content
.split('\n')
.filter((line) => !line.includes('generated by scripts/gen-capability-registry.cjs'))
.join('\n');
}
// ─── Main ─────────────────────────────────────────────────────────────────────
function main() {
const flag = process.argv[2];
if (flag === '--check') {
// Fix #3: read the REAL central config keys so collision detection fires and is visible.
const centralKeys = loadCentralConfigKeys();
const { capMap, errors, warnings } = loadAndValidate(centralKeys);
// ADR-2782 D4 — non-fatal manifest diagnostics. Emitted BEFORE the hard-error
// exit so a forward-built manifest still explains itself on a failing build.
for (const w of warnings) process.stderr.write(w + '\n');
// Separate pending-migration warnings from hard errors
const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(errors);
for (const w of pendingMigrationWarnings) process.stderr.write(w + '\n');
if (hardErrors.length > 0) {
for (const e of hardErrors) process.stderr.write(' ERROR ' + e + '\n');
throw new ExitError(1, 'capability validation failed (' + hardErrors.length + ' error(s))');
}
const registry = buildRegistry(capMap);
// ADR-857 phase 4a: emit pending-reconciliation warnings to stderr only
// (they do NOT affect the generated file content, so --check stays clean)
for (const w of (registry._reconciliationWarnings || [])) process.stderr.write(w + '\n');
const live = serializeRegistry(registry, capMap);
if (!fs.existsSync(REGISTRY_PATH)) {
process.stderr.write(
'gsd-core/bin/lib/capability-registry.cjs does not exist. Run:\n' +
' node scripts/gen-capability-registry.cjs --write\n',
);
throw new ExitError(1);
}
const committed = fs.readFileSync(REGISTRY_PATH, 'utf8');
if (normalizeEol(stripGeneratedComment(committed)) !== normalizeEol(stripGeneratedComment(live))) {
process.stderr.write(
'gsd-core/bin/lib/capability-registry.cjs is stale. Run:\n' +
' node scripts/gen-capability-registry.cjs --write\n',
);
throw new ExitError(1);
}
process.stdout.write('gsd-core/bin/lib/capability-registry.cjs is up to date.\n');
} else if (flag === '--write') {
// Fix #3: read the REAL central config keys so collision detection fires and is visible.
const centralKeys = loadCentralConfigKeys();
const { capMap, errors, warnings } = loadAndValidate(centralKeys);
// ADR-2782 D4 — non-fatal manifest diagnostics. Emitted BEFORE the hard-error
// exit so a forward-built manifest still explains itself on a failing build.
for (const w of warnings) process.stderr.write(w + '\n');
// Separate pending-migration warnings from hard errors
const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(errors);
for (const w of pendingMigrationWarnings) process.stderr.write(w + '\n');
if (hardErrors.length > 0) {
for (const e of hardErrors) process.stderr.write(' ERROR ' + e + '\n');
throw new ExitError(1, 'capability validation failed — registry not written');
}
const registry = buildRegistry(capMap);
// ADR-857 phase 4a: emit pending-reconciliation warnings to stderr only
for (const w of (registry._reconciliationWarnings || [])) process.stderr.write(w + '\n');
const content = serializeRegistry(registry, capMap);
// Fix #5: mkdir-p before writing so --write doesn't ENOENT in a fresh worktree.
fs.mkdirSync(path.dirname(REGISTRY_PATH), { recursive: true });
fs.writeFileSync(REGISTRY_PATH, content, 'utf8');
process.stdout.write('Wrote ' + REGISTRY_PATH + '\n');
} else {
// Default: print to stdout — use real central keys for visibility
const centralKeys = loadCentralConfigKeys();
const { capMap, errors } = loadAndValidate(centralKeys);
const { hardErrors, pendingMigrationWarnings } = classifyCrossErrors(errors);
for (const w of pendingMigrationWarnings) process.stderr.write(w + '\n');
if (hardErrors.length > 0) {
for (const e of hardErrors) process.stderr.write(' ERROR ' + e + '\n');
throw new ExitError(1, 'capability validation failed');
}
const registry = buildRegistry(capMap);
// ADR-857 phase 4a: emit pending-reconciliation warnings to stderr only
for (const w of (registry._reconciliationWarnings || [])) process.stderr.write(w + '\n');
process.stdout.write(serializeRegistry(registry, capMap) + '\n');
}
}
// ─── Exports (for tests) ──────────────────────────────────────────────────────
module.exports = {
validateCapability,
// ADR-1244 D1: versioned-manifest envelope validation (reused by the runtime overlay, D2)
validateVersionEnvelope,
SEMVER_RE,
SEMVER_RANGE_RE,
SHA512_INTEGRITY_RE,
validateAgainstContract,
validateConsumesGlobal,
validateCrossCapability,
classifyCrossErrors,
loadCentralConfigKeys,
loadCentralConfigPatterns,
loadAndValidate,
buildRegistry,
serializeRegistry,
computeRequiresClosure,
topoSortSteps,
normalizeLineEndings: normalizeEol,
stripGeneratedComment,
validateConfigSliceEntry,
VALID_CONFIG_SLICE_TYPES,
LOOP_HOST_CONTRACT,
VALID_LOOP_POINTS,
POINT_ORDER,
POINT_TO_CONTRACT,
HOST_ARTIFACT_EARLIEST_POINT_IDX,
SCHEMA_VERSION,
validateHooksWired,
// ADR-857 phase 4a: derived views + gates
deriveCapabilityClusters,
deriveProfileMembership,
runConsistencyGate,
// ADR-959: command entry validation
validateCommandEntry,
validateRuntimeCompat,
// ADR-1016 phase 5a: runtime body validators + closed-vocab sets
validateConfigHome,
validateArtifactLayout,
validateArtifactKindEntry,
VALID_CONFIG_HOME_KINDS,
VALID_COMMAND_STYLES,
VALID_HOOKS_SURFACES,
VALID_HOOK_EVENTS,
VALID_SANDBOX_TIERS,
VALID_ARTIFACT_KIND_NAMES,
VALID_ARTIFACT_NESTINGS,
// ADR-857 phase 5e: closed ConverterName enum
VALID_CONVERTER_NAMES,
// ADR-857 phase 5e: configFormat ↔ installSurface parity gate
runConfigFormatParityGate,
INSTALL_SURFACE_TO_CONFIG_FORMAT,
// ADR-857 phase 5f: cross-field consistency gates
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES,
VALID_INSTALL_SURFACES,
VALID_EXTENDED_HOOK_EVENTS,
VALID_PERMISSION_WRITERS,
validateRuntimeBody,
// FIX 5 (lazy): PROFILE_RANK and CLUSTERS are loaded on first access via getters
// so importing the generator on a fresh/unbuilt worktree doesn't fail at module load.
get PROFILE_RANK() { return getInstallProfiles().PROFILE_RANK; },
get CLUSTERS() { return getClusters().CLUSTERS; },
};
// ─── CLI entry point ──────────────────────────────────────────────────────────
if (require.main === module) {
runMain(main);
}