mandrel 2.54.0 → 2.56.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/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +5 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +34 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +374 -76
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +52 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +42 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +2 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +30 -0
- package/.agents/workflows/mandrel-plan.md +10 -13
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +37 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The strand shapes the table resolves, and why each is real:
|
|
15
15
|
*
|
|
16
|
-
* - `executing
|
|
17
|
-
* close.
|
|
16
|
+
* - `executing`, branch UNPUSHED, no PR → resume implementation. The work
|
|
17
|
+
* never reached close. Since Story #5267 the worker pushes before its
|
|
18
|
+
* creditable capture, so an unpushed branch means this and nothing else.
|
|
19
|
+
* - `executing`, branch PUSHED, no PR → run close. The worker's hand-off
|
|
20
|
+
* landed; only the close-and-land tail is owed, and re-initializing here
|
|
21
|
+
* would re-open finished work.
|
|
18
22
|
* - `closing` with a pending PR → resume the land. The overwhelmingly
|
|
19
23
|
* common shape now that the merge wait is bounded: the wait returned
|
|
20
24
|
* `pending` and something has to pick it back up.
|
|
@@ -315,6 +319,80 @@ function closeInFlightVerdict({ storyId, artifacts, evidence }) {
|
|
|
315
319
|
};
|
|
316
320
|
}
|
|
317
321
|
|
|
322
|
+
/**
|
|
323
|
+
* The `agent::executing` rows of the table (Story #4543; split on push state
|
|
324
|
+
* by Story #5267).
|
|
325
|
+
*
|
|
326
|
+
* Lifted out of {@link decideRecovery} because this label alone fans out into
|
|
327
|
+
* five distinct strands, and because the push-state split below only reads
|
|
328
|
+
* correctly next to the artifact probes it is ordered after.
|
|
329
|
+
*
|
|
330
|
+
* @param {{ storyId: number, branch: object, pr: object|null, closeArtifacts?: object, evidence: string[] }} args
|
|
331
|
+
* @returns {{ shape: string, nextCommand: string|null, detail: string, evidence: string[] }}
|
|
332
|
+
*/
|
|
333
|
+
function decideExecuting({ storyId, branch, pr, closeArtifacts, evidence }) {
|
|
334
|
+
if (pr?.number) {
|
|
335
|
+
return {
|
|
336
|
+
shape: 'executing-with-pr',
|
|
337
|
+
nextCommand: NEXT_COMMANDS.close(storyId),
|
|
338
|
+
detail:
|
|
339
|
+
`PR #${pr.number} exists but the Story is still \`agent::executing\` — the close ` +
|
|
340
|
+
`opened the PR and then died before the label flip. Re-run close; it reuses the ` +
|
|
341
|
+
`open PR rather than opening a duplicate.`,
|
|
342
|
+
evidence,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
// Story #4816 — the close artifacts get the first word here, and ONLY
|
|
346
|
+
// here. Every other row of this table describes a state whose evidence is
|
|
347
|
+
// already unambiguous; `executing` + no PR is the one row that reads
|
|
348
|
+
// identically for a dead implementation and for a close that is halfway
|
|
349
|
+
// through its gate chain, and answering it from labels alone is what sent
|
|
350
|
+
// operators to re-init on top of a live close.
|
|
351
|
+
if (closeLooksLive(closeArtifacts)) {
|
|
352
|
+
return closeInFlightVerdict({
|
|
353
|
+
storyId,
|
|
354
|
+
artifacts: closeArtifacts,
|
|
355
|
+
evidence,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
if (closeArtifacts?.envelope) {
|
|
359
|
+
return envelopeOnDiskVerdict({
|
|
360
|
+
storyId,
|
|
361
|
+
artifacts: closeArtifacts,
|
|
362
|
+
evidence,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
// Story #5267 — push state is what separates the two remaining strands, and
|
|
366
|
+
// it separates them cleanly now that the worker pushes BEFORE its creditable
|
|
367
|
+
// capture. Before that ordering, a worker whose turn ended on the
|
|
368
|
+
// backgrounded capture left an unpushed branch that was indistinguishable
|
|
369
|
+
// from work that never got started; now an unpushed branch means exactly one
|
|
370
|
+
// thing, and a pushed one means the hand-off happened and only close is
|
|
371
|
+
// owed.
|
|
372
|
+
if (branch?.remote) {
|
|
373
|
+
return {
|
|
374
|
+
shape: 'executing-pushed-no-pr',
|
|
375
|
+
nextCommand: NEXT_COMMANDS.close(storyId),
|
|
376
|
+
detail:
|
|
377
|
+
`\`story-${storyId}\` is PUSHED to origin but no PR exists and no close left an ` +
|
|
378
|
+
`artifact behind — the worker finished and handed off, and the close never ran (or ` +
|
|
379
|
+
`died before its first gate). Nothing needs re-implementing: run close, which is ` +
|
|
380
|
+
`idempotent. Do NOT re-init — the branch already carries the finished work.`,
|
|
381
|
+
evidence,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
shape: 'executing-no-pr',
|
|
386
|
+
nextCommand: NEXT_COMMANDS.implement(storyId),
|
|
387
|
+
detail:
|
|
388
|
+
`Story is \`agent::executing\`, \`story-${storyId}\` is UNPUSHED, there is no PR, and ` +
|
|
389
|
+
`no close left an artifact behind (no persisted terminal envelope, no recent gate ` +
|
|
390
|
+
`log) — implementation never finished. Re-init (idempotent — it reuses the existing ` +
|
|
391
|
+
`branch and worktree) and resume in the worktree it prints.`,
|
|
392
|
+
evidence,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
318
396
|
/**
|
|
319
397
|
* The decision table. Pure: every input is an already-observed probe, so the
|
|
320
398
|
* mapping is testable without git, GitHub, or a clock.
|
|
@@ -434,47 +512,7 @@ export function decideRecovery({
|
|
|
434
512
|
}
|
|
435
513
|
|
|
436
514
|
if (label === STATE_LABELS.EXECUTING) {
|
|
437
|
-
|
|
438
|
-
return {
|
|
439
|
-
shape: 'executing-with-pr',
|
|
440
|
-
nextCommand: NEXT_COMMANDS.close(storyId),
|
|
441
|
-
detail:
|
|
442
|
-
`PR #${pr.number} exists but the Story is still \`agent::executing\` — the close ` +
|
|
443
|
-
`opened the PR and then died before the label flip. Re-run close; it reuses the ` +
|
|
444
|
-
`open PR rather than opening a duplicate.`,
|
|
445
|
-
evidence,
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
// Story #4816 — the close artifacts get the first word here, and ONLY
|
|
449
|
-
// here. Every other row of this table describes a state whose evidence is
|
|
450
|
-
// already unambiguous; `executing` + no PR is the one row that reads
|
|
451
|
-
// identically for a dead implementation and for a close that is halfway
|
|
452
|
-
// through its gate chain, and answering it from labels alone is what sent
|
|
453
|
-
// operators to re-init on top of a live close.
|
|
454
|
-
if (closeLooksLive(closeArtifacts)) {
|
|
455
|
-
return closeInFlightVerdict({
|
|
456
|
-
storyId,
|
|
457
|
-
artifacts: closeArtifacts,
|
|
458
|
-
evidence,
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
if (closeArtifacts?.envelope) {
|
|
462
|
-
return envelopeOnDiskVerdict({
|
|
463
|
-
storyId,
|
|
464
|
-
artifacts: closeArtifacts,
|
|
465
|
-
evidence,
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
return {
|
|
469
|
-
shape: 'executing-no-pr',
|
|
470
|
-
nextCommand: NEXT_COMMANDS.implement(storyId),
|
|
471
|
-
detail:
|
|
472
|
-
`Story is \`agent::executing\` with no PR, and no close left an artifact behind (no ` +
|
|
473
|
-
`persisted terminal envelope, no recent gate log) — implementation never finished. ` +
|
|
474
|
-
`Re-init (idempotent — it reuses the existing branch and worktree) and resume in the ` +
|
|
475
|
-
`worktree it prints.`,
|
|
476
|
-
evidence,
|
|
477
|
-
};
|
|
515
|
+
return decideExecuting({ storyId, branch, pr, closeArtifacts, evidence });
|
|
478
516
|
}
|
|
479
517
|
|
|
480
518
|
return {
|
|
@@ -500,6 +538,7 @@ export function decideRecovery({
|
|
|
500
538
|
*/
|
|
501
539
|
const TRANSIENT_SHAPES = new Set([
|
|
502
540
|
'executing-no-pr',
|
|
541
|
+
'executing-pushed-no-pr',
|
|
503
542
|
'executing-with-pr',
|
|
504
543
|
'closing-no-pr',
|
|
505
544
|
'closing-pr-pending',
|
|
@@ -92,7 +92,7 @@ export async function findDependencyCandidates({
|
|
|
92
92
|
(p) => typeof p === 'string' && p.trim() !== '',
|
|
93
93
|
);
|
|
94
94
|
if (wanted.length === 0) return [];
|
|
95
|
-
if (typeof provider?.
|
|
95
|
+
if (typeof provider?.listTicketsByLabel !== 'function') return [];
|
|
96
96
|
|
|
97
97
|
const excluded = new Set(
|
|
98
98
|
[...excludeIds].map((id) => Number(id)).filter((n) => Number.isFinite(n)),
|
|
@@ -100,7 +100,7 @@ export async function findDependencyCandidates({
|
|
|
100
100
|
|
|
101
101
|
let issues;
|
|
102
102
|
try {
|
|
103
|
-
issues = await provider.
|
|
103
|
+
issues = await provider.listTicketsByLabel({
|
|
104
104
|
state: 'open',
|
|
105
105
|
labels: TYPE_LABELS.STORY,
|
|
106
106
|
});
|
|
@@ -113,7 +113,11 @@ export async function findDependencyCandidates({
|
|
|
113
113
|
|
|
114
114
|
const out = [];
|
|
115
115
|
for (const issue of Array.isArray(issues) ? issues : []) {
|
|
116
|
-
|
|
116
|
+
// The declared ticket shape: `id` is the issue number. Reading it through
|
|
117
|
+
// the old `number`-then-`id` fallback was the bug in waiting — on this
|
|
118
|
+
// shape the fallback never fires, and on a raw REST payload it silently
|
|
119
|
+
// produced database ids for every candidate the planner offered.
|
|
120
|
+
const id = Number(issue?.id);
|
|
117
121
|
if (!Number.isInteger(id) || id <= 0 || excluded.has(id)) continue;
|
|
118
122
|
|
|
119
123
|
const footprint = footprintOf(issue);
|
|
@@ -125,7 +129,7 @@ export async function findDependencyCandidates({
|
|
|
125
129
|
out.push({
|
|
126
130
|
id,
|
|
127
131
|
title: typeof issue?.title === 'string' ? issue.title : '',
|
|
128
|
-
url: issue?.
|
|
132
|
+
url: issue?.url ?? buildStoryUrl(id, { owner, repo }),
|
|
129
133
|
state: typeof issue?.state === 'string' ? issue.state : 'open',
|
|
130
134
|
overlappingPaths,
|
|
131
135
|
});
|
|
@@ -81,7 +81,12 @@ async function readChildTitles({ childIds, provider }) {
|
|
|
81
81
|
* @returns {Promise<{ id: number, title: string, url: string, score: number, childIds: number[] }|null>}
|
|
82
82
|
*/
|
|
83
83
|
async function scoreEpic({ epic, seedTokens, provider, owner, repo }) {
|
|
84
|
-
|
|
84
|
+
// `listTicketsByLabel` hands back the declared ticket shape, in which `id`
|
|
85
|
+
// is the issue number. The `number`-then-`id` fallback this replaced read as
|
|
86
|
+
// defensive and was not: on that shape it never fired, and on a raw REST
|
|
87
|
+
// payload it was the only thing standing between this and scoring an Epic
|
|
88
|
+
// under its database id.
|
|
89
|
+
const id = Number(epic?.id);
|
|
85
90
|
if (!Number.isInteger(id) || id <= 0) return null;
|
|
86
91
|
|
|
87
92
|
const title = typeof epic?.title === 'string' ? epic.title : '';
|
|
@@ -98,7 +103,7 @@ async function scoreEpic({ epic, seedTokens, provider, owner, repo }) {
|
|
|
98
103
|
return {
|
|
99
104
|
id,
|
|
100
105
|
title,
|
|
101
|
-
url: epic?.
|
|
106
|
+
url: epic?.url ?? buildEpicUrl(id, { owner, repo }),
|
|
102
107
|
score: Number(score.toFixed(4)),
|
|
103
108
|
childIds,
|
|
104
109
|
};
|
|
@@ -126,14 +131,14 @@ async function scoreEpic({ epic, seedTokens, provider, owner, repo }) {
|
|
|
126
131
|
*/
|
|
127
132
|
export async function findOpenEpicCandidates({ seed, provider, owner, repo }) {
|
|
128
133
|
if (typeof seed !== 'string' || seed.trim() === '') return [];
|
|
129
|
-
if (typeof provider?.
|
|
134
|
+
if (typeof provider?.listTicketsByLabel !== 'function') return [];
|
|
130
135
|
|
|
131
136
|
const seedTokens = tokenize(seed);
|
|
132
137
|
if (seedTokens.size === 0) return [];
|
|
133
138
|
|
|
134
139
|
let issues;
|
|
135
140
|
try {
|
|
136
|
-
issues = await provider.
|
|
141
|
+
issues = await provider.listTicketsByLabel({
|
|
137
142
|
state: 'open',
|
|
138
143
|
labels: TYPE_LABELS.EPIC,
|
|
139
144
|
});
|
|
@@ -129,14 +129,64 @@ export function isEpicTicket(issue) {
|
|
|
129
129
|
* really had. Callers skip the read on a `null` instead, the same clean
|
|
130
130
|
* no-op `providers/github/board-add.js` makes with `reason: 'no-node-id'`.
|
|
131
131
|
*
|
|
132
|
+
* Module-private since the reader that consumes it moved here: `nativeChildReader`
|
|
133
|
+
* below is the only production caller, and exporting a helper nothing outside
|
|
134
|
+
* imports fails the production dead-export gate. Its behaviour is pinned
|
|
135
|
+
* through that reader.
|
|
136
|
+
*
|
|
132
137
|
* @param {{ nodeId?: unknown, node_id?: unknown }} epic
|
|
133
138
|
* @returns {string|null}
|
|
134
139
|
*/
|
|
135
|
-
|
|
140
|
+
function resolveEpicNodeId(epic) {
|
|
136
141
|
const nodeId = epic?.nodeId ?? epic?.node_id;
|
|
137
142
|
return typeof nodeId === 'string' && nodeId !== '' ? nodeId : null;
|
|
138
143
|
}
|
|
139
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Read an Epic's native sub-issue children as issue numbers.
|
|
147
|
+
*
|
|
148
|
+
* The **one** definition, injected into `readEpicChildIdsFrom` by both the
|
|
149
|
+
* delivery expansion (`resolve-stories.js`) and the rollup
|
|
150
|
+
* (`epic-rollup.js`). It lived in each of them as a private copy, and the two
|
|
151
|
+
* copies are exactly the pair that must not drift: if the expansion sees a
|
|
152
|
+
* child the rollup does not, an Epic becomes expandable but permanently
|
|
153
|
+
* unclosable — the Story #5210 failure, arrived at from the other direction.
|
|
154
|
+
* Sharing the reader makes that class of divergence unrepresentable.
|
|
155
|
+
*
|
|
156
|
+
* It lives *here*, in the module that already describes what a container Epic
|
|
157
|
+
* is, rather than in either consumer: `resolve-stories.js` is a CLI entrypoint
|
|
158
|
+
* and importing one from the lib layer would invert the dependency direction.
|
|
159
|
+
* The provider is a parameter, so this module stays provider-agnostic.
|
|
160
|
+
*
|
|
161
|
+
* `resolveEpicNodeId` is what makes the two callers agree on the *id* as well
|
|
162
|
+
* as the reader: an Epic reached through a mapped read carries `nodeId`, one
|
|
163
|
+
* read raw from REST carries `node_id`, and neither caller can tell from the
|
|
164
|
+
* value it holds. A missing id yields `[]` rather than an `undefined` reaching
|
|
165
|
+
* GraphQL as a rejected `ID!`.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} provider
|
|
168
|
+
* @returns {(epic: object) => Promise<number[]>}
|
|
169
|
+
*/
|
|
170
|
+
export function nativeChildReader(provider) {
|
|
171
|
+
return async (epic) => {
|
|
172
|
+
const nodeId = resolveEpicNodeId(epic);
|
|
173
|
+
if (nodeId === null) return [];
|
|
174
|
+
// The declared port first, the legacy private alias second: both forward
|
|
175
|
+
// to the same gateway on the live provider, and the fallback is what keeps
|
|
176
|
+
// test doubles written against the older name working.
|
|
177
|
+
const read =
|
|
178
|
+
provider?.getNativeSubIssues ?? provider?._getNativeSubIssues ?? null;
|
|
179
|
+
if (typeof read !== 'function') return [];
|
|
180
|
+
// Diagnostics-only second argument, and the one place the two shapes are
|
|
181
|
+
// still read together on purpose: this module is the declared bridge
|
|
182
|
+
// between them (see `resolveEpicNodeId` above), and the expression is
|
|
183
|
+
// correct under both — a raw REST issue names the issue number `number`,
|
|
184
|
+
// a mapped ticket names it `id`. Every *consumer* module now receives one
|
|
185
|
+
// declared shape and reads the field directly.
|
|
186
|
+
return (await read.call(provider, nodeId, epic?.number ?? epic?.id)) ?? [];
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
140
190
|
/**
|
|
141
191
|
* Render a container Epic's body.
|
|
142
192
|
*
|
|
@@ -230,12 +280,22 @@ export function readEpicChildIds(body) {
|
|
|
230
280
|
* supplied none never asked for authority and is not degraded relative to what
|
|
231
281
|
* it requested.
|
|
232
282
|
*
|
|
283
|
+
* **`bodyOnlyIds` names the ids the union owes to the checklist alone.** The
|
|
284
|
+
* two sources are not equally trustworthy about a *single* id: a native edge
|
|
285
|
+
* is a link the backend holds, so an id it returns names a real issue, while a
|
|
286
|
+
* checklist row is hand-editable prose and can cite an issue that was deleted,
|
|
287
|
+
* transferred, or simply mistyped. Callers that must decide what an
|
|
288
|
+
* unresolvable id means need to know which source vouched for it — a native id
|
|
289
|
+
* that will not resolve is a failed read, a body-only one is a typo. Empty
|
|
290
|
+
* when the native read failed or never ran: with no authoritative source to
|
|
291
|
+
* contrast against, nothing is "body-only" in the sense that matters.
|
|
292
|
+
*
|
|
233
293
|
* @param {{
|
|
234
294
|
* epic: { number?: number, id?: number, body?: string, nodeId?: string },
|
|
235
295
|
* readNativeChildIds?: (epic: object) => Promise<number[]>,
|
|
236
296
|
* onWarn?: (message: string) => void,
|
|
237
297
|
* }} opts
|
|
238
|
-
* @returns {Promise<{ ids: number[], nativeReadFailed: boolean }>}
|
|
298
|
+
* @returns {Promise<{ ids: number[], nativeReadFailed: boolean, bodyOnlyIds: number[] }>}
|
|
239
299
|
*/
|
|
240
300
|
export async function readEpicChildIdsFrom({
|
|
241
301
|
epic,
|
|
@@ -244,14 +304,16 @@ export async function readEpicChildIdsFrom({
|
|
|
244
304
|
} = {}) {
|
|
245
305
|
const fromBody = readEpicChildIds(epic?.body);
|
|
246
306
|
if (typeof readNativeChildIds !== 'function') {
|
|
247
|
-
return { ids: fromBody, nativeReadFailed: false };
|
|
307
|
+
return { ids: fromBody, nativeReadFailed: false, bodyOnlyIds: [] };
|
|
248
308
|
}
|
|
249
309
|
|
|
250
310
|
try {
|
|
251
311
|
const native = normalizeChildIds(await readNativeChildIds(epic));
|
|
312
|
+
const nativeSet = new Set(native);
|
|
252
313
|
return {
|
|
253
314
|
ids: normalizeChildIds([...native, ...fromBody]),
|
|
254
315
|
nativeReadFailed: false,
|
|
316
|
+
bodyOnlyIds: fromBody.filter((id) => !nativeSet.has(id)),
|
|
255
317
|
};
|
|
256
318
|
} catch (err) {
|
|
257
319
|
onWarn?.(
|
|
@@ -259,6 +321,6 @@ export async function readEpicChildIdsFrom({
|
|
|
259
321
|
`#${epic?.number ?? epic?.id ?? '?'} (${err?.message ?? String(err)}); ` +
|
|
260
322
|
'using the body checklist alone — the child list may be incomplete.',
|
|
261
323
|
);
|
|
262
|
-
return { ids: fromBody, nativeReadFailed: true };
|
|
324
|
+
return { ids: fromBody, nativeReadFailed: true, bodyOnlyIds: [] };
|
|
263
325
|
}
|
|
264
326
|
}
|