@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,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three refusals every command owes a docs repo it cannot read.
|
|
3
|
+
*
|
|
4
|
+
* One doctrine, three ways of reaching it: the gate a command passes before it
|
|
5
|
+
* enumerates anything, the error the enumeration throws when the repo goes away
|
|
6
|
+
* underneath it, and the errno that comes back when the repo is real but one
|
|
7
|
+
* file under it will not open. Six commands reach for one or another of them
|
|
8
|
+
* (`adopt`, `list`, `new`, `show`, `status`, `validate`), and the whole point of
|
|
9
|
+
* collecting them is that all six answer with the same sentences.
|
|
10
|
+
*
|
|
11
|
+
* They live in `commands/` rather than in `core/`: each one calls `fail`, which
|
|
12
|
+
* writes to stdout or stderr and sets the exit code. That is a decision about
|
|
13
|
+
* how this process ends, and core modules compute answers rather than end
|
|
14
|
+
* processes.
|
|
15
|
+
*/
|
|
16
|
+
import { fail } from "../core/json.js";
|
|
17
|
+
import { docsRepoState } from "../core/repo.js";
|
|
18
|
+
/**
|
|
19
|
+
* The refusal every READ command owes a `docsDir` that is not a docs repo.
|
|
20
|
+
*
|
|
21
|
+
* It is not a formality. `validate --all` and `list` used to answer a docsDir
|
|
22
|
+
* that does not exist with a green report over zero services, which is the one
|
|
23
|
+
* output a fleet gate must never produce: "nothing is wrong" and "I could not
|
|
24
|
+
* look" are opposite facts, and in CI the first one merges. The two codes are
|
|
25
|
+
* kept apart because the fixes point in different directions — a wrong path in
|
|
26
|
+
* loam.json versus a docs repo that was never cloned or scaffolded — exactly
|
|
27
|
+
* the way `no-config` and `config-invalid` are kept apart.
|
|
28
|
+
*
|
|
29
|
+
* "Zero services" stays reachable, and only from a REAL docs repo whose
|
|
30
|
+
* `services/` is empty: that is a legitimate state (before the first
|
|
31
|
+
* `loam adopt`) and it must keep exiting 0.
|
|
32
|
+
*
|
|
33
|
+
* `need` says how much of a docs repo this particular run has to have. A run
|
|
34
|
+
* that only reads features genuinely does not need `services/` — repo.ts takes
|
|
35
|
+
* the same position for `listFeatures` — and refusing there would turn one
|
|
36
|
+
* diagnosis into two contradictory ones. A run that enumerates services needs
|
|
37
|
+
* the directory that IS the list of services.
|
|
38
|
+
*
|
|
39
|
+
* It used to live in validate.ts, on the reasoning that validate is the fleet
|
|
40
|
+
* gate this doctrine is about. Six commands import it now (`adopt`, `list`,
|
|
41
|
+
* `new`, `show`, `status`, `validate`), so the doctrine outgrew its host: `loam
|
|
42
|
+
* show` was structurally depending on the whole validate implementation for
|
|
43
|
+
* twenty lines of policy. The rule is the module now, and validate is one of
|
|
44
|
+
* its callers like the rest.
|
|
45
|
+
*/
|
|
46
|
+
export function docsRepoReady(json, docsDir, need) {
|
|
47
|
+
const state = docsRepoState(docsDir);
|
|
48
|
+
if (state.kind === "ok" || (state.kind === "no-services" && need === "docs"))
|
|
49
|
+
return true;
|
|
50
|
+
if (state.kind === "missing") {
|
|
51
|
+
fail(json, "docs-missing", `The configured docs repo does not exist: ${state.path}. ` +
|
|
52
|
+
"Fix `docsDir` in loam.json, clone the docs repo there, " +
|
|
53
|
+
"or run `loam init --docs <dir> --create` to make a new one.");
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
fail(json, "services-missing", `${state.path} is not a docs repo — it has no services/ directory. ` +
|
|
57
|
+
"Point `docsDir` in loam.json at the shared docs repo, " +
|
|
58
|
+
"or run `loam init --docs <dir> --create` to make one.");
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The same refusal, reached the other way: `listServices`/`listFeatures` throw
|
|
63
|
+
* `DocsRepoUnavailableError` when the repo goes away mid-run (or when a caller
|
|
64
|
+
* reaches enumeration without passing the gate above). Mapping it back onto the
|
|
65
|
+
* same two codes keeps one breach spelled one way, instead of surfacing as the
|
|
66
|
+
* `internal` catch-all in cli.ts.
|
|
67
|
+
*/
|
|
68
|
+
export function reportDocsRepoError(json, err) {
|
|
69
|
+
fail(json, err.state.kind === "missing" ? "docs-missing" : "services-missing", err.message);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The third refusal a reading command owes: the docs repo IS a docs repo, but
|
|
73
|
+
* one file under it cannot be read. `list`, `show`, `status` and `validate` all
|
|
74
|
+
* walk a directory tree to answer, so all four owe the same sentence — and all
|
|
75
|
+
* four kept their own copy of it, which is exactly how three of them ended up
|
|
76
|
+
* wrong. Sharing it here, beside `reportDocsRepoError`, is the same trade: one
|
|
77
|
+
* breach spelled one way instead of four that drift.
|
|
78
|
+
*
|
|
79
|
+
* A filesystem failure is recognised by its errno, NOT by carrying a `path`.
|
|
80
|
+
* Node reports EISDIR from `read()` with no path at all — the directory opened,
|
|
81
|
+
* the read failed — so a guard that looked only for `path` was defeated by the
|
|
82
|
+
* commonest way a malformed docs repo breaks: one directory sitting where a
|
|
83
|
+
* file belongs escaped as a bare `internal: EISDIR`, naming neither the file
|
|
84
|
+
* nor the target. Anything carrying no errno at all is a real bug, and still
|
|
85
|
+
* escapes untouched.
|
|
86
|
+
*
|
|
87
|
+
* `consequence` is the clause after "so" — what the caller could not answer
|
|
88
|
+
* because of this. It is the only part that differs between the four.
|
|
89
|
+
*/
|
|
90
|
+
export function reportRepositoryUnavailable(json, err, consequence, docsDir) {
|
|
91
|
+
const e = err;
|
|
92
|
+
if (e.path === undefined && typeof e.errno !== "number")
|
|
93
|
+
throw err;
|
|
94
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
95
|
+
fail(json, "repository-unavailable", e.path !== undefined
|
|
96
|
+
? `${e.path} could not be read, so ${consequence}. ${why}`
|
|
97
|
+
: // Naming the target's own directory here would be a guess, and a guess
|
|
98
|
+
// that is wrong precisely when it matters: one malformed artifact makes
|
|
99
|
+
// every OTHER target's answer fail too, and each would have accused its
|
|
100
|
+
// own files. Say what is known instead.
|
|
101
|
+
`A file under ${docsDir} could not be read, so ${consequence}. ${why}. ` +
|
|
102
|
+
`The failure named no path — that is how Node reports a directory sitting where a file belongs.`);
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=docs-repo-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"docs-repo-gate.js","sourceRoot":"","sources":["../../src/commands/docs-repo-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAC;AACvC,OAAO,EAAE,aAAa,EAAiC,MAAM,iBAAiB,CAAC;AAE/E;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,aAAa,CAAC,IAAa,EAAE,OAAe,EAAE,IAAyB;IACrF,MAAM,KAAK,GAAG,aAAa,CAAC,OAAO,CAAC,CAAC;IACrC,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,aAAa,IAAI,IAAI,KAAK,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1F,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QAC7B,IAAI,CACF,IAAI,EACJ,cAAc,EACd,4CAA4C,KAAK,CAAC,IAAI,IAAI;YACxD,yDAAyD;YACzD,6DAA6D,CAChE,CAAC;QACF,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,CACF,IAAI,EACJ,kBAAkB,EAClB,GAAG,KAAK,CAAC,IAAI,uDAAuD;QAClE,wDAAwD;QACxD,uDAAuD,CAC1D,CAAC;IACF,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAa,EAAE,GAA6B;IAC9E,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,kBAAkB,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;AAC9F,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,2BAA2B,CACzC,IAAa,EACb,GAAY,EACZ,WAAmB,EACnB,OAAe;IAEf,MAAM,CAAC,GAAG,GAA4B,CAAC;IACvC,IAAI,CAAC,CAAC,IAAI,KAAK,SAAS,IAAI,OAAO,CAAC,CAAC,KAAK,KAAK,QAAQ;QAAE,MAAM,GAAG,CAAC;IACnE,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7D,IAAI,CACF,IAAI,EACJ,wBAAwB,EACxB,CAAC,CAAC,IAAI,KAAK,SAAS;QAClB,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,0BAA0B,WAAW,KAAK,GAAG,EAAE;QAC1D,CAAC,CAAC,uEAAuE;YACvE,wEAAwE;YACxE,wEAAwE;YACxE,wCAAwC;YACxC,gBAAgB,OAAO,0BAA0B,WAAW,KAAK,GAAG,IAAI;gBACxE,gGAAgG,CACrG,CAAC;AACJ,CAAC","sourcesContent":["/**\n * The three refusals every command owes a docs repo it cannot read.\n *\n * One doctrine, three ways of reaching it: the gate a command passes before it\n * enumerates anything, the error the enumeration throws when the repo goes away\n * underneath it, and the errno that comes back when the repo is real but one\n * file under it will not open. Six commands reach for one or another of them\n * (`adopt`, `list`, `new`, `show`, `status`, `validate`), and the whole point of\n * collecting them is that all six answer with the same sentences.\n *\n * They live in `commands/` rather than in `core/`: each one calls `fail`, which\n * writes to stdout or stderr and sets the exit code. That is a decision about\n * how this process ends, and core modules compute answers rather than end\n * processes.\n */\nimport { fail } from \"../core/json.js\";\nimport { docsRepoState, type DocsRepoUnavailableError } from \"../core/repo.js\";\n\n/**\n * The refusal every READ command owes a `docsDir` that is not a docs repo.\n *\n * It is not a formality. `validate --all` and `list` used to answer a docsDir\n * that does not exist with a green report over zero services, which is the one\n * output a fleet gate must never produce: \"nothing is wrong\" and \"I could not\n * look\" are opposite facts, and in CI the first one merges. The two codes are\n * kept apart because the fixes point in different directions — a wrong path in\n * loam.json versus a docs repo that was never cloned or scaffolded — exactly\n * the way `no-config` and `config-invalid` are kept apart.\n *\n * \"Zero services\" stays reachable, and only from a REAL docs repo whose\n * `services/` is empty: that is a legitimate state (before the first\n * `loam adopt`) and it must keep exiting 0.\n *\n * `need` says how much of a docs repo this particular run has to have. A run\n * that only reads features genuinely does not need `services/` — repo.ts takes\n * the same position for `listFeatures` — and refusing there would turn one\n * diagnosis into two contradictory ones. A run that enumerates services needs\n * the directory that IS the list of services.\n *\n * It used to live in validate.ts, on the reasoning that validate is the fleet\n * gate this doctrine is about. Six commands import it now (`adopt`, `list`,\n * `new`, `show`, `status`, `validate`), so the doctrine outgrew its host: `loam\n * show` was structurally depending on the whole validate implementation for\n * twenty lines of policy. The rule is the module now, and validate is one of\n * its callers like the rest.\n */\nexport function docsRepoReady(json: boolean, docsDir: string, need: \"docs\" | \"services\"): boolean {\n const state = docsRepoState(docsDir);\n if (state.kind === \"ok\" || (state.kind === \"no-services\" && need === \"docs\")) return true;\n if (state.kind === \"missing\") {\n fail(\n json,\n \"docs-missing\",\n `The configured docs repo does not exist: ${state.path}. ` +\n \"Fix `docsDir` in loam.json, clone the docs repo there, \" +\n \"or run `loam init --docs <dir> --create` to make a new one.\",\n );\n return false;\n }\n fail(\n json,\n \"services-missing\",\n `${state.path} is not a docs repo — it has no services/ directory. ` +\n \"Point `docsDir` in loam.json at the shared docs repo, \" +\n \"or run `loam init --docs <dir> --create` to make one.\",\n );\n return false;\n}\n\n/**\n * The same refusal, reached the other way: `listServices`/`listFeatures` throw\n * `DocsRepoUnavailableError` when the repo goes away mid-run (or when a caller\n * reaches enumeration without passing the gate above). Mapping it back onto the\n * same two codes keeps one breach spelled one way, instead of surfacing as the\n * `internal` catch-all in cli.ts.\n */\nexport function reportDocsRepoError(json: boolean, err: DocsRepoUnavailableError): void {\n fail(json, err.state.kind === \"missing\" ? \"docs-missing\" : \"services-missing\", err.message);\n}\n\n/**\n * The third refusal a reading command owes: the docs repo IS a docs repo, but\n * one file under it cannot be read. `list`, `show`, `status` and `validate` all\n * walk a directory tree to answer, so all four owe the same sentence — and all\n * four kept their own copy of it, which is exactly how three of them ended up\n * wrong. Sharing it here, beside `reportDocsRepoError`, is the same trade: one\n * breach spelled one way instead of four that drift.\n *\n * A filesystem failure is recognised by its errno, NOT by carrying a `path`.\n * Node reports EISDIR from `read()` with no path at all — the directory opened,\n * the read failed — so a guard that looked only for `path` was defeated by the\n * commonest way a malformed docs repo breaks: one directory sitting where a\n * file belongs escaped as a bare `internal: EISDIR`, naming neither the file\n * nor the target. Anything carrying no errno at all is a real bug, and still\n * escapes untouched.\n *\n * `consequence` is the clause after \"so\" — what the caller could not answer\n * because of this. It is the only part that differs between the four.\n */\nexport function reportRepositoryUnavailable(\n json: boolean,\n err: unknown,\n consequence: string,\n docsDir: string,\n): void {\n const e = err as NodeJS.ErrnoException;\n if (e.path === undefined && typeof e.errno !== \"number\") throw err;\n const why = err instanceof Error ? err.message : String(err);\n fail(\n json,\n \"repository-unavailable\",\n e.path !== undefined\n ? `${e.path} could not be read, so ${consequence}. ${why}`\n : // Naming the target's own directory here would be a guess, and a guess\n // that is wrong precisely when it matters: one malformed artifact makes\n // every OTHER target's answer fail too, and each would have accused its\n // own files. Say what is known instead.\n `A file under ${docsDir} could not be read, so ${consequence}. ${why}. ` +\n `The failure named no path — that is how Node reports a directory sitting where a file belongs.`,\n );\n}\n"]}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { diagnose } from "../core/doctor.js";
|
|
2
|
+
import { emitJson } from "../core/json.js";
|
|
3
|
+
export function registerDoctor(program) {
|
|
4
|
+
program
|
|
5
|
+
.command("doctor")
|
|
6
|
+
.description("Diagnose local loam configuration and docs-repo accessibility without writing")
|
|
7
|
+
.option("--json", "emit the machine contract instead of the human view")
|
|
8
|
+
.action(async (opts) => {
|
|
9
|
+
const report = await diagnose();
|
|
10
|
+
if (opts.json === true)
|
|
11
|
+
emitJson({ command: "doctor", ...report });
|
|
12
|
+
else
|
|
13
|
+
printDoctor(report);
|
|
14
|
+
if (!report.healthy)
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function yesNo(value) {
|
|
19
|
+
return value ? "yes" : "no";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The stamp as one readable token. "absent" and "unstamped" are kept apart
|
|
23
|
+
* because they are different repos: the first has no AGENTS.md at all, the
|
|
24
|
+
* second has one nobody has claimed a version for.
|
|
25
|
+
*/
|
|
26
|
+
function stampLabel(stamp) {
|
|
27
|
+
if (!stamp.present)
|
|
28
|
+
return "absent";
|
|
29
|
+
if (stamp.version === null)
|
|
30
|
+
return "unstamped";
|
|
31
|
+
return `v${stamp.version}${stamp.stale ? " (stale)" : ""}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* What a killed writer left in the docs repo, as one line. "clean" is the
|
|
35
|
+
* answer on every healthy repo, and "(unresolved)" keeps it apart from clean:
|
|
36
|
+
* a docsDir that never resolved was never looked at.
|
|
37
|
+
*/
|
|
38
|
+
function writePathLabel(residue) {
|
|
39
|
+
if (residue === null)
|
|
40
|
+
return "(unresolved)";
|
|
41
|
+
const parts = [
|
|
42
|
+
...(residue.lock === null ? [] : [`lock held${residue.lock.stale ? " (stale)" : ""}`]),
|
|
43
|
+
...(residue.intentUnreadable
|
|
44
|
+
? ["interrupted commit (unreadable)"]
|
|
45
|
+
: residue.intent === null
|
|
46
|
+
? []
|
|
47
|
+
: [`interrupted ${residue.intent.command} of ${residue.intent.feature}`]),
|
|
48
|
+
...(residue.temps.length === 0 ? [] : [`${residue.temps.length} orphaned temp file(s)`]),
|
|
49
|
+
];
|
|
50
|
+
return parts.length === 0 ? "clean" : parts.join(" · ");
|
|
51
|
+
}
|
|
52
|
+
function printDoctor(report) {
|
|
53
|
+
console.log(`loam doctor — ${report.healthy ? "healthy" : "blocked"}`);
|
|
54
|
+
console.log(` runtime ${report.runtime.package}@${report.runtime.version}`);
|
|
55
|
+
console.log(` node ${report.runtime.node} ${report.runtime.platform}/${report.runtime.arch}`);
|
|
56
|
+
console.log(` config ${report.config.status} ${report.config.path}`);
|
|
57
|
+
if (report.config.error !== null)
|
|
58
|
+
console.log(` ${report.config.error}`);
|
|
59
|
+
console.log(` docsDir ${report.docs.path ?? "(unresolved)"}`);
|
|
60
|
+
console.log(` access exists ${yesNo(report.docs.exists)} · read ${yesNo(report.docs.readable)} · write ${yesNo(report.docs.writable)}`);
|
|
61
|
+
console.log(` fleet services/ ${yesNo(report.docs.servicesDir)} · landscape ${yesNo(report.docs.landscape)} · ${report.counts.services} services · ${report.counts.activeFeatures} active features`);
|
|
62
|
+
console.log(` binding ${report.currentService.configured ?? "(none)"} · ${report.currentService.status}`);
|
|
63
|
+
// The agent surface is reported whether or not it has drifted — like every
|
|
64
|
+
// line above it, this is state, not a complaint. Only the findings block is
|
|
65
|
+
// silent when there is nothing to say.
|
|
66
|
+
const agents = report.agents;
|
|
67
|
+
console.log(` agents ${agents.tools.length === 0 ? "(none)" : agents.tools.join(", ")}`
|
|
68
|
+
+ ` (${agents.toolsSource}) · ${agents.plannedFiles} files · ${agents.missingFiles.length} missing`
|
|
69
|
+
+ ` · ${agents.staleFiles.length} stale · AGENTS.md ${stampLabel(agents.stamp)}`);
|
|
70
|
+
// The write path is state too, and its clean answer is the one worth printing
|
|
71
|
+
// most often: a reader who has just been told the docs are half-written needs
|
|
72
|
+
// to see it go away.
|
|
73
|
+
console.log(` write path ${writePathLabel(report.writePath)}`);
|
|
74
|
+
if (report.findings.length > 0) {
|
|
75
|
+
console.log("\n findings");
|
|
76
|
+
for (const finding of report.findings) {
|
|
77
|
+
console.log(` ${finding.severity === "blocker" ? "✗" : "⚠"} ${finding.code}: ${finding.message}`);
|
|
78
|
+
// The fix is printed on its own indented line rather than appended to the
|
|
79
|
+
// message: what is wrong and what to type are two different sentences,
|
|
80
|
+
// and someone scanning a blocked repo reads down the `fix:` column.
|
|
81
|
+
console.log(` fix: ${finding.fix}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=doctor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"doctor.js","sourceRoot":"","sources":["../../src/commands/doctor.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Command } from \"commander\";\nimport { diagnose, type DoctorReport } from \"../core/doctor.js\";\nimport { emitJson } from \"../core/json.js\";\n\ninterface DoctorOptions {\n json?: boolean;\n}\n\nexport function registerDoctor(program: Command): void {\n program\n .command(\"doctor\")\n .description(\"Diagnose local loam configuration and docs-repo accessibility without writing\")\n .option(\"--json\", \"emit the machine contract instead of the human view\")\n .action(async (opts: DoctorOptions) => {\n const report = await diagnose();\n if (opts.json === true) emitJson({ command: \"doctor\", ...report });\n else printDoctor(report);\n if (!report.healthy) process.exitCode = 1;\n });\n}\n\nfunction yesNo(value: boolean): string {\n return value ? \"yes\" : \"no\";\n}\n\n/**\n * The stamp as one readable token. \"absent\" and \"unstamped\" are kept apart\n * because they are different repos: the first has no AGENTS.md at all, the\n * second has one nobody has claimed a version for.\n */\nfunction stampLabel(stamp: DoctorReport[\"agents\"][\"stamp\"]): string {\n if (!stamp.present) return \"absent\";\n if (stamp.version === null) return \"unstamped\";\n return `v${stamp.version}${stamp.stale ? \" (stale)\" : \"\"}`;\n}\n\n/**\n * What a killed writer left in the docs repo, as one line. \"clean\" is the\n * answer on every healthy repo, and \"(unresolved)\" keeps it apart from clean:\n * a docsDir that never resolved was never looked at.\n */\nfunction writePathLabel(residue: DoctorReport[\"writePath\"]): string {\n if (residue === null) return \"(unresolved)\";\n const parts = [\n ...(residue.lock === null ? [] : [`lock held${residue.lock.stale ? \" (stale)\" : \"\"}`]),\n ...(residue.intentUnreadable\n ? [\"interrupted commit (unreadable)\"]\n : residue.intent === null\n ? []\n : [`interrupted ${residue.intent.command} of ${residue.intent.feature}`]),\n ...(residue.temps.length === 0 ? [] : [`${residue.temps.length} orphaned temp file(s)`]),\n ];\n return parts.length === 0 ? \"clean\" : parts.join(\" · \");\n}\n\nfunction printDoctor(report: DoctorReport): void {\n console.log(`loam doctor — ${report.healthy ? \"healthy\" : \"blocked\"}`);\n console.log(` runtime ${report.runtime.package}@${report.runtime.version}`);\n console.log(` node ${report.runtime.node} ${report.runtime.platform}/${report.runtime.arch}`);\n console.log(` config ${report.config.status} ${report.config.path}`);\n if (report.config.error !== null) console.log(` ${report.config.error}`);\n console.log(` docsDir ${report.docs.path ?? \"(unresolved)\"}`);\n console.log(\n ` access exists ${yesNo(report.docs.exists)} · read ${yesNo(report.docs.readable)} · write ${yesNo(report.docs.writable)}`,\n );\n console.log(\n ` fleet services/ ${yesNo(report.docs.servicesDir)} · landscape ${yesNo(report.docs.landscape)} · ${report.counts.services} services · ${report.counts.activeFeatures} active features`,\n );\n console.log(\n ` binding ${report.currentService.configured ?? \"(none)\"} · ${report.currentService.status}`,\n );\n // The agent surface is reported whether or not it has drifted — like every\n // line above it, this is state, not a complaint. Only the findings block is\n // silent when there is nothing to say.\n const agents = report.agents;\n console.log(\n ` agents ${agents.tools.length === 0 ? \"(none)\" : agents.tools.join(\", \")}`\n + ` (${agents.toolsSource}) · ${agents.plannedFiles} files · ${agents.missingFiles.length} missing`\n + ` · ${agents.staleFiles.length} stale · AGENTS.md ${stampLabel(agents.stamp)}`,\n );\n // The write path is state too, and its clean answer is the one worth printing\n // most often: a reader who has just been told the docs are half-written needs\n // to see it go away.\n console.log(` write path ${writePathLabel(report.writePath)}`);\n if (report.findings.length > 0) {\n console.log(\"\\n findings\");\n for (const finding of report.findings) {\n console.log(` ${finding.severity === \"blocker\" ? \"✗\" : \"⚠\"} ${finding.code}: ${finding.message}`);\n // The fix is printed on its own indented line rather than appended to the\n // message: what is wrong and what to type are two different sentences,\n // and someone scanning a blocked repo reads down the `fix:` column.\n console.log(` fix: ${finding.fix}`);\n }\n }\n}\n"]}
|
|
@@ -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"]}
|