mandrel 2.32.0 → 2.33.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 +1 -0
- package/.agents/runtime-deps.json +2 -1
- package/.agents/schemas/agentrc.schema.json +6 -0
- 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/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/config/runners.js +38 -16
- package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
- 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 +69 -12
- 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-reference.md +28 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +57 -0
- package/.agents/workflows/helpers/plan-reference.md +76 -0
- package/docs/CHANGELOG.md +16 -0
- package/package.json +3 -3
|
@@ -35,11 +35,11 @@ import { fingerprintSeverity } from './severity.js';
|
|
|
35
35
|
const SEP = '␟'; // unit separator — keeps fingerprint fields unambiguous
|
|
36
36
|
const MARKER = 'audit-fingerprints:';
|
|
37
37
|
const SEMANTIC_MARKER = 'audit-semantic-keys:';
|
|
38
|
-
const SHA1_RE = /^[0-9a-f]{40}$/;
|
|
38
|
+
export const SHA1_RE = /^[0-9a-f]{40}$/;
|
|
39
39
|
// A semantic key round-trips through a comma-joined footer, so it must not
|
|
40
40
|
// carry a comma or a `>` (which would truncate the HTML comment). Both are
|
|
41
41
|
// stripped when the key is built, so this guard is defence-in-depth.
|
|
42
|
-
const SEMANTIC_KEY_RE = /^[^,>]+$/;
|
|
42
|
+
export const SEMANTIC_KEY_RE = /^[^,>]+$/;
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Normalise a single scalar identity field to a stable string.
|
|
@@ -343,12 +343,59 @@ function decisionForIssue(issue) {
|
|
|
343
343
|
return state === 'closed' ? 'regression-of-closed' : 'update-existing';
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Resolve the pool that **attributes** a finding, out of everything that
|
|
348
|
+
* confirmed it (Story #5045).
|
|
349
|
+
*
|
|
350
|
+
* Confirmation admits two different strengths of claim, and collapsing them
|
|
351
|
+
* was the source of two wrong routes:
|
|
352
|
+
*
|
|
353
|
+
* - An issue carrying the finding's exact **fingerprint** owns it. That is
|
|
354
|
+
* identity: this issue tracks *this* finding.
|
|
355
|
+
* - An issue matching only on the location-based **semantic key** is merely
|
|
356
|
+
* adjacent: it tracks *a* finding at the same `area␟primaryFile`.
|
|
357
|
+
*
|
|
358
|
+
* Owners win outright when any exist. Location-only matches are not discarded
|
|
359
|
+
* — they are the whole point of the semantic key and remain the pool when
|
|
360
|
+
* nothing carries the fingerprint (a reworded finding at an unchanged
|
|
361
|
+
* location). The pool is sorted by issue number so a genuine tie resolves to
|
|
362
|
+
* the earliest-filed issue rather than to whatever order the search port
|
|
363
|
+
* happened to return.
|
|
364
|
+
*
|
|
365
|
+
* @param {Array<{ number: number, state: string, body?: string }>} confirmed
|
|
366
|
+
* @param {string} sha
|
|
367
|
+
* @returns {Array<{ number: number, state: string }>}
|
|
368
|
+
*/
|
|
369
|
+
function attributedPool(confirmed, sha) {
|
|
370
|
+
const owns = (issue) => issueCarriesFingerprint(issue, sha);
|
|
371
|
+
const owners = confirmed.filter(owns);
|
|
372
|
+
const pool = owners.length > 0 ? owners : confirmed.filter((i) => !owns(i));
|
|
373
|
+
return [...pool].sort((a, b) => (a?.number ?? 0) - (b?.number ?? 0));
|
|
374
|
+
}
|
|
375
|
+
|
|
346
376
|
/**
|
|
347
377
|
* Decide the final route from a confirmed-match pool (issues that both
|
|
348
|
-
* surfaced in the candidate/search pass AND carry
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
378
|
+
* surfaced in the candidate/search pass AND carry a confirming footer).
|
|
379
|
+
* Shared by both the semantic-first and fingerprint-only code paths so the
|
|
380
|
+
* decision enum is identical regardless of how candidates were gathered.
|
|
381
|
+
*
|
|
382
|
+
* **Attribution decides, not array order (Story #5045).** The pool used to be
|
|
383
|
+
* read flat, which produced two wrong answers whenever more than one issue
|
|
384
|
+
* confirmed:
|
|
385
|
+
*
|
|
386
|
+
* 1. Two open matches routed `duplicate` pinned to `open[0]` — whichever
|
|
387
|
+
* issue the search port happened to return first. With per-Story
|
|
388
|
+
* provenance that pick is answerable rather than arbitrary: the issue
|
|
389
|
+
* carrying the finding's own fingerprint owns it, and a sibling matching
|
|
390
|
+
* only by location does not.
|
|
391
|
+
* 2. Any open match at all masked a closed one, so a finding whose
|
|
392
|
+
* fingerprint is owned by a **closed** Story routed `update-existing`
|
|
393
|
+
* against an open neighbour — a genuine regression filed as a
|
|
394
|
+
* business-as-usual update. Attribution restores it: state is read off the
|
|
395
|
+
* owning issue, not off whatever else shares its location.
|
|
396
|
+
*
|
|
397
|
+
* {@link attributedPool} owns that selection; the decision below reads only
|
|
398
|
+
* the pool it returns.
|
|
352
399
|
*
|
|
353
400
|
* @param {Array<{ number: number, state: string }>} confirmed
|
|
354
401
|
* @param {string} sha
|
|
@@ -359,7 +406,8 @@ function decideFromConfirmed(confirmed, sha) {
|
|
|
359
406
|
return { decision: 'new', matchedIssue: null, fingerprint: sha };
|
|
360
407
|
}
|
|
361
408
|
|
|
362
|
-
const
|
|
409
|
+
const attributed = attributedPool(confirmed, sha);
|
|
410
|
+
const open = attributed.filter((h) => normaliseField(h.state) === 'open');
|
|
363
411
|
if (open.length > 1) {
|
|
364
412
|
return { decision: 'duplicate', matchedIssue: open[0], fingerprint: sha };
|
|
365
413
|
}
|
|
@@ -371,7 +419,7 @@ function decideFromConfirmed(confirmed, sha) {
|
|
|
371
419
|
};
|
|
372
420
|
}
|
|
373
421
|
|
|
374
|
-
const closed =
|
|
422
|
+
const closed = attributed[0];
|
|
375
423
|
return {
|
|
376
424
|
decision: decisionForIssue(closed),
|
|
377
425
|
matchedIssue: closed,
|
|
@@ -484,4 +532,5 @@ export const __testing = {
|
|
|
484
532
|
decideFromConfirmed,
|
|
485
533
|
issueCarriesSemanticKey,
|
|
486
534
|
parseSemanticKeyFooter,
|
|
535
|
+
attributedPool,
|
|
487
536
|
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knip-config-resolver.js — resolve a repository's knip configuration the way
|
|
3
|
+
* knip itself would, and hand back its declared entry patterns.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `knip-entry-sync.js` (Story #5039) because these are two
|
|
6
|
+
* concerns that change for different reasons: *what knip's config says* tracks
|
|
7
|
+
* knip's own releases, while *which CLIs something invokes* tracks this
|
|
8
|
+
* repository's callers. Keeping them in one file also pushed that module's
|
|
9
|
+
* maintainability index below its floor.
|
|
10
|
+
*
|
|
11
|
+
* Why not read the file ourselves: #5026 hardcoded
|
|
12
|
+
* `JSON.parse(<root>/knip.json)`, so all seven of knip's other config
|
|
13
|
+
* locations plus `package.json#knip` resolved to ENOENT and failed the gate
|
|
14
|
+
* closed. That made it unusable in exactly the repositories that adopt the
|
|
15
|
+
* shared `knip.base.json` — knip 6 has no root-level `extends`, so inheriting
|
|
16
|
+
* the base means spreading it inside a TypeScript module, which a static
|
|
17
|
+
* `knip.json` structurally cannot express.
|
|
18
|
+
*
|
|
19
|
+
* Going through `createOptions` also drops the assumption that `entry` is
|
|
20
|
+
* *statically declared*. A config may build the array programmatically, so
|
|
21
|
+
* even a purpose-built `knip.config.ts` parser would read the wrong thing;
|
|
22
|
+
* knip evaluates the module and returns the computed value.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Load knip's own config resolver.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately a dynamic import behind an injectable seam. `knip` is declared
|
|
29
|
+
* in `runtime-deps.json` as an **optional** dependency, mirroring `typescript`:
|
|
30
|
+
* the `.agents/` payload is materialized into consumers that may not run knip
|
|
31
|
+
* at all, so an unresolvable `knip` must degrade to the skip path rather than
|
|
32
|
+
* crash the gate at module load — and must never be preflight-blocked.
|
|
33
|
+
*
|
|
34
|
+
* @returns {Promise<{ createOptions?: Function }>}
|
|
35
|
+
*/
|
|
36
|
+
function importKnipSession() {
|
|
37
|
+
return import('knip/session');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Collect every declared entry pattern from a resolved knip configuration.
|
|
42
|
+
*
|
|
43
|
+
* Entries live in two places and #5026 read only the first. A pnpm-workspace
|
|
44
|
+
* config — the shape that made this gate unusable in `Beestera/swarm-os` —
|
|
45
|
+
* declares them per workspace, so a config with no top-level `entry` at all is
|
|
46
|
+
* still fully enumerated. A named workspace's patterns are workspace-relative
|
|
47
|
+
* and are joined to the workspace name; the root workspace (`.`) is already
|
|
48
|
+
* repo-relative. A leading `!` is knip's *negation* marker (as opposed to the
|
|
49
|
+
* trailing production marker) and stays at the front of the pattern rather
|
|
50
|
+
* than getting buried behind the workspace prefix.
|
|
51
|
+
*
|
|
52
|
+
* `sawEntryArray` separates "declared nothing under `.agents/scripts/`" — a
|
|
53
|
+
* legitimate result, reported as `missing` divergences — from "this config
|
|
54
|
+
* enumerates no entry points anywhere", which leaves the gate nothing to
|
|
55
|
+
* compare against.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} parsedConfig knip's schema-parsed configuration
|
|
58
|
+
* @returns {{ patterns: string[], sawEntryArray: boolean }}
|
|
59
|
+
*/
|
|
60
|
+
function collectEntryPatterns(parsedConfig) {
|
|
61
|
+
const patterns = [];
|
|
62
|
+
let sawEntryArray = false;
|
|
63
|
+
|
|
64
|
+
const push = (value, prefix) => {
|
|
65
|
+
if (typeof value !== 'string') return;
|
|
66
|
+
if (!prefix) patterns.push(value);
|
|
67
|
+
else if (value.startsWith('!'))
|
|
68
|
+
patterns.push(`!${prefix}${value.slice(1)}`);
|
|
69
|
+
else patterns.push(`${prefix}${value}`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const addAll = (entry, prefix) => {
|
|
73
|
+
if (!Array.isArray(entry)) return;
|
|
74
|
+
sawEntryArray = true;
|
|
75
|
+
for (const value of entry) push(value, prefix);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
addAll(parsedConfig?.entry, '');
|
|
79
|
+
|
|
80
|
+
const workspaces = parsedConfig?.workspaces;
|
|
81
|
+
if (workspaces && typeof workspaces === 'object') {
|
|
82
|
+
for (const [name, workspace] of Object.entries(workspaces)) {
|
|
83
|
+
const trimmed = name.replace(/^\.\/+/, '').replace(/\/+$/, '');
|
|
84
|
+
const prefix = trimmed === '' || trimmed === '.' ? '' : `${trimmed}/`;
|
|
85
|
+
addAll(workspace?.entry, prefix);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { patterns, sawEntryArray };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the entry patterns knip would see for `repoRoot`.
|
|
94
|
+
*
|
|
95
|
+
* Three outcomes, deliberately distinct — collapsing the first two is how a
|
|
96
|
+
* broken configuration would come to look like an absent one:
|
|
97
|
+
*
|
|
98
|
+
* skipped — nothing to check (no config file, no `package.json#knip`, or no
|
|
99
|
+
* resolvable `knip`). The gate exits 0. This is what makes it safe to wire
|
|
100
|
+
* into every consumer, matching `qa.gherkinLint`'s opt-in posture.
|
|
101
|
+
* error — a configuration exists but could not be resolved, or enumerates no
|
|
102
|
+
* entry points at all. The gate exits 2.
|
|
103
|
+
* resolved — `patterns` carries every declared entry, top-level and
|
|
104
|
+
* per-workspace, with knip's trailing `!` production markers intact.
|
|
105
|
+
*
|
|
106
|
+
* @param {{ repoRoot: string, loadKnipSession?: () => Promise<object> }} opts
|
|
107
|
+
* @returns {Promise<{
|
|
108
|
+
* patterns: string[],
|
|
109
|
+
* configFilePath: string | null,
|
|
110
|
+
* skipped: string | null,
|
|
111
|
+
* error: string | null,
|
|
112
|
+
* }>}
|
|
113
|
+
*/
|
|
114
|
+
export async function resolveKnipEntryPatterns({
|
|
115
|
+
repoRoot,
|
|
116
|
+
loadKnipSession = importKnipSession,
|
|
117
|
+
}) {
|
|
118
|
+
const nothing = { patterns: [], configFilePath: null };
|
|
119
|
+
|
|
120
|
+
let createOptions;
|
|
121
|
+
try {
|
|
122
|
+
({ createOptions } = await loadKnipSession());
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return {
|
|
125
|
+
...nothing,
|
|
126
|
+
skipped: `the "knip" package is not resolvable from ${repoRoot} (${error.message})`,
|
|
127
|
+
error: null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (typeof createOptions !== 'function') {
|
|
131
|
+
return {
|
|
132
|
+
...nothing,
|
|
133
|
+
skipped: null,
|
|
134
|
+
error:
|
|
135
|
+
'the installed "knip" does not export createOptions from "knip/session" — this gate needs knip 6 or newer',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let options;
|
|
140
|
+
try {
|
|
141
|
+
options = await createOptions({ cwd: repoRoot, args: {} });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return {
|
|
144
|
+
...nothing,
|
|
145
|
+
skipped: null,
|
|
146
|
+
error: `cannot resolve the knip configuration: ${error.message}`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const configFilePath = options?.configFilePath ?? null;
|
|
151
|
+
if (!configFilePath) {
|
|
152
|
+
return {
|
|
153
|
+
...nothing,
|
|
154
|
+
skipped: `no knip configuration found under ${repoRoot} (no config file, no package.json#knip)`,
|
|
155
|
+
error: null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { patterns, sawEntryArray } = collectEntryPatterns(
|
|
160
|
+
options?.parsedConfig,
|
|
161
|
+
);
|
|
162
|
+
if (!sawEntryArray) {
|
|
163
|
+
return {
|
|
164
|
+
patterns: [],
|
|
165
|
+
configFilePath,
|
|
166
|
+
skipped: null,
|
|
167
|
+
error: `${configFilePath} declares no "entry" array, at the top level or in any workspace`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { patterns, configFilePath, skipped: null, error: null };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Test-only seam. Not API — `collectEntryPatterns` is exercised directly so the
|
|
176
|
+
* workspace-prefix and negation rules can be pinned without a fixture tree.
|
|
177
|
+
*/
|
|
178
|
+
export const __testing = Object.freeze({
|
|
179
|
+
collectEntryPatterns,
|
|
180
|
+
importKnipSession,
|
|
181
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* knip-entry-sync.js — derive which top-level `.agents/scripts/*.js` CLIs are
|
|
3
|
-
* actually invoked, and diff that set against
|
|
3
|
+
* actually invoked, and diff that set against the `entry` array of whatever
|
|
4
|
+
* configuration knip itself would load (Story #5039; #5026 could only read a
|
|
5
|
+
* literal `knip.json`).
|
|
4
6
|
*
|
|
5
7
|
* Why this exists (Story #5001 → the #5012 near-miss):
|
|
6
8
|
*
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
* real death later. It was caught only because a base-sync conflict forced a
|
|
21
23
|
* manual read of the diff.
|
|
22
24
|
*
|
|
23
|
-
* The fix is to stop depending on a human remembering to edit
|
|
25
|
+
* The fix is to stop depending on a human remembering to edit the config.
|
|
24
26
|
* #5001's own wording already names the derivation rule — entries are derived
|
|
25
27
|
* "from what package.json scripts, husky hooks, .github/workflows, and the
|
|
26
28
|
* .agents workflow/skill markdown actually invoke". This module mechanizes
|
|
@@ -53,6 +55,10 @@
|
|
|
53
55
|
|
|
54
56
|
import fs from 'node:fs';
|
|
55
57
|
import path from 'node:path';
|
|
58
|
+
import {
|
|
59
|
+
resolveKnipEntryPatterns,
|
|
60
|
+
__testing as resolverTesting,
|
|
61
|
+
} from './knip-config-resolver.js';
|
|
56
62
|
|
|
57
63
|
// Only `resolveEntrySync` and `renderEntrySyncReport` are public — they are
|
|
58
64
|
// what `check-knip-entries.js` calls. Everything else is module-private and
|
|
@@ -261,9 +267,13 @@ function listTopLevelClis({ repoRoot, fsImpl = fs }) {
|
|
|
261
267
|
}
|
|
262
268
|
|
|
263
269
|
/**
|
|
264
|
-
* Read the
|
|
265
|
-
*
|
|
266
|
-
*
|
|
270
|
+
* Read the `.agents/scripts/*.js` basenames declared as knip entry points.
|
|
271
|
+
*
|
|
272
|
+
* Resolution goes through knip's own loader — see
|
|
273
|
+
* `knip-config-resolver.js` for why, and for the skipped / error / resolved
|
|
274
|
+
* contract this forwards. All this adds is the projection onto top-level CLI
|
|
275
|
+
* basenames: glob-bearing patterns are dropped, so what remains is the explicit
|
|
276
|
+
* enumeration #5001 made authoritative.
|
|
267
277
|
*
|
|
268
278
|
* The trailing `!` is knip's production-mode marker and is load-bearing, not
|
|
269
279
|
* decoration: in a `--production` run knip keeps only the suffixed entries and
|
|
@@ -272,45 +282,41 @@ function listTopLevelClis({ repoRoot, fsImpl = fs }) {
|
|
|
272
282
|
* unreachable in exactly the pass that emits whole-file rows — the #5012 shape
|
|
273
283
|
* this gate exists to catch. Reading it as satisfied would point the operator
|
|
274
284
|
* away from the cause, so unsuffixed entries are reported as their own
|
|
275
|
-
* divergence rather than silently normalized.
|
|
285
|
+
* divergence rather than silently normalized. `createOptions` preserves the
|
|
286
|
+
* suffix, so the marker survives resolution.
|
|
276
287
|
*
|
|
277
|
-
* @param {{ repoRoot: string,
|
|
278
|
-
* @returns {{
|
|
288
|
+
* @param {{ repoRoot: string, loadKnipSession?: () => Promise<object> }} opts
|
|
289
|
+
* @returns {Promise<{
|
|
290
|
+
* entries: string[],
|
|
291
|
+
* unsuffixed: string[],
|
|
292
|
+
* configFilePath: string | null,
|
|
293
|
+
* skipped: string | null,
|
|
294
|
+
* error: string | null,
|
|
295
|
+
* }>}
|
|
279
296
|
*/
|
|
280
|
-
function readKnipEntries({ repoRoot,
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
} catch (error) {
|
|
286
|
-
return {
|
|
287
|
-
entries: [],
|
|
288
|
-
unsuffixed: [],
|
|
289
|
-
error: `cannot read knip.json: ${error.message}`,
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
if (!Array.isArray(parsed?.entry)) {
|
|
293
|
-
return {
|
|
294
|
-
entries: [],
|
|
295
|
-
unsuffixed: [],
|
|
296
|
-
error: 'knip.json has no "entry" array',
|
|
297
|
-
};
|
|
297
|
+
async function readKnipEntries({ repoRoot, loadKnipSession }) {
|
|
298
|
+
const { patterns, configFilePath, skipped, error } =
|
|
299
|
+
await resolveKnipEntryPatterns({ repoRoot, loadKnipSession });
|
|
300
|
+
if (skipped || error) {
|
|
301
|
+
return { entries: [], unsuffixed: [], configFilePath, skipped, error };
|
|
298
302
|
}
|
|
303
|
+
|
|
299
304
|
const prefix = '.agents/scripts/';
|
|
300
|
-
const declared =
|
|
301
|
-
.filter((e) =>
|
|
302
|
-
.filter((e) => !e.includes('*'))
|
|
305
|
+
const declared = patterns
|
|
306
|
+
.filter((e) => e.startsWith(prefix) && !e.includes('*'))
|
|
303
307
|
.map((e) => ({
|
|
304
308
|
cli: e.slice(prefix.length).replace(/!$/, ''),
|
|
305
309
|
suffixed: e.endsWith('!'),
|
|
306
310
|
}))
|
|
307
311
|
.filter((e) => !e.cli.includes('/'));
|
|
308
312
|
|
|
309
|
-
const entries = declared.map((e) => e.cli);
|
|
310
|
-
const unsuffixed = declared.filter((e) => !e.suffixed).map((e) => e.cli);
|
|
311
313
|
return {
|
|
312
|
-
entries: [...new Set(
|
|
313
|
-
unsuffixed: [
|
|
314
|
+
entries: [...new Set(declared.map((e) => e.cli))].sort(),
|
|
315
|
+
unsuffixed: [
|
|
316
|
+
...new Set(declared.filter((e) => !e.suffixed).map((e) => e.cli)),
|
|
317
|
+
].sort(),
|
|
318
|
+
configFilePath,
|
|
319
|
+
skipped: null,
|
|
314
320
|
error: null,
|
|
315
321
|
};
|
|
316
322
|
}
|
|
@@ -318,16 +324,27 @@ function readKnipEntries({ repoRoot, fsImpl = fs }) {
|
|
|
318
324
|
/**
|
|
319
325
|
* Resolve the full entry-sync report for a repository.
|
|
320
326
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
327
|
+
* Async because knip's config loader is: a `knip.config.ts` has to be
|
|
328
|
+
* *evaluated*, not read. `EntrySync` names entry-list synchronization, not
|
|
329
|
+
* synchronous I/O, so the name still describes what this returns.
|
|
330
|
+
*
|
|
331
|
+
* @param {{
|
|
332
|
+
* repoRoot: string,
|
|
333
|
+
* fsImpl?: typeof fs,
|
|
334
|
+
* loadKnipSession?: () => Promise<object>,
|
|
335
|
+
* }} opts
|
|
336
|
+
* @returns {Promise<{
|
|
323
337
|
* error: string | null,
|
|
338
|
+
* skipped: string | null,
|
|
339
|
+
* configFilePath: string | null,
|
|
324
340
|
* clis: string[],
|
|
325
341
|
* declared: string[],
|
|
326
342
|
* missing: Array<{ cli: string, invokers: string[] }>,
|
|
327
343
|
* stale: string[],
|
|
328
344
|
* phantom: string[],
|
|
329
345
|
* unsuffixed: string[],
|
|
330
|
-
* }}
|
|
346
|
+
* }>}
|
|
347
|
+
* `skipped` — nothing to check; the caller passes rather than failing.
|
|
331
348
|
* `missing` — invoked but not declared (the #5012 bug).
|
|
332
349
|
* `stale` — declared but invoked by nothing.
|
|
333
350
|
* `phantom` — declared but no such file on disk (a rename left the list behind).
|
|
@@ -335,13 +352,21 @@ function readKnipEntries({ repoRoot, fsImpl = fs }) {
|
|
|
335
352
|
* production pass negates rather than honours (the #5012 bug wearing a
|
|
336
353
|
* declared entry).
|
|
337
354
|
*/
|
|
338
|
-
export function resolveEntrySync({
|
|
355
|
+
export async function resolveEntrySync({
|
|
356
|
+
repoRoot,
|
|
357
|
+
fsImpl = fs,
|
|
358
|
+
loadKnipSession,
|
|
359
|
+
}) {
|
|
339
360
|
const {
|
|
340
361
|
entries: declared,
|
|
341
362
|
unsuffixed,
|
|
363
|
+
configFilePath,
|
|
364
|
+
skipped,
|
|
342
365
|
error,
|
|
343
|
-
} = readKnipEntries({ repoRoot,
|
|
366
|
+
} = await readKnipEntries({ repoRoot, loadKnipSession });
|
|
344
367
|
const empty = {
|
|
368
|
+
skipped: null,
|
|
369
|
+
configFilePath,
|
|
345
370
|
clis: [],
|
|
346
371
|
declared,
|
|
347
372
|
missing: [],
|
|
@@ -349,6 +374,7 @@ export function resolveEntrySync({ repoRoot, fsImpl = fs }) {
|
|
|
349
374
|
phantom: [],
|
|
350
375
|
unsuffixed: [],
|
|
351
376
|
};
|
|
377
|
+
if (skipped) return { error: null, ...empty, skipped };
|
|
352
378
|
if (error) return { error, ...empty };
|
|
353
379
|
|
|
354
380
|
const clis = listTopLevelClis({ repoRoot, fsImpl });
|
|
@@ -379,7 +405,17 @@ export function resolveEntrySync({ repoRoot, fsImpl = fs }) {
|
|
|
379
405
|
const onDisk = new Set(clis);
|
|
380
406
|
const phantom = declared.filter((cli) => !onDisk.has(cli));
|
|
381
407
|
|
|
382
|
-
return {
|
|
408
|
+
return {
|
|
409
|
+
error: null,
|
|
410
|
+
skipped: null,
|
|
411
|
+
configFilePath,
|
|
412
|
+
clis,
|
|
413
|
+
declared,
|
|
414
|
+
missing,
|
|
415
|
+
stale,
|
|
416
|
+
phantom,
|
|
417
|
+
unsuffixed,
|
|
418
|
+
};
|
|
383
419
|
}
|
|
384
420
|
|
|
385
421
|
/**
|
|
@@ -460,6 +496,9 @@ export function renderEntrySyncReport(report) {
|
|
|
460
496
|
*/
|
|
461
497
|
export const __testing = Object.freeze({
|
|
462
498
|
buildInvocationPatterns,
|
|
499
|
+
// Re-exported from `knip-config-resolver.js` so the suite reaches the whole
|
|
500
|
+
// gate through one seam rather than importing two modules to test one gate.
|
|
501
|
+
collectEntryPatterns: resolverTesting.collectEntryPatterns,
|
|
463
502
|
collectInvocationSurfaces,
|
|
464
503
|
INVOCATION_SURFACES,
|
|
465
504
|
listTopLevelClis,
|
|
@@ -15,11 +15,10 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import posix from 'node:path/posix';
|
|
18
|
-
import { resolveListValue } from '../../config/shared.js';
|
|
19
18
|
import { gitSpawn } from '../../git-utils.js';
|
|
20
19
|
import { validateTaskBodies } from '../task-body-validator.js';
|
|
21
20
|
import { validateAndNormalizeTickets } from '../ticket-validator.js';
|
|
22
|
-
import {
|
|
21
|
+
import { resolveConflictPolicy } from '../ticket-validator-conflicts.js';
|
|
23
22
|
import { normalizeVerifyTiers } from '../verify-tier-repair.js';
|
|
24
23
|
|
|
25
24
|
/**
|
|
@@ -176,30 +175,6 @@ export function makeDefaultFanOutCounter({ baseBranchRef, cwd, git } = {}) {
|
|
|
176
175
|
};
|
|
177
176
|
}
|
|
178
177
|
|
|
179
|
-
/**
|
|
180
|
-
* Resolve the cross-Story conflict-finding policy from `_config.planning`.
|
|
181
|
-
*/
|
|
182
|
-
function resolveConflictPolicy(cfg) {
|
|
183
|
-
const planning = cfg?.planning;
|
|
184
|
-
const policy = {
|
|
185
|
-
failOnSharedEditors: planning?.failOnSharedEditors === true,
|
|
186
|
-
requireExplicitCrossStoryDeps:
|
|
187
|
-
planning?.requireExplicitCrossStoryDeps === true,
|
|
188
|
-
failOnRegistryConflicts: planning?.failOnRegistryConflicts === true,
|
|
189
|
-
failOnLargeFanOut: planning?.failOnLargeFanOut === true,
|
|
190
|
-
};
|
|
191
|
-
if (Number.isFinite(planning?.largeFanOutThreshold)) {
|
|
192
|
-
policy.largeFanOutThreshold = planning.largeFanOutThreshold;
|
|
193
|
-
}
|
|
194
|
-
if (planning?.crossCuttingRegistries !== undefined) {
|
|
195
|
-
policy.registries = resolveListValue(
|
|
196
|
-
DEFAULT_REGISTRY_PATTERNS,
|
|
197
|
-
planning.crossCuttingRegistries,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
return policy;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
178
|
/**
|
|
204
179
|
* Resolve the ref the persist gates probe against.
|
|
205
180
|
*
|
|
@@ -62,6 +62,11 @@ import {
|
|
|
62
62
|
evaluateDraftReachability,
|
|
63
63
|
renderReachabilityOrphans,
|
|
64
64
|
} from '../plan-reachability.js';
|
|
65
|
+
import {
|
|
66
|
+
computeAssembledConflictFindings,
|
|
67
|
+
conflictFindingKey,
|
|
68
|
+
renderHardConflictError,
|
|
69
|
+
} from '../ticket-validator-conflicts.js';
|
|
65
70
|
import { upsertStructuredComment } from '../ticketing.js';
|
|
66
71
|
import {
|
|
67
72
|
enforceFanOutGate,
|
|
@@ -376,6 +381,50 @@ function resolveEffectiveRoute({
|
|
|
376
381
|
};
|
|
377
382
|
}
|
|
378
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Re-run the cross-Story conflict passes over the assembled bodies and route
|
|
386
|
+
* the result (Story #5045).
|
|
387
|
+
*
|
|
388
|
+
* `validateTickets` runs before assembly, over the raw payload, so until now
|
|
389
|
+
* plan-time conflict analysis judged an artifact that is not the one persist
|
|
390
|
+
* writes — and the passes that scan `body.acceptance` / `body.verify` were
|
|
391
|
+
* inert on the canonical top-level authoring shape as a result.
|
|
392
|
+
*
|
|
393
|
+
* Three outcomes, in order:
|
|
394
|
+
*
|
|
395
|
+
* 1. **Hard findings throw.** Policy upgrades (`planning.failOnSharedEditors`,
|
|
396
|
+
* `planning.requireExplicitCrossStoryDeps`) are off by default; when an
|
|
397
|
+
* operator turns one on it must bite on the persisted artifact too, and it
|
|
398
|
+
* must bite **before** the first `createIssue`.
|
|
399
|
+
* 2. **Soft findings the raw pass already reported are dropped**, so the same
|
|
400
|
+
* collision is not announced twice per run.
|
|
401
|
+
* 3. **Everything else is returned** for the plan-summary comment, which is
|
|
402
|
+
* where these findings stop being a stderr line nobody keeps.
|
|
403
|
+
*
|
|
404
|
+
* @param {{ stories: object[], config: object, rawFindings: object[] }} args
|
|
405
|
+
* @returns {object[]} The assembled-pass findings, for the summary comment.
|
|
406
|
+
*/
|
|
407
|
+
function analyzeAssembledStories({ stories, config, rawFindings }) {
|
|
408
|
+
const findings = computeAssembledConflictFindings({ stories, config });
|
|
409
|
+
const hard = findings.filter((finding) => finding.severity === 'hard');
|
|
410
|
+
if (hard.length > 0) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`[plan-persist] ${hard.length} cross-Story conflict(s) in the assembled ` +
|
|
413
|
+
`Story bodies:\n${hard.map((f) => ` - ${renderHardConflictError(f)}`).join('\n')}`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const alreadyReported = new Set(
|
|
417
|
+
(rawFindings ?? []).map((finding) => conflictFindingKey(finding)),
|
|
418
|
+
);
|
|
419
|
+
surfaceSoftConflictFindings(
|
|
420
|
+
findings.filter(
|
|
421
|
+
(finding) => !alreadyReported.has(conflictFindingKey(finding)),
|
|
422
|
+
),
|
|
423
|
+
'plan-persist/assembled',
|
|
424
|
+
);
|
|
425
|
+
return findings;
|
|
426
|
+
}
|
|
427
|
+
|
|
379
428
|
/**
|
|
380
429
|
* Reap abandoned `plan-*` directories under the temp root (Story #4541).
|
|
381
430
|
*
|
|
@@ -702,14 +751,25 @@ export async function runPlanPersist({
|
|
|
702
751
|
sharedSpec: techSpecContent,
|
|
703
752
|
planAcceptance: planAcceptance ?? undefined,
|
|
704
753
|
sourceTicketIds,
|
|
705
|
-
// The seed this plan was authored from
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
754
|
+
// The seed this plan was authored from: an audit sweep's Single-plan seed
|
|
755
|
+
// carries the `audit-fingerprints` / `audit-semantic-keys` footers, and
|
|
756
|
+
// assembly copies them into the persisted Story bodies so the next sweep
|
|
757
|
+
// recognises what it already planned (Story #4877). Since Story #5045 this
|
|
758
|
+
// is the **fallback** — it is carried onto every Story that did not
|
|
759
|
+
// attribute its own `provenance`, which keeps an un-attributed plan exactly
|
|
760
|
+
// as recall-safe as it was. Empty for a `--tickets` run, a no-op there.
|
|
710
761
|
provenanceSource: planContextEnvelope?.seed?.content ?? '',
|
|
711
762
|
});
|
|
712
763
|
|
|
764
|
+
// Story #5045: the cross-Story conflict passes re-run over the assembled,
|
|
765
|
+
// footer-stamped bodies — the artifact persist actually writes — before any
|
|
766
|
+
// GitHub call, so a policy upgrade still refuses the plan pre-creation.
|
|
767
|
+
const assembledConflicts = analyzeAssembledStories({
|
|
768
|
+
stories,
|
|
769
|
+
config,
|
|
770
|
+
rawFindings: validated.findings,
|
|
771
|
+
});
|
|
772
|
+
|
|
713
773
|
// Effective complexity route (Story #4722): the planner's authored lite
|
|
714
774
|
// verdict (recorded reason), validated against every assembled Story's own
|
|
715
775
|
// shape — a claim exceeding the shape ceilings fails closed to full. Lite
|
|
@@ -764,6 +824,10 @@ export async function runPlanPersist({
|
|
|
764
824
|
mode: 'stories',
|
|
765
825
|
planMetricsLine,
|
|
766
826
|
stories: created,
|
|
827
|
+
// Story #5045: the wave table promises what can run in parallel; the
|
|
828
|
+
// collisions the conflict passes found belong on the same surface, or the
|
|
829
|
+
// promise is the only half anyone reads.
|
|
830
|
+
conflictFindings: assembledConflicts,
|
|
767
831
|
});
|
|
768
832
|
|
|
769
833
|
if (!dryRun) {
|