@hegemonart/get-design-done 1.24.2 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +87 -0
- package/README.de.md +679 -0
- package/README.fr.md +679 -0
- package/README.it.md +679 -0
- package/README.ja.md +679 -0
- package/README.ko.md +679 -0
- package/README.md +399 -728
- package/README.zh-CN.md +480 -133
- package/SKILL.md +2 -0
- package/agents/README.md +60 -0
- package/agents/design-reflector.md +43 -0
- package/agents/gdd-intel-updater.md +34 -1
- package/agents/prototype-gate.md +122 -0
- package/agents/quality-gate-runner.md +125 -0
- package/hooks/budget-enforcer.ts +275 -11
- package/hooks/gdd-decision-injector.js +183 -3
- package/hooks/gdd-turn-closeout.js +238 -0
- package/hooks/hooks.json +10 -0
- package/package.json +5 -5
- package/reference/STATE-TEMPLATE.md +41 -0
- package/reference/config-schema.md +30 -0
- package/reference/model-prices.md +40 -19
- package/reference/prices/antigravity.md +21 -0
- package/reference/prices/augment.md +21 -0
- package/reference/prices/claude.md +42 -0
- package/reference/prices/cline.md +23 -0
- package/reference/prices/codebuddy.md +21 -0
- package/reference/prices/codex.md +25 -0
- package/reference/prices/copilot.md +21 -0
- package/reference/prices/cursor.md +21 -0
- package/reference/prices/gemini.md +25 -0
- package/reference/prices/kilo.md +21 -0
- package/reference/prices/opencode.md +23 -0
- package/reference/prices/qwen.md +25 -0
- package/reference/prices/trae.md +23 -0
- package/reference/prices/windsurf.md +21 -0
- package/reference/registry.json +107 -1
- package/reference/runtime-models.md +446 -0
- package/reference/schemas/runtime-models.schema.json +123 -0
- package/scripts/install.cjs +8 -0
- package/scripts/lib/budget-enforcer.cjs +446 -0
- package/scripts/lib/cost-arbitrage.cjs +294 -0
- package/scripts/lib/gdd-state/mutator.ts +454 -0
- package/scripts/lib/gdd-state/parser.ts +351 -1
- package/scripts/lib/gdd-state/types.ts +193 -0
- package/scripts/lib/install/installer.cjs +188 -11
- package/scripts/lib/install/parse-runtime-models.cjs +267 -0
- package/scripts/lib/install/runtimes.cjs +43 -0
- package/scripts/lib/quality-gate-detect.cjs +126 -0
- package/scripts/lib/runtime-detect.cjs +96 -0
- package/scripts/lib/tier-resolver.cjs +311 -0
- package/scripts/validate-frontmatter.ts +138 -1
- package/skills/quality-gate/SKILL.md +222 -0
- package/skills/router/SKILL.md +79 -10
- package/skills/sketch-wrap-up/SKILL.md +47 -2
- package/skills/spike-wrap-up/SKILL.md +41 -2
- package/skills/turn-closeout/SKILL.md +115 -0
- package/skills/verify/SKILL.md +22 -0
|
@@ -29,6 +29,13 @@ import {
|
|
|
29
29
|
type MustHave,
|
|
30
30
|
type ParsedState,
|
|
31
31
|
type Position,
|
|
32
|
+
type PrototypingBlock,
|
|
33
|
+
type QualityGateBlock,
|
|
34
|
+
type QualityGateRun,
|
|
35
|
+
type QualityGateStatus,
|
|
36
|
+
type SketchEntry,
|
|
37
|
+
type SkippedEntry,
|
|
38
|
+
type SpikeEntry,
|
|
32
39
|
} from './types.ts';
|
|
33
40
|
|
|
34
41
|
/**
|
|
@@ -250,6 +257,10 @@ function emitBlock(
|
|
|
250
257
|
return emitDecisions(state.decisions, rawBody);
|
|
251
258
|
case 'must_haves':
|
|
252
259
|
return emitMustHaves(state.must_haves, rawBody);
|
|
260
|
+
case 'prototyping':
|
|
261
|
+
return emitPrototyping(state.prototyping, rawBody);
|
|
262
|
+
case 'quality_gate':
|
|
263
|
+
return emitQualityGate(state.quality_gate, rawBody);
|
|
253
264
|
case 'connections':
|
|
254
265
|
return emitConnections(state.connections, rawBody);
|
|
255
266
|
case 'blockers':
|
|
@@ -340,6 +351,151 @@ function emitBlockers(blockers: Blocker[], rawBody: string | null): string {
|
|
|
340
351
|
return blockers.map((b) => `[${b.stage}] [${b.date}]: ${b.text}`).join('\n');
|
|
341
352
|
}
|
|
342
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Emit the body of a `<prototyping>` block (Phase 25 Plan 25-01).
|
|
356
|
+
*
|
|
357
|
+
* Returns `null` when the block should be omitted entirely — i.e. the
|
|
358
|
+
* parsed state has `prototyping === null` AND no raw body is on file.
|
|
359
|
+
* That signal short-circuits `emitBlock` so we don't litter the output
|
|
360
|
+
* with empty `<prototyping></prototyping>` pairs on fresh files.
|
|
361
|
+
*
|
|
362
|
+
* Fidelity rule (matches the other blocks): when `rawBody` round-trips
|
|
363
|
+
* through `tryReparsePrototyping` and matches the current value
|
|
364
|
+
* structurally, emit the raw body verbatim. Otherwise canonicalize.
|
|
365
|
+
*/
|
|
366
|
+
function emitPrototyping(
|
|
367
|
+
block: PrototypingBlock | null,
|
|
368
|
+
rawBody: string | null,
|
|
369
|
+
): string | null {
|
|
370
|
+
if (block === null && rawBody === null) return null;
|
|
371
|
+
if (block === null) {
|
|
372
|
+
// Block was present in source but state set it to null — caller wants
|
|
373
|
+
// to drop the block. Still return null so emitBlock skips emission.
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
if (rawBody !== null) {
|
|
377
|
+
const reparsed = tryReparsePrototyping(rawBody);
|
|
378
|
+
if (reparsed !== null && prototypingEqual(reparsed, block)) {
|
|
379
|
+
return rawBody;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return canonicalPrototyping(block);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function canonicalPrototyping(block: PrototypingBlock): string {
|
|
386
|
+
const lines: string[] = [];
|
|
387
|
+
for (const s of block.sketches) {
|
|
388
|
+
lines.push(canonicalSketch(s));
|
|
389
|
+
}
|
|
390
|
+
for (const sp of block.spikes) {
|
|
391
|
+
lines.push(canonicalSpike(sp));
|
|
392
|
+
}
|
|
393
|
+
for (const sk of block.skipped) {
|
|
394
|
+
lines.push(canonicalSkipped(sk));
|
|
395
|
+
}
|
|
396
|
+
return lines.join('\n');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function canonicalSketch(s: SketchEntry): string {
|
|
400
|
+
const parts: string[] = [
|
|
401
|
+
`slug=${formatPrototypingAttr(s.slug)}`,
|
|
402
|
+
`cycle=${formatPrototypingAttr(s.cycle)}`,
|
|
403
|
+
`decision=${formatPrototypingAttr(s.decision)}`,
|
|
404
|
+
`status=${formatPrototypingAttr(s.status)}`,
|
|
405
|
+
];
|
|
406
|
+
for (const [k, v] of Object.entries(s.extra_attrs)) {
|
|
407
|
+
parts.push(`${k}=${formatPrototypingAttr(v)}`);
|
|
408
|
+
}
|
|
409
|
+
return `<sketch ${parts.join(' ')}/>`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function canonicalSpike(s: SpikeEntry): string {
|
|
413
|
+
const parts: string[] = [
|
|
414
|
+
`slug=${formatPrototypingAttr(s.slug)}`,
|
|
415
|
+
`cycle=${formatPrototypingAttr(s.cycle)}`,
|
|
416
|
+
`decision=${formatPrototypingAttr(s.decision)}`,
|
|
417
|
+
`verdict=${formatPrototypingAttr(s.verdict)}`,
|
|
418
|
+
`status=${formatPrototypingAttr(s.status)}`,
|
|
419
|
+
];
|
|
420
|
+
for (const [k, v] of Object.entries(s.extra_attrs)) {
|
|
421
|
+
parts.push(`${k}=${formatPrototypingAttr(v)}`);
|
|
422
|
+
}
|
|
423
|
+
return `<spike ${parts.join(' ')}/>`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function canonicalSkipped(s: SkippedEntry): string {
|
|
427
|
+
const parts: string[] = [
|
|
428
|
+
`at=${formatPrototypingAttr(s.at)}`,
|
|
429
|
+
`cycle=${formatPrototypingAttr(s.cycle)}`,
|
|
430
|
+
`reason=${formatPrototypingAttr(s.reason)}`,
|
|
431
|
+
];
|
|
432
|
+
for (const [k, v] of Object.entries(s.extra_attrs)) {
|
|
433
|
+
parts.push(`${k}=${formatPrototypingAttr(v)}`);
|
|
434
|
+
}
|
|
435
|
+
return `<skipped ${parts.join(' ')}/>`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Format an attribute value for emission. We always quote with double
|
|
440
|
+
* quotes for canonical output: this avoids ambiguity on values containing
|
|
441
|
+
* whitespace, equal signs, or solidus. Embedded `"` are escaped to `"`
|
|
442
|
+
* which is what the parser already strips when re-reading. Empty strings
|
|
443
|
+
* are emitted as `""` (the parser tolerates them).
|
|
444
|
+
*/
|
|
445
|
+
function formatPrototypingAttr(v: string): string {
|
|
446
|
+
return `"${v.replace(/"/g, '"')}"`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Emit the body of a `<quality_gate>` block (Phase 25 Plan 25-03).
|
|
451
|
+
*
|
|
452
|
+
* Returns `null` when the block should be omitted entirely — i.e. the
|
|
453
|
+
* parsed state has `quality_gate === null` AND no raw body is on file.
|
|
454
|
+
* Mirror of `emitPrototyping`'s short-circuit behavior so fresh STATE.md
|
|
455
|
+
* files don't carry an empty `<quality_gate></quality_gate>` pair.
|
|
456
|
+
*
|
|
457
|
+
* Fidelity rule (matches the other blocks): when `rawBody` round-trips
|
|
458
|
+
* through `tryReparseQualityGate` and matches the current value
|
|
459
|
+
* structurally, emit the raw body verbatim. Otherwise canonicalize.
|
|
460
|
+
*/
|
|
461
|
+
function emitQualityGate(
|
|
462
|
+
block: QualityGateBlock | null,
|
|
463
|
+
rawBody: string | null,
|
|
464
|
+
): string | null {
|
|
465
|
+
if (block === null && rawBody === null) return null;
|
|
466
|
+
if (block === null) {
|
|
467
|
+
// Block was present in source but state set it to null — caller wants
|
|
468
|
+
// to drop the block. Still return null so emitBlock skips emission.
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
if (rawBody !== null) {
|
|
472
|
+
const reparsed = tryReparseQualityGate(rawBody);
|
|
473
|
+
if (reparsed !== null && qualityGateEqual(reparsed, block)) {
|
|
474
|
+
return rawBody;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return canonicalQualityGate(block);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function canonicalQualityGate(block: QualityGateBlock): string {
|
|
481
|
+
if (block.run === null) return '';
|
|
482
|
+
return canonicalQualityGateRun(block.run);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function canonicalQualityGateRun(run: QualityGateRun): string {
|
|
486
|
+
const parts: string[] = [
|
|
487
|
+
`started_at=${formatPrototypingAttr(run.started_at)}`,
|
|
488
|
+
`completed_at=${formatPrototypingAttr(run.completed_at)}`,
|
|
489
|
+
`status=${formatPrototypingAttr(run.status)}`,
|
|
490
|
+
`iteration=${formatPrototypingAttr(String(run.iteration))}`,
|
|
491
|
+
`commands_run=${formatPrototypingAttr(run.commands_run)}`,
|
|
492
|
+
];
|
|
493
|
+
for (const [k, v] of Object.entries(run.extra_attrs)) {
|
|
494
|
+
parts.push(`${k}=${formatPrototypingAttr(v)}`);
|
|
495
|
+
}
|
|
496
|
+
return `<run ${parts.join(' ')}/>`;
|
|
497
|
+
}
|
|
498
|
+
|
|
343
499
|
function emitTimestamps(
|
|
344
500
|
ts: Record<string, string>,
|
|
345
501
|
rawBody: string | null,
|
|
@@ -415,6 +571,90 @@ function blockersEqual(a: Blocker[], b: Blocker[]): boolean {
|
|
|
415
571
|
return true;
|
|
416
572
|
}
|
|
417
573
|
|
|
574
|
+
function prototypingEqual(
|
|
575
|
+
a: PrototypingBlock,
|
|
576
|
+
b: PrototypingBlock,
|
|
577
|
+
): boolean {
|
|
578
|
+
if (a.sketches.length !== b.sketches.length) return false;
|
|
579
|
+
if (a.spikes.length !== b.spikes.length) return false;
|
|
580
|
+
if (a.skipped.length !== b.skipped.length) return false;
|
|
581
|
+
for (let i = 0; i < a.sketches.length; i++) {
|
|
582
|
+
if (!sketchEqual(a.sketches[i]!, b.sketches[i]!)) return false;
|
|
583
|
+
}
|
|
584
|
+
for (let i = 0; i < a.spikes.length; i++) {
|
|
585
|
+
if (!spikeEqual(a.spikes[i]!, b.spikes[i]!)) return false;
|
|
586
|
+
}
|
|
587
|
+
for (let i = 0; i < a.skipped.length; i++) {
|
|
588
|
+
if (!skippedEqual(a.skipped[i]!, b.skipped[i]!)) return false;
|
|
589
|
+
}
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function sketchEqual(a: SketchEntry, b: SketchEntry): boolean {
|
|
594
|
+
return (
|
|
595
|
+
a.slug === b.slug &&
|
|
596
|
+
a.cycle === b.cycle &&
|
|
597
|
+
a.decision === b.decision &&
|
|
598
|
+
a.status === b.status &&
|
|
599
|
+
extraAttrsEqual(a.extra_attrs, b.extra_attrs)
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function spikeEqual(a: SpikeEntry, b: SpikeEntry): boolean {
|
|
604
|
+
return (
|
|
605
|
+
a.slug === b.slug &&
|
|
606
|
+
a.cycle === b.cycle &&
|
|
607
|
+
a.decision === b.decision &&
|
|
608
|
+
a.verdict === b.verdict &&
|
|
609
|
+
a.status === b.status &&
|
|
610
|
+
extraAttrsEqual(a.extra_attrs, b.extra_attrs)
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function skippedEqual(a: SkippedEntry, b: SkippedEntry): boolean {
|
|
615
|
+
return (
|
|
616
|
+
a.at === b.at &&
|
|
617
|
+
a.cycle === b.cycle &&
|
|
618
|
+
a.reason === b.reason &&
|
|
619
|
+
extraAttrsEqual(a.extra_attrs, b.extra_attrs)
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function extraAttrsEqual(
|
|
624
|
+
a: Record<string, string>,
|
|
625
|
+
b: Record<string, string>,
|
|
626
|
+
): boolean {
|
|
627
|
+
const ak = Object.keys(a);
|
|
628
|
+
const bk = Object.keys(b);
|
|
629
|
+
if (ak.length !== bk.length) return false;
|
|
630
|
+
for (let i = 0; i < ak.length; i++) {
|
|
631
|
+
if (ak[i] !== bk[i]) return false;
|
|
632
|
+
const key = ak[i]!;
|
|
633
|
+
if (a[key] !== b[key]) return false;
|
|
634
|
+
}
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function qualityGateEqual(
|
|
639
|
+
a: QualityGateBlock,
|
|
640
|
+
b: QualityGateBlock,
|
|
641
|
+
): boolean {
|
|
642
|
+
if (a.run === null && b.run === null) return true;
|
|
643
|
+
if (a.run === null || b.run === null) return false;
|
|
644
|
+
return qualityGateRunEqual(a.run, b.run);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function qualityGateRunEqual(a: QualityGateRun, b: QualityGateRun): boolean {
|
|
648
|
+
return (
|
|
649
|
+
a.started_at === b.started_at &&
|
|
650
|
+
a.completed_at === b.completed_at &&
|
|
651
|
+
a.status === b.status &&
|
|
652
|
+
a.iteration === b.iteration &&
|
|
653
|
+
a.commands_run === b.commands_run &&
|
|
654
|
+
extraAttrsEqual(a.extra_attrs, b.extra_attrs)
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
418
658
|
function recordsEqual(
|
|
419
659
|
a: Record<string, string>,
|
|
420
660
|
b: Record<string, string>,
|
|
@@ -553,6 +793,220 @@ function tryReparseBlockers(raw: string): Blocker[] | null {
|
|
|
553
793
|
}
|
|
554
794
|
}
|
|
555
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Reparse a `<prototyping>` body for fidelity comparison. Mirrors the
|
|
798
|
+
* shape of `parsePrototypingBody` in parser.ts but is intentionally
|
|
799
|
+
* separate (and tolerant) — returns `null` on any structural surprise so
|
|
800
|
+
* the caller falls back to canonical emission rather than throwing.
|
|
801
|
+
*
|
|
802
|
+
* Unlike the parser, this helper does NOT throw on missing required
|
|
803
|
+
* attributes. If the source body has been hand-edited into something the
|
|
804
|
+
* parser would reject, we treat it as "definitely changed" and return
|
|
805
|
+
* `null` so the canonical writer takes over.
|
|
806
|
+
*/
|
|
807
|
+
function tryReparsePrototyping(raw: string): PrototypingBlock | null {
|
|
808
|
+
try {
|
|
809
|
+
const sketches: SketchEntry[] = [];
|
|
810
|
+
const spikes: SpikeEntry[] = [];
|
|
811
|
+
const skipped: SkippedEntry[] = [];
|
|
812
|
+
const selfClose = /^<([a-z_]+)(\s+[^>]*?)?\s*\/>\s*$/;
|
|
813
|
+
for (const line of raw.split('\n')) {
|
|
814
|
+
const t = line.trim();
|
|
815
|
+
if (t === '' || t.startsWith('<!--')) continue;
|
|
816
|
+
const m = t.match(selfClose);
|
|
817
|
+
if (!m) {
|
|
818
|
+
// Anything non-comment that isn't a self-closing tag means the
|
|
819
|
+
// raw body is no longer a clean match for the parsed value.
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
const tag = m[1] ?? '';
|
|
823
|
+
const attrs = parseAttrInline(m[2] ?? '');
|
|
824
|
+
if (tag === 'sketch') {
|
|
825
|
+
const slug = attrs['slug'];
|
|
826
|
+
const cycle = attrs['cycle'];
|
|
827
|
+
const decision = attrs['decision'];
|
|
828
|
+
const status = attrs['status'] ?? 'resolved';
|
|
829
|
+
if (slug === undefined || cycle === undefined || decision === undefined) {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
if (status !== 'resolved') return null;
|
|
833
|
+
sketches.push({
|
|
834
|
+
slug,
|
|
835
|
+
cycle,
|
|
836
|
+
decision,
|
|
837
|
+
status: 'resolved',
|
|
838
|
+
extra_attrs: extractExtras(attrs, [
|
|
839
|
+
'slug',
|
|
840
|
+
'cycle',
|
|
841
|
+
'decision',
|
|
842
|
+
'status',
|
|
843
|
+
]),
|
|
844
|
+
});
|
|
845
|
+
} else if (tag === 'spike') {
|
|
846
|
+
const slug = attrs['slug'];
|
|
847
|
+
const cycle = attrs['cycle'];
|
|
848
|
+
const decision = attrs['decision'];
|
|
849
|
+
const verdict = attrs['verdict'];
|
|
850
|
+
const status = attrs['status'] ?? 'resolved';
|
|
851
|
+
if (
|
|
852
|
+
slug === undefined ||
|
|
853
|
+
cycle === undefined ||
|
|
854
|
+
decision === undefined ||
|
|
855
|
+
verdict === undefined
|
|
856
|
+
) {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
if (verdict !== 'yes' && verdict !== 'no' && verdict !== 'partial') {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
if (status !== 'resolved') return null;
|
|
863
|
+
spikes.push({
|
|
864
|
+
slug,
|
|
865
|
+
cycle,
|
|
866
|
+
decision,
|
|
867
|
+
verdict,
|
|
868
|
+
status: 'resolved',
|
|
869
|
+
extra_attrs: extractExtras(attrs, [
|
|
870
|
+
'slug',
|
|
871
|
+
'cycle',
|
|
872
|
+
'decision',
|
|
873
|
+
'verdict',
|
|
874
|
+
'status',
|
|
875
|
+
]),
|
|
876
|
+
});
|
|
877
|
+
} else if (tag === 'skipped') {
|
|
878
|
+
const at = attrs['at'];
|
|
879
|
+
const cycle = attrs['cycle'];
|
|
880
|
+
const reason = attrs['reason'];
|
|
881
|
+
if (at === undefined || cycle === undefined || reason === undefined) {
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
skipped.push({
|
|
885
|
+
at,
|
|
886
|
+
cycle,
|
|
887
|
+
reason,
|
|
888
|
+
extra_attrs: extractExtras(attrs, ['at', 'cycle', 'reason']),
|
|
889
|
+
});
|
|
890
|
+
} else {
|
|
891
|
+
// Unknown self-closing tag — return null to force canonical path.
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return { sketches, spikes, skipped };
|
|
896
|
+
} catch {
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Mirror of parser's `parsePrototypingAttrs` — kept local to avoid
|
|
902
|
+
* cross-file circular reach (mutator must not import parser internals). */
|
|
903
|
+
function parseAttrInline(span: string): Record<string, string> {
|
|
904
|
+
const out: Record<string, string> = {};
|
|
905
|
+
const re = /([a-zA-Z_][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s/>]+))/g;
|
|
906
|
+
let m: RegExpExecArray | null;
|
|
907
|
+
while ((m = re.exec(span)) !== null) {
|
|
908
|
+
const key = m[1] ?? '';
|
|
909
|
+
const value: string =
|
|
910
|
+
(m[2] !== undefined ? m[2] : undefined) ??
|
|
911
|
+
(m[3] !== undefined ? m[3] : undefined) ??
|
|
912
|
+
m[4] ??
|
|
913
|
+
'';
|
|
914
|
+
if (key !== '') out[key] = value;
|
|
915
|
+
}
|
|
916
|
+
return out;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function extractExtras(
|
|
920
|
+
all: Record<string, string>,
|
|
921
|
+
known: readonly string[],
|
|
922
|
+
): Record<string, string> {
|
|
923
|
+
const out: Record<string, string> = {};
|
|
924
|
+
for (const [k, v] of Object.entries(all)) {
|
|
925
|
+
if (!known.includes(k)) out[k] = v;
|
|
926
|
+
}
|
|
927
|
+
return out;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Reparse a `<quality_gate>` body for fidelity comparison. Mirror of
|
|
932
|
+
* `tryReparsePrototyping` — returns `null` on any structural surprise so
|
|
933
|
+
* the caller falls back to canonical emission rather than throwing.
|
|
934
|
+
*
|
|
935
|
+
* Tolerant of multiple `<run/>` lines (last-wins, matching the parser),
|
|
936
|
+
* blank lines, and comments. Strict on attribute presence and enum
|
|
937
|
+
* validity — a hand-edited body that drops `commands_run` will fail to
|
|
938
|
+
* round-trip and fall through to canonical form, which is correct.
|
|
939
|
+
*/
|
|
940
|
+
function tryReparseQualityGate(raw: string): QualityGateBlock | null {
|
|
941
|
+
try {
|
|
942
|
+
let run: QualityGateRun | null = null;
|
|
943
|
+
const selfClose = /^<([a-z_]+)(\s+[^>]*?)?\s*\/>\s*$/;
|
|
944
|
+
for (const line of raw.split('\n')) {
|
|
945
|
+
const t = line.trim();
|
|
946
|
+
if (t === '' || t.startsWith('<!--')) continue;
|
|
947
|
+
const m = t.match(selfClose);
|
|
948
|
+
if (!m) {
|
|
949
|
+
// Anything non-comment that isn't a self-closing tag means the
|
|
950
|
+
// raw body is no longer a clean match for the parsed value.
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
const tag = m[1] ?? '';
|
|
954
|
+
if (tag !== 'run') {
|
|
955
|
+
// Unknown self-closing tag inside <quality_gate> — force canonical.
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
const attrs = parseAttrInline(m[2] ?? '');
|
|
959
|
+
const started_at = attrs['started_at'];
|
|
960
|
+
const completed_at = attrs['completed_at'];
|
|
961
|
+
const status = attrs['status'];
|
|
962
|
+
const iterationRaw = attrs['iteration'];
|
|
963
|
+
const commands_run = attrs['commands_run'];
|
|
964
|
+
if (
|
|
965
|
+
started_at === undefined ||
|
|
966
|
+
completed_at === undefined ||
|
|
967
|
+
status === undefined ||
|
|
968
|
+
iterationRaw === undefined ||
|
|
969
|
+
commands_run === undefined
|
|
970
|
+
) {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
if (
|
|
974
|
+
status !== 'pass' &&
|
|
975
|
+
status !== 'fail' &&
|
|
976
|
+
status !== 'timeout' &&
|
|
977
|
+
status !== 'skipped'
|
|
978
|
+
) {
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
const iteration = Number(iterationRaw);
|
|
982
|
+
if (
|
|
983
|
+
!Number.isFinite(iteration) ||
|
|
984
|
+
!Number.isInteger(iteration) ||
|
|
985
|
+
iteration < 0
|
|
986
|
+
) {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
run = {
|
|
990
|
+
started_at,
|
|
991
|
+
completed_at,
|
|
992
|
+
status: status as QualityGateStatus,
|
|
993
|
+
iteration,
|
|
994
|
+
commands_run,
|
|
995
|
+
extra_attrs: extractExtras(attrs, [
|
|
996
|
+
'started_at',
|
|
997
|
+
'completed_at',
|
|
998
|
+
'status',
|
|
999
|
+
'iteration',
|
|
1000
|
+
'commands_run',
|
|
1001
|
+
]),
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
return { run };
|
|
1005
|
+
} catch {
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
556
1010
|
function tryReparseTimestamps(
|
|
557
1011
|
raw: string,
|
|
558
1012
|
): Record<string, string> | null {
|