@intentset/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/commands/architecture.d.ts +6 -0
  2. package/dist/commands/architecture.d.ts.map +1 -0
  3. package/dist/commands/architecture.js +65 -0
  4. package/dist/commands/architecture.js.map +1 -0
  5. package/dist/commands/context.d.ts +4 -0
  6. package/dist/commands/context.d.ts.map +1 -0
  7. package/dist/commands/context.js +124 -0
  8. package/dist/commands/context.js.map +1 -0
  9. package/dist/commands/evidence.d.ts +29 -0
  10. package/dist/commands/evidence.d.ts.map +1 -0
  11. package/dist/commands/evidence.js +157 -0
  12. package/dist/commands/evidence.js.map +1 -0
  13. package/dist/commands/graph.d.ts +18 -0
  14. package/dist/commands/graph.d.ts.map +1 -0
  15. package/dist/commands/graph.js +77 -0
  16. package/dist/commands/graph.js.map +1 -0
  17. package/dist/commands/impact.d.ts +20 -0
  18. package/dist/commands/impact.d.ts.map +1 -0
  19. package/dist/commands/impact.js +92 -0
  20. package/dist/commands/impact.js.map +1 -0
  21. package/dist/commands/init.d.ts +18 -0
  22. package/dist/commands/init.d.ts.map +1 -0
  23. package/dist/commands/init.js +122 -0
  24. package/dist/commands/init.js.map +1 -0
  25. package/dist/commands/mcp.d.ts +24 -0
  26. package/dist/commands/mcp.d.ts.map +1 -0
  27. package/dist/commands/mcp.js +122 -0
  28. package/dist/commands/mcp.js.map +1 -0
  29. package/dist/commands/publish.d.ts +19 -0
  30. package/dist/commands/publish.d.ts.map +1 -0
  31. package/dist/commands/publish.js +98 -0
  32. package/dist/commands/publish.js.map +1 -0
  33. package/dist/commands/review.d.ts +29 -0
  34. package/dist/commands/review.d.ts.map +1 -0
  35. package/dist/commands/review.js +147 -0
  36. package/dist/commands/review.js.map +1 -0
  37. package/dist/commands/serve.d.ts +14 -0
  38. package/dist/commands/serve.d.ts.map +1 -0
  39. package/dist/commands/serve.js +246 -0
  40. package/dist/commands/serve.js.map +1 -0
  41. package/dist/commands/shared.d.ts +19 -0
  42. package/dist/commands/shared.d.ts.map +1 -0
  43. package/dist/commands/shared.js +43 -0
  44. package/dist/commands/shared.js.map +1 -0
  45. package/dist/commands/validate.d.ts +11 -0
  46. package/dist/commands/validate.d.ts.map +1 -0
  47. package/dist/commands/validate.js +38 -0
  48. package/dist/commands/validate.js.map +1 -0
  49. package/dist/git.d.ts +25 -0
  50. package/dist/git.d.ts.map +1 -0
  51. package/dist/git.js +63 -0
  52. package/dist/git.js.map +1 -0
  53. package/dist/glob.d.ts +16 -0
  54. package/dist/glob.d.ts.map +1 -0
  55. package/dist/glob.js +60 -0
  56. package/dist/glob.js.map +1 -0
  57. package/dist/index.d.ts +9 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +8 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/intentset.d.ts +3 -0
  62. package/dist/intentset.d.ts.map +1 -0
  63. package/dist/intentset.js +15 -0
  64. package/dist/intentset.js.map +1 -0
  65. package/dist/main.d.ts +5 -0
  66. package/dist/main.d.ts.map +1 -0
  67. package/dist/main.js +406 -0
  68. package/dist/main.js.map +1 -0
  69. package/dist/output.d.ts +38 -0
  70. package/dist/output.d.ts.map +1 -0
  71. package/dist/output.js +53 -0
  72. package/dist/output.js.map +1 -0
  73. package/dist/outputs.d.ts +17 -0
  74. package/dist/outputs.d.ts.map +1 -0
  75. package/dist/outputs.js +70 -0
  76. package/dist/outputs.js.map +1 -0
  77. package/dist/reports.d.ts +16 -0
  78. package/dist/reports.d.ts.map +1 -0
  79. package/dist/reports.js +89 -0
  80. package/dist/reports.js.map +1 -0
  81. package/dist/repository.d.ts +56 -0
  82. package/dist/repository.d.ts.map +1 -0
  83. package/dist/repository.js +191 -0
  84. package/dist/repository.js.map +1 -0
  85. package/dist/session.d.ts +61 -0
  86. package/dist/session.d.ts.map +1 -0
  87. package/dist/session.js +202 -0
  88. package/dist/session.js.map +1 -0
  89. package/dist/tree.d.ts +15 -0
  90. package/dist/tree.d.ts.map +1 -0
  91. package/dist/tree.js +56 -0
  92. package/dist/tree.js.map +1 -0
  93. package/package.json +46 -0
  94. package/src/commands/architecture.ts +77 -0
  95. package/src/commands/context.ts +142 -0
  96. package/src/commands/evidence.ts +180 -0
  97. package/src/commands/graph.ts +89 -0
  98. package/src/commands/impact.ts +97 -0
  99. package/src/commands/init.ts +138 -0
  100. package/src/commands/mcp.ts +143 -0
  101. package/src/commands/publish.ts +119 -0
  102. package/src/commands/review.ts +155 -0
  103. package/src/commands/serve.ts +261 -0
  104. package/src/commands/shared.ts +46 -0
  105. package/src/commands/validate.ts +39 -0
  106. package/src/git.ts +81 -0
  107. package/src/glob.ts +58 -0
  108. package/src/index.ts +8 -0
  109. package/src/intentset.ts +15 -0
  110. package/src/main.ts +408 -0
  111. package/src/output.ts +78 -0
  112. package/src/outputs.ts +65 -0
  113. package/src/reports.ts +103 -0
  114. package/src/repository.ts +220 -0
  115. package/src/session.ts +276 -0
  116. package/src/tree.ts +64 -0
