mandrel 2.56.0 → 2.58.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/.agents/agents/plan-critic.md +13 -18
- package/.agents/agents/story-worker.md +25 -33
- package/.agents/docs/agentrc-reference.json +0 -30
- package/.agents/docs/configuration.md +8 -28
- package/.agents/docs/execution-reference.md +5 -5
- package/.agents/docs/quality-gates.md +8 -7
- package/.agents/instructions.md +9 -10
- package/.agents/schemas/agentrc.schema.json +9 -185
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +107 -17
- package/.agents/scripts/ceremony-derive.js +191 -0
- package/.agents/scripts/check-context-budget.js +28 -33
- package/.agents/scripts/check-cyclomatic.js +4 -3
- package/.agents/scripts/deliver-light.js +31 -94
- package/.agents/scripts/evidence-gate.js +17 -1
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
- package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
- package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
- package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
- package/.agents/scripts/lib/close-validation/gates.js +52 -1
- package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
- package/.agents/scripts/lib/config/delivery-routing.js +7 -33
- package/.agents/scripts/lib/config/explain.js +0 -19
- package/.agents/scripts/lib/config/limits.js +18 -78
- package/.agents/scripts/lib/config/quality.js +6 -3
- package/.agents/scripts/lib/config/runners.js +3 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
- package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
- package/.agents/scripts/lib/config-settings-schema.js +16 -143
- package/.agents/scripts/lib/crap-engine.js +35 -4
- package/.agents/scripts/lib/crap-utils.js +17 -1
- package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
- package/.agents/scripts/lib/orchestration/code-review.js +7 -3
- package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
- package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
- package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
- package/.agents/scripts/lib/orchestration/pinned-identifier-lint.js +137 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +189 -387
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
- package/.agents/scripts/lib/orchestration/plan-persist/acceptance-handle-repair.js +107 -0
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +305 -0
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +138 -170
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +128 -297
- package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +36 -135
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
- package/.agents/scripts/lib/orchestration/review-base-ref.js +138 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +37 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +6 -1
- package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
- package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
- package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
- package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
- package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
- package/.agents/scripts/lib/story-body/story-body.js +54 -240
- package/.agents/scripts/lib/templates/decomposer-prompts.js +133 -121
- package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
- package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
- package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
- package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
- package/.agents/scripts/lib/test-run-credit.js +277 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
- package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
- package/.agents/scripts/lib/workers/crap-worker.js +32 -41
- package/.agents/scripts/plan-context.js +7 -9
- package/.agents/scripts/plan-critics.js +28 -54
- package/.agents/scripts/plan-persist.js +25 -68
- package/.agents/scripts/quality-preview.js +51 -0
- package/.agents/scripts/run-tests.js +12 -0
- package/.agents/scripts/stories-wave-tick.js +23 -45
- package/.agents/scripts/test-isolate.js +13 -180
- package/.agents/scripts/update-coverage-baseline.js +25 -70
- package/.agents/scripts/update-crap-baseline.js +19 -123
- package/.agents/skills/core/scope-triage/SKILL.md +3 -3
- package/.agents/workflows/audit-clean-code.md +4 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
- package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
- package/.agents/workflows/helpers/code-review.md +2 -3
- package/.agents/workflows/helpers/deliver-digest.md +46 -55
- package/.agents/workflows/helpers/deliver-light.md +40 -105
- package/.agents/workflows/helpers/deliver-reference.md +1 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +54 -55
- package/.agents/workflows/helpers/deliver-story.md +10 -13
- package/.agents/workflows/helpers/plan-reference.md +163 -221
- package/.agents/workflows/mandrel-plan.md +31 -40
- package/.agents/workflows/memory-consolidate.md +9 -13
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/registry.js +98 -2
- package/lib/migrations/index.js +4 -0
- package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
- package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/framework-version.js +0 -39
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
- package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
- package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
- package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* changes-repair.js — repair-before-judging for `changes[]` entries
|
|
3
|
+
* (Story #5312).
|
|
4
|
+
*
|
|
5
|
+
* The `{ path, assumption }` object shape is a deterministic, mechanically
|
|
6
|
+
* derivable formality. The validator already knew how to salvage a path from
|
|
7
|
+
* a plain-string bullet (`suggestPathEntryFix`) — then rejected the plan
|
|
8
|
+
* anyway and charged the author a full re-drafting round to paste that exact
|
|
9
|
+
* object back. Story #5005 made the same call for the verify-tier suffix and
|
|
10
|
+
* repaired it instead; the suffix is gone now, and the repair moves to the
|
|
11
|
+
* one formality left: this module applies the inference the validator
|
|
12
|
+
* trusts, so the dry-run rewrites and **reports** each repair rather than
|
|
13
|
+
* refusing on it.
|
|
14
|
+
*
|
|
15
|
+
* Two shapes are repaired, on both authoring surfaces (a structured object
|
|
16
|
+
* body's `changes[]` array and a serialized string body's `## Changes`
|
|
17
|
+
* section):
|
|
18
|
+
*
|
|
19
|
+
* - a **plain-string bullet** (`src/app.js`, `` `src/app.js` ``,
|
|
20
|
+
* `src/app.js — adds the route`) becomes `{ path, assumption }`, the
|
|
21
|
+
* assumption resolved by probing the base branch — a path present at
|
|
22
|
+
* base is a `refactors-existing`, an absent one a `creates`;
|
|
23
|
+
* - a **trailing parenthetical** on a path (`src/app.js (new)`,
|
|
24
|
+
* `` `src/app.js (creates)` — refactors-existing ``) is stripped; an
|
|
25
|
+
* authored assumption is kept, an absent one probed as above.
|
|
26
|
+
*
|
|
27
|
+
* A string nothing path-shaped can be salvaged from is left untouched and
|
|
28
|
+
* still fails the body-shape validator — that is the one `changes[]` failure
|
|
29
|
+
* only the author can resolve.
|
|
30
|
+
*
|
|
31
|
+
* It lives beside the validator rather than inside it because the validator's
|
|
32
|
+
* job is to *judge*: mixing a mutating repair pass into a module of pure
|
|
33
|
+
* collectors muddies both. `persist-helpers.js#validateTickets` calls this
|
|
34
|
+
* first, then the validators.
|
|
35
|
+
*
|
|
36
|
+
* @module lib/orchestration/plan-persist/changes-repair
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { FILE_ASSUMPTION_VALUES } from '../file-assumption-enum.js';
|
|
40
|
+
|
|
41
|
+
/** The `## Changes` heading (either level the parser accepts). */
|
|
42
|
+
const CHANGES_HEADING_RE = /^#{2,3}\s+Changes\s*$/i;
|
|
43
|
+
|
|
44
|
+
/** Any heading — the end of the `## Changes` section. */
|
|
45
|
+
const ANY_HEADING_RE = /^#{1,6}\s+\S/;
|
|
46
|
+
|
|
47
|
+
/** A trailing `(…)` on a path token. */
|
|
48
|
+
const TRAILING_PARENTHETICAL_RE = /\s*\([^)]*\)\s*$/;
|
|
49
|
+
|
|
50
|
+
/** The humanized canonical bullet: `` `path` — assumption ``. */
|
|
51
|
+
const HUMANIZED_RE = /^`([^`]+)`\s+—\s+(\S+)$/;
|
|
52
|
+
|
|
53
|
+
// A token that looks like a file path / glob / module id: it carries a `/` or a
|
|
54
|
+
// `.`-separated segment. Deliberately loose — a false positive only produces a
|
|
55
|
+
// `{ path, assumption }` entry the base-branch probes then judge.
|
|
56
|
+
const PATH_LIKE_RE = /^[\w@*-]*[/.][\w@./*-]+$/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Strip a trailing parenthetical from a path token, reporting whether one
|
|
60
|
+
* was present.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} raw
|
|
63
|
+
* @returns {{ path: string, stripped: boolean }}
|
|
64
|
+
*/
|
|
65
|
+
function stripParenthetical(raw) {
|
|
66
|
+
const path = raw.replace(TRAILING_PARENTHETICAL_RE, '').trim();
|
|
67
|
+
return { path, stripped: path !== raw.trim() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Salvage the path token from a plain-string bullet: drop a leading list
|
|
72
|
+
* marker, take the segment before any humanized ` — ` tail, peel quotes and
|
|
73
|
+
* backticks, strip a trailing parenthetical. Returns `null` when nothing
|
|
74
|
+
* path-shaped survives.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} raw
|
|
77
|
+
* @returns {string|null}
|
|
78
|
+
*/
|
|
79
|
+
function salvagePath(raw) {
|
|
80
|
+
let s = raw
|
|
81
|
+
.trim()
|
|
82
|
+
.replace(/^[-*]\s+/, '')
|
|
83
|
+
.trim();
|
|
84
|
+
s = s.split('—')[0].trim();
|
|
85
|
+
s = s
|
|
86
|
+
.replace(/^[`'"]+/, '')
|
|
87
|
+
.replace(/[`'"]+$/, '')
|
|
88
|
+
.trim();
|
|
89
|
+
s = stripParenthetical(s).path;
|
|
90
|
+
return s !== '' && PATH_LIKE_RE.test(s) ? s : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the assumption for a path with none authored: present at base →
|
|
95
|
+
* `refactors-existing`, absent → `creates`.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} path
|
|
98
|
+
* @param {(path: string) => boolean} existsAtBase
|
|
99
|
+
* @returns {'refactors-existing'|'creates'}
|
|
100
|
+
*/
|
|
101
|
+
function probeAssumption(path, existsAtBase) {
|
|
102
|
+
return existsAtBase(path) ? 'refactors-existing' : 'creates';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Repair one structured `changes[]` item. Returns the corrected entry and a
|
|
107
|
+
* repair record, or `null` when the item needs no repair (or cannot be
|
|
108
|
+
* repaired).
|
|
109
|
+
*
|
|
110
|
+
* @param {unknown} item
|
|
111
|
+
* @param {(path: string) => boolean} existsAtBase
|
|
112
|
+
* @returns {{ entry: { path: string, assumption: string }, repair: object }|null}
|
|
113
|
+
*/
|
|
114
|
+
function repairStructuredItem(item, existsAtBase) {
|
|
115
|
+
if (typeof item === 'string') {
|
|
116
|
+
const path = salvagePath(item);
|
|
117
|
+
if (path === null) return null;
|
|
118
|
+
const assumption = probeAssumption(path, existsAtBase);
|
|
119
|
+
return {
|
|
120
|
+
entry: { path, assumption },
|
|
121
|
+
repair: { from: item, path, assumption, reason: 'plain-string' },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (item === null || typeof item !== 'object') return null;
|
|
125
|
+
if (typeof item.path !== 'string') return null;
|
|
126
|
+
const { path, stripped } = stripParenthetical(item.path);
|
|
127
|
+
const authored = FILE_ASSUMPTION_VALUES.includes(item.assumption);
|
|
128
|
+
if (!stripped && authored) return null;
|
|
129
|
+
if (path === '') return null;
|
|
130
|
+
const assumption = authored
|
|
131
|
+
? item.assumption
|
|
132
|
+
: probeAssumption(path, existsAtBase);
|
|
133
|
+
return {
|
|
134
|
+
entry: { path, assumption },
|
|
135
|
+
repair: {
|
|
136
|
+
from: item.path,
|
|
137
|
+
path,
|
|
138
|
+
assumption,
|
|
139
|
+
reason: stripped ? 'trailing-parenthetical' : 'missing-assumption',
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Repair one line of a serialized `## Changes` section. Returns the rewritten
|
|
146
|
+
* line and a repair record, or `null` when the line is already canonical or
|
|
147
|
+
* cannot be repaired.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} line
|
|
150
|
+
* @param {(path: string) => boolean} existsAtBase
|
|
151
|
+
* @returns {{ line: string, repair: object }|null}
|
|
152
|
+
*/
|
|
153
|
+
function repairSectionLine(line, existsAtBase) {
|
|
154
|
+
const marker = line.match(/^(\s*[-*]\s+)/);
|
|
155
|
+
if (!marker) return null;
|
|
156
|
+
const content = line.slice(marker[1].length).trim();
|
|
157
|
+
if (content === '') return null;
|
|
158
|
+
const humanized = content.match(HUMANIZED_RE);
|
|
159
|
+
if (humanized) {
|
|
160
|
+
const { path, stripped } = stripParenthetical(humanized[1]);
|
|
161
|
+
const authored = FILE_ASSUMPTION_VALUES.includes(humanized[2]);
|
|
162
|
+
if (!stripped && authored) return null;
|
|
163
|
+
if (path === '') return null;
|
|
164
|
+
const assumption = authored
|
|
165
|
+
? humanized[2]
|
|
166
|
+
: probeAssumption(path, existsAtBase);
|
|
167
|
+
return {
|
|
168
|
+
line: `${marker[1]}\`${path}\` — ${assumption}`,
|
|
169
|
+
repair: {
|
|
170
|
+
from: content,
|
|
171
|
+
path,
|
|
172
|
+
assumption,
|
|
173
|
+
reason: stripped ? 'trailing-parenthetical' : 'missing-assumption',
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (content.startsWith('{')) {
|
|
178
|
+
let parsed;
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(content);
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const repaired = repairStructuredItem(parsed, existsAtBase);
|
|
185
|
+
if (repaired === null) return null;
|
|
186
|
+
return {
|
|
187
|
+
line: `${marker[1]}\`${repaired.entry.path}\` — ${repaired.entry.assumption}`,
|
|
188
|
+
repair: repaired.repair,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const path = salvagePath(content);
|
|
192
|
+
if (path === null) return null;
|
|
193
|
+
const assumption = probeAssumption(path, existsAtBase);
|
|
194
|
+
return {
|
|
195
|
+
line: `${marker[1]}\`${path}\` — ${assumption}`,
|
|
196
|
+
repair: { from: content, path, assumption, reason: 'plain-string' },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Repair the `## Changes` section of a serialized body in place. Only lines
|
|
202
|
+
* between the heading and the next heading are touched; the rest of the
|
|
203
|
+
* body is byte-identical.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} body
|
|
206
|
+
* @param {(path: string) => boolean} existsAtBase
|
|
207
|
+
* @returns {{ body: string, repairs: object[] }}
|
|
208
|
+
*/
|
|
209
|
+
function repairSerializedBody(body, existsAtBase) {
|
|
210
|
+
const lines = body.split('\n');
|
|
211
|
+
const repairs = [];
|
|
212
|
+
let inChanges = false;
|
|
213
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
214
|
+
const line = lines[i];
|
|
215
|
+
if (CHANGES_HEADING_RE.test(line.trim())) {
|
|
216
|
+
inChanges = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!inChanges) continue;
|
|
220
|
+
if (ANY_HEADING_RE.test(line) || line.trim().startsWith('---')) {
|
|
221
|
+
inChanges = false;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const repaired = repairSectionLine(line, existsAtBase);
|
|
225
|
+
if (repaired === null) continue;
|
|
226
|
+
lines[i] = repaired.line;
|
|
227
|
+
repairs.push(repaired.repair);
|
|
228
|
+
}
|
|
229
|
+
return { body: lines.join('\n'), repairs };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Repair the `changes[]` of one ticket, on whichever surface carries it.
|
|
234
|
+
*
|
|
235
|
+
* @param {object} ticket Mutated in place.
|
|
236
|
+
* @param {(path: string) => boolean} existsAtBase
|
|
237
|
+
* @returns {object[]} The repairs applied to this ticket.
|
|
238
|
+
*/
|
|
239
|
+
function repairTicket(ticket, existsAtBase) {
|
|
240
|
+
const body = ticket.body;
|
|
241
|
+
if (typeof body === 'string') {
|
|
242
|
+
const { body: next, repairs } = repairSerializedBody(body, existsAtBase);
|
|
243
|
+
if (repairs.length > 0) ticket.body = next;
|
|
244
|
+
return repairs;
|
|
245
|
+
}
|
|
246
|
+
const changes =
|
|
247
|
+
body && typeof body === 'object' && Array.isArray(body.changes)
|
|
248
|
+
? body.changes
|
|
249
|
+
: Array.isArray(ticket.changes)
|
|
250
|
+
? ticket.changes
|
|
251
|
+
: null;
|
|
252
|
+
if (changes === null) return [];
|
|
253
|
+
const repairs = [];
|
|
254
|
+
for (let i = 0; i < changes.length; i += 1) {
|
|
255
|
+
const repaired = repairStructuredItem(changes[i], existsAtBase);
|
|
256
|
+
if (repaired === null) continue;
|
|
257
|
+
changes[i] = repaired.entry;
|
|
258
|
+
repairs.push(repaired.repair);
|
|
259
|
+
}
|
|
260
|
+
return repairs;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Render one `changes[]` repair as the dry-run line the operator reads.
|
|
265
|
+
*
|
|
266
|
+
* The dry-run's repair list is mixed — an `acceptance[]` handle strip is
|
|
267
|
+
* reported on it too — but the dispatch across kinds lives in
|
|
268
|
+
* [`acceptance-handle-repair.js`](acceptance-handle-repair.js)`#renderRepair`,
|
|
269
|
+
* which delegates here for this kind. Each producer owns its own line.
|
|
270
|
+
*
|
|
271
|
+
* @param {{ slug: string, from: string, path: string, assumption: string, reason: string }} repair
|
|
272
|
+
* @returns {string}
|
|
273
|
+
*/
|
|
274
|
+
export function renderChangeRepair({ slug, from, path, assumption, reason }) {
|
|
275
|
+
const why =
|
|
276
|
+
reason === 'plain-string'
|
|
277
|
+
? 'plain-string bullet'
|
|
278
|
+
: reason === 'trailing-parenthetical'
|
|
279
|
+
? 'trailing parenthetical'
|
|
280
|
+
: 'missing assumption';
|
|
281
|
+
return `Story "${slug}": changes[] entry "${from}" (${why}) repaired to {"path":"${path}","assumption":"${assumption}"} by probing base.`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Rewrite every repairable `changes[]` entry across the draft, probing the
|
|
286
|
+
* base branch for the assumption where none was authored. Mutates `tickets`
|
|
287
|
+
* in place (the persist pipeline threads this same array on to assembly)
|
|
288
|
+
* and returns the repairs, each tagged with the Story's slug. Total — a
|
|
289
|
+
* non-array argument and non-Story tickets are no-ops.
|
|
290
|
+
*
|
|
291
|
+
* @param {object[]} tickets
|
|
292
|
+
* @param {{ existsAtBase: (path: string) => boolean }} args
|
|
293
|
+
* @returns {Array<{ slug: string, from: string, path: string, assumption: string, reason: string }>}
|
|
294
|
+
*/
|
|
295
|
+
export function repairChangeEntries(tickets, { existsAtBase }) {
|
|
296
|
+
const repairs = [];
|
|
297
|
+
for (const ticket of Array.isArray(tickets) ? tickets : []) {
|
|
298
|
+
if (!ticket || ticket.type !== 'story') continue;
|
|
299
|
+
const slug = ticket.slug ?? ticket.title ?? '<unknown>';
|
|
300
|
+
for (const repair of repairTicket(ticket, existsAtBase)) {
|
|
301
|
+
repairs.push({ slug, ...repair });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return repairs;
|
|
305
|
+
}
|
|
@@ -3,224 +3,192 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Exports:
|
|
5
5
|
* - `resolveBaseBranchRef(config)` — the one place the persist gates learn
|
|
6
|
-
* which
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* which branch name the operator configured.
|
|
7
|
+
*
|
|
8
|
+
* The ref the probes read is resolved per checkout (`resolveProbeRef`): the
|
|
9
|
+
* local branch when it exists, else its `origin/` tracking ref (a PR
|
|
10
|
+
* checkout on CI has no local `main`), else nothing — a shallow checkout
|
|
11
|
+
* with no base at all skips the probes instead of reading every path as
|
|
12
|
+
* absent.
|
|
13
|
+
* - `validateTickets(tickets, config, opts)` — normalises the authored
|
|
14
|
+
* `acceptance[]` handles and repairs the mechanical `changes[]`
|
|
15
|
+
* formalities against the base branch, then runs the cross-link,
|
|
16
|
+
* freshness, and task-body validators in one pass.
|
|
17
|
+
*
|
|
18
|
+
* Story #5312 deleted the fan-out probe that lived here: the delete
|
|
19
|
+
* blast-radius count never refused a real plan, and the `git grep` it paid
|
|
20
|
+
* for on every persist bought a warning nobody acted on.
|
|
13
21
|
*
|
|
14
22
|
* @module lib/orchestration/plan-persist/persist-helpers
|
|
15
23
|
*/
|
|
16
24
|
|
|
17
|
-
import posix from 'node:path/posix';
|
|
18
25
|
import { gitSpawn } from '../../git-utils.js';
|
|
19
26
|
import { validateTaskBodies } from '../task-body-validator.js';
|
|
20
27
|
import { validateAndNormalizeTickets } from '../ticket-validator.js';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
28
|
+
import { normalizeAcceptanceHandles } from './acceptance-handle-repair.js';
|
|
29
|
+
import { repairChangeEntries } from './changes-repair.js';
|
|
23
30
|
|
|
24
31
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
32
|
+
* Resolve the ref the persist gates probe against.
|
|
33
|
+
*
|
|
34
|
+
* The canonical resolved config carries the base branch at
|
|
35
|
+
* `project.baseBranch` (`lib/config-resolver.js` defaults it to `main`).
|
|
36
|
+
* This helper used to read `config.baseBranch` — a key the resolver never
|
|
37
|
+
* produces — so every freshness / file-assumption probe silently targeted
|
|
38
|
+
* the literal `main` regardless of configuration. Benign in a repo whose
|
|
39
|
+
* base branch *is* `main`; wrong for any consumer that configured something
|
|
40
|
+
* else (Story #4541).
|
|
41
|
+
*
|
|
42
|
+
* The flat `config.baseBranch` fallback is retained for the legacy
|
|
43
|
+
* `settings`-bag callers that pass `{ baseBranch, paths, planning }`.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} [config] Resolved config, or a legacy settings bag.
|
|
46
|
+
* @returns {string}
|
|
27
47
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
/** Every quoted string on a candidate line — the specifier lives in one. */
|
|
31
|
-
const QUOTED_RE = /['"]([^'"\n]+)['"]/g;
|
|
32
|
-
|
|
33
|
-
function escapeRegExp(value) {
|
|
34
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
48
|
+
export function resolveBaseBranchRef(config) {
|
|
49
|
+
return config?.project?.baseBranch ?? config?.baseBranch ?? 'main';
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* This is a *candidate* net only. It is deliberately generous because the
|
|
44
|
-
* resolution pass below re-checks every hit against the real path; a tail
|
|
45
|
-
* that over-matches costs one extra resolve, a tail that under-matches
|
|
46
|
-
* loses a genuine importer.
|
|
53
|
+
* Does `ref` name a commit in the repo at `cwd`?
|
|
54
|
+
*
|
|
55
|
+
* @param {string} ref
|
|
56
|
+
* @param {string} cwd
|
|
57
|
+
* @returns {boolean}
|
|
47
58
|
*/
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (stem === 'index') {
|
|
54
|
-
const dir = posix.basename(posix.dirname(path));
|
|
55
|
-
if (dir && dir !== '.') tails.add(dir);
|
|
56
|
-
}
|
|
57
|
-
return [...tails].filter((t) => t.length > 0);
|
|
59
|
+
function refResolves(ref, cwd) {
|
|
60
|
+
return (
|
|
61
|
+
gitSpawn(cwd, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`)
|
|
62
|
+
.status === 0
|
|
63
|
+
);
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* `
|
|
67
|
+
* Resolve the configured base branch to a ref the footprint probes can read
|
|
68
|
+
* in this checkout, or `null` when none exists.
|
|
69
|
+
*
|
|
70
|
+
* A developer checkout carries a local `main`; a CI pull-request checkout
|
|
71
|
+
* (`actions/checkout` at the merge ref, detached) carries only
|
|
72
|
+
* `origin/main`; a shallow smoke checkout carries neither. Probing the
|
|
73
|
+
* bare branch name in the last two answers "absent" for every path, which
|
|
74
|
+
* turns each bare-path repair into a `creates` and every declared path into
|
|
75
|
+
* a stale reference. So: the local branch when it resolves, else its
|
|
76
|
+
* `origin/` tracking ref when that does, else `null` — the caller then
|
|
77
|
+
* skips the probes and says so, rather than reporting the tree as missing.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ baseBranch: string, cwd?: string }} opts
|
|
80
|
+
* @returns {string|null}
|
|
65
81
|
*/
|
|
66
|
-
function
|
|
67
|
-
const
|
|
68
|
-
|
|
82
|
+
function resolveProbeRef({ baseBranch, cwd }) {
|
|
83
|
+
const repo = cwd ?? process.cwd();
|
|
84
|
+
const candidates = [baseBranch, `origin/${baseBranch}`];
|
|
85
|
+
return candidates.find((ref) => refResolves(ref, repo)) ?? null;
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
/**
|
|
72
|
-
*
|
|
73
|
-
* path
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* deliberately does not read. Mandrel's own internal imports are all
|
|
80
|
-
* relative. Under-counting is the *quiet* failure direction — it argues for
|
|
81
|
-
* a deletion rather than against one — so if alias-importing consumers
|
|
82
|
-
* appear, this is the place to teach the probe their resolver.
|
|
89
|
+
* Default git probe: returns true when `path` exists at `ref` in the cwd repo.
|
|
90
|
+
* `git cat-file -e <ref>:<path>` is the standard low-cost existence check —
|
|
91
|
+
* the same probe the validator's own gates run, so the repair pass and the
|
|
92
|
+
* gates cannot disagree about what is at base.
|
|
93
|
+
*
|
|
94
|
+
* @param {{ baseBranchRef: string, path: string, cwd?: string }} opts
|
|
95
|
+
* @returns {boolean}
|
|
83
96
|
*/
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
function defaultGitRunner({ baseBranchRef, path, cwd }) {
|
|
98
|
+
const result = gitSpawn(
|
|
99
|
+
cwd ?? process.cwd(),
|
|
100
|
+
'cat-file',
|
|
101
|
+
'-e',
|
|
102
|
+
`${baseBranchRef}:${path}`,
|
|
88
103
|
);
|
|
89
|
-
|
|
90
|
-
if (`${resolved}${ext}` === deletedPath) return true;
|
|
91
|
-
if (ext && `${resolved}/index${ext}` === deletedPath) return true;
|
|
92
|
-
}
|
|
93
|
-
return false;
|
|
104
|
+
return result.status === 0;
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* gate's own audit trail would then argue for the deletion it is meant to
|
|
104
|
-
* question (Story #4547).
|
|
108
|
+
* The `existsAtBase` predicate the `changes[]` repair pass probes with. With
|
|
109
|
+
* no base to read, a bare bullet is taken as the in-place edit it almost
|
|
110
|
+
* always is.
|
|
111
|
+
*
|
|
112
|
+
* @param {{ baseBranchRef: string|null, cwd?: string, gitRunner?: Function }} opts
|
|
113
|
+
* @returns {(path: string) => boolean}
|
|
105
114
|
*/
|
|
106
|
-
function
|
|
107
|
-
if (
|
|
108
|
-
|
|
115
|
+
function makeExistsAtBase({ baseBranchRef, cwd, gitRunner }) {
|
|
116
|
+
if (baseBranchRef === null) return () => true;
|
|
117
|
+
const runner = gitRunner ?? defaultGitRunner;
|
|
118
|
+
return (path) => Boolean(runner({ baseBranchRef, path, cwd }));
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
/**
|
|
112
|
-
*
|
|
122
|
+
* The one warning a checkout with no readable base leaves on the dry-run.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} baseBranch
|
|
125
|
+
* @param {string|null} baseBranchRef
|
|
126
|
+
* @returns {string[]}
|
|
113
127
|
*/
|
|
114
|
-
function
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const afterPath = rest.slice(pathEnd + 1);
|
|
122
|
-
const lineEnd = afterPath.indexOf(':');
|
|
123
|
-
if (lineEnd === -1) return null;
|
|
124
|
-
return { path, text: afterPath.slice(lineEnd + 1) };
|
|
128
|
+
function probeSkipWarnings(baseBranch, baseBranchRef) {
|
|
129
|
+
if (baseBranchRef !== null) return [];
|
|
130
|
+
return [
|
|
131
|
+
`base branch ${baseBranch} does not resolve in this checkout (tried ` +
|
|
132
|
+
`${baseBranch} and origin/${baseBranch}) — footprint probes skipped; ` +
|
|
133
|
+
'the plan summary reports its references as ambiguous, not stale.',
|
|
134
|
+
];
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* Two-stage, because accuracy and cost pull in opposite directions:
|
|
133
|
-
*
|
|
134
|
-
* 1. `git grep -n -E` narrows the tree to lines whose quoted import /
|
|
135
|
-
* require specifier could name the module (final-segment match).
|
|
136
|
-
* 2. Each candidate specifier is resolved against its own importer's
|
|
137
|
-
* directory and compared to the deleted path. Only a real resolution
|
|
138
|
-
* counts.
|
|
139
|
-
*
|
|
140
|
-
* The predecessor (Story #2962) grepped the basename stem as a bare word
|
|
141
|
-
* across the whole tree, so a module named `notification` or `options`
|
|
142
|
-
* reported dozens of call sites drawn from prose, schemas, and unrelated
|
|
143
|
-
* modules — and the gate that fired on that number told the operator to
|
|
144
|
-
* split a migration that did not exist. It also returned 0 without probing
|
|
145
|
-
* for any stem under three characters, under-reporting in silence. Both are
|
|
146
|
-
* gone: coupling is measured by resolution, not vocabulary (Story #4547).
|
|
147
|
-
*
|
|
148
|
-
* @returns {(arg: { path: string }) => { count: number, files: string[], probe: string }}
|
|
138
|
+
* Attach non-enumerable bookkeeping to the validated array.
|
|
139
|
+
*
|
|
140
|
+
* @param {object[]} validated
|
|
141
|
+
* @param {Record<string, unknown>} extras
|
|
149
142
|
*/
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (result.status !== 0) return { count: 0, files: [], probe };
|
|
160
|
-
const files = new Set();
|
|
161
|
-
for (const line of result.stdout.split('\n')) {
|
|
162
|
-
if (line.trim().length === 0) continue;
|
|
163
|
-
const hit = parseGrepLine(line, baseBranchRef);
|
|
164
|
-
// The deleted module's own self-references are not call sites.
|
|
165
|
-
if (!hit || hit.path === path) continue;
|
|
166
|
-
for (const match of hit.text.matchAll(QUOTED_RE)) {
|
|
167
|
-
if (specifierResolvesTo(hit.path, match[1], path)) {
|
|
168
|
-
files.add(hit.path);
|
|
169
|
-
break;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
const sorted = [...files].sort();
|
|
174
|
-
return { count: sorted.length, files: sorted, probe };
|
|
175
|
-
};
|
|
143
|
+
function defineHidden(validated, extras) {
|
|
144
|
+
for (const [key, value] of Object.entries(extras)) {
|
|
145
|
+
Object.defineProperty(validated, key, {
|
|
146
|
+
value,
|
|
147
|
+
enumerable: false,
|
|
148
|
+
configurable: true,
|
|
149
|
+
writable: true,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
176
152
|
}
|
|
177
153
|
|
|
178
154
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* The canonical resolved config carries the base branch at
|
|
182
|
-
* `project.baseBranch` (`lib/config-resolver.js` defaults it to `main`).
|
|
183
|
-
* This helper used to read `config.baseBranch` — a key the resolver never
|
|
184
|
-
* produces — so every freshness / file-assumption / fan-out probe silently
|
|
185
|
-
* targeted the literal `main` regardless of configuration. Benign in a repo
|
|
186
|
-
* whose base branch *is* `main`; wrong for any consumer that configured
|
|
187
|
-
* something else (Story #4541).
|
|
155
|
+
* Repair the mechanical `changes[]` formalities, then validate.
|
|
188
156
|
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
157
|
+
* Repair before judging (Story #5312, the shape Story #5005 set for the
|
|
158
|
+
* verify tier): a plain-string bullet or a trailing parenthetical is
|
|
159
|
+
* rewritten into `{ path, assumption }` by probing the base branch, and
|
|
160
|
+
* each rewrite is reported on the returned array's `repairs` so the dry-run
|
|
161
|
+
* can print it. An entry nothing path-shaped can be salvaged from survives
|
|
162
|
+
* untouched and still hard-errors in `validateTaskBodies`.
|
|
191
163
|
*
|
|
192
|
-
* @param {object
|
|
193
|
-
* @
|
|
164
|
+
* @param {object[]} tickets Mutated in place.
|
|
165
|
+
* @param {object} config Resolved config.
|
|
166
|
+
* @param {{ cwd?: string, gitRunner?: Function }} [opts]
|
|
167
|
+
* @returns {object[] & { findings: object[], errors: string[], warnings: string[], normalizations: object[], repairs: object[], probeRef: string|null }}
|
|
194
168
|
*/
|
|
195
|
-
export function resolveBaseBranchRef(config) {
|
|
196
|
-
return config?.project?.baseBranch ?? config?.baseBranch ?? 'main';
|
|
197
|
-
}
|
|
198
|
-
|
|
199
169
|
export function validateTickets(tickets, config, opts = {}) {
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
170
|
+
const baseBranch = resolveBaseBranchRef(config);
|
|
171
|
+
const baseBranchRef = resolveProbeRef({ baseBranch, cwd: opts.cwd });
|
|
172
|
+
const repairs = [
|
|
173
|
+
...normalizeAcceptanceHandles(tickets),
|
|
174
|
+
...repairChangeEntries(tickets, {
|
|
175
|
+
existsAtBase: makeExistsAtBase({
|
|
176
|
+
baseBranchRef,
|
|
177
|
+
cwd: opts.cwd,
|
|
178
|
+
gitRunner: opts.gitRunner,
|
|
179
|
+
}),
|
|
180
|
+
}),
|
|
181
|
+
];
|
|
210
182
|
const validated = validateAndNormalizeTickets(tickets, {
|
|
211
|
-
baseBranchRef,
|
|
212
|
-
|
|
213
|
-
modelCapacity: opts.modelCapacity,
|
|
183
|
+
baseBranchRef: baseBranchRef ?? undefined,
|
|
184
|
+
gitRunner: opts.gitRunner,
|
|
214
185
|
// Thread the repo cwd into the AC-freshness / file-assumption git
|
|
215
186
|
// probes (#4474 PR7) — without it they silently ran against
|
|
216
187
|
// process.cwd(), which is only the repo root by coincidence.
|
|
217
188
|
cwd: opts.cwd,
|
|
218
189
|
});
|
|
219
|
-
|
|
220
|
-
// validator can already infer is auto-corrected rather than rejected, so a
|
|
221
|
-
// mechanically-fixable formality no longer costs a re-authoring round. An
|
|
222
|
-
// uninferable entry survives untouched and still hard-errors below.
|
|
223
|
-
normalizeVerifyTiers(validated);
|
|
190
|
+
validated.warnings.push(...probeSkipWarnings(baseBranch, baseBranchRef));
|
|
224
191
|
validateTaskBodies(validated);
|
|
192
|
+
defineHidden(validated, { repairs, probeRef: baseBranchRef });
|
|
225
193
|
return validated;
|
|
226
194
|
}
|