mandrel 2.54.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +5 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +34 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +374 -76
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +52 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +42 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +2 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +30 -0
- package/.agents/workflows/mandrel-plan.md +10 -13
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +37 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -48,8 +48,15 @@ export const LOCAL_SKILLS_SEGMENTS = Object.freeze([
|
|
|
48
48
|
* The pattern is deliberately strict: ids resolve to filesystem paths, so
|
|
49
49
|
* anything that could escape a root (`..`, absolute paths, backslashes) or
|
|
50
50
|
* smuggle a shell metacharacter is rejected rather than normalized.
|
|
51
|
+
*
|
|
52
|
+
* Exported because `config-settings-schema.js` mirrors it as the `pattern`
|
|
53
|
+
* on `qa.environments.*.signInSeam.skill`, so a malformed id is rejected at
|
|
54
|
+
* config-validation time rather than surviving to a path join (Story #5285).
|
|
55
|
+
* It is one regex with two enforcement points, never two regexes: the AJV
|
|
56
|
+
* `pattern` keyword takes the source string, so the schema must import this
|
|
57
|
+
* value rather than restate it.
|
|
51
58
|
*/
|
|
52
|
-
const SKILL_ID_RE = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/;
|
|
59
|
+
export const SKILL_ID_RE = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/;
|
|
53
60
|
|
|
54
61
|
/**
|
|
55
62
|
* Recursively enumerate `SKILL.md` paths under a directory.
|
|
@@ -145,17 +152,27 @@ export function collectLocalSkillFiles(repoRoot) {
|
|
|
145
152
|
* first and the local zone second (payload-wins, matching
|
|
146
153
|
* {@link collectAllSkillFiles}).
|
|
147
154
|
*
|
|
148
|
-
* Returns
|
|
149
|
-
*
|
|
150
|
-
*
|
|
155
|
+
* Returns rather than throwing so callers own the error message — a config
|
|
156
|
+
* resolver wants to name the offending config key, a workflow wants to name
|
|
157
|
+
* the seam. The two failures are **distinguishable** (Story #5285):
|
|
158
|
+
*
|
|
159
|
+
* - `null` — a well-formed id that resolves under neither root. The remedy
|
|
160
|
+
* is to author the skill, so the caller says so.
|
|
161
|
+
* - `{ reason: 'invalid-id' }` — an id the pattern rejects (`../../secrets`,
|
|
162
|
+
* `Core/Foo`, a bare `core`). No filesystem lookup happens, and telling
|
|
163
|
+
* the operator to author `../../secrets/SKILL.md` would be advice that
|
|
164
|
+
* cannot be followed. The remedy is to fix the id.
|
|
151
165
|
*
|
|
152
166
|
* @param {string} repoRoot
|
|
153
167
|
* @param {string} skillId Tier-relative id, e.g. `stack/qa/acme-sso`.
|
|
154
|
-
* @returns {{ path: string, root: string } |
|
|
155
|
-
* path and the POSIX repo-relative root it resolved
|
|
168
|
+
* @returns {{ path: string, root: string } | { reason: 'invalid-id' } | null}
|
|
169
|
+
* the absolute `SKILL.md` path and the POSIX repo-relative root it resolved
|
|
170
|
+
* under, the malformed-id marker, or `null` when nothing resolved.
|
|
156
171
|
*/
|
|
157
172
|
export function resolveSkillFile(repoRoot, skillId) {
|
|
158
|
-
if (typeof skillId !== 'string' || !SKILL_ID_RE.test(skillId))
|
|
173
|
+
if (typeof skillId !== 'string' || !SKILL_ID_RE.test(skillId)) {
|
|
174
|
+
return { reason: 'invalid-id' };
|
|
175
|
+
}
|
|
159
176
|
for (const segments of [PAYLOAD_SKILLS_SEGMENTS, LOCAL_SKILLS_SEGMENTS]) {
|
|
160
177
|
const candidate = path.join(repoRoot, ...segments, skillId, 'SKILL.md');
|
|
161
178
|
try {
|
|
@@ -51,12 +51,69 @@ export const SUITE_ROOT_PREFIX = 'mandrel-suite-';
|
|
|
51
51
|
*/
|
|
52
52
|
export const SUITE_ROOTS_KEY = '#suiteRoots';
|
|
53
53
|
|
|
54
|
+
/** Default `warn` sink: one line on stderr, shared by every seam below. */
|
|
55
|
+
const stderrWarn = (msg) => process.stderr.write(`${msg}\n`);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Remove one directory, reporting a failure rather than throwing it.
|
|
59
|
+
*
|
|
60
|
+
* Both reapers need exactly this: a suite that passed must not start
|
|
61
|
+
* failing because a directory could not be unlinked (a Windows file lock, a
|
|
62
|
+
* read-only mount). The leak is the lesser defect, and the guard reports it
|
|
63
|
+
* separately.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} target absolute path to remove
|
|
66
|
+
* @param {string} label what `target` is, for the failure message
|
|
67
|
+
* @param {typeof fs} fsImpl
|
|
68
|
+
* @param {(msg: string) => void} warn
|
|
69
|
+
* @returns {void}
|
|
70
|
+
*/
|
|
71
|
+
function rmQuietly(target, label, fsImpl, warn) {
|
|
72
|
+
try {
|
|
73
|
+
fsImpl.rmSync(target, { recursive: true, force: true });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
warn(`[test-temp] failed to reap ${label} ${target}: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
/** Per-process suite root, or `null` before the first `makeTempDir`. */
|
|
55
80
|
let _suiteRoot = null;
|
|
56
81
|
|
|
57
82
|
/** Guards against registering the exit reaper more than once. */
|
|
58
83
|
let _reaperRegistered = false;
|
|
59
84
|
|
|
85
|
+
/** Lead-in of the one warning a root disappearing mid-run produces. */
|
|
86
|
+
const VANISHED =
|
|
87
|
+
'[test-temp] suite temp root disappeared mid-run (removed by something ' +
|
|
88
|
+
'outside this process):';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Suite roots this process minted and later found gone.
|
|
92
|
+
*
|
|
93
|
+
* Recovery is the right behaviour — see {@link suiteTempRoot} — but a silent
|
|
94
|
+
* recovery is not. Incident #5272 was a required check going red with an
|
|
95
|
+
* unattributable `ENOENT`, and the hardening written to expose it made the
|
|
96
|
+
* run *quieter*: the re-mint succeeded, the suite went green, and the fact
|
|
97
|
+
* that something outside the process is deleting the temp tree left no trace
|
|
98
|
+
* anyone would look at. This list is that trace, and the exit reaper turns it
|
|
99
|
+
* into a verdict.
|
|
100
|
+
*/
|
|
101
|
+
const _vanishedRoots = [];
|
|
102
|
+
|
|
103
|
+
/** Exit code a run that lost a suite root reports, when nothing worse did. */
|
|
104
|
+
const VANISHED_EXIT_CODE = 1;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Default `setExitCode` sink. Never lowers an exit code already set: a real
|
|
108
|
+
* test failure is the more informative verdict.
|
|
109
|
+
*
|
|
110
|
+
* @param {number} code
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
function raiseExitCode(code) {
|
|
114
|
+
if (!process.exitCode) process.exitCode = code;
|
|
115
|
+
}
|
|
116
|
+
|
|
60
117
|
/**
|
|
61
118
|
* Test-only: forget the per-process suite root without removing it, so a
|
|
62
119
|
* test can exercise the creation branch repeatedly in one process.
|
|
@@ -64,10 +121,40 @@ let _reaperRegistered = false;
|
|
|
64
121
|
export function _resetSuiteTempRootForTests() {
|
|
65
122
|
_suiteRoot = null;
|
|
66
123
|
_reaperRegistered = false;
|
|
124
|
+
_vanishedRoots.length = 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Report every root that vanished under this process, and fail the run.
|
|
129
|
+
*
|
|
130
|
+
* Called from the exit reaper, so it is the last word on a run that
|
|
131
|
+
* otherwise passed. The exit code is only raised when nothing else already
|
|
132
|
+
* failed: a real test failure is the more informative verdict and must not
|
|
133
|
+
* be overwritten by this one.
|
|
134
|
+
*
|
|
135
|
+
* @param {{ warn?: (msg: string) => void, setExitCode?: (code: number) => void }} [deps]
|
|
136
|
+
* @returns {void}
|
|
137
|
+
*/
|
|
138
|
+
function reportVanishedRoots({
|
|
139
|
+
warn = stderrWarn,
|
|
140
|
+
setExitCode = raiseExitCode,
|
|
141
|
+
} = {}) {
|
|
142
|
+
if (_vanishedRoots.length === 0) return;
|
|
143
|
+
warn(
|
|
144
|
+
`[test-temp] FAIL — ${_vanishedRoots.length} suite temp root(s) disappeared mid-run:\n` +
|
|
145
|
+
_vanishedRoots.map((root) => ` - ${root}`).join('\n') +
|
|
146
|
+
'\n[test-temp] the suite recovered by re-minting, so the tests passed — but something outside this process is deleting the temp tree, and a fixture that hits the window between the removal and the re-mint fails with an unattributable ENOENT (#5272). Find the pruner before trusting this run.',
|
|
147
|
+
);
|
|
148
|
+
setExitCode(VANISHED_EXIT_CODE);
|
|
67
149
|
}
|
|
68
150
|
|
|
69
151
|
/**
|
|
70
|
-
*
|
|
152
|
+
* Report the suite root this process currently owns, without minting one.
|
|
153
|
+
*
|
|
154
|
+
* Test-only in spirit, but also the honest way for a failing fixture to say
|
|
155
|
+
* whether the root still exists when it reports a copy failure: asking
|
|
156
|
+
* {@link suiteTempRoot} would *create* one and destroy the evidence.
|
|
157
|
+
*
|
|
71
158
|
* @returns {string|null}
|
|
72
159
|
*/
|
|
73
160
|
export function _currentSuiteTempRoot() {
|
|
@@ -77,29 +164,23 @@ export function _currentSuiteTempRoot() {
|
|
|
77
164
|
/**
|
|
78
165
|
* Remove this process's suite root and everything under it.
|
|
79
166
|
*
|
|
80
|
-
* A teardown failure is reported on stderr and swallowed
|
|
81
|
-
*
|
|
82
|
-
* (a Windows file lock, a read-only mount). The leak is the lesser defect
|
|
83
|
-
* and the guard reports it separately.
|
|
167
|
+
* A teardown failure is reported on stderr and swallowed — see
|
|
168
|
+
* {@link rmQuietly}.
|
|
84
169
|
*
|
|
85
170
|
* Only the process that minted the root can reach a non-null `_suiteRoot`,
|
|
86
|
-
* so this is creator-only by construction.
|
|
171
|
+
* so this is creator-only by construction. It reads that variable *live*
|
|
172
|
+
* rather than a path captured at arming time, which is what keeps a root
|
|
173
|
+
* re-created mid-run (see {@link suiteTempRoot}) reapable and a root it has
|
|
174
|
+
* replaced unreachable — a stale path is never passed to `rmSync`.
|
|
87
175
|
*
|
|
88
176
|
* @param {{ fsImpl?: typeof fs, warn?: (msg: string) => void }} [deps]
|
|
89
177
|
* @returns {string|null} the removed root, or `null` when there was none
|
|
90
178
|
*/
|
|
91
|
-
export function reapSuiteTempRoot({
|
|
92
|
-
fsImpl = fs,
|
|
93
|
-
warn = (msg) => process.stderr.write(`${msg}\n`),
|
|
94
|
-
} = {}) {
|
|
179
|
+
export function reapSuiteTempRoot({ fsImpl = fs, warn = stderrWarn } = {}) {
|
|
95
180
|
const root = _suiteRoot;
|
|
96
181
|
if (root === null) return null;
|
|
97
182
|
_suiteRoot = null;
|
|
98
|
-
|
|
99
|
-
fsImpl.rmSync(root, { recursive: true, force: true });
|
|
100
|
-
} catch (err) {
|
|
101
|
-
warn(`[test-temp] failed to reap suite temp root ${root}: ${err.message}`);
|
|
102
|
-
}
|
|
183
|
+
rmQuietly(root, 'suite temp root', fsImpl, warn);
|
|
103
184
|
return root;
|
|
104
185
|
}
|
|
105
186
|
|
|
@@ -110,21 +191,79 @@ export function reapSuiteTempRoot({
|
|
|
110
191
|
* cannot exist without its teardown already armed — including when the
|
|
111
192
|
* suite fails, since a failing `node --test` run still exits normally.
|
|
112
193
|
*
|
|
113
|
-
*
|
|
194
|
+
* ## Why the memoised path is re-checked every call
|
|
195
|
+
*
|
|
196
|
+
* The root lives under a temp tree this process does not own exclusively
|
|
197
|
+
* (see the module docstring: a `/tmp` shared with self-hosted runners, an
|
|
198
|
+
* OS or operator pruner). Memoising the path without re-checking it made a
|
|
199
|
+
* single external removal terminal: every later `makeTempDir` in the
|
|
200
|
+
* process failed with an `ENOENT` naming an interior path, and with 204
|
|
201
|
+
* call sites reaching this helper one deletion cascaded through the rest
|
|
202
|
+
* of the file. Re-creating is strictly better than failing — nothing in
|
|
203
|
+
* the suite holds a handle to the root itself, only to directories minted
|
|
204
|
+
* beneath it, which the removal already took.
|
|
205
|
+
*
|
|
206
|
+
* The reaper armed at first use needs no re-arming: it reads `_suiteRoot`
|
|
207
|
+
* live, so it already covers whatever root this process owns at exit.
|
|
208
|
+
* Re-creation leaves the replaced path unreachable, so the creator-only
|
|
209
|
+
* invariant holds — this process still reaps only a root it minted, and
|
|
210
|
+
* never one it has replaced.
|
|
211
|
+
*
|
|
212
|
+
* @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void, warn?: (msg: string) => void, setExitCode?: (code: number) => void }} [deps]
|
|
114
213
|
* @returns {string} absolute path to the suite root
|
|
115
214
|
*/
|
|
116
|
-
export function suiteTempRoot({
|
|
215
|
+
export function suiteTempRoot(deps = {}) {
|
|
216
|
+
const fsImpl = deps.fsImpl ?? fs;
|
|
217
|
+
if (_suiteRoot !== null && fsImpl.existsSync(_suiteRoot)) return _suiteRoot;
|
|
218
|
+
return mintSuiteRoot(deps);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Mint the process's suite root — the first one, or a replacement for one
|
|
223
|
+
* that disappeared underneath it — and arm the reaper if nothing has.
|
|
224
|
+
*
|
|
225
|
+
* Separate from {@link suiteTempRoot} so the hot path stays the single
|
|
226
|
+
* existence check callers pay on every `makeTempDir`, and the recovery it
|
|
227
|
+
* guards reads as the exceptional branch it is.
|
|
228
|
+
*
|
|
229
|
+
* @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void, warn?: (msg: string) => void, setExitCode?: (code: number) => void }} deps
|
|
230
|
+
* @returns {string} absolute path to the new suite root
|
|
231
|
+
*/
|
|
232
|
+
function mintSuiteRoot({
|
|
117
233
|
fsImpl = fs,
|
|
118
234
|
tmpdir = os.tmpdir,
|
|
119
235
|
onExit = (fn) => process.once('exit', fn),
|
|
236
|
+
warn = stderrWarn,
|
|
237
|
+
setExitCode = raiseExitCode,
|
|
120
238
|
} = {}) {
|
|
121
|
-
|
|
239
|
+
const vanished = _suiteRoot;
|
|
240
|
+
const base = tmpdir();
|
|
241
|
+
// Recovery re-creates the suite's OWN root and nothing above it. An
|
|
242
|
+
// earlier revision called `mkdirSync(base, { recursive: true })` here, so a
|
|
243
|
+
// process whose OS temp root had been removed silently re-created `/tmp`
|
|
244
|
+
// — with this process's umask rather than the sticky 1777 the system sets
|
|
245
|
+
// — and carried on. That is a broken machine reported as a passing suite;
|
|
246
|
+
// name it instead.
|
|
247
|
+
if (!fsImpl.existsSync(base))
|
|
248
|
+
throw new Error(
|
|
249
|
+
`[test-temp] OS temp root ${base} does not exist; refusing to create it. Something removed the system temp directory (or TMPDIR points at a path that was never created) — fix the environment rather than letting the suite mint it.`,
|
|
250
|
+
);
|
|
122
251
|
_suiteRoot = fsImpl.mkdtempSync(
|
|
123
|
-
path.join(
|
|
252
|
+
path.join(base, `${SUITE_ROOT_PREFIX}${process.pid}-`),
|
|
124
253
|
);
|
|
254
|
+
// Say it once, loudly: this is the only trace that something outside the
|
|
255
|
+
// process touched the temp tree, and the incident it explains (#5272) was
|
|
256
|
+
// filed against the wrong mechanism for want of it.
|
|
257
|
+
if (vanished !== null) {
|
|
258
|
+
_vanishedRoots.push(vanished);
|
|
259
|
+
warn(`${VANISHED} ${vanished}; re-created as ${_suiteRoot}`);
|
|
260
|
+
}
|
|
125
261
|
if (!_reaperRegistered) {
|
|
126
262
|
_reaperRegistered = true;
|
|
127
|
-
onExit(() =>
|
|
263
|
+
onExit(() => {
|
|
264
|
+
reapSuiteTempRoot({ fsImpl, warn });
|
|
265
|
+
reportVanishedRoots({ warn, setExitCode });
|
|
266
|
+
});
|
|
128
267
|
}
|
|
129
268
|
return _suiteRoot;
|
|
130
269
|
}
|
|
@@ -137,8 +276,12 @@ export function suiteTempRoot({
|
|
|
137
276
|
* path is absolute and unique, so call sites change only where the
|
|
138
277
|
* directory comes from, never how it is used.
|
|
139
278
|
*
|
|
279
|
+
* A vanished suite root is re-created by {@link suiteTempRoot} first, so
|
|
280
|
+
* this never fails with an `ENOENT` naming a directory the caller did not
|
|
281
|
+
* ask for.
|
|
282
|
+
*
|
|
140
283
|
* @param {string} [prefix='t-'] label kept for readability in a stack trace
|
|
141
|
-
* @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void }} [deps]
|
|
284
|
+
* @param {{ fsImpl?: typeof fs, tmpdir?: () => string, onExit?: (fn: () => void) => void, warn?: (msg: string) => void }} [deps]
|
|
142
285
|
* @returns {string} absolute path to the new directory
|
|
143
286
|
*/
|
|
144
287
|
export function makeTempDir(prefix = 't-', deps = {}) {
|
|
@@ -157,7 +300,7 @@ export function makeTempDir(prefix = 't-', deps = {}) {
|
|
|
157
300
|
* never reap it, or it deletes its parent's scratch mid-run.
|
|
158
301
|
*
|
|
159
302
|
* Teardown failures are swallowed for the same reason as
|
|
160
|
-
* {@link reapSuiteTempRoot}
|
|
303
|
+
* {@link reapSuiteTempRoot} — see {@link rmQuietly}.
|
|
161
304
|
*
|
|
162
305
|
* @param {string} dirPath absolute path this process minted
|
|
163
306
|
* @param {{ fsImpl?: typeof fs, onExit?: (fn: () => void) => void, warn?: (msg: string) => void }} [deps]
|
|
@@ -168,16 +311,10 @@ export function reapOnExit(
|
|
|
168
311
|
{
|
|
169
312
|
fsImpl = fs,
|
|
170
313
|
onExit = (fn) => process.once('exit', fn),
|
|
171
|
-
warn =
|
|
314
|
+
warn = stderrWarn,
|
|
172
315
|
} = {},
|
|
173
316
|
) {
|
|
174
|
-
onExit(() =>
|
|
175
|
-
try {
|
|
176
|
-
fsImpl.rmSync(dirPath, { recursive: true, force: true });
|
|
177
|
-
} catch (err) {
|
|
178
|
-
warn(`[test-temp] failed to reap scratch dir ${dirPath}: ${err.message}`);
|
|
179
|
-
}
|
|
180
|
-
});
|
|
317
|
+
onExit(() => rmQuietly(dirPath, 'scratch dir', fsImpl, warn));
|
|
181
318
|
}
|
|
182
319
|
|
|
183
320
|
/**
|
|
@@ -265,6 +265,43 @@ export function recordPass(
|
|
|
265
265
|
return record;
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* The content identity of a working tree, as an evidence `inputFingerprint`
|
|
270
|
+
* (Story #5278).
|
|
271
|
+
*
|
|
272
|
+
* `commitSha` is the wrong key for "have these inputs already been checked".
|
|
273
|
+
* Close's own base-sync moves HEAD immediately before the gates run, so every
|
|
274
|
+
* gate the worker paid for is re-run against a commit whose *content* the
|
|
275
|
+
* evidence already covers whenever the sync brought nothing in — the record
|
|
276
|
+
* is discarded as `sha-mismatch` and lint, typecheck and the suite are all
|
|
277
|
+
* paid for twice.
|
|
278
|
+
*
|
|
279
|
+
* `git rev-parse HEAD^{tree}` is the exact answer: the tree object id is a
|
|
280
|
+
* hash of the committed content and nothing else, so it is stable across a
|
|
281
|
+
* fast-forward, a rebase, an empty merge and a commit-message amend, and it
|
|
282
|
+
* differs the instant any tracked byte does. It deliberately ignores
|
|
283
|
+
* uncommitted changes for the same reason `commitSha` did — the gates run on
|
|
284
|
+
* a committed Story branch.
|
|
285
|
+
*
|
|
286
|
+
* Returns `null` when the tree cannot be read, which routes every caller to
|
|
287
|
+
* the pre-#5278 SHA-only behaviour rather than to a false match.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} cwd Absolute worktree root.
|
|
290
|
+
* @param {Function} [gitSpawnFn] `(cwd, ...args) => { status, stdout }`.
|
|
291
|
+
* @returns {string|null} `tree:<oid>`, or `null` when unavailable.
|
|
292
|
+
*/
|
|
293
|
+
export function treeFingerprint(cwd, gitSpawnFn) {
|
|
294
|
+
if (typeof gitSpawnFn !== 'function') return null;
|
|
295
|
+
try {
|
|
296
|
+
const res = gitSpawnFn(cwd, 'rev-parse', 'HEAD^{tree}');
|
|
297
|
+
if (res?.status !== 0) return null;
|
|
298
|
+
const oid = String(res.stdout ?? '').trim();
|
|
299
|
+
return /^[0-9a-f]{40,64}$/.test(oid) ? `tree:${oid}` : null;
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
268
305
|
/**
|
|
269
306
|
* Decide whether a gate can be skipped given the current HEAD + command
|
|
270
307
|
* config. Skip is granted only on full triple-match: gateName + commitSha +
|
|
@@ -174,25 +174,106 @@ function isUnderTempRoot(path, tempRoot) {
|
|
|
174
174
|
* narrowing the scrape can never co-dispatch a pair the declared comparison
|
|
175
175
|
* would have caught.
|
|
176
176
|
*
|
|
177
|
+
* Each path is returned **with the field it was scraped from** (Story #5265).
|
|
178
|
+
* "This pair collided on a path neither declared" is only half an
|
|
179
|
+
* explanation: the operator's next question is always *where did that path
|
|
180
|
+
* come from*, and until they can answer it they cannot tell an unpredicted
|
|
181
|
+
* edit target from a citation the guard read as one. The measured case is a
|
|
182
|
+
* gate script every Story merely **runs** in `verify[]` — attribution turns
|
|
183
|
+
* that from an unexplained serialisation into a one-glance verdict.
|
|
184
|
+
*
|
|
177
185
|
* @param {object} story
|
|
178
186
|
* @param {object} [options]
|
|
179
187
|
* @param {string} [options.tempRoot='temp'] Resolved `project.paths.tempRoot`.
|
|
180
|
-
* @returns {Set<string
|
|
188
|
+
* @returns {Map<string, Set<string>>} Path → the field label(s) that named it.
|
|
181
189
|
*/
|
|
182
190
|
function storyEvidencePaths(story, { tempRoot = DEFAULT_TEMP_ROOT } = {}) {
|
|
183
|
-
const out = new
|
|
184
|
-
for (const field of
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (!isUnderTempRoot(token, tempRoot)) out.add(token);
|
|
191
|
+
const out = new Map();
|
|
192
|
+
for (const [field, text] of attributedSegments(story)) {
|
|
193
|
+
for (const [token] of text.matchAll(PROSE_PATH_RE)) {
|
|
194
|
+
if (isUnderTempRoot(token, tempRoot)) continue;
|
|
195
|
+
const fields = out.get(token);
|
|
196
|
+
if (fields) fields.add(field);
|
|
197
|
+
else out.set(token, new Set([field]));
|
|
191
198
|
}
|
|
192
199
|
}
|
|
193
200
|
return out;
|
|
194
201
|
}
|
|
195
202
|
|
|
203
|
+
/**
|
|
204
|
+
* A markdown section heading in a serialized Story body — the attribution
|
|
205
|
+
* grain (Story #5265).
|
|
206
|
+
*
|
|
207
|
+
* `body` alone would be a true but useless label: a Story body is the whole
|
|
208
|
+
* document, so every scraped path would report the same field. The section is
|
|
209
|
+
* where the distinction actually lives — a path under `## Changes` is a
|
|
210
|
+
* declaration restated, one under `## Verify` is a command line, one under
|
|
211
|
+
* `## Non-Goals` is explicitly *not* an edit target.
|
|
212
|
+
*/
|
|
213
|
+
const BODY_SECTION_RE = /^#{2,6}[ \t]+(\S.*?)[ \t]*$/gm;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Strip the two token sources that are structurally incapable of naming an
|
|
217
|
+
* edit target. Applied to the whole field **before** segmentation, so the
|
|
218
|
+
* scanned text is byte-identical to what the pre-attribution scrape read and
|
|
219
|
+
* a stripped footer can never be mistaken for a section boundary.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} text
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
function stripNonIntentTokens(text) {
|
|
225
|
+
return text
|
|
226
|
+
.replace(PROVENANCE_FOOTER_RE, ' ')
|
|
227
|
+
.replace(MARKDOWN_LINK_URL_RE, ']()');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Split a Story body into `[label, text]` segments at its `##` headings.
|
|
232
|
+
*
|
|
233
|
+
* The heading line stays with the section it opens rather than being consumed
|
|
234
|
+
* as a delimiter: a heading can itself name a path, and dropping that text
|
|
235
|
+
* would *narrow* the footprint — the one direction this layer must never move
|
|
236
|
+
* (Story #4875 / #5265 AC-8). Text before the first heading keeps the bare
|
|
237
|
+
* `body` label.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} body
|
|
240
|
+
* @returns {Array<[string, string]>}
|
|
241
|
+
*/
|
|
242
|
+
function bodySegments(body) {
|
|
243
|
+
const out = [];
|
|
244
|
+
let cursor = 0;
|
|
245
|
+
let label = 'body';
|
|
246
|
+
for (const match of body.matchAll(BODY_SECTION_RE)) {
|
|
247
|
+
if (match.index > cursor)
|
|
248
|
+
out.push([label, body.slice(cursor, match.index)]);
|
|
249
|
+
label = `body:${match[1].trim()}`;
|
|
250
|
+
cursor = match.index;
|
|
251
|
+
}
|
|
252
|
+
out.push([label, body.slice(cursor)]);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Every scannable `[fieldLabel, text]` pair on a Story record: `title` and
|
|
258
|
+
* `spec` whole, `body` split by section.
|
|
259
|
+
*
|
|
260
|
+
* @param {object} story
|
|
261
|
+
* @returns {Array<[string, string]>}
|
|
262
|
+
*/
|
|
263
|
+
function attributedSegments(story) {
|
|
264
|
+
const out = [];
|
|
265
|
+
if (typeof story?.title === 'string') {
|
|
266
|
+
out.push(['title', stripNonIntentTokens(story.title)]);
|
|
267
|
+
}
|
|
268
|
+
if (typeof story?.body === 'string') {
|
|
269
|
+
out.push(...bodySegments(stripNonIntentTokens(story.body)));
|
|
270
|
+
}
|
|
271
|
+
if (typeof story?.spec === 'string') {
|
|
272
|
+
out.push(['spec', stripNonIntentTokens(story.spec)]);
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
196
277
|
/**
|
|
197
278
|
* A Story's declared footprint **and** the evidence-widened one.
|
|
198
279
|
*
|
|
@@ -203,15 +284,54 @@ function storyEvidencePaths(story, { tempRoot = DEFAULT_TEMP_ROOT } = {}) {
|
|
|
203
284
|
* the scrape produced may be an artifact of how a body was worded. An operator
|
|
204
285
|
* reading an unfilled slot needs to tell those apart.
|
|
205
286
|
*
|
|
287
|
+
* `evidence` carries the third answer (Story #5265): *which field* produced
|
|
288
|
+
* each scraped path, so a collision can say where the token was written
|
|
289
|
+
* rather than only that nobody declared it.
|
|
290
|
+
*
|
|
206
291
|
* @param {object} story
|
|
207
292
|
* @param {object} [options]
|
|
208
|
-
* @returns {{ declared: Set<string>, widened: Set<string
|
|
293
|
+
* @returns {{ declared: Set<string>, widened: Set<string>, evidence: Map<string, Set<string>> }}
|
|
209
294
|
*/
|
|
210
295
|
function storyFootprints(story, options) {
|
|
211
296
|
const declared = storyFootprint(story);
|
|
297
|
+
const evidence = storyEvidencePaths(story, options);
|
|
212
298
|
const widened = new Set(declared);
|
|
213
|
-
for (const path of
|
|
214
|
-
return { declared, widened };
|
|
299
|
+
for (const path of evidence.keys()) widened.add(path);
|
|
300
|
+
return { declared, widened, evidence };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The field labels that scraped `path` on one side — empty when that side
|
|
305
|
+
* **declared** it, because a declaration is not evidence and reporting the
|
|
306
|
+
* prose restatement of a declared path would read as if the scrape had caused
|
|
307
|
+
* the collision.
|
|
308
|
+
*
|
|
309
|
+
* @param {{ declared: Set<string>, evidence: Map<string, Set<string>> }} side
|
|
310
|
+
* @param {string} path
|
|
311
|
+
* @returns {string[]}
|
|
312
|
+
*/
|
|
313
|
+
function scrapedFields(side, path) {
|
|
314
|
+
if (side.declared.has(path)) return [];
|
|
315
|
+
return [...(side.evidence.get(path) ?? [])];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Per-path provenance for one colliding path (Story #5265): whether both
|
|
320
|
+
* sides declared it, and — when at least one side did not — the field labels
|
|
321
|
+
* the scrape found it in, unioned across the two sides and sorted.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} fa
|
|
324
|
+
* @param {object} fb
|
|
325
|
+
* @param {string} path
|
|
326
|
+
* @param {boolean} declared
|
|
327
|
+
* @returns {{ path: string, declared: boolean, fields: string[] }}
|
|
328
|
+
*/
|
|
329
|
+
function attributePath(fa, fb, path, declared) {
|
|
330
|
+
const fields = new Set([
|
|
331
|
+
...scrapedFields(fa, path),
|
|
332
|
+
...scrapedFields(fb, path),
|
|
333
|
+
]);
|
|
334
|
+
return { path, declared, fields: [...fields].sort() };
|
|
215
335
|
}
|
|
216
336
|
|
|
217
337
|
/**
|
|
@@ -269,12 +389,19 @@ function recordGlobs(hits, side) {
|
|
|
269
389
|
* run for hours — and `resolve-stories.js` substitutes an UNKNOWN sentinel for
|
|
270
390
|
* any body it cannot parse, so one malformed Story would make a run serial.
|
|
271
391
|
*
|
|
392
|
+
* `attribution` (Story #5265) reports the same `paths`, one entry each, with
|
|
393
|
+
* the provenance a consumer needs to explain the withhold: `declared` says
|
|
394
|
+
* whether both sides named the path in `changes[]`, and `fields` names the
|
|
395
|
+
* field label(s) the scrape read it from otherwise (`title`, `spec`, or
|
|
396
|
+
* `body:<section>`). It is strictly additive — `paths` and `source` are
|
|
397
|
+
* unchanged, so no pair that collided before collides differently now.
|
|
398
|
+
*
|
|
272
399
|
* @param {object} a
|
|
273
400
|
* @param {object} b
|
|
274
401
|
* @param {object} [options]
|
|
275
402
|
* @param {boolean} [options.concreteOnly=false] Skip glob paths on both sides.
|
|
276
403
|
* @param {string} [options.tempRoot]
|
|
277
|
-
* @returns {{ paths: string[], source: string }|null}
|
|
404
|
+
* @returns {{ paths: string[], source: string, attribution: Array<{ path: string, declared: boolean, fields: string[] }> }|null}
|
|
278
405
|
*/
|
|
279
406
|
export function detectCollision(
|
|
280
407
|
a,
|
|
@@ -297,10 +424,36 @@ export function detectCollision(
|
|
|
297
424
|
recordGlobs(hits, fb);
|
|
298
425
|
}
|
|
299
426
|
if (hits.size === 0) return null;
|
|
427
|
+
const paths = [...hits.keys()].sort();
|
|
300
428
|
return {
|
|
301
|
-
paths
|
|
429
|
+
paths,
|
|
302
430
|
source: [...hits.values()].some(Boolean)
|
|
303
431
|
? OVERLAP_SOURCES.DECLARED
|
|
304
432
|
: OVERLAP_SOURCES.SCRAPED,
|
|
433
|
+
attribution: paths.map((path) =>
|
|
434
|
+
attributePath(fa, fb, path, hits.get(path)),
|
|
435
|
+
),
|
|
305
436
|
};
|
|
306
437
|
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Render one collision's scraped-path provenance as a single operator-facing
|
|
441
|
+
* clause, or `''` when every colliding path was declared by both sides.
|
|
442
|
+
*
|
|
443
|
+
* Shared by every report that names a withhold so the tick's envelope note
|
|
444
|
+
* and plan-persist's predicted-serialisation table read identically — the two
|
|
445
|
+
* surfaces describe the same computation and an operator comparing them
|
|
446
|
+
* should not have to translate (Story #5265).
|
|
447
|
+
*
|
|
448
|
+
* @param {Array<{ path: string, declared: boolean, fields: string[] }>} attribution
|
|
449
|
+
* @returns {string}
|
|
450
|
+
*/
|
|
451
|
+
export function renderScrapeAttribution(attribution) {
|
|
452
|
+
const scraped = (Array.isArray(attribution) ? attribution : []).filter(
|
|
453
|
+
(entry) => Array.isArray(entry?.fields) && entry.fields.length > 0,
|
|
454
|
+
);
|
|
455
|
+
if (scraped.length === 0) return '';
|
|
456
|
+
return scraped
|
|
457
|
+
.map((entry) => `${entry.path} ← ${entry.fields.join(', ')}`)
|
|
458
|
+
.join('; ');
|
|
459
|
+
}
|
|
@@ -264,7 +264,13 @@ export async function probeLiveState({
|
|
|
264
264
|
self,
|
|
265
265
|
warn,
|
|
266
266
|
}) {
|
|
267
|
-
|
|
267
|
+
// `allowUnlabelled` deliberately: the `agent::*` guard is an ADMISSION check
|
|
268
|
+
// at the entry resolution, where an operator names ids, and this is a
|
|
269
|
+
// per-beat REPORT on work already admitted. A Story dispatched a moment ago
|
|
270
|
+
// is legitimately unlabelled until `single-story-init.js` flips it — the
|
|
271
|
+
// init window this probe models explicitly — and refusing it here would fail
|
|
272
|
+
// a healthy beat mid-run over a Story the run already accepted.
|
|
273
|
+
const stories = await fetchStories(provider, ids, { allowUnlabelled: true });
|
|
268
274
|
const nativeEdges = native
|
|
269
275
|
? await readNativeEdges({ provider, stories, owner, repo })
|
|
270
276
|
: new Map();
|
|
@@ -301,7 +301,7 @@ function reservesConcretePath(held, candidate, options = {}) {
|
|
|
301
301
|
* @returns {{
|
|
302
302
|
* selected: StoryRecord[],
|
|
303
303
|
* withheldByInFlight: Array<{id: number, blockedBy: number}>,
|
|
304
|
-
* footprintWithholds: Array<{id: number, blockedBy: number, scope: string, source: string, paths: string[], enforced: boolean}>,
|
|
304
|
+
* footprintWithholds: Array<{id: number, blockedBy: number, scope: string, source: string, paths: string[], attribution: object[], enforced: boolean}>,
|
|
305
305
|
* guardMode: 'enforce'|'advisory'
|
|
306
306
|
* }}
|
|
307
307
|
* `selected` is the dispatch set: a subset of `stories`, ascending by id,
|