cadet-agent 0.44.0 → 0.46.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/README.md +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +184 -6
- package/src/harness/commands.mjs +9 -0
- package/src/harness/index.mjs +9 -0
- package/src/harness/policy.mjs +96 -1
- package/src/harness/reachability.mjs +431 -0
- package/src/harness/routing.mjs +161 -153
- package/src/harness/state.mjs +101 -5
- package/src/harness/verification.mjs +219 -5
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Mechanical reachability verification (Harness contract v6).
|
|
6
|
+
*
|
|
7
|
+
* Closes a defect class the framework previously had no check for at all: work
|
|
8
|
+
* that is fully tested and fully compiled while being reachable from nothing.
|
|
9
|
+
* Every gate could be green for many stories in a row and no user could reach a
|
|
10
|
+
* single one of them, because nothing asserted that a delivered capability is
|
|
11
|
+
* WIRED to anything a user or operator can touch.
|
|
12
|
+
*
|
|
13
|
+
* Three responsibilities:
|
|
14
|
+
* 1. parseReachabilityDeclaration — read a story's declared reachability: how
|
|
15
|
+
* its deliverable becomes witnessable, or which work item will make it so.
|
|
16
|
+
* 2. validateReachabilityDeclaration — check the declaration against the work
|
|
17
|
+
* items that exist, so a deferral cannot name a phantom target.
|
|
18
|
+
* 3. reconcileDeferrals — the falsifiability check. A deferral is a claim
|
|
19
|
+
* about the future, so it is re-examined once its target is `done`: a
|
|
20
|
+
* deferral that outlives its owner is a gap wearing a plan's clothes.
|
|
21
|
+
*
|
|
22
|
+
* WHAT THIS DELIBERATELY DOES NOT DO: it cannot know how a given project wires
|
|
23
|
+
* things, so a `witnessed` declaration is treated as a STATEMENT, not a proof.
|
|
24
|
+
* The proof comes from the project's own command
|
|
25
|
+
* (`reachability.command` in .cadet/harness.json), which the CLI runs and whose
|
|
26
|
+
* exit code is the verdict. That split is the point: a generic rule that tried
|
|
27
|
+
* to guess per-project wiring would be wrong often enough to be switched off,
|
|
28
|
+
* which is how a check erodes. No project command configured means the
|
|
29
|
+
* declaration level is all that is enforceable, and the CLI says so rather than
|
|
30
|
+
* implying a stronger guarantee.
|
|
31
|
+
*
|
|
32
|
+
* Nothing here passes on missing input: a story that declares nothing is a
|
|
33
|
+
* failure, not a default. Silence is not reachability, exactly as an acceptance
|
|
34
|
+
* criterion that declares no test is not coverage.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export const REACHABILITY_KINDS = Object.freeze(['witnessed', 'deferred']);
|
|
38
|
+
|
|
39
|
+
/** Bound on how much of a story is scanned, mirroring the report bound in verify-acs. */
|
|
40
|
+
export const DEFAULT_MAX_STORY_BYTES = 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
/** Bound on sibling stories scanned for the deferral graph. */
|
|
43
|
+
export const DEFAULT_MAX_SIBLING_STORIES = 200;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize a work-item reference so `epic::story.md`, `story.md` and a bare
|
|
47
|
+
* epic id can be compared.
|
|
48
|
+
*
|
|
49
|
+
* The canonical form is `epicKey::storyFile` (what state.json stores). Anything
|
|
50
|
+
* else is resolved leniently: a bare file name matches an existing story file,
|
|
51
|
+
* and an epic key matches that epic. Case-insensitive, because a hand-written
|
|
52
|
+
* deferral target is prose-adjacent and casing drift is not the defect this
|
|
53
|
+
* check exists to catch.
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeWorkItemRef(ref) {
|
|
56
|
+
if (ref === null || ref === undefined) return '';
|
|
57
|
+
const s = String(ref).trim().replace(/\\/g, '/');
|
|
58
|
+
const withoutAnchor = s.replace(/^#/, '');
|
|
59
|
+
return withoutAnchor.toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Extract the set of work items that exist, from a state document.
|
|
64
|
+
*
|
|
65
|
+
* Includes stories (`epic::story`), bare story file names and epic keys, plus
|
|
66
|
+
* spike ids — deferring to a spike is legitimate, because a spike is exactly how
|
|
67
|
+
* an unverified assumption becomes a deliverable.
|
|
68
|
+
*
|
|
69
|
+
* Returns `{ refs, status }` where `refs` is a Set of normalized references and
|
|
70
|
+
* `status` maps a normalized reference to `'done' | 'planned' | 'in-progress' |
|
|
71
|
+
* 'complete' | 'planned'` so the caller can tell an in-flight target from a
|
|
72
|
+
* finished one.
|
|
73
|
+
*/
|
|
74
|
+
export function collectWorkItems(state) {
|
|
75
|
+
const refs = new Set();
|
|
76
|
+
const status = new Map();
|
|
77
|
+
|
|
78
|
+
const add = (ref, value) => {
|
|
79
|
+
const key = normalizeWorkItemRef(ref);
|
|
80
|
+
if (!key) return;
|
|
81
|
+
refs.add(key);
|
|
82
|
+
if (value) status.set(key, String(value).toLowerCase());
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const epics = state && typeof state === 'object' && state.epics && typeof state.epics === 'object'
|
|
86
|
+
? state.epics
|
|
87
|
+
: {};
|
|
88
|
+
|
|
89
|
+
for (const [epicKey, epic] of Object.entries(epics)) {
|
|
90
|
+
const epicStatus = epic && typeof epic === 'object' ? epic.status : undefined;
|
|
91
|
+
add(epicKey, epicStatus);
|
|
92
|
+
const stories = epic && typeof epic.stories === 'object' ? epic.stories : {};
|
|
93
|
+
for (const [storyFile, storyStatus] of Object.entries(stories)) {
|
|
94
|
+
const full = `${epicKey}::${storyFile}`;
|
|
95
|
+
add(full, storyStatus);
|
|
96
|
+
add(storyFile, storyStatus);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const spikes = state && typeof state === 'object' && state.spikes && typeof state.spikes === 'object'
|
|
101
|
+
? state.spikes
|
|
102
|
+
: {};
|
|
103
|
+
for (const [spikeId, spikeStatus] of Object.entries(spikes)) {
|
|
104
|
+
add(spikeId, spikeStatus);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const active = state && typeof state === 'object' ? state.activeWorkItem : null;
|
|
108
|
+
if (active && active.epicId && active.storyId) {
|
|
109
|
+
add(`${active.epicId}::${active.storyId}`);
|
|
110
|
+
add(active.storyId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { refs, status };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse a story's reachability declaration.
|
|
118
|
+
*
|
|
119
|
+
* Expected shape (per the story template), one line, in the story header block:
|
|
120
|
+
*
|
|
121
|
+
* Reachability: witnessed — <what a user/operator does and what they see>
|
|
122
|
+
* Reachability: deferred to <work-item ref> — <why it cannot be witnessed yet>
|
|
123
|
+
*
|
|
124
|
+
* Returns `{ declared, kind, witness, deferTo, reason, line, errors }`.
|
|
125
|
+
* `errors` is non-empty only for a MALFORMED declaration (a recognised keyword
|
|
126
|
+
* with no content). A story with no declaration at all is `declared: false`,
|
|
127
|
+
* which the validator reports as a gap rather than a parse error — the two are
|
|
128
|
+
* different findings and the caller keeps them apart.
|
|
129
|
+
*
|
|
130
|
+
* Fenced code blocks are skipped, so a story may quote an example declaration in
|
|
131
|
+
* a note without it being mistaken for its own.
|
|
132
|
+
*/
|
|
133
|
+
export function parseReachabilityDeclarationText(text, { maxBytes = DEFAULT_MAX_STORY_BYTES } = {}) {
|
|
134
|
+
const raw = typeof text === 'string' ? text : String(text ?? '');
|
|
135
|
+
const body = raw.length > maxBytes ? raw.slice(0, maxBytes) : raw;
|
|
136
|
+
const lines = body.split(/\r?\n/);
|
|
137
|
+
|
|
138
|
+
const errors = [];
|
|
139
|
+
let inFence = false;
|
|
140
|
+
|
|
141
|
+
for (let i = 0; i < lines.length; i++) {
|
|
142
|
+
const trimmed = lines[i].trim();
|
|
143
|
+
if (/^```/.test(trimmed)) {
|
|
144
|
+
inFence = !inFence;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (inFence) continue;
|
|
148
|
+
|
|
149
|
+
const m = /^Reachability\s*:\s*(.*)$/i.exec(trimmed);
|
|
150
|
+
if (!m) continue;
|
|
151
|
+
|
|
152
|
+
const rest = m[1].trim();
|
|
153
|
+
const line = i + 1;
|
|
154
|
+
if (rest === '') {
|
|
155
|
+
errors.push(`line ${line}: "Reachability:" declares nothing — state either "witnessed — <how>" or "deferred to <work item> — <why>".`);
|
|
156
|
+
return { declared: false, kind: null, witness: null, deferTo: null, reason: '', line, errors };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const deferred = /^deferred\s+to\s+(\S+)\s*(?:[—-]\s*(.*))?$/i.exec(rest);
|
|
160
|
+
if (deferred) {
|
|
161
|
+
const target = deferred[1].replace(/[.,;]$/, '');
|
|
162
|
+
const reason = (deferred[2] || '').trim();
|
|
163
|
+
if (!reason) {
|
|
164
|
+
errors.push(`line ${line}: a deferral must say WHY it cannot be witnessed yet ("deferred to ${target} — <reason>"); an unexplained deferral is how a gap becomes permanent.`);
|
|
165
|
+
}
|
|
166
|
+
return { declared: true, kind: 'deferred', witness: null, deferTo: target, reason, line, errors };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const witnessed = /^witnessed\s*(?:[—-]\s*(.*))?$/i.exec(rest);
|
|
170
|
+
if (witnessed) {
|
|
171
|
+
const witness = (witnessed[1] || '').trim();
|
|
172
|
+
if (witness === '') {
|
|
173
|
+
errors.push(`line ${line}: "witnessed" must say what a user or operator does and what they see ("witnessed — <how>").`);
|
|
174
|
+
}
|
|
175
|
+
return { declared: true, kind: 'witnessed', witness, deferTo: null, reason: '', line, errors };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A line that begins "Reachability:" with an unrecognised form. Reported
|
|
179
|
+
// rather than ignored: an ignored declaration is indistinguishable from a
|
|
180
|
+
// missing one, and a story that is wrong in a new way must not read as a
|
|
181
|
+
// story that is fine.
|
|
182
|
+
errors.push(`line ${line}: unrecognised reachability declaration "${rest}" — expected "witnessed — <how>" or "deferred to <work item> — <why>".`);
|
|
183
|
+
return { declared: false, kind: null, witness: null, deferTo: null, reason: '', line, errors };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { declared: false, kind: null, witness: null, deferTo: null, reason: '', line: null, errors };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** File variant of parseReachabilityDeclarationText. */
|
|
190
|
+
export function parseReachabilityDeclaration(storyPath) {
|
|
191
|
+
const text = readFileSync(storyPath, 'utf-8');
|
|
192
|
+
return parseReachabilityDeclarationText(text);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Validate one declaration against the work items that exist.
|
|
197
|
+
*
|
|
198
|
+
* Returns `{ ok, code, message }`. Codes are stable so a caller can branch and a
|
|
199
|
+
* test can assert the FINDING rather than the prose:
|
|
200
|
+
* malformed — the declaration parsed with errors. Checked FIRST:
|
|
201
|
+
* a reasonless deferral or a content-free "witnessed"
|
|
202
|
+
* parses far enough to be typed, and must still be
|
|
203
|
+
* refused — the parser said no, and the parser's no
|
|
204
|
+
* is the rule (contract v6 §1).
|
|
205
|
+
* not-declared — the story declares nothing at all.
|
|
206
|
+
* deferral-self — a deferral names the story itself (`self`).
|
|
207
|
+
* unknown-target — a deferral names a work item that does not exist.
|
|
208
|
+
* deferral-target-done — a deferral names a work item that is already done,
|
|
209
|
+
* so the witness it promised can never arrive.
|
|
210
|
+
*
|
|
211
|
+
* `self` is the story's own reference (bare file name, or a list of its
|
|
212
|
+
* references) so a story cannot be made "reachable" by deferring to itself.
|
|
213
|
+
*
|
|
214
|
+
* `deferral-target-done` is the tooth that matters. A deferral is only honest
|
|
215
|
+
* while its owner is still ahead; once the owner lands, the deferral is a claim
|
|
216
|
+
* that has been overtaken by events, and it is reported as a gap rather than
|
|
217
|
+
* inherited forever.
|
|
218
|
+
*/
|
|
219
|
+
export function validateReachabilityDeclaration(declaration, { workItems = null, self = null } = {}) {
|
|
220
|
+
if (declaration && Array.isArray(declaration.errors) && declaration.errors.length > 0) {
|
|
221
|
+
return { ok: false, code: 'malformed', message: declaration.errors.join(' ') };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!declaration || declaration.declared !== true) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
code: 'not-declared',
|
|
228
|
+
message: 'the story declares no reachability — add "Reachability: witnessed — <how a user/operator reaches and sees this>" or "Reachability: deferred to <work item> — <why>". A story that says nothing about reachability is indistinguishable from one whose deliverable cannot be reached.',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (declaration.kind === 'witnessed') {
|
|
233
|
+
return { ok: true, code: 'witnessed', message: `witnessed: ${declaration.witness}` };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Deferred.
|
|
237
|
+
const target = declaration.deferTo;
|
|
238
|
+
const selfRefs = Array.isArray(self)
|
|
239
|
+
? self.map(normalizeWorkItemRef)
|
|
240
|
+
: (self ? [normalizeWorkItemRef(self)] : []);
|
|
241
|
+
if (selfRefs.length > 0 && selfRefs.includes(normalizeWorkItemRef(target))) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
code: 'deferral-self',
|
|
245
|
+
message: `the deferral names "${target}", which is this story itself. A story cannot be made reachable by deferring to itself: declare "witnessed", or name the work item that will wire it.`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (!workItems) {
|
|
249
|
+
// No state to check against — the target cannot be verified, and "cannot
|
|
250
|
+
// verify" is reported as such rather than assumed fine.
|
|
251
|
+
return { ok: true, code: 'deferred-unchecked', message: `deferred to ${target} (no state document to check the target against)` };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const key = normalizeWorkItemRef(target);
|
|
255
|
+
if (!workItems.refs.has(key)) {
|
|
256
|
+
const known = [...workItems.refs].filter((r) => r.includes('::')).slice(0, 8);
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
code: 'unknown-target',
|
|
260
|
+
message: `the deferral names "${target}", which is not a work item in state.json. A deferral to something that does not exist never expires and never lands. Known work items include: ${known.join(', ') || '(none)'}.`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const status = workItems.status.get(key);
|
|
265
|
+
if (status === 'done' || status === 'complete') {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
code: 'deferral-target-done',
|
|
269
|
+
message: `the deferral names "${target}", which is already ${status}. The work item that was going to make this reachable has landed, so the deferral has expired: either this story is reachable now (declare "witnessed") or the wiring was missed when "${target}" closed.`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { ok: true, code: 'deferred', message: `deferred to ${target} (${status || 'unknown status'})` };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Build a deferral graph from a set of declarations and report every cycle.
|
|
278
|
+
*
|
|
279
|
+
* A chain of deferrals that closes on itself is not a plan: nothing in the loop
|
|
280
|
+
* is ever witnessed, and each item can point at another to explain why. The
|
|
281
|
+
* cycle is reported as one finding naming the whole chain, because naming a
|
|
282
|
+
* single node would hide the shape that makes it a gap.
|
|
283
|
+
*
|
|
284
|
+
* `declarations` is an array of `{ id, aliases?, declaration }`. `aliases` lets
|
|
285
|
+
* one node carry both the `epicKey::story.md` form and the bare file name.
|
|
286
|
+
*/
|
|
287
|
+
export function findDeferralCycles(declarations) {
|
|
288
|
+
// An entry may carry ALIASES (`epicKey::story.md` and the bare `story.md` are
|
|
289
|
+
// the same node). Without them, a deferral written in the long form and a
|
|
290
|
+
// sibling found by file name would be two disconnected nodes and a real cycle
|
|
291
|
+
// would go unreported - a check that cannot see the edge it exists to find.
|
|
292
|
+
const aliasToNode = new Map();
|
|
293
|
+
for (const entry of declarations || []) {
|
|
294
|
+
if (!entry || !entry.id) continue;
|
|
295
|
+
const ids = [entry.id, ...(Array.isArray(entry.aliases) ? entry.aliases : [])];
|
|
296
|
+
for (const alias of ids) aliasToNode.set(normalizeWorkItemRef(alias), normalizeWorkItemRef(entry.id));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const resolve = (ref) => aliasToNode.get(normalizeWorkItemRef(ref)) ?? normalizeWorkItemRef(ref);
|
|
300
|
+
|
|
301
|
+
const target = new Map();
|
|
302
|
+
for (const entry of declarations || []) {
|
|
303
|
+
if (entry && entry.declaration && entry.declaration.kind === 'deferred' && entry.declaration.deferTo) {
|
|
304
|
+
target.set(normalizeWorkItemRef(entry.id), resolve(entry.declaration.deferTo));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const cycles = [];
|
|
309
|
+
const seenCycleKeys = new Set();
|
|
310
|
+
|
|
311
|
+
for (const start of target.keys()) {
|
|
312
|
+
const path = [];
|
|
313
|
+
const onPath = new Set();
|
|
314
|
+
let node = start;
|
|
315
|
+
|
|
316
|
+
while (node && target.has(node)) {
|
|
317
|
+
if (onPath.has(node)) {
|
|
318
|
+
const at = path.indexOf(node);
|
|
319
|
+
const chain = path.slice(at);
|
|
320
|
+
// Canonicalize so the same cycle found from two entry points is one finding.
|
|
321
|
+
const key = [...chain].sort().join('|');
|
|
322
|
+
if (!seenCycleKeys.has(key)) {
|
|
323
|
+
seenCycleKeys.add(key);
|
|
324
|
+
cycles.push([...chain, node]);
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
onPath.add(node);
|
|
329
|
+
path.push(node);
|
|
330
|
+
node = target.get(node);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return cycles;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Read a story and every sibling `story-*.md` in its directory, and parse each
|
|
339
|
+
* declaration, so the deferral graph covers the epic rather than one story. The
|
|
340
|
+
* story itself is ALWAYS a node — its own declaration must participate in the
|
|
341
|
+
* cycle graph even when its file name does not match the `story-*` pattern.
|
|
342
|
+
*
|
|
343
|
+
* Aliases tie the `epicKey::story.md` form and the bare file name to ONE node;
|
|
344
|
+
* without them a real cycle written in the long form goes unreported (contract
|
|
345
|
+
* v6 §4.2). The epic key is taken from the caller's work-item index when
|
|
346
|
+
* supplied — every `epicKey::name` ref that actually exists in state.json —
|
|
347
|
+
* because deriving it from the directory name is only a heuristic: a bare
|
|
348
|
+
* relative filename has dirname `.`, and any other layout may not be named
|
|
349
|
+
* after the epic at all. The directory-name derivation remains as a fallback
|
|
350
|
+
* for callers without state.
|
|
351
|
+
*
|
|
352
|
+
* Returns `[{ id, aliases, path, declaration }]`. Unreadable files are skipped
|
|
353
|
+
* rather than fatal: the check is about the story under test, and an unreadable
|
|
354
|
+
* sibling must not turn a reachability verdict into a filesystem error.
|
|
355
|
+
*/
|
|
356
|
+
export function readSiblingDeclarations(storyPath, { max = DEFAULT_MAX_SIBLING_STORIES, workItems = null } = {}) {
|
|
357
|
+
const dir = dirname(storyPath);
|
|
358
|
+
// Heuristic fallback: the epic directory's own name is often the epic key
|
|
359
|
+
// state.json uses. Unreliable on its own — see the docstring above.
|
|
360
|
+
const dirKey = dir.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '';
|
|
361
|
+
|
|
362
|
+
const aliasesFor = (name) => {
|
|
363
|
+
const key = normalizeWorkItemRef(name);
|
|
364
|
+
const aliases = new Set();
|
|
365
|
+
if (dirKey) aliases.add(normalizeWorkItemRef(`${dirKey}::${name}`));
|
|
366
|
+
if (workItems && workItems.refs) {
|
|
367
|
+
for (const ref of workItems.refs) {
|
|
368
|
+
if (ref.endsWith(`::${key}`)) aliases.add(ref);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
aliases.delete(key);
|
|
372
|
+
return [...aliases];
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const out = [];
|
|
376
|
+
const seen = new Set();
|
|
377
|
+
const add = (name, path) => {
|
|
378
|
+
const id = normalizeWorkItemRef(name);
|
|
379
|
+
if (seen.has(id)) return;
|
|
380
|
+
seen.add(id);
|
|
381
|
+
try {
|
|
382
|
+
out.push({
|
|
383
|
+
id: name,
|
|
384
|
+
aliases: aliasesFor(name),
|
|
385
|
+
path,
|
|
386
|
+
declaration: parseReachabilityDeclaration(path),
|
|
387
|
+
});
|
|
388
|
+
} catch {
|
|
389
|
+
// A file that cannot be read is not this verdict's business.
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// The story itself, always — its own deferral edges are the ones being judged.
|
|
394
|
+
add(basename(storyPath), storyPath);
|
|
395
|
+
|
|
396
|
+
let entries = null;
|
|
397
|
+
try {
|
|
398
|
+
entries = readdirSync(dir);
|
|
399
|
+
} catch {
|
|
400
|
+
entries = null;
|
|
401
|
+
}
|
|
402
|
+
if (entries) {
|
|
403
|
+
for (const name of entries.sort()) {
|
|
404
|
+
if (out.length >= max) break;
|
|
405
|
+
if (!/^story-.*\.md$/i.test(name)) continue;
|
|
406
|
+
const path = join(dir, name);
|
|
407
|
+
if (!existsSync(path)) continue;
|
|
408
|
+
add(name, path);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Format reachability gaps as concrete, actionable lines, in the shape
|
|
416
|
+
* `describeCoverageGaps` uses for AC gaps so the two read consistently in a
|
|
417
|
+
* terminal.
|
|
418
|
+
*/
|
|
419
|
+
export function describeReachabilityGaps({ validation, cycles = [], story } = {}) {
|
|
420
|
+
const lines = [];
|
|
421
|
+
if (validation && validation.ok !== true) {
|
|
422
|
+
lines.push(` reachability: ${validation.message}`);
|
|
423
|
+
}
|
|
424
|
+
for (const cycle of cycles) {
|
|
425
|
+
lines.push(` reachability deferral cycle: ${cycle.join(' -> ')} — nothing in this loop can ever be witnessed; at least one item must become "witnessed" or the chain is a gap.`);
|
|
426
|
+
}
|
|
427
|
+
if (lines.length > 0 && story) {
|
|
428
|
+
lines.unshift(` story: ${story}`);
|
|
429
|
+
}
|
|
430
|
+
return lines;
|
|
431
|
+
}
|