@planu/cli 4.11.6 → 4.11.8
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/CHANGELOG.md +16 -0
- package/dist/cli/commands/serve.js +4 -0
- package/dist/config/license-plans.json +1 -0
- package/dist/engine/actuals/git-analyzer.js +4 -4
- package/dist/engine/browser-validator.js +26 -21
- package/dist/engine/crash-shield/file-collector.d.ts +20 -3
- package/dist/engine/crash-shield/file-collector.js +137 -8
- package/dist/engine/crash-shield/index.d.ts +18 -1
- package/dist/engine/crash-shield/index.js +58 -17
- package/dist/engine/diff-spec-generator.js +12 -5
- package/dist/engine/dogfooding/runtime-gap-detector.d.ts +3 -0
- package/dist/engine/dogfooding/runtime-gap-detector.js +386 -0
- package/dist/engine/figma/visual-qa.d.ts +2 -1
- package/dist/engine/figma/visual-qa.js +8 -7
- package/dist/engine/git-safe-input.d.ts +6 -0
- package/dist/engine/git-safe-input.js +41 -0
- package/dist/engine/qa-gate.js +2 -1
- package/dist/engine/spec-state-machine/transition-spec.d.ts +16 -1
- package/dist/engine/spec-state-machine/transition-spec.js +19 -4
- package/dist/engine/triagier/classifier.d.ts +2 -2
- package/dist/engine/triagier/classifier.js +12 -15
- package/dist/index.js +12 -4
- package/dist/storage/approval-operation-lock.d.ts +10 -0
- package/dist/storage/approval-operation-lock.js +44 -0
- package/dist/storage/approval-store.d.ts +2 -0
- package/dist/storage/approval-store.js +9 -1
- package/dist/storage/spec-store.d.ts +29 -2
- package/dist/storage/spec-store.js +307 -7
- package/dist/tools/approval-handler.js +255 -124
- package/dist/tools/browser-validate-handler.js +17 -3
- package/dist/tools/code-impact-handler.js +4 -2
- package/dist/tools/dogfood-watch.d.ts +6 -0
- package/dist/tools/dogfood-watch.js +48 -0
- package/dist/tools/figma/visual-qa.js +2 -1
- package/dist/tools/tool-registry/core-tools.js +12 -0
- package/dist/tools/tool-registry/group-quality-compliance.js +12 -1
- package/dist/tools/update-status/file-sync.js +3 -2
- package/dist/tools/update-status/index.d.ts +2 -0
- package/dist/tools/update-status/index.js +1086 -812
- package/dist/tools/update-status/response-builder.js +11 -0
- package/dist/tools/update-status/side-effects.d.ts +16 -1
- package/dist/tools/update-status/side-effects.js +140 -0
- package/dist/tools/update-status/transition-guard.js +1 -1
- package/dist/tools/update-status-actions.d.ts +10 -2
- package/dist/tools/update-status-actions.js +166 -192
- package/dist/tools/update-status-convention-gate.d.ts +3 -1
- package/dist/tools/update-status-convention-gate.js +135 -7
- package/dist/types/browser-validator.d.ts +2 -0
- package/dist/types/dogfooding.d.ts +34 -0
- package/dist/types/dogfooding.js +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/spec/core.d.ts +28 -1
- package/package.json +25 -25
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
const REPEATED_SIGNAL_THRESHOLD = 2;
|
|
6
|
+
const GIT_TIMEOUT_MS = 1_500;
|
|
7
|
+
const GIT_MAX_BUFFER_BYTES = 64 * 1024;
|
|
8
|
+
const ARTIFACTS = [
|
|
9
|
+
{ label: 'package.json', relativePath: 'package.json', maxBytes: 64 * 1024, json: true },
|
|
10
|
+
{
|
|
11
|
+
label: 'session-context',
|
|
12
|
+
relativePath: join('planu', 'session-context.md'),
|
|
13
|
+
maxBytes: 256 * 1024,
|
|
14
|
+
json: false,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
label: 'session',
|
|
18
|
+
relativePath: join('planu', 'session.json'),
|
|
19
|
+
maxBytes: 256 * 1024,
|
|
20
|
+
json: true,
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
const SIGNAL_RULES = [
|
|
24
|
+
{
|
|
25
|
+
signature: 'forced-lifecycle:force-status',
|
|
26
|
+
title: 'Repeated forceStatus lifecycle bypasses',
|
|
27
|
+
severity: 'high',
|
|
28
|
+
nextAction: 'create_spec',
|
|
29
|
+
patterns: [
|
|
30
|
+
/^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)update_status\b[^\n]{0,160}\bforceStatus\b\s*[:=]\s*true\b/gim,
|
|
31
|
+
/^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)forceStatus\b\s*[:=]\s*true\b[^\n]{0,160}\bupdate_status\b/gim,
|
|
32
|
+
/^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)forced status transition\b/gim,
|
|
33
|
+
],
|
|
34
|
+
structuredBooleanKey: 'forceStatus',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
signature: 'forced-lifecycle:force-approve',
|
|
38
|
+
title: 'Repeated forceApprove lifecycle bypasses',
|
|
39
|
+
severity: 'high',
|
|
40
|
+
nextAction: 'create_spec',
|
|
41
|
+
patterns: [
|
|
42
|
+
/^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)approve_spec\b[^\n]{0,160}\bforceApprove\b\s*[:=]\s*true\b/gim,
|
|
43
|
+
/^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)forceApprove\b\s*[:=]\s*true\b[^\n]{0,160}\bapprove_spec\b/gim,
|
|
44
|
+
/^\s*(?:[-*]\s*)?(?:tool|action|event|call)(?:\s+|\s*:\s*)force-approved lifecycle transition\b/gim,
|
|
45
|
+
],
|
|
46
|
+
structuredBooleanKey: 'forceApprove',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
signature: 'guard-fallback-visible',
|
|
50
|
+
title: 'Repeated guarded fallback behavior',
|
|
51
|
+
severity: 'medium',
|
|
52
|
+
nextAction: 'create_spec',
|
|
53
|
+
patterns: [/\bGUARD_FALLBACK\b/gi, /\bguard(?:ed)? fallback\b/gi],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
signature: 'placeholder-output-visible',
|
|
57
|
+
title: 'Repeated placeholder-facing output',
|
|
58
|
+
severity: 'medium',
|
|
59
|
+
nextAction: 'create_spec',
|
|
60
|
+
patterns: [
|
|
61
|
+
/\/\/\s*TO[D]O\b/gi,
|
|
62
|
+
/\braw TO[D]O placeholder\b/gi,
|
|
63
|
+
/\bplaceholder(?:-facing)? output\b/gi,
|
|
64
|
+
/\bempty placeholder comparison\b/gi,
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
signature: 'validate-runtime-mismatch',
|
|
69
|
+
title: 'Repeated validation and runtime mismatch',
|
|
70
|
+
severity: 'high',
|
|
71
|
+
nextAction: 'create_spec',
|
|
72
|
+
patterns: [
|
|
73
|
+
/\bvalidat(?:e|ed|ion)[^\n]{0,80}\bruntime (?:mismatch|failed|failure|error|timeout)\b/gi,
|
|
74
|
+
/\bruntime mismatch[^\n]{0,80}\bvalidat(?:e|ed|ion)\b/gi,
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
signature: 'manual-recovery-loop',
|
|
79
|
+
title: 'Repeated manual recovery steps',
|
|
80
|
+
severity: 'medium',
|
|
81
|
+
nextAction: 'submit_feedback',
|
|
82
|
+
patterns: [
|
|
83
|
+
/\bmanual recovery\b/gi,
|
|
84
|
+
/\brepair_frontmatter_drift\b/gi,
|
|
85
|
+
/\breconcile_spec\b/gi,
|
|
86
|
+
/\bretry(?:ing)? update_status\b/gi,
|
|
87
|
+
/\brecoveryRequired\b/gi,
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
function isWithin(root, candidate) {
|
|
92
|
+
const rel = relative(root, candidate);
|
|
93
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
94
|
+
}
|
|
95
|
+
async function loadArtifact(projectRoot, artifact) {
|
|
96
|
+
const candidate = resolve(projectRoot, artifact.relativePath);
|
|
97
|
+
if (!isWithin(projectRoot, candidate)) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
let handle;
|
|
101
|
+
try {
|
|
102
|
+
const canonicalBeforeOpen = await realpath(candidate);
|
|
103
|
+
if (!isWithin(projectRoot, canonicalBeforeOpen)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
107
|
+
const handleStat = await handle.stat();
|
|
108
|
+
if (!handleStat.isFile() || handleStat.size > artifact.maxBytes) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
// Re-resolve after opening and verify the path still names the opened inode.
|
|
112
|
+
// This closes parent-symlink traversal and path-swap races without trusting
|
|
113
|
+
// the lexical project-relative path alone.
|
|
114
|
+
const canonicalAfterOpen = await realpath(candidate);
|
|
115
|
+
if (!isWithin(projectRoot, canonicalAfterOpen)) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const pathStat = await stat(canonicalAfterOpen);
|
|
119
|
+
if (pathStat.dev !== handleStat.dev || pathStat.ino !== handleStat.ino) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const content = await handle.readFile({ encoding: 'utf8' });
|
|
123
|
+
if (!artifact.json) {
|
|
124
|
+
return { label: artifact.label, content, json: null };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
return { label: artifact.label, content, json: JSON.parse(content) };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await handle?.close().catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function countMatches(content, patterns) {
|
|
141
|
+
let count = 0;
|
|
142
|
+
for (const pattern of patterns) {
|
|
143
|
+
pattern.lastIndex = 0;
|
|
144
|
+
count += [...content.matchAll(pattern)].length;
|
|
145
|
+
}
|
|
146
|
+
return count;
|
|
147
|
+
}
|
|
148
|
+
function countStructuredBoolean(value, key) {
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
return value.reduce((total, item) => total + countStructuredBoolean(item, key), 0);
|
|
151
|
+
}
|
|
152
|
+
if (typeof value !== 'object' || value === null) {
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
let count = 0;
|
|
156
|
+
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
157
|
+
if (/^(?:docs?|documentation|examples?|schema|inputSchema)$/i.test(entryKey)) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (entryKey === key && entryValue === true) {
|
|
161
|
+
count += 1;
|
|
162
|
+
}
|
|
163
|
+
count += countStructuredBoolean(entryValue, key);
|
|
164
|
+
}
|
|
165
|
+
return count;
|
|
166
|
+
}
|
|
167
|
+
function addRepeatedSignals(runtimeArtifacts, findings) {
|
|
168
|
+
for (const rule of SIGNAL_RULES) {
|
|
169
|
+
const sourceCounts = runtimeArtifacts
|
|
170
|
+
.map((artifact) => ({
|
|
171
|
+
label: artifact.label,
|
|
172
|
+
count: countMatches(artifact.content, rule.patterns) +
|
|
173
|
+
(rule.structuredBooleanKey
|
|
174
|
+
? countStructuredBoolean(artifact.json, rule.structuredBooleanKey)
|
|
175
|
+
: 0),
|
|
176
|
+
}))
|
|
177
|
+
.filter((entry) => entry.count > 0);
|
|
178
|
+
const occurrences = sourceCounts.reduce((total, entry) => total + entry.count, 0);
|
|
179
|
+
if (occurrences < REPEATED_SIGNAL_THRESHOLD) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
findings.set(rule.signature, {
|
|
183
|
+
signature: rule.signature,
|
|
184
|
+
title: rule.title,
|
|
185
|
+
severity: rule.severity,
|
|
186
|
+
nextAction: rule.nextAction,
|
|
187
|
+
occurrences,
|
|
188
|
+
evidence: [
|
|
189
|
+
`${String(occurrences)} occurrence(s) across ${String(sourceCounts.length)} allowlisted runtime artifact(s)`,
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function addRepeatedToolFailures(runtimeArtifacts, findings) {
|
|
195
|
+
const counts = new Map();
|
|
196
|
+
for (const artifact of runtimeArtifacts) {
|
|
197
|
+
for (const match of artifact.content.matchAll(/(?:^|[^a-z0-9_-])-\s*[✗x]\s+([a-z0-9_-]+)/gi)) {
|
|
198
|
+
const tool = match[1];
|
|
199
|
+
if (!tool) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const current = counts.get(tool) ?? { occurrences: 0, sources: new Set() };
|
|
203
|
+
current.occurrences += 1;
|
|
204
|
+
current.sources.add(artifact.label);
|
|
205
|
+
counts.set(tool, current);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const [tool, aggregate] of counts) {
|
|
209
|
+
if (aggregate.occurrences < REPEATED_SIGNAL_THRESHOLD) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
findings.set(`repeated-tool-failure:${tool}`, {
|
|
213
|
+
signature: `repeated-tool-failure:${tool}`,
|
|
214
|
+
title: `Repeated ${tool} failures during dogfooding`,
|
|
215
|
+
severity: 'high',
|
|
216
|
+
nextAction: 'create_spec',
|
|
217
|
+
occurrences: aggregate.occurrences,
|
|
218
|
+
evidence: [
|
|
219
|
+
`${tool} failed ${String(aggregate.occurrences)} time(s) across ${String(aggregate.sources.size)} allowlisted runtime artifact(s)`,
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function packageVersion(artifact) {
|
|
225
|
+
if (!artifact || typeof artifact.json !== 'object' || artifact.json === null) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
const version = artifact.json.version;
|
|
229
|
+
return typeof version === 'string' ? version : null;
|
|
230
|
+
}
|
|
231
|
+
function sessionVersion(runtimeArtifacts) {
|
|
232
|
+
for (const artifact of runtimeArtifacts) {
|
|
233
|
+
const match = /\*\*Version\*\*:\s*([0-9]+\.[0-9]+\.[0-9]+)/.exec(artifact.content);
|
|
234
|
+
if (match?.[1]) {
|
|
235
|
+
return match[1];
|
|
236
|
+
}
|
|
237
|
+
if (typeof artifact.json === 'object' && artifact.json !== null) {
|
|
238
|
+
const version = artifact.json.version;
|
|
239
|
+
if (typeof version === 'string') {
|
|
240
|
+
return version;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
function runGit(projectRoot, args) {
|
|
247
|
+
return new Promise((resolveResult) => {
|
|
248
|
+
execFile('git', [...args], {
|
|
249
|
+
cwd: projectRoot,
|
|
250
|
+
encoding: 'utf8',
|
|
251
|
+
timeout: GIT_TIMEOUT_MS,
|
|
252
|
+
maxBuffer: GIT_MAX_BUFFER_BYTES,
|
|
253
|
+
windowsHide: true,
|
|
254
|
+
}, (error, stdout) => {
|
|
255
|
+
resolveResult(error ? null : stdout.trim());
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
async function detectBranchDrift(projectRoot, findings) {
|
|
260
|
+
const refs = [
|
|
261
|
+
'refs/heads/main',
|
|
262
|
+
'refs/remotes/origin/main',
|
|
263
|
+
'refs/heads/develop',
|
|
264
|
+
'refs/remotes/origin/develop',
|
|
265
|
+
'refs/heads/release',
|
|
266
|
+
'refs/remotes/origin/release',
|
|
267
|
+
];
|
|
268
|
+
const values = await Promise.all(refs.map((ref) => runGit(projectRoot, ['rev-parse', '--verify', ref])));
|
|
269
|
+
const resolved = new Map(refs.map((ref, index) => [ref, values[index] ?? null]));
|
|
270
|
+
const remoteMainSha = resolved.get('refs/remotes/origin/main');
|
|
271
|
+
const localMainSha = resolved.get('refs/heads/main');
|
|
272
|
+
const mainSha = remoteMainSha ?? localMainSha;
|
|
273
|
+
if (!mainSha) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
for (const branch of ['develop', 'release']) {
|
|
277
|
+
const local = resolved.get(`refs/heads/${branch}`);
|
|
278
|
+
const remote = resolved.get(`refs/remotes/origin/${branch}`);
|
|
279
|
+
if (remoteMainSha && remote && remote !== remoteMainSha) {
|
|
280
|
+
const signature = `remote-mirror-stale:${branch}`;
|
|
281
|
+
findings.set(signature, {
|
|
282
|
+
signature,
|
|
283
|
+
title: `Remote ${branch} mirror is stale`,
|
|
284
|
+
severity: 'high',
|
|
285
|
+
nextAction: 'create_spec',
|
|
286
|
+
occurrences: 1,
|
|
287
|
+
evidence: [`origin/${branch} does not match authoritative origin/main`],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (local && local !== mainSha) {
|
|
291
|
+
const signature = `local-mirror-stale:${branch}`;
|
|
292
|
+
findings.set(signature, {
|
|
293
|
+
signature,
|
|
294
|
+
title: `Local ${branch} mirror is stale`,
|
|
295
|
+
severity: 'medium',
|
|
296
|
+
nextAction: 'create_spec',
|
|
297
|
+
occurrences: 1,
|
|
298
|
+
evidence: [
|
|
299
|
+
`Local ${branch} does not match ${remoteMainSha ? 'authoritative origin/main' : 'local main'}`,
|
|
300
|
+
],
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
else if (!local && remote) {
|
|
304
|
+
const signature = `local-mirror-missing:${branch}`;
|
|
305
|
+
findings.set(signature, {
|
|
306
|
+
signature,
|
|
307
|
+
title: `Local ${branch} mirror is missing`,
|
|
308
|
+
severity: 'medium',
|
|
309
|
+
nextAction: 'create_spec',
|
|
310
|
+
occurrences: 1,
|
|
311
|
+
evidence: [`Local ${branch} is missing while origin/${branch} exists`],
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
export async function analyzeRuntimeDogfooding(projectPath) {
|
|
318
|
+
let projectRoot;
|
|
319
|
+
try {
|
|
320
|
+
projectRoot = await realpath(resolve(projectPath));
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return {
|
|
324
|
+
status: 'insufficient_evidence',
|
|
325
|
+
findings: [],
|
|
326
|
+
analyzedSources: [],
|
|
327
|
+
unavailableSources: ARTIFACTS.map((artifact) => artifact.label),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const loaded = await Promise.all(ARTIFACTS.map((artifact) => loadArtifact(projectRoot, artifact)));
|
|
331
|
+
const artifacts = loaded.filter((artifact) => artifact !== null);
|
|
332
|
+
const analyzedSources = artifacts.map((artifact) => artifact.label);
|
|
333
|
+
const unavailableSources = ARTIFACTS.filter((_, index) => loaded[index] === null).map((artifact) => artifact.label);
|
|
334
|
+
const runtimeArtifacts = artifacts.filter((artifact) => artifact.label === 'session-context' || artifact.label === 'session');
|
|
335
|
+
const findings = new Map();
|
|
336
|
+
addRepeatedSignals(runtimeArtifacts, findings);
|
|
337
|
+
addRepeatedToolFailures(runtimeArtifacts, findings);
|
|
338
|
+
const currentVersion = packageVersion(artifacts.find((item) => item.label === 'package.json'));
|
|
339
|
+
const persistedVersion = sessionVersion(runtimeArtifacts);
|
|
340
|
+
if (currentVersion && persistedVersion && currentVersion !== persistedVersion) {
|
|
341
|
+
findings.set('session-version-drift', {
|
|
342
|
+
signature: 'session-version-drift',
|
|
343
|
+
title: 'Persisted session version drifted from the current package version',
|
|
344
|
+
severity: 'medium',
|
|
345
|
+
nextAction: 'submit_feedback',
|
|
346
|
+
occurrences: 1,
|
|
347
|
+
evidence: ['Persisted session version does not match package.json'],
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const gitEvidenceAvailable = await detectBranchDrift(projectRoot, findings);
|
|
351
|
+
if (gitEvidenceAvailable) {
|
|
352
|
+
analyzedSources.push('git-local-refs');
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
unavailableSources.push('git-local-refs');
|
|
356
|
+
}
|
|
357
|
+
const normalizedFindings = [...findings.values()].sort((a, b) => {
|
|
358
|
+
const rank = { high: 0, medium: 1, low: 2 };
|
|
359
|
+
return rank[a.severity] - rank[b.severity] || a.title.localeCompare(b.title);
|
|
360
|
+
});
|
|
361
|
+
if (normalizedFindings.length > 0) {
|
|
362
|
+
return {
|
|
363
|
+
status: 'findings',
|
|
364
|
+
findings: normalizedFindings,
|
|
365
|
+
analyzedSources,
|
|
366
|
+
unavailableSources,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const hasCompleteRuntimeEvidence = runtimeArtifacts.some((artifact) => artifact.label === 'session-context') &&
|
|
370
|
+
runtimeArtifacts.some((artifact) => artifact.label === 'session');
|
|
371
|
+
if (!hasCompleteRuntimeEvidence || !gitEvidenceAvailable) {
|
|
372
|
+
return {
|
|
373
|
+
status: 'insufficient_evidence',
|
|
374
|
+
findings: [],
|
|
375
|
+
analyzedSources,
|
|
376
|
+
unavailableSources,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
status: 'no_actionable_gaps',
|
|
381
|
+
findings: [],
|
|
382
|
+
analyzedSources,
|
|
383
|
+
unavailableSources,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
//# sourceMappingURL=runtime-gap-detector.js.map
|
|
@@ -12,6 +12,7 @@ export interface VisualQAResult {
|
|
|
12
12
|
figmaImageUrl: string;
|
|
13
13
|
screenshotUrl: string;
|
|
14
14
|
comparedAt: string;
|
|
15
|
+
comparisonMode: 'pixel-diff' | 'evidence-only';
|
|
15
16
|
diffs: VisualDiffEntry[];
|
|
16
17
|
criticalCount: number;
|
|
17
18
|
warningCount: number;
|
|
@@ -26,7 +27,7 @@ export interface VisualQASummary {
|
|
|
26
27
|
validatedAt: string;
|
|
27
28
|
}
|
|
28
29
|
export declare function exportFigmaFrameImage(fileKey: string, nodeId: string, accessToken: string): Promise<string>;
|
|
29
|
-
export declare function createVisualQAResult(specId: string, nodeId: string, figmaImageUrl: string, screenshotUrl: string, diffs: VisualDiffEntry[]): VisualQAResult;
|
|
30
|
+
export declare function createVisualQAResult(specId: string, nodeId: string, figmaImageUrl: string, screenshotUrl: string, diffs: VisualDiffEntry[], comparisonMode?: 'pixel-diff' | 'evidence-only'): VisualQAResult;
|
|
30
31
|
export declare function buildPlaceholderDiff(_screenshotUrl: string, _figmaImageUrl: string): VisualDiffEntry[];
|
|
31
32
|
export declare function formatVisualQAReport(results: VisualQAResult[]): string;
|
|
32
33
|
export declare function buildSummary(results: VisualQAResult[], totalSpecs: number): VisualQASummary;
|
|
@@ -21,7 +21,7 @@ export async function exportFigmaFrameImage(fileKey, nodeId, accessToken) {
|
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
// createVisualQAResult — build a VisualQAResult from diffs
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
|
-
export function createVisualQAResult(specId, nodeId, figmaImageUrl, screenshotUrl, diffs) {
|
|
24
|
+
export function createVisualQAResult(specId, nodeId, figmaImageUrl, screenshotUrl, diffs, comparisonMode = 'pixel-diff') {
|
|
25
25
|
const criticalCount = diffs.filter((d) => d.severity === 'critical').length;
|
|
26
26
|
const warningCount = diffs.filter((d) => d.severity === 'warning').length;
|
|
27
27
|
const infoCount = diffs.filter((d) => d.severity === 'info').length;
|
|
@@ -31,18 +31,18 @@ export function createVisualQAResult(specId, nodeId, figmaImageUrl, screenshotUr
|
|
|
31
31
|
figmaImageUrl,
|
|
32
32
|
screenshotUrl,
|
|
33
33
|
comparedAt: new Date().toISOString(),
|
|
34
|
+
comparisonMode,
|
|
34
35
|
diffs,
|
|
35
36
|
criticalCount,
|
|
36
37
|
warningCount,
|
|
37
38
|
infoCount,
|
|
38
|
-
passed: criticalCount === 0,
|
|
39
|
+
passed: comparisonMode === 'pixel-diff' && criticalCount === 0,
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
42
43
|
// buildPlaceholderDiff — placeholder for pixel-level image comparison
|
|
43
44
|
// ---------------------------------------------------------------------------
|
|
44
45
|
export function buildPlaceholderDiff(_screenshotUrl, _figmaImageUrl) {
|
|
45
|
-
// TODO: Integrate with canvas/Playwright for pixel-level comparison
|
|
46
46
|
return [];
|
|
47
47
|
}
|
|
48
48
|
// ---------------------------------------------------------------------------
|
|
@@ -55,7 +55,7 @@ export function formatVisualQAReport(results) {
|
|
|
55
55
|
const header = '| Spec | Status | Critical | Warnings | Compared At |';
|
|
56
56
|
const separator = '|------|--------|---------|---------|------------|';
|
|
57
57
|
const rows = results.map((r) => {
|
|
58
|
-
const status = r.passed ? '✅ Pass' : '❌ Fail';
|
|
58
|
+
const status = r.comparisonMode === 'evidence-only' ? '⚠ Evidence only' : r.passed ? '✅ Pass' : '❌ Fail';
|
|
59
59
|
const date = r.comparedAt.slice(0, 10);
|
|
60
60
|
return `| ${r.specId} | ${status} | ${r.criticalCount} | ${r.warningCount} | ${date} |`;
|
|
61
61
|
});
|
|
@@ -65,11 +65,12 @@ export function formatVisualQAReport(results) {
|
|
|
65
65
|
// buildSummary — aggregate summary across all results
|
|
66
66
|
// ---------------------------------------------------------------------------
|
|
67
67
|
export function buildSummary(results, totalSpecs) {
|
|
68
|
+
const validatedResults = results.filter((result) => result.comparisonMode === 'pixel-diff');
|
|
68
69
|
return {
|
|
69
70
|
totalSpecs,
|
|
70
|
-
specsValidated:
|
|
71
|
-
specsWithCritical:
|
|
72
|
-
specsNeverValidated: totalSpecs -
|
|
71
|
+
specsValidated: validatedResults.length,
|
|
72
|
+
specsWithCritical: validatedResults.filter((r) => r.criticalCount > 0).length,
|
|
73
|
+
specsNeverValidated: Math.max(0, totalSpecs - validatedResults.length),
|
|
73
74
|
validatedAt: new Date().toISOString(),
|
|
74
75
|
};
|
|
75
76
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function isSafeGitRef(value: string): boolean;
|
|
2
|
+
export declare function assertSafeGitRef(value: string, label?: string): void;
|
|
3
|
+
export declare function isSafeGitDiffRange(value: string): boolean;
|
|
4
|
+
export declare function assertSafeGitDiffRange(value: string): void;
|
|
5
|
+
export declare function assertPositivePrNumber(value: number): void;
|
|
6
|
+
//# sourceMappingURL=git-safe-input.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/@{}:+~-]{0,200}$/;
|
|
2
|
+
function hasShellMetacharacters(value) {
|
|
3
|
+
return /[\s`$;&|<>\\\n\r]/.test(value);
|
|
4
|
+
}
|
|
5
|
+
export function isSafeGitRef(value) {
|
|
6
|
+
return (value.length > 0 &&
|
|
7
|
+
SAFE_GIT_REF_PATTERN.test(value) &&
|
|
8
|
+
!value.startsWith('-') &&
|
|
9
|
+
!value.includes('..') &&
|
|
10
|
+
!hasShellMetacharacters(value));
|
|
11
|
+
}
|
|
12
|
+
export function assertSafeGitRef(value, label = 'git ref') {
|
|
13
|
+
if (!isSafeGitRef(value)) {
|
|
14
|
+
throw new Error(`Invalid ${label}. Use a canonical commit hash or git ref without whitespace or shell metacharacters.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function isSafeGitDiffRange(value) {
|
|
18
|
+
if (value.length === 0 || hasShellMetacharacters(value)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
if (value.includes('...')) {
|
|
22
|
+
const parts = value.split('...');
|
|
23
|
+
return parts.length === 2 && parts.every((part) => part !== '' && isSafeGitRef(part));
|
|
24
|
+
}
|
|
25
|
+
if (value.includes('..')) {
|
|
26
|
+
const parts = value.split('..');
|
|
27
|
+
return parts.length === 2 && parts.every((part) => part !== '' && isSafeGitRef(part));
|
|
28
|
+
}
|
|
29
|
+
return isSafeGitRef(value);
|
|
30
|
+
}
|
|
31
|
+
export function assertSafeGitDiffRange(value) {
|
|
32
|
+
if (!isSafeGitDiffRange(value)) {
|
|
33
|
+
throw new Error('Invalid commitRange. Use a canonical git ref or a safe range like base..head or base...head.');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function assertPositivePrNumber(value) {
|
|
37
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
38
|
+
throw new Error('Invalid prNumber. Use a positive integer pull request number.');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=git-safe-input.js.map
|
package/dist/engine/qa-gate.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readFileSync, statSync } from 'node:fs';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { resolveProjectCommandPlan } from '../tools/validate-runtime.js';
|
|
7
7
|
const TIMEOUT_MS = 120_000;
|
|
8
|
+
const MAX_OUTPUT_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
8
9
|
const QA_FINGERPRINT_FILES = [
|
|
9
10
|
'package.json',
|
|
10
11
|
'pnpm-lock.yaml',
|
|
@@ -30,7 +31,7 @@ function runCheck(name, command, args, cwd) {
|
|
|
30
31
|
cwd,
|
|
31
32
|
timeout: TIMEOUT_MS,
|
|
32
33
|
encoding: 'utf-8',
|
|
33
|
-
maxBuffer:
|
|
34
|
+
maxBuffer: MAX_OUTPUT_BUFFER_BYTES,
|
|
34
35
|
});
|
|
35
36
|
const durationMs = Date.now() - start;
|
|
36
37
|
const passed = result.status === 0 && !result.error;
|
|
@@ -12,9 +12,19 @@ export interface TransitionContext {
|
|
|
12
12
|
viaSync?: boolean;
|
|
13
13
|
/** Optional reason supplied by the LLM (forceStatus / reverse transition). */
|
|
14
14
|
reason?: string;
|
|
15
|
+
/** Non-status fields committed atomically with the status mutation. */
|
|
16
|
+
updates?: Omit<Partial<Spec>, 'status'>;
|
|
17
|
+
/** Optional work scheduled after the durable transition boundary. */
|
|
18
|
+
pendingBackgroundActions?: readonly string[];
|
|
19
|
+
/** Snapshot used by lifecycle gates; the store rejects intervening mutations. */
|
|
20
|
+
expectedSpec?: Spec;
|
|
15
21
|
}
|
|
16
22
|
export interface TransitionRecord {
|
|
23
|
+
projectId: string;
|
|
24
|
+
specId: string;
|
|
17
25
|
spec: Spec;
|
|
26
|
+
previousSpec: Spec;
|
|
27
|
+
transitionId: string;
|
|
18
28
|
fromStatus: SpecStatus;
|
|
19
29
|
toStatus: SpecStatus;
|
|
20
30
|
trigger: TransitionTrigger;
|
|
@@ -29,7 +39,12 @@ export interface TransitionRecord {
|
|
|
29
39
|
* is responsible for running all gates (DoD, validate, QA, security, etc.) BEFORE
|
|
30
40
|
* invoking transitionSpec. transitionSpec never runs gates itself.
|
|
31
41
|
*
|
|
32
|
-
* Callable
|
|
42
|
+
* Callable only from the lifecycle status orchestrator.
|
|
33
43
|
*/
|
|
34
44
|
export declare function transitionSpec(projectId: string, specId: string, newStatus: SpecStatus, ctx: TransitionContext): Promise<TransitionRecord>;
|
|
45
|
+
/**
|
|
46
|
+
* Restore the exact pre-transition snapshot after paired persistence fails.
|
|
47
|
+
* The update_status caller must still hold the per-spec cross-process lock.
|
|
48
|
+
*/
|
|
49
|
+
export declare function rollbackTransitionSpec(record: TransitionRecord): Promise<Spec>;
|
|
35
50
|
//# sourceMappingURL=transition-spec.d.ts.map
|
|
@@ -12,23 +12,38 @@ import { specStore } from '../../storage/index.js';
|
|
|
12
12
|
* is responsible for running all gates (DoD, validate, QA, security, etc.) BEFORE
|
|
13
13
|
* invoking transitionSpec. transitionSpec never runs gates itself.
|
|
14
14
|
*
|
|
15
|
-
* Callable
|
|
15
|
+
* Callable only from the lifecycle status orchestrator.
|
|
16
16
|
*/
|
|
17
17
|
export async function transitionSpec(projectId, specId, newStatus, ctx) {
|
|
18
|
-
const before = await specStore.
|
|
18
|
+
const before = ctx.expectedSpec ?? (await specStore.getSpecFresh(projectId, specId));
|
|
19
19
|
if (!before) {
|
|
20
20
|
throw new Error(`Spec ${specId} not found in project ${projectId}`);
|
|
21
21
|
}
|
|
22
22
|
// Use the internal-only write path that bypasses the status guard
|
|
23
|
-
const after = await specStore.__internalSetStatus(projectId, specId, newStatus);
|
|
23
|
+
const after = await specStore.__internalSetStatus(projectId, specId, newStatus, ctx.updates, ctx.pendingBackgroundActions, { status: before.status, updatedAt: before.updatedAt });
|
|
24
|
+
const receipt = after.statusHistory?.at(-1);
|
|
25
|
+
if (!receipt?.transitionId) {
|
|
26
|
+
throw new Error(`Transition receipt was not persisted for ${specId}`);
|
|
27
|
+
}
|
|
24
28
|
return {
|
|
29
|
+
projectId,
|
|
30
|
+
specId,
|
|
25
31
|
spec: after,
|
|
32
|
+
previousSpec: before,
|
|
33
|
+
transitionId: receipt.transitionId,
|
|
26
34
|
fromStatus: before.status,
|
|
27
35
|
toStatus: newStatus,
|
|
28
36
|
trigger: ctx.trigger,
|
|
29
37
|
actor: ctx.actor,
|
|
30
38
|
viaSync: ctx.viaSync ?? false,
|
|
31
|
-
timestamp:
|
|
39
|
+
timestamp: receipt.changedAt,
|
|
32
40
|
};
|
|
33
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Restore the exact pre-transition snapshot after paired persistence fails.
|
|
44
|
+
* The update_status caller must still hold the per-spec cross-process lock.
|
|
45
|
+
*/
|
|
46
|
+
export async function rollbackTransitionSpec(record) {
|
|
47
|
+
return specStore.__internalRestoreSpecSnapshot(record.projectId, record.specId, record.previousSpec, record.spec);
|
|
48
|
+
}
|
|
34
49
|
//# sourceMappingURL=transition-spec.js.map
|
|
@@ -19,8 +19,8 @@ interface HeuristicResult {
|
|
|
19
19
|
export declare function heuristicClassify(description: string): HeuristicResult;
|
|
20
20
|
/**
|
|
21
21
|
* Classify the intent of a user request into a TriageKind.
|
|
22
|
-
* Wraps the
|
|
23
|
-
* On guard trip, returns a safe fallback
|
|
22
|
+
* Wraps the guarded classifier path in a cost guard (5s wall-clock).
|
|
23
|
+
* On guard trip, returns a safe deterministic fallback.
|
|
24
24
|
*/
|
|
25
25
|
export declare function classifyIntent(input: TriageRequestInput): Promise<TriageResult>;
|
|
26
26
|
export {};
|