mandrel 2.32.0 → 2.34.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/docs/SDLC.md +8 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +3 -2
- package/.agents/runtime-deps.json +2 -1
- package/.agents/schemas/agentrc.schema.json +8 -2
- package/.agents/scripts/README.md +9 -0
- package/.agents/scripts/audit-to-stories.js +160 -41
- package/.agents/scripts/check-knip-entries.js +47 -24
- package/.agents/scripts/check-lifecycle-lint.js +72 -12
- package/.agents/scripts/coverage-capture.js +7 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
- package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
- package/.agents/scripts/lib/baselines/kernel.js +20 -7
- package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
- package/.agents/scripts/lib/config/quality.js +7 -0
- package/.agents/scripts/lib/config/runners.js +38 -16
- package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
- package/.agents/scripts/lib/coverage-capture.js +10 -15
- package/.agents/scripts/lib/dependency-parser.js +20 -7
- package/.agents/scripts/lib/findings/provenance-field.js +135 -0
- package/.agents/scripts/lib/findings/route-finding.js +57 -8
- package/.agents/scripts/lib/knip-config-resolver.js +181 -0
- package/.agents/scripts/lib/knip-entry-sync.js +78 -39
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +93 -19
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
- package/.agents/scripts/lib/story-body/footer-block.js +97 -0
- package/.agents/scripts/lib/story-body/story-body.js +6 -22
- package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
- package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
- package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
- package/.agents/scripts/resolve-stories.js +21 -5
- package/.agents/scripts/stories-wave-tick.js +192 -9
- package/.agents/workflows/audit-to-stories.md +26 -0
- package/.agents/workflows/helpers/deliver-light.md +5 -2
- package/.agents/workflows/helpers/deliver-reference.md +28 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +80 -1
- package/.agents/workflows/helpers/deliver-story.md +4 -2
- package/.agents/workflows/helpers/plan-reference.md +76 -0
- package/docs/CHANGELOG.md +26 -0
- package/package.json +3 -3
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
19
|
import { applyBlockedByDependencies } from '../../../providers/github/blocked-by-add.js';
|
|
20
|
+
import {
|
|
21
|
+
normalizeOwnedProvenance,
|
|
22
|
+
ownedProvenanceSource,
|
|
23
|
+
} from '../../findings/provenance-field.js';
|
|
20
24
|
import { carryProvenanceFooters } from '../../findings/route-finding.js';
|
|
21
25
|
import { Logger } from '../../Logger.js';
|
|
22
26
|
import { AGENT_LABELS, TYPE_LABELS } from '../../label-constants.js';
|
|
@@ -319,8 +323,12 @@ function syncContractFieldFromTopLevel(ticket, bodyObject, field) {
|
|
|
319
323
|
* bookkeeping for the `--tickets` source issues, not part of the Story's
|
|
320
324
|
* executable body, so it is deliberately not serialized into the markdown.
|
|
321
325
|
*
|
|
326
|
+
* `provenance` is likewise top-level-only (Story #5045) — it names the audit
|
|
327
|
+
* identities *this* Story owns, and is stamped into the body as footers rather
|
|
328
|
+
* than serialized as a section.
|
|
329
|
+
*
|
|
322
330
|
* @param {object} ticket
|
|
323
|
-
* @returns {{ slug: string, title: string, bodyObject: object, depends_on: string[], labels: string[], supersedes: Array<{ id: number, note: string|null }
|
|
331
|
+
* @returns {{ slug: string, title: string, bodyObject: object, depends_on: string[], labels: string[], supersedes: Array<{ id: number, note: string|null }>, provenance: { fingerprints: string[], semanticKeys: string[] }|null }}
|
|
324
332
|
*/
|
|
325
333
|
export function normalizeStoryTicket(ticket) {
|
|
326
334
|
if (!ticket || typeof ticket !== 'object') {
|
|
@@ -345,8 +353,22 @@ export function normalizeStoryTicket(ticket) {
|
|
|
345
353
|
const depends_on = normalizeDependsOn(ticket, bodyObject);
|
|
346
354
|
const supersedes = normalizeSupersedes(ticket, slug);
|
|
347
355
|
const labels = sanitizeAuthoredLabels(ticket.labels, slug);
|
|
356
|
+
let provenance;
|
|
357
|
+
try {
|
|
358
|
+
provenance = normalizeOwnedProvenance(ticket.provenance, slug);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
throw new Error(`[plan-persist] ${err.message}`);
|
|
361
|
+
}
|
|
348
362
|
|
|
349
|
-
return {
|
|
363
|
+
return {
|
|
364
|
+
slug,
|
|
365
|
+
title,
|
|
366
|
+
bodyObject,
|
|
367
|
+
depends_on,
|
|
368
|
+
labels,
|
|
369
|
+
supersedes,
|
|
370
|
+
provenance,
|
|
371
|
+
};
|
|
350
372
|
}
|
|
351
373
|
|
|
352
374
|
/**
|
|
@@ -388,24 +410,59 @@ export function foldSpecIntoStoryBody(bodyObject, slug, opts = {}) {
|
|
|
388
410
|
return { bodyObject: next };
|
|
389
411
|
}
|
|
390
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Resolve the provenance source one Story is stamped from (Story #5045).
|
|
415
|
+
*
|
|
416
|
+
* Two channels, and the precedence between them is the whole contract:
|
|
417
|
+
*
|
|
418
|
+
* - **Attributed** — the Story authored a `provenance` field naming the audit
|
|
419
|
+
* identities *it* owns. Exactly those are stamped. This is what makes the
|
|
420
|
+
* footers answer "which Story tracks this finding?" instead of "which sweep
|
|
421
|
+
* planned it?": under the union every sibling carried every key, so the next
|
|
422
|
+
* sweep's confirmation pass could only pick an arbitrary open Story, and a
|
|
423
|
+
* key whose owner had since closed was masked by any open neighbour.
|
|
424
|
+
* - **Union fallback** — no `provenance` field, so the whole seed's footers are
|
|
425
|
+
* carried, exactly as before. This is **not** dead weight to be tidied away:
|
|
426
|
+
* hand-carried provenance is the failure Stories #4626 / #4877 measured, and
|
|
427
|
+
* the union is what closed it. Attribution is additive and recall-safe;
|
|
428
|
+
* deleting the fallback would re-open that hole for every plan that does not
|
|
429
|
+
* attribute.
|
|
430
|
+
*
|
|
431
|
+
* @param {{ fingerprints: string[], semanticKeys: string[] }|null} provenance
|
|
432
|
+
* @param {object} opts Assembly options carrying `provenanceSource`.
|
|
433
|
+
* @returns {string}
|
|
434
|
+
*/
|
|
435
|
+
function resolveProvenanceSource(provenance, opts) {
|
|
436
|
+
if (provenance !== null) return ownedProvenanceSource(provenance);
|
|
437
|
+
return opts.provenanceSource ?? '';
|
|
438
|
+
}
|
|
439
|
+
|
|
391
440
|
function assembleOnePlanStory(ticket, opts) {
|
|
392
|
-
const {
|
|
393
|
-
|
|
441
|
+
const {
|
|
442
|
+
slug,
|
|
443
|
+
title,
|
|
444
|
+
bodyObject,
|
|
445
|
+
depends_on,
|
|
446
|
+
labels,
|
|
447
|
+
supersedes,
|
|
448
|
+
provenance,
|
|
449
|
+
} = normalizeStoryTicket(ticket);
|
|
394
450
|
const { bodyObject: folded } = foldSpecIntoStoryBody(bodyObject, slug, {
|
|
395
451
|
sharedSpec: opts.sharedSpec ?? null,
|
|
396
452
|
});
|
|
397
453
|
// Body first: the fingerprint is an identity over the *assembled* content,
|
|
398
454
|
// so it cannot be computed until that content exists.
|
|
399
455
|
const serialized = serializeStoryBody({ ...folded, depends_on });
|
|
400
|
-
// Carry audit dedup provenance
|
|
401
|
-
//
|
|
402
|
-
// `audit-
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
456
|
+
// Carry audit dedup provenance into the persisted body (Story #4877). The
|
|
457
|
+
// audit sweep's Single-plan path stamps the `audit-fingerprints` /
|
|
458
|
+
// `audit-semantic-keys` footers into the seed it hands `/plan`; without this
|
|
459
|
+
// the persisted Story carries no provenance and the next sweep re-files work
|
|
460
|
+
// it already planned. Mechanical on purpose — the authoring agent is not
|
|
461
|
+
// asked to notice HTML comments in a one-pager. A non-audit seed carries no
|
|
462
|
+
// footers, so this is a no-op there. Which identities reach *this* Story is
|
|
463
|
+
// `resolveProvenanceSource`'s call (Story #5045).
|
|
407
464
|
const { body } = carryProvenanceFooters({
|
|
408
|
-
from: opts
|
|
465
|
+
from: resolveProvenanceSource(provenance, opts),
|
|
409
466
|
into: serialized,
|
|
410
467
|
});
|
|
411
468
|
const fingerprint = planStoryFingerprint({ slug, title, body });
|
|
@@ -615,13 +672,30 @@ function renderStoryBodyForCreate(story, idBySlug) {
|
|
|
615
672
|
const dependencyRefs = story.depends_on.map(
|
|
616
673
|
(slug) => `#${idBySlug.get(slug)}`,
|
|
617
674
|
);
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
675
|
+
let base = story.body;
|
|
676
|
+
if (dependencyRefs.length > 0) {
|
|
677
|
+
// Re-serializing from `bodyObject` is what resolves the sibling slugs to
|
|
678
|
+
// real issue ids — but `bodyObject` never held the provenance footers
|
|
679
|
+
// (`assembleOnePlanStory` appends those to the body *string*), so this
|
|
680
|
+
// branch drops them unless the carry is re-applied. That is the exact
|
|
681
|
+
// loss site Story #4935 diagnosed, #4939 fixed, and #4956 reverted
|
|
682
|
+
// wholesale hours later; Story #5056 restored it with a persist-side
|
|
683
|
+
// regression test that reads the POSTed body.
|
|
684
|
+
//
|
|
685
|
+
// `from: story.body` — not the seed — is load-bearing: it re-carries the
|
|
686
|
+
// identities *this* Story was stamped with under Story #5045 attribution
|
|
687
|
+
// rather than reintroducing the whole seed's union. `carryProvenanceFooters`
|
|
688
|
+
// is additive, union-preserving and idempotent, so re-applying is safe by
|
|
689
|
+
// construction.
|
|
690
|
+
const reserialized = serializeStoryBody(
|
|
691
|
+
{ ...story.bodyObject, depends_on: dependencyRefs },
|
|
692
|
+
{ includeFooter: true },
|
|
693
|
+
);
|
|
694
|
+
base = carryProvenanceFooters({
|
|
695
|
+
from: story.body,
|
|
696
|
+
into: reserialized,
|
|
697
|
+
}).body;
|
|
698
|
+
}
|
|
625
699
|
return `${base}\n\n${planFingerprintMarker(story.fingerprint)}`;
|
|
626
700
|
}
|
|
627
701
|
|
|
@@ -65,6 +65,53 @@ function renderWaveTableLines(waveTable) {
|
|
|
65
65
|
return ['| Order | Stories |', '| --- | --- |', ...rows];
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Render the shared-editor collisions beside the wave table (Story #5045).
|
|
70
|
+
*
|
|
71
|
+
* The wave table is a promise about parallelism — "these Stories can run
|
|
72
|
+
* together". `computeSharedEditorFindings` knows exactly where that promise
|
|
73
|
+
* breaks down: a path two same-wave Stories both write will conflict on every
|
|
74
|
+
* merge after the first. Until now those findings degraded to a `Logger.warn`
|
|
75
|
+
* on stderr and were discarded, so the comment carried the optimistic half of
|
|
76
|
+
* the analysis and none of the caveat. Rendering them here puts the promise
|
|
77
|
+
* and its known exceptions on one durable surface.
|
|
78
|
+
*
|
|
79
|
+
* Advisory by default and labelled as such — `planning.failOnSharedEditors`
|
|
80
|
+
* is the knob that makes a collision refuse the plan, and it stays off.
|
|
81
|
+
*
|
|
82
|
+
* @param {object[]|null} conflictFindings
|
|
83
|
+
* @returns {string[]} Lines to splice after the wave table, or `[]`.
|
|
84
|
+
*/
|
|
85
|
+
function renderSharedEditorLines(conflictFindings) {
|
|
86
|
+
const shared = (
|
|
87
|
+
Array.isArray(conflictFindings) ? conflictFindings : []
|
|
88
|
+
).filter((finding) => finding?.kind === 'shared-editor');
|
|
89
|
+
if (shared.length === 0) return [];
|
|
90
|
+
const rows = shared
|
|
91
|
+
.slice()
|
|
92
|
+
.sort((a, b) => String(a.path).localeCompare(String(b.path)))
|
|
93
|
+
.map(
|
|
94
|
+
(finding) =>
|
|
95
|
+
`| \`${finding.path}\` | ${(finding.storySlugs ?? [])
|
|
96
|
+
.map((slug) => `\`${slug}\``)
|
|
97
|
+
.join(', ')} |`,
|
|
98
|
+
);
|
|
99
|
+
return [
|
|
100
|
+
'',
|
|
101
|
+
`#### ⚠️ Known collisions (${shared.length} shared file(s))`,
|
|
102
|
+
'',
|
|
103
|
+
'| Path | Stories in the same order |',
|
|
104
|
+
'| --- | --- |',
|
|
105
|
+
...rows,
|
|
106
|
+
'',
|
|
107
|
+
'_These Stories are scheduled to run together **and** write the same ' +
|
|
108
|
+
'file — expect a merge conflict on every landing after the first. Add a ' +
|
|
109
|
+
'`depends_on` edge to serialize them, or move the shared edit into one ' +
|
|
110
|
+
'Story. Advisory: `planning.failOnSharedEditors` turns this into a ' +
|
|
111
|
+
'refusal and is off by default._',
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
68
115
|
/**
|
|
69
116
|
* Build the `plan-summary` structured-comment body.
|
|
70
117
|
*
|
|
@@ -81,6 +128,7 @@ export function buildPlanSummaryCommentBody({
|
|
|
81
128
|
mode = 'stories',
|
|
82
129
|
planMetricsLine = null,
|
|
83
130
|
stories = null,
|
|
131
|
+
conflictFindings = null,
|
|
84
132
|
// legacy unused knobs kept so older test call sites don't crash mid-migration
|
|
85
133
|
single = null,
|
|
86
134
|
amend = null,
|
|
@@ -132,6 +180,7 @@ export function buildPlanSummaryCommentBody({
|
|
|
132
180
|
'#### Delivery order (`depends_on`)',
|
|
133
181
|
'',
|
|
134
182
|
...renderWaveTableLines(waveTable),
|
|
183
|
+
...renderSharedEditorLines(conflictFindings),
|
|
135
184
|
'',
|
|
136
185
|
`_Deliver with \`${deliverCommand}\` — \`/deliver\` resolves the dependency graph from live state, so edges may point at Stories from earlier plan runs._`,
|
|
137
186
|
].join('\n');
|
|
@@ -182,9 +182,16 @@ export function storyFootprintPaths(body, id, warn) {
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
/**
|
|
185
|
-
* Build the DAG nodes. `dependsOn` is the union the
|
|
186
|
-
*
|
|
187
|
-
*
|
|
185
|
+
* Build the DAG nodes. `dependsOn` is the **union of the two declared-edge
|
|
186
|
+
* channels**: the Story body's `---` footer (`blocked by #N`) and the native
|
|
187
|
+
* GitHub `blocked_by` relations threaded in via `nativeEdges`. `files` is a
|
|
188
|
+
* plain `string[]`.
|
|
189
|
+
*
|
|
190
|
+
* The body channel is footer-scoped and strict (`parseBlockedBy`, Story
|
|
191
|
+
* #5046) — a `blocked by #123` mention in prose no longer mints a dispatch
|
|
192
|
+
* gate. Only `{ id, dependsOn }` is handed to the adjacency builder, never the
|
|
193
|
+
* body: the edge set is decided here, once, so the builder's own body parse
|
|
194
|
+
* cannot re-derive a different one behind this function's back.
|
|
188
195
|
*
|
|
189
196
|
* @param {object[]} stories
|
|
190
197
|
* @param {Map<number, number[]>} [nativeEdges]
|
|
@@ -193,7 +200,7 @@ export function storyFootprintPaths(body, id, warn) {
|
|
|
193
200
|
*/
|
|
194
201
|
export function storiesToDag(stories, nativeEdges = new Map(), warn) {
|
|
195
202
|
const withNative = stories.map((s) => ({
|
|
196
|
-
|
|
203
|
+
id: s.id,
|
|
197
204
|
dependsOn: [
|
|
198
205
|
...new Set([
|
|
199
206
|
...parseBlockedBy(s.body ?? ''),
|
|
@@ -222,28 +229,39 @@ export function storiesToDag(stories, nativeEdges = new Map(), warn) {
|
|
|
222
229
|
* matching no Story, foreign to the set, never satisfiable, and (because
|
|
223
230
|
* foreign edges are real gates) a silent permanent wedge.
|
|
224
231
|
*
|
|
225
|
-
*
|
|
226
|
-
* is not this repo's #4530, and treating it as one could
|
|
227
|
-
* is still open.
|
|
232
|
+
* A cross-repo blocker is **dropped with a loud warning**, never matched:
|
|
233
|
+
* another repo's #4530 is not this repo's #4530, and treating it as one could
|
|
234
|
+
* satisfy a gate that is still open. It used to throw, which failed the WHOLE
|
|
235
|
+
* resolution — one Story's unsupported edge took every sibling down with it
|
|
236
|
+
* (Story #5046). The degrade is now scoped to the Story carrying the edge:
|
|
237
|
+
* its siblings resolve normally, and the operator is told, by number, which
|
|
238
|
+
* Story lost which edge.
|
|
228
239
|
*
|
|
229
240
|
* @param {unknown} data Parsed API response.
|
|
230
|
-
* @param {{ owner: string, repo: string, issueNumber: number }} ctx
|
|
241
|
+
* @param {{ owner: string, repo: string, issueNumber: number, warn?: (msg: string) => void }} ctx
|
|
231
242
|
* @returns {number[]}
|
|
232
243
|
*/
|
|
233
|
-
export function nativeBlockedByNumbers(
|
|
244
|
+
export function nativeBlockedByNumbers(
|
|
245
|
+
data,
|
|
246
|
+
{ owner, repo, issueNumber, warn },
|
|
247
|
+
) {
|
|
234
248
|
if (!Array.isArray(data)) return [];
|
|
235
249
|
const out = [];
|
|
236
250
|
for (const item of data) {
|
|
237
251
|
const repoUrl = item?.repository_url ?? item?.repository?.url ?? null;
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
252
|
+
if (
|
|
253
|
+
typeof repoUrl === 'string' &&
|
|
254
|
+
repoUrl.length > 0 &&
|
|
255
|
+
!repoUrl.endsWith(`/repos/${owner}/${repo}`)
|
|
256
|
+
) {
|
|
257
|
+
warn?.(
|
|
258
|
+
`[resolve-stories] #${issueNumber} declares a native blocked_by edge on an issue in ` +
|
|
259
|
+
`another repository (${repoUrl}). Cross-repo edges are not supported — its number ` +
|
|
260
|
+
`cannot be matched against this repo's Stories without risking a false match, so the ` +
|
|
261
|
+
`edge is DROPPED for #${issueNumber} only. Its siblings resolve normally; re-declare ` +
|
|
262
|
+
`the ordering in this repo if #${issueNumber} must wait.`,
|
|
263
|
+
);
|
|
264
|
+
continue;
|
|
247
265
|
}
|
|
248
266
|
const number = Number(item?.number);
|
|
249
267
|
if (Number.isInteger(number) && number > 0) out.push(number);
|
|
@@ -252,17 +270,33 @@ export function nativeBlockedByNumbers(data, { owner, repo, issueNumber }) {
|
|
|
252
270
|
}
|
|
253
271
|
|
|
254
272
|
/**
|
|
255
|
-
* Read an issue's native `blocked_by` edges as issue numbers
|
|
273
|
+
* Read an issue's native `blocked_by` edges as issue numbers, **paginated to
|
|
274
|
+
* exhaustion**.
|
|
275
|
+
*
|
|
276
|
+
* The read used to take the first page only, so a Story with more than a
|
|
277
|
+
* page of blockers silently lost every edge past the boundary — the exact
|
|
278
|
+
* failure this function's fail-loud contract exists to prevent, arriving
|
|
279
|
+
* through the one door that never raised (Story #5046). `paginate` is
|
|
280
|
+
* injected (the CLI passes `paginateRest`) so the lib layer stays free of a
|
|
281
|
+
* provider import and the page walk stays testable without a live round-trip.
|
|
256
282
|
*
|
|
257
|
-
* **Fails loud**, deliberately inverting the write path's
|
|
258
|
-
* A dropped write-side edge is cosmetic (the ordering
|
|
259
|
-
* `blocked by #N` body footer); a dropped READ-side edge
|
|
260
|
-
* dispatch gate, so
|
|
261
|
-
*
|
|
262
|
-
* against unlanded blockers. A 404 means "no dependencies on this issue" and
|
|
263
|
-
* is a legitimate empty result.
|
|
283
|
+
* **Fails loud on every non-OK read**, deliberately inverting the write path's
|
|
284
|
+
* non-fatal contract. A dropped write-side edge is cosmetic (the ordering
|
|
285
|
+
* still lives in the `blocked by #N` body footer); a dropped READ-side edge
|
|
286
|
+
* silently removes a dispatch gate, so one failure would erase every native
|
|
287
|
+
* edge at once and co-dispatch the run against unlanded blockers.
|
|
264
288
|
*
|
|
265
|
-
*
|
|
289
|
+
* **A 404 is not an empty result.** It used to be treated as "this issue has
|
|
290
|
+
* no dependencies", which is how GitHub answers an issue that genuinely has
|
|
291
|
+
* none — but it is *also* how GitHub answers a token that cannot see the
|
|
292
|
+
* dependencies API at all. Reading the second as the first erases every
|
|
293
|
+
* native edge in the run under a mis-scoped token, silently, with a clean
|
|
294
|
+
* exit code. An issue with no dependencies returns `200 []`, so the empty
|
|
295
|
+
* case needs no 404 escape hatch and the ambiguity resolves loud.
|
|
296
|
+
*
|
|
297
|
+
* @param {{ gh: object, owner: string, repo: string, issueNumber: number,
|
|
298
|
+
* paginate: (gh: object, endpoint: string, opts?: object) => Promise<unknown[]>,
|
|
299
|
+
* warn?: (msg: string) => void }} opts
|
|
266
300
|
* @returns {Promise<number[]>}
|
|
267
301
|
*/
|
|
268
302
|
export async function readNativeBlockedBy({
|
|
@@ -270,27 +304,30 @@ export async function readNativeBlockedBy({
|
|
|
270
304
|
owner,
|
|
271
305
|
repo,
|
|
272
306
|
issueNumber,
|
|
273
|
-
|
|
307
|
+
paginate,
|
|
308
|
+
warn,
|
|
274
309
|
}) {
|
|
275
|
-
|
|
310
|
+
const endpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`;
|
|
311
|
+
let items;
|
|
276
312
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
endpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/dependencies/blocked_by`,
|
|
313
|
+
items = await paginate(gh, endpoint, {
|
|
314
|
+
label: `[resolve-stories] blocked_by #${issueNumber}`,
|
|
280
315
|
});
|
|
281
316
|
} catch (err) {
|
|
282
317
|
const detail = String(err?.message ?? err);
|
|
283
|
-
if (/404|not found/i.test(detail)) return [];
|
|
284
318
|
throw new Error(
|
|
285
319
|
`[resolve-stories] Could not read native blocked_by edges for #${issueNumber}: ${detail}. ` +
|
|
286
320
|
`Refusing to continue: a dropped dependency edge would silently remove a dispatch gate ` +
|
|
287
|
-
`and co-dispatch this Story against an unlanded blocker
|
|
321
|
+
`and co-dispatch this Story against an unlanded blocker. A 404 here is NOT "no ` +
|
|
322
|
+
`dependencies" (that answers 200 with an empty list) — check the token's scopes and ` +
|
|
323
|
+
`that the dependencies API is enabled for ${owner}/${repo}.`,
|
|
288
324
|
);
|
|
289
325
|
}
|
|
290
|
-
return nativeBlockedByNumbers(
|
|
326
|
+
return nativeBlockedByNumbers(items, {
|
|
291
327
|
owner,
|
|
292
328
|
repo,
|
|
293
329
|
issueNumber,
|
|
330
|
+
warn,
|
|
294
331
|
});
|
|
295
332
|
}
|
|
296
333
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveListValue } from '../config/shared.js';
|
|
1
2
|
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
2
3
|
import { collectStoryAssumptionEntries } from './file-assumptions.js';
|
|
3
4
|
import { computeStoryReachability } from './story-reachability.js';
|
|
@@ -108,8 +109,11 @@ const DEFAULT_POLICY = Object.freeze({
|
|
|
108
109
|
* Patterns support two shapes:
|
|
109
110
|
* - exact path — `lib/orchestration/lifecycle/listeners/index.js`
|
|
110
111
|
* - `**` suffix — `**\/listeners/index.js` (matches any depth)
|
|
112
|
+
*
|
|
113
|
+
* Module-private since `resolveConflictPolicy` became the one production
|
|
114
|
+
* reader; tests reach it through `_internal`.
|
|
111
115
|
*/
|
|
112
|
-
|
|
116
|
+
const DEFAULT_REGISTRY_PATTERNS = Object.freeze([
|
|
113
117
|
'lib/orchestration/lifecycle/listeners/index.js',
|
|
114
118
|
'**/listeners/index.js',
|
|
115
119
|
'**/handlers/index.js',
|
|
@@ -762,6 +766,117 @@ export function computeConflictFindings({ stories, policy } = {}) {
|
|
|
762
766
|
];
|
|
763
767
|
}
|
|
764
768
|
|
|
769
|
+
/**
|
|
770
|
+
* Resolve the config-derived half of the conflict policy from
|
|
771
|
+
* `config.planning` — the severity flags, the fan-out threshold, and the
|
|
772
|
+
* registry patterns, in one place.
|
|
773
|
+
*
|
|
774
|
+
* Two passes consume `planning.*` and must not disagree: the raw
|
|
775
|
+
* pre-assembly pass (`persist-helpers.validateTickets`, which attaches its
|
|
776
|
+
* production `fanOutCounter` on top of this) and the post-assembly pass
|
|
777
|
+
* (`computeAssembledConflictFindings`, which forces `fanOutCounter: null`).
|
|
778
|
+
* Under Story #5045 each resolved its own copy, and the copies had already
|
|
779
|
+
* drifted — `failOnMissingBddScaffold` reached only the assembled pass,
|
|
780
|
+
* `failOnLargeFanOut` / `largeFanOutThreshold` / `crossCuttingRegistries`
|
|
781
|
+
* only the raw one — so a knob set in config silently applied on one of the
|
|
782
|
+
* two passes. A knob read here reaches both; that is the contract.
|
|
783
|
+
*
|
|
784
|
+
* `fanOutCounter` is deliberately absent: it is probe machinery, not
|
|
785
|
+
* config, and each caller owns its own.
|
|
786
|
+
*
|
|
787
|
+
* @param {object} [config] Resolved config carrying `planning.*`.
|
|
788
|
+
* @returns {object} A `computeConflictFindings` policy (no `fanOutCounter`).
|
|
789
|
+
*/
|
|
790
|
+
export function resolveConflictPolicy(config) {
|
|
791
|
+
const planning = config?.planning;
|
|
792
|
+
const policy = {
|
|
793
|
+
failOnSharedEditors: planning?.failOnSharedEditors === true,
|
|
794
|
+
requireExplicitCrossStoryDeps:
|
|
795
|
+
planning?.requireExplicitCrossStoryDeps === true,
|
|
796
|
+
failOnRegistryConflicts: planning?.failOnRegistryConflicts === true,
|
|
797
|
+
failOnLargeFanOut: planning?.failOnLargeFanOut === true,
|
|
798
|
+
failOnMissingBddScaffold: planning?.failOnMissingBddScaffold === true,
|
|
799
|
+
};
|
|
800
|
+
if (Number.isFinite(planning?.largeFanOutThreshold)) {
|
|
801
|
+
policy.largeFanOutThreshold = planning.largeFanOutThreshold;
|
|
802
|
+
}
|
|
803
|
+
if (planning?.crossCuttingRegistries !== undefined) {
|
|
804
|
+
policy.registries = resolveListValue(
|
|
805
|
+
DEFAULT_REGISTRY_PATTERNS,
|
|
806
|
+
planning.crossCuttingRegistries,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
return policy;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Re-run the cross-Story conflict passes over the **assembled** Story bodies —
|
|
814
|
+
* the artifact persist actually writes (Story #5045).
|
|
815
|
+
*
|
|
816
|
+
* `validateTickets` runs before `assemblePlanStories`, over the raw
|
|
817
|
+
* `stories.json` payload, so plan-time conflict analysis never saw what got
|
|
818
|
+
* persisted. That is not a cosmetic ordering nit: the canonical authoring shape
|
|
819
|
+
* carries `acceptance[]` / `verify[]` at the ticket's **top level**, and it is
|
|
820
|
+
* assembly's `syncContractFieldFromTopLevel` that folds them into the body.
|
|
821
|
+
* `indexConsumers` scans `body.acceptance` / `body.verify` for producer paths —
|
|
822
|
+
* so on the real payload it scanned two empty arrays, and every
|
|
823
|
+
* `implicit-cross-story-dep` and `missing-bdd-scaffold` finding was silently
|
|
824
|
+
* unreachable. Running the passes again over the serialized bodies restores
|
|
825
|
+
* them.
|
|
826
|
+
*
|
|
827
|
+
* **The fan-out pass is deliberately not re-run.** It is a `git grep` per
|
|
828
|
+
* deleted path and its inputs (`changes[]` deletes) are identical on both
|
|
829
|
+
* sides, so re-probing would double the git cost for a byte-identical answer;
|
|
830
|
+
* `enforceFanOutGate` already owns that class over the raw payload.
|
|
831
|
+
*
|
|
832
|
+
* @param {object} args
|
|
833
|
+
* @param {Array<{ slug: string, title?: string, body: string, depends_on?: string[] }>} args.stories
|
|
834
|
+
* Assembled Stories — `body` is the serialized, footer-stamped markdown.
|
|
835
|
+
* @param {object} [args.config] Resolved config; `planning.*` supplies the
|
|
836
|
+
* policy via {@link resolveConflictPolicy} — the same resolver the raw
|
|
837
|
+
* pass uses, so a knob cannot apply on only one of the two passes.
|
|
838
|
+
* @returns {ConflictFinding[]}
|
|
839
|
+
*/
|
|
840
|
+
export function computeAssembledConflictFindings({ stories, config } = {}) {
|
|
841
|
+
return computeConflictFindings({
|
|
842
|
+
stories: (Array.isArray(stories) ? stories : []).map((story) => ({
|
|
843
|
+
slug: story.slug,
|
|
844
|
+
title: story.title,
|
|
845
|
+
body: story.body,
|
|
846
|
+
depends_on: Array.isArray(story.depends_on) ? story.depends_on : [],
|
|
847
|
+
})),
|
|
848
|
+
policy: {
|
|
849
|
+
...resolveConflictPolicy(config),
|
|
850
|
+
fanOutCounter: null,
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Stable identity for one conflict finding, so the post-assembly pass can be
|
|
857
|
+
* diffed against the raw pass and only the genuinely-new findings reported
|
|
858
|
+
* (Story #5045). Without it the two passes announce the same shared-editor
|
|
859
|
+
* collision twice per run, which is how a warning channel gets discounted.
|
|
860
|
+
*
|
|
861
|
+
* The separator is written as the `\u0000` escape and never as a raw byte — a
|
|
862
|
+
* literal NUL would make git classify this file as binary and drop its diffs.
|
|
863
|
+
*
|
|
864
|
+
* @param {object} finding
|
|
865
|
+
* @returns {string}
|
|
866
|
+
*/
|
|
867
|
+
export function conflictFindingKey(finding) {
|
|
868
|
+
return [
|
|
869
|
+
finding?.kind ?? '',
|
|
870
|
+
finding?.path ?? finding?.registryPath ?? '',
|
|
871
|
+
Array.isArray(finding?.storySlugs)
|
|
872
|
+
? [...finding.storySlugs].sort().join(',')
|
|
873
|
+
: (finding?.storySlug ?? ''),
|
|
874
|
+
finding?.producer?.storySlug ?? '',
|
|
875
|
+
finding?.consumer?.storySlug ?? '',
|
|
876
|
+
finding?.consumer?.sourceField ?? '',
|
|
877
|
+
].join('\u0000');
|
|
878
|
+
}
|
|
879
|
+
|
|
765
880
|
/**
|
|
766
881
|
* Render the audit trail behind a fan-out finding's number, so an operator
|
|
767
882
|
* can check the figure rather than trust it (Story #4547).
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ValidationError } from '../errors/index.js';
|
|
2
|
+
import { normalizeOwnedProvenance } from '../findings/provenance-field.js';
|
|
2
3
|
import { detectCycle } from '../Graph.js';
|
|
3
4
|
import { gitSpawn } from '../git-utils.js';
|
|
4
5
|
|
|
@@ -528,6 +529,41 @@ function assertEveryStoryHasInlineContract({ stories }) {
|
|
|
528
529
|
);
|
|
529
530
|
}
|
|
530
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Shape-check the optional per-Story `provenance` field (Story #5045).
|
|
534
|
+
*
|
|
535
|
+
* The field decides which audit identities persist stamps into a Story body,
|
|
536
|
+
* so a malformed entry has to fail at the validator rather than at the
|
|
537
|
+
* stamper: by the time assembly runs, an unnoticed drop is indistinguishable
|
|
538
|
+
* from a Story that legitimately owns nothing — and the cost lands a whole
|
|
539
|
+
* sweep later, when the next audit re-files work this plan already tracked.
|
|
540
|
+
*
|
|
541
|
+
* Absence is valid and common: a Story with no `provenance` inherits the
|
|
542
|
+
* whole-seed union carry, which is the recall-safe default.
|
|
543
|
+
*
|
|
544
|
+
* Errors are batched across the backlog so one pass names every offender.
|
|
545
|
+
*
|
|
546
|
+
* @param {{ stories: object[] }} args
|
|
547
|
+
* @throws {Error} naming each malformed field.
|
|
548
|
+
*/
|
|
549
|
+
function assertStoryProvenanceShape({ stories }) {
|
|
550
|
+
const violations = [];
|
|
551
|
+
for (const story of stories) {
|
|
552
|
+
try {
|
|
553
|
+
normalizeOwnedProvenance(story?.provenance, story?.slug ?? '<unknown>');
|
|
554
|
+
} catch (err) {
|
|
555
|
+
violations.push(` - ${err.message}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (violations.length === 0) return;
|
|
559
|
+
throw new Error(
|
|
560
|
+
`Cross-Validation Failed: ${violations.length} Story provenance field(s) ` +
|
|
561
|
+
`are malformed:\n${violations.join('\n')}\n\nAuthor provenance as ` +
|
|
562
|
+
'{ "fingerprints": ["<40-char sha1>"], "semanticKeys": ["<area␟path>"] }, ' +
|
|
563
|
+
'or omit it entirely to inherit the seed-wide union.',
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
531
567
|
function assertNoUnknownDeps({ tickets, ticketBySlug }) {
|
|
532
568
|
const unknownDeps = [];
|
|
533
569
|
for (const t of tickets) {
|
|
@@ -575,6 +611,7 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
|
|
|
575
611
|
|
|
576
612
|
assertAllTicketsAreStories({ tickets, stories });
|
|
577
613
|
assertEveryStoryHasInlineContract({ stories });
|
|
614
|
+
assertStoryProvenanceShape({ stories });
|
|
578
615
|
assertNoUnknownDeps({ tickets, ticketBySlug });
|
|
579
616
|
|
|
580
617
|
assertAcyclic(slugAdjacency);
|
|
@@ -704,6 +741,7 @@ export const _internal = {
|
|
|
704
741
|
indexTicketsBySlug,
|
|
705
742
|
assertAllTicketsAreStories,
|
|
706
743
|
assertEveryStoryHasInlineContract,
|
|
744
|
+
assertStoryProvenanceShape,
|
|
707
745
|
assertNoUnknownDeps,
|
|
708
746
|
assertAcyclic,
|
|
709
747
|
attachFindingsAndErrors,
|