@webpresso/agent-kit 3.2.0 → 3.3.0
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/bin/_run.js +4 -1
- package/dist/esm/audit/blueprint-trust.js +14 -10
- package/dist/esm/audit/changeset-evidence.d.ts +23 -0
- package/dist/esm/audit/changeset-evidence.js +30 -0
- package/dist/esm/audit/changeset-required.d.ts +10 -0
- package/dist/esm/audit/changeset-required.js +73 -0
- package/dist/esm/audit/ci-guardrails-detection.d.ts +27 -0
- package/dist/esm/audit/ci-guardrails-detection.js +68 -0
- package/dist/esm/audit/ci-guardrails-wiring.js +3 -13
- package/dist/esm/audit/registry.d.ts +4 -4
- package/dist/esm/audit/registry.js +6 -1
- package/dist/esm/blueprint/core/parser.js +61 -4
- package/dist/esm/blueprint/core/schema.d.ts +1 -0
- package/dist/esm/blueprint/core/schema.js +26 -0
- package/dist/esm/blueprint/lifecycle/audit.js +22 -10
- package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
- package/dist/esm/blueprint/lifecycle/review-provenance.js +39 -8
- package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +97 -0
- package/dist/esm/blueprint/lifecycle/rollout-anchor.js +161 -0
- package/dist/esm/blueprint/sync/client.d.ts +2 -1
- package/dist/esm/blueprint/sync/client.js +1 -1
- package/dist/esm/blueprint/trust/dossier.d.ts +1 -0
- package/dist/esm/blueprint/trust/dossier.js +40 -6
- package/dist/esm/blueprint/trust/promotion.js +12 -0
- package/dist/esm/build/cli-mcp-parity.js +5 -0
- package/dist/esm/cli/cli.d.ts +1 -1
- package/dist/esm/cli/cli.js +9 -0
- package/dist/esm/cli/commands/agent-launch.d.ts +2 -0
- package/dist/esm/cli/commands/agent-launch.js +31 -20
- package/dist/esm/cli/commands/audit.js +4 -0
- package/dist/esm/cli/commands/ci-preflight.js +24 -3
- package/dist/esm/cli/commands/init/convergence-apply.d.ts +64 -0
- package/dist/esm/cli/commands/init/convergence-apply.js +127 -23
- package/dist/esm/cli/commands/init/index.d.ts +2 -0
- package/dist/esm/cli/commands/init/index.js +2 -2
- package/dist/esm/cli/commands/init/package-root.d.ts +7 -0
- package/dist/esm/cli/commands/init/package-root.js +10 -2
- package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +12 -3
- package/dist/esm/cli/commands/init/scaffold-base-kit.js +31 -30
- package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +3 -1
- package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +17 -2
- package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +189 -17
- package/dist/esm/cli/commands/opencode-probe.d.ts +107 -0
- package/dist/esm/cli/commands/opencode-probe.js +323 -0
- package/dist/esm/cli/commands/opencode-rotate.d.ts +23 -0
- package/dist/esm/cli/commands/opencode-rotate.js +23 -11
- package/dist/esm/cli/commands/quality-runner.js +3 -3
- package/dist/esm/cli/commands/review.js +27 -5
- package/dist/esm/cli/commands/rust-check.d.ts +24 -0
- package/dist/esm/cli/commands/rust-check.js +110 -0
- package/dist/esm/cli/commands/typecheck.d.ts +1 -0
- package/dist/esm/cli/commands/typecheck.js +30 -1
- package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +6 -0
- package/dist/esm/cli/commands/worktree/router-dispatch.js +113 -1
- package/dist/esm/cli/commands/worktree/router.js +10 -0
- package/dist/esm/hooks/doctor.d.ts +2 -0
- package/dist/esm/hooks/doctor.js +37 -1
- package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +6 -1
- package/dist/esm/hooks/shared/types.js +27 -1
- package/dist/esm/hooks/stop/qa-changed-files.js +18 -0
- package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +5 -0
- package/dist/esm/mcp/blueprint/handlers/document-mutations.js +31 -7
- package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
- package/dist/esm/mcp/tools/audit.d.ts +2 -1
- package/dist/esm/mcp/tools/audit.js +12 -0
- package/dist/esm/mcp/tools/audits.d.ts +1 -0
- package/dist/esm/mcp/tools/session-fetch-and-index.d.ts +1 -1
- package/dist/esm/package.json +2 -0
- package/dist/esm/platform/client.d.ts +7 -1
- package/dist/esm/platform/github-contract-source.d.ts +2 -1
- package/dist/esm/pr-description/contract.js +24 -4
- package/dist/esm/review/authority.js +14 -9
- package/dist/esm/review/availability.d.ts +11 -0
- package/dist/esm/review/availability.js +18 -3
- package/dist/esm/review/execution/adapters.d.ts +11 -1
- package/dist/esm/review/execution/adapters.js +10 -1
- package/dist/esm/review/execution/artifacts.d.ts +1 -1
- package/dist/esm/review/execution/review-checkout.js +35 -16
- package/dist/esm/review/execution/supervisor.js +31 -4
- package/dist/esm/review/execution/types.d.ts +1 -0
- package/dist/esm/review/subject.d.ts +13 -0
- package/dist/esm/review/subject.js +42 -4
- package/dist/esm/rust/affected-manifests.d.ts +38 -0
- package/dist/esm/rust/affected-manifests.js +69 -0
- package/dist/esm/session-memory/fetch-index.d.ts +1 -1
- package/dist/esm/status/snapshot.js +2 -6
- package/dist/esm/test-helpers/global-setup.d.ts +0 -1
- package/dist/esm/test-helpers/global-setup.js +18 -15
- package/dist/esm/typecheck/planner.d.ts +9 -1
- package/dist/esm/typecheck/planner.js +31 -0
- package/dist/esm/worktrees/orphan-scan.d.ts +50 -0
- package/dist/esm/worktrees/orphan-scan.js +211 -0
- package/package.json +14 -12
package/bin/_run.js
CHANGED
|
@@ -382,10 +382,13 @@ function runtimeSourceRequiresSourceLaunch(sourceRootDir, builtRootDir) {
|
|
|
382
382
|
return false;
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
-
function wpSourceRequiresSourceLaunch(repoRoot) {
|
|
385
|
+
export function wpSourceRequiresSourceLaunch(repoRoot) {
|
|
386
386
|
const sourceRoots = [
|
|
387
387
|
["src", "cli"],
|
|
388
388
|
["src", "tool-runtime"],
|
|
389
|
+
["src", "mcp"],
|
|
390
|
+
["src", "blueprint"],
|
|
391
|
+
["src", "audit"],
|
|
389
392
|
];
|
|
390
393
|
|
|
391
394
|
return sourceRoots.some((segments) =>
|
|
@@ -4,15 +4,16 @@ import matter from "gray-matter";
|
|
|
4
4
|
import { parseTrustDossier } from "#trust/dossier.js";
|
|
5
5
|
import { validateBlueprintTrust } from "#trust/validator.js";
|
|
6
6
|
import { parseBlueprintDocumentRelativePath } from "#utils/document-paths.js";
|
|
7
|
+
import { stableSlugForBlueprintFile } from "#lifecycle/review-provenance.js";
|
|
8
|
+
import { isBlueprintExemptFromRollout, ROLLOUT_DATE } from "#lifecycle/rollout-anchor.js";
|
|
7
9
|
const EXECUTABLE_DIRS = ["planned", "in-progress", "completed"];
|
|
8
|
-
const TRUST_DOSSIER_ROLLOUT_DATE = "2026-07-12";
|
|
9
10
|
export function auditBlueprintTrust(rootDirectory = process.cwd()) {
|
|
10
11
|
const violations = [];
|
|
11
12
|
let checked = 0;
|
|
12
13
|
for (const file of findExecutableBlueprints(rootDirectory)) {
|
|
13
14
|
checked += 1;
|
|
14
15
|
const markdown = readFileSync(path.join(rootDirectory, file), "utf8");
|
|
15
|
-
const grandfatherMissingDossier = isPreContractBlueprintMissingDossier(markdown);
|
|
16
|
+
const grandfatherMissingDossier = isPreContractBlueprintMissingDossier(markdown, rootDirectory, file);
|
|
16
17
|
const status = readStatus(markdown);
|
|
17
18
|
const normalizedStatus = isBlueprintTrustStatus(status) ? status : "planned";
|
|
18
19
|
if (!isBlueprintTrustStatus(status)) {
|
|
@@ -40,20 +41,23 @@ export function auditBlueprintTrust(rootDirectory = process.cwd()) {
|
|
|
40
41
|
}
|
|
41
42
|
return { ok: violations.length === 0, title: "Blueprint trust", checked, violations };
|
|
42
43
|
}
|
|
43
|
-
function isPreContractBlueprintMissingDossier(markdown) {
|
|
44
|
+
function isPreContractBlueprintMissingDossier(markdown, repoRoot, file) {
|
|
44
45
|
const parsedDossier = parseTrustDossier(markdown);
|
|
45
46
|
if (parsedDossier.violations.length !== 1 ||
|
|
46
47
|
parsedDossier.violations[0]?.section !== "Trust Dossier" ||
|
|
47
48
|
parsedDossier.violations[0]?.message !== "missing Trust Dossier section") {
|
|
48
49
|
return false;
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
// Was a compare against frontmatter `created`, which the blueprint own
|
|
52
|
+
// author controls -- writing an old date suppressed the missing-dossier
|
|
53
|
+
// violation outright. Presence at the rollout anchor commit is the
|
|
54
|
+
// unforgeable equivalent; see `#lifecycle/rollout-anchor.js`. Fails closed.
|
|
55
|
+
return isBlueprintExemptFromRollout({
|
|
56
|
+
repoRoot,
|
|
57
|
+
slug: stableSlugForBlueprintFile(file),
|
|
58
|
+
created: matter(markdown).data["created"],
|
|
59
|
+
rolloutDate: ROLLOUT_DATE,
|
|
60
|
+
});
|
|
57
61
|
}
|
|
58
62
|
export function findExecutableBlueprints(rootDirectory) {
|
|
59
63
|
const root = path.join(rootDirectory, "blueprints");
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "does this change carry release evidence" predicate.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the `changeset-missing` rule described in
|
|
5
|
+
* `catalog/agent/rules/changeset-release.md`: release-visible changes need a
|
|
6
|
+
* `.changeset/*.md` entry (or an all-docs change, or a `Changeset-exempt:`
|
|
7
|
+
* trailer). Consumed by both the live-PR readiness evaluator
|
|
8
|
+
* (`#status/snapshot.js`) and the local branch-scoped audit
|
|
9
|
+
* (`#audit/changeset-required.js`) so the two verdicts can never diverge.
|
|
10
|
+
*/
|
|
11
|
+
export declare const CHANGESET_EXEMPT_PATTERN: RegExp;
|
|
12
|
+
/**
|
|
13
|
+
* True when `files` carries release evidence for its own change: every
|
|
14
|
+
* changed file is documentation, one of the changed files is itself a
|
|
15
|
+
* `.changeset/*.md` entry, or one of `exemptionTexts` (PR body, commit
|
|
16
|
+
* messages, ...) carries a `Changeset-exempt: <reason>` trailer.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors the original `wp_pr_status` behavior exactly: an empty `files` list
|
|
19
|
+
* is NOT treated as evidence (fails closed) — callers that consider "no
|
|
20
|
+
* changed files" a legitimate, non-suspicious state (e.g. a freshly rebased
|
|
21
|
+
* local branch) must short-circuit before calling this function.
|
|
22
|
+
*/
|
|
23
|
+
export declare function hasChangesetEvidence(files: readonly string[], exemptionTexts: readonly string[]): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "does this change carry release evidence" predicate.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the `changeset-missing` rule described in
|
|
5
|
+
* `catalog/agent/rules/changeset-release.md`: release-visible changes need a
|
|
6
|
+
* `.changeset/*.md` entry (or an all-docs change, or a `Changeset-exempt:`
|
|
7
|
+
* trailer). Consumed by both the live-PR readiness evaluator
|
|
8
|
+
* (`#status/snapshot.js`) and the local branch-scoped audit
|
|
9
|
+
* (`#audit/changeset-required.js`) so the two verdicts can never diverge.
|
|
10
|
+
*/
|
|
11
|
+
const CHANGESET_FILE_PATTERN = /^\.changeset\/[^/]+\.md$/u;
|
|
12
|
+
export const CHANGESET_EXEMPT_PATTERN = /Changeset-exempt:\s*\S/iu;
|
|
13
|
+
/**
|
|
14
|
+
* True when `files` carries release evidence for its own change: every
|
|
15
|
+
* changed file is documentation, one of the changed files is itself a
|
|
16
|
+
* `.changeset/*.md` entry, or one of `exemptionTexts` (PR body, commit
|
|
17
|
+
* messages, ...) carries a `Changeset-exempt: <reason>` trailer.
|
|
18
|
+
*
|
|
19
|
+
* Mirrors the original `wp_pr_status` behavior exactly: an empty `files` list
|
|
20
|
+
* is NOT treated as evidence (fails closed) — callers that consider "no
|
|
21
|
+
* changed files" a legitimate, non-suspicious state (e.g. a freshly rebased
|
|
22
|
+
* local branch) must short-circuit before calling this function.
|
|
23
|
+
*/
|
|
24
|
+
export function hasChangesetEvidence(files, exemptionTexts) {
|
|
25
|
+
if (files.length > 0 && files.every((file) => file.endsWith(".md")))
|
|
26
|
+
return true;
|
|
27
|
+
if (files.some((file) => CHANGESET_FILE_PATTERN.test(file)))
|
|
28
|
+
return true;
|
|
29
|
+
return exemptionTexts.some((text) => CHANGESET_EXEMPT_PATTERN.test(text));
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RepoAuditResult } from "./repo-guardrails.js";
|
|
2
|
+
export interface ChangesetRequiredOptions {
|
|
3
|
+
/** Base ref for the branch diff. Defaults to `origin/${GITHUB_BASE_REF:-main}`. */
|
|
4
|
+
readonly baseRef?: string;
|
|
5
|
+
/** Test/adapter seam for an already-resolved changed-file list. */
|
|
6
|
+
readonly changedFiles?: readonly string[];
|
|
7
|
+
/** Test/adapter seam for already-resolved commit messages. */
|
|
8
|
+
readonly commitMessages?: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function auditChangesetRequired(rootDirectory?: string, options?: ChangesetRequiredOptions): RepoAuditResult;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { defaultBranchBaseRef, getBranchChangedFiles } from "#git/changed-files";
|
|
3
|
+
import { hasChangesetEvidence } from "./changeset-evidence.js";
|
|
4
|
+
/**
|
|
5
|
+
* `wp audit changeset-required` — the local, branch-scoped mirror of the
|
|
6
|
+
* `changeset-missing` blocker `wp_pr_status` computes against a live PR (see
|
|
7
|
+
* `#status/snapshot.js`). Both call the same `hasChangesetEvidence` predicate
|
|
8
|
+
* so a locally-green `wp ci-preflight` can never diverge from the landing
|
|
9
|
+
* verdict (`catalog/agent/rules/ci-cost-local-first.md`).
|
|
10
|
+
*
|
|
11
|
+
* Unlike the PR-based check (which reads `pr.files`/`pr.body` from the GitHub
|
|
12
|
+
* API), this audit resolves its own branch diff and scans local commit
|
|
13
|
+
* messages for a `Changeset-exempt: <reason>` trailer, so it works fully
|
|
14
|
+
* offline pre-push.
|
|
15
|
+
*/
|
|
16
|
+
const TITLE = "Changeset required";
|
|
17
|
+
const GIT_LOG_PROBE_OPTIONS = { timeout: 1_500, maxBuffer: 1024 * 1024 };
|
|
18
|
+
export function auditChangesetRequired(rootDirectory = process.cwd(), options = {}) {
|
|
19
|
+
const baseRef = options.baseRef?.trim() || defaultBranchBaseRef();
|
|
20
|
+
let changedFiles;
|
|
21
|
+
if (options.changedFiles) {
|
|
22
|
+
changedFiles = options.changedFiles;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
const resolved = resolveChangedFiles(rootDirectory, baseRef);
|
|
26
|
+
if (resolved.error)
|
|
27
|
+
return fail(resolved.error, 0);
|
|
28
|
+
changedFiles = resolved.files;
|
|
29
|
+
}
|
|
30
|
+
if (changedFiles.length === 0) {
|
|
31
|
+
return passWithWarning(`[warn] changeset-required: no changed files vs ${baseRef} — skipping`, 0);
|
|
32
|
+
}
|
|
33
|
+
const exemptionMessages = options.commitMessages ?? readCommitMessages(rootDirectory, baseRef);
|
|
34
|
+
if (hasChangesetEvidence(changedFiles, exemptionMessages)) {
|
|
35
|
+
return pass(changedFiles.length);
|
|
36
|
+
}
|
|
37
|
+
return fail("release-visible changes require a .changeset/*.md entry (`wp run changeset`) " +
|
|
38
|
+
"or a `Changeset-exempt: <reason>` commit trailer", changedFiles.length);
|
|
39
|
+
}
|
|
40
|
+
function pass(checked) {
|
|
41
|
+
return { ok: true, title: TITLE, checked, violations: [] };
|
|
42
|
+
}
|
|
43
|
+
function passWithWarning(message, checked) {
|
|
44
|
+
return { ok: true, title: TITLE, checked, violations: [{ message }] };
|
|
45
|
+
}
|
|
46
|
+
function fail(message, checked) {
|
|
47
|
+
return { ok: false, title: TITLE, checked, violations: [{ message }] };
|
|
48
|
+
}
|
|
49
|
+
function resolveChangedFiles(cwd, baseRef) {
|
|
50
|
+
const result = getBranchChangedFiles(cwd, baseRef);
|
|
51
|
+
if (result.degraded) {
|
|
52
|
+
return {
|
|
53
|
+
files: [],
|
|
54
|
+
error: `unable to resolve changed files vs ${baseRef} (${result.reason}); ` +
|
|
55
|
+
"refusing to skip changeset enforcement",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { files: result.files };
|
|
59
|
+
}
|
|
60
|
+
function readCommitMessages(cwd, baseRef) {
|
|
61
|
+
const result = spawnSync("git", ["log", "--format=%B%x00", `${baseRef}...HEAD`], {
|
|
62
|
+
cwd,
|
|
63
|
+
encoding: "utf8",
|
|
64
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
65
|
+
...GIT_LOG_PROBE_OPTIONS,
|
|
66
|
+
});
|
|
67
|
+
if (result.status !== 0 || result.error)
|
|
68
|
+
return [];
|
|
69
|
+
return result.stdout
|
|
70
|
+
.split("\0")
|
|
71
|
+
.map((entry) => entry.trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared content-absence detection for the `wp audit guardrails` CI wiring
|
|
3
|
+
* contract. Both the audit (`ci-guardrails-wiring.ts`) and the self-healing
|
|
4
|
+
* injector (`scaffold-base-kit.ts`'s `ensureGuardrailsCiStep`) must agree on
|
|
5
|
+
* what counts as a "live" test-run step to anchor guardrails after — keeping
|
|
6
|
+
* that detection in one place is what keeps the two from silently diverging.
|
|
7
|
+
*
|
|
8
|
+
* Prior to this module, the audit matched the test-run pattern against whole
|
|
9
|
+
* file content (including inside `#`-commented lines) while the injector's
|
|
10
|
+
* anchor scan already skipped comment lines. A workflow whose only test-run
|
|
11
|
+
* mention was commented out therefore tripped the audit red with no anchor
|
|
12
|
+
* for the injector to repair — an unrepairable-red state (tech-debt h-016).
|
|
13
|
+
*/
|
|
14
|
+
/** True when `content` invokes `wp audit guardrails` anywhere (including
|
|
15
|
+
* inside comments — see the pattern's doc comment above). */
|
|
16
|
+
export declare function hasGuardrailsInvocation(content: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Scans `content` line by line, skipping commented-out lines, tracking the
|
|
19
|
+
* most recent step-start line (`- ...`), and returning the index of the step
|
|
20
|
+
* that owns the first non-commented line matching the live test-run pattern.
|
|
21
|
+
* Returns -1 when no such line exists.
|
|
22
|
+
*
|
|
23
|
+
* This is the single source of truth for "is there a live test-run step to
|
|
24
|
+
* anchor a guardrails step before" — used by the audit (as a boolean) and by
|
|
25
|
+
* the injector (as an insertion index).
|
|
26
|
+
*/
|
|
27
|
+
export declare function findLiveTestRunAnchor(content: string): number;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared content-absence detection for the `wp audit guardrails` CI wiring
|
|
3
|
+
* contract. Both the audit (`ci-guardrails-wiring.ts`) and the self-healing
|
|
4
|
+
* injector (`scaffold-base-kit.ts`'s `ensureGuardrailsCiStep`) must agree on
|
|
5
|
+
* what counts as a "live" test-run step to anchor guardrails after — keeping
|
|
6
|
+
* that detection in one place is what keeps the two from silently diverging.
|
|
7
|
+
*
|
|
8
|
+
* Prior to this module, the audit matched the test-run pattern against whole
|
|
9
|
+
* file content (including inside `#`-commented lines) while the injector's
|
|
10
|
+
* anchor scan already skipped comment lines. A workflow whose only test-run
|
|
11
|
+
* mention was commented out therefore tripped the audit red with no anchor
|
|
12
|
+
* for the injector to repair — an unrepairable-red state (tech-debt h-016).
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The guardrails invocation, keyed on the command itself (not the `wp`
|
|
16
|
+
* prefix nor a marker comment) so `wp audit guardrails`,
|
|
17
|
+
* `./bin/wp audit guardrails`, and the `--affected`/`--full` variants all
|
|
18
|
+
* count as wired.
|
|
19
|
+
*
|
|
20
|
+
* NOTE: this intentionally matches inside comments too — it is a
|
|
21
|
+
* whole-content test, not line-scanned. A commented-out `wp audit
|
|
22
|
+
* guardrails` line therefore still counts as "wired" by both the audit and
|
|
23
|
+
* the injector. This is an inherent limitation of content-absence
|
|
24
|
+
* pattern-matching (neither side parses YAML as executable steps) rather
|
|
25
|
+
* than a bug unique to either detector — see tech-debt h-016. Do not "fix"
|
|
26
|
+
* this by line-scanning here without first solving real YAML-step parsing;
|
|
27
|
+
* a half-parsed heuristic would be worse than the documented limitation.
|
|
28
|
+
*/
|
|
29
|
+
const GUARDRAILS_INVOCATION_PATTERN = /\baudit guardrails\b/u;
|
|
30
|
+
/**
|
|
31
|
+
* Lines that run the default test suite — the signal that a workflow is a
|
|
32
|
+
* quality-CI pipeline that owes a guardrails step. Comment lines are
|
|
33
|
+
* excluded: a commented-out test-run mention is not a live test run.
|
|
34
|
+
*/
|
|
35
|
+
const TEST_RUN_PATTERN = /(?:\bvp\s+run\s+test\b|\bpnpm\s+(?:run\s+)?test\b|\bnpm\s+(?:run\s+)?test\b|\bbun\s+(?:run\s+)?test\b|\bwp\s+test\b|\bvitest\s+run\b)/iu;
|
|
36
|
+
function isCommentLine(line) {
|
|
37
|
+
return line.trim().startsWith("#");
|
|
38
|
+
}
|
|
39
|
+
/** True when `content` invokes `wp audit guardrails` anywhere (including
|
|
40
|
+
* inside comments — see the pattern's doc comment above). */
|
|
41
|
+
export function hasGuardrailsInvocation(content) {
|
|
42
|
+
return GUARDRAILS_INVOCATION_PATTERN.test(content);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Scans `content` line by line, skipping commented-out lines, tracking the
|
|
46
|
+
* most recent step-start line (`- ...`), and returning the index of the step
|
|
47
|
+
* that owns the first non-commented line matching the live test-run pattern.
|
|
48
|
+
* Returns -1 when no such line exists.
|
|
49
|
+
*
|
|
50
|
+
* This is the single source of truth for "is there a live test-run step to
|
|
51
|
+
* anchor a guardrails step before" — used by the audit (as a boolean) and by
|
|
52
|
+
* the injector (as an insertion index).
|
|
53
|
+
*/
|
|
54
|
+
export function findLiveTestRunAnchor(content) {
|
|
55
|
+
const lines = content.split("\n");
|
|
56
|
+
let lastStepStart = -1;
|
|
57
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
58
|
+
// Safe: index is bounded by the loop condition against lines.length.
|
|
59
|
+
const line = lines[index];
|
|
60
|
+
if (isCommentLine(line))
|
|
61
|
+
continue;
|
|
62
|
+
if (/^\s*-\s/u.test(line))
|
|
63
|
+
lastStepStart = index;
|
|
64
|
+
if (TEST_RUN_PATTERN.test(line))
|
|
65
|
+
return lastStepStart;
|
|
66
|
+
}
|
|
67
|
+
return -1;
|
|
68
|
+
}
|
|
@@ -20,19 +20,9 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
22
22
|
import { join, relative } from "node:path";
|
|
23
|
+
import { findLiveTestRunAnchor, hasGuardrailsInvocation } from "./ci-guardrails-detection.js";
|
|
23
24
|
const WORKFLOW_DIR = ".github/workflows";
|
|
24
25
|
const WORKFLOW_FILE_PATTERN = /\.(ya?ml)$/iu;
|
|
25
|
-
/** The guardrails invocation, keyed on the command itself (not the `wp` prefix
|
|
26
|
-
* nor a marker comment) so `wp audit guardrails`, `./bin/wp audit guardrails`,
|
|
27
|
-
* and the `--affected`/`--full` variants all count as wired. */
|
|
28
|
-
const GUARDRAILS_INVOCATION_PATTERN = /\baudit guardrails\b/u;
|
|
29
|
-
/**
|
|
30
|
-
* Lines that run the default test suite — the signal that a workflow is a
|
|
31
|
-
* quality-CI pipeline that owes a guardrails step. Mirrors the notion of a
|
|
32
|
-
* test-running workflow used by `ci-test-perf.ts`; kept local so the two audits
|
|
33
|
-
* stay independent.
|
|
34
|
-
*/
|
|
35
|
-
const TEST_RUN_PATTERN = /(?:\bvp\s+run\s+test\b|\bpnpm\s+(?:run\s+)?test\b|\bnpm\s+(?:run\s+)?test\b|\bbun\s+(?:run\s+)?test\b|\bwp\s+test\b|\bvitest\s+run\b)/iu;
|
|
36
26
|
function walkWorkflowFiles(dir) {
|
|
37
27
|
if (!existsSync(dir))
|
|
38
28
|
return [];
|
|
@@ -56,9 +46,9 @@ export function auditCiGuardrailsWiring(rootDirectory = process.cwd()) {
|
|
|
56
46
|
let anyWorkflowWiresGuardrails = false;
|
|
57
47
|
for (const file of files) {
|
|
58
48
|
const content = readFileSync(file, "utf8");
|
|
59
|
-
if (
|
|
49
|
+
if (hasGuardrailsInvocation(content))
|
|
60
50
|
anyWorkflowWiresGuardrails = true;
|
|
61
|
-
if (
|
|
51
|
+
if (findLiveTestRunAnchor(content) !== -1) {
|
|
62
52
|
testWorkflows.push(relative(rootDirectory, file).replace(/\\/gu, "/"));
|
|
63
53
|
}
|
|
64
54
|
}
|
|
@@ -14,11 +14,11 @@ export interface AuditKindDescriptor {
|
|
|
14
14
|
}
|
|
15
15
|
export declare const SCRIPT_AUDIT_KIND_IDS: readonly ["test-smells", "tph-e2e"];
|
|
16
16
|
export declare const SPECIAL_AUDIT_KIND_IDS: readonly ["bundle-budget", "commit-message", "blueprint-pr-coverage", "mutation", "guardrails", "quality"];
|
|
17
|
-
export declare const REPO_AUDIT_KIND_IDS: readonly ["catalog-drift", "package-surface", "command-surface", "reference-parity-matrix", "blueprint-readme-drift", "blueprint-lifecycle", "blueprint-trust", "roadmap-links", "docs-frontmatter", "agents", "vision", "tech-debt", "supported-agent-clis", "no-relative-parent-imports", "no-link-protocol", "no-relative-package-scripts", "test-isolation", "bucket-boundary", "skill-sizes", "broken-refs", "memory-rotation", "gitignore-agent-surfaces", "memory-unified", "compile-drift", "no-legacy-cli-bin", "architecture-drift", "cloudflare-deploy-contract", "toolchain-isolation", "absolute-path-policy", "no-first-party-mjs", "outside-review-runtime-ownership", "no-math-random", "agent-cost", "blueprint-db-consistency", "tech-debt-cadence", "cross-repo-correlation", "ai-contracts", "atomic-state-writes", "hook-surface", "hook-vendor-drift", "session-memory-hardcut", "open-source-licenses", "secrets-policy", "no-dev-vars", "secret-provider-quarantine", "github-actions-secrets", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "ci-guardrails-wiring", "test-scan-perf", "worktree-main-ownership", "sast", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "host-substitution-risk", "legacy-workflow-identity", "rules", "skills", "typescript-version"];
|
|
17
|
+
export declare const REPO_AUDIT_KIND_IDS: readonly ["catalog-drift", "package-surface", "command-surface", "reference-parity-matrix", "blueprint-readme-drift", "blueprint-lifecycle", "blueprint-trust", "roadmap-links", "docs-frontmatter", "agents", "vision", "tech-debt", "supported-agent-clis", "no-relative-parent-imports", "no-link-protocol", "no-relative-package-scripts", "test-isolation", "bucket-boundary", "skill-sizes", "broken-refs", "memory-rotation", "gitignore-agent-surfaces", "memory-unified", "compile-drift", "no-legacy-cli-bin", "architecture-drift", "cloudflare-deploy-contract", "toolchain-isolation", "absolute-path-policy", "no-first-party-mjs", "outside-review-runtime-ownership", "no-math-random", "agent-cost", "blueprint-db-consistency", "tech-debt-cadence", "cross-repo-correlation", "ai-contracts", "atomic-state-writes", "hook-surface", "hook-vendor-drift", "session-memory-hardcut", "open-source-licenses", "secrets-policy", "no-dev-vars", "secret-provider-quarantine", "github-actions-secrets", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "ci-guardrails-wiring", "test-scan-perf", "worktree-main-ownership", "sast", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "host-substitution-risk", "legacy-workflow-identity", "rules", "skills", "typescript-version", "changeset-required"];
|
|
18
18
|
export declare const AUDIT_KIND_DESCRIPTORS: readonly AuditKindDescriptor[];
|
|
19
|
-
export declare const AUDIT_KIND_IDS: readonly ["test-smells", "tph-e2e", ...("absolute-path-policy" | "agent-cost" | "agents" | "ai-contracts" | "architecture-drift" | "atomic-state-writes" | "blueprint-db-consistency" | "blueprint-lifecycle" | "blueprint-pr-coverage" | "blueprint-readme-drift" | "blueprint-trust" | "broken-refs" | "bucket-boundary" | "bundle-budget" | "catalog-drift" | "ci-guardrails-wiring" | "ci-test-perf" | "cloudflare-deploy-contract" | "command-surface" | "commit-message" | "compile-drift" | "consumer-agent-kit-dependency" | "cross-repo-correlation" | "docs-frontmatter" | "github-actions-secrets" | "gitignore-agent-surfaces" | "guardrails" | "harness-overlay-evidence" | "harness-surfaces" | "hook-surface" | "hook-vendor-drift" | "host-substitution-risk" | "legacy-workflow-identity" | "memory-rotation" | "memory-unified" | "mutation" | "no-dev-vars" | "no-first-party-mjs" | "no-legacy-cli-bin" | "no-link-protocol" | "no-math-random" | "no-relative-package-scripts" | "no-relative-parent-imports" | "open-source-licenses" | "outside-review-runtime-ownership" | "package-surface" | "quality" | "reference-parity-matrix" | "roadmap-links" | "rules" | "sast" | "secret-provider-quarantine" | "secrets-config" | "secrets-policy" | "security-quality-regressions" | "session-memory-hardcut" | "skill-sizes" | "skills" | "supported-agent-clis" | "tech-debt" | "tech-debt-cadence" | "test-isolation" | "test-scan-perf" | "toolchain-isolation" | "typescript-version" | "vision" | "weakness-mining" | "worktree-main-ownership")[]];
|
|
20
|
-
export declare const MCP_AUDIT_KIND_IDS: readonly ["test-smells", "tph-e2e", "agents", "catalog-drift", "package-surface", "reference-parity-matrix", "docs-frontmatter", "blueprint-readme-drift", "blueprint-pr-coverage", "blueprint-lifecycle", "blueprint-trust", "architecture-drift", "cloudflare-deploy-contract", "absolute-path-policy", "no-first-party-mjs", "no-math-random", "roadmap-links", "bundle-budget", "commit-message", "tech-debt", "supported-agent-clis", "hook-surface", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "host-substitution-risk", "ai-contracts", "atomic-state-writes", "no-relative-package-scripts", "toolchain-isolation", "open-source-licenses", "secrets-policy", "no-dev-vars", "github-actions-secrets", "secret-provider-quarantine", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "test-scan-perf", "session-memory-hardcut", "worktree-main-ownership", "typescript-version", "ci-guardrails-wiring", "sast"];
|
|
21
|
-
export declare const AFFECTED_SAFE_AUDIT_KIND_IDS: readonly ["blueprint-readme-drift", "blueprint-lifecycle", "bucket-boundary", "skill-sizes", "broken-refs", "no-dev-vars", "absolute-path-policy", "secret-provider-quarantine", "secrets-config", "docs-frontmatter", "no-relative-parent-imports", "no-link-protocol", "no-first-party-mjs", "no-math-random", "test-scan-perf", "ci-guardrails-wiring"];
|
|
19
|
+
export declare const AUDIT_KIND_IDS: readonly ["test-smells", "tph-e2e", ...("absolute-path-policy" | "agent-cost" | "agents" | "ai-contracts" | "architecture-drift" | "atomic-state-writes" | "blueprint-db-consistency" | "blueprint-lifecycle" | "blueprint-pr-coverage" | "blueprint-readme-drift" | "blueprint-trust" | "broken-refs" | "bucket-boundary" | "bundle-budget" | "catalog-drift" | "changeset-required" | "ci-guardrails-wiring" | "ci-test-perf" | "cloudflare-deploy-contract" | "command-surface" | "commit-message" | "compile-drift" | "consumer-agent-kit-dependency" | "cross-repo-correlation" | "docs-frontmatter" | "github-actions-secrets" | "gitignore-agent-surfaces" | "guardrails" | "harness-overlay-evidence" | "harness-surfaces" | "hook-surface" | "hook-vendor-drift" | "host-substitution-risk" | "legacy-workflow-identity" | "memory-rotation" | "memory-unified" | "mutation" | "no-dev-vars" | "no-first-party-mjs" | "no-legacy-cli-bin" | "no-link-protocol" | "no-math-random" | "no-relative-package-scripts" | "no-relative-parent-imports" | "open-source-licenses" | "outside-review-runtime-ownership" | "package-surface" | "quality" | "reference-parity-matrix" | "roadmap-links" | "rules" | "sast" | "secret-provider-quarantine" | "secrets-config" | "secrets-policy" | "security-quality-regressions" | "session-memory-hardcut" | "skill-sizes" | "skills" | "supported-agent-clis" | "tech-debt" | "tech-debt-cadence" | "test-isolation" | "test-scan-perf" | "toolchain-isolation" | "typescript-version" | "vision" | "weakness-mining" | "worktree-main-ownership")[]];
|
|
20
|
+
export declare const MCP_AUDIT_KIND_IDS: readonly ["test-smells", "tph-e2e", "agents", "catalog-drift", "package-surface", "reference-parity-matrix", "docs-frontmatter", "blueprint-readme-drift", "blueprint-pr-coverage", "blueprint-lifecycle", "blueprint-trust", "architecture-drift", "cloudflare-deploy-contract", "absolute-path-policy", "no-first-party-mjs", "no-math-random", "roadmap-links", "bundle-budget", "commit-message", "tech-debt", "supported-agent-clis", "hook-surface", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "host-substitution-risk", "ai-contracts", "atomic-state-writes", "no-relative-package-scripts", "toolchain-isolation", "open-source-licenses", "secrets-policy", "no-dev-vars", "github-actions-secrets", "secret-provider-quarantine", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "test-scan-perf", "session-memory-hardcut", "worktree-main-ownership", "typescript-version", "ci-guardrails-wiring", "sast", "changeset-required"];
|
|
21
|
+
export declare const AFFECTED_SAFE_AUDIT_KIND_IDS: readonly ["blueprint-readme-drift", "blueprint-lifecycle", "bucket-boundary", "skill-sizes", "broken-refs", "no-dev-vars", "absolute-path-policy", "secret-provider-quarantine", "secrets-config", "docs-frontmatter", "no-relative-parent-imports", "no-link-protocol", "no-first-party-mjs", "no-math-random", "test-scan-perf", "ci-guardrails-wiring", "changeset-required"];
|
|
22
22
|
export type AuditKindId = (typeof AUDIT_KIND_IDS)[number];
|
|
23
23
|
export type RepoAuditKindId = (typeof REPO_AUDIT_KIND_IDS)[number];
|
|
24
24
|
export type MCPAuditKindId = (typeof MCP_AUDIT_KIND_IDS)[number];
|
|
@@ -18,6 +18,7 @@ const affectedSafeKinds = [
|
|
|
18
18
|
"no-math-random",
|
|
19
19
|
"test-scan-perf",
|
|
20
20
|
"ci-guardrails-wiring",
|
|
21
|
+
"changeset-required",
|
|
21
22
|
];
|
|
22
23
|
const mcpKindIds = [
|
|
23
24
|
"test-smells",
|
|
@@ -65,6 +66,7 @@ const mcpKindIds = [
|
|
|
65
66
|
"typescript-version",
|
|
66
67
|
"ci-guardrails-wiring",
|
|
67
68
|
"sast",
|
|
69
|
+
"changeset-required",
|
|
68
70
|
];
|
|
69
71
|
const affectedSafe = new Set(affectedSafeKinds);
|
|
70
72
|
const mcpKinds = new Set(mcpKindIds);
|
|
@@ -148,6 +150,7 @@ const repoKinds = [
|
|
|
148
150
|
"rules",
|
|
149
151
|
"skills",
|
|
150
152
|
"typescript-version",
|
|
153
|
+
"changeset-required",
|
|
151
154
|
];
|
|
152
155
|
function descriptor(id, kindClass) {
|
|
153
156
|
return {
|
|
@@ -158,7 +161,9 @@ function descriptor(id, kindClass) {
|
|
|
158
161
|
gateClass: signalKinds.has(id) ? "signal" : "blocking",
|
|
159
162
|
scopeSafety: affectedSafe.has(id) ? "affected-safe" : "full-scan-only",
|
|
160
163
|
summaryLabel: id === "sast" ? "SAST" : id,
|
|
161
|
-
...(["sast", "host-substitution-risk"].includes(id)
|
|
164
|
+
...(["sast", "host-substitution-risk", "changeset-required"].includes(id)
|
|
165
|
+
? { includedInGuardrails: false }
|
|
166
|
+
: {}),
|
|
162
167
|
...(["blueprint-readme-drift", "docs-frontmatter"].includes(id) ? { fixable: true } : {}),
|
|
163
168
|
};
|
|
164
169
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import matter from "gray-matter";
|
|
8
8
|
import { planFrontmatterSchema, taskStatusSchema } from "#core/schema";
|
|
9
|
+
import { buildTaskHeadingRegex } from "#markdown/task-heading";
|
|
9
10
|
function parseWithSchema(markdown, schema) {
|
|
10
11
|
const { data: rawData, content } = matter(markdown);
|
|
11
12
|
const data = schema.parse(rawData);
|
|
@@ -75,7 +76,7 @@ export function serializeBlueprint(blueprint) {
|
|
|
75
76
|
}
|
|
76
77
|
const { tasks: _tasks, task_statuses: _task_statuses, ...cleanedData } = data;
|
|
77
78
|
const serialized = quoteGeneratedProgressScalar(matter.stringify(content, cleanedData), blueprint.progress);
|
|
78
|
-
return preserveOriginalFrontmatterScalars(serialized, blueprint.raw);
|
|
79
|
+
return preserveOriginalFrontmatterMultiline(preserveOriginalFrontmatterScalars(serialized, blueprint.raw), blueprint.raw);
|
|
79
80
|
}
|
|
80
81
|
// Frontmatter keys serializeBlueprint intentionally rewrites; every other
|
|
81
82
|
// scalar must survive a write byte-for-byte.
|
|
@@ -99,8 +100,8 @@ function quoteGeneratedProgressScalar(serialized, progress) {
|
|
|
99
100
|
// re-quote on a write silently invalidates a recorded review approval. Restore
|
|
100
101
|
// the ORIGINAL single-line scalar verbatim for every field this serializer does
|
|
101
102
|
// not own, so a status/progress write never disturbs `title`/`owner`/`created`
|
|
102
|
-
// etc. Multi-line
|
|
103
|
-
//
|
|
103
|
+
// etc. Multi-line/array fields are restored separately by
|
|
104
|
+
// preserveOriginalFrontmatterMultiline. Generalizes the earlier progress-only patch.
|
|
104
105
|
function preserveOriginalFrontmatterScalars(serialized, originalRaw) {
|
|
105
106
|
const originalFrontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(originalRaw)?.[1] ?? "";
|
|
106
107
|
const originalScalars = new Map();
|
|
@@ -124,6 +125,59 @@ function preserveOriginalFrontmatterScalars(serialized, originalRaw) {
|
|
|
124
125
|
.join("\n");
|
|
125
126
|
return `${serialized.slice(0, block.index)}${block[1]}${rebuilt}${block[3]}${serialized.slice(block.index + block[0].length)}`;
|
|
126
127
|
}
|
|
128
|
+
// Same residual as scalars, for multi-line blocks (YAML arrays/maps under a
|
|
129
|
+
// key with no inline value). `matter.stringify` re-dumps arrays and drops
|
|
130
|
+
// item quotes (`- "review"` -> `- review`). MCP put and the CLI disagree on
|
|
131
|
+
// that quoting; restore the ORIGINAL multi-line block for every non-owned key.
|
|
132
|
+
// Allow optional CR on the key line so CRLF checkouts still match.
|
|
133
|
+
const FRONTMATTER_MULTILINE_KEY = /^([A-Za-z0-9_]+):[ \t]*\r?$/;
|
|
134
|
+
function preserveOriginalFrontmatterMultiline(serialized, originalRaw) {
|
|
135
|
+
const originalFrontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(originalRaw)?.[1] ?? "";
|
|
136
|
+
const originalBlocks = new Map();
|
|
137
|
+
const lines = originalFrontmatter.split("\n");
|
|
138
|
+
for (let index = 0; index < lines.length;) {
|
|
139
|
+
const match = FRONTMATTER_MULTILINE_KEY.exec(lines[index]);
|
|
140
|
+
if (!match) {
|
|
141
|
+
index += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const key = match[1];
|
|
145
|
+
if (SERIALIZE_OWNED_FRONTMATTER_KEYS.has(key)) {
|
|
146
|
+
index += 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// Strip a trailing CR so joined blocks stay LF-normalized like the rest of the pipeline.
|
|
150
|
+
const blockLines = [lines[index].replace(/\r$/, "")];
|
|
151
|
+
index += 1;
|
|
152
|
+
while (index < lines.length && /^[ \t]/.test(lines[index])) {
|
|
153
|
+
blockLines.push(lines[index].replace(/\r$/, ""));
|
|
154
|
+
index += 1;
|
|
155
|
+
}
|
|
156
|
+
if (blockLines.length > 1) {
|
|
157
|
+
originalBlocks.set(key, blockLines.join("\n"));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (originalBlocks.size === 0)
|
|
161
|
+
return serialized;
|
|
162
|
+
const wrapper = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(serialized);
|
|
163
|
+
if (!wrapper)
|
|
164
|
+
return serialized;
|
|
165
|
+
let body = wrapper[2];
|
|
166
|
+
for (const [key, originalBlock] of originalBlocks) {
|
|
167
|
+
// Match the serialized multi-line block. A trailing newline is optional when
|
|
168
|
+
// the block is the last key in the frontmatter body (wrapper[2] does not
|
|
169
|
+
// include the newline before `---`).
|
|
170
|
+
const pattern = new RegExp(String.raw `^${key}:[ \t]*\n(?:[ \t][^\n]*\n?)*`, "mu");
|
|
171
|
+
if (!pattern.test(body))
|
|
172
|
+
continue;
|
|
173
|
+
// Replacer function: avoid `$` re-interpretation of originalBlock contents,
|
|
174
|
+
// and only re-append a trailing newline when the match consumed one — otherwise
|
|
175
|
+
// a last-key `tags:` block gains a blank line before `---` and the plan digest
|
|
176
|
+
// flips on the first lifecycle write after MCP put (B1).
|
|
177
|
+
body = body.replace(pattern, (matched) => matched.endsWith("\n") ? `${originalBlock}\n` : originalBlock);
|
|
178
|
+
}
|
|
179
|
+
return `${serialized.slice(0, wrapper.index)}${wrapper[1]}${body}${wrapper[3]}${serialized.slice(wrapper.index + wrapper[0].length)}`;
|
|
180
|
+
}
|
|
127
181
|
function extractCheckboxStatus(section) {
|
|
128
182
|
const checkboxRegex = /^- \[([ x])\]/gm;
|
|
129
183
|
const matches = Array.from(section.matchAll(checkboxRegex));
|
|
@@ -178,7 +232,10 @@ function validateTaskFormat(content) {
|
|
|
178
232
|
}
|
|
179
233
|
}
|
|
180
234
|
function extractTasks(content) {
|
|
181
|
-
|
|
235
|
+
// Built from #markdown/task-heading's shared pattern (also consumed by
|
|
236
|
+
// #core/schema's taskIdSchema) so the reader and the MCP put-task writer
|
|
237
|
+
// cannot drift apart on what counts as a valid task ID.
|
|
238
|
+
const taskRegex = buildTaskHeadingRegex();
|
|
182
239
|
const matches = Array.from(content.matchAll(taskRegex));
|
|
183
240
|
validateTaskFormat(content);
|
|
184
241
|
return matches.map((match, index) => {
|
|
@@ -11,6 +11,7 @@ import { z } from "zod";
|
|
|
11
11
|
*/
|
|
12
12
|
import { executionBackendSchema } from "#types/execution-backend.js";
|
|
13
13
|
export { executionBackendSchema } from "#types/execution-backend.js";
|
|
14
|
+
import { TASK_ID_PATTERN as TASK_HEADING_TASK_ID_PATTERN } from "#markdown/task-heading";
|
|
14
15
|
/**
|
|
15
16
|
* Valid plan status values.
|
|
16
17
|
* Maps to plan lifecycle: draft/planned/parked → in-progress → completed/archived.
|
|
@@ -58,6 +59,31 @@ export const crossRepoDependencySchema = z.object({
|
|
|
58
59
|
slug: z.string().min(1),
|
|
59
60
|
require_status: planStatusSchema.optional(),
|
|
60
61
|
});
|
|
62
|
+
/**
|
|
63
|
+
* Task-ID validator for MCP writer boundaries (e.g. `wp_blueprint_put`).
|
|
64
|
+
*
|
|
65
|
+
* Built from `#markdown/task-heading`'s `TASK_ID_PATTERN` — the existing
|
|
66
|
+
* single source of truth for the numeric-dotted task-ID shape (e.g. "1.1",
|
|
67
|
+
* "2.3.1") already consumed by `#core/parser`'s `extractTasks()` (via
|
|
68
|
+
* `buildTaskHeadingRegex()`) and by `verification.ts` / `markdown/helpers.ts`.
|
|
69
|
+
* Validating writer-supplied IDs against the same pattern the reader uses
|
|
70
|
+
* means the writer (MCP `put`) and the reader (parser) cannot drift apart —
|
|
71
|
+
* a non-numeric-dotted ID like "task-1" is rejected before it ever reaches
|
|
72
|
+
* markdown, instead of being silently unparseable afterward.
|
|
73
|
+
*/
|
|
74
|
+
const TASK_ID_ANCHORED_PATTERN = new RegExp(`^${TASK_HEADING_TASK_ID_PATTERN}$`);
|
|
75
|
+
export const taskIdSchema = z
|
|
76
|
+
.string()
|
|
77
|
+
.min(1)
|
|
78
|
+
.superRefine((value, ctx) => {
|
|
79
|
+
if (!TASK_ID_ANCHORED_PATTERN.test(value)) {
|
|
80
|
+
ctx.addIssue({
|
|
81
|
+
code: z.ZodIssueCode.custom,
|
|
82
|
+
message: `Task IDs must be numeric dotted (e.g. "1.1", "2.3.1") — the blueprint parser only ` +
|
|
83
|
+
`recognizes "#### Task <numeric-dotted-id>:" headers. Use "1.1", got "${value}".`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
61
87
|
/**
|
|
62
88
|
* Plan frontmatter schema.
|
|
63
89
|
*
|
|
@@ -12,7 +12,8 @@ import { scanBlueprintDirectory } from "#service/scanner";
|
|
|
12
12
|
import { resolveBlueprintRoot } from "#utils/blueprint-root";
|
|
13
13
|
import { parseBlueprintDocumentRelativePath } from "#utils/document-paths.js";
|
|
14
14
|
import { relativeBlueprintSlug } from "#lifecycle/local.js";
|
|
15
|
-
import { collectV2ReviewProvenance, collectTrackedReviewProvenance, normalizeTrackedReviewApprovals, } from "#lifecycle/review-provenance.js";
|
|
15
|
+
import { collectV2ReviewProvenance, collectTrackedReviewProvenance, normalizeTrackedReviewApprovals, stableSlugForBlueprintFile, } from "#lifecycle/review-provenance.js";
|
|
16
|
+
import { isBlueprintExemptFromRollout, ROLLOUT_DATE } from "#lifecycle/rollout-anchor.js";
|
|
16
17
|
import { MAINTAINER_OVERRIDE_REVIEWER } from "#review/events.js";
|
|
17
18
|
import { isPolicyApprovalVerdict } from "#review/verdict.js";
|
|
18
19
|
function isBlueprintOverview(file) {
|
|
@@ -51,14 +52,25 @@ function readLifecycleAuditFrontmatter(raw) {
|
|
|
51
52
|
* blueprints that predate those changed-state gates.
|
|
52
53
|
*/
|
|
53
54
|
const APPROVAL_GATED_STATUSES = new Set(["planned"]);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
/**
|
|
56
|
+
* True when this blueprint predates the completed-approval gate and is exempt.
|
|
57
|
+
*
|
|
58
|
+
* Inside this repository the rollout anchor commit decides and the
|
|
59
|
+
* author-supplied `created` is ignored, which closes a real bypass: `created`
|
|
60
|
+
* is written by the same author the gate constrains, so an old date used to
|
|
61
|
+
* disable the gate outright -- on the CI audit path and on the unconditional
|
|
62
|
+
* `assertCompletedApprovalGate` write path, letting a blueprint reach
|
|
63
|
+
* `completed` with no approval at all. Outside this repository the anchor
|
|
64
|
+
* cannot answer the question and the legacy date compare still applies. See
|
|
65
|
+
* `#lifecycle/rollout-anchor.js` for the full resolution order.
|
|
66
|
+
*/
|
|
67
|
+
function predatesCompletedApprovalGate(file, created) {
|
|
68
|
+
return isBlueprintExemptFromRollout({
|
|
69
|
+
repoRoot: findGitRoot(file),
|
|
70
|
+
slug: stableSlugForBlueprintFile(file),
|
|
71
|
+
created,
|
|
72
|
+
rolloutDate: ROLLOUT_DATE,
|
|
73
|
+
});
|
|
62
74
|
}
|
|
63
75
|
/**
|
|
64
76
|
* Promotion gate: a blueprint past `draft` must carry either one provenance-backed
|
|
@@ -144,7 +156,7 @@ export function validateApprovalGate(file, frontmatter, options = {}) {
|
|
|
144
156
|
const status = typeof frontmatter.status === "string" ? frontmatter.status : "";
|
|
145
157
|
if (status === "completed" &&
|
|
146
158
|
options.completedApprovalGate === true &&
|
|
147
|
-
predatesCompletedApprovalGate(frontmatter.created)) {
|
|
159
|
+
predatesCompletedApprovalGate(file, frontmatter.created)) {
|
|
148
160
|
return [];
|
|
149
161
|
}
|
|
150
162
|
const gateApplies = APPROVAL_GATED_STATUSES.has(status) ||
|
|
@@ -36,6 +36,12 @@ export interface TrackedReviewProvenanceOptions {
|
|
|
36
36
|
readonly targetHash?: string;
|
|
37
37
|
}
|
|
38
38
|
export declare function normalizeTrackedReviewApprovals(approvals: unknown): TrackedReviewApproval[];
|
|
39
|
+
/**
|
|
40
|
+
* Slug for a blueprint file, invariant across lifecycle moves
|
|
41
|
+
* (`draft/ → planned/ → completed/`) and across both the folder
|
|
42
|
+
* (`<slug>/_overview.md`) and legacy flat (`<slug>.md`) shapes.
|
|
43
|
+
*/
|
|
44
|
+
export declare function stableSlugForBlueprintFile(file: string): string;
|
|
39
45
|
/**
|
|
40
46
|
* Read `_overview.md` for a blueprint at `ref`. Prefer the working-tree path;
|
|
41
47
|
* if missing (lifecycle move draft→planned before the promote commit), resolve
|