valent-pipeline 0.19.66 → 0.19.68
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/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { isAbsolute, join } from 'path';
|
|
|
3
3
|
import { checkSpecConformance } from '../lib/spec-conformance.js';
|
|
4
4
|
import { evidenceDir, latestRun } from '../lib/evidence.js';
|
|
5
5
|
import { parseJunit } from '../lib/junit.js';
|
|
6
|
+
import { detectManifestCaseDrift } from '../lib/spec-manifest.js';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_TEST_RE = /\.(test|spec)\.(js|ts|jsx|tsx|mjs|cjs)$/;
|
|
8
9
|
const IGNORE_DIRS = new Set([
|
|
@@ -79,6 +80,12 @@ export async function checkSpecConformanceCmd(options) {
|
|
|
79
80
|
process.exit(2);
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
const drift = detectManifestCaseDrift(manifest);
|
|
84
|
+
if (drift) {
|
|
85
|
+
console.error(`spec-conformance: ${drift}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
82
89
|
if (!Array.isArray(manifest?.cases) || manifest.cases.length === 0) {
|
|
83
90
|
console.log(`spec-conformance: manifest has no cases — skipping (no-op pass).`);
|
|
84
91
|
process.exit(0);
|
package/src/commands/trace.js
CHANGED
|
@@ -11,6 +11,7 @@ import { join, isAbsolute, dirname } from 'path';
|
|
|
11
11
|
import { traceCheck, traceMatrix } from '../lib/trace.js';
|
|
12
12
|
import { evidenceDir, resolveRunForAssert, requiredCaseIdsForRun, resolveRoot } from '../lib/evidence.js';
|
|
13
13
|
import { parseJunit } from '../lib/junit.js';
|
|
14
|
+
import { detectManifestCaseDrift } from '../lib/spec-manifest.js';
|
|
14
15
|
|
|
15
16
|
/** Load an exact evidence run by id (for `--run`), or null if absent/unreadable. */
|
|
16
17
|
function loadRunById(evDir, runId) {
|
|
@@ -48,6 +49,8 @@ function loadInputs(options) {
|
|
|
48
49
|
if (!existsSync(specPath)) return { noop: `no spec manifest at ${specPath} — skipping (no-op pass).` };
|
|
49
50
|
const reqsManifest = readJson(reqsPath, 'reqs manifest');
|
|
50
51
|
const specManifest = readJson(specPath, 'spec manifest');
|
|
52
|
+
const drift = detectManifestCaseDrift(specManifest);
|
|
53
|
+
if (drift) return { error: drift };
|
|
51
54
|
const cases = Array.isArray(specManifest?.cases) ? specManifest.cases : [];
|
|
52
55
|
if (cases.length > 0 && !cases.some((c) => Array.isArray(c.ac) && c.ac.length > 0)) {
|
|
53
56
|
return { noop: 'spec manifest predates the per-case `ac` field — skipping (no-op pass). Re-run QA-A to gain mechanical AC coverage.' };
|
|
@@ -93,6 +96,10 @@ export async function traceCheckCmd(options) {
|
|
|
93
96
|
process.exit(2);
|
|
94
97
|
}
|
|
95
98
|
const inputs = loadInputs(options);
|
|
99
|
+
if (inputs.error) {
|
|
100
|
+
console.error(`trace-check: ${inputs.error}`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
96
103
|
if (inputs.noop) {
|
|
97
104
|
console.log(`trace-check: ${inputs.noop}`);
|
|
98
105
|
process.exit(0);
|
|
@@ -108,6 +115,10 @@ export async function traceMatrixCmd(options) {
|
|
|
108
115
|
process.exit(2);
|
|
109
116
|
}
|
|
110
117
|
const inputs = loadInputs(options);
|
|
118
|
+
if (inputs.error) {
|
|
119
|
+
console.error(`trace-matrix: ${inputs.error}`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
111
122
|
if (inputs.noop) {
|
|
112
123
|
console.log(`trace-matrix: ${inputs.noop}`);
|
|
113
124
|
process.exit(0);
|
package/src/lib/git-flow.js
CHANGED
|
@@ -139,15 +139,19 @@ const REFRESH_TARGET_WINS = new Set(['pipeline-backlog.yaml']);
|
|
|
139
139
|
// bug entry never reached the branch and `backlog ship --story <bug>` then errored "not found" →
|
|
140
140
|
// manual reconcile every bug-fix sprint. The ship CLAMP is untouched — evidence still verifies against
|
|
141
141
|
// the branch's own tree; this only delivers the backlog ENTRY the target already committed.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
let conflicted;
|
|
142
|
+
// The repo-relative paths git reports as unmerged (conflicted) in the current merge. [] on error.
|
|
143
|
+
function conflictedPaths(dir) {
|
|
145
144
|
try {
|
|
146
|
-
|
|
145
|
+
return git(dir, ['diff', '--name-only', '--diff-filter=U'])
|
|
147
146
|
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
148
147
|
} catch {
|
|
149
|
-
return
|
|
148
|
+
return [];
|
|
150
149
|
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveRefreshConflict(dir) {
|
|
153
|
+
if (!mergeInProgress(dir)) return false; // a non-conflict merge failure — the caller aborts
|
|
154
|
+
const conflicted = conflictedPaths(dir);
|
|
151
155
|
if (!conflicted.length || !conflicted.every((p) => REFRESH_TARGET_WINS.has(p))) return false;
|
|
152
156
|
for (const p of conflicted) {
|
|
153
157
|
git(dir, ['checkout', '--theirs', '--', p]);
|
|
@@ -181,7 +185,6 @@ function isMergedInto(root, branch, target) {
|
|
|
181
185
|
// was filed) so a resume never churns an empty commit; gated to inline --from-story fixes (basedOnStory)
|
|
182
186
|
// whose target genuinely advanced, mirroring the create-path guard.
|
|
183
187
|
function syncBacklogFromTargetOnResume(gitDir, root, story, target) {
|
|
184
|
-
const backlogFile = 'pipeline-backlog.yaml';
|
|
185
188
|
try {
|
|
186
189
|
if (!target || !branchExists(root, target)) return false;
|
|
187
190
|
const rec = readFlowRecord(gitDir, story) || readFlowRecord(root, story);
|
|
@@ -189,20 +192,33 @@ function syncBacklogFromTargetOnResume(gitDir, root, story, target) {
|
|
|
189
192
|
if (mergeInProgress(gitDir)) return false; // never touch a half-merged tree
|
|
190
193
|
const branch = rec.branch || currentBranch(gitDir);
|
|
191
194
|
if (!branch || isMergedInto(root, target, branch)) return false; // target not ahead → nothing to inherit
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
195
|
-
if (!staged.includes(backlogFile)) return false; // already current → no empty commit
|
|
196
|
-
git(gitDir, ['commit', '-m',
|
|
197
|
-
buildCommitMessage({ storyId: story, phase: 'sync',
|
|
198
|
-
summary: `inherit target backlog (filed bug entries) on resume — no code merge, pin intact (M-208)` }),
|
|
199
|
-
'--', backlogFile]);
|
|
200
|
-
return true;
|
|
195
|
+
return commitTargetBacklog(gitDir, story, target,
|
|
196
|
+
'inherit target backlog (filed bug entries) on resume — no code merge, pin intact (M-208)');
|
|
201
197
|
} catch {
|
|
202
198
|
return false; // best-effort: a sync failure leaves the branch as it was (degraded, not wedged)
|
|
203
199
|
}
|
|
204
200
|
}
|
|
205
201
|
|
|
202
|
+
// Deliver ONLY the target's canonical pipeline-backlog.yaml blob onto the CURRENT branch as a
|
|
203
|
+
// path-scoped chore commit (no code merge, HEAD/pin/code untouched). Returns true iff it committed a
|
|
204
|
+
// change (no-op + false when the branch backlog already matches the target — never an empty commit).
|
|
205
|
+
// Shared by the resume-path sync (M-208) and the create-path refresh-abort fallback (M-219): a refresh
|
|
206
|
+
// merge that ABORTS on a non-backlog co-conflict (a project coordination doc, the M-52 untrack reset, …)
|
|
207
|
+
// rolls the cleanly-mergeable bug entry back with the whole merge, so the disposition's filed entry never
|
|
208
|
+
// reaches the branch and `backlog ship --story <bug>` later errors "not found". The backlog is
|
|
209
|
+
// target-canonical (never legitimately branch-edited — target-wins on every merge), so deliver it
|
|
210
|
+
// directly. Caller must ensure no merge is in progress (a clean tree at base).
|
|
211
|
+
function commitTargetBacklog(gitDir, story, target, summary) {
|
|
212
|
+
const backlogFile = 'pipeline-backlog.yaml';
|
|
213
|
+
git(gitDir, ['checkout', target, '--', backlogFile]); // stage the target's canonical backlog blob
|
|
214
|
+
const staged = git(gitDir, ['diff', '--cached', '--name-only'])
|
|
215
|
+
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
216
|
+
if (!staged.includes(backlogFile)) return false; // already current → no empty commit
|
|
217
|
+
git(gitDir, ['commit', '-m',
|
|
218
|
+
buildCommitMessage({ storyId: story, phase: 'sync', summary }), '--', backlogFile]);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
206
222
|
// --- worktree mode (parallel story execution) -------------------------------------------------
|
|
207
223
|
// Each parallel story gets its own worktree under `<root>/<worktreeDir>/<storyId>` with the story
|
|
208
224
|
// branch checked out there; the MAIN checkout stays on the target branch and is the only place
|
|
@@ -586,6 +602,7 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
586
602
|
// a conflict aborts cleanly in the worktree, leaving the branch at its base. Needed for parallel:2.
|
|
587
603
|
let refreshedFromTarget = false;
|
|
588
604
|
let refreshConflict = false;
|
|
605
|
+
let backlogCleanlyMergeable = false;
|
|
589
606
|
if (creating && basedOnStory && branchExists(root, target) && !isMergedInto(root, target, branch)) {
|
|
590
607
|
try {
|
|
591
608
|
git(wtPath, ['merge', '--no-ff', '-m',
|
|
@@ -595,14 +612,27 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
595
612
|
// A conflict ONLY on target-authoritative shared state (the backlog with the filed bug entry)
|
|
596
613
|
// → resolve toward the target so the entry reaches this branch (M-65/M-69); any real-code
|
|
597
614
|
// conflict aborts cleanly (setupWorktree re-materializes the install if the sync removed it).
|
|
615
|
+
const conflicted = conflictedPaths(wtPath); // captured BEFORE resolve concludes/abort clears it
|
|
598
616
|
if (resolveRefreshConflict(wtPath)) {
|
|
599
617
|
refreshedFromTarget = true;
|
|
600
618
|
} else {
|
|
601
619
|
if (mergeInProgress(wtPath)) git(wtPath, ['merge', '--abort']);
|
|
602
620
|
refreshConflict = true;
|
|
621
|
+
backlogCleanlyMergeable = conflicted.length > 0 && !conflicted.some((p) => REFRESH_TARGET_WINS.has(p));
|
|
603
622
|
}
|
|
604
623
|
}
|
|
605
624
|
}
|
|
625
|
+
// M-219 (worktree variant): the refresh aborted on a non-backlog co-conflict while the backlog itself
|
|
626
|
+
// would have merged clean → deliver ONLY the target backlog blob into the worktree so the filed bug
|
|
627
|
+
// entry is present for `backlog ship` (code stays at base; conflict still surfaced via refreshConflict).
|
|
628
|
+
// Runs in wtPath BEFORE setupWorktree re-materializes the install — the tree is clean at base.
|
|
629
|
+
let backlogDelivered = false;
|
|
630
|
+
if (creating && basedOnStory && refreshConflict && !refreshedFromTarget && backlogCleanlyMergeable && branchExists(root, target)) {
|
|
631
|
+
try {
|
|
632
|
+
backlogDelivered = commitTargetBacklog(wtPath, story, target,
|
|
633
|
+
'inherit target backlog (filed bug entries) after refresh aborted on a co-conflict — no code merge (M-219)');
|
|
634
|
+
} catch { /* best-effort: leave the branch at its base */ }
|
|
635
|
+
}
|
|
606
636
|
const setup = setupWorktree(root, wtPath, setupCommands);
|
|
607
637
|
if (creating) {
|
|
608
638
|
writeFlowRecord(wtPath, story, {
|
|
@@ -622,6 +652,7 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
622
652
|
...(orphanMovedTo ? { orphanMovedTo, warning: `orphaned worktree directory moved aside to ${orphanMovedTo} (crashed run; inspect/delete it manually)` } : {}),
|
|
623
653
|
...(refreshedFromTarget ? { refreshedFromTarget: true } : {}),
|
|
624
654
|
...(refreshConflict ? { refreshConflict: true } : {}),
|
|
655
|
+
...(backlogDelivered ? { backlogDelivered: true } : {}),
|
|
625
656
|
...setup,
|
|
626
657
|
};
|
|
627
658
|
}
|
|
@@ -650,6 +681,7 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
650
681
|
// than wedging a half-merge that would hard-stop every later commit in this checkout.
|
|
651
682
|
let refreshedFromTarget = false;
|
|
652
683
|
let refreshConflict = false;
|
|
684
|
+
let backlogCleanlyMergeable = false;
|
|
653
685
|
if (basedOnStory && branchExists(root, target) && !isMergedInto(root, target, branch)) {
|
|
654
686
|
// Capture whether the vendored install is present BEFORE the sync, so the hazard guard below fires
|
|
655
687
|
// ONLY when the merge actually REMOVED it — not when a repo simply never had a materialized install
|
|
@@ -664,11 +696,17 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
664
696
|
// bug entry into) is resolved toward the target so the entry reaches this branch (M-65/M-69); a
|
|
665
697
|
// conflict touching any real code aborts cleanly as before (degraded, but no worse). The M-52
|
|
666
698
|
// framework-deletion guard below still runs on the concluded merge.
|
|
699
|
+
const conflicted = conflictedPaths(root); // captured BEFORE resolve concludes/abort clears it
|
|
667
700
|
if (resolveRefreshConflict(root)) {
|
|
668
701
|
refreshedFromTarget = true;
|
|
669
702
|
} else {
|
|
670
703
|
if (mergeInProgress(root)) git(root, ['merge', '--abort']);
|
|
671
704
|
refreshConflict = true;
|
|
705
|
+
// M-219: deliver the backlog entry below ONLY when the backlog itself did NOT conflict — i.e. the
|
|
706
|
+
// entry would have merged clean but an UNRELATED file's conflict (a project coordination doc, …)
|
|
707
|
+
// aborted the whole merge. A backlog+code divergence is a genuinely diverged branch (recut) —
|
|
708
|
+
// leave it untouched so the negative guard "never swallows a code conflict" holds.
|
|
709
|
+
backlogCleanlyMergeable = conflicted.length > 0 && !conflicted.some((p) => REFRESH_TARGET_WINS.has(p));
|
|
672
710
|
}
|
|
673
711
|
}
|
|
674
712
|
// M-52 transition hazard: when `target` UNTRACKS the vendored framework (the post-untrack install
|
|
@@ -690,6 +728,19 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
690
728
|
);
|
|
691
729
|
}
|
|
692
730
|
}
|
|
731
|
+
// M-219: a refresh that ABORTED (a non-backlog co-conflict — e.g. a project coordination doc whose
|
|
732
|
+
// conflict makes resolveRefreshConflict bail — or the M-52 untrack reset above) rolled the
|
|
733
|
+
// cleanly-mergeable target backlog back with the whole merge, so the disposition's filed bug entry
|
|
734
|
+
// never reached this branch and `backlog ship --story <bug>` would error "not found" (3.5/3.7-BUG-001).
|
|
735
|
+
// The tree is clean at base here (merge --abort / reset --hard ran), so deliver ONLY the target
|
|
736
|
+
// backlog blob directly — code stays at base (the conflict is still surfaced via refreshConflict).
|
|
737
|
+
let backlogDelivered = false;
|
|
738
|
+
if (basedOnStory && refreshConflict && !refreshedFromTarget && backlogCleanlyMergeable && branchExists(root, target)) {
|
|
739
|
+
try {
|
|
740
|
+
backlogDelivered = commitTargetBacklog(root, story, target,
|
|
741
|
+
'inherit target backlog (filed bug entries) after refresh aborted on a co-conflict — no code merge (M-219)');
|
|
742
|
+
} catch { /* best-effort: leave the branch at its base */ }
|
|
743
|
+
}
|
|
693
744
|
writeFlowRecord(root, story, {
|
|
694
745
|
schema: 1,
|
|
695
746
|
kind: 'git-flow',
|
|
@@ -702,7 +753,8 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
702
753
|
});
|
|
703
754
|
return { branch, base, target, created: true, resumed: false, snapshotSha,
|
|
704
755
|
...(refreshedFromTarget ? { refreshedFromTarget: true } : {}),
|
|
705
|
-
...(refreshConflict ? { refreshConflict: true } : {})
|
|
756
|
+
...(refreshConflict ? { refreshConflict: true } : {}),
|
|
757
|
+
...(backlogDelivered ? { backlogDelivered: true } : {}) };
|
|
706
758
|
}
|
|
707
759
|
|
|
708
760
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Shared guard against qa-test-spec.manifest.json schema drift (M-223).
|
|
2
|
+
//
|
|
3
|
+
// The canonical manifest carries its spec'd cases under `cases` (pipeline/schemas/qa-spec-manifest.schema.json,
|
|
4
|
+
// `required: ["schema","story","cases"]`). QA-A is an LLM and can drift the key — e.g. emit `testCases` instead
|
|
5
|
+
// of `cases`. The downstream readers (`trace check`, `check-spec-conformance`) then silently see ZERO cases:
|
|
6
|
+
// trace check reports every AC uncovered (a phantom `uncoveredAcs` coverage gap that reads as a real defect),
|
|
7
|
+
// and spec-conformance takes its empty-manifest no-op PASS (masking the drift entirely). Both cost a confused
|
|
8
|
+
// diagnosis + a re-run (3.9, 2026-06-28). The schema existed but was never enforced at ingestion.
|
|
9
|
+
//
|
|
10
|
+
// This detects the specific, recoverable drift — `cases` absent/empty WHILE a case-like array (objects with a
|
|
11
|
+
// string `id`) sits under another top-level key — and returns an actionable message so the gate fails LOUD with
|
|
12
|
+
// the real reason instead of a phantom coverage gap or a vacuous pass. It deliberately does NOT fire for a
|
|
13
|
+
// genuinely empty/absent `cases` (a pre-Phase manifest or a story with no spec'd cases yet) — that stays a
|
|
14
|
+
// legitimate no-op, so this adds a clear failure ONLY where the manifest is populated-but-misnamed. Returns the
|
|
15
|
+
// message string, or null when there is no drift.
|
|
16
|
+
export function detectManifestCaseDrift(manifest) {
|
|
17
|
+
if (!manifest || typeof manifest !== 'object') return null;
|
|
18
|
+
if (Array.isArray(manifest.cases) && manifest.cases.length > 0) return null; // canonical + populated
|
|
19
|
+
|
|
20
|
+
const caseLike = (arr) => Array.isArray(arr) && arr.length > 0
|
|
21
|
+
&& arr.every((c) => c && typeof c === 'object' && typeof c.id === 'string' && c.id.length > 0);
|
|
22
|
+
const aliases = Object.keys(manifest).filter((k) => k !== 'cases' && caseLike(manifest[k]));
|
|
23
|
+
if (!aliases.length) return null; // genuinely empty/absent — legitimate no-op, not drift
|
|
24
|
+
|
|
25
|
+
const k = aliases[0];
|
|
26
|
+
const n = manifest[k].length;
|
|
27
|
+
return `no populated \`cases\` array but found ${n} case-like entr${n === 1 ? 'y' : 'ies'} under \`${k}\` `
|
|
28
|
+
+ `— qa-test-spec.manifest.json schema drift (the canonical key is \`cases\`). Re-author the manifest with `
|
|
29
|
+
+ `the cases under \`cases\` (see pipeline/schemas/qa-spec-manifest.schema.json).`;
|
|
30
|
+
}
|