@ybotok/loam 0.1.0-beta.1 → 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.
Files changed (115) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +6 -1
  3. package/dist/cli.js +4 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/adopt.js +17 -3
  6. package/dist/commands/adopt.js.map +1 -1
  7. package/dist/commands/archive.js +24 -8
  8. package/dist/commands/archive.js.map +1 -1
  9. package/dist/commands/delta.js +74 -47
  10. package/dist/commands/delta.js.map +1 -1
  11. package/dist/commands/dependencies.js +7 -2
  12. package/dist/commands/dependencies.js.map +1 -1
  13. package/dist/commands/docs-repo-gate.js +104 -0
  14. package/dist/commands/docs-repo-gate.js.map +1 -0
  15. package/dist/commands/explore.js +195 -0
  16. package/dist/commands/explore.js.map +1 -0
  17. package/dist/commands/format.js +17 -0
  18. package/dist/commands/format.js.map +1 -0
  19. package/dist/commands/gherkin.js +87 -48
  20. package/dist/commands/gherkin.js.map +1 -1
  21. package/dist/commands/init.js +3 -3
  22. package/dist/commands/init.js.map +1 -1
  23. package/dist/commands/instructions.js +70 -0
  24. package/dist/commands/instructions.js.map +1 -0
  25. package/dist/commands/list.js +6 -76
  26. package/dist/commands/list.js.map +1 -1
  27. package/dist/commands/migrate-openspec.js +45 -28
  28. package/dist/commands/migrate-openspec.js.map +1 -1
  29. package/dist/commands/new.js +35 -34
  30. package/dist/commands/new.js.map +1 -1
  31. package/dist/commands/rebase.js +2 -4
  32. package/dist/commands/rebase.js.map +1 -1
  33. package/dist/commands/show.js +26 -31
  34. package/dist/commands/show.js.map +1 -1
  35. package/dist/commands/status.js +8 -24
  36. package/dist/commands/status.js.map +1 -1
  37. package/dist/commands/unarchive.js +12 -5
  38. package/dist/commands/unarchive.js.map +1 -1
  39. package/dist/commands/validate.js +115 -133
  40. package/dist/commands/validate.js.map +1 -1
  41. package/dist/commands/verify.js +33 -17
  42. package/dist/commands/verify.js.map +1 -1
  43. package/dist/commands/vouch.js +77 -59
  44. package/dist/commands/vouch.js.map +1 -1
  45. package/dist/core/agent.js +273 -31
  46. package/dist/core/agent.js.map +1 -1
  47. package/dist/core/arch.js +28 -2
  48. package/dist/core/arch.js.map +1 -1
  49. package/dist/core/brief.js +3 -3
  50. package/dist/core/brief.js.map +1 -1
  51. package/dist/core/coherence.js +37 -13
  52. package/dist/core/coherence.js.map +1 -1
  53. package/dist/core/concurrency.js +51 -0
  54. package/dist/core/concurrency.js.map +1 -0
  55. package/dist/core/config.js +10 -2
  56. package/dist/core/config.js.map +1 -1
  57. package/dist/core/delta.js +21 -2
  58. package/dist/core/delta.js.map +1 -1
  59. package/dist/core/dependencies.js +46 -42
  60. package/dist/core/dependencies.js.map +1 -1
  61. package/dist/core/docs.js +2 -1
  62. package/dist/core/docs.js.map +1 -1
  63. package/dist/core/doctor.js +41 -26
  64. package/dist/core/doctor.js.map +1 -1
  65. package/dist/core/document-bytes.js +59 -0
  66. package/dist/core/document-bytes.js.map +1 -0
  67. package/dist/core/explore.js +225 -0
  68. package/dist/core/explore.js.map +1 -0
  69. package/dist/core/fleet-context.js +16 -63
  70. package/dist/core/fleet-context.js.map +1 -1
  71. package/dist/core/frontmatter.js +1 -5
  72. package/dist/core/frontmatter.js.map +1 -1
  73. package/dist/core/gherkin.js +3 -61
  74. package/dist/core/gherkin.js.map +1 -1
  75. package/dist/core/health.js +1 -5
  76. package/dist/core/health.js.map +1 -1
  77. package/dist/core/ids.js +34 -5
  78. package/dist/core/ids.js.map +1 -1
  79. package/dist/core/json.js +8 -1
  80. package/dist/core/json.js.map +1 -1
  81. package/dist/core/likec4.js +6 -1
  82. package/dist/core/likec4.js.map +1 -1
  83. package/dist/core/maturity.js +66 -0
  84. package/dist/core/maturity.js.map +1 -0
  85. package/dist/core/openapi-merge.js +39 -6
  86. package/dist/core/openapi-merge.js.map +1 -1
  87. package/dist/core/openapi.js +17 -5
  88. package/dist/core/openapi.js.map +1 -1
  89. package/dist/core/openspec-inventory.js +64 -27
  90. package/dist/core/openspec-inventory.js.map +1 -1
  91. package/dist/core/path-safety.js +59 -0
  92. package/dist/core/path-safety.js.map +1 -1
  93. package/dist/core/provenance.js +35 -10
  94. package/dist/core/provenance.js.map +1 -1
  95. package/dist/core/records.js +33 -0
  96. package/dist/core/records.js.map +1 -0
  97. package/dist/core/repo.js +75 -1
  98. package/dist/core/repo.js.map +1 -1
  99. package/dist/core/report.js +9 -0
  100. package/dist/core/report.js.map +1 -1
  101. package/dist/core/results.js +1 -3
  102. package/dist/core/results.js.map +1 -1
  103. package/dist/core/spec.js +3 -3
  104. package/dist/core/spec.js.map +1 -1
  105. package/dist/core/staging.js +124 -17
  106. package/dist/core/staging.js.map +1 -1
  107. package/dist/core/status.js +122 -33
  108. package/dist/core/status.js.map +1 -1
  109. package/dist/core/steps.js +72 -0
  110. package/dist/core/steps.js.map +1 -0
  111. package/dist/core/verify.js +18 -4
  112. package/dist/core/verify.js.map +1 -1
  113. package/dist/core/version.js +17 -1
  114. package/dist/core/version.js.map +1 -1
  115. 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"]}
@@ -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/fleet-context.js";
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", "No service. Pass --service <id> or set it in loam.json.");
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 (featureArg !== undefined) {
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, { service, featureTag: featureId, version: LOAM_VERSION });
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 (existsSync(root)) {
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
- const owners = existing === null
164
- ? []
165
- : existing.tags.filter((t) => t !== featureId && activeIds.has(t));
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
- const conflicts = actions.filter((a) => a.action === "conflict");
176
- if (conflicts.length > 0) {
177
- const detail = conflicts
178
- .map((c) => `${rel(c.path)} is @${c.owners.join(" @")} (requirement '${c.requirement.name}')`)
179
- .join("; ");
180
- const message = `Cannot emit gherkin for ${featureId}: ${conflicts.length} file(s) belong to another feature still in flight — ${detail}. ` +
181
- `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. ` +
182
- `Archive (or abandon) the owning feature first, or rename the requirement in ${featureId}'s delta so the two stop sharing a file name.`;
183
- if (json) {
184
- emitJsonError("gherkin-conflict", message, {
185
- conflicts: conflicts.map((c) => ({
186
- path: rel(c.path),
187
- action: "conflict",
188
- requirement: c.requirement.name,
189
- inFlight: c.owners,
190
- })),
191
- });
192
- return;
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
- ...(featureId === undefined ? {} : { feature: featureId }),
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