mandrel 1.58.0 → 1.59.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/README.md +89 -87
- package/.agents/docs/SDLC.md +11 -7
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/audit-rules.json +20 -0
- package/.agents/scripts/acceptance-eval.js +20 -3
- package/.agents/scripts/assert-branch.js +1 -3
- package/.agents/scripts/bootstrap.js +1 -1
- package/.agents/scripts/check-arch-cycles.js +360 -0
- package/.agents/scripts/coverage-capture.js +24 -3
- package/.agents/scripts/epic-deliver-preflight.js +5 -3
- package/.agents/scripts/epic-deliver-prepare.js +12 -4
- package/.agents/scripts/epic-execute-record-wave.js +1 -1
- package/.agents/scripts/evidence-gate.js +1 -1
- package/.agents/scripts/git-rebase-and-resolve.js +1 -1
- package/.agents/scripts/hierarchy-gate.js +34 -14
- package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
- package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
- package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
- package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
- package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
- package/.agents/scripts/lib/baselines/writer.js +1 -1
- package/.agents/scripts/lib/close-validation/commands.js +188 -0
- package/.agents/scripts/lib/close-validation/gates.js +235 -0
- package/.agents/scripts/lib/close-validation/process.js +101 -0
- package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
- package/.agents/scripts/lib/close-validation/runner.js +325 -0
- package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
- package/.agents/scripts/lib/config/quality.js +6 -6
- package/.agents/scripts/lib/config-resolver.js +2 -5
- package/.agents/scripts/lib/coverage-capture.js +147 -4
- package/.agents/scripts/lib/cpu-pool.js +14 -0
- package/.agents/scripts/lib/crap-utils.js +6 -11
- package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
- package/.agents/scripts/lib/git-utils.js +24 -22
- package/.agents/scripts/lib/maintainability-engine.js +1 -1
- package/.agents/scripts/lib/maintainability-utils.js +4 -187
- package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
- package/.agents/scripts/lib/orchestration/code-review.js +90 -77
- package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
- package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
- package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
- package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
- package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
- package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
- package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
- package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
- package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
- package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
- package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
- package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
- package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
- package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
- package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
- package/.agents/scripts/lib/project-root.js +17 -0
- package/.agents/scripts/lib/story-adjacency.js +76 -0
- package/.agents/scripts/lib/story-lifecycle.js +1 -1
- package/.agents/scripts/lib/transpile.js +93 -0
- package/.agents/scripts/lib/wave-runner/tick.js +4 -153
- package/.agents/scripts/lib/workers/crap-worker.js +1 -1
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
- package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
- package/.agents/scripts/providers/github/tickets.js +110 -6
- package/.agents/scripts/run-lint.js +9 -0
- package/.agents/scripts/run-tests.js +24 -4
- package/.agents/scripts/stories-wave-tick.js +8 -5
- package/.agents/scripts/story-init.js +149 -10
- package/.agents/scripts/sync-branch-from-base.js +1 -1
- package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
- package/.agents/workflows/audit-documentation.md +226 -0
- package/.agents/workflows/epic-deliver.md +16 -23
- package/.agents/workflows/epic-plan.md +1 -1
- package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
- package/.agents/workflows/helpers/single-story-deliver.md +2 -1
- package/.agents/workflows/onboard.md +4 -3
- package/.agents/workflows/story-deliver.md +1 -1
- package/README.md +13 -8
- package/lib/cli/init.js +336 -0
- package/package.json +2 -1
- package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
- package/.agents/scripts/lib/close-validation.js +0 -897
- package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
- package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
- package/.agents/scripts/lib/task-utils.js +0 -26
- package/.agents/scripts/story-deliver-prepare.js +0 -267
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
* therefore validates each Story against the **simulated post-predecessor
|
|
26
26
|
* tree** — base-branch existence overlaid with the create/delete delta of
|
|
27
27
|
* the Story's transitive `depends_on` predecessors (the same reachability
|
|
28
|
-
* walk the conflict gate uses, imported from
|
|
29
|
-
* rather than re-derived). Two extra
|
|
28
|
+
* walk the conflict gate uses, imported from the shared
|
|
29
|
+
* `story-reachability.js` leaf rather than re-derived). Two extra
|
|
30
|
+
* wave-aware rules layer on top of the
|
|
30
31
|
* base-branch rules:
|
|
31
32
|
* - `creates` + path created by a **predecessor** → mismatch
|
|
32
33
|
* (`expected: 'refactors-existing'`) telling the planner to declare
|
|
@@ -48,8 +49,8 @@
|
|
|
48
49
|
import { gitSpawn } from '../git-utils.js';
|
|
49
50
|
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
50
51
|
import { FILE_ASSUMPTION_VALUES } from './file-assumption-enum.js';
|
|
52
|
+
import { computeStoryReachability } from './story-reachability.js';
|
|
51
53
|
import { isObjectPathEntry } from './task-body-validator.js';
|
|
52
|
-
import { computeStoryReachability } from './ticket-validator-conflicts.js';
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
56
|
* Default git probe — returns `true` when `path` exists at
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lease-guard-shared.js — Story #3992: single-source the lease-acquisition
|
|
3
|
+
* kernel shared by the three per-surface lease guards.
|
|
4
|
+
*
|
|
5
|
+
* `epic-deliver-lease-guard.js`, `epic-plan-lease-guard.js`, and
|
|
6
|
+
* `single-story-lease-guard.js` historically each carried their own copy of
|
|
7
|
+
* the operator-handle resolution and the fail-closed acquire wrapper around
|
|
8
|
+
* `ticket-lease.acquireLease` (anchor `heartbeatAt` to `now` so a foreign
|
|
9
|
+
* assignee always reads as a live claim, then throw an operator-facing
|
|
10
|
+
* refusal naming the current owner unless `--steal`). The three copies had
|
|
11
|
+
* already diverged — different `resolveOperator` signatures and different
|
|
12
|
+
* missing-handle behaviour (`null` vs `throw`) — and were synchronised only
|
|
13
|
+
* by docstring promise ("This mirrors the sibling lease guards…").
|
|
14
|
+
*
|
|
15
|
+
* This module is the single home for the kernel, modelled on the
|
|
16
|
+
* shared plumbing inside `story-close/format-autofix.js` (Story #3332,
|
|
17
|
+
* consolidated by Story #4017). The
|
|
18
|
+
* per-surface guards now differ only in injected **policy**:
|
|
19
|
+
*
|
|
20
|
+
* - **Operator candidates** — each surface supplies its own ordered
|
|
21
|
+
* candidate list (e.g. `--as` flag → `github.operatorHandle` →
|
|
22
|
+
* `git user.email` for `/epic-deliver`; bare `operatorHandle` for the
|
|
23
|
+
* plan/standalone paths).
|
|
24
|
+
* - **Missing-handle behaviour** — `'null'` (return null; the caller fails
|
|
25
|
+
* closed at acquire time) vs `'throw'` (refuse immediately with surface
|
|
26
|
+
* wording). The divergence between the plan path (`null`) and the
|
|
27
|
+
* standalone path (`throw`) is intentional: the plan path also calls
|
|
28
|
+
* `resolveOperator` on its best-effort release leg, where a missing
|
|
29
|
+
* handle must degrade to a `no-operator` no-op rather than throw.
|
|
30
|
+
* - **Refusal wording** — each surface renders its own operator-facing
|
|
31
|
+
* message via `renderRefusal(result, ticketId)`.
|
|
32
|
+
* - **Liveness anchoring** — the plan/standalone paths have no heartbeat
|
|
33
|
+
* ledger, so they anchor `heartbeatAt` to `now` (fail-closed: every
|
|
34
|
+
* foreign claim reads live). `/epic-deliver` threads a real
|
|
35
|
+
* `heartbeatAt` through from the lifecycle ledger, so it opts out of
|
|
36
|
+
* anchoring.
|
|
37
|
+
*
|
|
38
|
+
* The unclaimed / already-held / foreign-claim decision table itself (and
|
|
39
|
+
* the steal transfer) lives in `ticket-lease.acquireLease`; this kernel owns
|
|
40
|
+
* the fail-closed parameterisation and the refuse-by-throw boundary that the
|
|
41
|
+
* three guards previously each re-implemented.
|
|
42
|
+
*
|
|
43
|
+
* Per `.agents/rules/orchestration-error-handling.md`, failures surface via
|
|
44
|
+
* `throw new Error(...)`, never `Logger.fatal`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { acquireLease, normalizeOperatorHandle } from './ticket-lease.js';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the operator handle from an ordered candidate list, applying the
|
|
51
|
+
* surface's missing-handle policy.
|
|
52
|
+
*
|
|
53
|
+
* Each candidate is passed through the shared `normalizeOperatorHandle` so a
|
|
54
|
+
* leading `@` is stripped (the assignees API expects bare logins, not
|
|
55
|
+
* `@`-prefixed mentions) and the shipped `@[USERNAME]` placeholder maps to
|
|
56
|
+
* `null` — otherwise the assignee PATCH is rejected (HTTP 422) and the
|
|
57
|
+
* self-held-claim comparison (`owner === operator`) never matches. The first
|
|
58
|
+
* candidate that normalises to a non-null handle wins.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} opts
|
|
61
|
+
* @param {Array<string|null|undefined>} opts.candidates Ordered raw handles.
|
|
62
|
+
* @param {'null'|'throw'} [opts.missingHandleBehavior='null'] What to do
|
|
63
|
+
* when no candidate resolves: return `null`, or throw with the surface's
|
|
64
|
+
* configured wording.
|
|
65
|
+
* @param {string} [opts.missingHandleMessage] Error message used when
|
|
66
|
+
* `missingHandleBehavior` is `'throw'`.
|
|
67
|
+
* @returns {string|null} Bare operator handle, or `null` (policy `'null'`).
|
|
68
|
+
* @throws {Error} When no candidate resolves and the policy is `'throw'`.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveOperatorFromCandidates({
|
|
71
|
+
candidates,
|
|
72
|
+
missingHandleBehavior = 'null',
|
|
73
|
+
missingHandleMessage,
|
|
74
|
+
} = {}) {
|
|
75
|
+
for (const raw of candidates ?? []) {
|
|
76
|
+
const normalized = normalizeOperatorHandle(raw);
|
|
77
|
+
if (normalized !== null) return normalized;
|
|
78
|
+
}
|
|
79
|
+
if (missingHandleBehavior === 'throw') {
|
|
80
|
+
throw new Error(missingHandleMessage);
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Acquire a ticket lease, failing closed by throwing the surface's refusal
|
|
87
|
+
* message when the claim is refused (live foreign owner, no `steal`).
|
|
88
|
+
*
|
|
89
|
+
* When `anchorHeartbeatToNow` is set (the plan/standalone paths, which have
|
|
90
|
+
* no heartbeat ledger), `heartbeatAt` and `now` are both anchored to the
|
|
91
|
+
* same resolved clock value so `isClaimLive` returns true for ANY foreign
|
|
92
|
+
* owner — `acquireLease` then refuses a foreign assignee unless `steal` is
|
|
93
|
+
* set, while unclaimed and self-held tickets still proceed without a write.
|
|
94
|
+
* When unset (`/epic-deliver`), the caller-supplied `heartbeatAt` / `now`
|
|
95
|
+
* pass through untouched.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} opts
|
|
98
|
+
* @param {object} opts.provider Ticketing provider.
|
|
99
|
+
* @param {number} opts.ticketId Ticket to claim.
|
|
100
|
+
* @param {string} opts.operator Resolved operator handle.
|
|
101
|
+
* @param {number|null} [opts.heartbeatAt=null] Current owner's last
|
|
102
|
+
* heartbeat (epoch ms). Ignored when `anchorHeartbeatToNow` is set.
|
|
103
|
+
* @param {boolean} [opts.steal=false] Forcibly transfer a foreign claim.
|
|
104
|
+
* @param {object} [opts.config] Resolved config (TTL default).
|
|
105
|
+
* @param {number} [opts.now] Injectable clock (epoch ms; tests).
|
|
106
|
+
* @param {boolean} [opts.anchorHeartbeatToNow=false] Fail-closed liveness
|
|
107
|
+
* anchoring for surfaces with no heartbeat source.
|
|
108
|
+
* @param {(result: object, ticketId: number) => string} opts.renderRefusal
|
|
109
|
+
* Renders the operator-facing refusal message for a refused claim.
|
|
110
|
+
* @returns {Promise<{ acquired: boolean, owner: string, previousOwner: string|null, reason: string }>}
|
|
111
|
+
* @throws {Error} When the claim is refused (`result.acquired === false`).
|
|
112
|
+
*/
|
|
113
|
+
export async function acquireLeaseFailClosed({
|
|
114
|
+
provider,
|
|
115
|
+
ticketId,
|
|
116
|
+
operator,
|
|
117
|
+
heartbeatAt = null,
|
|
118
|
+
steal = false,
|
|
119
|
+
config,
|
|
120
|
+
now,
|
|
121
|
+
anchorHeartbeatToNow = false,
|
|
122
|
+
renderRefusal,
|
|
123
|
+
}) {
|
|
124
|
+
let resolvedHeartbeatAt = heartbeatAt;
|
|
125
|
+
let resolvedNow = now;
|
|
126
|
+
if (anchorHeartbeatToNow) {
|
|
127
|
+
resolvedNow =
|
|
128
|
+
typeof now === 'number' && Number.isFinite(now) ? now : Date.now();
|
|
129
|
+
resolvedHeartbeatAt = resolvedNow;
|
|
130
|
+
}
|
|
131
|
+
const result = await acquireLease({
|
|
132
|
+
provider,
|
|
133
|
+
ticketId,
|
|
134
|
+
operator,
|
|
135
|
+
heartbeatAt: resolvedHeartbeatAt,
|
|
136
|
+
steal,
|
|
137
|
+
config,
|
|
138
|
+
now: resolvedNow,
|
|
139
|
+
});
|
|
140
|
+
if (!result.acquired) {
|
|
141
|
+
throw new Error(renderRefusal(result, ticketId));
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
*
|
|
24
24
|
* The schema declares `additionalProperties: false`, so this emitter's
|
|
25
25
|
* signature is deliberately narrow: only the schema-allowed fields are
|
|
26
|
-
* accepted.
|
|
27
|
-
*
|
|
26
|
+
* accepted. The earlier per-child Task id and progress counters
|
|
27
|
+
* were dropped under Epic #3078's
|
|
28
28
|
* 3-tier hard cutover — they would fail strict validation and have no
|
|
29
29
|
* meaning now that the Story is the leaf execution unit with no child
|
|
30
30
|
* tickets. The optional `operator` field (Story #3480) records the handle
|
|
@@ -109,7 +109,7 @@ export const extractPrNumber = parsePrNumberFromUrl;
|
|
|
109
109
|
*
|
|
110
110
|
* Exported so tests can stub.
|
|
111
111
|
*/
|
|
112
|
-
|
|
112
|
+
function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
113
113
|
const result = spawnFn(
|
|
114
114
|
'gh',
|
|
115
115
|
[
|
|
@@ -134,7 +134,7 @@ export function ghPrChecks({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
134
134
|
* can detect the BEHIND condition (PR head is behind its base branch)
|
|
135
135
|
* AFTER every required check is green. Exported so tests can stub.
|
|
136
136
|
*/
|
|
137
|
-
|
|
137
|
+
function ghPrView({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
138
138
|
const result = spawnFn(
|
|
139
139
|
'gh',
|
|
140
140
|
['pr', 'view', prUrl, '--json', 'mergeStateStatus'],
|
|
@@ -154,7 +154,7 @@ export function ghPrView({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
154
154
|
* BEHIND" (the conservative recovery branch). Pure — exported for
|
|
155
155
|
* tests.
|
|
156
156
|
*/
|
|
157
|
-
|
|
157
|
+
function parseMergeStateStatus(stdout) {
|
|
158
158
|
const trimmed = String(stdout ?? '').trim();
|
|
159
159
|
if (trimmed.length === 0) return '';
|
|
160
160
|
try {
|
|
@@ -172,7 +172,7 @@ export function parseMergeStateStatus(stdout) {
|
|
|
172
172
|
* loop to fast-forward the PR head with its base branch. Exported so
|
|
173
173
|
* tests can stub and assert call counts.
|
|
174
174
|
*/
|
|
175
|
-
|
|
175
|
+
function ghPrUpdateBranch({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
176
176
|
const result = spawnFn('gh', ['pr', 'update-branch', prUrl], {
|
|
177
177
|
cwd,
|
|
178
178
|
encoding: 'utf-8',
|
|
@@ -192,7 +192,7 @@ export function ghPrUpdateBranch({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
192
192
|
* the same definition as the downstream predicate. Pure — exported
|
|
193
193
|
* for tests.
|
|
194
194
|
*/
|
|
195
|
-
|
|
195
|
+
const GREEN_CHECK_OUTCOMES = Object.freeze(
|
|
196
196
|
new Set(['success', 'neutral', 'skipped']),
|
|
197
197
|
);
|
|
198
198
|
|
|
@@ -202,7 +202,7 @@ export const GREEN_CHECK_OUTCOMES = Object.freeze(
|
|
|
202
202
|
* regardless of mergeStateStatus, so we never auto-recover into a
|
|
203
203
|
* failing PR.
|
|
204
204
|
*/
|
|
205
|
-
|
|
205
|
+
function allGreen(outcomes) {
|
|
206
206
|
const values = Object.values(outcomes);
|
|
207
207
|
if (values.length === 0) return false;
|
|
208
208
|
for (const v of values) {
|
|
@@ -271,7 +271,7 @@ export function allTerminal(outcomes) {
|
|
|
271
271
|
* behaviour is reviewable. Called only when the poll loop exits via
|
|
272
272
|
* the iteration cap.
|
|
273
273
|
*/
|
|
274
|
-
|
|
274
|
+
function promotePendingToTimedOut(outcomes) {
|
|
275
275
|
const out = {};
|
|
276
276
|
for (const [k, v] of Object.entries(outcomes)) {
|
|
277
277
|
out[k] = v === 'pending' ? 'timed_out' : v;
|
|
@@ -21,7 +21,7 @@ export async function notificationPhase(ctx, state) {
|
|
|
21
21
|
storyId,
|
|
22
22
|
story,
|
|
23
23
|
epicBranch,
|
|
24
|
-
|
|
24
|
+
config,
|
|
25
25
|
progress,
|
|
26
26
|
provider,
|
|
27
27
|
notifyFn = notify,
|
|
@@ -39,7 +39,7 @@ export async function notificationPhase(ctx, state) {
|
|
|
39
39
|
level: 'story',
|
|
40
40
|
epicId,
|
|
41
41
|
},
|
|
42
|
-
{
|
|
42
|
+
{ config },
|
|
43
43
|
);
|
|
44
44
|
// Fire a rolled-up `epic-progress` webhook so operators see the Epic's
|
|
45
45
|
// overall stories-done count tick up at each story-close, without
|
|
@@ -66,7 +66,7 @@ export async function notificationPhase(ctx, state) {
|
|
|
66
66
|
level: 'epic',
|
|
67
67
|
epicId,
|
|
68
68
|
},
|
|
69
|
-
{
|
|
69
|
+
{ config, skipComment: true },
|
|
70
70
|
);
|
|
71
71
|
} catch (err) {
|
|
72
72
|
logger?.warn?.(
|
|
@@ -54,9 +54,9 @@ function findStillRegisteredEntry(entries, storyId) {
|
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function resolveWorktreeRoot(repoRoot,
|
|
57
|
+
function resolveWorktreeRoot(repoRoot, delivery) {
|
|
58
58
|
if (!repoRoot) return null;
|
|
59
|
-
const configuredRoot =
|
|
59
|
+
const configuredRoot = delivery?.worktreeIsolation?.root ?? '.worktrees';
|
|
60
60
|
return path.join(repoRoot, configuredRoot);
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -263,7 +263,7 @@ function logStaleRegistryEntry({ state, stillRegistered, logger }) {
|
|
|
263
263
|
|
|
264
264
|
export async function worktreeReapPhase(ctx) {
|
|
265
265
|
const {
|
|
266
|
-
|
|
266
|
+
delivery,
|
|
267
267
|
storyId,
|
|
268
268
|
epicId,
|
|
269
269
|
epicBranch,
|
|
@@ -276,7 +276,7 @@ export async function worktreeReapPhase(ctx) {
|
|
|
276
276
|
recordPendingCleanupFn = recordPendingCleanup,
|
|
277
277
|
pathExistsFn = fs.existsSync,
|
|
278
278
|
} = ctx;
|
|
279
|
-
const wtConfig =
|
|
279
|
+
const wtConfig = delivery?.worktreeIsolation;
|
|
280
280
|
const log = reapPhaseLogger(progress);
|
|
281
281
|
const skipState = resolveSkipState(wtConfig, log);
|
|
282
282
|
if (skipState) return skipState;
|
|
@@ -309,7 +309,7 @@ export async function worktreeReapPhase(ctx) {
|
|
|
309
309
|
stillRegistered,
|
|
310
310
|
reapResult,
|
|
311
311
|
storyId,
|
|
312
|
-
|
|
312
|
+
delivery,
|
|
313
313
|
repoRoot,
|
|
314
314
|
logger,
|
|
315
315
|
recordPendingCleanupFn,
|
|
@@ -352,7 +352,7 @@ function applyStillRegisteredState({
|
|
|
352
352
|
stillRegistered,
|
|
353
353
|
reapResult,
|
|
354
354
|
storyId,
|
|
355
|
-
|
|
355
|
+
delivery,
|
|
356
356
|
repoRoot,
|
|
357
357
|
logger,
|
|
358
358
|
recordPendingCleanupFn,
|
|
@@ -370,7 +370,7 @@ function applyStillRegisteredState({
|
|
|
370
370
|
reason: 'still-registered-after-reap',
|
|
371
371
|
};
|
|
372
372
|
}
|
|
373
|
-
const worktreeRoot = resolveWorktreeRoot(repoRoot,
|
|
373
|
+
const worktreeRoot = resolveWorktreeRoot(repoRoot, delivery);
|
|
374
374
|
let manifestEntry = null;
|
|
375
375
|
if (worktreeRoot) {
|
|
376
376
|
try {
|
|
@@ -53,28 +53,51 @@ export function preflightCachePath({ epicId, cwd }) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* Per-ticket fingerprint fields shared by the Epic and each Story in the
|
|
57
|
+
* cache key: id, body, sorted labels, and updatedAt. Story bodies carry
|
|
58
|
+
* the dependency edges, so hashing them means a Story-dependency edit
|
|
59
|
+
* forces a cache miss (Story #4019 — the previous Epic-only key let
|
|
60
|
+
* dependency edits slip through unnoticed).
|
|
61
|
+
*
|
|
62
|
+
* @param {{ id?: number|string, number?: number|string, body?: string, labels?: string[], updatedAt?: string }} ticket
|
|
63
|
+
* @returns {{ id: number|string|null, body: string, labels: string[], updatedAt: string|null }}
|
|
64
|
+
*/
|
|
65
|
+
function ticketFingerprint(ticket) {
|
|
66
|
+
const t = ticket && typeof ticket === 'object' ? ticket : {};
|
|
67
|
+
return {
|
|
68
|
+
id: t.id ?? t.number ?? null,
|
|
69
|
+
body: typeof t.body === 'string' ? t.body : '',
|
|
70
|
+
labels: Array.isArray(t.labels) ? [...t.labels].map(String).sort() : [],
|
|
71
|
+
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Stable string fingerprint of an Epic snapshot **plus its Story
|
|
77
|
+
* dependency state**. The hash is keyed on the exact fields
|
|
78
|
+
* `runSnapshotPhase` reads (`getTicket(epicId)` return value) and on each
|
|
79
|
+
* Story's id/body/labels/updatedAt, so that any drift the snapshot or
|
|
80
|
+
* wave-DAG phases would observe — including a Story-dependency edit —
|
|
81
|
+
* forces a cache miss (Story #4019).
|
|
59
82
|
*
|
|
60
83
|
* Labels are sorted to absorb GitHub's non-deterministic label order
|
|
61
|
-
* across responses
|
|
84
|
+
* across responses; stories are sorted by id so enumeration order never
|
|
85
|
+
* perturbs the key. The hash is sha256; we return the full hex digest so
|
|
62
86
|
* the cache key is collision-resistant for the lifetime of a delivery.
|
|
63
87
|
*
|
|
64
88
|
* @param {{ id?: number|string, number?: number|string, body?: string, labels?: string[], updatedAt?: string }} epic
|
|
89
|
+
* @param {Array<{ id?: number|string, number?: number|string, body?: string, labels?: string[], updatedAt?: string }>} [stories]
|
|
65
90
|
* @returns {string}
|
|
66
91
|
*/
|
|
67
|
-
export function computeBaseSha(epic) {
|
|
92
|
+
export function computeBaseSha(epic, stories = []) {
|
|
68
93
|
if (!epic || typeof epic !== 'object') {
|
|
69
94
|
throw new TypeError('computeBaseSha: epic snapshot must be an object');
|
|
70
95
|
}
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const updatedAt = typeof epic.updatedAt === 'string' ? epic.updatedAt : null;
|
|
77
|
-
const payload = JSON.stringify({ id, body, labels, updatedAt });
|
|
96
|
+
const epicPrint = ticketFingerprint(epic);
|
|
97
|
+
const storyPrints = (Array.isArray(stories) ? stories : [])
|
|
98
|
+
.map(ticketFingerprint)
|
|
99
|
+
.sort((a, b) => Number(a.id ?? 0) - Number(b.id ?? 0));
|
|
100
|
+
const payload = JSON.stringify({ ...epicPrint, stories: storyPrints });
|
|
78
101
|
return createHash('sha256').update(payload).digest('hex');
|
|
79
102
|
}
|
|
80
103
|
|
|
@@ -31,6 +31,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
31
31
|
import fs from 'node:fs';
|
|
32
32
|
import os from 'node:os';
|
|
33
33
|
import path from 'node:path';
|
|
34
|
+
import { parseProviderFindings } from './parse-findings.js';
|
|
34
35
|
import { renderDepthDirective } from './review-depth.js';
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -173,66 +174,10 @@ export function mapCodexSeverity(raw) {
|
|
|
173
174
|
* @throws {Error} when stdout is not parseable JSON.
|
|
174
175
|
*/
|
|
175
176
|
export function parseCodexFindings(rawStdout) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
parsed = JSON.parse(text);
|
|
182
|
-
} catch (err) {
|
|
183
|
-
throw new Error(
|
|
184
|
-
`[codex-review] Failed to parse /codex:review stdout as JSON: ${
|
|
185
|
-
err?.message ?? err
|
|
186
|
-
}`,
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Unwrap a single layer of envelope when present.
|
|
191
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
192
|
-
if (Array.isArray(parsed.findings)) parsed = parsed.findings;
|
|
193
|
-
else if (parsed.result !== undefined) parsed = parsed.result;
|
|
194
|
-
else if (parsed.data !== undefined) parsed = parsed.data;
|
|
195
|
-
}
|
|
196
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
197
|
-
if (Array.isArray(parsed.findings)) parsed = parsed.findings;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (!Array.isArray(parsed)) return [];
|
|
201
|
-
|
|
202
|
-
/** @type {Finding[]} */
|
|
203
|
-
const findings = [];
|
|
204
|
-
for (const entry of parsed) {
|
|
205
|
-
if (!entry || typeof entry !== 'object') continue;
|
|
206
|
-
const title =
|
|
207
|
-
typeof entry.title === 'string' && entry.title.trim().length > 0
|
|
208
|
-
? entry.title.trim()
|
|
209
|
-
: null;
|
|
210
|
-
const body =
|
|
211
|
-
typeof entry.body === 'string' && entry.body.trim().length > 0
|
|
212
|
-
? entry.body
|
|
213
|
-
: typeof entry.message === 'string' && entry.message.trim().length > 0
|
|
214
|
-
? entry.message
|
|
215
|
-
: null;
|
|
216
|
-
if (!title || !body) continue;
|
|
217
|
-
|
|
218
|
-
/** @type {Finding} */
|
|
219
|
-
const finding = {
|
|
220
|
-
severity: mapCodexSeverity(entry.severity),
|
|
221
|
-
title,
|
|
222
|
-
body,
|
|
223
|
-
};
|
|
224
|
-
if (typeof entry.file === 'string' && entry.file.length > 0) {
|
|
225
|
-
finding.file = entry.file;
|
|
226
|
-
}
|
|
227
|
-
if (Number.isInteger(entry.line) && entry.line > 0) {
|
|
228
|
-
finding.line = entry.line;
|
|
229
|
-
}
|
|
230
|
-
if (typeof entry.category === 'string' && entry.category.length > 0) {
|
|
231
|
-
finding.category = entry.category;
|
|
232
|
-
}
|
|
233
|
-
findings.push(finding);
|
|
234
|
-
}
|
|
235
|
-
return findings;
|
|
177
|
+
return parseProviderFindings(rawStdout, {
|
|
178
|
+
errorPrefix: '[codex-review] Failed to parse /codex:review stdout as JSON',
|
|
179
|
+
mapSeverity: mapCodexSeverity,
|
|
180
|
+
});
|
|
236
181
|
}
|
|
237
182
|
|
|
238
183
|
/**
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
|
|
43
43
|
import { spawnSync } from 'node:child_process';
|
|
44
44
|
import path from 'node:path';
|
|
45
|
-
import {
|
|
46
|
-
import { runOnPool } from '../../cpu-pool.js';
|
|
45
|
+
import { POOL_SERIAL_THRESHOLD, runOnPool } from '../../cpu-pool.js';
|
|
47
46
|
import { gitSpawn } from '../../git-utils.js';
|
|
48
47
|
import {
|
|
49
48
|
calculateReport,
|
|
50
49
|
classifyReport,
|
|
51
50
|
} from '../../maintainability-engine.js';
|
|
52
|
-
import {
|
|
51
|
+
import { PROJECT_ROOT } from '../../project-root.js';
|
|
52
|
+
import { transpileIfNeeded } from '../../transpile.js';
|
|
53
53
|
import {
|
|
54
54
|
hashCommandConfig,
|
|
55
55
|
recordPass,
|
|
@@ -67,10 +67,11 @@ const MAINTAINABILITY_REPORT_WORKER_URL = new URL(
|
|
|
67
67
|
* `analyzeChangedFiles` scores in-process (the pre-pool serial path). At or
|
|
68
68
|
* above it, per-file `calculateReportForFile` scoring is offloaded to the
|
|
69
69
|
* shared worker pool so the event loop is not blocked during epic-scoped
|
|
70
|
-
* reviews (f-performance).
|
|
71
|
-
*
|
|
70
|
+
* reviews (f-performance). Single-sourced in `cpu-pool.js` (see the
|
|
71
|
+
* `POOL_SERIAL_THRESHOLD` docstring for the tuning rationale); the
|
|
72
|
+
* `SERIAL_THRESHOLD` export name is preserved as this module's public API.
|
|
72
73
|
*/
|
|
73
|
-
export const SERIAL_THRESHOLD =
|
|
74
|
+
export const SERIAL_THRESHOLD = POOL_SERIAL_THRESHOLD;
|
|
74
75
|
|
|
75
76
|
const JS_MAINTAINABILITY_EXTS = new Set(['.js', '.mjs', '.cjs']);
|
|
76
77
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-providers/parse-findings.js — shared JSON-findings parser.
|
|
3
|
+
*
|
|
4
|
+
* Story #3981 — extracts the verbatim-duplicated parsing logic from
|
|
5
|
+
* `parseCodexFindings` (codex.js) and `parseSecurityReviewFindings`
|
|
6
|
+
* (security-review.js) into one templated parser. Both adapters emit
|
|
7
|
+
* JSON; the parser is liberal in what it accepts:
|
|
8
|
+
* - A bare array of finding objects.
|
|
9
|
+
* - An object with a `findings` array.
|
|
10
|
+
* - Either shape wrapped in an outer envelope with a `result` or
|
|
11
|
+
* `data` key (covers minor wire-format drift across versions
|
|
12
|
+
* without re-shimming).
|
|
13
|
+
*
|
|
14
|
+
* Each entry's severity is funnelled through the caller-supplied
|
|
15
|
+
* `mapSeverity` so the canonical enum is the only thing that reaches
|
|
16
|
+
* the renderer. Entries without a `title` or `body` are skipped — the
|
|
17
|
+
* orchestrator cannot post an empty finding, and silently dropping the
|
|
18
|
+
* entry is safer than fabricating one.
|
|
19
|
+
*
|
|
20
|
+
* Per-provider deltas ride in as options:
|
|
21
|
+
* - `errorPrefix` — prefix for the JSON-parse failure message.
|
|
22
|
+
* - `mapSeverity` — provider severity vocabulary → canonical enum.
|
|
23
|
+
* - `defaultCategory` — when set, entries missing a `category` get
|
|
24
|
+
* this value (security-review defaults to `'security'`); when
|
|
25
|
+
* omitted, `category` is only set when present (codex behavior).
|
|
26
|
+
*
|
|
27
|
+
* @typedef {import('./types.js').Finding} Finding
|
|
28
|
+
* @typedef {import('./types.js').Severity} Severity
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a provider's raw stdout into `Finding[]`.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} rawStdout
|
|
35
|
+
* @param {{
|
|
36
|
+
* errorPrefix: string,
|
|
37
|
+
* mapSeverity: (raw: unknown) => Severity,
|
|
38
|
+
* defaultCategory?: string,
|
|
39
|
+
* }} options
|
|
40
|
+
* @returns {Finding[]}
|
|
41
|
+
* @throws {Error} when stdout is not parseable JSON.
|
|
42
|
+
*/
|
|
43
|
+
export function parseProviderFindings(rawStdout, options) {
|
|
44
|
+
const { errorPrefix, mapSeverity, defaultCategory } = options;
|
|
45
|
+
const text = (rawStdout ?? '').trim();
|
|
46
|
+
if (text.length === 0) return [];
|
|
47
|
+
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(text);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new Error(`${errorPrefix}: ${err?.message ?? err}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Unwrap a single layer of envelope when present.
|
|
56
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
57
|
+
if (Array.isArray(parsed.findings)) parsed = parsed.findings;
|
|
58
|
+
else if (parsed.result !== undefined) parsed = parsed.result;
|
|
59
|
+
else if (parsed.data !== undefined) parsed = parsed.data;
|
|
60
|
+
}
|
|
61
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
62
|
+
if (Array.isArray(parsed.findings)) parsed = parsed.findings;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!Array.isArray(parsed)) return [];
|
|
66
|
+
|
|
67
|
+
/** @type {Finding[]} */
|
|
68
|
+
const findings = [];
|
|
69
|
+
for (const entry of parsed) {
|
|
70
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
71
|
+
const title =
|
|
72
|
+
typeof entry.title === 'string' && entry.title.trim().length > 0
|
|
73
|
+
? entry.title.trim()
|
|
74
|
+
: null;
|
|
75
|
+
const body =
|
|
76
|
+
typeof entry.body === 'string' && entry.body.trim().length > 0
|
|
77
|
+
? entry.body
|
|
78
|
+
: typeof entry.message === 'string' && entry.message.trim().length > 0
|
|
79
|
+
? entry.message
|
|
80
|
+
: null;
|
|
81
|
+
if (!title || !body) continue;
|
|
82
|
+
|
|
83
|
+
/** @type {Finding} */
|
|
84
|
+
const finding = {
|
|
85
|
+
severity: mapSeverity(entry.severity),
|
|
86
|
+
title,
|
|
87
|
+
body,
|
|
88
|
+
};
|
|
89
|
+
const category =
|
|
90
|
+
typeof entry.category === 'string' && entry.category.length > 0
|
|
91
|
+
? entry.category
|
|
92
|
+
: defaultCategory;
|
|
93
|
+
if (category !== undefined) {
|
|
94
|
+
finding.category = category;
|
|
95
|
+
}
|
|
96
|
+
if (typeof entry.file === 'string' && entry.file.length > 0) {
|
|
97
|
+
finding.file = entry.file;
|
|
98
|
+
}
|
|
99
|
+
if (Number.isInteger(entry.line) && entry.line > 0) {
|
|
100
|
+
finding.line = entry.line;
|
|
101
|
+
}
|
|
102
|
+
findings.push(finding);
|
|
103
|
+
}
|
|
104
|
+
return findings;
|
|
105
|
+
}
|