@ybotok/loam 0.1.0-beta.1 → 0.1.0-beta.3
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 +96 -0
- package/README.md +6 -1
- package/SCHEMA.md +22 -4
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt.js +17 -3
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/archive.js +24 -8
- package/dist/commands/archive.js.map +1 -1
- package/dist/commands/delta.js +74 -47
- package/dist/commands/delta.js.map +1 -1
- package/dist/commands/dependencies.js +7 -2
- package/dist/commands/dependencies.js.map +1 -1
- package/dist/commands/docs-repo-gate.js +104 -0
- package/dist/commands/docs-repo-gate.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 +87 -48
- package/dist/commands/gherkin.js.map +1 -1
- package/dist/commands/init.js +3 -3
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/instructions.js +80 -0
- package/dist/commands/instructions.js.map +1 -0
- package/dist/commands/list.js +10 -76
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/migrate-openspec.js +45 -28
- package/dist/commands/migrate-openspec.js.map +1 -1
- package/dist/commands/new.js +35 -34
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/rebase.js +2 -4
- package/dist/commands/rebase.js.map +1 -1
- package/dist/commands/show.js +26 -31
- package/dist/commands/show.js.map +1 -1
- package/dist/commands/status.js +17 -25
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/unarchive.js +12 -5
- package/dist/commands/unarchive.js.map +1 -1
- package/dist/commands/validate.js +306 -142
- package/dist/commands/validate.js.map +1 -1
- package/dist/commands/verify.js +33 -17
- package/dist/commands/verify.js.map +1 -1
- package/dist/commands/vouch.js +156 -60
- package/dist/commands/vouch.js.map +1 -1
- package/dist/core/agent.js +392 -42
- package/dist/core/agent.js.map +1 -1
- package/dist/core/agents-stamp.js +47 -10
- package/dist/core/agents-stamp.js.map +1 -1
- package/dist/core/arch.js +28 -2
- package/dist/core/arch.js.map +1 -1
- package/dist/core/asyncapi.js +253 -0
- package/dist/core/asyncapi.js.map +1 -0
- package/dist/core/brief.js +28 -6
- package/dist/core/brief.js.map +1 -1
- package/dist/core/coherence.js +37 -13
- package/dist/core/coherence.js.map +1 -1
- package/dist/core/concurrency.js +51 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/config.js +10 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/delta.js +21 -2
- package/dist/core/delta.js.map +1 -1
- package/dist/core/dependencies.js +46 -42
- package/dist/core/dependencies.js.map +1 -1
- package/dist/core/docs.js +65 -3
- package/dist/core/docs.js.map +1 -1
- package/dist/core/doctor.js +79 -27
- package/dist/core/doctor.js.map +1 -1
- 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 +29 -63
- package/dist/core/fleet-context.js.map +1 -1
- package/dist/core/frontmatter.js +1 -5
- package/dist/core/frontmatter.js.map +1 -1
- package/dist/core/gherkin.js +3 -61
- package/dist/core/gherkin.js.map +1 -1
- package/dist/core/health.js +1 -5
- package/dist/core/health.js.map +1 -1
- package/dist/core/ids.js +34 -5
- package/dist/core/ids.js.map +1 -1
- package/dist/core/json.js +8 -1
- package/dist/core/json.js.map +1 -1
- package/dist/core/likec4.js +19 -4
- package/dist/core/likec4.js.map +1 -1
- package/dist/core/maturity.js +66 -0
- package/dist/core/maturity.js.map +1 -0
- package/dist/core/openapi-merge.js +39 -6
- package/dist/core/openapi-merge.js.map +1 -1
- package/dist/core/openapi.js +17 -5
- package/dist/core/openapi.js.map +1 -1
- package/dist/core/openspec-inventory.js +64 -27
- package/dist/core/openspec-inventory.js.map +1 -1
- package/dist/core/path-safety.js +59 -0
- package/dist/core/path-safety.js.map +1 -1
- package/dist/core/provenance.js +102 -10
- package/dist/core/provenance.js.map +1 -1
- package/dist/core/records.js +33 -0
- package/dist/core/records.js.map +1 -0
- package/dist/core/repo.js +77 -1
- package/dist/core/repo.js.map +1 -1
- package/dist/core/report.js +9 -0
- package/dist/core/report.js.map +1 -1
- package/dist/core/results.js +1 -3
- package/dist/core/results.js.map +1 -1
- package/dist/core/spec.js +28 -4
- package/dist/core/spec.js.map +1 -1
- package/dist/core/staging.js +124 -17
- package/dist/core/staging.js.map +1 -1
- package/dist/core/status.js +152 -35
- package/dist/core/status.js.map +1 -1
- package/dist/core/steps.js +72 -0
- package/dist/core/steps.js.map +1 -0
- package/dist/core/verify.js +18 -4
- package/dist/core/verify.js.map +1 -1
- package/dist/core/version.js +17 -1
- package/dist/core/version.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { loadConfig } from "../core/config.js";
|
|
2
|
+
import { explore } from "../core/explore.js";
|
|
3
|
+
import { FleetContext } from "../core/fleet-context.js";
|
|
4
|
+
import { emitJson, fail, reportNoConfig } from "../core/json.js";
|
|
5
|
+
import { assertServiceId, featureIdProblem, InvalidIdError } from "../core/ids.js";
|
|
6
|
+
import { DocsRepoUnavailableError } from "../core/repo.js";
|
|
7
|
+
import { docsRepoReady, reportDocsRepoError, reportRepositoryUnavailable } from "./docs-repo-gate.js";
|
|
8
|
+
import { plural } from "./format.js";
|
|
9
|
+
/**
|
|
10
|
+
* The feature id in the suggested command line when `--as` did not say.
|
|
11
|
+
*
|
|
12
|
+
* It has to satisfy the feature-id grammar, because `scaffold` is documented as
|
|
13
|
+
* the literal `loam new` line and AGENTS.md's rule is that an instruction loam
|
|
14
|
+
* prints and loam refuses is a defect. `FEAT-NNN` — the obvious placeholder —
|
|
15
|
+
* is exactly that: `loam new FEAT-NNN` comes back `invalid-option`, so the last
|
|
16
|
+
* line of every default run was a command that could not be run.
|
|
17
|
+
*
|
|
18
|
+
* `FEAT-000` parses and is still unmistakably a placeholder, which buys the
|
|
19
|
+
* property the refusal was standing in for: an agent that pastes it unedited
|
|
20
|
+
* gets a feature called FEAT-000, visible in `loam list` within seconds and
|
|
21
|
+
* deletable with `git rm -r`. A plausible id (FEAT-1) would not be.
|
|
22
|
+
*/
|
|
23
|
+
const PLACEHOLDER = "FEAT-000";
|
|
24
|
+
export function registerExplore(program) {
|
|
25
|
+
program
|
|
26
|
+
.command("explore")
|
|
27
|
+
.argument("[service...]", "service ids the change starts from")
|
|
28
|
+
.description("Read the fleet around a change nobody has written down yet — writes nothing")
|
|
29
|
+
.option("--json", "emit the machine contract instead of the human view")
|
|
30
|
+
.option("--op <operationId>", "seed from an operation instead of a service; repeatable", (value, prev = []) => [...prev, value])
|
|
31
|
+
.option("--as <FEAT>", `feature id for the suggested command line (default: ${PLACEHOLDER})`)
|
|
32
|
+
.action(async (serviceArgs, opts) => {
|
|
33
|
+
const json = opts.json === true;
|
|
34
|
+
const services = serviceArgs ?? [];
|
|
35
|
+
const operations = opts.op ?? [];
|
|
36
|
+
// `--as` is interpolated into the `loam new` line this command prints
|
|
37
|
+
// under `next:` and returns as `scaffold`, and `loam-feature` teaches an
|
|
38
|
+
// agent to pass `$1` straight into it. Unchecked, `explore` hands back a
|
|
39
|
+
// command `loam new` then refuses — loam instructing an agent to run
|
|
40
|
+
// something loam rejects, which is the one thing the printed-command
|
|
41
|
+
// guard exists to prevent and the one shape it cannot see, because it
|
|
42
|
+
// reads literal source strings and this line is assembled from argv.
|
|
43
|
+
// Same grammar as `new`'s, from core/ids.ts, so the two cannot disagree.
|
|
44
|
+
if (opts.as !== undefined) {
|
|
45
|
+
const problem = featureIdProblem(opts.as, "feature id for --as");
|
|
46
|
+
if (problem !== null) {
|
|
47
|
+
fail(json, "invalid-option", problem);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (services.length === 0 && operations.length === 0) {
|
|
52
|
+
fail(json, "invalid-option", "Nothing to explore from. Name at least one service, or pass --op <operationId>. " +
|
|
53
|
+
"`loam list services` is the fleet; `loam show <service>` is one of them.");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
// Rule 6 in docs/DESIGN.md: a raw argv string that reaches a path join is
|
|
57
|
+
// guarded at the command boundary. Every id here reaches `servicePaths`.
|
|
58
|
+
// Before `loadConfig`, exactly as `loam new` guards its `--touches` list:
|
|
59
|
+
// the grammar is a fact about the argument, not about the repository, so
|
|
60
|
+
// an unusable id should answer the same whether or not you are standing
|
|
61
|
+
// in a wired repo.
|
|
62
|
+
//
|
|
63
|
+
// A seed naming a service that does not EXIST is fine and reported as
|
|
64
|
+
// `unknown` — the feature may be introducing it — but one that could not
|
|
65
|
+
// be a service directory at all is a typo, not an intention.
|
|
66
|
+
try {
|
|
67
|
+
for (const id of services)
|
|
68
|
+
assertServiceId(id, "service");
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (!(err instanceof InvalidIdError))
|
|
72
|
+
throw err;
|
|
73
|
+
fail(json, "invalid-option", err.message);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const config = await loadConfig();
|
|
77
|
+
if (!config) {
|
|
78
|
+
reportNoConfig(json);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const { docsDir } = config;
|
|
82
|
+
// `services`, not `docs`: every answer here is a statement about the
|
|
83
|
+
// fleet, and an empty ring read out of a directory that is not a docs
|
|
84
|
+
// repo says "nothing calls this service" about a repo nobody looked in.
|
|
85
|
+
if (!docsRepoReady(json, docsDir, "services"))
|
|
86
|
+
return;
|
|
87
|
+
try {
|
|
88
|
+
const result = await explore({
|
|
89
|
+
docsDir,
|
|
90
|
+
services,
|
|
91
|
+
operations,
|
|
92
|
+
featureId: opts.as ?? PLACEHOLDER,
|
|
93
|
+
context: new FleetContext(),
|
|
94
|
+
});
|
|
95
|
+
if (json) {
|
|
96
|
+
emitJson({ command: "explore", docsDir, ...result });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
print(result);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (err instanceof DocsRepoUnavailableError) {
|
|
103
|
+
reportDocsRepoError(json, err);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// The scan is all-or-nothing — it reads every seed's contract to build
|
|
107
|
+
// one table — so the honest answer to an unreadable file is a refusal
|
|
108
|
+
// naming the path, not a ring with a silent hole in it.
|
|
109
|
+
reportRepositoryUnavailable(json, err, "the exploration would be missing a service", docsDir);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/* ------------------------------------------------------------------ */
|
|
114
|
+
function print(r) {
|
|
115
|
+
if (!r.landscape.present || !r.landscape.parses) {
|
|
116
|
+
// Said first and without hedging: with no readable map there IS no ring,
|
|
117
|
+
// and a reader who takes the empty neighbour list at face value concludes
|
|
118
|
+
// the change is contained when nothing checked that.
|
|
119
|
+
const why = r.landscape.present ? "does not parse" : "does not exist";
|
|
120
|
+
console.log(`! architecture/landscape.likec4 ${why} — no neighbour below is derived, and none is missing either\n`);
|
|
121
|
+
}
|
|
122
|
+
for (const s of r.services) {
|
|
123
|
+
console.log(`${s.id} ${label(s)}`);
|
|
124
|
+
if (!s.known) {
|
|
125
|
+
// Two different facts wear the same absence. A SEED nobody typed by
|
|
126
|
+
// accident is a service the feature introduces; one typed by accident is
|
|
127
|
+
// a typo, and the near-miss list is what separates them. A neighbour is
|
|
128
|
+
// neither — the fleet map drew an edge to something with no
|
|
129
|
+
// `services/<id>/`, which is usually an external system and occasionally
|
|
130
|
+
// a service nobody has adopted. Saying "typo" about that one sends a
|
|
131
|
+
// reader looking for a mistake they did not make.
|
|
132
|
+
const near = r.unknown.find((u) => u.id === s.id)?.nearest ?? [];
|
|
133
|
+
const why = s.reason === "seed"
|
|
134
|
+
? "the feature introduces it, or the id is a typo"
|
|
135
|
+
: "an external system, or a service nobody has adopted";
|
|
136
|
+
console.log(` no services/${s.id}/ — ${why}` +
|
|
137
|
+
(near.length > 0 ? `; closest existing: ${near.join(", ")}` : ""));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
if (s.missing.length > 0)
|
|
141
|
+
console.log(` missing: ${s.missing.join(", ")}`);
|
|
142
|
+
if (!s.modelled)
|
|
143
|
+
console.log(` not in the fleet map — no edge into it can be checked`);
|
|
144
|
+
// Said before `exposes`, and said at all: an unreadable contract exposes
|
|
145
|
+
// an empty list, and printing nothing there reads as "this service offers
|
|
146
|
+
// no endpoints" — the answer that turns a MODIFIED requirement into an
|
|
147
|
+
// ADDED one.
|
|
148
|
+
if (s.openapi.unreadable) {
|
|
149
|
+
console.log(` ! openapi.yaml does not parse — nothing here lists what ${s.id} exposes`);
|
|
150
|
+
if (s.openapi.error !== undefined)
|
|
151
|
+
console.log(` ${s.openapi.error}`);
|
|
152
|
+
}
|
|
153
|
+
else if (s.operations.length > 0) {
|
|
154
|
+
console.log(` exposes: ${s.operations.join(", ")}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const e of s.inbound)
|
|
158
|
+
console.log(` ← ${e.service}${edgeNote(e.op, e.title)}`);
|
|
159
|
+
for (const e of s.outbound)
|
|
160
|
+
console.log(` → ${e.service}${edgeNote(e.op, e.title)}`);
|
|
161
|
+
console.log("");
|
|
162
|
+
}
|
|
163
|
+
if (r.unresolvedOperations.length > 0) {
|
|
164
|
+
console.log(`! no living contract defines ${r.unresolvedOperations.join(", ")} — ` +
|
|
165
|
+
`the feature is adding ${r.unresolvedOperations.length === 1 ? "it" : "them"}, or the id is wrong\n`);
|
|
166
|
+
}
|
|
167
|
+
if (r.neighbours.length > 0) {
|
|
168
|
+
// The whole point of the command, so it is stated as a question rather than
|
|
169
|
+
// a list: these are candidates for --touches, and loam does not know which
|
|
170
|
+
// of them this change actually touches.
|
|
171
|
+
console.log(`${plural(r.neighbours.length, "neighbour")} one hop out: ${r.neighbours.join(", ")}`);
|
|
172
|
+
console.log(" weigh each one — loam knows they are connected, not whether you change them\n");
|
|
173
|
+
}
|
|
174
|
+
if (r.overlaps.length > 0) {
|
|
175
|
+
console.log("already in flight over this ground");
|
|
176
|
+
for (const o of r.overlaps)
|
|
177
|
+
console.log(` ${o.feature} ${o.services.join(", ")}`);
|
|
178
|
+
console.log(" `loam dependencies` says which must archive first, and what no ordering fixes\n");
|
|
179
|
+
}
|
|
180
|
+
console.log(`next: ${r.scaffold}`);
|
|
181
|
+
}
|
|
182
|
+
function label(s) {
|
|
183
|
+
const where = s.reason === "seed" ? "seed" : s.reason === "calls-seed" ? "calls a seed" : "called by a seed";
|
|
184
|
+
// `known`, matching the branch in `print` — not `maturity === null`. The two
|
|
185
|
+
// are set together and cannot disagree today, but discriminating on different
|
|
186
|
+
// fields for the same question is how they start to: a reader fixing one
|
|
187
|
+
// branch has no reason to look at the other.
|
|
188
|
+
return s.known ? `${where} · ${s.maturity}` : `${where} · not in the docs repo`;
|
|
189
|
+
}
|
|
190
|
+
function edgeNote(op, title) {
|
|
191
|
+
if (op !== null)
|
|
192
|
+
return ` ${op}`;
|
|
193
|
+
return title !== null ? ` "${title}"` : "";
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=explore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"explore.js","sourceRoot":"","sources":["../../src/commands/explore.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Command } from \"commander\";\nimport { loadConfig } from \"../core/config.js\";\nimport { explore, type Exploration, type ExploreService } from \"../core/explore.js\";\nimport { FleetContext } from \"../core/fleet-context.js\";\nimport { emitJson, fail, reportNoConfig } from \"../core/json.js\";\nimport { assertServiceId, featureIdProblem, InvalidIdError } from \"../core/ids.js\";\nimport { DocsRepoUnavailableError } from \"../core/repo.js\";\nimport { docsRepoReady, reportDocsRepoError, reportRepositoryUnavailable } from \"./docs-repo-gate.js\";\nimport { plural } from \"./format.js\";\n\n/**\n * The feature id in the suggested command line when `--as` did not say.\n *\n * It has to satisfy the feature-id grammar, because `scaffold` is documented as\n * the literal `loam new` line and AGENTS.md's rule is that an instruction loam\n * prints and loam refuses is a defect. `FEAT-NNN` — the obvious placeholder —\n * is exactly that: `loam new FEAT-NNN` comes back `invalid-option`, so the last\n * line of every default run was a command that could not be run.\n *\n * `FEAT-000` parses and is still unmistakably a placeholder, which buys the\n * property the refusal was standing in for: an agent that pastes it unedited\n * gets a feature called FEAT-000, visible in `loam list` within seconds and\n * deletable with `git rm -r`. A plausible id (FEAT-1) would not be.\n */\nconst PLACEHOLDER = \"FEAT-000\";\n\ninterface ExploreOptions {\n json?: boolean;\n op?: string[];\n as?: string;\n}\n\nexport function registerExplore(program: Command): void {\n program\n .command(\"explore\")\n .argument(\"[service...]\", \"service ids the change starts from\")\n .description(\"Read the fleet around a change nobody has written down yet — writes nothing\")\n .option(\"--json\", \"emit the machine contract instead of the human view\")\n .option(\n \"--op <operationId>\",\n \"seed from an operation instead of a service; repeatable\",\n (value: string, prev: string[] = []) => [...prev, value],\n )\n .option(\"--as <FEAT>\", `feature id for the suggested command line (default: ${PLACEHOLDER})`)\n .action(async (serviceArgs: string[], opts: ExploreOptions) => {\n const json = opts.json === true;\n const services = serviceArgs ?? [];\n const operations = opts.op ?? [];\n\n // `--as` is interpolated into the `loam new` line this command prints\n // under `next:` and returns as `scaffold`, and `loam-feature` teaches an\n // agent to pass `$1` straight into it. Unchecked, `explore` hands back a\n // command `loam new` then refuses — loam instructing an agent to run\n // something loam rejects, which is the one thing the printed-command\n // guard exists to prevent and the one shape it cannot see, because it\n // reads literal source strings and this line is assembled from argv.\n // Same grammar as `new`'s, from core/ids.ts, so the two cannot disagree.\n if (opts.as !== undefined) {\n const problem = featureIdProblem(opts.as, \"feature id for --as\");\n if (problem !== null) {\n fail(json, \"invalid-option\", problem);\n return;\n }\n }\n if (services.length === 0 && operations.length === 0) {\n fail(\n json,\n \"invalid-option\",\n \"Nothing to explore from. Name at least one service, or pass --op <operationId>. \" +\n \"`loam list services` is the fleet; `loam show <service>` is one of them.\",\n );\n return;\n }\n\n // Rule 6 in docs/DESIGN.md: a raw argv string that reaches a path join is\n // guarded at the command boundary. Every id here reaches `servicePaths`.\n // Before `loadConfig`, exactly as `loam new` guards its `--touches` list:\n // the grammar is a fact about the argument, not about the repository, so\n // an unusable id should answer the same whether or not you are standing\n // in a wired repo.\n //\n // A seed naming a service that does not EXIST is fine and reported as\n // `unknown` — the feature may be introducing it — but one that could not\n // be a service directory at all is a typo, not an intention.\n try {\n for (const id of services) assertServiceId(id, \"service\");\n } catch (err) {\n if (!(err instanceof InvalidIdError)) throw err;\n fail(json, \"invalid-option\", err.message);\n return;\n }\n\n const config = await loadConfig();\n if (!config) {\n reportNoConfig(json);\n return;\n }\n const { docsDir } = config;\n // `services`, not `docs`: every answer here is a statement about the\n // fleet, and an empty ring read out of a directory that is not a docs\n // repo says \"nothing calls this service\" about a repo nobody looked in.\n if (!docsRepoReady(json, docsDir, \"services\")) return;\n\n try {\n const result = await explore({\n docsDir,\n services,\n operations,\n featureId: opts.as ?? PLACEHOLDER,\n context: new FleetContext(),\n });\n if (json) {\n emitJson({ command: \"explore\", docsDir, ...result });\n return;\n }\n print(result);\n } catch (err) {\n if (err instanceof DocsRepoUnavailableError) {\n reportDocsRepoError(json, err);\n return;\n }\n // The scan is all-or-nothing — it reads every seed's contract to build\n // one table — so the honest answer to an unreadable file is a refusal\n // naming the path, not a ring with a silent hole in it.\n reportRepositoryUnavailable(json, err, \"the exploration would be missing a service\", docsDir);\n }\n });\n}\n\n/* ------------------------------------------------------------------ */\n\nfunction print(r: Exploration): void {\n if (!r.landscape.present || !r.landscape.parses) {\n // Said first and without hedging: with no readable map there IS no ring,\n // and a reader who takes the empty neighbour list at face value concludes\n // the change is contained when nothing checked that.\n const why = r.landscape.present ? \"does not parse\" : \"does not exist\";\n console.log(\n `! architecture/landscape.likec4 ${why} — no neighbour below is derived, and none is missing either\\n`,\n );\n }\n\n for (const s of r.services) {\n console.log(`${s.id} ${label(s)}`);\n if (!s.known) {\n // Two different facts wear the same absence. A SEED nobody typed by\n // accident is a service the feature introduces; one typed by accident is\n // a typo, and the near-miss list is what separates them. A neighbour is\n // neither — the fleet map drew an edge to something with no\n // `services/<id>/`, which is usually an external system and occasionally\n // a service nobody has adopted. Saying \"typo\" about that one sends a\n // reader looking for a mistake they did not make.\n const near = r.unknown.find((u) => u.id === s.id)?.nearest ?? [];\n const why =\n s.reason === \"seed\"\n ? \"the feature introduces it, or the id is a typo\"\n : \"an external system, or a service nobody has adopted\";\n console.log(\n ` no services/${s.id}/ — ${why}` +\n (near.length > 0 ? `; closest existing: ${near.join(\", \")}` : \"\"),\n );\n } else {\n if (s.missing.length > 0) console.log(` missing: ${s.missing.join(\", \")}`);\n if (!s.modelled) console.log(` not in the fleet map — no edge into it can be checked`);\n // Said before `exposes`, and said at all: an unreadable contract exposes\n // an empty list, and printing nothing there reads as \"this service offers\n // no endpoints\" — the answer that turns a MODIFIED requirement into an\n // ADDED one.\n if (s.openapi.unreadable) {\n console.log(` ! openapi.yaml does not parse — nothing here lists what ${s.id} exposes`);\n if (s.openapi.error !== undefined) console.log(` ${s.openapi.error}`);\n } else if (s.operations.length > 0) {\n console.log(` exposes: ${s.operations.join(\", \")}`);\n }\n }\n for (const e of s.inbound) console.log(` ← ${e.service}${edgeNote(e.op, e.title)}`);\n for (const e of s.outbound) console.log(` → ${e.service}${edgeNote(e.op, e.title)}`);\n console.log(\"\");\n }\n\n if (r.unresolvedOperations.length > 0) {\n console.log(\n `! no living contract defines ${r.unresolvedOperations.join(\", \")} — ` +\n `the feature is adding ${r.unresolvedOperations.length === 1 ? \"it\" : \"them\"}, or the id is wrong\\n`,\n );\n }\n\n if (r.neighbours.length > 0) {\n // The whole point of the command, so it is stated as a question rather than\n // a list: these are candidates for --touches, and loam does not know which\n // of them this change actually touches.\n console.log(\n `${plural(r.neighbours.length, \"neighbour\")} one hop out: ${r.neighbours.join(\", \")}`,\n );\n console.log(\" weigh each one — loam knows they are connected, not whether you change them\\n\");\n }\n\n if (r.overlaps.length > 0) {\n console.log(\"already in flight over this ground\");\n for (const o of r.overlaps) console.log(` ${o.feature} ${o.services.join(\", \")}`);\n console.log(\" `loam dependencies` says which must archive first, and what no ordering fixes\\n\");\n }\n\n console.log(`next: ${r.scaffold}`);\n}\n\nfunction label(s: ExploreService): string {\n const where = s.reason === \"seed\" ? \"seed\" : s.reason === \"calls-seed\" ? \"calls a seed\" : \"called by a seed\";\n // `known`, matching the branch in `print` — not `maturity === null`. The two\n // are set together and cannot disagree today, but discriminating on different\n // fields for the same question is how they start to: a reader fixing one\n // branch has no reason to look at the other.\n return s.known ? `${where} · ${s.maturity}` : `${where} · not in the docs repo`;\n}\n\nfunction edgeNote(op: string | null, title: string | null): string {\n if (op !== null) return ` ${op}`;\n return title !== null ? ` \"${title}\"` : \"\";\n}\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small shaping helpers for the human renderers.
|
|
3
|
+
*
|
|
4
|
+
* They live in `commands/` and not in `core/`: nothing here computes an answer,
|
|
5
|
+
* it only decides how an answer reads to a person. The `--json` payloads never
|
|
6
|
+
* touch this file.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* "1 service", "2 services". The regular English plural, spelled once because
|
|
10
|
+
* five renderers had drifted into five copies of the same ternary and a sixth
|
|
11
|
+
* would have been written the next time somebody counted something. A noun with
|
|
12
|
+
* an irregular plural does not belong here — say both forms at the call site.
|
|
13
|
+
*/
|
|
14
|
+
export function plural(n, noun) {
|
|
15
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=format.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.js","sourceRoot":"","sources":["../../src/commands/format.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;GAKG;AACH,MAAM,UAAU,MAAM,CAAC,CAAS,EAAE,IAAY;IAC5C,OAAO,GAAG,CAAC,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC;AAC7C,CAAC","sourcesContent":["/**\n * Small shaping helpers for the human renderers.\n *\n * They live in `commands/` and not in `core/`: nothing here computes an answer,\n * it only decides how an answer reads to a person. The `--json` payloads never\n * touch this file.\n */\n\n/**\n * \"1 service\", \"2 services\". The regular English plural, spelled once because\n * five renderers had drifted into five copies of the same ternary and a sixth\n * would have been written the next time somebody counted something. A noun with\n * an irregular plural does not belong here — say both forms at the call site.\n */\nexport function plural(n: number, noun: string): string {\n return `${n} ${noun}${n === 1 ? \"\" : \"s\"}`;\n}\n"]}
|
package/dist/commands/gherkin.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
2
|
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join, relative } from "node:path";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
4
|
import { loadConfig } from "../core/config.js";
|
|
5
|
-
import { decodeDocument, NotUtf8DocumentError } from "../core/
|
|
6
|
-
import { emitJson, emitJsonError, fail, reportNoConfig } from "../core/json.js";
|
|
5
|
+
import { decodeDocument, NotUtf8DocumentError } from "../core/document-bytes.js";
|
|
6
|
+
import { emitJson, emitJsonError, fail, NO_SERVICE_MESSAGE, reportNoConfig } from "../core/json.js";
|
|
7
7
|
import { featureSpecPaths, listFeatures, missingFeatureMessage, resolveFeature, servicePaths, SPEC_AXES, } from "../core/repo.js";
|
|
8
8
|
import { parseRequirements } from "../core/spec.js";
|
|
9
9
|
import { axisLabel, featureFilesUnder, gherkinRoot, parseStampedFeature, planEmission, } from "../core/gherkin.js";
|
|
10
10
|
import { LOAM_VERSION } from "../core/version.js";
|
|
11
|
-
import { resolveInside, UnsafePathError } from "../core/path-safety.js";
|
|
11
|
+
import { isPathInside, resolveInside, UnsafePathError } from "../core/path-safety.js";
|
|
12
12
|
export function registerGherkin(program) {
|
|
13
13
|
program
|
|
14
14
|
.command("gherkin")
|
|
@@ -27,7 +27,7 @@ export function registerGherkin(program) {
|
|
|
27
27
|
}
|
|
28
28
|
const service = opts.service ?? config.service;
|
|
29
29
|
if (service === undefined) {
|
|
30
|
-
return fail(json, "invalid-option",
|
|
30
|
+
return fail(json, "invalid-option", NO_SERVICE_MESSAGE);
|
|
31
31
|
}
|
|
32
32
|
// The emission writes into the repo loam is standing in, so it can only
|
|
33
33
|
// run where that repo is this service's — vouch's refusal, verbatim logic.
|
|
@@ -53,6 +53,11 @@ export function registerGherkin(program) {
|
|
|
53
53
|
return fail(json, "invalid-option", `Cannot emit Gherkin: ${err.message}. The output directory must stay inside the service repo.`);
|
|
54
54
|
}
|
|
55
55
|
const rel = (abs) => relative(repoDir, abs).split(/[\\/]/).join("/");
|
|
56
|
+
const feature = featureArg === undefined ? null : await resolveFeature(config.docsDir, featureArg, "exclude");
|
|
57
|
+
if (featureArg !== undefined && feature === null) {
|
|
58
|
+
return fail(json, "unknown-target", await missingFeatureMessage(config.docsDir, featureArg));
|
|
59
|
+
}
|
|
60
|
+
const scope = feature === null ? { mode: "living" } : { mode: "feature", featureId: feature.id };
|
|
56
61
|
// The requirement set, by scope. Feature mode takes ADDED and MODIFIED —
|
|
57
62
|
// a BASE requirement is the living state quoted, a REMOVED one is being
|
|
58
63
|
// retired, and neither is behaviour anybody is about to test. Living mode
|
|
@@ -64,18 +69,10 @@ export function registerGherkin(program) {
|
|
|
64
69
|
// a destructive one — an empty plan makes every existing .feature under
|
|
65
70
|
// loam/ an orphan, and a living run DELETES its orphans. The whole
|
|
66
71
|
// generated suite would go because a docs file was written by PowerShell.
|
|
67
|
-
let mode = "living";
|
|
68
|
-
let featureId;
|
|
69
72
|
const byAxis = [];
|
|
70
73
|
const readSpec = async (path) => parseRequirements(decodeDocument(await readFile(path), path));
|
|
71
74
|
try {
|
|
72
|
-
if (
|
|
73
|
-
const feature = await resolveFeature(config.docsDir, featureArg, "exclude");
|
|
74
|
-
if (!feature) {
|
|
75
|
-
return fail(json, "unknown-target", await missingFeatureMessage(config.docsDir, featureArg));
|
|
76
|
-
}
|
|
77
|
-
mode = "feature";
|
|
78
|
-
featureId = feature.id;
|
|
75
|
+
if (feature !== null) {
|
|
79
76
|
const paths = featureSpecPaths(feature.dir, service);
|
|
80
77
|
for (const axis of SPEC_AXES) {
|
|
81
78
|
const path = paths[axis.key];
|
|
@@ -104,7 +101,11 @@ export function registerGherkin(program) {
|
|
|
104
101
|
}
|
|
105
102
|
// `service` is not decoration: it salts every `@loam-digest-…` stamp, and
|
|
106
103
|
// it is the same string `loam verify` files the matching claims under.
|
|
107
|
-
const plan = planEmission(byAxis, {
|
|
104
|
+
const plan = planEmission(byAxis, {
|
|
105
|
+
service,
|
|
106
|
+
featureTag: scope.mode === "feature" ? scope.featureId : undefined,
|
|
107
|
+
version: LOAM_VERSION,
|
|
108
|
+
});
|
|
108
109
|
// Orphans of THIS scope, inside loam/ and nowhere else. Feature mode owns
|
|
109
110
|
// only its own emissions: stamped files carrying this feature's tag whose
|
|
110
111
|
// file is no longer in the plan (a requirement renamed or dropped).
|
|
@@ -117,14 +118,46 @@ export function registerGherkin(program) {
|
|
|
117
118
|
// deletion and replacement, and feature mode has to recognise another
|
|
118
119
|
// feature's in-flight file to refuse overwriting it.
|
|
119
120
|
const activeIds = new Set((await listFeatures(config.docsDir)).map((f) => f.id));
|
|
121
|
+
// "Inside loam/" has to hold after the links are resolved, not merely in
|
|
122
|
+
// the spelling of the path. `featureFilesUnder` FOLLOWS symlinks and
|
|
123
|
+
// recurses with the LINK's path, so a planted `<gherkinDir>/loam/sub ->
|
|
124
|
+
// /outside` hands back candidates that read as ours while `unlink`
|
|
125
|
+
// resolves them and destroys files that were never in this repo. Planned
|
|
126
|
+
// names are flat, so nothing nested is ever spared by `planned.has(...)`,
|
|
127
|
+
// and deletion is the one irreversible thing this command does: a
|
|
128
|
+
// candidate is an orphan only when its CONTAINING directory really
|
|
129
|
+
// resolves inside the owned root. The write path below refuses the same
|
|
130
|
+
// attack for the same reason.
|
|
131
|
+
//
|
|
132
|
+
// The candidate FILE is deliberately NOT resolved. A symlinked .feature
|
|
133
|
+
// sitting directly in loam/ is loam's to remove — unlinking it takes the
|
|
134
|
+
// link and leaves its target alone — so only the directory chain has to
|
|
135
|
+
// be ours.
|
|
136
|
+
const realOrNull = (p) => {
|
|
137
|
+
try {
|
|
138
|
+
return realpathSync(p);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
// A root that does not resolve — absent, dangling, or vanished mid-run —
|
|
145
|
+
// proves nothing about what lives beneath it, so nothing is collected.
|
|
146
|
+
// Leaving an orphan behind is self-repairing (`loam validate` grades it
|
|
147
|
+
// `gherkin.orphaned` and the next run removes it); a deletion taken on an
|
|
148
|
+
// unproven path is not.
|
|
149
|
+
const rootReal = realOrNull(root);
|
|
120
150
|
const orphans = [];
|
|
121
|
-
if (
|
|
151
|
+
if (rootReal !== null) {
|
|
122
152
|
for (const abs of await featureFilesUnder(root)) {
|
|
123
153
|
if (planned.has(relative(root, abs).split(/[\\/]/).join("/")))
|
|
124
154
|
continue;
|
|
155
|
+
const holder = realOrNull(dirname(abs));
|
|
156
|
+
if (holder === null || !isPathInside(rootReal, holder))
|
|
157
|
+
continue;
|
|
125
158
|
const stamped = parseStampedFeature(await readFile(abs, "utf8"));
|
|
126
|
-
if (mode === "feature") {
|
|
127
|
-
if (stamped !== null && stamped.tags.includes(featureId))
|
|
159
|
+
if (scope.mode === "feature") {
|
|
160
|
+
if (stamped !== null && stamped.tags.includes(scope.featureId))
|
|
128
161
|
orphans.push(abs);
|
|
129
162
|
}
|
|
130
163
|
else {
|
|
@@ -153,16 +186,17 @@ export function registerGherkin(program) {
|
|
|
153
186
|
continue;
|
|
154
187
|
}
|
|
155
188
|
const existing = parseStampedFeature(await readFile(path, "utf8"));
|
|
156
|
-
if (mode === "living") {
|
|
189
|
+
if (scope.mode === "living") {
|
|
157
190
|
if (existing !== null && existing.tags.some((t) => activeIds.has(t))) {
|
|
158
191
|
actions.push({ ...f, path, action: "kept", kept: existing });
|
|
159
192
|
continue;
|
|
160
193
|
}
|
|
161
194
|
}
|
|
162
|
-
else {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
195
|
+
else if (existing !== null) {
|
|
196
|
+
// An unstamped file is nobody's delta — it owns nothing, so it is
|
|
197
|
+
// replaced like any other, which is what the empty owner list used
|
|
198
|
+
// to say the long way round.
|
|
199
|
+
const owners = existing.tags.filter((t) => t !== scope.featureId && activeIds.has(t));
|
|
166
200
|
if (owners.length > 0) {
|
|
167
201
|
actions.push({ ...f, path, action: "conflict", kept: existing, owners });
|
|
168
202
|
continue;
|
|
@@ -172,26 +206,31 @@ export function registerGherkin(program) {
|
|
|
172
206
|
}
|
|
173
207
|
// All or nothing: one conflicting file refuses the whole emission, so a
|
|
174
208
|
// half-written suite can never be the state an agent has to reason about.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
209
|
+
// Only a feature run can conflict — living mode keeps the in-flight file
|
|
210
|
+
// rather than refusing — and standing inside that scope is also how the
|
|
211
|
+
// message names the feature without asserting there is one.
|
|
212
|
+
if (scope.mode === "feature") {
|
|
213
|
+
const conflicts = actions.filter((a) => a.action === "conflict");
|
|
214
|
+
if (conflicts.length > 0) {
|
|
215
|
+
const detail = conflicts
|
|
216
|
+
.map((c) => `${rel(c.path)} is @${c.owners.join(" @")} (requirement '${c.requirement.name}')`)
|
|
217
|
+
.join("; ");
|
|
218
|
+
const message = `Cannot emit gherkin for ${scope.featureId}: ${conflicts.length} file(s) belong to another feature still in flight — ${detail}. ` +
|
|
219
|
+
`A .feature file carries one feature's delta; overwriting would revert that feature's wording and destroy the digest stamps \`loam verify --results\` matches on. ` +
|
|
220
|
+
`Archive (or abandon) the owning feature first, or rename the requirement in ${scope.featureId}'s delta so the two stop sharing a file name.`;
|
|
221
|
+
if (json) {
|
|
222
|
+
emitJsonError("gherkin-conflict", message, {
|
|
223
|
+
conflicts: conflicts.map((c) => ({
|
|
224
|
+
path: rel(c.path),
|
|
225
|
+
action: "conflict",
|
|
226
|
+
requirement: c.requirement.name,
|
|
227
|
+
inFlight: c.owners,
|
|
228
|
+
})),
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
return fail(json, "gherkin-conflict", message);
|
|
193
233
|
}
|
|
194
|
-
return fail(json, "gherkin-conflict", message);
|
|
195
234
|
}
|
|
196
235
|
const writes = actions.filter((a) => a.action !== "kept");
|
|
197
236
|
if (!dryRun) {
|
|
@@ -207,8 +246,8 @@ export function registerGherkin(program) {
|
|
|
207
246
|
}
|
|
208
247
|
if (json) {
|
|
209
248
|
emitJson({
|
|
210
|
-
mode,
|
|
211
|
-
...(
|
|
249
|
+
mode: scope.mode,
|
|
250
|
+
...(scope.mode === "feature" ? { feature: scope.featureId } : {}),
|
|
212
251
|
service,
|
|
213
252
|
root: rel(root),
|
|
214
253
|
written: !dryRun,
|
|
@@ -229,11 +268,11 @@ export function registerGherkin(program) {
|
|
|
229
268
|
});
|
|
230
269
|
return;
|
|
231
270
|
}
|
|
232
|
-
const head = mode === "feature" ? `${featureId} · ${service}` : `${service} (living suite)`;
|
|
271
|
+
const head = scope.mode === "feature" ? `${scope.featureId} · ${service}` : `${service} (living suite)`;
|
|
233
272
|
console.log(`gherkin ${head} → ${rel(root)}/${dryRun ? " (dry run)" : ""}\n`);
|
|
234
273
|
if (actions.length === 0) {
|
|
235
|
-
console.log(mode === "feature"
|
|
236
|
-
? ` ${featureId} has no ADDED or MODIFIED requirements for ${service} — nothing to emit.`
|
|
274
|
+
console.log(scope.mode === "feature"
|
|
275
|
+
? ` ${scope.featureId} has no ADDED or MODIFIED requirements for ${service} — nothing to emit.`
|
|
237
276
|
: ` the living specs hold no requirements for ${service} — nothing to emit.`);
|
|
238
277
|
}
|
|
239
278
|
// `conflict` never reaches here — the run refused above — but the map is
|