gsdd-cli 0.28.0 → 0.29.1
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/agents/executor.md +6 -6
- package/agents/planner.md +10 -26
- package/agents/verifier.md +1 -1
- package/bin/lib/health-truth.mjs +4 -2
- package/bin/lib/phase.mjs +734 -3
- package/bin/lib/plan-constants.mjs +0 -5
- package/distilled/DESIGN.md +31 -29
- package/distilled/EVIDENCE-INDEX.md +3 -3
- package/distilled/templates/delegates/plan-checker.md +13 -14
- package/distilled/templates/ui-proof.md +81 -181
- package/distilled/workflows/audit-milestone.md +1 -1
- package/distilled/workflows/execute.md +5 -6
- package/distilled/workflows/plan.md +43 -51
- package/distilled/workflows/quick.md +7 -8
- package/distilled/workflows/verify.md +6 -6
- package/docs/USER-GUIDE.md +9 -1
- package/package.json +1 -1
package/bin/lib/phase.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// IMPORTANT: No module-scope process.cwd() — ESM caching means sub-modules
|
|
4
4
|
// evaluate once, so CWD must be computed inside function bodies.
|
|
5
5
|
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
7
|
-
import { basename, dirname, join, relative } from 'path';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, realpathSync } from 'fs';
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'path';
|
|
8
8
|
import { output } from './cli-utils.mjs';
|
|
9
9
|
import { resolveWorkspaceContext } from './workspace-root.mjs';
|
|
10
10
|
|
|
@@ -168,6 +168,698 @@ function extractPlanFileArtifacts(planContent, workspaceRoot) {
|
|
|
168
168
|
return artifacts;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
function extractFrontmatter(content) {
|
|
172
|
+
const match = String(content || '').replace(/\r\n/g, '\n').match(/^---\n([\s\S]*?)\n---/);
|
|
173
|
+
return match ? match[1] : '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readTopLevelScalar(frontmatter, key) {
|
|
177
|
+
const escapedKey = String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
178
|
+
const match = String(frontmatter || '').match(new RegExp(`^${escapedKey}:\\s*(.*)$`, 'm'));
|
|
179
|
+
return match ? normalizeScalarValue(match[1]) : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function hasTopLevelKey(frontmatter, key) {
|
|
183
|
+
const escapedKey = String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
184
|
+
return new RegExp(`^${escapedKey}:\\s*.*$`, 'm').test(String(frontmatter || ''));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function readTopLevelBlock(frontmatter, key) {
|
|
188
|
+
const escapedKey = String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
189
|
+
const lines = String(frontmatter || '').replace(/\r\n/g, '\n').split('\n');
|
|
190
|
+
const startIndex = lines.findIndex((line) => new RegExp(`^${escapedKey}:\\s*(.*)$`).test(line));
|
|
191
|
+
if (startIndex === -1) return null;
|
|
192
|
+
|
|
193
|
+
const scalar = normalizeScalarValue(lines[startIndex].replace(new RegExp(`^${escapedKey}:\\s*`), ''));
|
|
194
|
+
const nested = [];
|
|
195
|
+
for (const line of lines.slice(startIndex + 1)) {
|
|
196
|
+
if (/^\S[^:]*:\s*/.test(line)) break;
|
|
197
|
+
if (line.trim()) nested.push(line.trim());
|
|
198
|
+
}
|
|
199
|
+
return { scalar, nested };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function legacyUiProofSlotsState(frontmatter) {
|
|
203
|
+
const block = readTopLevelBlock(frontmatter, 'ui_proof_slots');
|
|
204
|
+
if (!block) return null;
|
|
205
|
+
if (/^\[\s*\]$/.test(block.scalar)) return 'empty';
|
|
206
|
+
if (isMeaningfulFieldValue(block.scalar)) return 'present';
|
|
207
|
+
return block.nested.length > 0 ? 'present' : 'empty';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function stripInlineComment(value) {
|
|
211
|
+
const text = String(value || '');
|
|
212
|
+
let quote = null;
|
|
213
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
214
|
+
const char = text[index];
|
|
215
|
+
if (quote) {
|
|
216
|
+
if (quote === '"' && char === '\\') {
|
|
217
|
+
index += 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (char === quote) quote = null;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (char === '"' || char === "'") {
|
|
224
|
+
quote = char;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (char === '#') return text.slice(0, index);
|
|
228
|
+
}
|
|
229
|
+
return text;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizeScalarValue(value) {
|
|
233
|
+
let normalized = stripInlineComment(value).trim();
|
|
234
|
+
if (
|
|
235
|
+
(normalized.startsWith('"') && normalized.endsWith('"'))
|
|
236
|
+
|| (normalized.startsWith("'") && normalized.endsWith("'"))
|
|
237
|
+
) {
|
|
238
|
+
normalized = normalized.slice(1, -1).trim();
|
|
239
|
+
}
|
|
240
|
+
return normalized;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function extractMarkdownSection(content, heading) {
|
|
244
|
+
return extractMarkdownSections(content, heading)[0] || '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function extractMarkdownSections(content, heading) {
|
|
248
|
+
const normalized = String(content || '').replace(/\r\n/g, '\n');
|
|
249
|
+
const escapedHeading = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
250
|
+
const headingMatcher = new RegExp(`^##\\s+${escapedHeading}\\s*$`, 'gim');
|
|
251
|
+
const matches = Array.from(normalized.matchAll(headingMatcher));
|
|
252
|
+
return matches.map((headingMatch, index) => {
|
|
253
|
+
const start = headingMatch.index + headingMatch[0].length;
|
|
254
|
+
const nextStart = matches[index + 1]?.index;
|
|
255
|
+
const rest = normalized.slice(start, nextStart);
|
|
256
|
+
const nextHeadingIndex = rest.search(/^##\s+/m);
|
|
257
|
+
return (nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex)).trim();
|
|
258
|
+
}).filter(Boolean);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function sectionFieldValue(section, label) {
|
|
262
|
+
const text = String(section || '');
|
|
263
|
+
const escapedLabel = String(label).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
264
|
+
const match = new RegExp(`^\\s*(?:[-*]\\s*)?${escapedLabel}:\\s*(.*)$`, 'im').exec(text);
|
|
265
|
+
if (!match) return '';
|
|
266
|
+
|
|
267
|
+
const inlineValue = normalizeScalarValue(match[1]);
|
|
268
|
+
if (inlineValue) return inlineValue;
|
|
269
|
+
|
|
270
|
+
const nestedValues = [];
|
|
271
|
+
const followingLines = text.slice(match.index + match[0].length).split(/\r?\n/);
|
|
272
|
+
for (const line of followingLines) {
|
|
273
|
+
if (!line.trim()) continue;
|
|
274
|
+
if (/^\S/.test(line)) break;
|
|
275
|
+
const nestedBullet = line.match(/^\s+[-*]\s*(.+)$/);
|
|
276
|
+
if (nestedBullet) {
|
|
277
|
+
nestedValues.push(nestedBullet[1].trim());
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const nestedText = line.match(/^\s{2,}(.+)$/);
|
|
281
|
+
if (nestedText) {
|
|
282
|
+
nestedValues.push(nestedText[1].trim());
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
return normalizeScalarValue(nestedValues.join(' '));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isMeaningfulFieldValue(value) {
|
|
291
|
+
const normalized = normalizeScalarValue(value);
|
|
292
|
+
if (!normalized) return false;
|
|
293
|
+
if (/^n\/?a$/i.test(normalized)) return false;
|
|
294
|
+
if (/^(none|null|nil|~|tbd|todo|unknown)$/i.test(normalized)) return false;
|
|
295
|
+
if (/^\[.*\]$/.test(normalized)) return false;
|
|
296
|
+
if (/^<.*>$/.test(normalized)) return false;
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function nonEmptyField(section, label) {
|
|
301
|
+
const value = sectionFieldValue(section, label);
|
|
302
|
+
return isMeaningfulFieldValue(value);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function firstFieldValue(section, labels) {
|
|
306
|
+
for (const label of labels) {
|
|
307
|
+
const value = sectionFieldValue(section, label);
|
|
308
|
+
if (isMeaningfulFieldValue(value)) return value;
|
|
309
|
+
}
|
|
310
|
+
return '';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function evidenceKindValues(section) {
|
|
314
|
+
const value = firstFieldValue(section, ['Evidence kind', 'Evidence kinds']);
|
|
315
|
+
return value
|
|
316
|
+
.split(/[,/;|]|\band\b/i)
|
|
317
|
+
.map((part) => normalizeScalarValue(part).toLowerCase())
|
|
318
|
+
.filter(Boolean);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function hasSupportedBrowserProofEvidenceKind(section) {
|
|
322
|
+
return evidenceKindValues(section).some((kind) => kind === 'runtime' || kind === 'test');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function hasPassingBrowserProofResult(section) {
|
|
326
|
+
const value = firstFieldValue(section, ['Result']).toLowerCase();
|
|
327
|
+
if (!isMeaningfulFieldValue(value)) return false;
|
|
328
|
+
if (/\b(not\s+passed|fail(?:ed|ing)?|partial|partly|blocked|waived|deferred|missing|not[_ -]?applicable|unknown)\b/i.test(value)) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
return /\b(pass(?:ed|ing)?|satisfied|success(?:ful)?)\b/i.test(value);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isBoundedBrowserProofClaim(section) {
|
|
335
|
+
const value = firstFieldValue(section, ['Claim limit']).toLowerCase();
|
|
336
|
+
if (!isMeaningfulFieldValue(value)) return false;
|
|
337
|
+
if (/\b(no limit|unlimited|entire app|whole app|full app|complete app|everything works|all works|all flows|all ui|all screens|works everywhere)\b/i.test(value)) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function hasPrivacySafetyNote(section) {
|
|
344
|
+
if (firstFieldValue(section, ['Privacy/safety', 'Privacy note', 'Safety note', 'Safe to publish'])) return true;
|
|
345
|
+
const artifacts = firstFieldValue(section, ['Artifacts']);
|
|
346
|
+
return /\b(local[-_ ]?only|safe to publish|not safe to publish|publishable|private|unsafe|sanitized)\b/i.test(artifacts);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function browserProofFixHint(code) {
|
|
350
|
+
if (code === 'retired_browser_proof_contract') {
|
|
351
|
+
return 'Replace retired ui_proof_slots/no_ui_proof_rationale fields with browser_proof_required and browser_proof_rationale.';
|
|
352
|
+
}
|
|
353
|
+
if (code === 'legacy_browser_proof_contract') {
|
|
354
|
+
return 'Update PLAN.md frontmatter to browser_proof_required: false and browser_proof_rationale when you next touch this phase.';
|
|
355
|
+
}
|
|
356
|
+
if (code === 'legacy_browser_proof_slots_require_migration') {
|
|
357
|
+
return 'Migrate non-empty ui_proof_slots to browser_proof_required: true plus a Browser Proof Plan before verifying this phase.';
|
|
358
|
+
}
|
|
359
|
+
if (code === 'conflicting_browser_proof_contract') {
|
|
360
|
+
return 'Use either the new browser_proof_* frontmatter or the legacy ui_proof_* frontmatter, not both.';
|
|
361
|
+
}
|
|
362
|
+
if (code === 'missing_browser_proof_declaration') {
|
|
363
|
+
return 'Add browser_proof_required: true|false and browser_proof_rationale to PLAN.md frontmatter.';
|
|
364
|
+
}
|
|
365
|
+
if (code === 'invalid_browser_proof_required') {
|
|
366
|
+
return 'Set browser_proof_required to true or false in PLAN.md frontmatter.';
|
|
367
|
+
}
|
|
368
|
+
if (code === 'missing_browser_proof_rationale') {
|
|
369
|
+
return 'Add a nonblank browser_proof_rationale explaining why browser proof is or is not required.';
|
|
370
|
+
}
|
|
371
|
+
if (code === 'missing_browser_proof_plan') {
|
|
372
|
+
return 'Add a ## Browser Proof Plan section or set browser_proof_required: false with a rationale if the work is not UI-sensitive.';
|
|
373
|
+
}
|
|
374
|
+
if (code === 'missing_browser_proof_observation') {
|
|
375
|
+
return 'Record a ## Browser Proof Observation in SUMMARY.md or link to an observation record before verifying browser-sensitive work.';
|
|
376
|
+
}
|
|
377
|
+
if (code === 'incomplete_browser_proof_observation') {
|
|
378
|
+
return 'Complete the Browser Proof Observation fields or narrow the proof claim.';
|
|
379
|
+
}
|
|
380
|
+
if (code === 'failed_browser_proof_observation') {
|
|
381
|
+
return 'Record a passing browser-proof observation, or narrow the claim and leave verification blocked.';
|
|
382
|
+
}
|
|
383
|
+
if (code === 'unsupported_browser_proof_evidence_kind') {
|
|
384
|
+
return 'Use Evidence kind: runtime or Evidence kind: test for browser proof, or narrow the claim.';
|
|
385
|
+
}
|
|
386
|
+
if (code === 'overbroad_browser_proof_claim') {
|
|
387
|
+
return 'Narrow Claim limit to the exact route, state, viewport, and behavior actually observed.';
|
|
388
|
+
}
|
|
389
|
+
if (code === 'invalid_browser_proof_observation_link') {
|
|
390
|
+
return 'Link browser-proof observation records as repo-local regular files inside this workspace.';
|
|
391
|
+
}
|
|
392
|
+
if (code === 'unmatched_browser_proof_observation') {
|
|
393
|
+
return 'Add a Plan field to each Browser Proof Observation that names the exact required PLAN.md artifact.';
|
|
394
|
+
}
|
|
395
|
+
return 'Complete the Browser Proof Plan fields or narrow the proof claim.';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function evaluateBrowserProofContract(planContent, planPath) {
|
|
399
|
+
const frontmatter = extractFrontmatter(planContent);
|
|
400
|
+
let requiredRaw = readTopLevelScalar(frontmatter, 'browser_proof_required');
|
|
401
|
+
let rationale = readTopLevelScalar(frontmatter, 'browser_proof_rationale');
|
|
402
|
+
let declarationPresent = requiredRaw !== null || rationale !== null;
|
|
403
|
+
const legacySlots = legacyUiProofSlotsState(frontmatter);
|
|
404
|
+
const legacyRationale = readTopLevelScalar(frontmatter, 'no_ui_proof_rationale');
|
|
405
|
+
const legacyNoUiCompatible = !declarationPresent
|
|
406
|
+
&& legacySlots === 'empty'
|
|
407
|
+
&& isMeaningfulFieldValue(legacyRationale);
|
|
408
|
+
const retiredKeys = ['ui_proof_slots', 'no_ui_proof_rationale', 'proof_bundle_version', 'claim_limits']
|
|
409
|
+
.filter((key) => hasTopLevelKey(frontmatter, key));
|
|
410
|
+
const blockers = [];
|
|
411
|
+
const warnings = [];
|
|
412
|
+
|
|
413
|
+
if (declarationPresent && retiredKeys.length > 0) {
|
|
414
|
+
blockers.push({
|
|
415
|
+
code: 'conflicting_browser_proof_contract',
|
|
416
|
+
severity: 'blocker',
|
|
417
|
+
path: planPath,
|
|
418
|
+
message: `PLAN.md mixes new browser-proof field(s) with retired field(s): ${retiredKeys.join(', ')}.`,
|
|
419
|
+
fix_hint: browserProofFixHint('conflicting_browser_proof_contract'),
|
|
420
|
+
});
|
|
421
|
+
return {
|
|
422
|
+
path: planPath,
|
|
423
|
+
declaration_present: declarationPresent,
|
|
424
|
+
required: null,
|
|
425
|
+
rationale,
|
|
426
|
+
legacy_compatible: false,
|
|
427
|
+
satisfied: false,
|
|
428
|
+
warnings,
|
|
429
|
+
blockers,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (!declarationPresent && legacySlots === 'present') {
|
|
434
|
+
blockers.push({
|
|
435
|
+
code: 'legacy_browser_proof_slots_require_migration',
|
|
436
|
+
severity: 'blocker',
|
|
437
|
+
path: planPath,
|
|
438
|
+
message: 'PLAN.md uses legacy non-empty ui_proof_slots; migrate them to browser_proof_required plus a Browser Proof Plan before verification.',
|
|
439
|
+
fix_hint: browserProofFixHint('legacy_browser_proof_slots_require_migration'),
|
|
440
|
+
});
|
|
441
|
+
return {
|
|
442
|
+
path: planPath,
|
|
443
|
+
declaration_present: false,
|
|
444
|
+
required: null,
|
|
445
|
+
rationale: legacyRationale,
|
|
446
|
+
legacy_compatible: false,
|
|
447
|
+
satisfied: false,
|
|
448
|
+
warnings,
|
|
449
|
+
blockers,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!declarationPresent && legacySlots === 'empty' && !isMeaningfulFieldValue(legacyRationale)) {
|
|
454
|
+
blockers.push({
|
|
455
|
+
code: 'missing_browser_proof_rationale',
|
|
456
|
+
severity: 'blocker',
|
|
457
|
+
path: planPath,
|
|
458
|
+
message: 'Legacy ui_proof_slots: [] requires a meaningful no_ui_proof_rationale, or migration to browser_proof_required/browser_proof_rationale.',
|
|
459
|
+
fix_hint: browserProofFixHint('missing_browser_proof_rationale'),
|
|
460
|
+
});
|
|
461
|
+
return {
|
|
462
|
+
path: planPath,
|
|
463
|
+
declaration_present: false,
|
|
464
|
+
required: null,
|
|
465
|
+
rationale: legacyRationale,
|
|
466
|
+
legacy_compatible: false,
|
|
467
|
+
satisfied: false,
|
|
468
|
+
warnings,
|
|
469
|
+
blockers,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (legacyNoUiCompatible) {
|
|
474
|
+
requiredRaw = 'false';
|
|
475
|
+
rationale = legacyRationale;
|
|
476
|
+
declarationPresent = true;
|
|
477
|
+
warnings.push({
|
|
478
|
+
code: 'legacy_browser_proof_contract',
|
|
479
|
+
severity: 'warning',
|
|
480
|
+
path: planPath,
|
|
481
|
+
message: 'PLAN.md uses legacy no-UI proof frontmatter; treating it as browser_proof_required: false for compatibility.',
|
|
482
|
+
fix_hint: browserProofFixHint('legacy_browser_proof_contract'),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const blockingRetiredKeys = legacyNoUiCompatible
|
|
487
|
+
? retiredKeys.filter((key) => key !== 'ui_proof_slots' && key !== 'no_ui_proof_rationale')
|
|
488
|
+
: retiredKeys;
|
|
489
|
+
|
|
490
|
+
if (blockingRetiredKeys.length > 0) {
|
|
491
|
+
blockers.push({
|
|
492
|
+
code: 'retired_browser_proof_contract',
|
|
493
|
+
severity: 'blocker',
|
|
494
|
+
path: planPath,
|
|
495
|
+
message: `PLAN.md uses retired browser-proof field(s): ${blockingRetiredKeys.join(', ')}.`,
|
|
496
|
+
fix_hint: browserProofFixHint('retired_browser_proof_contract'),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!declarationPresent && retiredKeys.length === 0) {
|
|
501
|
+
blockers.push({
|
|
502
|
+
code: 'missing_browser_proof_declaration',
|
|
503
|
+
severity: 'blocker',
|
|
504
|
+
path: planPath,
|
|
505
|
+
message: 'PLAN.md must declare browser_proof_required and browser_proof_rationale before verification.',
|
|
506
|
+
fix_hint: browserProofFixHint('missing_browser_proof_declaration'),
|
|
507
|
+
});
|
|
508
|
+
return {
|
|
509
|
+
path: planPath,
|
|
510
|
+
declaration_present: false,
|
|
511
|
+
required: null,
|
|
512
|
+
rationale: null,
|
|
513
|
+
legacy_compatible: false,
|
|
514
|
+
satisfied: false,
|
|
515
|
+
warnings,
|
|
516
|
+
blockers,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const normalizedRequired = String(requiredRaw || '').toLowerCase();
|
|
521
|
+
const required = normalizedRequired === 'true'
|
|
522
|
+
? true
|
|
523
|
+
: normalizedRequired === 'false'
|
|
524
|
+
? false
|
|
525
|
+
: null;
|
|
526
|
+
|
|
527
|
+
if (required === null) {
|
|
528
|
+
blockers.push({
|
|
529
|
+
code: 'invalid_browser_proof_required',
|
|
530
|
+
severity: 'blocker',
|
|
531
|
+
path: planPath,
|
|
532
|
+
message: 'browser_proof_required must be true or false when the browser-proof contract is declared.',
|
|
533
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_required'),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!isMeaningfulFieldValue(rationale)) {
|
|
538
|
+
blockers.push({
|
|
539
|
+
code: 'missing_browser_proof_rationale',
|
|
540
|
+
severity: 'blocker',
|
|
541
|
+
path: planPath,
|
|
542
|
+
message: 'browser_proof_rationale is required when the browser-proof contract is declared.',
|
|
543
|
+
fix_hint: browserProofFixHint('missing_browser_proof_rationale'),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (required === true) {
|
|
548
|
+
const section = extractMarkdownSection(planContent, 'Browser Proof Plan');
|
|
549
|
+
if (!section) {
|
|
550
|
+
blockers.push({
|
|
551
|
+
code: 'missing_browser_proof_plan',
|
|
552
|
+
severity: 'blocker',
|
|
553
|
+
path: planPath,
|
|
554
|
+
message: 'browser_proof_required is true but PLAN.md has no ## Browser Proof Plan section.',
|
|
555
|
+
fix_hint: browserProofFixHint('missing_browser_proof_plan'),
|
|
556
|
+
});
|
|
557
|
+
} else {
|
|
558
|
+
const missingFields = ['Routes/states', 'Viewports', 'Runtime path', 'Observations', 'Artifacts', 'Claim limit']
|
|
559
|
+
.filter((field) => !nonEmptyField(section, field));
|
|
560
|
+
const hasEvidenceCommand = nonEmptyField(section, 'Evidence command');
|
|
561
|
+
const hasNoCommandRationale = nonEmptyField(section, 'No-command rationale');
|
|
562
|
+
if (!hasEvidenceCommand && !hasNoCommandRationale) {
|
|
563
|
+
missingFields.push('Evidence command or No-command rationale');
|
|
564
|
+
}
|
|
565
|
+
if (!hasSupportedBrowserProofEvidenceKind(section)) {
|
|
566
|
+
missingFields.push('Evidence kind');
|
|
567
|
+
}
|
|
568
|
+
if (!hasPrivacySafetyNote(section)) {
|
|
569
|
+
missingFields.push('Privacy/safety');
|
|
570
|
+
}
|
|
571
|
+
if (!isBoundedBrowserProofClaim(section)) {
|
|
572
|
+
missingFields.push('bounded Claim limit');
|
|
573
|
+
}
|
|
574
|
+
if (missingFields.length > 0) {
|
|
575
|
+
blockers.push({
|
|
576
|
+
code: 'incomplete_browser_proof_plan',
|
|
577
|
+
severity: 'blocker',
|
|
578
|
+
path: planPath,
|
|
579
|
+
message: `Browser Proof Plan is missing required field(s): ${missingFields.join(', ')}.`,
|
|
580
|
+
fix_hint: browserProofFixHint('incomplete_browser_proof_plan'),
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
path: planPath,
|
|
588
|
+
declaration_present: declarationPresent,
|
|
589
|
+
required,
|
|
590
|
+
rationale,
|
|
591
|
+
legacy_compatible: legacyNoUiCompatible,
|
|
592
|
+
satisfied: blockers.length === 0,
|
|
593
|
+
warnings,
|
|
594
|
+
blockers,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function sanitizeLinkedObservationPath(value) {
|
|
599
|
+
let text = normalizeScalarValue(value);
|
|
600
|
+
const markdownLink = text.match(/^\[[^\]]+\]\(([^)]+)\)$/);
|
|
601
|
+
if (markdownLink) text = markdownLink[1].trim();
|
|
602
|
+
text = text.replace(/^`|`$/g, '').trim();
|
|
603
|
+
return text;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function isInsidePath(parent, child) {
|
|
607
|
+
const relativePath = relative(resolve(parent), resolve(child));
|
|
608
|
+
return relativePath === '' || (!!relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function readLinkedObservationRecords(summaryContent, summaryFullPath, workspaceRoot) {
|
|
612
|
+
const records = [];
|
|
613
|
+
const blockers = [];
|
|
614
|
+
const matcher = /^\s*(?:[-*]\s*)?(?:Browser Proof Observation|Browser proof observation|Observation record|Browser proof record):\s*(.+)$/gim;
|
|
615
|
+
let match;
|
|
616
|
+
while ((match = matcher.exec(String(summaryContent || '')))) {
|
|
617
|
+
const linkedPath = sanitizeLinkedObservationPath(match[1]);
|
|
618
|
+
if (!linkedPath) continue;
|
|
619
|
+
if (/^[a-z]+:\/\//i.test(linkedPath)) {
|
|
620
|
+
blockers.push({
|
|
621
|
+
code: 'invalid_browser_proof_observation_link',
|
|
622
|
+
severity: 'blocker',
|
|
623
|
+
path: linkedPath,
|
|
624
|
+
message: 'Browser Proof Observation link must be a repo-local relative file path, not a URL.',
|
|
625
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
626
|
+
});
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (isAbsolute(linkedPath)) {
|
|
630
|
+
blockers.push({
|
|
631
|
+
code: 'invalid_browser_proof_observation_link',
|
|
632
|
+
severity: 'blocker',
|
|
633
|
+
path: linkedPath,
|
|
634
|
+
message: 'Browser Proof Observation link must be a repo-local relative file path.',
|
|
635
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
636
|
+
});
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const candidates = [resolve(dirname(summaryFullPath), linkedPath), resolve(workspaceRoot, linkedPath)]
|
|
641
|
+
.filter((candidate, index, list) => list.indexOf(candidate) === index);
|
|
642
|
+
const existingPath = candidates.find((candidate) => existsSync(candidate));
|
|
643
|
+
if (!existingPath) {
|
|
644
|
+
blockers.push({
|
|
645
|
+
code: 'invalid_browser_proof_observation_link',
|
|
646
|
+
severity: 'blocker',
|
|
647
|
+
path: linkedPath,
|
|
648
|
+
message: 'Browser Proof Observation link does not resolve to an existing file in this workspace.',
|
|
649
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
650
|
+
});
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
const realWorkspaceRoot = realpathSync(workspaceRoot);
|
|
655
|
+
const realExistingPath = realpathSync(existingPath);
|
|
656
|
+
if (!isInsidePath(realWorkspaceRoot, realExistingPath)) {
|
|
657
|
+
blockers.push({
|
|
658
|
+
code: 'invalid_browser_proof_observation_link',
|
|
659
|
+
severity: 'blocker',
|
|
660
|
+
path: linkedPath,
|
|
661
|
+
message: 'Browser Proof Observation link resolves outside this workspace.',
|
|
662
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
663
|
+
});
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const stat = statSync(existingPath);
|
|
667
|
+
if (!stat.isFile()) {
|
|
668
|
+
blockers.push({
|
|
669
|
+
code: 'invalid_browser_proof_observation_link',
|
|
670
|
+
severity: 'blocker',
|
|
671
|
+
path: linkedPath,
|
|
672
|
+
message: 'Browser Proof Observation link must resolve to a regular file.',
|
|
673
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
674
|
+
});
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
records.push(readFileSync(existingPath, 'utf-8'));
|
|
678
|
+
} catch (error) {
|
|
679
|
+
blockers.push({
|
|
680
|
+
code: 'invalid_browser_proof_observation_link',
|
|
681
|
+
severity: 'blocker',
|
|
682
|
+
path: linkedPath,
|
|
683
|
+
message: `Browser Proof Observation link could not be read: ${error.code || error.message}.`,
|
|
684
|
+
fix_hint: browserProofFixHint('invalid_browser_proof_observation_link'),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return { records, blockers };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function findBrowserProofObservationSections(summaryContent, summaryFullPath, workspaceRoot) {
|
|
692
|
+
const linked = readLinkedObservationRecords(summaryContent, summaryFullPath, workspaceRoot);
|
|
693
|
+
return {
|
|
694
|
+
sections: [
|
|
695
|
+
...extractMarkdownSections(summaryContent, 'Browser Proof Observation'),
|
|
696
|
+
...linked.records
|
|
697
|
+
.flatMap((record) => extractMarkdownSections(record, 'Browser Proof Observation')),
|
|
698
|
+
].filter(Boolean),
|
|
699
|
+
blockers: linked.blockers,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function browserProofObservationIssues(section) {
|
|
704
|
+
const requiredFieldSets = [
|
|
705
|
+
['Flow', 'Routes/states'],
|
|
706
|
+
['Viewports'],
|
|
707
|
+
['Runtime path'],
|
|
708
|
+
['Evidence kind', 'Evidence kinds'],
|
|
709
|
+
['Evidence command', 'No-command rationale'],
|
|
710
|
+
['Observed', 'Observations'],
|
|
711
|
+
['Artifacts'],
|
|
712
|
+
['Result'],
|
|
713
|
+
['Claim limit'],
|
|
714
|
+
];
|
|
715
|
+
const missingFields = requiredFieldSets
|
|
716
|
+
.filter((labels) => !labels.some((label) => nonEmptyField(section, label)))
|
|
717
|
+
.map((labels) => labels.join(' or '));
|
|
718
|
+
if (!hasPrivacySafetyNote(section)) {
|
|
719
|
+
missingFields.push('Privacy/safety');
|
|
720
|
+
}
|
|
721
|
+
const issues = missingFields.length > 0
|
|
722
|
+
? [{
|
|
723
|
+
code: 'incomplete_browser_proof_observation',
|
|
724
|
+
missingFields,
|
|
725
|
+
message: `Browser Proof Observation is missing required observed-proof field(s): ${missingFields.join(', ')}.`,
|
|
726
|
+
}]
|
|
727
|
+
: [];
|
|
728
|
+
if (!hasSupportedBrowserProofEvidenceKind(section)) {
|
|
729
|
+
issues.push({
|
|
730
|
+
code: 'unsupported_browser_proof_evidence_kind',
|
|
731
|
+
message: 'Browser Proof Observation evidence kind must include runtime or test.',
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
if (!hasPassingBrowserProofResult(section)) {
|
|
735
|
+
issues.push({
|
|
736
|
+
code: 'failed_browser_proof_observation',
|
|
737
|
+
message: 'Browser Proof Observation result is not an explicit pass.',
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
if (!isBoundedBrowserProofClaim(section)) {
|
|
741
|
+
issues.push({
|
|
742
|
+
code: 'overbroad_browser_proof_claim',
|
|
743
|
+
message: 'Browser Proof Observation claim limit is missing or too broad.',
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
return issues;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function isCompleteBrowserProofObservation(section) {
|
|
750
|
+
return browserProofObservationIssues(section).length === 0;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function normalizeArtifactReference(value) {
|
|
754
|
+
return String(value || '').replace(/\\/g, '/').toLowerCase();
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function observationReferencesPlan(section, planPath) {
|
|
758
|
+
const planReference = sectionFieldValue(section, 'Plan')
|
|
759
|
+
|| sectionFieldValue(section, 'Plan artifact')
|
|
760
|
+
|| sectionFieldValue(section, 'PLAN');
|
|
761
|
+
if (!isMeaningfulFieldValue(planReference)) return false;
|
|
762
|
+
const normalizedReferences = normalizeArtifactReference(planReference)
|
|
763
|
+
.split(/[\n,;]+/)
|
|
764
|
+
.map((reference) => reference.trim().replace(/^\.\//, ''))
|
|
765
|
+
.filter(Boolean);
|
|
766
|
+
const normalizedPlanPath = normalizeArtifactReference(planPath);
|
|
767
|
+
return normalizedReferences.some((reference) => reference === normalizedPlanPath);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function evaluateBrowserProofObservation(browserProofPlans, matchingSummaries, phasesDir, workspaceRoot) {
|
|
771
|
+
const requiredPlans = browserProofPlans.filter((plan) => plan.required === true);
|
|
772
|
+
if (requiredPlans.length === 0 || matchingSummaries.length === 0) {
|
|
773
|
+
return {
|
|
774
|
+
satisfied: true,
|
|
775
|
+
sections: [],
|
|
776
|
+
warnings: [],
|
|
777
|
+
blockers: [],
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const sections = matchingSummaries.flatMap((summaryPath) => {
|
|
782
|
+
const summaryFullPath = join(phasesDir, summaryPath);
|
|
783
|
+
if (!existsSync(summaryFullPath)) return [];
|
|
784
|
+
const found = findBrowserProofObservationSections(
|
|
785
|
+
readFileSync(summaryFullPath, 'utf-8'),
|
|
786
|
+
summaryFullPath,
|
|
787
|
+
workspaceRoot
|
|
788
|
+
);
|
|
789
|
+
return found.sections.map((section) => ({ section, summaryPath }));
|
|
790
|
+
});
|
|
791
|
+
const linkBlockers = matchingSummaries.flatMap((summaryPath) => {
|
|
792
|
+
const summaryFullPath = join(phasesDir, summaryPath);
|
|
793
|
+
if (!existsSync(summaryFullPath)) return [];
|
|
794
|
+
return findBrowserProofObservationSections(
|
|
795
|
+
readFileSync(summaryFullPath, 'utf-8'),
|
|
796
|
+
summaryFullPath,
|
|
797
|
+
workspaceRoot
|
|
798
|
+
).blockers.map((blocker) => ({ ...blocker, path: `${summaryPath}: ${blocker.path}` }));
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
if (sections.length === 0 && linkBlockers.length === 0) {
|
|
802
|
+
return {
|
|
803
|
+
satisfied: false,
|
|
804
|
+
sections,
|
|
805
|
+
warnings: [],
|
|
806
|
+
blockers: [{
|
|
807
|
+
code: 'missing_browser_proof_observation',
|
|
808
|
+
severity: 'blocker',
|
|
809
|
+
path: matchingSummaries.join(', '),
|
|
810
|
+
message: 'A plan requires browser proof, but no SUMMARY.md or linked record contains ## Browser Proof Observation.',
|
|
811
|
+
fix_hint: browserProofFixHint('missing_browser_proof_observation'),
|
|
812
|
+
}],
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const issueBlockers = sections.flatMap(({ section, summaryPath }) => (
|
|
817
|
+
browserProofObservationIssues(section).map((issue) => ({
|
|
818
|
+
code: issue.code,
|
|
819
|
+
severity: 'blocker',
|
|
820
|
+
path: summaryPath,
|
|
821
|
+
message: issue.message,
|
|
822
|
+
fix_hint: browserProofFixHint(issue.code),
|
|
823
|
+
}))
|
|
824
|
+
));
|
|
825
|
+
const completeSections = sections.filter(({ section }) => isCompleteBrowserProofObservation(section));
|
|
826
|
+
if (completeSections.length === 0) {
|
|
827
|
+
return {
|
|
828
|
+
satisfied: false,
|
|
829
|
+
sections: sections.map(({ section }) => section),
|
|
830
|
+
warnings: [],
|
|
831
|
+
blockers: [...linkBlockers, ...issueBlockers],
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const unmatchedPlans = requiredPlans.filter((plan) => (
|
|
836
|
+
!completeSections.some(({ section }) => observationReferencesPlan(section, plan.path))
|
|
837
|
+
));
|
|
838
|
+
const unmatchedBlockers = unmatchedPlans.map((plan) => ({
|
|
839
|
+
code: 'unmatched_browser_proof_observation',
|
|
840
|
+
severity: 'blocker',
|
|
841
|
+
path: plan.path,
|
|
842
|
+
message: `No complete Browser Proof Observation references required plan ${plan.path}.`,
|
|
843
|
+
fix_hint: browserProofFixHint('unmatched_browser_proof_observation'),
|
|
844
|
+
}));
|
|
845
|
+
const blockers = [...linkBlockers, ...issueBlockers, ...unmatchedBlockers];
|
|
846
|
+
if (blockers.length === 0) {
|
|
847
|
+
return {
|
|
848
|
+
satisfied: true,
|
|
849
|
+
sections: sections.map(({ section }) => section),
|
|
850
|
+
warnings: [],
|
|
851
|
+
blockers: [],
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
satisfied: false,
|
|
857
|
+
sections: sections.map(({ section }) => section),
|
|
858
|
+
warnings: [],
|
|
859
|
+
blockers,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
171
863
|
function isPlanArtifactSatisfied(artifact) {
|
|
172
864
|
if (artifact.operation === 'delete') return !artifact.exists;
|
|
173
865
|
return artifact.exists;
|
|
@@ -403,13 +1095,45 @@ export function buildPhaseVerificationReport(...args) {
|
|
|
403
1095
|
? extractPlanFileArtifacts(readFileSync(fullPath, 'utf-8'), workspaceRoot)
|
|
404
1096
|
: [];
|
|
405
1097
|
});
|
|
1098
|
+
const browserProofPlans = matchingPlans.map((planPath) => {
|
|
1099
|
+
const fullPath = join(phasesDir, planPath);
|
|
1100
|
+
return existsSync(fullPath)
|
|
1101
|
+
? evaluateBrowserProofContract(readFileSync(fullPath, 'utf-8'), planPath)
|
|
1102
|
+
: {
|
|
1103
|
+
path: planPath,
|
|
1104
|
+
declaration_present: false,
|
|
1105
|
+
required: null,
|
|
1106
|
+
rationale: null,
|
|
1107
|
+
satisfied: true,
|
|
1108
|
+
warnings: [],
|
|
1109
|
+
blockers: [],
|
|
1110
|
+
};
|
|
1111
|
+
});
|
|
1112
|
+
const browserProofObservationStatus = evaluateBrowserProofObservation(
|
|
1113
|
+
browserProofPlans,
|
|
1114
|
+
matchingSummaries,
|
|
1115
|
+
phasesDir,
|
|
1116
|
+
workspaceRoot
|
|
1117
|
+
);
|
|
1118
|
+
const browserProofBlockers = [
|
|
1119
|
+
...browserProofPlans.flatMap((plan) => plan.blockers),
|
|
1120
|
+
...browserProofObservationStatus.blockers,
|
|
1121
|
+
];
|
|
1122
|
+
const browserProofWarnings = [
|
|
1123
|
+
...browserProofPlans.flatMap((plan) => plan.warnings || []),
|
|
1124
|
+
...(browserProofObservationStatus.warnings || []),
|
|
1125
|
+
];
|
|
406
1126
|
const artifactStatus = evaluatePlanArtifacts(artifacts);
|
|
407
1127
|
const legacyVerified = matchingPlans.length > 0 && matchingSummaries.length > 0;
|
|
408
1128
|
const blockedOn = [
|
|
409
1129
|
...(prerequisiteBlockers.length > 0 ? ['prerequisites'] : []),
|
|
410
1130
|
...(artifactStatus.satisfied ? [] : ['artifacts']),
|
|
1131
|
+
...(browserProofBlockers.length > 0 ? ['browser_proof'] : []),
|
|
411
1132
|
];
|
|
412
|
-
const closureVerified = legacyVerified
|
|
1133
|
+
const closureVerified = legacyVerified
|
|
1134
|
+
&& prerequisiteBlockers.length === 0
|
|
1135
|
+
&& artifactStatus.satisfied
|
|
1136
|
+
&& browserProofBlockers.length === 0;
|
|
413
1137
|
|
|
414
1138
|
const result = {
|
|
415
1139
|
phase: normalizePhaseToken(phaseNum),
|
|
@@ -419,6 +1143,13 @@ export function buildPhaseVerificationReport(...args) {
|
|
|
419
1143
|
artifacts,
|
|
420
1144
|
allExist: artifacts.every((artifact) => artifact.exists),
|
|
421
1145
|
artifact_status: artifactStatus,
|
|
1146
|
+
browser_proof_status: {
|
|
1147
|
+
satisfied: browserProofBlockers.length === 0,
|
|
1148
|
+
plans: browserProofPlans,
|
|
1149
|
+
observations: browserProofObservationStatus,
|
|
1150
|
+
warnings: browserProofWarnings,
|
|
1151
|
+
blockers: browserProofBlockers,
|
|
1152
|
+
},
|
|
422
1153
|
verified: closureVerified,
|
|
423
1154
|
legacy_verified: legacyVerified,
|
|
424
1155
|
phase_artifacts_present: legacyVerified,
|