@webpresso/agent-kit 3.3.3 → 3.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD-PARTY-NOTICES.md +12 -10
- package/catalog/agent/rules/changeset-release.md +15 -4
- package/catalog/agent/rules/managed-tool-binaries.md +67 -0
- package/catalog/agent/rules/parallel-orchestration-quota.md +99 -1
- package/catalog/agent/rules/pre-implementation.md +36 -20
- package/catalog/agent/rules/rtk-routing.md +22 -16
- package/catalog/agent/skills/autopilot/SKILL.md +4 -3
- package/catalog/agent/skills/ultragoal/SKILL.md +11 -9
- package/dist/esm/audit/agents.js +126 -26
- package/dist/esm/audit/hook-surface.js +5 -1
- package/dist/esm/audit/repo-guardrails.d.ts +8 -0
- package/dist/esm/audit/repo-guardrails.js +71 -1
- package/dist/esm/blueprint/core/parser.js +38 -12
- package/dist/esm/blueprint/execution/artifacts.js +8 -4
- package/dist/esm/blueprint/execution/frontmatter-keys.d.ts +17 -0
- package/dist/esm/blueprint/execution/frontmatter-keys.js +24 -0
- package/dist/esm/blueprint/execution/metadata.js +8 -4
- package/dist/esm/blueprint/lifecycle/audit.d.ts +21 -0
- package/dist/esm/blueprint/lifecycle/audit.js +89 -3
- package/dist/esm/blueprint/lifecycle/review-provenance.js +63 -10
- package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +22 -0
- package/dist/esm/blueprint/lifecycle/rollout-anchor.js +37 -1
- package/dist/esm/blueprint/markdown/blocked-line.d.ts +49 -0
- package/dist/esm/blueprint/markdown/blocked-line.js +63 -0
- package/dist/esm/blueprint/markdown/helpers.js +10 -4
- package/dist/esm/blueprint/tracked-document/parser.js +3 -3
- package/dist/esm/blueprint/trust/dossier.js +41 -1
- package/dist/esm/blueprint/trust/gate-row.d.ts +55 -0
- package/dist/esm/blueprint/trust/gate-row.js +113 -0
- package/dist/esm/blueprint/trust/promotion.d.ts +17 -0
- package/dist/esm/blueprint/trust/promotion.js +19 -19
- package/dist/esm/build/atomic-file-copy.d.ts +32 -0
- package/dist/esm/build/atomic-file-copy.js +44 -0
- package/dist/esm/build/vendor-oxlint-plugins.d.ts +11 -0
- package/dist/esm/build/vendor-oxlint-plugins.js +48 -0
- package/dist/esm/cli/commands/blueprint/mutations.d.ts +15 -0
- package/dist/esm/cli/commands/blueprint/mutations.js +8 -2
- package/dist/esm/cli/commands/compile.d.ts +13 -0
- package/dist/esm/cli/commands/compile.js +182 -62
- package/dist/esm/cli/commands/format.js +17 -1
- package/dist/esm/cli/commands/gain/index.js +10 -3
- package/dist/esm/cli/commands/init/gitignore-patcher.js +6 -0
- package/dist/esm/cli/commands/init/index.js +32 -52
- package/dist/esm/cli/commands/init/mcp-spec.d.ts +48 -13
- package/dist/esm/cli/commands/init/mcp-spec.js +86 -30
- package/dist/esm/cli/commands/init/merge.js +30 -10
- package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +56 -31
- package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +180 -43
- package/dist/esm/cli/commands/init/scaffolders/rtk/index.d.ts +17 -5
- package/dist/esm/cli/commands/init/scaffolders/rtk/index.js +56 -75
- package/dist/esm/cli/commands/init/scaffolders/runtime-check/index.js +7 -6
- package/dist/esm/cli/commands/init/untracked-collision.d.ts +12 -1
- package/dist/esm/cli/commands/init/untracked-collision.js +23 -1
- package/dist/esm/cli/commands/pr-help.js +1 -1
- package/dist/esm/cli/commands/pr.js +18 -4
- package/dist/esm/cli/commands/review.d.ts +1 -0
- package/dist/esm/cli/commands/review.js +137 -7
- package/dist/esm/cli/commands/sync.d.ts +8 -6
- package/dist/esm/cli/commands/sync.js +11 -9
- package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +24 -2
- package/dist/esm/cli/commands/worktree/router-dispatch.js +80 -48
- package/dist/esm/cli/commands/worktree/router.js +5 -2
- package/dist/esm/cli/direct-provider-launch.js +12 -4
- package/dist/esm/compiler/flatten.d.ts +10 -1
- package/dist/esm/compiler/flatten.js +14 -4
- package/dist/esm/compose/admin-blocks-registry-integrity.d.ts +22 -0
- package/dist/esm/compose/admin-blocks-registry-integrity.js +90 -0
- package/dist/esm/compose/compose.d.ts +8 -0
- package/dist/esm/compose/compose.js +115 -8
- package/dist/esm/compose/ops-report-schema.js +17 -3
- package/dist/esm/compose/registry-resolve.d.ts +17 -0
- package/dist/esm/compose/registry-resolve.js +78 -11
- package/dist/esm/config/oxlint/graphql-conventions.js +2 -5
- package/dist/esm/config/oxlint/import-hygiene.js +21 -8
- package/dist/esm/config/oxlint/oxlint-config.fixture.d.ts +1 -1
- package/dist/esm/config/oxlint/oxlint-config.fixture.js +2 -3
- package/dist/esm/config/oxlint/oxlintrc.d.ts +5 -7
- package/dist/esm/config/oxlint/oxlintrc.js +19 -31
- package/dist/esm/config/oxlint/path-roles.js +53 -0
- package/dist/esm/config/oxlint/query-patterns.js +2 -5
- package/dist/esm/content/dispatch.js +4 -0
- package/dist/esm/content/loader.d.ts +1 -1
- package/dist/esm/content/loader.js +10 -6
- package/dist/esm/content/skill-policy.d.ts +11 -0
- package/dist/esm/content/skill-policy.js +33 -0
- package/dist/esm/daemon/domains/blueprint-exec-ledger.d.ts +86 -0
- package/dist/esm/daemon/domains/blueprint-exec-ledger.js +134 -0
- package/dist/esm/daemon/domains/blueprint-exec.d.ts +106 -0
- package/dist/esm/daemon/domains/blueprint-exec.js +161 -0
- package/dist/esm/daemon/domains/launch.d.ts +25 -0
- package/dist/esm/daemon/domains/launch.js +30 -1
- package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +12 -1
- package/dist/esm/daemon/handlers/blueprints-handlers.js +148 -6
- package/dist/esm/daemon/handlers/product.d.ts +10 -0
- package/dist/esm/daemon/handlers/product.js +10 -1
- package/dist/esm/daemon/methods.d.ts +21 -1
- package/dist/esm/daemon/methods.js +20 -0
- package/dist/esm/daemon/protocol/contract.d.ts +58 -0
- package/dist/esm/daemon/protocol/contract.js +38 -0
- package/dist/esm/daemon/server.js +7 -0
- package/dist/esm/docs-linter/blueprint-plan.js +2 -2
- package/dist/esm/errors/wp-error.js +21 -2
- package/dist/esm/hooks/doctor.d.ts +11 -0
- package/dist/esm/hooks/doctor.js +114 -45
- package/dist/esm/hooks/pretool-guard/dev-routing.js +68 -5
- package/dist/esm/hooks/pretool-guard/rtk-rewrite.d.ts +24 -0
- package/dist/esm/hooks/pretool-guard/rtk-rewrite.js +50 -0
- package/dist/esm/hooks/pretool-guard/runner.js +6 -0
- package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +62 -3
- package/dist/esm/hooks/status/index.js +1 -1
- package/dist/esm/mcp/blueprint/_shared/lifecycle.js +39 -11
- package/dist/esm/mcp/blueprint/handlers/review-log.d.ts +14 -7
- package/dist/esm/mcp/blueprint/handlers/review-log.js +46 -22
- package/dist/esm/mcp/blueprint/registration.js +1 -1
- package/dist/esm/mcp/tools/_shared/redact.d.ts +22 -0
- package/dist/esm/mcp/tools/_shared/redact.js +114 -8
- package/dist/esm/mcp/tools/gain.js +28 -2
- package/dist/esm/mcp/tools/worktree.js +65 -3
- package/dist/esm/mcp/tools/wp-ui-compose.js +38 -4
- package/dist/esm/mcp/tools/wp-ui-preview.js +14 -7
- package/dist/esm/review/authority.js +56 -1
- package/dist/esm/review/delivery-verifier.d.ts +4 -0
- package/dist/esm/review/delivery-verifier.js +9 -1
- package/dist/esm/review/lifecycle-writers.d.ts +76 -0
- package/dist/esm/review/lifecycle-writers.js +330 -0
- package/dist/esm/review/subject.js +66 -32
- package/dist/esm/status/snapshot.d.ts +5 -0
- package/dist/esm/status/snapshot.js +27 -2
- package/dist/esm/symlinker/consumers.d.ts +3 -1
- package/dist/esm/symlinker/consumers.js +7 -1
- package/dist/esm/symlinker/unified-sync.d.ts +3 -3
- package/dist/esm/symlinker/unified-sync.js +88 -11
- package/dist/esm/test/shard-durations.json +0 -2
- package/dist/esm/test-helpers/global-setup.js +38 -3
- package/dist/esm/tool-runtime/managed-rtk.d.ts +44 -0
- package/dist/esm/tool-runtime/managed-rtk.js +275 -0
- package/dist/esm/tool-runtime/resolve-runner.js +18 -194
- package/dist/esm/utils/package-root.d.ts +32 -0
- package/dist/esm/utils/package-root.js +82 -0
- package/dist/esm/worktrees/git-metadata.d.ts +41 -0
- package/dist/esm/worktrees/git-metadata.js +48 -0
- package/package.json +16 -21
- package/dist/esm/cli/commands/init/scaffolders/subagents/index.d.ts +0 -6
- package/dist/esm/cli/commands/init/scaffolders/subagents/index.js +0 -77
- package/dist/esm/config/oxlint/code-safety.d.ts +0 -18
- package/dist/esm/config/oxlint/foundation-purity.d.ts +0 -21
- package/dist/esm/config/oxlint/graphql-conventions.d.ts +0 -23
- package/dist/esm/config/oxlint/import-hygiene.d.ts +0 -34
- package/dist/esm/config/oxlint/index.d.ts +0 -22
- package/dist/esm/config/oxlint/index.js +0 -28
- package/dist/esm/config/oxlint/monorepo-paths.d.ts +0 -22
- package/dist/esm/config/oxlint/query-patterns.d.ts +0 -24
- package/dist/esm/config/oxlint/testing-quality.d.ts +0 -35
- package/dist/esm/config/oxlint/tier-boundaries.d.ts +0 -35
- package/dist/esm/output-transforms/rulesync.d.ts +0 -2
- package/dist/esm/output-transforms/rulesync.js +0 -79
|
@@ -108,10 +108,22 @@ export function readBlueprintOverviewAtRef(repoRoot, ref, file) {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Whether the working-tree document still represents what `ref` holds, so the
|
|
113
|
+
* ref-side review ledger may be consulted.
|
|
114
|
+
*
|
|
115
|
+
* Every `false` branch records a specific reason in `issues`. Without that, the
|
|
116
|
+
* caller reports "0 approvals" with no `Evidence issues:` suffix and no way to
|
|
117
|
+
* tell a substantive plan edit apart from a missing overview, a path outside the
|
|
118
|
+
* repo, or a failed git probe — an opacity that has cost whole debugging
|
|
119
|
+
* sessions on exactly this class of digest divergence.
|
|
120
|
+
*/
|
|
121
|
+
function workingContentMatchesRef(repoRoot, file, purpose, ref, issues) {
|
|
112
122
|
const relative = path.relative(repoRoot, file);
|
|
113
|
-
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
123
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
124
|
+
issues?.push(`Review authority at ${ref} not consulted for ${file}: the blueprint path resolves outside the repository root ${repoRoot}.`);
|
|
114
125
|
return false;
|
|
126
|
+
}
|
|
115
127
|
try {
|
|
116
128
|
if (purpose === "plan") {
|
|
117
129
|
// Compare normalized plan content, not raw bytes: promotion rewrites
|
|
@@ -122,10 +134,16 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
|
|
|
122
134
|
// Also resolve by stable slug when the lifecycle path changed (draft→planned)
|
|
123
135
|
// so pre-commit on a rename does not falsely drop plan authority.
|
|
124
136
|
const atRef = readBlueprintOverviewAtRef(repoRoot, ref, file);
|
|
125
|
-
if (!atRef)
|
|
137
|
+
if (!atRef) {
|
|
138
|
+
issues?.push(`Review authority at ${ref} not consulted for ${relative}: no blueprint overview for this slug exists at ${ref}.`);
|
|
126
139
|
return false;
|
|
140
|
+
}
|
|
127
141
|
const working = readFileSync(file);
|
|
128
|
-
|
|
142
|
+
if (normalizePlanSubjectContent(working).equals(normalizePlanSubjectContent(atRef))) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
issues?.push(`Review authority at ${ref} not consulted for ${relative}: the working document's normalized plan content differs from ${ref}, so its plan-subject digest differs and no approval recorded against ${ref} can match. Re-run the plan review gate against the current content, or restore the reviewed content.`);
|
|
146
|
+
return false;
|
|
129
147
|
}
|
|
130
148
|
const output = execFileSync("git", ["status", "--porcelain=v1", "--untracked-files=all"], {
|
|
131
149
|
cwd: repoRoot,
|
|
@@ -133,9 +151,13 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
|
|
|
133
151
|
stdio: ["ignore", "pipe", "pipe"],
|
|
134
152
|
timeout: 5_000,
|
|
135
153
|
});
|
|
136
|
-
|
|
154
|
+
if (output.trim().length === 0)
|
|
155
|
+
return true;
|
|
156
|
+
issues?.push(`Review authority at ${ref} not consulted for ${relative}: the working tree is dirty, so a delivery subject at ${ref} does not cover the current content.`);
|
|
157
|
+
return false;
|
|
137
158
|
}
|
|
138
|
-
catch {
|
|
159
|
+
catch (error) {
|
|
160
|
+
issues?.push(`Review authority at ${ref} not consulted for ${relative}: divergence probe failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
139
161
|
return false;
|
|
140
162
|
}
|
|
141
163
|
}
|
|
@@ -224,13 +246,17 @@ function collectWorkingDeliverySubjects(input) {
|
|
|
224
246
|
function collectWorkingV2ReviewProvenance(file, purpose, issues) {
|
|
225
247
|
try {
|
|
226
248
|
const repoRoot = findGitRoot(file);
|
|
227
|
-
if (!repoRoot)
|
|
249
|
+
if (!repoRoot) {
|
|
250
|
+
issues?.push(`Working review authority unavailable for ${file}: no enclosing git repository was found.`);
|
|
228
251
|
return [];
|
|
252
|
+
}
|
|
229
253
|
const slug = stableSlugForBlueprintFile(file);
|
|
230
254
|
const working = readWorkingReviewEvents(file, slug);
|
|
255
|
+
issues?.push(...working.issues);
|
|
256
|
+
// No v2 ledger at all is the normal shape for a blueprint whose authority is
|
|
257
|
+
// a legacy frontmatter approval, so it is not a reason worth reporting.
|
|
231
258
|
if (working.events.length === 0)
|
|
232
259
|
return [];
|
|
233
|
-
issues?.push(...working.issues);
|
|
234
260
|
// Events whose recorded `reviewedCommit` no longer resolves (typically
|
|
235
261
|
// orphaned by a rebase). Collected here rather than mutating
|
|
236
262
|
// `working.compromisedIds`, which is a ReadonlySet by contract, then merged
|
|
@@ -252,7 +278,7 @@ function collectWorkingV2ReviewProvenance(file, purpose, issues) {
|
|
|
252
278
|
unresolvableEventIds,
|
|
253
279
|
issues,
|
|
254
280
|
});
|
|
255
|
-
|
|
281
|
+
const approvals = subjects
|
|
256
282
|
.flatMap((subject) => evaluateReviewEvents(working.events, {
|
|
257
283
|
purpose,
|
|
258
284
|
blueprintSlug: slug,
|
|
@@ -267,6 +293,19 @@ function collectWorkingV2ReviewProvenance(file, purpose, issues) {
|
|
|
267
293
|
artifact: event.artifactPath,
|
|
268
294
|
targetHash: event.subjectDigest,
|
|
269
295
|
}));
|
|
296
|
+
if (approvals.length === 0) {
|
|
297
|
+
// The ledger has events but none of them approves the CURRENT content.
|
|
298
|
+
// Name both sides of the comparison: this is the digest-divergence
|
|
299
|
+
// failure mode, and reporting it as a bare "0 approvals" is what makes it
|
|
300
|
+
// expensive to diagnose.
|
|
301
|
+
const computed = subjects.length === 0
|
|
302
|
+
? "no subject could be computed for the current content"
|
|
303
|
+
: `computed subject digest(s) ${subjects.map((subject) => subject.digest).join(", ")}`;
|
|
304
|
+
issues?.push(`No working-tree approval matched the current ${purpose} subject of ${slug}: ${computed}; the ledger's ${working.events.length} event(s) bind ${[
|
|
305
|
+
...new Set(working.events.map((event) => event.subjectDigest)),
|
|
306
|
+
].join(", ")}.`);
|
|
307
|
+
}
|
|
308
|
+
return approvals;
|
|
270
309
|
}
|
|
271
310
|
catch (error) {
|
|
272
311
|
issues?.push(`Working review authority unavailable for ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -277,9 +316,23 @@ export function collectV2ReviewProvenance(file, purpose, ref = "HEAD", issues) {
|
|
|
277
316
|
const repoRoot = findGitRoot(file);
|
|
278
317
|
if (!repoRoot)
|
|
279
318
|
return [];
|
|
319
|
+
// Only explain a skipped/unmatched v2 lookup when the blueprint actually has a
|
|
320
|
+
// v2 ledger. A blueprint whose authority is a legacy frontmatter approval has
|
|
321
|
+
// no ledger by design, and narrating the absence for every one of those would
|
|
322
|
+
// bury the one case that matters (a ledger that no longer matches its subject)
|
|
323
|
+
// under warnings that report nothing wrong.
|
|
324
|
+
const explain = existsSync(path.join(path.dirname(file), "review-events.jsonl"))
|
|
325
|
+
? issues
|
|
326
|
+
: undefined;
|
|
280
327
|
const workingApprovals = collectWorkingV2ReviewProvenance(file, purpose, issues);
|
|
281
|
-
if (!workingContentMatchesRef(repoRoot, file, purpose, ref))
|
|
328
|
+
if (!workingContentMatchesRef(repoRoot, file, purpose, ref, explain)) {
|
|
329
|
+
// The ref-side ledger is deliberately skipped here (its approvals bind a
|
|
330
|
+
// subject the working content no longer matches), so the working set is all
|
|
331
|
+
// the authority there is. Say so: an empty result on this path used to be
|
|
332
|
+
// indistinguishable from "there was never any review".
|
|
333
|
+
explain?.push(`Returning ${workingApprovals.length} working-tree approval(s) for ${file} without ref authority from ${ref}.`);
|
|
282
334
|
return workingApprovals;
|
|
335
|
+
}
|
|
283
336
|
try {
|
|
284
337
|
const authority = readReviewAuthorityAtRef(repoRoot, ref, stableSlugForBlueprintFile(file), purpose);
|
|
285
338
|
issues?.push(...authority.issues);
|
|
@@ -25,6 +25,28 @@
|
|
|
25
25
|
export declare const ROLLOUT_ANCHOR_COMMIT = "c6ccb5f31cbecde3be4dbd6d3b7e67918006d22e";
|
|
26
26
|
/** The date that anchor landed, used only by the legacy fallback below. */
|
|
27
27
|
export declare const ROLLOUT_DATE = "2026-07-12";
|
|
28
|
+
/**
|
|
29
|
+
* The commit that closed the legacy frontmatter approval bypass ("fix(review):
|
|
30
|
+
* bind the legacy frontmatter approval leg to the rollout anchor", 2026-07-25).
|
|
31
|
+
*
|
|
32
|
+
* Until that change, a frontmatter `approvals:` entry naming a strong reviewer
|
|
33
|
+
* satisfied the promotion gate with **no content binding whatsoever**: the gate
|
|
34
|
+
* called `evaluateApprovalGate` without a `targetHash`, so `matchesApproval`
|
|
35
|
+
* skipped its binding clause, and `collectTrackedReviewProvenance` never read or
|
|
36
|
+
* hashed the blueprint body at all. Any such blueprint passed the gate no matter
|
|
37
|
+
* what its plan currently said.
|
|
38
|
+
*
|
|
39
|
+
* 172 already-landed blueprints resolve their approval through that leg, so it
|
|
40
|
+
* is grandfathered rather than deleted: slugs tracked at this anchor keep the
|
|
41
|
+
* legacy leg, and every blueprint created afterwards must produce authority
|
|
42
|
+
* through the content-addressed v2 `review-events.jsonl` ledger. Anchor-tree
|
|
43
|
+
* membership is used for the same reason as {@link ROLLOUT_ANCHOR_COMMIT}: no
|
|
44
|
+
* file content can add a slug to a tree that was written earlier, so the
|
|
45
|
+
* grandfather cannot be forged by editing `created:`.
|
|
46
|
+
*/
|
|
47
|
+
export declare const LEGACY_APPROVAL_LEG_ANCHOR_COMMIT = "079be82e39de7099a5bda1261e37598baed8644e";
|
|
48
|
+
/** The date that anchor landed, used only by the non-agent-kit fallback. */
|
|
49
|
+
export declare const LEGACY_APPROVAL_LEG_ROLLOUT_DATE = "2026-07-25";
|
|
28
50
|
/**
|
|
29
51
|
* Outcome of consulting the anchor, cached per (repoRoot, anchorCommit).
|
|
30
52
|
*
|
|
@@ -26,12 +26,42 @@ import { execFileSync } from "node:child_process";
|
|
|
26
26
|
export const ROLLOUT_ANCHOR_COMMIT = "c6ccb5f31cbecde3be4dbd6d3b7e67918006d22e";
|
|
27
27
|
/** The date that anchor landed, used only by the legacy fallback below. */
|
|
28
28
|
export const ROLLOUT_DATE = "2026-07-12";
|
|
29
|
+
/**
|
|
30
|
+
* The commit that closed the legacy frontmatter approval bypass ("fix(review):
|
|
31
|
+
* bind the legacy frontmatter approval leg to the rollout anchor", 2026-07-25).
|
|
32
|
+
*
|
|
33
|
+
* Until that change, a frontmatter `approvals:` entry naming a strong reviewer
|
|
34
|
+
* satisfied the promotion gate with **no content binding whatsoever**: the gate
|
|
35
|
+
* called `evaluateApprovalGate` without a `targetHash`, so `matchesApproval`
|
|
36
|
+
* skipped its binding clause, and `collectTrackedReviewProvenance` never read or
|
|
37
|
+
* hashed the blueprint body at all. Any such blueprint passed the gate no matter
|
|
38
|
+
* what its plan currently said.
|
|
39
|
+
*
|
|
40
|
+
* 172 already-landed blueprints resolve their approval through that leg, so it
|
|
41
|
+
* is grandfathered rather than deleted: slugs tracked at this anchor keep the
|
|
42
|
+
* legacy leg, and every blueprint created afterwards must produce authority
|
|
43
|
+
* through the content-addressed v2 `review-events.jsonl` ledger. Anchor-tree
|
|
44
|
+
* membership is used for the same reason as {@link ROLLOUT_ANCHOR_COMMIT}: no
|
|
45
|
+
* file content can add a slug to a tree that was written earlier, so the
|
|
46
|
+
* grandfather cannot be forged by editing `created:`.
|
|
47
|
+
*/
|
|
48
|
+
export const LEGACY_APPROVAL_LEG_ANCHOR_COMMIT = "079be82e39de7099a5bda1261e37598baed8644e";
|
|
49
|
+
/** The date that anchor landed, used only by the non-agent-kit fallback. */
|
|
50
|
+
export const LEGACY_APPROVAL_LEG_ROLLOUT_DATE = "2026-07-25";
|
|
29
51
|
const anchorSlugCache = new Map();
|
|
52
|
+
/**
|
|
53
|
+
* Every git call here is on the audit/promote hot path, so each carries an
|
|
54
|
+
* explicit deadline (parity with `review-provenance.ts`). A wedged git process
|
|
55
|
+
* must not hang the gate; a timeout surfaces as the same failure the `catch`
|
|
56
|
+
* already handles, which for the two membership probes means fail-closed.
|
|
57
|
+
*/
|
|
58
|
+
const ANCHOR_GIT_TIMEOUT_MS = 5_000;
|
|
30
59
|
function anchorCommitExists(repoRoot, anchorCommit) {
|
|
31
60
|
try {
|
|
32
61
|
execFileSync("git", ["cat-file", "-e", `${anchorCommit}^{commit}`], {
|
|
33
62
|
cwd: repoRoot,
|
|
34
63
|
stdio: ["ignore", "ignore", "ignore"],
|
|
64
|
+
timeout: ANCHOR_GIT_TIMEOUT_MS,
|
|
35
65
|
});
|
|
36
66
|
return true;
|
|
37
67
|
}
|
|
@@ -51,6 +81,7 @@ function isShallowRepository(repoRoot) {
|
|
|
51
81
|
cwd: repoRoot,
|
|
52
82
|
encoding: "utf8",
|
|
53
83
|
stdio: ["ignore", "pipe", "ignore"],
|
|
84
|
+
timeout: ANCHOR_GIT_TIMEOUT_MS,
|
|
54
85
|
});
|
|
55
86
|
// A path that is not a git repository at all answers with a non-zero exit,
|
|
56
87
|
// handled below. Anything else that is not a clean "false" is treated as
|
|
@@ -86,7 +117,12 @@ export function blueprintSlugsAtRolloutAnchor(repoRoot, anchorCommit = ROLLOUT_A
|
|
|
86
117
|
return isShallowRepository(repoRoot) ? { kind: "unreadable" } : { kind: "not-applicable" };
|
|
87
118
|
}
|
|
88
119
|
try {
|
|
89
|
-
const stdout = execFileSync("git", ["ls-tree", "-r", "--name-only", anchorCommit, "blueprints/"], {
|
|
120
|
+
const stdout = execFileSync("git", ["ls-tree", "-r", "--name-only", anchorCommit, "blueprints/"], {
|
|
121
|
+
cwd: repoRoot,
|
|
122
|
+
encoding: "utf8",
|
|
123
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
124
|
+
timeout: ANCHOR_GIT_TIMEOUT_MS,
|
|
125
|
+
});
|
|
90
126
|
const found = new Set();
|
|
91
127
|
for (const line of stdout.split("\n")) {
|
|
92
128
|
const match = /^blueprints\/[^/]+\/(.+)$/u.exec(line.trim());
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single owner of the task-body `**Blocked:** <reason>` line grammar.
|
|
3
|
+
*
|
|
4
|
+
* Both sides of the plan-approval contract read this file: `updateBlockedReason`
|
|
5
|
+
* (`#markdown/helpers.js`) writes the line, and `neutralizeTaskBlockedReason`
|
|
6
|
+
* (`#review/subject.js`) removes it before the plan-subject digest is taken. That
|
|
7
|
+
* pairing is exactly the arrangement the Promotion Gates row grammar already uses
|
|
8
|
+
* (`#trust/gate-row.js`), and for the same reason: when a writer and the
|
|
9
|
+
* normalizer spell the same grammar twice, they drift, the lifecycle stamp
|
|
10
|
+
* survives into the digest, and the plan approval the promotion gates on is
|
|
11
|
+
* silently dismissed.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately dependency-free so any module on either side can import it
|
|
14
|
+
* without risking a cycle.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Regex source for a whole `**Blocked:** …` line, anchored to line bounds.
|
|
18
|
+
*
|
|
19
|
+
* Anchoring is load-bearing in both directions. Blueprint prose legitimately
|
|
20
|
+
* mentions the marker mid-sentence ("add a non-empty **Blocked:** line with the
|
|
21
|
+
* reason") — those mentions are plan substance, and an unanchored pattern would
|
|
22
|
+
* have the writer overwrite them and the normalizer strip them.
|
|
23
|
+
*/
|
|
24
|
+
export declare const BLOCKED_LINE_SOURCE: string;
|
|
25
|
+
/**
|
|
26
|
+
* Build a matcher for the blocked line. Mirrors the `buildTaskHeadingRegex`
|
|
27
|
+
* convention in `./task-heading.js`: callers pick the flags they need, but the
|
|
28
|
+
* pattern itself has exactly one definition.
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildBlockedLineRegex(flags?: string): RegExp;
|
|
31
|
+
/** True when `line` IS a blocked-reason line, not merely a line that mentions one. */
|
|
32
|
+
export declare function isBlockedLine(line: string): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Serialize the blocked line. The writer-side counterpart to
|
|
35
|
+
* {@link isBlockedLine}: every emitter goes through here so the written form and
|
|
36
|
+
* the parsed form cannot diverge.
|
|
37
|
+
*/
|
|
38
|
+
export declare function formatBlockedLine(reason: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Read the reason off the first blocked line in a task section, or `undefined`
|
|
41
|
+
* when the section has none.
|
|
42
|
+
*
|
|
43
|
+
* The reader counterpart to {@link formatBlockedLine}. Three parsers used to
|
|
44
|
+
* carry their own unanchored copy of this pattern (`src/blueprint/core/parser.ts`,
|
|
45
|
+
* `src/blueprint/tracked-document/parser.ts`, `src/docs-linter/blueprint-plan.ts`),
|
|
46
|
+
* so a task whose prose merely mentioned the marker was read as genuinely
|
|
47
|
+
* blocked and the lifecycle audit failed the document.
|
|
48
|
+
*/
|
|
49
|
+
export declare function extractBlockedReason(section: string): string | undefined;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single owner of the task-body `**Blocked:** <reason>` line grammar.
|
|
3
|
+
*
|
|
4
|
+
* Both sides of the plan-approval contract read this file: `updateBlockedReason`
|
|
5
|
+
* (`#markdown/helpers.js`) writes the line, and `neutralizeTaskBlockedReason`
|
|
6
|
+
* (`#review/subject.js`) removes it before the plan-subject digest is taken. That
|
|
7
|
+
* pairing is exactly the arrangement the Promotion Gates row grammar already uses
|
|
8
|
+
* (`#trust/gate-row.js`), and for the same reason: when a writer and the
|
|
9
|
+
* normalizer spell the same grammar twice, they drift, the lifecycle stamp
|
|
10
|
+
* survives into the digest, and the plan approval the promotion gates on is
|
|
11
|
+
* silently dismissed.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately dependency-free so any module on either side can import it
|
|
14
|
+
* without risking a cycle.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Regex source for a whole `**Blocked:** …` line, anchored to line bounds.
|
|
18
|
+
*
|
|
19
|
+
* Anchoring is load-bearing in both directions. Blueprint prose legitimately
|
|
20
|
+
* mentions the marker mid-sentence ("add a non-empty **Blocked:** line with the
|
|
21
|
+
* reason") — those mentions are plan substance, and an unanchored pattern would
|
|
22
|
+
* have the writer overwrite them and the normalizer strip them.
|
|
23
|
+
*/
|
|
24
|
+
export const BLOCKED_LINE_SOURCE = String.raw `^\*\*Blocked:\*\*.*$`;
|
|
25
|
+
/**
|
|
26
|
+
* Build a matcher for the blocked line. Mirrors the `buildTaskHeadingRegex`
|
|
27
|
+
* convention in `./task-heading.js`: callers pick the flags they need, but the
|
|
28
|
+
* pattern itself has exactly one definition.
|
|
29
|
+
*/
|
|
30
|
+
export function buildBlockedLineRegex(flags = "imu") {
|
|
31
|
+
return new RegExp(BLOCKED_LINE_SOURCE, flags);
|
|
32
|
+
}
|
|
33
|
+
const SINGLE_LINE_MATCHER = buildBlockedLineRegex("iu");
|
|
34
|
+
/** True when `line` IS a blocked-reason line, not merely a line that mentions one. */
|
|
35
|
+
export function isBlockedLine(line) {
|
|
36
|
+
return SINGLE_LINE_MATCHER.test(line);
|
|
37
|
+
}
|
|
38
|
+
const MARKER_LENGTH = "**Blocked:**".length;
|
|
39
|
+
/**
|
|
40
|
+
* Serialize the blocked line. The writer-side counterpart to
|
|
41
|
+
* {@link isBlockedLine}: every emitter goes through here so the written form and
|
|
42
|
+
* the parsed form cannot diverge.
|
|
43
|
+
*/
|
|
44
|
+
export function formatBlockedLine(reason) {
|
|
45
|
+
return `**Blocked:** ${reason}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Read the reason off the first blocked line in a task section, or `undefined`
|
|
49
|
+
* when the section has none.
|
|
50
|
+
*
|
|
51
|
+
* The reader counterpart to {@link formatBlockedLine}. Three parsers used to
|
|
52
|
+
* carry their own unanchored copy of this pattern (`src/blueprint/core/parser.ts`,
|
|
53
|
+
* `src/blueprint/tracked-document/parser.ts`, `src/docs-linter/blueprint-plan.ts`),
|
|
54
|
+
* so a task whose prose merely mentioned the marker was read as genuinely
|
|
55
|
+
* blocked and the lifecycle audit failed the document.
|
|
56
|
+
*/
|
|
57
|
+
export function extractBlockedReason(section) {
|
|
58
|
+
const match = buildBlockedLineRegex().exec(section);
|
|
59
|
+
if (!match)
|
|
60
|
+
return undefined;
|
|
61
|
+
const reason = match[0].slice(MARKER_LENGTH).trim();
|
|
62
|
+
return reason === "" ? undefined : reason;
|
|
63
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* produces identical output (for the same inputs).
|
|
9
9
|
*/
|
|
10
10
|
import { escapeRegex } from "#utils/string";
|
|
11
|
+
import { BLOCKED_LINE_SOURCE, buildBlockedLineRegex, formatBlockedLine } from "./blocked-line.js";
|
|
11
12
|
import { buildTaskHeaderRegexForId, buildTaskSectionBoundaryRegex, TASK_HEADING_PREFIX_PATTERN, } from "./task-heading.js";
|
|
12
13
|
function buildTaskPattern(taskId) {
|
|
13
14
|
return new RegExp(`(####\\s+${TASK_HEADING_PREFIX_PATTERN}Task\\s+${escapeRegex(taskId)}[:\\s].*?)(?=\\n####|\\n###|$)`, "s");
|
|
@@ -65,7 +66,11 @@ export function updateBlockedReason(content, taskId, reason) {
|
|
|
65
66
|
const taskPattern = buildTaskPattern(taskId);
|
|
66
67
|
return content.replace(taskPattern, (section) => {
|
|
67
68
|
const trimmedReason = reason.trim();
|
|
68
|
-
|
|
69
|
+
// Grammar comes from ./blocked-line.js, which the plan-subject normalizer
|
|
70
|
+
// consumes too. Line-anchored on purpose: a task whose prose mentions
|
|
71
|
+
// `**Blocked:**` mid-sentence is plan substance, and the previous
|
|
72
|
+
// unanchored pattern would have overwritten it.
|
|
73
|
+
const blockedPattern = new RegExp(String.raw `\n?${BLOCKED_LINE_SOURCE}\n*`, "imu");
|
|
69
74
|
if (!trimmedReason) {
|
|
70
75
|
// Preserve the section's trailing whitespace: trimEnd on the whole
|
|
71
76
|
// section would also delete the blank line separating this task from the
|
|
@@ -76,9 +81,10 @@ export function updateBlockedReason(content, taskId, reason) {
|
|
|
76
81
|
.replace(/\n{3,}/g, "\n\n")
|
|
77
82
|
.trimEnd() + trailingWhitespace);
|
|
78
83
|
}
|
|
79
|
-
const blockedLine =
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
const blockedLine = formatBlockedLine(trimmedReason);
|
|
85
|
+
const existingLine = buildBlockedLineRegex();
|
|
86
|
+
if (existingLine.test(section)) {
|
|
87
|
+
return section.replace(existingLine, blockedLine);
|
|
82
88
|
}
|
|
83
89
|
return insertAfterStatusLine(section, blockedLine);
|
|
84
90
|
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Git-Native SSoT: Checkbox state derives status, no external state.
|
|
8
8
|
*/
|
|
9
|
+
import { extractBlockedReason } from "#markdown/blocked-line";
|
|
9
10
|
import { isTaskHeaderLine } from "#markdown/task-heading";
|
|
10
11
|
/**
|
|
11
12
|
* Extract checkbox status from a markdown section and derive task state.
|
|
@@ -100,10 +101,9 @@ export function extractDepends(section) {
|
|
|
100
101
|
* ```
|
|
101
102
|
*/
|
|
102
103
|
export function extractBlocked(section) {
|
|
103
|
-
const
|
|
104
|
-
if (
|
|
104
|
+
const blockedText = extractBlockedReason(section);
|
|
105
|
+
if (blockedText === undefined)
|
|
105
106
|
return undefined;
|
|
106
|
-
const blockedText = blockedMatch[1].trim();
|
|
107
107
|
// Handle missing/empty reason gracefully
|
|
108
108
|
if (blockedText === "" || blockedText.toLowerCase() === "none") {
|
|
109
109
|
return undefined;
|
|
@@ -39,8 +39,15 @@ export function parseTrustDossier(markdown) {
|
|
|
39
39
|
const gates = parsePromotionGatesTable(requiredBlock(blocks, "Promotion Gates"), violations);
|
|
40
40
|
const residualUnknowns = requiredBlock(blocks, "Residual Unknowns").trim();
|
|
41
41
|
for (const [section, block] of blocks) {
|
|
42
|
-
|
|
42
|
+
const fields = findPlaceholderFields(block);
|
|
43
|
+
if (fields.length > 0) {
|
|
44
|
+
for (const field of fields) {
|
|
45
|
+
violations.push({ section, message: `placeholder value not filled in: ${field}` });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (containsPlaceholder(block)) {
|
|
43
49
|
violations.push({ section, message: "placeholder values are not allowed" });
|
|
50
|
+
}
|
|
44
51
|
}
|
|
45
52
|
if (violations.length > 0 || readiness === null)
|
|
46
53
|
return { violations };
|
|
@@ -181,6 +188,39 @@ function splitRow(row) {
|
|
|
181
188
|
function containsPlaceholder(value) {
|
|
182
189
|
return /<[^>]+>|\bTBD\b|\bTODO\b/u.test(value);
|
|
183
190
|
}
|
|
191
|
+
// Names the specific field(s) inside a Trust Dossier subsection block that
|
|
192
|
+
// still carry a scaffold placeholder, instead of only flagging the section as
|
|
193
|
+
// a whole. Covers both shapes the dossier uses: `- key: value` list lines
|
|
194
|
+
// (Readiness Verdict) and markdown table rows (Material Claims/Decisions,
|
|
195
|
+
// Promotion Gates). Free-text sections (Residual Unknowns) have no named
|
|
196
|
+
// field to point at, so the caller falls back to the whole-section message
|
|
197
|
+
// when this returns an empty array.
|
|
198
|
+
function findPlaceholderFields(block) {
|
|
199
|
+
const fields = [];
|
|
200
|
+
for (const line of block.split("\n")) {
|
|
201
|
+
const match = /^-\s*([^:]+):\s*(.+?)\s*$/u.exec(line.trim());
|
|
202
|
+
if (match && containsPlaceholder(match[2] ?? ""))
|
|
203
|
+
fields.push(match[1].trim());
|
|
204
|
+
}
|
|
205
|
+
const rows = block
|
|
206
|
+
.split("\n")
|
|
207
|
+
.map((line) => line.trim())
|
|
208
|
+
.filter((line) => line.startsWith("|") && line.endsWith("|"));
|
|
209
|
+
if (rows.length >= 2) {
|
|
210
|
+
const header = splitRow(rows[0] ?? "");
|
|
211
|
+
for (const row of rows.slice(2)) {
|
|
212
|
+
const cells = splitRow(row);
|
|
213
|
+
for (const [index, cell] of cells.entries()) {
|
|
214
|
+
if (!containsPlaceholder(cell))
|
|
215
|
+
continue;
|
|
216
|
+
const columnName = header[index] ?? `column ${index + 1}`;
|
|
217
|
+
const rowId = cells[0] ? ` (row ${cells[0]})` : "";
|
|
218
|
+
fields.push(`${columnName}${rowId}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return fields;
|
|
223
|
+
}
|
|
184
224
|
function escapeRegExp(value) {
|
|
185
225
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
186
226
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single owner of the Trust Dossier "Promotion Gates" table row grammar.
|
|
3
|
+
*
|
|
4
|
+
* Two callers must agree on this grammar byte-for-byte:
|
|
5
|
+
*
|
|
6
|
+
* - the lifecycle WRITER `updateGateLastResult` (`./promotion.ts`), which stamps
|
|
7
|
+
* the `Last result` cell during `wp blueprint promote`; and
|
|
8
|
+
* - the plan-subject digest NORMALIZER `neutralizePromotionGateResults`
|
|
9
|
+
* (`#review/subject.js`), which replaces that same cell with a placeholder so
|
|
10
|
+
* the stamp is never hashed into a plan approval's subject.
|
|
11
|
+
*
|
|
12
|
+
* They each used to implement the grammar independently, and diverged on four
|
|
13
|
+
* axes at once — cell count (writer 4 or 5, normalizer 4 only), section scope
|
|
14
|
+
* (writer any table row in the document, normalizer inside `### Promotion Gates`
|
|
15
|
+
* only), trailing pipe (writer `trim().startsWith("|")` alone, normalizer both
|
|
16
|
+
* pipes), and trailing whitespace after the closing pipe (writer sliced the
|
|
17
|
+
* UNTRIMMED line, so it mis-counted a 4-cell row as 5 and appended a spurious
|
|
18
|
+
* empty cell). Every divergence made `digest(doc) !== digest(writer(doc))`,
|
|
19
|
+
* which destroys the plan approval the promotion is gating on. One owner,
|
|
20
|
+
* consumed from both sides, is the only structural fix; anything else lets them
|
|
21
|
+
* drift again.
|
|
22
|
+
*
|
|
23
|
+
* The grammar deliberately matches what `parsePromotionGatesTable` in
|
|
24
|
+
* `./dossier.ts` already enforces on committed documents: a row is the trimmed
|
|
25
|
+
* line starting AND ending with a pipe, cells are split on `|` and trimmed, and
|
|
26
|
+
* the accepted widths are 4 (legacy) and 5 (opt-in `Defer` column). A row this
|
|
27
|
+
* module rejects is a row the dossier parser reports as `malformed table row`,
|
|
28
|
+
* so it can never be a live gate — skipping it is the fail-safe direction.
|
|
29
|
+
*/
|
|
30
|
+
/** Zero-based index of the lifecycle-owned `Last result` cell. */
|
|
31
|
+
export declare const LAST_RESULT_CELL_INDEX = 3;
|
|
32
|
+
/**
|
|
33
|
+
* Cells of one Promotion Gates table row, trimmed, or null when the line is not
|
|
34
|
+
* a row this grammar owns (no pipes, a single pipe, or an unsupported width).
|
|
35
|
+
*/
|
|
36
|
+
export declare function parsePromotionGateCells(line: string): readonly string[] | null;
|
|
37
|
+
/**
|
|
38
|
+
* Canonical row bytes. Whitespace around a row (leading indent, padding inside
|
|
39
|
+
* cells, whitespace after the closing pipe) is not plan substance, so both
|
|
40
|
+
* callers re-emit this single canonical form. That is what keeps a stamped row
|
|
41
|
+
* and its normalized counterpart byte-identical, and it is exactly the form the
|
|
42
|
+
* normalizer has always emitted for a 4-cell row, so committed plan digests are
|
|
43
|
+
* unaffected.
|
|
44
|
+
*/
|
|
45
|
+
export declare function formatPromotionGateRow(cells: readonly string[]): string;
|
|
46
|
+
/** Copy `cells` with the lifecycle-owned `Last result` cell set to `value`. */
|
|
47
|
+
export declare function setLastResultCell(cells: readonly string[], value: string): readonly string[];
|
|
48
|
+
/**
|
|
49
|
+
* Rewrite every Promotion Gates table row (header, separator, and data rows
|
|
50
|
+
* alike) inside the `### Promotion Gates` subsection through `transform`.
|
|
51
|
+
* Returning the received `cells` reference unchanged leaves the line
|
|
52
|
+
* byte-identical; returning a new array re-emits the canonical row. Every line
|
|
53
|
+
* outside the section is always byte-identical.
|
|
54
|
+
*/
|
|
55
|
+
export declare function mapPromotionGateRows(text: string, transform: (cells: readonly string[]) => readonly string[]): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single owner of the Trust Dossier "Promotion Gates" table row grammar.
|
|
3
|
+
*
|
|
4
|
+
* Two callers must agree on this grammar byte-for-byte:
|
|
5
|
+
*
|
|
6
|
+
* - the lifecycle WRITER `updateGateLastResult` (`./promotion.ts`), which stamps
|
|
7
|
+
* the `Last result` cell during `wp blueprint promote`; and
|
|
8
|
+
* - the plan-subject digest NORMALIZER `neutralizePromotionGateResults`
|
|
9
|
+
* (`#review/subject.js`), which replaces that same cell with a placeholder so
|
|
10
|
+
* the stamp is never hashed into a plan approval's subject.
|
|
11
|
+
*
|
|
12
|
+
* They each used to implement the grammar independently, and diverged on four
|
|
13
|
+
* axes at once — cell count (writer 4 or 5, normalizer 4 only), section scope
|
|
14
|
+
* (writer any table row in the document, normalizer inside `### Promotion Gates`
|
|
15
|
+
* only), trailing pipe (writer `trim().startsWith("|")` alone, normalizer both
|
|
16
|
+
* pipes), and trailing whitespace after the closing pipe (writer sliced the
|
|
17
|
+
* UNTRIMMED line, so it mis-counted a 4-cell row as 5 and appended a spurious
|
|
18
|
+
* empty cell). Every divergence made `digest(doc) !== digest(writer(doc))`,
|
|
19
|
+
* which destroys the plan approval the promotion is gating on. One owner,
|
|
20
|
+
* consumed from both sides, is the only structural fix; anything else lets them
|
|
21
|
+
* drift again.
|
|
22
|
+
*
|
|
23
|
+
* The grammar deliberately matches what `parsePromotionGatesTable` in
|
|
24
|
+
* `./dossier.ts` already enforces on committed documents: a row is the trimmed
|
|
25
|
+
* line starting AND ending with a pipe, cells are split on `|` and trimmed, and
|
|
26
|
+
* the accepted widths are 4 (legacy) and 5 (opt-in `Defer` column). A row this
|
|
27
|
+
* module rejects is a row the dossier parser reports as `malformed table row`,
|
|
28
|
+
* so it can never be a live gate — skipping it is the fail-safe direction.
|
|
29
|
+
*/
|
|
30
|
+
/** Zero-based index of the lifecycle-owned `Last result` cell. */
|
|
31
|
+
export const LAST_RESULT_CELL_INDEX = 3;
|
|
32
|
+
/**
|
|
33
|
+
* Exact end-anchored heading match, mirroring `sectionBlock(markdown, 3, ...)`
|
|
34
|
+
* in `./dossier.ts` — `### Promotion Gates Notes` is NOT the owned section and
|
|
35
|
+
* its contents stay plan substance.
|
|
36
|
+
*/
|
|
37
|
+
const PROMOTION_GATES_HEADING = /^###\s+Promotion Gates\s*$/iu;
|
|
38
|
+
/**
|
|
39
|
+
* The section ends at the next heading of ANY level. `sectionBlock` itself ends
|
|
40
|
+
* at the next heading of level <= 3, so a table placed after an intervening
|
|
41
|
+
* `####` heading would be a gate to the parser but not to this walk. The
|
|
42
|
+
* narrower scope is deliberate: it is the scope every committed plan digest was
|
|
43
|
+
* computed against (measured: zero tracked blueprint documents put gate rows in
|
|
44
|
+
* that gap), and the consequence of the mismatch is fail-closed — the writer
|
|
45
|
+
* leaves such a row unstamped, so `validateBlueprintTrust` rejects the promotion
|
|
46
|
+
* with a non-passing gate instead of silently leaking the stamp into the digest.
|
|
47
|
+
*/
|
|
48
|
+
const ANY_HEADING = /^#{1,6}\s/u;
|
|
49
|
+
const ACCEPTED_ROW_WIDTHS = [4, 5];
|
|
50
|
+
/**
|
|
51
|
+
* Cells of one Promotion Gates table row, trimmed, or null when the line is not
|
|
52
|
+
* a row this grammar owns (no pipes, a single pipe, or an unsupported width).
|
|
53
|
+
*/
|
|
54
|
+
export function parsePromotionGateCells(line) {
|
|
55
|
+
const body = line.trim();
|
|
56
|
+
if (body.length < 2 || !body.startsWith("|") || !body.endsWith("|"))
|
|
57
|
+
return null;
|
|
58
|
+
const cells = body
|
|
59
|
+
.slice(1, -1)
|
|
60
|
+
.split("|")
|
|
61
|
+
.map((cell) => cell.trim());
|
|
62
|
+
if (!ACCEPTED_ROW_WIDTHS.includes(cells.length))
|
|
63
|
+
return null;
|
|
64
|
+
return cells;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Canonical row bytes. Whitespace around a row (leading indent, padding inside
|
|
68
|
+
* cells, whitespace after the closing pipe) is not plan substance, so both
|
|
69
|
+
* callers re-emit this single canonical form. That is what keeps a stamped row
|
|
70
|
+
* and its normalized counterpart byte-identical, and it is exactly the form the
|
|
71
|
+
* normalizer has always emitted for a 4-cell row, so committed plan digests are
|
|
72
|
+
* unaffected.
|
|
73
|
+
*/
|
|
74
|
+
export function formatPromotionGateRow(cells) {
|
|
75
|
+
return `| ${cells.join(" | ")} |`;
|
|
76
|
+
}
|
|
77
|
+
/** Copy `cells` with the lifecycle-owned `Last result` cell set to `value`. */
|
|
78
|
+
export function setLastResultCell(cells, value) {
|
|
79
|
+
const next = [...cells];
|
|
80
|
+
next[LAST_RESULT_CELL_INDEX] = value;
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Rewrite every Promotion Gates table row (header, separator, and data rows
|
|
85
|
+
* alike) inside the `### Promotion Gates` subsection through `transform`.
|
|
86
|
+
* Returning the received `cells` reference unchanged leaves the line
|
|
87
|
+
* byte-identical; returning a new array re-emits the canonical row. Every line
|
|
88
|
+
* outside the section is always byte-identical.
|
|
89
|
+
*/
|
|
90
|
+
export function mapPromotionGateRows(text, transform) {
|
|
91
|
+
const lines = text.split("\n");
|
|
92
|
+
let inGatesSection = false;
|
|
93
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
94
|
+
const line = lines[index] ?? "";
|
|
95
|
+
if (PROMOTION_GATES_HEADING.test(line)) {
|
|
96
|
+
inGatesSection = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (inGatesSection && ANY_HEADING.test(line)) {
|
|
100
|
+
inGatesSection = false;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!inGatesSection)
|
|
104
|
+
continue;
|
|
105
|
+
const cells = parsePromotionGateCells(line);
|
|
106
|
+
if (!cells)
|
|
107
|
+
continue;
|
|
108
|
+
const next = transform(cells);
|
|
109
|
+
if (next !== cells)
|
|
110
|
+
lines[index] = formatPromotionGateRow(next);
|
|
111
|
+
}
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
}
|
|
@@ -19,3 +19,20 @@ export declare function assertPromotionTrustProofCurrent(input: Pick<PromotionTr
|
|
|
19
19
|
export declare function runPromotionCommand(repoRoot: string, command: string, deps?: {
|
|
20
20
|
spawn?: typeof spawnSync;
|
|
21
21
|
}): void;
|
|
22
|
+
/**
|
|
23
|
+
* Exported for the plan-subject digest invariance suite
|
|
24
|
+
* (`src/review/lifecycle-writers.ts`, `src/review/subject-normalize.test.ts`).
|
|
25
|
+
* Those tests MUST call the production writer, never a hand-written mirror: a
|
|
26
|
+
* mirror can drift with the very bug under test and stay self-consistent, which
|
|
27
|
+
* is exactly how the 5-cell Promotion Gates leak survived the suite.
|
|
28
|
+
*/
|
|
29
|
+
export declare function upsertReadinessValue(markdown: string, key: string, value: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Stamp the `Last result` cell of the gate named `gate`.
|
|
32
|
+
*
|
|
33
|
+
* Exported for the same reason as `upsertReadinessValue` above. The row grammar
|
|
34
|
+
* and the section scope both come from `./gate-row.js`, which the plan-subject
|
|
35
|
+
* digest normalizer consumes too — hand-rolling either here is what let the two
|
|
36
|
+
* sides diverge on four axes and destroy plan approvals on promotion.
|
|
37
|
+
*/
|
|
38
|
+
export declare function updateGateLastResult(markdown: string, gate: string, result: string): string;
|