@ybotok/loam 0.0.0 → 0.1.0-beta.2
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/CHANGELOG.md +138 -0
- package/COMPARISON.md +63 -0
- package/LICENSE +21 -0
- package/MIGRATING-from-OpenSpec.md +202 -0
- package/README.md +246 -9
- package/SCHEMA.md +425 -0
- package/SECURITY.md +9 -0
- package/dist/cli.js +142 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/adopt.js +240 -0
- package/dist/commands/adopt.js.map +1 -0
- package/dist/commands/archive.js +1313 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/delta.js +388 -0
- package/dist/commands/delta.js.map +1 -0
- package/dist/commands/dependencies.js +83 -0
- package/dist/commands/dependencies.js.map +1 -0
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.js.map +1 -0
- package/dist/commands/doctor.js +85 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/explore.js +195 -0
- package/dist/commands/explore.js.map +1 -0
- package/dist/commands/format.js +17 -0
- package/dist/commands/format.js.map +1 -0
- package/dist/commands/gherkin.js +309 -0
- package/dist/commands/gherkin.js.map +1 -0
- package/dist/commands/init.js +250 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/instructions.js +70 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +291 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/migrate-openspec.js +950 -0
- package/dist/commands/migrate-openspec.js.map +1 -0
- package/dist/commands/new.js +413 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/rebase.js +356 -0
- package/dist/commands/rebase.js.map +1 -0
- package/dist/commands/show.js +320 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/status.js +254 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/unarchive.js +317 -0
- package/dist/commands/unarchive.js.map +1 -0
- package/dist/commands/validate.js +1591 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/verify.js +775 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/vouch.js +343 -0
- package/dist/commands/vouch.js.map +1 -0
- package/dist/core/agent.js +2065 -0
- package/dist/core/agent.js.map +1 -0
- package/dist/core/agents-stamp.js +68 -0
- package/dist/core/agents-stamp.js.map +1 -0
- package/dist/core/arch.js +130 -0
- package/dist/core/arch.js.map +1 -0
- package/dist/core/brief.js +733 -0
- package/dist/core/brief.js.map +1 -0
- package/dist/core/coherence.js +577 -0
- package/dist/core/coherence.js.map +1 -0
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +180 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/delta.js +476 -0
- package/dist/core/delta.js.map +1 -0
- package/dist/core/dependencies.js +356 -0
- package/dist/core/dependencies.js.map +1 -0
- package/dist/core/docs.js +144 -0
- package/dist/core/docs.js.map +1 -0
- package/dist/core/doctor.js +559 -0
- package/dist/core/doctor.js.map +1 -0
- package/dist/core/document-bytes.js +59 -0
- package/dist/core/document-bytes.js.map +1 -0
- package/dist/core/explore.js +225 -0
- package/dist/core/explore.js.map +1 -0
- package/dist/core/fleet-context.js +225 -0
- package/dist/core/fleet-context.js.map +1 -0
- package/dist/core/frontmatter.js +199 -0
- package/dist/core/frontmatter.js.map +1 -0
- package/dist/core/gherkin.js +463 -0
- package/dist/core/gherkin.js.map +1 -0
- package/dist/core/health.js +67 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/ids.js +122 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/issue.js +9 -0
- package/dist/core/issue.js.map +1 -0
- package/dist/core/json.js +74 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/likec4.js +479 -0
- package/dist/core/likec4.js.map +1 -0
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +552 -0
- package/dist/core/openapi-merge.js.map +1 -0
- package/dist/core/openapi.js +255 -0
- package/dist/core/openapi.js.map +1 -0
- package/dist/core/openspec-inventory.js +1440 -0
- package/dist/core/openspec-inventory.js.map +1 -0
- package/dist/core/path-safety.js +168 -0
- package/dist/core/path-safety.js.map +1 -0
- package/dist/core/provenance.js +720 -0
- package/dist/core/provenance.js.map +1 -0
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +436 -0
- package/dist/core/repo.js.map +1 -0
- package/dist/core/report.js +47 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/results.js +199 -0
- package/dist/core/results.js.map +1 -0
- package/dist/core/spec.js +481 -0
- package/dist/core/spec.js.map +1 -0
- package/dist/core/staging.js +890 -0
- package/dist/core/staging.js.map +1 -0
- package/dist/core/status.js +1104 -0
- package/dist/core/status.js.map +1 -0
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +807 -0
- package/dist/core/verify.js.map +1 -0
- package/dist/core/version.js +24 -0
- package/dist/core/version.js.map +1 -0
- package/package.json +63 -4
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where does this work stand, and what is the next thing to do?
|
|
3
|
+
*
|
|
4
|
+
* Every other command answers a question you already know how to ask: validate
|
|
5
|
+
* grades a target you name, delta briefs a service you name, verify checks a
|
|
6
|
+
* feature you name. The one thing loam could not answer was the question an
|
|
7
|
+
* agent asks FIRST — the one it has when it joins a repository halfway, or
|
|
8
|
+
* comes back after losing its session. That knowledge existed, but only as
|
|
9
|
+
* prose inside the slash commands ("1. scaffold, 2. author, 3. validate"),
|
|
10
|
+
* which is to say it existed for whoever had read them recently and for nobody
|
|
11
|
+
* else.
|
|
12
|
+
*
|
|
13
|
+
* The obvious implementation is a state file — a `.loam/state.json` recording
|
|
14
|
+
* which step each feature reached. It is rejected here for the reason verify.ts
|
|
15
|
+
* refuses a second mutable database: the moment a second record of the truth
|
|
16
|
+
* exists, an author who edits a file by hand, a merge, a rebase or a
|
|
17
|
+
* `git checkout` desynchronises it, and the state file's answer is confidently
|
|
18
|
+
* wrong exactly when somebody trusts it. So `status` stores nothing and caches
|
|
19
|
+
* nothing. It is a projection: every invocation re-derives the whole answer
|
|
20
|
+
* from the files, and a file edited in another window is visible on the next
|
|
21
|
+
* run with no invalidation step for anyone to forget.
|
|
22
|
+
*
|
|
23
|
+
* It also grades nothing itself. Every verdict here is somebody else's
|
|
24
|
+
* function: `featureCoherence` (what `loam archive` refuses on) plus
|
|
25
|
+
* `featureProvenance` and `requirementsMissingScenarios` (`loam validate
|
|
26
|
+
* --feature`'s other error sources, as far as they exist as shared functions),
|
|
27
|
+
* and `readVerificationState` + `featureChecklist`, the pair `loam verify`
|
|
28
|
+
* uses. A second opinion about whether a feature holds together would be a
|
|
29
|
+
* second answer to grade a pull request against, and the one a reader consults
|
|
30
|
+
* is never the one the gate obeys (doctor.ts learnt this with its own config
|
|
31
|
+
* validator). A check that lives inline inside `validate` cannot be inherited
|
|
32
|
+
* at all, which is why `stage` never rests on `checks.coherent` alone.
|
|
33
|
+
*
|
|
34
|
+
* Which is why the rule this module is held to is one-directional: **status may
|
|
35
|
+
* be more pessimistic than the gates, never greener.** `validate` and `archive`
|
|
36
|
+
* answer two different questions — is the DOCUMENT valid, is the MERGE safe
|
|
37
|
+
* (issue.ts) — and a projection that reported only one of them told an author
|
|
38
|
+
* "ship it" about a feature the other one refuses. So `stage` and `next[]` take
|
|
39
|
+
* the UNION: an error `validate` grades and a warning `archive` gates on both
|
|
40
|
+
* make a feature `draft`, and test/status-agrees-with-gate.test.ts holds the
|
|
41
|
+
* direction on a matrix of deliberately-damaged trees.
|
|
42
|
+
*
|
|
43
|
+
* ## The status vocabulary
|
|
44
|
+
*
|
|
45
|
+
* Five values, closed, and an agent is expected to branch on them:
|
|
46
|
+
*
|
|
47
|
+
* `missing` the artifact is owed and is not on disk; nothing stands in the
|
|
48
|
+
* way of writing it now.
|
|
49
|
+
* `blocked` the work is stalled on something that is NOT this artifact — an
|
|
50
|
+
* artifact it is derived from is absent, or another feature in
|
|
51
|
+
* flight has to archive first.
|
|
52
|
+
* `draft` it is on disk and the shared checks report an error against it:
|
|
53
|
+
* what exists is wrong.
|
|
54
|
+
* `ready` it is on disk and clean, but its obligation is not discharged —
|
|
55
|
+
* something outside the documents (code, a test run, a recording)
|
|
56
|
+
* still has to answer it.
|
|
57
|
+
* `done` nothing is owed here.
|
|
58
|
+
*
|
|
59
|
+
* The two pairs that look alike are the point of the set. `missing` and
|
|
60
|
+
* `blocked` both mean "not written", and they are different instructions: one
|
|
61
|
+
* says write it now, the other says you cannot yet and names what comes first —
|
|
62
|
+
* an agent that cannot tell them apart authors a verification record against a
|
|
63
|
+
* checklist that does not exist. `draft` and `ready` both mean "on disk", and
|
|
64
|
+
* they are different instructions too: `draft` says the document is wrong and
|
|
65
|
+
* the fix is in this repository, `ready` says the document is right and the
|
|
66
|
+
* world has not caught up — the code is not built, or the claims are not
|
|
67
|
+
* answered. Collapsing those two would send an agent to re-edit a spec that is
|
|
68
|
+
* already correct.
|
|
69
|
+
*
|
|
70
|
+
* A value unreachable in a scope is simply never emitted there, deliberately:
|
|
71
|
+
* the fleet form never says `draft`, because grading is a per-feature cost it
|
|
72
|
+
* refuses to pay.
|
|
73
|
+
*
|
|
74
|
+
* ## `next[]`
|
|
75
|
+
*
|
|
76
|
+
* The payload's reason to exist. Each entry carries a stable `code` to branch
|
|
77
|
+
* on, one sentence a person can read, and the literal command to run — ordered,
|
|
78
|
+
* first entry first. `command` is the loam invocation that carries the step
|
|
79
|
+
* forward: the one that does the work where loam does it (`rebase`, `archive`),
|
|
80
|
+
* and the one that states or grades the work where the author does it (`delta`,
|
|
81
|
+
* `validate`, `verify`). It is never a command that would write the artifact
|
|
82
|
+
* behind the author's back — loam does not author.
|
|
83
|
+
*
|
|
84
|
+
* `next` is never empty. A feature with nothing outstanding still has a next
|
|
85
|
+
* step (ship it), and a repository with nothing outstanding still has one (keep
|
|
86
|
+
* the fleet green) — an empty array would be indistinguishable from a bug in
|
|
87
|
+
* the caller's own parsing.
|
|
88
|
+
*
|
|
89
|
+
* ## Why the fleet form is cheaper than the feature form
|
|
90
|
+
*
|
|
91
|
+
* `loam status` with no feature runs over every active feature, and deriving a
|
|
92
|
+
* coherence report or a verification checklist per feature means spinning a
|
|
93
|
+
* fresh LikeC4 workspace per feature. On a fleet-sized repository that is the
|
|
94
|
+
* cost that gets a command switched off — `list` made the same call about its
|
|
95
|
+
* verification column. So the fleet form grades nothing: it reports artifact
|
|
96
|
+
* PRESENCE, the dependency order, and a verification record only where one
|
|
97
|
+
* already exists, and its `next[]` hands off to `loam status <FEAT>`, which is
|
|
98
|
+
* where the checks actually run.
|
|
99
|
+
*/
|
|
100
|
+
import { existsSync } from "node:fs";
|
|
101
|
+
import { join } from "node:path";
|
|
102
|
+
import { featureCoherence, livingMergeConflicts, unknownDeltaServices } from "./coherence.js";
|
|
103
|
+
import { analyzeDependencies } from "./dependencies.js";
|
|
104
|
+
import { FleetContext } from "./fleet-context.js";
|
|
105
|
+
import { gatesArchive } from "./issue.js";
|
|
106
|
+
import { repoPath } from "./json.js";
|
|
107
|
+
import { featureProvenance } from "./provenance.js";
|
|
108
|
+
import { compareIds, featurePaths, featureSpecPaths, featureSpecServices, listFeatures, listServices, servicePaths, SPEC_AXES, } from "./repo.js";
|
|
109
|
+
import { requirementsMissingScenarios } from "./spec.js";
|
|
110
|
+
import { COMMIT_INTENT, readCommitIntent } from "./staging.js";
|
|
111
|
+
import { attestedNotice, featureChecklist, readVerificationState, tallyRecord, verificationPath, verificationVerdict, } from "./verify.js";
|
|
112
|
+
/** The closed set. The module header says what each value instructs. */
|
|
113
|
+
export const ARTIFACT_STATUSES = ["missing", "blocked", "draft", "ready", "done"];
|
|
114
|
+
/**
|
|
115
|
+
* The artifact kinds a feature can owe, in the order the cycle authors them.
|
|
116
|
+
* `spec`, `arch-spec` and `openapi` are per-service and repeat once per service
|
|
117
|
+
* the feature touches; the rest are feature-wide.
|
|
118
|
+
*/
|
|
119
|
+
export const ARTIFACT_IDS = [
|
|
120
|
+
"intent",
|
|
121
|
+
"delta",
|
|
122
|
+
"spec",
|
|
123
|
+
"arch-spec",
|
|
124
|
+
"openapi",
|
|
125
|
+
"verification",
|
|
126
|
+
];
|
|
127
|
+
/* ------------------------------------------------------------------ */
|
|
128
|
+
/* The one state that outranks everything else */
|
|
129
|
+
/* ------------------------------------------------------------------ */
|
|
130
|
+
/**
|
|
131
|
+
* The intent journal, if a commit left one behind. Null on every healthy repo,
|
|
132
|
+
* which is every repo except one whose last write was killed.
|
|
133
|
+
*
|
|
134
|
+
* Cheap on purpose: an `existsSync` and, at most, one small JSON read. `doctor`
|
|
135
|
+
* pays for the whole `scanWritePathResidue` walk because it is a diagnosis;
|
|
136
|
+
* `status` is run before every step of the loop and asks the one question whose
|
|
137
|
+
* answer invalidates all its others.
|
|
138
|
+
*/
|
|
139
|
+
async function readInterruptedCommit(docsDir) {
|
|
140
|
+
const intent = await readCommitIntent(docsDir);
|
|
141
|
+
if (intent === null) {
|
|
142
|
+
if (!existsSync(join(docsDir, COMMIT_INTENT)))
|
|
143
|
+
return null;
|
|
144
|
+
return {
|
|
145
|
+
command: null,
|
|
146
|
+
feature: null,
|
|
147
|
+
host: null,
|
|
148
|
+
pid: null,
|
|
149
|
+
at: null,
|
|
150
|
+
files: [],
|
|
151
|
+
unreadable: true,
|
|
152
|
+
recover: null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
command: intent.command,
|
|
157
|
+
feature: intent.feature,
|
|
158
|
+
// Coerced here rather than trusted. `readCommitIntent` validates exactly the
|
|
159
|
+
// fields a REPAIR needs — the version, the command, the restore direction,
|
|
160
|
+
// the feature, the two move paths and the file digests — so `host`, `pid`
|
|
161
|
+
// and `at` arrive as whatever the journal happened to hold, `undefined`
|
|
162
|
+
// included. That is worse than null in both renderings: `JSON.stringify`
|
|
163
|
+
// drops an undefined value's key entirely, so `--json` loses the field
|
|
164
|
+
// instead of reporting it empty, and the two human lines print
|
|
165
|
+
// "(undefined, pid undefined, undefined)". These three are the label a
|
|
166
|
+
// person places the crash against and nothing branches on them, so the
|
|
167
|
+
// coercion belongs at this boundary — widening the validator would change
|
|
168
|
+
// which journals `archive` and `unarchive` agree to repair, which is a
|
|
169
|
+
// decision about somebody's half-written docs, not about a label.
|
|
170
|
+
host: typeof intent.host === "string" ? intent.host : null,
|
|
171
|
+
pid: typeof intent.pid === "number" ? intent.pid : null,
|
|
172
|
+
at: typeof intent.at === "string" ? intent.at : null,
|
|
173
|
+
files: intent.files.map((f) => f.path),
|
|
174
|
+
unreadable: false,
|
|
175
|
+
// The recovering command is the interrupted one re-run: it repairs first,
|
|
176
|
+
// under the lock, from the snapshot it took before the commit. Spelled per
|
|
177
|
+
// branch rather than interpolated, like doctor's own repair line, so
|
|
178
|
+
// test/agent-commands-runnable.test.ts can parse it against the real program.
|
|
179
|
+
recover: intent.command === "archive" ? `loam archive ${intent.feature}` : `loam unarchive ${intent.feature}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The step that has to come before every other one. Always first, and never
|
|
184
|
+
* elided by the fleet form's cap: nothing else in the list can be done until
|
|
185
|
+
* somebody knows whether the living docs are the docs.
|
|
186
|
+
*/
|
|
187
|
+
function recoverStep(i) {
|
|
188
|
+
return {
|
|
189
|
+
code: "next.recover-commit",
|
|
190
|
+
statement: i.unreadable
|
|
191
|
+
? `${COMMIT_INTENT} in this docs repo cannot be read — a commit was interrupted and the one record of which files it had already written is unreadable. Nothing below can be trusted until a human compares the living docs against version control.`
|
|
192
|
+
: `A \`${i.recover}\` was killed mid-commit (${i.host}, pid ${i.pid}, ${i.at}) — ${i.files.length} file(s) may be half-written: ${i.files.join(", ")}. Everything below is derived from those files.`,
|
|
193
|
+
// No command loam offers repairs an unreadable journal — saying otherwise
|
|
194
|
+
// would send an agent to run something that refuses.
|
|
195
|
+
command: i.recover ?? "loam doctor --json",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/* ------------------------------------------------------------------ */
|
|
199
|
+
/* One feature */
|
|
200
|
+
/* ------------------------------------------------------------------ */
|
|
201
|
+
/**
|
|
202
|
+
* Everything about one feature in flight. `service` narrows the per-service
|
|
203
|
+
* artifacts and the per-service steps; the feature's own `services` list stays
|
|
204
|
+
* complete either way, because a narrowed view that also hid which other
|
|
205
|
+
* services exist would read as a feature that touches exactly one.
|
|
206
|
+
*/
|
|
207
|
+
export async function featureStatus(docsDir, feature, opts = {}) {
|
|
208
|
+
const context = opts.context ?? new FleetContext();
|
|
209
|
+
const interrupted = await readInterruptedCommit(docsDir);
|
|
210
|
+
const services = await featureSpecServices(feature.dir, context);
|
|
211
|
+
const narrowed = opts.service;
|
|
212
|
+
const inView = narrowed === undefined ? services : services.filter((s) => s === narrowed);
|
|
213
|
+
const scans = feature.archived ? [] : await scanDeltas(docsDir, feature, services, context);
|
|
214
|
+
const read = await verificationState(docsDir, feature);
|
|
215
|
+
const verification = read.state;
|
|
216
|
+
// An archived feature is history: its delta is already folded into the living
|
|
217
|
+
// docs, so coherence would grade the change against a world that has absorbed
|
|
218
|
+
// it and report contradictions that are only the merge having happened. The
|
|
219
|
+
// same reason `list` never calls an archived record stale.
|
|
220
|
+
const findings = feature.archived ? [] : await featureFindings(docsDir, feature, scans, context);
|
|
221
|
+
// `verify.scenario-attested` rides in the same array an agent already parses,
|
|
222
|
+
// under verify's own code — a feature confirmed the short way has to be
|
|
223
|
+
// MARKED here, not merely absent from `stage: done`. It is a warning and it
|
|
224
|
+
// gates nothing, so `coherent` is untouched.
|
|
225
|
+
if (!feature.archived && read.notice !== null) {
|
|
226
|
+
findings.push({ severity: read.notice.severity, code: read.notice.code, message: read.notice.message });
|
|
227
|
+
}
|
|
228
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
229
|
+
const warnings = findings.filter((f) => f.severity === "warn").length;
|
|
230
|
+
// The union of the two gates, and the set every verdict below is drawn from.
|
|
231
|
+
const blocking = findings.filter(unshippable);
|
|
232
|
+
const governs = governedServices(scans);
|
|
233
|
+
// The whole feature is graded, then the view is narrowed — never the other
|
|
234
|
+
// way round. `--service` is a lens on the table and the steps; a rollup that
|
|
235
|
+
// moved with it would report a feature `ready` because the one service you
|
|
236
|
+
// asked about happens to be written, while three others have nothing.
|
|
237
|
+
const graded = featureArtifacts(docsDir, feature, services, blocking, verification, contractsHeldElsewhere(await contractOwners(docsDir, context), feature.id), governs);
|
|
238
|
+
const artifacts = narrowed === undefined ? graded : graded.filter((a) => a.service === null || a.service === narrowed);
|
|
239
|
+
// The dependency index rather than the `*-pending` issue codes: those name the
|
|
240
|
+
// other feature in prose, and prose is not an API — dependencies.ts opens with
|
|
241
|
+
// exactly that argument. An archived feature waits on nothing; it landed.
|
|
242
|
+
const graph = feature.archived
|
|
243
|
+
? null
|
|
244
|
+
: await analyzeDependencies(docsDir, feature.id, context);
|
|
245
|
+
const blockedBy = graph?.nodes.find((n) => n.id === feature.id)?.dependsOn ?? [];
|
|
246
|
+
return {
|
|
247
|
+
interrupted,
|
|
248
|
+
feature: {
|
|
249
|
+
id: feature.id,
|
|
250
|
+
dirName: feature.dirName,
|
|
251
|
+
path: repoPath(docsDir, feature.dir),
|
|
252
|
+
archived: feature.archived,
|
|
253
|
+
stage: featureStage(feature, graded, services, blocking.length, blockedBy, verification, interrupted),
|
|
254
|
+
services,
|
|
255
|
+
blockedBy,
|
|
256
|
+
},
|
|
257
|
+
service: narrowed ?? null,
|
|
258
|
+
artifacts,
|
|
259
|
+
checks: {
|
|
260
|
+
ran: !feature.archived,
|
|
261
|
+
coherent: errors.length === 0,
|
|
262
|
+
errors: errors.length,
|
|
263
|
+
warnings,
|
|
264
|
+
gating: findings.filter((f) => f.gates === true).length,
|
|
265
|
+
issues: findings,
|
|
266
|
+
},
|
|
267
|
+
verification,
|
|
268
|
+
next: featureNext(feature, inView, artifacts, findings, blockedBy, verification, scans, opts.boundService, interrupted),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Everything `loam validate --feature` and `loam archive` would say about this
|
|
273
|
+
* feature, in one list, through the very functions they call.
|
|
274
|
+
*
|
|
275
|
+
* Coherence alone was not it. `validate --feature` also runs `featureProvenance`
|
|
276
|
+
* over intent.md and the scenario-coverage check over every per-service delta,
|
|
277
|
+
* and both grade ERRORS — so a feature whose requirements carry no scenario at
|
|
278
|
+
* all read `coherent: true` here while `validate` exited 1 on the same tree.
|
|
279
|
+
* The coherence findings carry `gates` resolved, because archive's question is
|
|
280
|
+
* not severity's (issue.ts) and no consumer should re-derive it.
|
|
281
|
+
*/
|
|
282
|
+
async function featureFindings(docsDir, feature, scans, context) {
|
|
283
|
+
const issues = await featureCoherence(docsDir, feature.dir, feature.id, undefined, context);
|
|
284
|
+
const out = issues.map((i) => ({
|
|
285
|
+
severity: i.severity,
|
|
286
|
+
code: i.code,
|
|
287
|
+
gates: gatesArchive(i),
|
|
288
|
+
...(i.subject === undefined ? {} : { subject: i.subject }),
|
|
289
|
+
message: i.message,
|
|
290
|
+
}));
|
|
291
|
+
out.push(...(await featureProvenance(feature.dir, feature.id)).filter((f) => f.severity !== "ok"));
|
|
292
|
+
// The two refusals `archive` makes at PLAN time, from the same functions it
|
|
293
|
+
// calls. They are not coherence issues — one is about a service id that means
|
|
294
|
+
// nothing, the other about a LIVING document nobody wrote — so they reach
|
|
295
|
+
// archive outside `featureCoherence`, and a projection that only inherited
|
|
296
|
+
// coherence printed "ship it" over both. Errors: each is a refusal, and the
|
|
297
|
+
// rule this module is held to is one-directional.
|
|
298
|
+
out.push(...(await unknownDeltaServices(docsDir, feature.dir, feature.id, undefined, context)));
|
|
299
|
+
out.push(...(await livingMergeConflicts(docsDir, await featureSpecServices(feature.dir, context), context)));
|
|
300
|
+
for (const scan of scans) {
|
|
301
|
+
if (scan.bare.length === 0)
|
|
302
|
+
continue;
|
|
303
|
+
out.push({
|
|
304
|
+
severity: "error",
|
|
305
|
+
code: "requirements.missing-scenarios",
|
|
306
|
+
subject: scan.service,
|
|
307
|
+
message: `${scan.label}: ${scan.bare.length} requirement(s) without a scenario`,
|
|
308
|
+
details: scan.bare.map((r) => r.name),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Would either gate refuse this? Severity answers `validate`'s question and
|
|
315
|
+
* `gates` answers archive's; a finding that fails EITHER is one an author has
|
|
316
|
+
* work to do about, and collapsing them to `severity === "error"` is how
|
|
317
|
+
* `status` came to print "ship it" beside a warning archive exits 1 on.
|
|
318
|
+
*/
|
|
319
|
+
function unshippable(f) {
|
|
320
|
+
return f.severity === "error" || f.gates === true;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* The feature's own rollup, worst first. The order is the order the work has to
|
|
324
|
+
* happen in, so the winning branch is also the phase the feature is in:
|
|
325
|
+
* unwritten artifacts come before an ordering problem (you can author while
|
|
326
|
+
* another feature is still in flight), an ordering problem comes before this
|
|
327
|
+
* feature's own errors (fixing them may be pointless until the other one
|
|
328
|
+
* lands), and everything comes before the record — answers written over a
|
|
329
|
+
* broken feature are answers to questions that are about to change.
|
|
330
|
+
*
|
|
331
|
+
* The record is excluded from the `missing` branch on purpose. It is not
|
|
332
|
+
* something an author writes: it is the done-check's output, and counting its
|
|
333
|
+
* absence as unwritten work would report every correctly-authored feature as
|
|
334
|
+
* `missing` right up until somebody ran `loam verify` — which is the state
|
|
335
|
+
* `ready` exists to name, and which the fleet form already names correctly.
|
|
336
|
+
*/
|
|
337
|
+
function featureStage(feature, artifacts, services, blocking, blockedBy, verification, interrupted) {
|
|
338
|
+
// Ahead of `archived`, and ahead of everything else: a killed commit may be
|
|
339
|
+
// this very feature's, half-applied, so "it shipped" is a claim about files
|
|
340
|
+
// nobody has established the contents of. `blocked` is the honest word — the
|
|
341
|
+
// work is stalled on something that is not this feature's artifact — and it
|
|
342
|
+
// is what stops the projection printing `done` over a half-merged repo.
|
|
343
|
+
if (interrupted !== null)
|
|
344
|
+
return "blocked";
|
|
345
|
+
if (feature.archived)
|
|
346
|
+
return "done";
|
|
347
|
+
// A feature with no `specs/<service>/` changes nothing. There is no artifact
|
|
348
|
+
// row to be missing — the rows are per service — so the emptiness itself is
|
|
349
|
+
// the finding, and `next.touch-service` is the step.
|
|
350
|
+
if (services.length === 0)
|
|
351
|
+
return "missing";
|
|
352
|
+
if (artifacts.some((a) => a.id !== "verification" && a.status === "missing"))
|
|
353
|
+
return "missing";
|
|
354
|
+
if (blockedBy.length > 0)
|
|
355
|
+
return "blocked";
|
|
356
|
+
if (blocking > 0)
|
|
357
|
+
return "draft";
|
|
358
|
+
return fullyVerified(verification) ? "done" : "ready";
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Is the done-check discharged? `verificationVerdict`'s answer and nobody
|
|
362
|
+
* else's — status re-deriving it would be a fourth place the words "verified"
|
|
363
|
+
* are defined, and the day one of them changed (a scenario claim on an agent's
|
|
364
|
+
* word stopped counting) the reader's copy would be the one still saying yes.
|
|
365
|
+
* `attested` is deliberately NOT `done` here: it is complete work resting on an
|
|
366
|
+
* assertion, which is what `ready` has always meant — the document is right and
|
|
367
|
+
* the world has not caught up.
|
|
368
|
+
*/
|
|
369
|
+
function fullyVerified(v) {
|
|
370
|
+
return v.verdict === "verified";
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Which artifact an error names.
|
|
374
|
+
*
|
|
375
|
+
* Attribution is by code family, because that is the only machine-readable
|
|
376
|
+
* pointer an `Issue` carries: `subject` narrows to a service, never to a file.
|
|
377
|
+
* The families are unambiguous — `c4-api.*`/`c4.*` and the two `delta.*` codes
|
|
378
|
+
* about the LikeC4 document itself grade the architecture axis, `openapi.*`
|
|
379
|
+
* grades the contract, and the remaining `delta.*` codes are the delta-shape
|
|
380
|
+
* checks, every one of which is about a requirement in a spec delta.
|
|
381
|
+
*
|
|
382
|
+
* An error that maps to nothing (or whose subject is unknown) still lands in
|
|
383
|
+
* `checks.issues` and still drives `next.fix-coherence` — it just does not turn
|
|
384
|
+
* any single artifact `draft`, because guessing which file to blame is how a
|
|
385
|
+
* reader gets sent to edit the wrong one.
|
|
386
|
+
*/
|
|
387
|
+
function faultedArtifact(code) {
|
|
388
|
+
if (code === "delta.invalid" || code === "delta.nothing-tagged")
|
|
389
|
+
return "delta";
|
|
390
|
+
if (code.startsWith("c4-api.") || code.startsWith("c4."))
|
|
391
|
+
return "delta";
|
|
392
|
+
if (code.startsWith("openapi."))
|
|
393
|
+
return "openapi";
|
|
394
|
+
if (code.startsWith("delta.") || code.startsWith("spec-api."))
|
|
395
|
+
return "spec";
|
|
396
|
+
// The two families validate contributes: the frontmatter checks read only
|
|
397
|
+
// intent.md, and a requirement with no scenario is a requirement in a spec
|
|
398
|
+
// delta. Both name exactly one file, so both may turn a row `draft`.
|
|
399
|
+
if (code.startsWith("frontmatter."))
|
|
400
|
+
return "intent";
|
|
401
|
+
if (code === "requirements.missing-scenarios")
|
|
402
|
+
return "spec";
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* The artifact table. Every entry is graded by two questions in order — is it
|
|
407
|
+
* there, and does anything the shared checks reported name it — so a file that
|
|
408
|
+
* exists and is wrong can never be reported the way one that was never written
|
|
409
|
+
* is.
|
|
410
|
+
*/
|
|
411
|
+
function featureArtifacts(docsDir, feature, services, blocking, verification, contracted, governs) {
|
|
412
|
+
const paths = featurePaths(feature.dir);
|
|
413
|
+
const rel = (abs) => repoPath(docsDir, abs);
|
|
414
|
+
const faults = blocking.map((f) => ({ artifact: faultedArtifact(f.code), subject: f.subject }));
|
|
415
|
+
const faulted = (id, service) => faults.some((f) => f.artifact === id && (service === null || f.subject === service));
|
|
416
|
+
const out = [
|
|
417
|
+
fileState("intent", null, rel(paths.intent), existsSync(paths.intent), true, faulted("intent", null)),
|
|
418
|
+
fileState("delta", null, rel(paths.delta), existsSync(paths.delta), false, faulted("delta", null)),
|
|
419
|
+
];
|
|
420
|
+
for (const svc of services) {
|
|
421
|
+
const p = featureSpecPaths(feature.dir, svc);
|
|
422
|
+
const specFault = faulted("spec", svc);
|
|
423
|
+
out.push(fileState("spec", svc, rel(p.spec), existsSync(p.spec), true, specFault));
|
|
424
|
+
out.push(fileState("arch-spec", svc, rel(p.archSpec), existsSync(p.archSpec), false, specFault));
|
|
425
|
+
out.push(fileState("openapi", svc, rel(p.openapi), existsSync(p.openapi), owesContract(docsDir, svc, contracted, governs.has(svc)), faulted("openapi", svc)));
|
|
426
|
+
}
|
|
427
|
+
// The record is the one artifact with a real prerequisite: its checklist is
|
|
428
|
+
// derived from the delta and the per-service deltas, so with none of them
|
|
429
|
+
// written there is nothing to answer — authoring one is impossible rather
|
|
430
|
+
// than merely premature, which is the whole distinction `blocked` carries.
|
|
431
|
+
const feeders = out.filter((a) => a.id !== "intent" && a.id !== "arch-spec" && a.exists);
|
|
432
|
+
out.push({
|
|
433
|
+
id: "verification",
|
|
434
|
+
service: null,
|
|
435
|
+
path: rel(verificationPath(feature.dir)),
|
|
436
|
+
exists: verification.state !== "absent",
|
|
437
|
+
required: true,
|
|
438
|
+
status: feeders.length === 0 ? "blocked" : verificationStatus(verification),
|
|
439
|
+
blockedBy: feeders.length === 0 ? ["delta", "spec", "openapi"] : [],
|
|
440
|
+
});
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
function fileState(id, service, path, exists, required, faulted) {
|
|
444
|
+
const status = !exists
|
|
445
|
+
? required
|
|
446
|
+
? "missing"
|
|
447
|
+
: "done"
|
|
448
|
+
: faulted
|
|
449
|
+
? "draft"
|
|
450
|
+
: "done";
|
|
451
|
+
return { id, service, path, exists, required, status, blockedBy: [] };
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Does this feature owe `svc` an openapi.yaml?
|
|
455
|
+
*
|
|
456
|
+
* Keyed on the living CONTRACT FILE, never on `services/<svc>/` merely existing
|
|
457
|
+
* as a directory. A service adopted without a contract is precisely the case
|
|
458
|
+
* where a feature that governs operations has to write one, and the directory
|
|
459
|
+
* test answered "none owed" there — printing `(not written — none owed)` beside
|
|
460
|
+
* the `spec-api.op-undefined` finding, in the same payload, about that very
|
|
461
|
+
* file, while suppressing the only step that names it.
|
|
462
|
+
*
|
|
463
|
+
* Two ways to owe nothing, both about somebody else discharging it: the living
|
|
464
|
+
* docs already carry the contract, or another feature in flight brings it — the
|
|
465
|
+
* same softening coherence applies with its `*-pending` codes, where an
|
|
466
|
+
* operation "defined by another feature still in flight" is an ordering fact
|
|
467
|
+
* and not a hole. And one way to owe nothing at all: a service that ALREADY
|
|
468
|
+
* EXISTS under `services/`, has no living contract, and that this feature sends
|
|
469
|
+
* no operation to. A UI or a worker nobody calls has no API to write down,
|
|
470
|
+
* which is the reading `validate` and `list` already take — demanding one from
|
|
471
|
+
* an adopted service of that shape would be loam telling an author to invent a
|
|
472
|
+
* contract.
|
|
473
|
+
*
|
|
474
|
+
* That last exemption is deliberately withheld from a service the living docs
|
|
475
|
+
* have never heard of. A feature that introduces a service and sends it no
|
|
476
|
+
* operation still owes the file, because nothing else in the repository will
|
|
477
|
+
* ever describe its surface, and `c4-api.op-undefined` fires on an op-tagged
|
|
478
|
+
* delta edge whether or not any `Operations:` line was written — so answering
|
|
479
|
+
* "none owed" there printed `(not written — none owed)` in the same payload as
|
|
480
|
+
* an error about that very file.
|
|
481
|
+
*/
|
|
482
|
+
function owesContract(docsDir, svc, contracted, governsOperations) {
|
|
483
|
+
if (existsSync(servicePaths(docsDir, svc).openapi))
|
|
484
|
+
return false;
|
|
485
|
+
if (contracted.has(svc))
|
|
486
|
+
return false;
|
|
487
|
+
return !existsSync(servicePaths(docsDir, svc).dir) || governsOperations;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Which features in flight carry an `openapi.yaml` for which service — one
|
|
491
|
+
* enumeration of the whole index, service by service.
|
|
492
|
+
*
|
|
493
|
+
* The owners are recorded as feature IDS rather than directories because that
|
|
494
|
+
* is the comparison {@link contractsHeldElsewhere} makes: two directories that
|
|
495
|
+
* spell the same id are one feature as far as this question goes, and an index
|
|
496
|
+
* keyed by directory would let a duplicated id answer "somebody else has it"
|
|
497
|
+
* about its own twin.
|
|
498
|
+
*
|
|
499
|
+
* Enumeration only — the feature list and its `specs/` subdirectories are
|
|
500
|
+
* already in the request-scoped index by the time anything asks.
|
|
501
|
+
*/
|
|
502
|
+
async function contractOwners(docsDir, context) {
|
|
503
|
+
const out = new Map();
|
|
504
|
+
for (const f of await listFeatures(docsDir, {}, context)) {
|
|
505
|
+
for (const svc of f.services) {
|
|
506
|
+
if (!existsSync(featureSpecPaths(f.dir, svc).openapi))
|
|
507
|
+
continue;
|
|
508
|
+
const owners = out.get(svc);
|
|
509
|
+
if (owners === undefined)
|
|
510
|
+
out.set(svc, new Set([f.id]));
|
|
511
|
+
else
|
|
512
|
+
owners.add(f.id);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Services some OTHER active feature already carries an `openapi.yaml` for.
|
|
519
|
+
*
|
|
520
|
+
* Derived from the shared index rather than re-walked, because the answer for
|
|
521
|
+
* feature A and the answer for feature B are two readings of one enumeration:
|
|
522
|
+
* asking it once per feature made the fleet form pay F²·S `existsSync` calls
|
|
523
|
+
* over the two quantities that actually grow.
|
|
524
|
+
*/
|
|
525
|
+
function contractsHeldElsewhere(owners, self) {
|
|
526
|
+
const out = new Set();
|
|
527
|
+
for (const [svc, ids] of owners) {
|
|
528
|
+
for (const id of ids) {
|
|
529
|
+
if (id !== self) {
|
|
530
|
+
out.add(svc);
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* The record's own rung. `stale` and `unreadable` are `draft` because both mean
|
|
539
|
+
* the file on disk is the thing to fix; unconfirmed claims are `ready` because
|
|
540
|
+
* the file is fine and the answer lives in the code.
|
|
541
|
+
*
|
|
542
|
+
* All four states are spelled, `recorded` included, so the `never` below fails
|
|
543
|
+
* the build rather than the reader: a fifth record state added to
|
|
544
|
+
* {@link VerificationState} would otherwise fall through into the last branch
|
|
545
|
+
* and be graded by `fullyVerified` alone — silently answering `ready` about a
|
|
546
|
+
* record whose new state nobody here has decided a rung for.
|
|
547
|
+
*/
|
|
548
|
+
function verificationStatus(v) {
|
|
549
|
+
if (v.state === "absent")
|
|
550
|
+
return "missing";
|
|
551
|
+
if (v.state === "unreadable" || v.state === "stale")
|
|
552
|
+
return "draft";
|
|
553
|
+
if (v.state === "recorded")
|
|
554
|
+
return fullyVerified(v) ? "done" : "ready";
|
|
555
|
+
const unreachable = v.state;
|
|
556
|
+
throw new Error(`verificationStatus: no rung for verification state '${String(unreachable)}'`);
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Read the record and, only when there is one, ask whether it still answers
|
|
560
|
+
* this feature. The checklist is derived on demand and never speculatively: it
|
|
561
|
+
* loads the delta into a LikeC4 workspace, and paying that in order to print
|
|
562
|
+
* "no record" is the cost `list` refused for its verification column.
|
|
563
|
+
*
|
|
564
|
+
* An archived feature's record is frozen history — archive merged its
|
|
565
|
+
* operations into the living contract, so a re-derived checklist can only
|
|
566
|
+
* mismatch, and calling it stale would slander every feature that shipped.
|
|
567
|
+
*/
|
|
568
|
+
async function verificationState(docsDir, feature) {
|
|
569
|
+
const empty = {
|
|
570
|
+
recorded: null,
|
|
571
|
+
verdict: "unverified",
|
|
572
|
+
claims: 0,
|
|
573
|
+
confirmed: 0,
|
|
574
|
+
unconfirmed: 0,
|
|
575
|
+
unanswered: 0,
|
|
576
|
+
attested: 0,
|
|
577
|
+
};
|
|
578
|
+
const read = await readVerificationState(feature.dir);
|
|
579
|
+
if (read.state === "absent")
|
|
580
|
+
return { state: { state: "absent", ...empty }, notice: null };
|
|
581
|
+
// A record whose summary contradicts its own claims is refused by the reader
|
|
582
|
+
// itself (`verify.record-miscounted`), so this branch reports zeros rather
|
|
583
|
+
// than salvaging counts from a file nobody can say which half of was edited.
|
|
584
|
+
// Where there ARE counts they are `tallyRecord`'s, never the summary's — the
|
|
585
|
+
// block used to be copied out verbatim, and a record `verify --json` calls
|
|
586
|
+
// unverified read here as "11 of 11 confirmed".
|
|
587
|
+
if (read.state === "unreadable")
|
|
588
|
+
return { state: { state: "unreadable", ...empty }, notice: null };
|
|
589
|
+
const v = read.verification;
|
|
590
|
+
const stale = feature.archived
|
|
591
|
+
? false
|
|
592
|
+
: (await featureChecklist(docsDir, feature.dir, feature.id)).digest !== v.checklist;
|
|
593
|
+
const tally = tallyRecord(v);
|
|
594
|
+
return {
|
|
595
|
+
state: {
|
|
596
|
+
state: stale ? "stale" : "recorded",
|
|
597
|
+
recorded: v.recorded,
|
|
598
|
+
verdict: verificationVerdict(tally, stale),
|
|
599
|
+
claims: tally.claims,
|
|
600
|
+
confirmed: tally.confirmed,
|
|
601
|
+
unconfirmed: tally.unconfirmed,
|
|
602
|
+
unanswered: tally.unanswered,
|
|
603
|
+
attested: tally.attested,
|
|
604
|
+
},
|
|
605
|
+
notice: attestedNotice(v.claims, feature.id),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
/* ------------------------------------------------------------------ */
|
|
609
|
+
/* next[] — one feature */
|
|
610
|
+
/* ------------------------------------------------------------------ */
|
|
611
|
+
/** Codes whose one fix is `loam rebase`: a pin nobody ever wrote, on either axis. */
|
|
612
|
+
const UNPINNED = new Set(["delta.baseline-missing", "openapi.baseline-missing"]);
|
|
613
|
+
function featureNext(feature, services, artifacts, findings, blockedBy, verification, scans, boundService, interrupted) {
|
|
614
|
+
const id = feature.id;
|
|
615
|
+
// Before the archived shortcut: the interrupted commit may be this feature's
|
|
616
|
+
// own, and "it shipped" is not a thing to tell somebody whose living docs are
|
|
617
|
+
// half-written.
|
|
618
|
+
const first = interrupted === null ? [] : [recoverStep(interrupted)];
|
|
619
|
+
if (feature.archived) {
|
|
620
|
+
return [
|
|
621
|
+
...first,
|
|
622
|
+
{
|
|
623
|
+
code: "next.archived",
|
|
624
|
+
statement: `${id} is archived — it shipped, and its delta is already folded into the living docs.`,
|
|
625
|
+
command: `loam show ${id} --json`,
|
|
626
|
+
},
|
|
627
|
+
];
|
|
628
|
+
}
|
|
629
|
+
const steps = [...first];
|
|
630
|
+
const owed = (kind, service) => artifacts.find((a) => a.id === kind && a.status === "missing" && (service === undefined || a.service === service));
|
|
631
|
+
const intent = owed("intent");
|
|
632
|
+
if (intent !== undefined) {
|
|
633
|
+
steps.push({
|
|
634
|
+
code: "next.author-intent",
|
|
635
|
+
statement: `Write ${intent.path} — what ${id} is for, in prose, before anything is derived from it.`,
|
|
636
|
+
command: `loam validate ${id} --json`,
|
|
637
|
+
artifact: "intent",
|
|
638
|
+
path: intent.path,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
if (services.length === 0) {
|
|
642
|
+
steps.push({
|
|
643
|
+
code: "next.touch-service",
|
|
644
|
+
statement: `${id} carries no per-service delta — add specs/<service>/spec.md for every service it changes.`,
|
|
645
|
+
command: "loam list services --json",
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
for (const svc of services) {
|
|
649
|
+
const spec = owed("spec", svc);
|
|
650
|
+
if (spec !== undefined) {
|
|
651
|
+
steps.push({
|
|
652
|
+
code: "next.author-spec",
|
|
653
|
+
statement: `Write ${spec.path} — the ADDED/MODIFIED/REMOVED requirements ${id} makes to ${svc}, each with a scenario.`,
|
|
654
|
+
command: `loam delta ${id} --service ${svc} --json`,
|
|
655
|
+
artifact: "spec",
|
|
656
|
+
service: svc,
|
|
657
|
+
path: spec.path,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
const api = owed("openapi", svc);
|
|
661
|
+
if (api !== undefined) {
|
|
662
|
+
steps.push({
|
|
663
|
+
code: "next.author-openapi",
|
|
664
|
+
// Not "the service is not in the living docs": a service adopted
|
|
665
|
+
// without a contract is the commoner case, and that wording sent a
|
|
666
|
+
// reader looking for a directory that was right there. And not "the
|
|
667
|
+
// operations it governs" either — this step also fires for a service
|
|
668
|
+
// the feature INTRODUCES, where owesContract asks nothing about
|
|
669
|
+
// operations and the delta may tag none, so naming them promised the
|
|
670
|
+
// author a list they would go looking for and not find.
|
|
671
|
+
statement: `Write ${api.path} — nothing gives ${svc} a living openapi.yaml, so this feature is the only thing that can write down its API surface.`,
|
|
672
|
+
command: `loam delta ${id} --service ${svc} --json`,
|
|
673
|
+
artifact: "openapi",
|
|
674
|
+
service: svc,
|
|
675
|
+
path: api.path,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// A requirement with no scenario yields no generated .feature and no
|
|
680
|
+
// scenario.tested claim: a promise that reaches neither the suite nor the
|
|
681
|
+
// done-check, and that nothing downstream will ever mention again. One entry
|
|
682
|
+
// per service rather than per requirement — a delta with forty bare
|
|
683
|
+
// requirements would otherwise BE the payload (validate caps its details for
|
|
684
|
+
// the same reason).
|
|
685
|
+
for (const gap of scans) {
|
|
686
|
+
if (gap.bare.length === 0 || !services.includes(gap.service))
|
|
687
|
+
continue;
|
|
688
|
+
steps.push({
|
|
689
|
+
code: "next.author-scenarios",
|
|
690
|
+
statement: `${gap.bare.length} requirement(s) in ${gap.service}'s delta have no '#### Scenario:' ` +
|
|
691
|
+
`(starting with '${gap.bare[0].name}') — they generate no test and no verify claim.`,
|
|
692
|
+
command: `loam delta ${id} --service ${gap.service} --json`,
|
|
693
|
+
artifact: "spec",
|
|
694
|
+
service: gap.service,
|
|
695
|
+
path: gap.path,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
if (findings.some((f) => UNPINNED.has(f.code))) {
|
|
699
|
+
steps.push({
|
|
700
|
+
code: "next.rebase",
|
|
701
|
+
statement: `${id} carries requirements or operations with no baseline pin — until they have one the merge cannot tell what it EDITS from what it merely quotes.`,
|
|
702
|
+
command: `loam rebase ${id}`,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
if (blockedBy.length > 0) {
|
|
706
|
+
steps.push({
|
|
707
|
+
code: "next.archive-first",
|
|
708
|
+
statement: `${id} builds on ${blockedBy.join(", ")}, which ${blockedBy.length === 1 ? "has" : "have"} to archive first.`,
|
|
709
|
+
command: `loam dependencies ${id} --json`,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
// Everything either gate refuses on, not just the errors: a warning marked
|
|
713
|
+
// `gates: true` stops archive exactly as hard, and leaving it out of the
|
|
714
|
+
// steps was the other half of leaving it out of the stage.
|
|
715
|
+
const blocking = findings.filter(unshippable);
|
|
716
|
+
if (blocking.length > 0) {
|
|
717
|
+
const gating = blocking.filter((f) => f.gates === true && f.severity !== "error").length;
|
|
718
|
+
steps.push({
|
|
719
|
+
code: "next.fix-coherence",
|
|
720
|
+
statement: `${id} has ${blocking.length} finding(s) that stop it, starting with ${blocking[0].code}` +
|
|
721
|
+
(gating > 0
|
|
722
|
+
? ` — ${gating} of them ${gating === 1 ? "is a warning that GATES" : "are warnings that GATE"} archive, ` +
|
|
723
|
+
"which `validate` grades valid, so its exit code alone will not show you why archive refuses."
|
|
724
|
+
: " — the axes disagree, and archive refuses."),
|
|
725
|
+
command: `loam validate ${id} --json`,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
// The implementation half of the forward flow, which next[] never named at
|
|
729
|
+
// all: an agent read "author the spec" and then "record the verification",
|
|
730
|
+
// with nothing in between saying where a test comes from — so the only way
|
|
731
|
+
// left to answer a scenario claim was its own word, through `--record`.
|
|
732
|
+
steps.push(...testStep(id, services, scans, verification, boundService));
|
|
733
|
+
steps.push(...verifyStep(id, artifacts, verification, boundService, services));
|
|
734
|
+
// Always last, and always present: a feature whose every artifact holds and
|
|
735
|
+
// whose record is complete still has one thing left to do, and an empty
|
|
736
|
+
// `next[]` would leave a caller unable to tell "finished" from "this command
|
|
737
|
+
// had nothing to say".
|
|
738
|
+
steps.push({
|
|
739
|
+
code: "next.archive",
|
|
740
|
+
statement: steps.length === 0
|
|
741
|
+
? `${id} is authored, coherent and verified — ship it.`
|
|
742
|
+
: `${id} ships once everything above is done; the dry run writes nothing and lists every file the merge would touch.`,
|
|
743
|
+
command: `loam archive ${id} --dry-run --json`,
|
|
744
|
+
});
|
|
745
|
+
return steps;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* The done-check step, or none. Four different situations reach `loam verify`
|
|
749
|
+
* and they are not the same work — nothing recorded, a file that will not read,
|
|
750
|
+
* a record the feature has moved out from under, and claims still open — so the
|
|
751
|
+
* statement says which. The code splits only where the WORK splits: starting a
|
|
752
|
+
* verification and finishing one are different jobs, and an agent that treats a
|
|
753
|
+
* stale record as a finished one ships a feature nobody checked.
|
|
754
|
+
*/
|
|
755
|
+
function verifyStep(id, artifacts, verification, boundService, services) {
|
|
756
|
+
const record = artifacts.find((a) => a.id === "verification");
|
|
757
|
+
const open = verification.unconfirmed + verification.unanswered;
|
|
758
|
+
// The code is spelled at each call site as a `code:` property rather than
|
|
759
|
+
// passed positionally, and the helper takes the whole pair: test/codes-drift.ts
|
|
760
|
+
// collects the vocabulary by reading the source for that exact shape, so a
|
|
761
|
+
// factory with a `code` PARAMETER would hide every code it emits from the one
|
|
762
|
+
// guard that checks they are documented. Same DRY-ness, visible to the guard.
|
|
763
|
+
const step = (s) => [
|
|
764
|
+
{ ...s, command: `loam verify ${id} --json`, artifact: "verification", path: record.path },
|
|
765
|
+
];
|
|
766
|
+
if (record.status === "blocked") {
|
|
767
|
+
return step({
|
|
768
|
+
code: "next.verify",
|
|
769
|
+
statement: `${id} has nothing to derive a checklist from yet — write the delta and the requirement deltas first.`,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
if (verification.state === "absent") {
|
|
773
|
+
return step({
|
|
774
|
+
code: "next.verify",
|
|
775
|
+
statement: `Nothing has been verified for ${id} — derive the checklist and answer every claim with evidence.`,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
if (verification.state === "unreadable") {
|
|
779
|
+
return step({
|
|
780
|
+
code: "next.verify",
|
|
781
|
+
statement: `${record.path} exists but does not read as a record — repair or delete it; nothing will overwrite it while it is unreadable.`,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
if (verification.state === "stale") {
|
|
785
|
+
return step({
|
|
786
|
+
code: "next.verify",
|
|
787
|
+
statement: `${id} moved after ${record.path} was written — the record answers a checklist that is no longer the one being asked.`,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
if (open > 0) {
|
|
791
|
+
// The recording form only when this repository is one of the services that
|
|
792
|
+
// owes an answer: `verify --record --service` binds the answers to THIS
|
|
793
|
+
// repo's HEAD and refuses anywhere else, so handing a docs-repo reader that
|
|
794
|
+
// command would name a step it cannot take.
|
|
795
|
+
return boundService !== undefined && services.includes(boundService)
|
|
796
|
+
? [
|
|
797
|
+
{
|
|
798
|
+
code: "next.attest-service",
|
|
799
|
+
statement: `${open} of ${verification.claims} claim(s) on ${id} are open, and this repository is '${boundService}' — ` +
|
|
800
|
+
`answer the ones filed under it, bound to this commit; the rest belong to their own repositories.`,
|
|
801
|
+
command: `loam verify ${id} --record answers.json --service ${boundService}`,
|
|
802
|
+
artifact: "verification",
|
|
803
|
+
service: boundService,
|
|
804
|
+
path: record.path,
|
|
805
|
+
},
|
|
806
|
+
]
|
|
807
|
+
: step({
|
|
808
|
+
code: "next.verify-unconfirmed",
|
|
809
|
+
statement: `${open} of ${verification.claims} claim(s) on ${id} are not confirmed — close them from each affected service's own repository.`,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
if (verification.claims === 0) {
|
|
813
|
+
return step({
|
|
814
|
+
code: "next.verify",
|
|
815
|
+
statement: `${record.path} answers no claims at all — nothing was asked, so nothing was checked; re-derive the checklist and see what this feature actually promises.`,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
if (verification.verdict === "attested") {
|
|
819
|
+
return [
|
|
820
|
+
{
|
|
821
|
+
code: "next.verify-attested",
|
|
822
|
+
statement: `${verification.attested} of ${id}'s scenario claim(s) are confirmed on an agent's word, not on a test run ` +
|
|
823
|
+
`(verify.scenario-attested) — answer them from a cucumber report, and the record stops resting on an assertion.`,
|
|
824
|
+
command: `loam verify ${id} --results <cucumber-report.json>`,
|
|
825
|
+
artifact: "verification",
|
|
826
|
+
path: record.path,
|
|
827
|
+
},
|
|
828
|
+
];
|
|
829
|
+
}
|
|
830
|
+
return [];
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Where a test comes from. `loam gherkin` writes the .feature files whose green
|
|
834
|
+
* run is the only thing that may answer a `scenario.tested` claim, and it runs
|
|
835
|
+
* from the service's own repository — so the step exists wherever there are
|
|
836
|
+
* scenarios to generate from, and names the repository to run it in when that
|
|
837
|
+
* is not this one.
|
|
838
|
+
*/
|
|
839
|
+
function testStep(id, services, scans, verification, boundService) {
|
|
840
|
+
if (fullyVerified(verification))
|
|
841
|
+
return [];
|
|
842
|
+
const withScenarios = [
|
|
843
|
+
...new Set(scans.filter((s) => s.scenarios > 0 && services.includes(s.service)).map((s) => s.service)),
|
|
844
|
+
];
|
|
845
|
+
if (withScenarios.length === 0)
|
|
846
|
+
return [];
|
|
847
|
+
const svc = boundService !== undefined && withScenarios.includes(boundService) ? boundService : withScenarios[0];
|
|
848
|
+
const here = svc === boundService;
|
|
849
|
+
return [
|
|
850
|
+
{
|
|
851
|
+
code: "next.generate-tests",
|
|
852
|
+
statement: `${id}'s delta for ${svc} carries scenarios that no test run has answered yet — generate the suite, then implement against it` +
|
|
853
|
+
(here
|
|
854
|
+
? "."
|
|
855
|
+
: `. \`loam gherkin\` writes into the service's own repository, so run it from ${svc}'s${withScenarios.length > 1 ? ` (and likewise for ${withScenarios.slice(1).join(", ")})` : ""}.`),
|
|
856
|
+
command: `loam gherkin ${id} --service ${svc}`,
|
|
857
|
+
artifact: "spec",
|
|
858
|
+
service: svc,
|
|
859
|
+
},
|
|
860
|
+
];
|
|
861
|
+
}
|
|
862
|
+
async function scanDeltas(docsDir, feature, services, context) {
|
|
863
|
+
const out = [];
|
|
864
|
+
for (const svc of services) {
|
|
865
|
+
const p = featureSpecPaths(feature.dir, svc);
|
|
866
|
+
// SPEC_AXES already carries the prose name of each axis — "requirements"
|
|
867
|
+
// and "arch requirements" are its `label`s verbatim — so the pair is read
|
|
868
|
+
// from there rather than spelled again, and a service's status can never
|
|
869
|
+
// walk a different set of spec files than its validation does.
|
|
870
|
+
for (const [file, label] of SPEC_AXES.map((axis) => [p[axis.key], `${svc}: ${axis.label}`])) {
|
|
871
|
+
if (!existsSync(file))
|
|
872
|
+
continue;
|
|
873
|
+
const reqs = await context.readRequirements(file);
|
|
874
|
+
out.push({
|
|
875
|
+
service: svc,
|
|
876
|
+
path: repoPath(docsDir, file),
|
|
877
|
+
label,
|
|
878
|
+
bare: requirementsMissingScenarios(reqs),
|
|
879
|
+
scenarios: reqs.reduce((n, r) => n + r.scenarios.length, 0),
|
|
880
|
+
operations: file === p.spec
|
|
881
|
+
? reqs.filter((r) => r.kind !== "REMOVED").reduce((n, r) => n + r.operations.length, 0)
|
|
882
|
+
: 0,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
/** Services this feature sends at least one operation to — see {@link owesContract}. */
|
|
889
|
+
function governedServices(scans) {
|
|
890
|
+
return new Set(scans.filter((s) => s.operations > 0).map((s) => s.service));
|
|
891
|
+
}
|
|
892
|
+
/* ------------------------------------------------------------------ */
|
|
893
|
+
/* The whole repository */
|
|
894
|
+
/* ------------------------------------------------------------------ */
|
|
895
|
+
/**
|
|
896
|
+
* The fleet view: how much of `services/` anyone has written down, every
|
|
897
|
+
* feature in flight with the stage it has reached, and the order they have to
|
|
898
|
+
* land in. Nothing here is graded — the module header says why — so the stages
|
|
899
|
+
* are drawn from artifact presence and from a record that already exists, and
|
|
900
|
+
* `next[]` hands off to `loam status <FEAT>` for everything else.
|
|
901
|
+
*/
|
|
902
|
+
export async function fleetStatus(docsDir, opts = {}) {
|
|
903
|
+
const context = opts.context ?? new FleetContext();
|
|
904
|
+
const interrupted = await readInterruptedCommit(docsDir);
|
|
905
|
+
const narrowed = opts.service;
|
|
906
|
+
const all = await listServices(docsDir, context);
|
|
907
|
+
const services = narrowed === undefined ? all : all.filter((s) => s.id === narrowed);
|
|
908
|
+
const graph = await analyzeDependencies(docsDir, undefined, context);
|
|
909
|
+
const entries = await listFeatures(docsDir, {}, context);
|
|
910
|
+
const inScope = narrowed === undefined ? entries : entries.filter((f) => f.services.includes(narrowed));
|
|
911
|
+
// Once for the fleet, not once per feature: which features hold a contract
|
|
912
|
+
// for which service is one fact about the repository, and every feature below
|
|
913
|
+
// reads its own row out of it. Built over the UNNARROWED list on purpose — a
|
|
914
|
+
// `--service` view still has to know that a feature outside it discharges the
|
|
915
|
+
// contract, or the narrowing would invent an obligation.
|
|
916
|
+
const owners = await contractOwners(docsDir, context);
|
|
917
|
+
const features = await Promise.all(inScope.map((f) => fleetFeature(docsDir, f, graph, contractsHeldElsewhere(owners, f.id), context, interrupted)));
|
|
918
|
+
features.sort((a, b) => compareIds(a.id, b.id));
|
|
919
|
+
return {
|
|
920
|
+
interrupted,
|
|
921
|
+
services: {
|
|
922
|
+
total: services.length,
|
|
923
|
+
undocumented: services.filter(undocumented).length,
|
|
924
|
+
draft: services.filter((s) => !undocumented(s) && !vouched(s)).length,
|
|
925
|
+
vouched: services.filter(vouched).length,
|
|
926
|
+
},
|
|
927
|
+
features,
|
|
928
|
+
order: graph.order.filter((id) => features.some((f) => f.id === id)),
|
|
929
|
+
service: narrowed ?? null,
|
|
930
|
+
next: fleetNext(services, features, graph, interrupted),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
/** Nothing is written down: no living spec at all. Every other absence is partial adoption. */
|
|
934
|
+
function undocumented(s) {
|
|
935
|
+
return !s.has.spec;
|
|
936
|
+
}
|
|
937
|
+
/** A person stamped it — `loam vouch`'s exact postcondition, read back off the frontmatter. */
|
|
938
|
+
function vouched(s) {
|
|
939
|
+
return s.status === "verified" && s.sources.stamped;
|
|
940
|
+
}
|
|
941
|
+
async function fleetFeature(docsDir, feature, graph, contracted, context, interrupted) {
|
|
942
|
+
const paths = featurePaths(feature.dir);
|
|
943
|
+
const missing = [];
|
|
944
|
+
if (!existsSync(paths.intent))
|
|
945
|
+
missing.push("intent");
|
|
946
|
+
if (feature.services.length === 0)
|
|
947
|
+
missing.push("spec");
|
|
948
|
+
// Requirement text, not a coherence run: `Operations:` lines are read off the
|
|
949
|
+
// same cached parse `list` already pays for, and the contract question needs
|
|
950
|
+
// them (owesContract). No LikeC4 workspace is spun here, which is the cost
|
|
951
|
+
// this form actually refuses.
|
|
952
|
+
const governs = governedServices(await scanDeltas(docsDir, feature, feature.services, context));
|
|
953
|
+
for (const svc of feature.services) {
|
|
954
|
+
const p = featureSpecPaths(feature.dir, svc);
|
|
955
|
+
if (!existsSync(p.spec))
|
|
956
|
+
missing.push(`${svc}/spec`);
|
|
957
|
+
// The same question featureArtifacts asks, through the same function: the
|
|
958
|
+
// two forms must never disagree about whether a feature owes a contract.
|
|
959
|
+
if (owesContract(docsDir, svc, contracted, governs.has(svc)) && !existsSync(p.openapi)) {
|
|
960
|
+
missing.push(`${svc}/openapi`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const verification = (await verificationState(docsDir, feature)).state;
|
|
964
|
+
const blockedBy = graph.nodes.find((n) => n.id === feature.id)?.dependsOn ?? [];
|
|
965
|
+
// No `draft` here, ever: that verdict needs the coherence run this form
|
|
966
|
+
// refuses to pay for, and inferring it from presence alone would be a second
|
|
967
|
+
// opinion about validity — the one thing status must never invent.
|
|
968
|
+
//
|
|
969
|
+
// The interrupted commit leads, exactly as it does in the feature form: every
|
|
970
|
+
// presence test below is a question about files a killed `archive` may have
|
|
971
|
+
// half-written, so answering `done` from them is a claim about bytes nobody
|
|
972
|
+
// has established. Without this the two forms contradicted each other over
|
|
973
|
+
// one repository — `loam status --json` said `done` and offered "ship it"
|
|
974
|
+
// while `loam status <FEAT>` said `blocked` — and the module header's rule is
|
|
975
|
+
// that this projection is never greener than the gates.
|
|
976
|
+
const stage = interrupted !== null
|
|
977
|
+
? "blocked"
|
|
978
|
+
: missing.length > 0
|
|
979
|
+
? "missing"
|
|
980
|
+
: blockedBy.length > 0
|
|
981
|
+
? "blocked"
|
|
982
|
+
: fullyVerified(verification)
|
|
983
|
+
? "done"
|
|
984
|
+
: "ready";
|
|
985
|
+
return {
|
|
986
|
+
id: feature.id,
|
|
987
|
+
dirName: feature.dirName,
|
|
988
|
+
path: repoPath(docsDir, feature.dir),
|
|
989
|
+
stage,
|
|
990
|
+
services: feature.services,
|
|
991
|
+
blockedBy,
|
|
992
|
+
missing,
|
|
993
|
+
verification,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
/** How urgent a stage is at fleet level. See {@link fleetNext}. */
|
|
997
|
+
const STAGE_RANK = {
|
|
998
|
+
done: 0,
|
|
999
|
+
ready: 1,
|
|
1000
|
+
draft: 2,
|
|
1001
|
+
missing: 3,
|
|
1002
|
+
blocked: 4,
|
|
1003
|
+
};
|
|
1004
|
+
/**
|
|
1005
|
+
* How many per-service and per-feature steps the fleet form will print before
|
|
1006
|
+
* it stops and says how many it left out. A fleet mid-adoption produced 135
|
|
1007
|
+
* entries under the heading "the next thing to do" — a list nobody reads is a
|
|
1008
|
+
* list that is not navigation, and the first ten are the ten that matter
|
|
1009
|
+
* because the ordering below is already most-unblocking-first.
|
|
1010
|
+
*
|
|
1011
|
+
* Exported so the test asserts the cap through the constant: a literal 10 in a
|
|
1012
|
+
* fixture is a second declaration of the limit, free to disagree the day this
|
|
1013
|
+
* one moves.
|
|
1014
|
+
*/
|
|
1015
|
+
export const FLEET_NEXT_LIMIT = 10;
|
|
1016
|
+
/**
|
|
1017
|
+
* Fleet-level steps, most-unblocking first. A feature ready to ship comes
|
|
1018
|
+
* before one that still needs authoring, because archiving it is what releases
|
|
1019
|
+
* everything queued behind it; a blocked feature comes last, because there is
|
|
1020
|
+
* nothing to do about it from here — its prerequisite is already higher up the
|
|
1021
|
+
* list under its own entry.
|
|
1022
|
+
*
|
|
1023
|
+
* The gate is the LAST entry and it is always there. `next.fleet-clean` used to
|
|
1024
|
+
* be the only route to `loam validate --all`, and it was emitted only when
|
|
1025
|
+
* nothing was in flight — so a repository that FAILS the fleet gate, which is
|
|
1026
|
+
* every repository with work in it, was handed a green-looking list that never
|
|
1027
|
+
* once named the command CI runs. This form grades nothing; the least it can do
|
|
1028
|
+
* is say where grading happens.
|
|
1029
|
+
*/
|
|
1030
|
+
function fleetNext(services, features, graph, interrupted) {
|
|
1031
|
+
const steps = [];
|
|
1032
|
+
for (const s of services.filter(undocumented)) {
|
|
1033
|
+
steps.push({
|
|
1034
|
+
code: "next.adopt",
|
|
1035
|
+
statement: `services/${s.id}/ has no spec.md — nothing about ${s.id} is written down, so no feature can be graded against it.`,
|
|
1036
|
+
command: `loam adopt --service ${s.id} --json`,
|
|
1037
|
+
service: s.id,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
// Adoption does not end at spec.md. A service with a living spec and no
|
|
1041
|
+
// model.likec4 is the state archive itself warns about (`service.no-model`)
|
|
1042
|
+
// and the fleet gate reports — invisible here until now, because this form
|
|
1043
|
+
// looked at exactly one bit of each service's artifact set.
|
|
1044
|
+
for (const s of services.filter((x) => !undocumented(x) && !x.has.model)) {
|
|
1045
|
+
steps.push({
|
|
1046
|
+
code: "next.complete-service",
|
|
1047
|
+
statement: `services/${s.id}/ has a spec.md but no model.likec4 — the fleet gate reports the service incomplete until something models it.`,
|
|
1048
|
+
command: `loam validate --service ${s.id} --json`,
|
|
1049
|
+
service: s.id,
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
const ordered = [...features].sort((a, b) => STAGE_RANK[a.stage] - STAGE_RANK[b.stage] ||
|
|
1053
|
+
graph.order.indexOf(a.id) - graph.order.indexOf(b.id) ||
|
|
1054
|
+
compareIds(a.id, b.id));
|
|
1055
|
+
for (const f of ordered) {
|
|
1056
|
+
if (f.stage === "done") {
|
|
1057
|
+
steps.push({
|
|
1058
|
+
code: "next.archive",
|
|
1059
|
+
statement: `${f.id} is authored and verified — ship it, and everything waiting on it is released.`,
|
|
1060
|
+
command: `loam archive ${f.id} --dry-run --json`,
|
|
1061
|
+
});
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
steps.push({
|
|
1065
|
+
code: "next.feature",
|
|
1066
|
+
statement:
|
|
1067
|
+
// The list, not the stage, is what this sentence names. `blocked` has
|
|
1068
|
+
// two causes now — another feature in flight, and an interrupted commit
|
|
1069
|
+
// that stalls the whole repository — and the second one names no
|
|
1070
|
+
// feature, so keying off the stage alone printed "FEAT-1 is waiting
|
|
1071
|
+
// on ." The recover step above already says what the repository is
|
|
1072
|
+
// waiting on.
|
|
1073
|
+
f.stage === "blocked" && f.blockedBy.length > 0
|
|
1074
|
+
? `${f.id} is waiting on ${f.blockedBy.join(", ")}.`
|
|
1075
|
+
: `${f.id} is at '${f.stage}'${f.missing.length > 0 ? ` — missing ${f.missing.join(", ")}` : ""}.`,
|
|
1076
|
+
command: `loam status ${f.id} --json`,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
const elided = Math.max(0, steps.length - FLEET_NEXT_LIMIT);
|
|
1080
|
+
// Outside the cap, not merely first inside it: the one step that must never
|
|
1081
|
+
// be the one this list left out.
|
|
1082
|
+
const out = [...(interrupted === null ? [] : [recoverStep(interrupted)]), ...steps.slice(0, FLEET_NEXT_LIMIT)];
|
|
1083
|
+
if (elided > 0) {
|
|
1084
|
+
out.push({
|
|
1085
|
+
code: "next.elided",
|
|
1086
|
+
statement: `${elided} more step(s) of the same kinds were left out — this list is ordered most-unblocking first, so work down it and run status again.`,
|
|
1087
|
+
command: "loam list --json",
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
out.push(steps.length === 0
|
|
1091
|
+
? {
|
|
1092
|
+
code: "next.fleet-clean",
|
|
1093
|
+
statement: "Nothing is in flight and every service is written down — keep the fleet green, then start the next feature.",
|
|
1094
|
+
command: "loam validate --all --json",
|
|
1095
|
+
}
|
|
1096
|
+
: {
|
|
1097
|
+
code: "next.fleet-gate",
|
|
1098
|
+
statement: `${features.length} feature(s) in flight over ${services.length} service(s) — ` +
|
|
1099
|
+
"status grades none of them; `validate --all` is the gate this repository has to pass, and it is what CI runs.",
|
|
1100
|
+
command: "loam validate --all --json",
|
|
1101
|
+
});
|
|
1102
|
+
return out;
|
|
1103
|
+
}
|
|
1104
|
+
//# sourceMappingURL=status.js.map
|