* test(#3606): pin hook-kind coverage in the wired guard * fix(#3606): validate hook-kind coverage at call sites and dispatch generically * fix(#3606): address review - segment-granular narrowing, zero-coverage diagnosis, quick.md, fragment extraction * fix(#3606): drop stale shrink-ack, export HOOK_GROUP_KINDS, dedupe scanner regex * chore(#3606): regenerate install-tree fixtures for new wave-post fragment * chore(#3606): sync canonical launcher preamble into new fragment * fix(#3606): keep fragment preamble ahead of first gsd_run mention * fix(#3606): revert sync script's preamble move in explore.md * chore(#3606): regenerate derived manifests post-rebase * chore(#3606): allowlist peer test files - base was red on the count lane * chore(#3606): regenerate inventory for peer's verify-command-grounding doc * chore(#3606): grounding test maps to its own module by longest prefix * chore(#3606): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -33,8 +33,8 @@ const CONFIG_SCHEMA_PATH = path.join(ROOT, 'gsd-core', 'bin', 'shared', 'config-
|
||||
// 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');
|
||||
// Wired-kinds helper — per point, which hook kinds the render-hooks call sites' dispatch text covers.
|
||||
const { getWiredKinds } = 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');
|
||||
@@ -419,9 +419,11 @@ function loadAndValidate(centralKeys, capabilitiesDir, centralPatterns) {
|
||||
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);
|
||||
// Compute wired points + covered kinds ONCE before iterating capabilities so
|
||||
// the filesystem scan is not repeated per-capability. ROOT is the repo root
|
||||
// (defined at top of file). #3606: the kinds map carries, per point, which
|
||||
// hook kinds the call sites' dispatch text actually covers.
|
||||
const wiredKinds = getWiredKinds(ROOT);
|
||||
|
||||
const folderEntries = fs.readdirSync(resolvedCapDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
@@ -459,7 +461,7 @@ function loadAndValidate(centralKeys, capabilitiesDir, centralPatterns) {
|
||||
}
|
||||
|
||||
// Gen-time wired guard: reject hooks that declare a valid point with no call site.
|
||||
const wiredErrors = validateHooksWired(cap, wiredSet);
|
||||
const wiredErrors = validateHooksWired(cap, wiredKinds);
|
||||
if (wiredErrors.length > 0) {
|
||||
for (const e of wiredErrors) errors.push(folderId + '/capability.json: ' + e);
|
||||
continue;
|
||||
|
||||
@@ -446,8 +446,11 @@ const HOST_LOOP_FILES = STEP_WORKFLOWS.map((w) => 'gsd-core/workflows/' + w.file
|
||||
* @param {string} text Content of a workflow file (or any text).
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
/** The call-site shape both scanners key on — one regex, two consumers (#3606). */
|
||||
const CALL_SITE_RE = /loop render-hooks\s+([a-z:]+)/g;
|
||||
|
||||
function scanWiredPoints(text) {
|
||||
const re = /loop render-hooks\s+([a-z:]+)/g;
|
||||
const re = CALL_SITE_RE;
|
||||
const result = new Set();
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
@@ -456,6 +459,132 @@ function scanWiredPoints(text) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Hook-kind coverage (#3606) ──────────────────────────────────────────────
|
||||
|
||||
const HOOK_KINDS = ['contribution', 'step', 'gate'];
|
||||
|
||||
/**
|
||||
* The kinds one call site's dispatch text actually covers (#3606).
|
||||
*
|
||||
* A point having a `loop render-hooks <point>` call site proves the hooks are
|
||||
* RENDERED, not that they are DISPATCHED — a consumer that iterates only
|
||||
* `kind == "gate"` (or narrows `kind == "step"` to one `ref.skill`) silently
|
||||
* drops every other registered kind. Coverage rules for the text following a
|
||||
* call site, up to the next call site or the region cap, judged LINE by line:
|
||||
*
|
||||
* - A deferral line (one carrying an `@`-included path to the generic
|
||||
* contract, e.g. `@gsd-core/references/loop-hook-dispatch.md`) that names a
|
||||
* kind (`kind == "step"`) covers that kind; a deferral line with no kind
|
||||
* discriminator ("apply each entry") covers every kind. A bare §-citation
|
||||
* of the reference (validation guidance only, no `@`) covers nothing —
|
||||
* plan-phase cites the gate-validation section while dispatching only gates.
|
||||
* - Otherwise a kind is covered when some LINE dispatches it unconditionally:
|
||||
* a `kind == "<kind>"` discriminator with NO same-line narrowing to one
|
||||
* hook (`ref.skill ==`, `ref.agent ==`, `ref.command ==`). A narrowed line
|
||||
* special-cases ONE hook and proves nothing about the kind generally — the
|
||||
* exact hand-rolled-consumer shape the reference warns about.
|
||||
*
|
||||
* Quote style and spacing vary across the corpus (`kind == "step"`,
|
||||
* `kind === 'gate'`), so the matcher is tolerant of both quote characters and
|
||||
* of `==`/`===`.
|
||||
*
|
||||
* Pure: same input, same output; CRLF-safe (line splitting tolerates \r).
|
||||
*
|
||||
* @param {string} region Dispatch text following one call site.
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
function coveredKindsInRegion(region) {
|
||||
const covered = new Set();
|
||||
// Same-SEGMENT narrowing to ONE hook voids credit: `ref.skill ==`, `capId ==`,
|
||||
// and `into ==` each special-case a subset, not the kind generally
|
||||
// (plan-phase's `kind == "contribution" and capId == "security"` is the
|
||||
// hand-rolled shape; `into == "planner"` covers only planner-targeted
|
||||
// contributions). Segments, not lines: execute-phase legitimately writes
|
||||
// "dispatch `kind == "step"` hooks per … . `ref.skill == "code-review"`:" —
|
||||
// the deferral is one sentence, the specialization the next; narrowing in a
|
||||
// DIFFERENT segment must not void the deferral's credit.
|
||||
const narrowingRe = /(?:ref\.(?:skill|agent|command)|capId|into)\s*={2,3}/;
|
||||
// Negated mentions describe an absence, not a dispatch ("Branch 1 — no active
|
||||
// step hooks (`activeHooks` has no entry with `kind == "step"`)" — ship.md).
|
||||
const negationRe = /\b(?:no|without|absent|lacks?|missing)\b[^.|]*kind\s*={2,3}/;
|
||||
const deferralRe = /@\S*loop-hook-dispatch\.md/;
|
||||
for (const line of region.split(/\r?\n/)) {
|
||||
// Sentence segments: a `.`/`;` followed by whitespace ends a segment. A
|
||||
// period NOT followed by whitespace (the `.md` inside a deferral path,
|
||||
// `ref.skill`) is not a boundary.
|
||||
for (const segment of line.split(/(?<=[.;])\s+/)) {
|
||||
const kindDiscriminators = [];
|
||||
for (const kind of HOOK_KINDS) {
|
||||
if (new RegExp(`kind\\s*={2,3}\\s*["']${kind}["']`).test(segment)) kindDiscriminators.push(kind);
|
||||
}
|
||||
if (kindDiscriminators.length === 0) {
|
||||
// A deferral with no kind discriminator ("apply each entry per …")
|
||||
// still covers every kind.
|
||||
if (deferralRe.test(segment)) for (const kind of HOOK_KINDS) covered.add(kind);
|
||||
continue;
|
||||
}
|
||||
if (negationRe.test(segment)) continue;
|
||||
if (deferralRe.test(segment)) {
|
||||
// Deferral naming kinds ("dispatch `kind == "step"` hooks per …").
|
||||
if (!narrowingRe.test(segment)) for (const kind of kindDiscriminators) covered.add(kind);
|
||||
continue;
|
||||
}
|
||||
if (!narrowingRe.test(segment)) for (const kind of kindDiscriminators) covered.add(kind);
|
||||
}
|
||||
}
|
||||
return covered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan every `loop render-hooks <point>` call site in `text` and accumulate,
|
||||
* per point, the union of hook kinds its dispatch regions cover (#3606).
|
||||
*
|
||||
* @param {string} text Content of a workflow file (or any text).
|
||||
* @returns {Map<string, Set<string>>} point → covered kinds.
|
||||
*/
|
||||
function scanWiredKinds(text) {
|
||||
const result = new Map();
|
||||
const siteRe = CALL_SITE_RE;
|
||||
const sites = [];
|
||||
let m;
|
||||
while ((m = siteRe.exec(text)) !== null) sites.push({ point: m[1], start: m.index });
|
||||
const REGION_CAP = 6000;
|
||||
for (let i = 0; i < sites.length; i++) {
|
||||
const regionEnd = i + 1 < sites.length ? sites[i + 1].start : Math.min(text.length, sites[i].start + REGION_CAP);
|
||||
const region = text.slice(sites[i].start, regionEnd);
|
||||
const covered = coveredKindsInRegion(region);
|
||||
if (!result.has(sites[i].point)) result.set(sites[i].point, new Set());
|
||||
for (const kind of covered) result.get(sites[i].point).add(kind);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every host-loop workflow file and return, per point, the union of hook
|
||||
* kinds its call sites' dispatch text covers (#3606).
|
||||
*
|
||||
* @param {string} [repoRoot] Path to the repository root. Defaults to ROOT.
|
||||
* @returns {Map<string, Set<string>>}
|
||||
*/
|
||||
function getWiredKinds(repoRoot) {
|
||||
const resolvedRoot = repoRoot !== undefined ? repoRoot : ROOT;
|
||||
const result = new Map();
|
||||
for (const relPath of HOST_LOOP_FILES) {
|
||||
const absPath = path.join(resolvedRoot, relPath);
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(absPath, 'utf8');
|
||||
} catch (err) {
|
||||
throw new Error('getWiredKinds: cannot read host-loop file ' + absPath + ': ' + err.message);
|
||||
}
|
||||
for (const [point, kinds] of scanWiredKinds(content)) {
|
||||
if (!result.has(point)) result.set(point, new Set());
|
||||
for (const kind of kinds) result.get(point).add(kind);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every host-loop workflow file and return the union of all wired loop points
|
||||
* (i.e. points that have a `loop render-hooks <point>` call site).
|
||||
@@ -497,6 +626,10 @@ module.exports = {
|
||||
ROLE_TO_AGENT,
|
||||
scanWiredPoints,
|
||||
getWiredLoopPoints,
|
||||
coveredKindsInRegion,
|
||||
scanWiredKinds,
|
||||
getWiredKinds,
|
||||
HOOK_KINDS,
|
||||
};
|
||||
|
||||
// ─── CLI entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -106,7 +106,8 @@
|
||||
"verify-work-auto-transition.test.cjs",
|
||||
"verify.test.cjs"
|
||||
],
|
||||
"issue": "3767"
|
||||
"issue": "3767",
|
||||
"justification": "verify-command-grounding added by #2401/#3678; allowlist follow-up landed with #3606 (base was red on this lane)."
|
||||
},
|
||||
"install": {
|
||||
"files": [
|
||||
|
||||
Reference in New Issue
Block a user