mandrel 2.32.0 → 2.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/SDLC.md +8 -5
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +3 -2
- package/.agents/runtime-deps.json +2 -1
- package/.agents/schemas/agentrc.schema.json +8 -2
- package/.agents/scripts/README.md +9 -0
- package/.agents/scripts/audit-to-stories.js +160 -41
- package/.agents/scripts/check-knip-entries.js +47 -24
- package/.agents/scripts/check-lifecycle-lint.js +72 -12
- package/.agents/scripts/coverage-capture.js +7 -1
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +81 -34
- package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +185 -0
- package/.agents/scripts/lib/baselines/kernel.js +20 -7
- package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
- package/.agents/scripts/lib/config/quality.js +7 -0
- package/.agents/scripts/lib/config/runners.js +38 -16
- package/.agents/scripts/lib/config-settings-schema-delivery.js +10 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
- package/.agents/scripts/lib/coverage-capture.js +10 -15
- package/.agents/scripts/lib/dependency-parser.js +20 -7
- package/.agents/scripts/lib/findings/provenance-field.js +135 -0
- package/.agents/scripts/lib/findings/route-finding.js +57 -8
- package/.agents/scripts/lib/knip-config-resolver.js +181 -0
- package/.agents/scripts/lib/knip-entry-sync.js +78 -39
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -26
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +69 -5
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +93 -19
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +49 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +72 -35
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +116 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +38 -0
- package/.agents/scripts/lib/story-body/footer-block.js +97 -0
- package/.agents/scripts/lib/story-body/story-body.js +6 -22
- package/.agents/scripts/lib/wave-runner/footprint.js +306 -0
- package/.agents/scripts/lib/wave-runner/ready-set.js +198 -181
- package/.agents/scripts/providers/github/blocked-by-add.js +25 -10
- package/.agents/scripts/resolve-stories.js +21 -5
- package/.agents/scripts/stories-wave-tick.js +192 -9
- package/.agents/workflows/audit-to-stories.md +26 -0
- package/.agents/workflows/helpers/deliver-light.md +5 -2
- package/.agents/workflows/helpers/deliver-reference.md +28 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +80 -1
- package/.agents/workflows/helpers/deliver-story.md +4 -2
- package/.agents/workflows/helpers/plan-reference.md +76 -0
- package/docs/CHANGELOG.md +26 -0
- package/package.json +3 -3
|
@@ -30,9 +30,19 @@ export const DEFAULT_DECOMPOSER = Object.freeze({
|
|
|
30
30
|
* `verifyWaveResults` loop it claimed to bound never existed in the tree, and
|
|
31
31
|
* its only reader was the retired execution-analysis CLI, which echoed the
|
|
32
32
|
* number into a report rather than bounding anything.
|
|
33
|
+
*
|
|
34
|
+
* **Serialization tradeoff — `footprintGuard`.** `enforce` is the default and
|
|
35
|
+
* stays it: the file-overlap guard encodes delivery-time-only knowledge (which
|
|
36
|
+
* implementation windows are open, which Stories a foreign lease holds) that no
|
|
37
|
+
* plan-time `depends_on` edge can carry, so demoting it by default would trade
|
|
38
|
+
* a real merge-conflict class for throughput nobody asked for. `advisory`
|
|
39
|
+
* detects collisions and reports every would-be withhold but lets dispatch
|
|
40
|
+
* follow the declared edges alone — for runs whose ordering is fully declared
|
|
41
|
+
* (Story #5044).
|
|
33
42
|
*/
|
|
34
43
|
const DEFAULT_DELIVER_RUNNER = Object.freeze({
|
|
35
44
|
concurrencyCap: 3,
|
|
45
|
+
footprintGuard: 'enforce',
|
|
36
46
|
});
|
|
37
47
|
|
|
38
48
|
/**
|
|
@@ -51,28 +61,40 @@ export const DEFAULT_CODE_REVIEW = Object.freeze({
|
|
|
51
61
|
*
|
|
52
62
|
* @param {object | null | undefined} config
|
|
53
63
|
* @returns {{
|
|
54
|
-
* deliverRunner: { concurrencyCap: number },
|
|
64
|
+
* deliverRunner: { concurrencyCap: number, footprintGuard: 'enforce'|'advisory' },
|
|
55
65
|
* codeReview: { maxFixAttempts: number, maxFixScopeFiles: number, autoFixSeverity: 'high'|'medium' },
|
|
56
66
|
* decomposer: { concurrencyCap: number },
|
|
57
67
|
* }}
|
|
58
68
|
*/
|
|
59
69
|
export function getRunners(config) {
|
|
60
|
-
const deliverRunnerUser = config?.delivery?.deliverRunner ?? {};
|
|
61
|
-
const codeReviewUser = config?.delivery?.codeReview ?? {};
|
|
62
70
|
return {
|
|
63
|
-
deliverRunner:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
codeReview: {
|
|
69
|
-
maxFixAttempts:
|
|
70
|
-
codeReviewUser.maxFixAttempts ?? DEFAULT_CODE_REVIEW.maxFixAttempts,
|
|
71
|
-
maxFixScopeFiles:
|
|
72
|
-
codeReviewUser.maxFixScopeFiles ?? DEFAULT_CODE_REVIEW.maxFixScopeFiles,
|
|
73
|
-
autoFixSeverity:
|
|
74
|
-
codeReviewUser.autoFixSeverity ?? DEFAULT_CODE_REVIEW.autoFixSeverity,
|
|
75
|
-
},
|
|
71
|
+
deliverRunner: withDefaults(
|
|
72
|
+
DEFAULT_DELIVER_RUNNER,
|
|
73
|
+
config?.delivery?.deliverRunner,
|
|
74
|
+
),
|
|
75
|
+
codeReview: withDefaults(DEFAULT_CODE_REVIEW, config?.delivery?.codeReview),
|
|
76
76
|
decomposer: DEFAULT_DECOMPOSER,
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Overlay an operator's block onto the framework defaults — the per-key `??`
|
|
82
|
+
* fallback these accessors have always applied, written once.
|
|
83
|
+
*
|
|
84
|
+
* Iterating the **defaults'** keys rather than the user's is what keeps the
|
|
85
|
+
* returned shape closed: a key the framework does not define cannot reach a
|
|
86
|
+
* consumer through here even if one somehow survived AJV, so a typo degrades to
|
|
87
|
+
* the default rather than to an undefined a caller would read as configuration.
|
|
88
|
+
*
|
|
89
|
+
* @template {Record<string, unknown>} T
|
|
90
|
+
* @param {T} defaults Frozen framework defaults.
|
|
91
|
+
* @param {object|null|undefined} user Operator block from `.agentrc`.
|
|
92
|
+
* @returns {T} A fresh object; the frozen defaults are never mutated.
|
|
93
|
+
*/
|
|
94
|
+
function withDefaults(defaults, user) {
|
|
95
|
+
const out = { ...defaults };
|
|
96
|
+
for (const key of Object.keys(defaults)) {
|
|
97
|
+
if (user?.[key] != null) out[key] = user[key];
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
@@ -73,10 +73,18 @@ const DELIVER_RUNNER_SCHEMA = {
|
|
|
73
73
|
minimum: 1,
|
|
74
74
|
description:
|
|
75
75
|
'Maximum ready Stories dispatched by /deliver at once. Default 3. Moderate by design — keeps host-quota consumption predictable while allowing a small ready-set fan-out. Set 1 for strictly sequential delivery; raise further on hosts with adequate parallel-agent quota. See deliver.md for the sequencing model and throughput tradeoff.',
|
|
76
|
-
// getRunners() resolves this
|
|
77
|
-
// constant; the
|
|
76
|
+
// getRunners() resolves this from its own DEFAULT_DELIVER_RUNNER
|
|
77
|
+
// constant, not from this annotation; the parity suite asserts the two
|
|
78
|
+
// agree.
|
|
78
79
|
default: 3,
|
|
79
80
|
},
|
|
81
|
+
footprintGuard: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
enum: ['enforce', 'advisory'],
|
|
84
|
+
description:
|
|
85
|
+
"How a file-footprint collision affects dispatch. 'enforce' (default, and the behaviour to keep unless you have a reason) withholds a Story whose footprint races a peer admitted this beat or one still in flight — the guard encodes delivery-time-only knowledge (open implementation windows, foreign leases, ground that moved since planning) that no depends_on edge can carry. 'advisory' still DETECTS every collision and reports each would-be withhold in the tick envelope, but lets dispatch follow the declared depends_on edges alone — a deliberate throughput trade for a run whose ordering is fully declared. See stories-wave-tick.js and helpers/deliver-reference.md.",
|
|
86
|
+
default: 'enforce',
|
|
87
|
+
},
|
|
80
88
|
},
|
|
81
89
|
additionalProperties: false,
|
|
82
90
|
};
|
|
@@ -14,6 +14,14 @@ import path from 'node:path';
|
|
|
14
14
|
* Run the incremental capture path when
|
|
15
15
|
* `delivery.quality.gates.crap.incrementalCoverage.enabled` is true.
|
|
16
16
|
*
|
|
17
|
+
* **This does not shorten the capture run.** The changed-file set decides
|
|
18
|
+
* *whether* to capture, never *what* the capture executes: when nothing under
|
|
19
|
+
* `crap.targetDirs` changed there is no capture at all, and otherwise the
|
|
20
|
+
* ordinary full `npm run test:coverage` runs. The saving that makes the mode
|
|
21
|
+
* worth having is the skip; the other half is the CRAP join, which resolves
|
|
22
|
+
* methods in untouched files from the committed baseline row
|
|
23
|
+
* (`crap-baseline-join.js`) instead of demanding fresh coverage for them.
|
|
24
|
+
*
|
|
17
25
|
* Returns the process exit code when incremental mode handled the run
|
|
18
26
|
* (skip, capture, or a capture failure), or `null` when the caller should
|
|
19
27
|
* fall through to the full-scope path — either incremental mode is
|
|
@@ -81,13 +89,12 @@ export function tryIncrementalCapture({
|
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
logger.info(
|
|
84
|
-
`[coverage-capture] Incremental mode:
|
|
92
|
+
`[coverage-capture] Incremental mode: ${scopedFiles.length} changed file(s) under [${crap.targetDirs.join(', ')}] — capturing…`,
|
|
85
93
|
);
|
|
86
94
|
const code = runCaptureImpl({
|
|
87
95
|
cwd: args.cwd,
|
|
88
96
|
timeoutMs: coverage?.timeoutMs,
|
|
89
97
|
log: (m) => logger.info(m),
|
|
90
|
-
files: scopedFiles,
|
|
91
98
|
});
|
|
92
99
|
if (code !== 0) {
|
|
93
100
|
logger.error(
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coverage-capture-usage.js — the `--help` spec for `coverage-capture.js`
|
|
3
|
+
* (Story #5063).
|
|
4
|
+
*
|
|
5
|
+
* The delivery workflow invokes `coverage-capture.js` by name
|
|
6
|
+
* (`helpers/deliver-story-reference.md` § Step 1), which brings it under the
|
|
7
|
+
* workflow-invoked self-description contract enforced by
|
|
8
|
+
* `tests/enforcement/workflow-script-help.test.js`. It failed that contract:
|
|
9
|
+
* `--help` fell through to the capture path and spawned the whole coverage
|
|
10
|
+
* suite instead of describing the script.
|
|
11
|
+
*
|
|
12
|
+
* The spec lives here rather than inline for the same reason
|
|
13
|
+
* `coverage-capture-incremental.js` does — a same-file expansion of the CLI
|
|
14
|
+
* shell costs maintainability index on a file already near its floor, and a
|
|
15
|
+
* usage table is data, not decision logic.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { respondToHelp } from './cli-usage.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Usage spec consumed by `cli-usage.js#respondToHelp`. `coverage-capture.js`
|
|
22
|
+
* does not route through `runAsCli` (its synchronous main returns an exit
|
|
23
|
+
* code that `process.exit` forwards), so the help short-circuit is wired by
|
|
24
|
+
* hand rather than declared on a `runAsCli` call.
|
|
25
|
+
*
|
|
26
|
+
* @type {{ invocation: string, summary: string, flags: Array<[string, string]> }}
|
|
27
|
+
*/
|
|
28
|
+
const COVERAGE_CAPTURE_USAGE = {
|
|
29
|
+
invocation:
|
|
30
|
+
'node .agents/scripts/coverage-capture.js [--skip-when-no-crap-files] [--ref <git-ref>] [--cwd <path>]',
|
|
31
|
+
summary:
|
|
32
|
+
'Ensure coverage/coverage-final.json is present and fresh before the CRAP gate fires, spawning `npm run test:coverage` only when it is stale. Writes a content-digest capture stamp that close-validation reads to skip a redundant re-run.',
|
|
33
|
+
flags: [
|
|
34
|
+
[
|
|
35
|
+
'--skip-when-no-crap-files',
|
|
36
|
+
'Exit 0 without capturing when no changed file under the CRAP target dirs differs from --ref.',
|
|
37
|
+
],
|
|
38
|
+
['--ref <git-ref>', 'Git ref the changed-file set is computed against.'],
|
|
39
|
+
['--cwd <path>', 'Repository root the capture runs in.'],
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Answer `--help` / `-h` on stdout, returning whether the caller should stop.
|
|
45
|
+
* Takes the full `process.argv`-shaped array so the CLI shell hands over its
|
|
46
|
+
* own argv unchanged and the index arithmetic lives here rather than at the
|
|
47
|
+
* call site.
|
|
48
|
+
*
|
|
49
|
+
* @param {string[]} argv Full `process.argv`-shaped array.
|
|
50
|
+
* @param {{ write: (s: string) => void }} [out] Defaults to `process.stdout`.
|
|
51
|
+
* @returns {boolean} `true` when help was printed and the run must not proceed.
|
|
52
|
+
*/
|
|
53
|
+
export function handleCoverageCaptureHelp(argv = [], out = process.stdout) {
|
|
54
|
+
return respondToHelp(argv.slice(2), COVERAGE_CAPTURE_USAGE, out);
|
|
55
|
+
}
|
|
@@ -363,20 +363,21 @@ export const COVERAGE_TIMEOUT_EXIT_CODE = 124;
|
|
|
363
363
|
* `timeout(1)` convention exit code 124 so callers can pattern-match a
|
|
364
364
|
* runaway runner without inspecting signal names.
|
|
365
365
|
*
|
|
366
|
-
*
|
|
367
|
-
* test:coverage -- <files
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
366
|
+
* The spawn takes **no positional file arguments**. Story #4981 forwarded the
|
|
367
|
+
* changed-file list as `npm run test:coverage -- <files...>` on the premise
|
|
368
|
+
* that a test runner treats trailing positionals as filters over the suite.
|
|
369
|
+
* Node's runner does not: it treats each path as a test file to execute, so a
|
|
370
|
+
* forwarded *source* file runs as a trivially-passing test and the real suite
|
|
371
|
+
* never runs. `run-coverage.js` discarded the list, which is the only reason
|
|
372
|
+
* that never bit; Story #5063 measured it and Story #5065 removed the
|
|
373
|
+
* plumbing rather than leave a parameter whose obvious "fix" empties the
|
|
374
|
+
* coverage artifact.
|
|
373
375
|
*
|
|
374
376
|
* @param {{
|
|
375
377
|
* cwd: string,
|
|
376
378
|
* timeoutMs?: number,
|
|
377
379
|
* runner?: typeof spawnSync,
|
|
378
380
|
* log?: (m: string) => void,
|
|
379
|
-
* files?: string[] | null,
|
|
380
381
|
* }} opts
|
|
381
382
|
* @returns {number}
|
|
382
383
|
*/
|
|
@@ -385,14 +386,8 @@ export function runCapture({
|
|
|
385
386
|
timeoutMs,
|
|
386
387
|
runner = spawnSync,
|
|
387
388
|
log = () => {},
|
|
388
|
-
files = null,
|
|
389
389
|
} = {}) {
|
|
390
|
-
const
|
|
391
|
-
const args = [
|
|
392
|
-
'run',
|
|
393
|
-
'test:coverage',
|
|
394
|
-
...(scopedFiles ? ['--', ...scopedFiles] : []),
|
|
395
|
-
];
|
|
390
|
+
const args = ['run', 'test:coverage'];
|
|
396
391
|
log(`[coverage-capture] ▶ npm ${args.join(' ')}`);
|
|
397
392
|
const spawnOpts = {
|
|
398
393
|
cwd,
|
|
@@ -7,18 +7,31 @@
|
|
|
7
7
|
* lib/story-adjacency.js, lib/branch-name-guard.js).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { parseFooterBlockedByIds } from './story-body/footer-block.js';
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
|
-
* Parse
|
|
12
|
-
*
|
|
13
|
+
* Parse a body's declared blocker issue numbers — **footer-scoped and
|
|
14
|
+
* strict**.
|
|
15
|
+
*
|
|
16
|
+
* Only a `blocked by #N` line standing alone inside the `---` footer block
|
|
17
|
+
* declares an edge. The unanchored predecessor scanned the whole body for
|
|
18
|
+
* `blocked by|depends on #N` anywhere, so a Story whose prose merely mentioned
|
|
19
|
+
* a blocker — an example, a changelog note, an acceptance criterion describing
|
|
20
|
+
* this very defect — minted a real dispatch gate that withheld the Story until
|
|
21
|
+
* an unrelated issue closed.
|
|
22
|
+
*
|
|
23
|
+
* The behaviour change is deliberate and user-visible: prose-only mentions
|
|
24
|
+
* outside the footer no longer gate. Every machine-authored body already
|
|
25
|
+
* carries the canonical footer form (`plan-persist` has always serialized it),
|
|
26
|
+
* so only hand-written prose edges are affected — those must be moved into the
|
|
27
|
+
* footer block to keep gating. The grammar itself lives in
|
|
28
|
+
* `lib/story-body/footer-block.js`, shared with the body parser.
|
|
13
29
|
*
|
|
14
30
|
* @param {string} body - Issue body or freeform text.
|
|
15
|
-
* @returns {number[]} Array of issue numbers this
|
|
31
|
+
* @returns {number[]} Array of issue numbers this body declares as blockers.
|
|
16
32
|
*/
|
|
17
33
|
export function parseBlockedBy(body) {
|
|
18
|
-
|
|
19
|
-
const re = /(?:blocked\s+by|depends\s+on):?\s+#(\d+)/gi;
|
|
20
|
-
const ids = [...body.matchAll(re)].map((m) => Number.parseInt(m[1], 10));
|
|
21
|
-
return [...new Set(ids)];
|
|
34
|
+
return parseFooterBlockedByIds(body);
|
|
22
35
|
}
|
|
23
36
|
|
|
24
37
|
/**
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/findings/provenance-field.js — the per-Story `provenance` field.
|
|
3
|
+
*
|
|
4
|
+
* An audit-seeded plan carries dedup identities forward so the next sweep
|
|
5
|
+
* recognises what it already planned. The optional top-level `provenance`
|
|
6
|
+
* field on a `stories.json` entry says **which of them that Story owns**:
|
|
7
|
+
*
|
|
8
|
+
* ```jsonc
|
|
9
|
+
* { "fingerprints": ["<40-char sha1>"], "semanticKeys": ["architecture␟lib/a.js"] }
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Two callers, deliberately split from
|
|
13
|
+
* [`route-finding.js`](route-finding.js): the ticket validator shape-checks
|
|
14
|
+
* the authored field, and plan-persist's assembly renders the owned identities
|
|
15
|
+
* into the footer source it stamps. Neither is dedup *routing*, which is what
|
|
16
|
+
* `route-finding.js` is for — this module reads its identity vocabulary
|
|
17
|
+
* (`SHA1_RE`, `SEMANTIC_KEY_RE`, and the two footer renderers) from there so
|
|
18
|
+
* there is exactly one definition of what a fingerprint or a semantic key
|
|
19
|
+
* looks like.
|
|
20
|
+
*
|
|
21
|
+
* @module lib/findings/provenance-field
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
fingerprintFooter,
|
|
26
|
+
SEMANTIC_KEY_RE,
|
|
27
|
+
SHA1_RE,
|
|
28
|
+
semanticKeyFooter,
|
|
29
|
+
} from './route-finding.js';
|
|
30
|
+
|
|
31
|
+
/** Human-readable rendering of the `provenance` field's two lists. */
|
|
32
|
+
const PROVENANCE_SHAPE = 'fingerprints[] / semanticKeys[]';
|
|
33
|
+
|
|
34
|
+
/** What each `provenance` list accepts, and how to say so when it does not. */
|
|
35
|
+
const PROVENANCE_FIELDS = Object.freeze({
|
|
36
|
+
fingerprints: { pattern: SHA1_RE, expected: 'a 40-char sha1 hex string' },
|
|
37
|
+
semanticKeys: {
|
|
38
|
+
pattern: SEMANTIC_KEY_RE,
|
|
39
|
+
expected: 'a non-empty key carrying no comma or ">"',
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validate one authored `provenance` list into its normalized form.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} list
|
|
47
|
+
* @param {{ where: string, field: string, pattern: RegExp, expected: string }} spec
|
|
48
|
+
* @returns {string[]} Trimmed, de-duplicated, first-seen order.
|
|
49
|
+
*/
|
|
50
|
+
function normalizeList(list, { where, field, pattern, expected }) {
|
|
51
|
+
if (list === null || list === undefined) return [];
|
|
52
|
+
if (!Array.isArray(list)) {
|
|
53
|
+
throw new Error(`${where}: ${field} must be an array of strings`);
|
|
54
|
+
}
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const entry of list) {
|
|
57
|
+
const value = typeof entry === 'string' ? entry.trim() : '';
|
|
58
|
+
if (!pattern.test(value)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${where}: ${field} entry ${JSON.stringify(entry)} is not ${expected}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (!out.includes(value)) out.push(value);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize the optional per-Story `provenance` field a plan may author —
|
|
70
|
+
* the identities of the findings **that Story owns**.
|
|
71
|
+
*
|
|
72
|
+
* Absence is meaningful and must stay cheap: `undefined` / `null` returns
|
|
73
|
+
* `null`, which is the caller's signal to fall back to the whole-seed union
|
|
74
|
+
* carry. That fallback is not vestigial — leaving the authoring agent to
|
|
75
|
+
* hand-carry provenance out of the seed's HTML comments was measured to fail,
|
|
76
|
+
* and the mechanical union is what closed it. Attribution is **additive**: a
|
|
77
|
+
* plan that attributes gets exact stamping, a plan that does not keeps recall.
|
|
78
|
+
*
|
|
79
|
+
* An empty object is therefore *not* the same as an absent field: it means
|
|
80
|
+
* "this Story owns nothing", and stamps nothing.
|
|
81
|
+
*
|
|
82
|
+
* Present-but-malformed is a hard error rather than a silent drop, because a
|
|
83
|
+
* dropped identity is invisible until the next sweep re-files work that was
|
|
84
|
+
* already planned.
|
|
85
|
+
*
|
|
86
|
+
* @param {unknown} raw
|
|
87
|
+
* @param {string} [label] Identifier for the error message (a Story slug).
|
|
88
|
+
* @returns {{ fingerprints: string[], semanticKeys: string[] }|null}
|
|
89
|
+
* @throws {Error} On any shape the stamper cannot honour exactly.
|
|
90
|
+
*/
|
|
91
|
+
export function normalizeOwnedProvenance(raw, label = 'story') {
|
|
92
|
+
if (raw === undefined || raw === null) return null;
|
|
93
|
+
const where = `provenance on "${label}"`;
|
|
94
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
95
|
+
throw new Error(`${where} must be an object of ${PROVENANCE_SHAPE}`);
|
|
96
|
+
}
|
|
97
|
+
const out = { fingerprints: [], semanticKeys: [] };
|
|
98
|
+
for (const [field, list] of Object.entries(raw)) {
|
|
99
|
+
const spec = PROVENANCE_FIELDS[field];
|
|
100
|
+
if (!spec) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${where} carries an unknown field: ${field} — only ${PROVENANCE_SHAPE} are stamped`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
out[field] = normalizeList(list, { where, field, ...spec });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Render the provenance **source document** for a set of owned identities, in
|
|
112
|
+
* the same footer vocabulary `carryProvenanceFooters` harvests from an audit
|
|
113
|
+
* seed. That reuse is the point: attribution changes *which* identities reach
|
|
114
|
+
* a Story body, never how they are stamped, so the carry stays additive,
|
|
115
|
+
* union-preserving and idempotent for an attributed plan exactly as it is for
|
|
116
|
+
* an un-attributed one.
|
|
117
|
+
*
|
|
118
|
+
* An empty (or absent) set renders the empty string, which the carry treats as
|
|
119
|
+
* nothing-to-do — so a Story that owns no findings is stamped with none rather
|
|
120
|
+
* than inheriting its siblings'.
|
|
121
|
+
*
|
|
122
|
+
* Expects the normalized shape {@link normalizeOwnedProvenance} returns; the
|
|
123
|
+
* validator runs first on every production path.
|
|
124
|
+
*
|
|
125
|
+
* @param {{ fingerprints?: string[], semanticKeys?: string[] }|null} [provenance]
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function ownedProvenanceSource(provenance) {
|
|
129
|
+
const shas = provenance?.fingerprints ?? [];
|
|
130
|
+
const keys = provenance?.semanticKeys ?? [];
|
|
131
|
+
const parts = [];
|
|
132
|
+
if (shas.length > 0) parts.push(fingerprintFooter(shas));
|
|
133
|
+
if (keys.length > 0) parts.push(semanticKeyFooter(keys));
|
|
134
|
+
return parts.join('\n');
|
|
135
|
+
}
|
|
@@ -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
|
};
|