@webpresso/agent-kit 3.1.28 → 3.1.30
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/catalog/AGENTS.md.tpl +4 -4
- package/catalog/agent/rules/ci-cost-local-first.md +139 -0
- package/catalog/agent/rules/ci-test-perf.md +5 -1
- package/catalog/agent/rules/cmd-execution.md +9 -0
- package/catalog/agent/rules/pre-implementation.md +2 -1
- package/catalog/agent/rules/test-scan-perf.md +80 -0
- package/catalog/agent/rules/workflow-skills-routing.md +4 -2
- package/catalog/agent/skills/fix/SKILL.md +3 -0
- package/catalog/agent/skills/verify/SKILL.md +18 -0
- package/dist/esm/audit/blueprint-pr-coverage.js +68 -7
- package/dist/esm/audit/registry.d.ts +4 -4
- package/dist/esm/audit/registry.js +3 -0
- package/dist/esm/audit/roadmap-links.js +6 -0
- package/dist/esm/audit/test-scan-perf.d.ts +18 -0
- package/dist/esm/audit/test-scan-perf.js +275 -0
- package/dist/esm/blueprint/lifecycle/engine.d.ts +9 -0
- package/dist/esm/blueprint/lifecycle/engine.js +53 -6
- package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
- package/dist/esm/blueprint/lifecycle/review-provenance.js +93 -12
- package/dist/esm/blueprint/markdown/helpers.js +6 -2
- package/dist/esm/ci/act-replay.js +110 -7
- package/dist/esm/ci/act-runner.d.ts +4 -0
- package/dist/esm/ci/act-runner.js +85 -26
- package/dist/esm/ci/act-worktree-git.d.ts +11 -0
- package/dist/esm/ci/act-worktree-git.js +219 -0
- package/dist/esm/cli/commands/audit.js +3 -0
- package/dist/esm/cli/commands/blueprint/mutations.js +35 -18
- package/dist/esm/cli/commands/blueprint/router.js +27 -2
- package/dist/esm/cli/commands/ci.d.ts +8 -1
- package/dist/esm/cli/commands/ci.js +80 -21
- package/dist/esm/cli/commands/dash/keymap.js +1 -0
- package/dist/esm/cli/commands/dash/launch-preferences.d.ts +15 -0
- package/dist/esm/cli/commands/dash/launch-preferences.js +46 -17
- package/dist/esm/cli/commands/dash/plan-once-label.d.ts +7 -0
- package/dist/esm/cli/commands/dash/plan-once-label.js +5 -0
- package/dist/esm/cli/commands/dash/plan-once-membership.d.ts +67 -0
- package/dist/esm/cli/commands/dash/plan-once-membership.js +151 -0
- package/dist/esm/cli/commands/dash/plan-once-merge.d.ts +143 -0
- package/dist/esm/cli/commands/dash/plan-once-merge.js +238 -0
- package/dist/esm/cli/commands/dash/plan-once.d.ts +22 -2
- package/dist/esm/cli/commands/dash/plan-once.js +41 -4
- package/dist/esm/cli/commands/dash/tui/attention-hud.d.ts +9 -0
- package/dist/esm/cli/commands/dash/tui/attention-hud.js +9 -0
- package/dist/esm/cli/commands/dash/tui/chat-bridge.d.ts +41 -0
- package/dist/esm/cli/commands/dash/tui/chat-bridge.js +73 -0
- package/dist/esm/cli/commands/dash/tui/chat-mode.d.ts +47 -0
- package/dist/esm/cli/commands/dash/tui/chat-mode.js +98 -0
- package/dist/esm/cli/commands/dash/tui/frame.d.ts +3 -0
- package/dist/esm/cli/commands/dash/tui/frame.js +3 -1
- package/dist/esm/cli/commands/dash/tui/palette.d.ts +1 -1
- package/dist/esm/cli/commands/dash/tui/palette.js +14 -0
- package/dist/esm/cli/commands/dash/tui/runtime.js +239 -4
- package/dist/esm/cli/commands/dash/tui/state.d.ts +41 -0
- package/dist/esm/cli/commands/dash/tui/state.js +257 -4
- package/dist/esm/cli/commands/format.js +0 -2
- package/dist/esm/cli/commands/init/config.d.ts +0 -1
- package/dist/esm/cli/commands/init/config.js +1 -4
- package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.d.ts +5 -6
- package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.js +6 -13
- package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.d.ts +0 -2
- package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.js +0 -2
- package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.d.ts +6 -3
- package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.js +39 -26
- package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +61 -23
- package/dist/esm/cli/commands/logs.d.ts +15 -0
- package/dist/esm/cli/commands/logs.js +192 -3
- package/dist/esm/cli/commands/quality-log-store.d.ts +1 -0
- package/dist/esm/cli/commands/quality-log-store.js +187 -51
- package/dist/esm/cli/commands/review.js +5 -8
- package/dist/esm/cli/commands/secrets.d.ts +1 -0
- package/dist/esm/cli/commands/secrets.js +4 -1
- package/dist/esm/cli/direct-provider-launch.js +25 -1
- package/dist/esm/hooks/doctor.d.ts +15 -0
- package/dist/esm/hooks/doctor.js +56 -0
- package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.js +4 -4
- package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.d.ts +0 -1
- package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.js +8 -5
- package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.d.ts +9 -7
- package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +126 -14
- 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 +10 -0
- package/dist/esm/mcp/tools/audits.d.ts +1 -0
- package/dist/esm/mcp/tools/ci-act.js +9 -11
- package/dist/esm/paths/state-root.js +3 -0
- package/dist/esm/review/authority.js +17 -2
- package/dist/esm/review/availability.js +18 -5
- package/dist/esm/review/subject.d.ts +15 -0
- package/dist/esm/review/subject.js +105 -10
- package/dist/esm/runtime/executor.d.ts +1 -0
- package/dist/esm/runtime/executor.js +2 -1
- package/dist/esm/secret-gate/runner.d.ts +1 -0
- package/dist/esm/secret-gate/runner.js +4 -1
- package/dist/esm/worktrees/current.d.ts +1 -0
- package/dist/esm/worktrees/current.js +1 -0
- package/dist/esm/worktrees/project-agent-surfaces.d.ts +1 -0
- package/dist/esm/worktrees/project-agent-surfaces.js +2 -1
- package/package.json +14 -14
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardrail: keep tests from silently growing multi-second / multi-ten-second
|
|
3
|
+
* full-repo filesystem scans, and from "fixing" that cost with elevated
|
|
4
|
+
* per-test timeouts.
|
|
5
|
+
*
|
|
6
|
+
* The private-UI cutover anti-regression suite originally walked the monorepo
|
|
7
|
+
* with readdirSync/statSync/readFileSync and needed a 30s timeout. Prefer
|
|
8
|
+
* `git grep` (tracked files) or a bounded fixture tree instead.
|
|
9
|
+
*
|
|
10
|
+
* Opt-out: `// test-scan-perf-allow` on the same line or previous line as the
|
|
11
|
+
* walk/timeout site, or `// test-scan-perf-allow-file` anywhere in the file.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
15
|
+
import { join, relative } from "node:path";
|
|
16
|
+
const TITLE = "test-scan-perf";
|
|
17
|
+
const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
|
|
18
|
+
const E2E_FILE_PATTERN = /\.e2e\./u;
|
|
19
|
+
const SKIP_DIRS = new Set([
|
|
20
|
+
".git",
|
|
21
|
+
"node_modules",
|
|
22
|
+
"dist",
|
|
23
|
+
"coverage",
|
|
24
|
+
".turbo",
|
|
25
|
+
".webpresso",
|
|
26
|
+
".agent",
|
|
27
|
+
".agents",
|
|
28
|
+
".claude",
|
|
29
|
+
".codex",
|
|
30
|
+
".cursor",
|
|
31
|
+
".opencode",
|
|
32
|
+
".omx",
|
|
33
|
+
"playwright-report",
|
|
34
|
+
"test-results",
|
|
35
|
+
"logs",
|
|
36
|
+
"_worktrees",
|
|
37
|
+
]);
|
|
38
|
+
const ALLOW_LINE_PATTERN = /test-scan-perf-allow(?:-timeout|-walk)?\b/iu;
|
|
39
|
+
const ALLOW_FILE_PATTERN = /test-scan-perf-allow-file\b/iu;
|
|
40
|
+
/** Recursive walk helper that also reads file contents (content scan). */
|
|
41
|
+
const RECURSIVE_WALK_HINT = /(?:function\s+walk\b|const\s+walk\s*=|let\s+walk\s*=|var\s+walk\s*=)/u;
|
|
42
|
+
const RECURSIVE_CALL_HINT = /\bwalk\s*\(\s*(?:full|dir|current|directory|entryPath|sub|next)\b/u;
|
|
43
|
+
const READDIR_HINT = /\breaddirSync\b/u;
|
|
44
|
+
const READ_FILE_HINT = /\breadFileSync\b/u;
|
|
45
|
+
/**
|
|
46
|
+
* True when the file acquires the *workspace* root (not a mkdtemp fixture)
|
|
47
|
+
* and feeds that root into a recursive walk / file-finder helper.
|
|
48
|
+
*/
|
|
49
|
+
function hasWorkspaceRootScanStart(code) {
|
|
50
|
+
const assignsWorkspaceRoot = /\bfindRepoRoot\s*\(/u.test(code) ||
|
|
51
|
+
/(?:const|let|var)\s+(?:repoRoot|REPO_ROOT)\s*=\s*process\.cwd\s*\(/u.test(code) ||
|
|
52
|
+
/(?:const|let|var)\s+root\s*=\s*(?:findRepoRoot\s*\(|process\.cwd\s*\()/u.test(code);
|
|
53
|
+
if (!assignsWorkspaceRoot)
|
|
54
|
+
return false;
|
|
55
|
+
// Direct walk(repoRoot|REPO_ROOT) or helper(repoRoot|REPO_ROOT) that owns the walk.
|
|
56
|
+
if (/\b(?:walk|findFiles|list\w*|scan\w*|collect\w*)\s*\(\s*(?:repoRoot|REPO_ROOT)\b/u.test(code)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
// walk(root) only when `root` itself is the workspace root assignment.
|
|
60
|
+
if (/(?:const|let|var)\s+root\s*=\s*(?:findRepoRoot\s*\(|process\.cwd\s*\()/u.test(code) &&
|
|
61
|
+
/\b(?:walk|findFiles|list\w*|scan\w*|collect\w*)\s*\(\s*root\b/u.test(code)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const MIN_ELEVATED_TIMEOUT_MS = 30_000;
|
|
67
|
+
export function isTestScanPerfTargetPath(relativePath) {
|
|
68
|
+
const normalized = relativePath.replaceAll("\\", "/");
|
|
69
|
+
if (!TEST_FILE_PATTERN.test(normalized))
|
|
70
|
+
return false;
|
|
71
|
+
if (E2E_FILE_PATTERN.test(normalized))
|
|
72
|
+
return false;
|
|
73
|
+
// Audit self-tests and fixtures intentionally include positive examples.
|
|
74
|
+
if (normalized.includes("/__fixtures__/"))
|
|
75
|
+
return false;
|
|
76
|
+
if (normalized.endsWith("/test-scan-perf.test.ts"))
|
|
77
|
+
return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
function stripComments(source) {
|
|
81
|
+
return source.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/(^|[^:])\/\/[^\n]*/gu, "$1");
|
|
82
|
+
}
|
|
83
|
+
function stripAllowedLines(source) {
|
|
84
|
+
const lines = source.split(/\r?\n/u);
|
|
85
|
+
return lines.map((line, index) => (lineAllowed(lines, index) ? "" : line)).join("\n");
|
|
86
|
+
}
|
|
87
|
+
function lineAllowed(lines, index) {
|
|
88
|
+
const current = lines[index] ?? "";
|
|
89
|
+
if (ALLOW_LINE_PATTERN.test(current))
|
|
90
|
+
return true;
|
|
91
|
+
if (index > 0 && ALLOW_LINE_PATTERN.test(lines[index - 1] ?? ""))
|
|
92
|
+
return true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
function hasRepoRootContentWalk(code) {
|
|
96
|
+
if (!READDIR_HINT.test(code) || !READ_FILE_HINT.test(code))
|
|
97
|
+
return false;
|
|
98
|
+
if (!RECURSIVE_WALK_HINT.test(code))
|
|
99
|
+
return false;
|
|
100
|
+
const recursiveCall = RECURSIVE_CALL_HINT.test(code) || /\bwalk\s*\(\s*join\s*\(/u.test(code);
|
|
101
|
+
if (!recursiveCall)
|
|
102
|
+
return false;
|
|
103
|
+
return hasWorkspaceRootScanStart(code);
|
|
104
|
+
}
|
|
105
|
+
function parseTimeoutMs(raw) {
|
|
106
|
+
const digits = raw.replaceAll("_", "");
|
|
107
|
+
if (!/^\d+$/u.test(digits))
|
|
108
|
+
return null;
|
|
109
|
+
const value = Number(digits);
|
|
110
|
+
return Number.isFinite(value) ? value : null;
|
|
111
|
+
}
|
|
112
|
+
function findElevatedTimeoutLines(text) {
|
|
113
|
+
const hits = [];
|
|
114
|
+
const lines = text.split(/\r?\n/u);
|
|
115
|
+
// Line-oriented: `{ timeout: 30000 }`, trailing `, 30000)`, or a multi-line
|
|
116
|
+
// `it(\n "…",\n () => {…},\n 30_000,\n)` form.
|
|
117
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
118
|
+
const line = lines[index] ?? "";
|
|
119
|
+
if (lineAllowed(lines, index))
|
|
120
|
+
continue;
|
|
121
|
+
const optionMatch = line.match(/timeout\s*:\s*([0-9_]+)/u);
|
|
122
|
+
if (optionMatch?.[1]) {
|
|
123
|
+
const ms = parseTimeoutMs(optionMatch[1]);
|
|
124
|
+
if (ms !== null && ms >= MIN_ELEVATED_TIMEOUT_MS) {
|
|
125
|
+
hits.push({ line: index + 1, ms });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Same-line trailing timeout: `}, 30_000)` / `, 30000)`
|
|
130
|
+
const trailingMatch = line.match(/,\s*([0-9_]+)\s*\)\s*;?\s*$/u);
|
|
131
|
+
// Own-line timeout before closing paren: ` 30_000,` / ` 30000`
|
|
132
|
+
const ownLineMatch = line.match(/^\s*([0-9_]+)\s*,?\s*$/u);
|
|
133
|
+
const raw = trailingMatch?.[1] ?? ownLineMatch?.[1];
|
|
134
|
+
if (!raw)
|
|
135
|
+
continue;
|
|
136
|
+
const ms = parseTimeoutMs(raw);
|
|
137
|
+
const windowStart = Math.max(0, index - 16);
|
|
138
|
+
const windowEnd = Math.min(lines.length, index + 3);
|
|
139
|
+
const window = lines.slice(windowStart, windowEnd).join("\n");
|
|
140
|
+
if (ms !== null &&
|
|
141
|
+
ms >= MIN_ELEVATED_TIMEOUT_MS &&
|
|
142
|
+
/\b(?:it|test)(?:\.only|\.each)?\s*\(/u.test(window)) {
|
|
143
|
+
hits.push({ line: index + 1, ms });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Deduplicate by line.
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
return hits.filter((hit) => {
|
|
149
|
+
if (seen.has(hit.line))
|
|
150
|
+
return false;
|
|
151
|
+
seen.add(hit.line);
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
export function findTestScanPerfViolationsInText(relativePath, text) {
|
|
156
|
+
if (!isTestScanPerfTargetPath(relativePath))
|
|
157
|
+
return [];
|
|
158
|
+
if (ALLOW_FILE_PATTERN.test(text))
|
|
159
|
+
return [];
|
|
160
|
+
const violations = [];
|
|
161
|
+
const code = stripComments(stripAllowedLines(text));
|
|
162
|
+
const hasSlowWalk = hasRepoRootContentWalk(code);
|
|
163
|
+
if (hasSlowWalk) {
|
|
164
|
+
violations.push({
|
|
165
|
+
file: relativePath,
|
|
166
|
+
ruleId: "repo-root-recursive-content-scan",
|
|
167
|
+
severity: "error",
|
|
168
|
+
message: `${relativePath}: recursive readdir/readFile content scan from the repo root. ` +
|
|
169
|
+
`Use \`git grep -l\` (tracked files) or a bounded fixture tree instead of walking ` +
|
|
170
|
+
`the whole workspace. Opt out with \`// test-scan-perf-allow\` on the walk site ` +
|
|
171
|
+
`or \`// test-scan-perf-allow-file\` once with justification.`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// Elevated timeouts are only flagged when co-located with a repo-root FS walk —
|
|
175
|
+
// that is the "band-aid for a slow scan" pattern. Legitimate long e2e/subprocess
|
|
176
|
+
// timeouts without a root walk are out of scope.
|
|
177
|
+
if (hasSlowWalk) {
|
|
178
|
+
for (const hit of findElevatedTimeoutLines(text)) {
|
|
179
|
+
violations.push({
|
|
180
|
+
file: relativePath,
|
|
181
|
+
line: hit.line,
|
|
182
|
+
ruleId: "timeout-band-aid-for-scan",
|
|
183
|
+
severity: "error",
|
|
184
|
+
message: `${relativePath}:${hit.line}: per-test timeout ${hit.ms}ms co-located with a ` +
|
|
185
|
+
`repo-root recursive FS scan. Fix the scan (git-grep / bounded tree) instead of ` +
|
|
186
|
+
`raising the timeout. Opt out with \`// test-scan-perf-allow\` on that line.`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return violations;
|
|
191
|
+
}
|
|
192
|
+
function listTestFilesViaGit(root) {
|
|
193
|
+
try {
|
|
194
|
+
const stdout = execFileSync("git", ["-C", root, "ls-files", "-z"], {
|
|
195
|
+
encoding: "utf8",
|
|
196
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
197
|
+
});
|
|
198
|
+
return stdout
|
|
199
|
+
.split("\0")
|
|
200
|
+
.map((entry) => entry.trim())
|
|
201
|
+
.filter(Boolean)
|
|
202
|
+
.filter((entry) => isTestScanPerfTargetPath(entry));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function walkTestFiles(dir, root, out) {
|
|
209
|
+
let entries;
|
|
210
|
+
try {
|
|
211
|
+
entries = readdirSync(dir);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
if (SKIP_DIRS.has(entry))
|
|
218
|
+
continue;
|
|
219
|
+
const full = join(dir, entry);
|
|
220
|
+
let st;
|
|
221
|
+
try {
|
|
222
|
+
st = statSync(full);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (st.isDirectory()) {
|
|
228
|
+
walkTestFiles(full, root, out);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!st.isFile())
|
|
232
|
+
continue;
|
|
233
|
+
const rel = relative(root, full).replaceAll("\\", "/");
|
|
234
|
+
if (isTestScanPerfTargetPath(rel))
|
|
235
|
+
out.push(rel);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function listTestFiles(root, affectedFiles) {
|
|
239
|
+
if (affectedFiles !== undefined) {
|
|
240
|
+
return affectedFiles
|
|
241
|
+
.map((file) => file.replaceAll("\\", "/"))
|
|
242
|
+
.filter((file) => isTestScanPerfTargetPath(file));
|
|
243
|
+
}
|
|
244
|
+
const fromGit = listTestFilesViaGit(root);
|
|
245
|
+
if (fromGit)
|
|
246
|
+
return fromGit;
|
|
247
|
+
const out = [];
|
|
248
|
+
walkTestFiles(root, root, out);
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
export function auditTestScanPerf(rootDirectory = process.cwd(), options = {}) {
|
|
252
|
+
const root = rootDirectory;
|
|
253
|
+
if (!existsSync(root)) {
|
|
254
|
+
return { ok: true, title: TITLE, checked: 0, violations: [] };
|
|
255
|
+
}
|
|
256
|
+
const files = listTestFiles(root, options.affectedFiles);
|
|
257
|
+
const violations = [];
|
|
258
|
+
for (const rel of files) {
|
|
259
|
+
const full = join(root, rel);
|
|
260
|
+
let text;
|
|
261
|
+
try {
|
|
262
|
+
text = readFileSync(full, "utf8");
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
violations.push(...findTestScanPerfViolationsInText(rel, text));
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
ok: violations.length === 0,
|
|
271
|
+
title: TITLE,
|
|
272
|
+
checked: files.length,
|
|
273
|
+
violations,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -35,5 +35,14 @@ export interface BlueprintLifecycleResult {
|
|
|
35
35
|
progress: string;
|
|
36
36
|
targetStatus: LifecycleBlueprintStatus;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Update only the named frontmatter keys, byte-preserving every other line.
|
|
40
|
+
* Review authority binds plan approvals to normalized blueprint bytes
|
|
41
|
+
* (`normalizePlanSubjectContent`), so lifecycle writers must never re-serialize
|
|
42
|
+
* fields they do not own — a whole-document YAML round-trip (quote-style or
|
|
43
|
+
* folding changes to an untouched `title:`) would strand committed approvals
|
|
44
|
+
* mid-lifecycle. Falls back to a gray-matter round-trip only when the document
|
|
45
|
+
* has no frontmatter block at all.
|
|
46
|
+
*/
|
|
38
47
|
export declare function setBlueprintFrontmatterFields(markdown: string, updates: Record<string, string | string[] | undefined>): string;
|
|
39
48
|
export declare function applyBlueprintLifecycle(markdown: string, slug: string, intent: BlueprintLifecycleIntent): BlueprintLifecycleResult;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import matter from "gray-matter";
|
|
2
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
2
3
|
import { parseBlueprint } from "#core/parser";
|
|
3
4
|
import { lifecycleBlueprintStatusSchema } from "#core/schema";
|
|
4
5
|
import { updateBlockedReason, updateTaskStatus } from "#markdown/helpers";
|
|
@@ -6,17 +7,63 @@ import { applyVerification, assertAllTasksHaveCanonicalPassingEvidence } from "#
|
|
|
6
7
|
function todayIsoDate() {
|
|
7
8
|
return new Date().toISOString().split("T")[0] ?? new Date().toISOString();
|
|
8
9
|
}
|
|
10
|
+
function serializeFrontmatterEntry(key, value) {
|
|
11
|
+
// lineWidth: 0 disables folding so long scalars stay on one line. gray-matter
|
|
12
|
+
// parses frontmatter with js-yaml, which resolves plain date-like scalars
|
|
13
|
+
// ("2026-07-21") into Date objects — quote a string whenever gray-matter
|
|
14
|
+
// would not round-trip its plain form back to the same string.
|
|
15
|
+
const plain = stringifyYaml({ [key]: value }, { lineWidth: 0 }).trimEnd();
|
|
16
|
+
if (typeof value === "string" && matter(`---\n${plain}\n---\n`).data[key] !== value) {
|
|
17
|
+
return stringifyYaml({ [key]: value }, { defaultKeyType: "PLAIN", defaultStringType: "QUOTE_DOUBLE", lineWidth: 0 })
|
|
18
|
+
.trimEnd()
|
|
19
|
+
.split("\n");
|
|
20
|
+
}
|
|
21
|
+
return plain.split("\n");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Update only the named frontmatter keys, byte-preserving every other line.
|
|
25
|
+
* Review authority binds plan approvals to normalized blueprint bytes
|
|
26
|
+
* (`normalizePlanSubjectContent`), so lifecycle writers must never re-serialize
|
|
27
|
+
* fields they do not own — a whole-document YAML round-trip (quote-style or
|
|
28
|
+
* folding changes to an untouched `title:`) would strand committed approvals
|
|
29
|
+
* mid-lifecycle. Falls back to a gray-matter round-trip only when the document
|
|
30
|
+
* has no frontmatter block at all.
|
|
31
|
+
*/
|
|
9
32
|
export function setBlueprintFrontmatterFields(markdown, updates) {
|
|
10
|
-
const
|
|
33
|
+
const match = /^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/u.exec(markdown);
|
|
34
|
+
if (!match) {
|
|
35
|
+
const parsed = matter(markdown);
|
|
36
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
37
|
+
if (value === undefined || value === "") {
|
|
38
|
+
delete parsed.data[key];
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
parsed.data[key] = value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return matter.stringify(parsed.content, parsed.data);
|
|
45
|
+
}
|
|
46
|
+
const lines = (match[2] ?? "").split("\n");
|
|
11
47
|
for (const [key, value] of Object.entries(updates)) {
|
|
12
|
-
|
|
13
|
-
|
|
48
|
+
const keyPattern = new RegExp(String.raw `^${key}:`, "u");
|
|
49
|
+
const start = lines.findIndex((line) => keyPattern.test(line));
|
|
50
|
+
let removeCount = 0;
|
|
51
|
+
if (start !== -1) {
|
|
52
|
+
removeCount = 1;
|
|
53
|
+
while (start + removeCount < lines.length &&
|
|
54
|
+
/^[ \t]/u.test(lines[start + removeCount] ?? "")) {
|
|
55
|
+
removeCount += 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const replacement = value === undefined || value === "" ? [] : serializeFrontmatterEntry(key, value);
|
|
59
|
+
if (start !== -1) {
|
|
60
|
+
lines.splice(start, removeCount, ...replacement);
|
|
14
61
|
}
|
|
15
|
-
else {
|
|
16
|
-
|
|
62
|
+
else if (replacement.length > 0) {
|
|
63
|
+
lines.push(...replacement);
|
|
17
64
|
}
|
|
18
65
|
}
|
|
19
|
-
return
|
|
66
|
+
return `${match[1]}${lines.join("\n")}${match[3]}${markdown.slice(match[0].length)}`;
|
|
20
67
|
}
|
|
21
68
|
function assertExecutableStatus(status) {
|
|
22
69
|
const parsed = lifecycleBlueprintStatusSchema.safeParse(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
|
+
* Read `_overview.md` for a blueprint at `ref`. Prefer the working-tree path;
|
|
41
|
+
* if missing (lifecycle move draft→planned before the promote commit), resolve
|
|
42
|
+
* by stable slug under any lifecycle directory at that ref.
|
|
43
|
+
*/
|
|
44
|
+
export declare function readBlueprintOverviewAtRef(repoRoot: string, ref: string, file: string): Buffer | null;
|
|
39
45
|
export declare function collectV2ReviewProvenance(file: string, purpose: ReviewEvent["purpose"], ref?: string, issues?: string[]): readonly ValidTrackedReview[];
|
|
40
46
|
export declare function auditReviewEventAppendOnly(cwd: string, baseRef: string, targetRef?: string): readonly string[];
|
|
41
47
|
export declare function parseTrackedReviewRecords(markdown: string): TrackedReviewRecord[];
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { readReviewAuthorityAtRef } from "#review/authority.js";
|
|
5
|
-
import { parseReviewEventLog } from "#review/events.js";
|
|
6
|
-
import { normalizePlanSubjectContent } from "#review/subject.js";
|
|
4
|
+
import { readReviewAuthorityAtRef, readWorkingReviewEvents } from "#review/authority.js";
|
|
5
|
+
import { evaluateReviewEvents, parseReviewEventLog } from "#review/events.js";
|
|
6
|
+
import { createPlanSubjectDigestForContent, normalizePlanSubjectContent } from "#review/subject.js";
|
|
7
7
|
import { isPolicyApprovalVerdict } from "#review/verdict.js";
|
|
8
8
|
const REVIEW_ENTRY_MARKER = "<!-- wp:review-entry ";
|
|
9
9
|
function normalizePath(file) {
|
|
@@ -52,6 +52,53 @@ function stableSlugForBlueprintFile(file) {
|
|
|
52
52
|
}
|
|
53
53
|
return path.basename(path.dirname(file));
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Read `_overview.md` for a blueprint at `ref`. Prefer the working-tree path;
|
|
57
|
+
* if missing (lifecycle move draft→planned before the promote commit), resolve
|
|
58
|
+
* by stable slug under any lifecycle directory at that ref.
|
|
59
|
+
*/
|
|
60
|
+
export function readBlueprintOverviewAtRef(repoRoot, ref, file) {
|
|
61
|
+
const relative = normalizePath(path.relative(repoRoot, file));
|
|
62
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
return execFileSync("git", ["show", `${ref}:${relative}`], {
|
|
66
|
+
cwd: repoRoot,
|
|
67
|
+
encoding: "buffer",
|
|
68
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
69
|
+
timeout: 5_000,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Path may have moved across lifecycle dirs (draft→planned) while the
|
|
74
|
+
// promote commit is still uncommitted. Stable slug is the authority key.
|
|
75
|
+
const slug = stableSlugForBlueprintFile(file);
|
|
76
|
+
if (!slug || slug.includes(".."))
|
|
77
|
+
return null;
|
|
78
|
+
try {
|
|
79
|
+
const names = execFileSync("git", ["ls-tree", "-r", "--name-only", ref], {
|
|
80
|
+
cwd: repoRoot,
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
83
|
+
timeout: 5_000,
|
|
84
|
+
});
|
|
85
|
+
const escaped = slug.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
86
|
+
const pattern = new RegExp(`(?:^|/)(?:draft|planned|parked|in-progress|completed|archived)/${escaped}/_overview\\.md$`, "u");
|
|
87
|
+
const hits = names.split("\n").filter((name) => pattern.test(normalizePath(name)));
|
|
88
|
+
if (hits.length !== 1)
|
|
89
|
+
return null;
|
|
90
|
+
return execFileSync("git", ["show", `${ref}:${hits[0]}`], {
|
|
91
|
+
cwd: repoRoot,
|
|
92
|
+
encoding: "buffer",
|
|
93
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
94
|
+
timeout: 5_000,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
55
102
|
function workingContentMatchesRef(repoRoot, file, purpose, ref) {
|
|
56
103
|
const relative = path.relative(repoRoot, file);
|
|
57
104
|
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
@@ -63,12 +110,11 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
|
|
|
63
110
|
// in the working tree before the promotion commit exists, and that
|
|
64
111
|
// window must not strip a legitimately promoted blueprint of its plan
|
|
65
112
|
// authority. Substantive edits still differ after normalization.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
});
|
|
113
|
+
// Also resolve by stable slug when the lifecycle path changed (draft→planned)
|
|
114
|
+
// so pre-commit on a rename does not falsely drop plan authority.
|
|
115
|
+
const atRef = readBlueprintOverviewAtRef(repoRoot, ref, file);
|
|
116
|
+
if (!atRef)
|
|
117
|
+
return false;
|
|
72
118
|
const working = readFileSync(file);
|
|
73
119
|
return normalizePlanSubjectContent(working).equals(normalizePlanSubjectContent(atRef));
|
|
74
120
|
}
|
|
@@ -84,27 +130,62 @@ function workingContentMatchesRef(repoRoot, file, purpose, ref) {
|
|
|
84
130
|
return false;
|
|
85
131
|
}
|
|
86
132
|
}
|
|
133
|
+
// Working-ledger acceptance: pre-commit validates a blueprint whose new review
|
|
134
|
+
// events are staged alongside it and therefore not yet at any git ref. An
|
|
135
|
+
// event still counts only when its subjectDigest matches the digest of the
|
|
136
|
+
// exact working bytes being committed and its artifact hash-verifies on disk —
|
|
137
|
+
// content-addressing keeps this sound before the commit exists.
|
|
138
|
+
function collectWorkingV2ReviewProvenance(file, issues) {
|
|
139
|
+
try {
|
|
140
|
+
const slug = stableSlugForBlueprintFile(file);
|
|
141
|
+
const working = readWorkingReviewEvents(file, slug);
|
|
142
|
+
if (working.events.length === 0)
|
|
143
|
+
return [];
|
|
144
|
+
issues?.push(...working.issues);
|
|
145
|
+
const digest = createPlanSubjectDigestForContent(slug, readFileSync(file));
|
|
146
|
+
const evaluation = evaluateReviewEvents(working.events, {
|
|
147
|
+
purpose: "plan",
|
|
148
|
+
blueprintSlug: slug,
|
|
149
|
+
subjectScheme: "wp-blueprint-v1",
|
|
150
|
+
subjectDigest: digest,
|
|
151
|
+
}, working.compromisedIds);
|
|
152
|
+
return evaluation.approvals
|
|
153
|
+
.filter((event) => event.artifactPath)
|
|
154
|
+
.map((event) => ({
|
|
155
|
+
reviewer: event.reviewer,
|
|
156
|
+
artifact: event.artifactPath,
|
|
157
|
+
targetHash: event.subjectDigest,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
issues?.push(`Working review authority unavailable for ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
87
165
|
export function collectV2ReviewProvenance(file, purpose, ref = "HEAD", issues) {
|
|
88
166
|
const repoRoot = findGitRoot(file);
|
|
89
167
|
if (!repoRoot)
|
|
90
168
|
return [];
|
|
169
|
+
const workingApprovals = purpose === "plan" ? collectWorkingV2ReviewProvenance(file, issues) : [];
|
|
91
170
|
if (!workingContentMatchesRef(repoRoot, file, purpose, ref))
|
|
92
|
-
return
|
|
171
|
+
return workingApprovals;
|
|
93
172
|
try {
|
|
94
173
|
const authority = readReviewAuthorityAtRef(repoRoot, ref, stableSlugForBlueprintFile(file), purpose);
|
|
95
174
|
issues?.push(...authority.issues);
|
|
96
|
-
|
|
175
|
+
const refApprovals = authority.approvals.map((event) => ({
|
|
97
176
|
reviewer: event.reviewer,
|
|
98
177
|
artifact: event.artifactPath,
|
|
99
178
|
targetHash: event.subjectDigest,
|
|
100
179
|
}));
|
|
180
|
+
const seen = new Set(refApprovals.map((approval) => approval.reviewer));
|
|
181
|
+
return [...refApprovals, ...workingApprovals.filter((entry) => !seen.has(entry.reviewer))];
|
|
101
182
|
}
|
|
102
183
|
catch (error) {
|
|
103
184
|
// Container-level failure (unparseable ledger, duplicate ids, cross-slug
|
|
104
185
|
// events, unresolved commit): fail closed for the whole file, but surface
|
|
105
186
|
// the reason instead of silently reporting zero authority.
|
|
106
187
|
issues?.push(`Review authority unavailable for ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
|
-
return
|
|
188
|
+
return workingApprovals;
|
|
108
189
|
}
|
|
109
190
|
}
|
|
110
191
|
function reviewEventLogsAtRef(cwd, ref) {
|
|
@@ -67,10 +67,14 @@ export function updateBlockedReason(content, taskId, reason) {
|
|
|
67
67
|
const trimmedReason = reason.trim();
|
|
68
68
|
const blockedPattern = /\n?\*\*Blocked:\*\*\s*.*(?:\n+)?/i;
|
|
69
69
|
if (!trimmedReason) {
|
|
70
|
-
|
|
70
|
+
// Preserve the section's trailing whitespace: trimEnd on the whole
|
|
71
|
+
// section would also delete the blank line separating this task from the
|
|
72
|
+
// next heading, changing bytes the writer does not own.
|
|
73
|
+
const trailingWhitespace = section.slice(section.trimEnd().length);
|
|
74
|
+
return (section
|
|
71
75
|
.replace(blockedPattern, "\n")
|
|
72
76
|
.replace(/\n{3,}/g, "\n\n")
|
|
73
|
-
.trimEnd();
|
|
77
|
+
.trimEnd() + trailingWhitespace);
|
|
74
78
|
}
|
|
75
79
|
const blockedLine = `**Blocked:** ${trimmedReason}`;
|
|
76
80
|
if (/\*\*Blocked:\*\*/i.test(section)) {
|
|
@@ -31,8 +31,105 @@ export function buildReplayWorkflowSource(sourceYaml, options) {
|
|
|
31
31
|
: basename(options.workflowPath);
|
|
32
32
|
workflow.name = `replay: ${originalName}`;
|
|
33
33
|
workflow.on = normalizeWorkflowOnSection(workflow.on, options.eventName);
|
|
34
|
+
workflow.jobs = rewriteReplayJobs(workflow.jobs, options.eventName);
|
|
34
35
|
return stringify(workflow);
|
|
35
36
|
}
|
|
37
|
+
function rewriteReplayJobs(jobs, eventName) {
|
|
38
|
+
if (typeof jobs !== "object" || jobs === null || Array.isArray(jobs)) {
|
|
39
|
+
return jobs;
|
|
40
|
+
}
|
|
41
|
+
return Object.fromEntries(Object.entries(jobs).map(([jobId, job]) => [
|
|
42
|
+
jobId,
|
|
43
|
+
rewriteReplayJob(job, eventName),
|
|
44
|
+
]));
|
|
45
|
+
}
|
|
46
|
+
function rewriteReplayJob(job, eventName) {
|
|
47
|
+
if (typeof job !== "object" || job === null || Array.isArray(job)) {
|
|
48
|
+
return job;
|
|
49
|
+
}
|
|
50
|
+
const jobRecord = { ...job };
|
|
51
|
+
if (Array.isArray(jobRecord.steps)) {
|
|
52
|
+
jobRecord.steps = jobRecord.steps.map((step) => rewriteReplayStep(step, eventName));
|
|
53
|
+
}
|
|
54
|
+
return jobRecord;
|
|
55
|
+
}
|
|
56
|
+
function rewriteReplayStep(step, eventName) {
|
|
57
|
+
if (!isRecord(step) || !isSameRepoCheckoutStep(step, eventName)) {
|
|
58
|
+
return step;
|
|
59
|
+
}
|
|
60
|
+
const rewritten = {
|
|
61
|
+
name: typeof step.name === "string" ? step.name : "Use prepared local replay workspace",
|
|
62
|
+
run: [
|
|
63
|
+
'echo "wp ci act replay: using prepared local workspace instead of actions/checkout"',
|
|
64
|
+
"test -d .git",
|
|
65
|
+
"git rev-parse --is-inside-work-tree",
|
|
66
|
+
"git rev-parse --verify HEAD",
|
|
67
|
+
].join("\n"),
|
|
68
|
+
"working-directory": "${{ github.workspace }}",
|
|
69
|
+
};
|
|
70
|
+
copyOptionalStepField(step, rewritten, "id");
|
|
71
|
+
copyOptionalStepField(step, rewritten, "if");
|
|
72
|
+
copyOptionalStepField(step, rewritten, "timeout-minutes");
|
|
73
|
+
copyOptionalStepField(step, rewritten, "continue-on-error");
|
|
74
|
+
return rewritten;
|
|
75
|
+
}
|
|
76
|
+
function isSameRepoCheckoutStep(step, eventName) {
|
|
77
|
+
if (typeof step.uses !== "string" || !/^actions\/checkout(?:@|$)/u.test(step.uses)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (step.env !== undefined && step.env !== null) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const withConfig = step.with;
|
|
84
|
+
if (withConfig === undefined || withConfig === null) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (!isRecord(withConfig)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (hasAnyCheckoutInputExcept(withConfig, ["ref"])) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return isCurrentReplayRef(withConfig.ref, eventName);
|
|
94
|
+
}
|
|
95
|
+
function isCurrentReplayRef(value, eventName) {
|
|
96
|
+
if (value === undefined || value === null || String(value).trim().length === 0) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
const normalized = normalizeGithubExpression(String(value));
|
|
100
|
+
if (normalized === null)
|
|
101
|
+
return false;
|
|
102
|
+
if (normalized === "github.sha") {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (normalized ===
|
|
106
|
+
"github.event_name=='pull_request'&&github.event.pull_request.base.sha||github.sha") {
|
|
107
|
+
return eventName !== "pull_request";
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
function normalizeGithubExpression(value) {
|
|
112
|
+
const match = /^\$\{\{\s*([\s\S]*?)\s*\}\}$/u.exec(value.trim());
|
|
113
|
+
if (!match?.[1])
|
|
114
|
+
return null;
|
|
115
|
+
return match[1].replace(/"/gu, "'").replace(/\s+/gu, "");
|
|
116
|
+
}
|
|
117
|
+
function hasAnyCheckoutInputExcept(withConfig, allowedKeys) {
|
|
118
|
+
const allowed = new Set(allowedKeys);
|
|
119
|
+
return Object.entries(withConfig).some(([key, value]) => {
|
|
120
|
+
if (allowed.has(key))
|
|
121
|
+
return false;
|
|
122
|
+
return value !== undefined && value !== null && String(value).trim().length > 0;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
function copyOptionalStepField(source, target, field) {
|
|
126
|
+
if (source[field] !== undefined) {
|
|
127
|
+
target[field] = source[field];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function isRecord(value) {
|
|
131
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
132
|
+
}
|
|
36
133
|
export function createReplayWorkflow(options) {
|
|
37
134
|
const sourcePath = resolve(options.cwd, options.workflowPath);
|
|
38
135
|
const sourceYaml = readFileSync(sourcePath, "utf8");
|
|
@@ -42,11 +139,17 @@ export function createReplayWorkflow(options) {
|
|
|
42
139
|
});
|
|
43
140
|
const dir = mkdtempSync(join(tmpdir(), "wp-ci-act-replay-"));
|
|
44
141
|
const workflowPath = join(dir, "workflow.yml");
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
142
|
+
try {
|
|
143
|
+
writeFileSync(workflowPath, generatedYaml, "utf8");
|
|
144
|
+
return {
|
|
145
|
+
workflowPath,
|
|
146
|
+
cleanup() {
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
rmSync(dir, { recursive: true, force: true });
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
52
155
|
}
|