package/src/git.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The git reads `review` needs: a base to compare with and the files changed
3
+ * since it. Every call is a read and passes --no-optional-locks, so not even
4
+ * git's own index refresh is written (invariant 7). Paths come back relative
5
+ * to the repository root the CLI is working in, which may be a subdirectory
6
+ * of the git work tree.
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+ import { compareStrings } from "@intentset/core";
10
+
11
+ function git(root: string, args: string[]): string | null {
12
+ try {
13
+ return execFileSync("git", ["--no-optional-locks", ...args], {
14
+ cwd: root,
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "pipe"],
17
+ maxBuffer: 256 * 1024 * 1024,
18
+ });
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function paths(out: string | null): string[] {
25
+ return out === null ? [] : out.split("\0").filter((path) => path !== "");
26
+ }
27
+
28
+ export interface Base {
29
+ /** How the base was chosen, for the report: the ref given, "merge-base with main", or "HEAD~1". */
30
+ ref: string;
31
+ commit: string;
32
+ }
33
+
34
+ export interface Changes {
35
+ base: Base | null;
36
+ /** Why there is no base, when there is none. */
37
+ baseUnavailable?: string;
38
+ /** Changed between the base and HEAD, sorted. */
39
+ committed: string[];
40
+ /** Changed in the working tree or index against HEAD, and untracked files .gitignore does not exclude, sorted. */
41
+ uncommitted: string[];
42
+ }
43
+
44
+ /** The commit a ref names, or null. */
45
+ export function resolveCommit(root: string, ref: string): string | null {
46
+ const out = git(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
47
+ return out === null || out.trim() === "" ? null : out.trim();
48
+ }
49
+
50
+ /**
51
+ * The base: `given` when there is one (null when it names no commit), else
52
+ * the merge-base with main when that is not HEAD itself, else HEAD~1.
53
+ */
54
+ export function chooseBase(root: string, given: string | undefined): Base | null | { unavailable: string } {
55
+ if (given !== undefined) {
56
+ const commit = resolveCommit(root, given);
57
+ return commit === null ? null : { ref: given, commit };
58
+ }
59
+ const head = resolveCommit(root, "HEAD");
60
+ if (head === null) return { unavailable: "the repository has no commit at HEAD" };
61
+ const mergeBase = git(root, ["merge-base", "HEAD", "main"])?.trim();
62
+ if (mergeBase !== undefined && mergeBase !== "" && mergeBase !== head) {
63
+ return { ref: "merge-base with main", commit: mergeBase };
64
+ }
65
+ const parent = resolveCommit(root, "HEAD~1");
66
+ if (parent !== null) return { ref: "HEAD~1", commit: parent };
67
+ return { unavailable: "HEAD has no parent and no merge-base with main differs from it" };
68
+ }
69
+
70
+ export function changedFiles(root: string, base: Base | null): Omit<Changes, "base" | "baseUnavailable"> {
71
+ const committed =
72
+ base === null ? [] : paths(git(root, ["diff", "--relative", "--name-only", "-z", `${base.commit}...HEAD`]));
73
+ const uncommitted = [
74
+ ...paths(git(root, ["diff", "--relative", "--name-only", "-z", "HEAD"])),
75
+ ...paths(git(root, ["ls-files", "-z", "--others", "--exclude-standard"])),
76
+ ];
77
+ return {
78
+ committed: [...new Set(committed)].sort(compareStrings),
79
+ uncommitted: [...new Set(uncommitted)].sort(compareStrings),
80
+ };
81
+ }
package/src/glob.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The pattern language of `.intentset/config.yaml` scope and ignore lists,
3
+ * which is VSA §3's: repository-relative POSIX paths made of literal segments,
4
+ * `*` within one segment, and `**` across zero or more segments. Nothing else
5
+ * is special: no `?`, no classes, no braces, no negation, so there is no
6
+ * precedence to explain. A pattern and a path match segment by segment, and
7
+ * `*` never crosses a `/`.
8
+ *
9
+ * The architecture package has its own copy for claims. Duplicating thirty
10
+ * lines keeps the CLI's core commands independent of it.
11
+ */
12
+
13
+ /** VSA §3: true when the repository-relative POSIX `path` matches `pattern`. */
14
+ export function matchGlob(pattern: string, path: string): boolean {
15
+ const p = normalize(pattern).split("/");
16
+ const s = path.split("/");
17
+ // Memo of (pattern index, path index) pairs already known not to match, so
18
+ // several `**` in one pattern stay polynomial rather than exponential.
19
+ const failed = new Set<number>();
20
+ const width = s.length + 1;
21
+ const match = (i: number, j: number): boolean => {
22
+ if (failed.has(i * width + j)) return false;
23
+ let ok: boolean;
24
+ if (i === p.length) ok = j === s.length;
25
+ else if (p[i] === "**") ok = match(i + 1, j) || (j < s.length && match(i, j + 1));
26
+ else ok = j < s.length && matchSegment(p[i], s[j]) && match(i + 1, j + 1);
27
+ if (!ok) failed.add(i * width + j);
28
+ return ok;
29
+ };
30
+ return match(0, 0);
31
+ }
32
+
33
+ /** True when any of `patterns` matches `path`. */
34
+ export function matchAny(patterns: readonly string[], path: string): boolean {
35
+ return patterns.some((pattern) => matchGlob(pattern, path));
36
+ }
37
+
38
+ /** A leading `./` names the same path; repeated `/` separate nothing. */
39
+ function normalize(pattern: string): string {
40
+ return pattern.replace(/^(?:\.\/)+/, "").replace(/\/{2,}/g, "/");
41
+ }
42
+
43
+ const SEGMENTS = new Map<string, RegExp>();
44
+
45
+ /** One segment: literal text, with each `*` standing for any run of characters other than `/`. */
46
+ function matchSegment(pattern: string, name: string): boolean {
47
+ if (!pattern.includes("*")) return pattern === name;
48
+ let regex = SEGMENTS.get(pattern);
49
+ if (regex === undefined) {
50
+ const body = pattern
51
+ .split("*")
52
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
53
+ .join("[^/]*");
54
+ regex = new RegExp(`^${body}$`);
55
+ SEGMENTS.set(pattern, regex);
56
+ }
57
+ return regex.test(name);
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @intentset/cli as a library: `main` for running the command in-process,
3
+ * and the repository loader and pattern matcher it is built on.
4
+ */
5
+ export { main, USAGE } from "./main.ts";
6
+ export type { Io } from "./output.ts";
7
+ export { matchAny, matchGlob } from "./glob.ts";
8
+ export { CONFIG_PATH, enumerate, findRoot, loadRepository, readCommit, type Repository } from "./repository.ts";
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./main.ts";
3
+
4
+ // The first SIGINT or SIGTERM asks a long-running command (serve, mcp) to stop
5
+ // cleanly; the listener is removed as it fires, so a second one terminates.
6
+ const controller = new AbortController();
7
+ for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => controller.abort());
8
+
9
+ const code = await main(process.argv.slice(2), {
10
+ stdout: (s) => process.stdout.write(s),
11
+ stderr: (s) => process.stderr.write(s),
12
+ cwd: process.cwd(),
13
+ signal: controller.signal,
14
+ });
15
+ process.exitCode = code;
package/src/main.ts ADDED
@@ -0,0 +1,408 @@
1
+ /**
2
+ * The `intentset` command: argument parsing, dispatch and exit codes, with
3
+ * every command's work in commands/. `main` returns the exit code rather than
4
+ * exiting, so tests run it in-process with captured io.
5
+ *
6
+ * Exit codes (Core §11, ADR 0004): 0 no errors, 1 validation errors, 2
7
+ * invocation or tool failure. validate, graph, architecture check, impact,
8
+ * context, review, serve and mcp never write; graph --out, architecture
9
+ * check --write-baseline, evidence import --out, publish --out and serve
10
+ * --out write only what they name; init only creates.
11
+ */
12
+ import { parseArgs } from "node:util";
13
+ import type { Mode } from "@intentset/architecture";
14
+ import { LEVELS, type Level } from "@intentset/core";
15
+ import type { RunScope } from "@intentset/verification";
16
+ import { architectureCommand } from "./commands/architecture.ts";
17
+ import { contextCommand } from "./commands/context.ts";
18
+ import { evidenceImportCommand, evidenceUsageProblem } from "./commands/evidence.ts";
19
+ import { graphCommand, graphUsageProblem, parseRelease } from "./commands/graph.ts";
20
+ import { impactCommand } from "./commands/impact.ts";
21
+ import { initCommand } from "./commands/init.ts";
22
+ import { mcpCommand, mcpUsageProblem } from "./commands/mcp.ts";
23
+ import { publishCommand, publishUsageProblem } from "./commands/publish.ts";
24
+ import { reviewCommand } from "./commands/review.ts";
25
+ import { serveCommand } from "./commands/serve.ts";
26
+ import { validateCommand } from "./commands/validate.ts";
27
+ import type { Io } from "./output.ts";
28
+ import { type CarrierName, CARRIERS, findRoot } from "./repository.ts";
29
+ import { EVIDENCE_DIR, evidenceFiles, openSession, type SessionOptions } from "./session.ts";
30
+
31
+ export type { Io } from "./output.ts";
32
+
33
+ export const USAGE = `usage: intentset <command> [options]
34
+
35
+ commands
36
+ init [--repository <name>] [--example]
37
+ create .intentset/config.yaml and an empty .intentset/registries.yaml;
38
+ --example adds the scheduling example under product/scheduling/.
39
+ Never overwrites a file.
40
+ validate every check the level asks for; exit 1 when any diagnostic is an error
41
+ graph [--format json] [--include-bodies] [--release <product>:<label>] [--out <file>]
42
+ [--generated-at <time>]
43
+ print the intentset/export/0.1 envelope, or write it to --out
44
+ architecture check [--mode migration|strict] [--baseline <file>] [--write-baseline <file>]
45
+ ownership, claims, imports, layers and regions (default level L2)
46
+ evidence import --from vitest|node-tap <report> --out <file> --product <ID> --release <label>
47
+ [--environment <text>] [--started-at <time>] [--finished-at <time>] [--uri-base <uri>]
48
+ [--tool-version <v>]
49
+ turn a test report into run records bound to this commit and graph
50
+ impact <ID> what depends on an artifact: direct, candidates, review context, ancestors
51
+ context <ID> [--include-restricted]
52
+ the bounded engineering context of an artifact, with paths and bodies
53
+ review [--base <ref>] the local review report: checks, coverage, and the impact of what changed
54
+ publish --visibility <v> --audience <a> --product <ID> --release <r> --role <r> --edition <e>
55
+ [--flag <f>]... [--authorized-internal] [--authorized-restricted] --out <dir> [--html]
56
+ [--published-at <time>]
57
+ reviewed knowledge for one audience and release, as Markset with provenance
58
+ serve [--port <n>] [--host <address>] [--out <dir>]
59
+ the Atlas, the internal review pages, on http://127.0.0.1:3000/ (rebuilt
60
+ when a file changes), or written to --out; runs at L2, or L3 with run records
61
+ mcp --mode engineering [--include-restricted]
62
+ mcp --mode customer --visibility <public|customer> --audience <a> --product <ID> --release <r>
63
+ --role <r> --edition <e> [--flag <f>]...
64
+ the read-only MCP server on stdio: the engineering graph, or one
65
+ publication made in memory as publish makes it, and nothing else
66
+
67
+ options
68
+ --root <dir> repository root (default: the nearest directory at or above this one
69
+ holding .intentset/config.yaml)
70
+ --json machine-readable output, keys in canonical order
71
+ --carrier <markset|plain> how documents are read (default markset)
72
+ --level <L1..L4> conformance level; each includes those below it (default L1):
73
+ L2 adds architecture, L3 evidence, L4 publication readiness
74
+ --evidence <file> L3 and above: run records to read, repeatable (default: every *.json in
75
+ ${EVIDENCE_DIR}/, which holds local output and is not committed)
76
+ --product <ID> --release <label>
77
+ L3 and above: the exact scope evidence is assessed for (default: every scope)
78
+ --mode <migration|strict> --baseline <file>
79
+ L2 and above: whether a baseline of known violations applies, and which
80
+ (for mcp, --mode names the server's mode instead)
81
+ -h, --help show this help
82
+
83
+ exit codes: 0 no errors, 1 validation errors, 2 invocation or tool failure`;
84
+
85
+ const COMMANDS = [
86
+ "init",
87
+ "validate",
88
+ "graph",
89
+ "architecture",
90
+ "evidence",
91
+ "impact",
92
+ "context",
93
+ "review",
94
+ "publish",
95
+ "serve",
96
+ "mcp",
97
+ ];
98
+
99
+ const GLOBAL = ["root", "json", "carrier", "level", "help"];
100
+ const LEVEL_OPTIONS = ["evidence", "product", "release", "mode", "baseline"];
101
+ const COMMAND_OPTIONS: Record<string, string[]> = {
102
+ init: ["repository", "example"],
103
+ validate: LEVEL_OPTIONS,
104
+ graph: ["format", "include-bodies", "release", "out", "generated-at", "evidence", "mode", "baseline"],
105
+ architecture: ["mode", "baseline", "write-baseline"],
106
+ evidence: [
107
+ "from",
108
+ "out",
109
+ "product",
110
+ "release",
111
+ "environment",
112
+ "started-at",
113
+ "finished-at",
114
+ "uri-base",
115
+ "tool-version",
116
+ ],
117
+ impact: LEVEL_OPTIONS,
118
+ context: ["include-restricted", ...LEVEL_OPTIONS],
119
+ review: ["base", ...LEVEL_OPTIONS],
120
+ serve: ["port", "host", "out", ...LEVEL_OPTIONS],
121
+ mcp: [
122
+ "mode",
123
+ "include-restricted",
124
+ "visibility",
125
+ "audience",
126
+ "product",
127
+ "release",
128
+ "role",
129
+ "edition",
130
+ "flag",
131
+ "authorized-internal",
132
+ "authorized-restricted",
133
+ "published-at",
134
+ ],
135
+ publish: [
136
+ "visibility",
137
+ "audience",
138
+ "product",
139
+ "release",
140
+ "role",
141
+ "edition",
142
+ "flag",
143
+ "authorized-internal",
144
+ "authorized-restricted",
145
+ "out",
146
+ "html",
147
+ "published-at",
148
+ ],
149
+ };
150
+
151
+ /** Positional arguments after the command: a fixed subcommand, then how many more. */
152
+ const POSITIONALS: Record<string, { sub?: string; count: number; what?: string }> = {
153
+ architecture: { sub: "check", count: 0 },
154
+ evidence: { sub: "import", count: 1, what: "a report file" },
155
+ impact: { count: 1, what: "an artifact ID" },
156
+ context: { count: 1, what: "an artifact ID" },
157
+ };
158
+
159
+ export async function main(argv: string[], io: Io): Promise<number> {
160
+ let parsed: ReturnType<typeof parse>;
161
+ try {
162
+ parsed = parse(argv);
163
+ } catch (error) {
164
+ io.stderr(`intentset: ${(error as Error).message}\n\n${USAGE}\n`);
165
+ return 2;
166
+ }
167
+ const { values, positionals, tokens } = parsed;
168
+ const [command, ...rest] = positionals;
169
+ if (values.help || command === undefined) {
170
+ io.stdout(`${USAGE}\n`);
171
+ return values.help ? 0 : 2;
172
+ }
173
+ // The command is checked before its arguments, so a typo reads as a typo and not as a missing ID.
174
+ if (!COMMANDS.includes(command)) {
175
+ io.stderr(`intentset: unknown command "${command}"\n\n${USAGE}\n`);
176
+ return 2;
177
+ }
178
+ const usage = (message: string) => {
179
+ io.stderr(`intentset ${command}: ${message}\n`);
180
+ return 2;
181
+ };
182
+ const allowed = [...GLOBAL, ...(COMMAND_OPTIONS[command] ?? [])];
183
+ for (const token of tokens) {
184
+ if (token.kind === "option" && !allowed.includes(token.name)) {
185
+ return usage(`--${token.name} is not an option of ${command}`);
186
+ }
187
+ }
188
+
189
+ const shape = POSITIONALS[command] ?? { count: 0 };
190
+ let args = rest;
191
+ if (shape.sub !== undefined) {
192
+ if (args[0] !== shape.sub) return usage(`the only subcommand is "${command} ${shape.sub}"`);
193
+ args = args.slice(1);
194
+ }
195
+ if (args.length < shape.count) return usage(`${shape.what} is required`);
196
+ if (args.length > shape.count) {
197
+ const hint = shape.count === 0 ? " (use --root <dir> to name the repository)" : "";
198
+ return usage(`unexpected argument "${args[shape.count]}"${hint}`);
199
+ }
200
+
201
+ const carrier = values.carrier ?? "markset";
202
+ if (!Object.hasOwn(CARRIERS, carrier)) {
203
+ return usage(`--carrier ${carrier}: the carriers are ${Object.keys(CARRIERS).join(" and ")}`);
204
+ }
205
+ const levelGiven = values.level as Level | undefined;
206
+ if (levelGiven !== undefined && !LEVELS.includes(levelGiven)) {
207
+ return usage(`--level ${levelGiven}: the levels are ${LEVELS.join(", ")}`);
208
+ }
209
+ if (levelGiven === "L5") {
210
+ return usage("L5 is a claim about continuous CI, not something one run can check (Core §11).");
211
+ }
212
+ if (command !== "mcp" && values.mode !== undefined && values.mode !== "migration" && values.mode !== "strict") {
213
+ return usage(`--mode ${values.mode}: the modes are migration and strict`);
214
+ }
215
+ if (command === "init") {
216
+ return initCommand({ root: values.root, repository: values.repository, example: values.example }, io);
217
+ }
218
+
219
+ // Evidence scope: --product with --release, or graph's --release <product>:<label>.
220
+ let scope: RunScope | null = null;
221
+ if (command !== "evidence" && command !== "publish" && command !== "mcp") {
222
+ if (command === "graph") {
223
+ const release = values.release === undefined ? null : parseRelease(values.release);
224
+ scope = release === null ? null : { product: release.product, release: release.label };
225
+ } else if ((values.product === undefined) !== (values.release === undefined)) {
226
+ return usage("--product and --release name one scope together; give both or neither");
227
+ } else if (values.product !== undefined && values.release !== undefined) {
228
+ scope = { product: values.product, release: values.release };
229
+ }
230
+ }
231
+
232
+ const sessionOptions = (level: Level): SessionOptions => ({
233
+ root: values.root,
234
+ carrier: carrier as CarrierName,
235
+ level,
236
+ mode: command === "mcp" ? undefined : (values.mode as Mode | undefined),
237
+ baseline: values.baseline,
238
+ evidence: values.evidence,
239
+ scope,
240
+ });
241
+
242
+ switch (command) {
243
+ case "validate": {
244
+ const session = openSession(command, sessionOptions(levelGiven ?? "L1"), io);
245
+ return typeof session === "number" ? session : validateCommand(session, values.json, io);
246
+ }
247
+ case "graph": {
248
+ const options = {
249
+ format: values.format,
250
+ includeBodies: values["include-bodies"],
251
+ release: values.release,
252
+ out: values.out,
253
+ generatedAt: values["generated-at"],
254
+ };
255
+ const problem = graphUsageProblem(options);
256
+ if (problem !== null) return usage(problem);
257
+ const session = openSession(command, sessionOptions(levelGiven ?? "L1"), io);
258
+ return typeof session === "number" ? session : graphCommand(session, options, io);
259
+ }
260
+ case "architecture": {
261
+ if (levelGiven === "L1") return usage("the architecture check runs at L2 and above, where ownership joins");
262
+ const session = openSession("architecture check", sessionOptions(levelGiven ?? "L2"), io);
263
+ if (typeof session === "number") return session;
264
+ return architectureCommand(session, { writeBaseline: values["write-baseline"] }, values.json, io);
265
+ }
266
+ case "evidence": {
267
+ const options = {
268
+ from: values.from,
269
+ report: args[0],
270
+ out: values.out,
271
+ product: values.product,
272
+ release: values.release,
273
+ environment: values.environment,
274
+ startedAt: values["started-at"],
275
+ finishedAt: values["finished-at"],
276
+ uriBase: values["uri-base"],
277
+ toolVersion: values["tool-version"],
278
+ };
279
+ const problem = evidenceUsageProblem(options);
280
+ if (problem !== null) return usage(problem);
281
+ const session = openSession("evidence import", sessionOptions(levelGiven ?? "L1"), io);
282
+ return typeof session === "number" ? session : evidenceImportCommand(session, options, io);
283
+ }
284
+ case "impact": {
285
+ const session = openSession(command, sessionOptions(levelGiven ?? "L1"), io);
286
+ return typeof session === "number" ? session : impactCommand(session, args[0], values.json, io);
287
+ }
288
+ case "context": {
289
+ const session = openSession(command, sessionOptions(levelGiven ?? "L1"), io);
290
+ if (typeof session === "number") return session;
291
+ return contextCommand(session, args[0], values["include-restricted"], values.json, io);
292
+ }
293
+ case "review": {
294
+ let level = levelGiven;
295
+ let reason = "as asked";
296
+ if (level === undefined) {
297
+ const root = findRoot(io.cwd, values.root);
298
+ const records = root === null ? [] : evidenceFiles(root, io.cwd, values.evidence);
299
+ level = records.length > 0 ? "L4" : "L2";
300
+ reason =
301
+ records.length > 0
302
+ ? "the highest the inputs allow: run records were given or found"
303
+ : `the highest the inputs allow: no run records were given or found in ${EVIDENCE_DIR}/, so L3 and L4 were not checked`;
304
+ }
305
+ const session = openSession(command, sessionOptions(level), io);
306
+ if (typeof session === "number") return session;
307
+ return reviewCommand(session, { base: values.base, levelReason: reason }, values.json, io);
308
+ }
309
+ case "serve": {
310
+ const port = values.port === undefined ? 3000 : Number(values.port);
311
+ if (!Number.isInteger(port) || port < 0 || port > 65535 || values.port === "") {
312
+ return usage(`--port ${values.port}: give a port number from 0 to 65535`);
313
+ }
314
+ let level = levelGiven;
315
+ if (level === undefined) {
316
+ const root = findRoot(io.cwd, values.root);
317
+ level = root !== null && evidenceFiles(root, io.cwd, values.evidence).length > 0 ? "L3" : "L2";
318
+ }
319
+ return serveCommand({ port, host: values.host ?? "127.0.0.1", out: values.out }, sessionOptions(level), io);
320
+ }
321
+ case "mcp": {
322
+ const options = {
323
+ mode: values.mode,
324
+ includeRestricted: values["include-restricted"],
325
+ visibility: values.visibility,
326
+ audience: values.audience,
327
+ product: values.product,
328
+ release: values.release,
329
+ role: values.role,
330
+ edition: values.edition,
331
+ flags: values.flag ?? [],
332
+ authorizedInternal: values["authorized-internal"],
333
+ authorizedRestricted: values["authorized-restricted"],
334
+ publishedAt: values["published-at"],
335
+ };
336
+ const problem = mcpUsageProblem(options);
337
+ if (problem !== null) return usage(problem);
338
+ return mcpCommand(options, sessionOptions(levelGiven ?? "L1"), io);
339
+ }
340
+ default: {
341
+ const options = {
342
+ visibility: values.visibility,
343
+ audience: values.audience,
344
+ product: values.product,
345
+ release: values.release,
346
+ role: values.role,
347
+ edition: values.edition,
348
+ flags: values.flag ?? [],
349
+ authorizedInternal: values["authorized-internal"],
350
+ authorizedRestricted: values["authorized-restricted"],
351
+ out: values.out,
352
+ html: values.html,
353
+ publishedAt: values["published-at"],
354
+ };
355
+ const problem = publishUsageProblem(options);
356
+ if (problem !== null) return usage(problem);
357
+ const session = openSession(command, sessionOptions(levelGiven ?? "L1"), io);
358
+ return typeof session === "number" ? session : publishCommand(session, options, io);
359
+ }
360
+ }
361
+ }
362
+
363
+ function parse(argv: string[]) {
364
+ return parseArgs({
365
+ args: argv,
366
+ allowPositionals: true,
367
+ strict: true,
368
+ tokens: true,
369
+ options: {
370
+ root: { type: "string" },
371
+ json: { type: "boolean", default: false },
372
+ carrier: { type: "string" },
373
+ level: { type: "string" },
374
+ help: { type: "boolean", short: "h", default: false },
375
+ repository: { type: "string" },
376
+ example: { type: "boolean", default: false },
377
+ format: { type: "string" },
378
+ "include-bodies": { type: "boolean", default: false },
379
+ release: { type: "string" },
380
+ out: { type: "string" },
381
+ "generated-at": { type: "string" },
382
+ "include-restricted": { type: "boolean", default: false },
383
+ evidence: { type: "string", multiple: true },
384
+ product: { type: "string" },
385
+ mode: { type: "string" },
386
+ baseline: { type: "string" },
387
+ "write-baseline": { type: "string" },
388
+ from: { type: "string" },
389
+ environment: { type: "string" },
390
+ "started-at": { type: "string" },
391
+ "finished-at": { type: "string" },
392
+ "uri-base": { type: "string" },
393
+ "tool-version": { type: "string" },
394
+ base: { type: "string" },
395
+ visibility: { type: "string" },
396
+ audience: { type: "string" },
397
+ role: { type: "string" },
398
+ edition: { type: "string" },
399
+ flag: { type: "string", multiple: true },
400
+ "authorized-internal": { type: "boolean", default: false },
401
+ "authorized-restricted": { type: "boolean", default: false },
402
+ html: { type: "boolean", default: false },
403
+ "published-at": { type: "string" },
404
+ port: { type: "string" },
405
+ host: { type: "string" },
406
+ },
407
+ });
408
+ }
package/src/output.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What every command shares about output: the io it writes through, the
3
+ * tool's identity, JSON in canonical key order, and the one text form of a
4
+ * diagnostic. Nothing here touches the filesystem except reading this
5
+ * package's own manifest for its version.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { type Diagnostic, sortKeysDeep } from "@intentset/core";
9
+
10
+ export interface Io {
11
+ stdout(s: string): void;
12
+ stderr(s: string): void;
13
+ /** The directory the command was run from; relative paths in arguments resolve against it. */
14
+ cwd: string;
15
+ /** Aborted to stop a long-running command (serve, mcp); the bin aborts it on SIGINT and SIGTERM. */
16
+ signal?: AbortSignal;
17
+ }
18
+
19
+ /** The checker identity a report carries (Core §11: publish the checker version). */
20
+ export const TOOL: { name: "intentset"; version: string } = { name: "intentset", version: readVersion() };
21
+
22
+ function readVersion(): string {
23
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
24
+ version: string;
25
+ };
26
+ return manifest.version;
27
+ }
28
+
29
+ /** ADR 0005: JSON with every object's keys sorted, two-space indent, one trailing newline. */
30
+ export function json(value: unknown): string {
31
+ return `${JSON.stringify(sortKeysDeep(value), null, 2)}\n`;
32
+ }
33
+
34
+ /** Core §9 and ADR 0004: origin "syntax" is Markset's alone, so the origin decides. */
35
+ export function isMarkset(d: Diagnostic): boolean {
36
+ return d.origin === "syntax";
37
+ }
38
+
39
+ /**
40
+ * ADR 0004 as text: `<path>:<line>:<col> <severity> <CODE> [<artifact>] <message>`,
41
+ * then the remediation and the field on their own indented lines. A location
42
+ * that is not known is left out rather than written as 0. Markset's
43
+ * diagnostics are prefixed `markset:` so they read as Markset's (Core §9).
44
+ */
45
+ export function formatDiagnostic(d: Diagnostic): string {
46
+ const where = d.path ?? "(repository)";
47
+ const line = d.location === undefined ? "" : `:${d.location.line}`;
48
+ const column = d.location?.column === undefined ? "" : `:${d.location.column}`;
49
+ const artifact = d.artifact === null ? "" : ` [${d.artifact}]`;
50
+ const prefix = isMarkset(d) ? "markset: " : "";
51
+ let text = `${prefix}${where}${line}${column} ${d.severity} ${d.code}${artifact} ${d.message}\n`;
52
+ if (d.remediation !== "") text += ` fix: ${d.remediation}\n`;
53
+ if (d.field !== undefined) text += ` field: ${d.field}\n`;
54
+ return text;
55
+ }
56
+
57
+ /** "1 artifact", "2 artifacts"; "1 entry", "2 entries" with the plural given. */
58
+ export function count(n: number, noun: string, plural = `${noun}s`): string {
59
+ return `${n} ${n === 1 ? noun : plural}`;
60
+ }
61
+
62
+ /** The snapshot a report describes (Core §10, §11): the commit, or why there is none, and the graph hash. */
63
+ export interface Snapshot {
64
+ commit: string | null;
65
+ commitUnavailable?: string;
66
+ /** True when tracked files differ from the commit; the graph hash still describes exactly what was read. */
67
+ uncommitted?: boolean;
68
+ graphHash: string;
69
+ }
70
+
71
+ /** One line naming the snapshot, for the top of a text report. */
72
+ export function snapshotLine(snapshot: Snapshot): string {
73
+ const commit =
74
+ snapshot.commit === null
75
+ ? `no commit (${snapshot.commitUnavailable ?? "none was read"})`
76
+ : `commit ${snapshot.commit}${snapshot.uncommitted === true ? " plus uncommitted changes" : ""}`;
77
+ return `Snapshot: ${commit}, graph ${snapshot.graphHash}`;
78
+ }