@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.
- package/dist/commands/architecture.d.ts +6 -0
- package/dist/commands/architecture.d.ts.map +1 -0
- package/dist/commands/architecture.js +65 -0
- package/dist/commands/architecture.js.map +1 -0
- package/dist/commands/context.d.ts +4 -0
- package/dist/commands/context.d.ts.map +1 -0
- package/dist/commands/context.js +124 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/evidence.d.ts +29 -0
- package/dist/commands/evidence.d.ts.map +1 -0
- package/dist/commands/evidence.js +157 -0
- package/dist/commands/evidence.js.map +1 -0
- package/dist/commands/graph.d.ts +18 -0
- package/dist/commands/graph.d.ts.map +1 -0
- package/dist/commands/graph.js +77 -0
- package/dist/commands/graph.js.map +1 -0
- package/dist/commands/impact.d.ts +20 -0
- package/dist/commands/impact.d.ts.map +1 -0
- package/dist/commands/impact.js +92 -0
- package/dist/commands/impact.js.map +1 -0
- package/dist/commands/init.d.ts +18 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +122 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/mcp.d.ts +24 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +122 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/publish.d.ts +19 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +98 -0
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/review.d.ts +29 -0
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/commands/review.js +147 -0
- package/dist/commands/review.js.map +1 -0
- package/dist/commands/serve.d.ts +14 -0
- package/dist/commands/serve.d.ts.map +1 -0
- package/dist/commands/serve.js +246 -0
- package/dist/commands/serve.js.map +1 -0
- package/dist/commands/shared.d.ts +19 -0
- package/dist/commands/shared.d.ts.map +1 -0
- package/dist/commands/shared.js +43 -0
- package/dist/commands/shared.js.map +1 -0
- package/dist/commands/validate.d.ts +11 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +38 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/git.d.ts +25 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +63 -0
- package/dist/git.js.map +1 -0
- package/dist/glob.d.ts +16 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +60 -0
- package/dist/glob.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/intentset.d.ts +3 -0
- package/dist/intentset.d.ts.map +1 -0
- package/dist/intentset.js +15 -0
- package/dist/intentset.js.map +1 -0
- package/dist/main.d.ts +5 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +406 -0
- package/dist/main.js.map +1 -0
- package/dist/output.d.ts +38 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +53 -0
- package/dist/output.js.map +1 -0
- package/dist/outputs.d.ts +17 -0
- package/dist/outputs.d.ts.map +1 -0
- package/dist/outputs.js +70 -0
- package/dist/outputs.js.map +1 -0
- package/dist/reports.d.ts +16 -0
- package/dist/reports.d.ts.map +1 -0
- package/dist/reports.js +89 -0
- package/dist/reports.js.map +1 -0
- package/dist/repository.d.ts +56 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +191 -0
- package/dist/repository.js.map +1 -0
- package/dist/session.d.ts +61 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +202 -0
- package/dist/session.js.map +1 -0
- package/dist/tree.d.ts +15 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +56 -0
- package/dist/tree.js.map +1 -0
- package/package.json +46 -0
- package/src/commands/architecture.ts +77 -0
- package/src/commands/context.ts +142 -0
- package/src/commands/evidence.ts +180 -0
- package/src/commands/graph.ts +89 -0
- package/src/commands/impact.ts +97 -0
- package/src/commands/init.ts +138 -0
- package/src/commands/mcp.ts +143 -0
- package/src/commands/publish.ts +119 -0
- package/src/commands/review.ts +155 -0
- package/src/commands/serve.ts +261 -0
- package/src/commands/shared.ts +46 -0
- package/src/commands/validate.ts +39 -0
- package/src/git.ts +81 -0
- package/src/glob.ts +58 -0
- package/src/index.ts +8 -0
- package/src/intentset.ts +15 -0
- package/src/main.ts +408 -0
- package/src/output.ts +78 -0
- package/src/outputs.ts +65 -0
- package/src/reports.ts +103 -0
- package/src/repository.ts +220 -0
- package/src/session.ts +276 -0
- package/src/tree.ts +64 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intentset evidence import` (Core §8): a test runner's report turned into
|
|
3
|
+
* run records bound to this repository's snapshot. A verification's
|
|
4
|
+
* `selector` is matched against each test's full title, and every
|
|
5
|
+
* verification whose selector no test matched is listed, since it gets no
|
|
6
|
+
* record from this run and reads as missing. Evidence must name the commit
|
|
7
|
+
* it ran at, so an unknown commit refuses. Writes the one file `--out` names,
|
|
8
|
+
* and never over an existing one: earlier records are history.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { compareStrings } from "@intentset/core";
|
|
14
|
+
import { type ParsedRun, isUtcTimestamp, parseNodeTap, parseVitestJson, toRunRecords } from "@intentset/verification";
|
|
15
|
+
import { count, type Io } from "../output.ts";
|
|
16
|
+
import { outputProblem } from "../outputs.ts";
|
|
17
|
+
import type { Session } from "../session.ts";
|
|
18
|
+
|
|
19
|
+
export const REPORTERS = {
|
|
20
|
+
vitest: { tool: "vitest", parse: (text: string) => parseVitestJson(JSON.parse(text)) },
|
|
21
|
+
"node-tap": { tool: "node:test", parse: (text: string) => parseNodeTap(text) },
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export interface EvidenceImportOptions {
|
|
25
|
+
from?: string;
|
|
26
|
+
report?: string;
|
|
27
|
+
out?: string;
|
|
28
|
+
product?: string;
|
|
29
|
+
release?: string;
|
|
30
|
+
environment?: string;
|
|
31
|
+
startedAt?: string;
|
|
32
|
+
finishedAt?: string;
|
|
33
|
+
uriBase?: string;
|
|
34
|
+
toolVersion?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
|
|
38
|
+
|
|
39
|
+
/** Problems that need no repository, so a typo costs nothing. */
|
|
40
|
+
export function evidenceUsageProblem(options: EvidenceImportOptions): string | null {
|
|
41
|
+
if (options.from === undefined || !Object.hasOwn(REPORTERS, options.from)) {
|
|
42
|
+
return `--from ${options.from ?? "(missing)"}: the reporters are ${Object.keys(REPORTERS).join(" and ")}`;
|
|
43
|
+
}
|
|
44
|
+
if (options.report === undefined) return "a report file is required: evidence import --from <reporter> <report>";
|
|
45
|
+
for (const [flag, value] of [
|
|
46
|
+
["--out", options.out],
|
|
47
|
+
["--product", options.product],
|
|
48
|
+
["--release", options.release],
|
|
49
|
+
] as const) {
|
|
50
|
+
if (value === undefined || value === "") return `${flag} is required`;
|
|
51
|
+
}
|
|
52
|
+
for (const [flag, value] of [
|
|
53
|
+
["--started-at", options.startedAt],
|
|
54
|
+
["--finished-at", options.finishedAt],
|
|
55
|
+
] as const) {
|
|
56
|
+
if (value !== undefined && !isUtcTimestamp(value)) {
|
|
57
|
+
return `${flag} ${value}: give a UTC time such as 2026-10-02T12:00:00Z`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (options.uriBase !== undefined && !URI.test(options.uriBase)) {
|
|
61
|
+
return `--uri-base ${options.uriBase}: give a URI with a scheme, such as https://ci.example/runs/42`;
|
|
62
|
+
}
|
|
63
|
+
if (options.environment !== undefined && options.environment.trim() === "") return "--environment is empty";
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function evidenceImportCommand(session: Session, options: EvidenceImportOptions, io: Io): number {
|
|
68
|
+
const fail = (message: string) => {
|
|
69
|
+
io.stderr(`intentset evidence import: ${message}\n`);
|
|
70
|
+
return 2;
|
|
71
|
+
};
|
|
72
|
+
const { repo, snapshot, result } = session;
|
|
73
|
+
const graph = result.graph;
|
|
74
|
+
const reporter = REPORTERS[options.from as keyof typeof REPORTERS];
|
|
75
|
+
const product = options.product as string;
|
|
76
|
+
const release = options.release as string;
|
|
77
|
+
|
|
78
|
+
if (snapshot.commit === null) {
|
|
79
|
+
return fail(`evidence must name the commit it ran at, and none could be read: ${snapshot.commitUnavailable}`);
|
|
80
|
+
}
|
|
81
|
+
if (snapshot.uncommitted === true) {
|
|
82
|
+
return fail(
|
|
83
|
+
`tracked files differ from commit ${snapshot.commit}, so records bound to it would name a commit the run did not test; commit first (Core §8).`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (graph.artifacts.get(product)?.meta.type !== "product") {
|
|
87
|
+
return fail(`--product ${product} is not a product in this graph.`);
|
|
88
|
+
}
|
|
89
|
+
if (repo.registries.releases.length > 0 && !repo.registries.releases.includes(release)) {
|
|
90
|
+
return fail(`--release ${release} is not a release in the registries; labels match exactly (Core §7).`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const reportFile = resolve(io.cwd, options.report as string);
|
|
94
|
+
let parsed: ParsedRun;
|
|
95
|
+
let modified: string;
|
|
96
|
+
try {
|
|
97
|
+
modified = statSync(reportFile).mtime.toISOString();
|
|
98
|
+
parsed = reporter.parse(readFileSync(reportFile, "utf8"));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return fail(`could not read ${options.report} as ${options.from} output: ${(error as Error).message}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const target = resolve(io.cwd, options.out as string);
|
|
104
|
+
const problem = outputProblem(repo, target);
|
|
105
|
+
if (problem !== null) return fail(`--out ${options.out} ${problem}; nothing was written.`);
|
|
106
|
+
if (existsSync(target)) {
|
|
107
|
+
return fail(`--out ${options.out} exists; run records are history, so import into a new file.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Selector -> verification, from the automated verifications that are not retired, first ID winning a shared selector.
|
|
111
|
+
const verifications = new Map<string, string>();
|
|
112
|
+
const shared: string[] = [];
|
|
113
|
+
for (const id of [...graph.artifacts.keys()].sort(compareStrings)) {
|
|
114
|
+
const meta = graph.artifacts.get(id)?.meta;
|
|
115
|
+
if (meta?.type !== "verification" || meta.status === "retired" || meta.verification?.method !== "automated")
|
|
116
|
+
continue;
|
|
117
|
+
const selector = meta.verification.selector;
|
|
118
|
+
if (verifications.has(selector)) shared.push(`${selector} (${verifications.get(selector)} and ${id})`);
|
|
119
|
+
else verifications.set(selector, id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const startedAt = options.startedAt ?? modified;
|
|
123
|
+
const finishedAt = options.finishedAt ?? modified;
|
|
124
|
+
let converted: ReturnType<typeof toRunRecords>;
|
|
125
|
+
try {
|
|
126
|
+
converted = toRunRecords(parsed, {
|
|
127
|
+
verifications,
|
|
128
|
+
commit: snapshot.commit,
|
|
129
|
+
graphHash: snapshot.graphHash,
|
|
130
|
+
environment: options.environment ?? "local",
|
|
131
|
+
scope: { product, release },
|
|
132
|
+
tool: { name: reporter.tool, version: options.toolVersion ?? "unknown" },
|
|
133
|
+
startedAt,
|
|
134
|
+
finishedAt,
|
|
135
|
+
uriBase: options.uriBase ?? pathToFileURL(reportFile).href,
|
|
136
|
+
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return fail((error as Error).message);
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
142
|
+
writeFileSync(target, `${JSON.stringify(converted.records, null, 2)}\n`, { flag: "wx" });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return fail(`could not write ${options.out}: ${(error as Error).message}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const lines = [
|
|
148
|
+
`Imported ${count(converted.records.length, "run record")} from ${options.report} (${options.from}, ${count(parsed.tests.length, "test")}) into ${options.out}`,
|
|
149
|
+
` bound to commit ${snapshot.commit}, graph ${snapshot.graphHash}, for ${product} ${release}, environment ${options.environment ?? "local"}`,
|
|
150
|
+
];
|
|
151
|
+
if (options.startedAt === undefined || options.finishedAt === undefined) {
|
|
152
|
+
lines.push(
|
|
153
|
+
` timestamps: the report file's modification time, ${modified}, stands in for the run's ` +
|
|
154
|
+
`${options.startedAt === undefined && options.finishedAt === undefined ? "start and finish" : options.startedAt === undefined ? "start" : "finish"}; ` +
|
|
155
|
+
"pass --started-at and --finished-at to record the run's own",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
for (const record of converted.records) {
|
|
159
|
+
lines.push(` ${record.verificationId} ${record.result} ${record.rationale ?? ""}`.trimEnd());
|
|
160
|
+
}
|
|
161
|
+
lines.push(
|
|
162
|
+
`Unmatched selectors (${converted.unmatched.length}): ${converted.unmatched.length === 0 ? "none" : "no test title carries them, so their verifications get no record from this run and read as missing"}`,
|
|
163
|
+
...converted.unmatched.map((selector) => ` - ${selector} (${verifications.get(selector)})`),
|
|
164
|
+
);
|
|
165
|
+
if (shared.length > 0) {
|
|
166
|
+
lines.push(`Selectors shared by two verifications, matched to the first only (${shared.length}):`);
|
|
167
|
+
lines.push(...shared.map((item) => ` - ${item}`));
|
|
168
|
+
}
|
|
169
|
+
if (parsed.problems.length > 0) {
|
|
170
|
+
lines.push(`Problems in the report that no test title carries (${parsed.problems.length}):`);
|
|
171
|
+
lines.push(...parsed.problems.map((item) => ` - ${item}`));
|
|
172
|
+
}
|
|
173
|
+
if (!result.ok) {
|
|
174
|
+
lines.push(
|
|
175
|
+
`Validation has ${count(session.errors, "error")}; the records are bound to that graph. Run \`intentset validate\`.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
io.stdout(`${lines.join("\n")}\n`);
|
|
179
|
+
return result.ok ? 0 : 1;
|
|
180
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intentset graph --format json` (Core §12): the `intentset/export/0.1`
|
|
3
|
+
* envelope over the validated repository. Printed to stdout, or written to
|
|
4
|
+
* the one file `--out` names, which may not be any file the repository reads.
|
|
5
|
+
* A failing validation still exports, with `validation.status: "fail"`, and
|
|
6
|
+
* exits 1.
|
|
7
|
+
*/
|
|
8
|
+
import { writeFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { ID_PATTERN, exportGraph } from "@intentset/core";
|
|
11
|
+
import type { Io } from "../output.ts";
|
|
12
|
+
import { outputProblem } from "../outputs.ts";
|
|
13
|
+
import type { Session } from "../session.ts";
|
|
14
|
+
|
|
15
|
+
export interface GraphOptions {
|
|
16
|
+
format?: string;
|
|
17
|
+
includeBodies: boolean;
|
|
18
|
+
release?: string;
|
|
19
|
+
out?: string;
|
|
20
|
+
generatedAt?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The export schema's generatedAt pattern. */
|
|
24
|
+
const ISO_8601 = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})$/;
|
|
25
|
+
|
|
26
|
+
/** Check the options that need no repository, so a typo costs nothing. Returns a message, or null. */
|
|
27
|
+
export function graphUsageProblem(options: GraphOptions): string | null {
|
|
28
|
+
if (options.format !== undefined && options.format !== "json") {
|
|
29
|
+
return `--format ${options.format}: the only format is json`;
|
|
30
|
+
}
|
|
31
|
+
if (options.release !== undefined && parseRelease(options.release) === null) {
|
|
32
|
+
return `--release ${options.release}: write it as <product ID>:<label>, such as PRD-LANTERN:pilot-1`;
|
|
33
|
+
}
|
|
34
|
+
if (options.generatedAt !== undefined && !ISO_8601.test(options.generatedAt)) {
|
|
35
|
+
return `--generated-at ${options.generatedAt}: give an ISO 8601 time such as 2026-10-02T12:00:00Z`;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `<product>:<label>`, split at the first colon; the label is opaque and matched exactly (Core §7). */
|
|
41
|
+
export function parseRelease(text: string): { product: string; label: string } | null {
|
|
42
|
+
const colon = text.indexOf(":");
|
|
43
|
+
if (colon === -1) return null;
|
|
44
|
+
const product = text.slice(0, colon);
|
|
45
|
+
const label = text.slice(colon + 1);
|
|
46
|
+
if (!ID_PATTERN.test(product) || label === "") return null;
|
|
47
|
+
return { product, label };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function graphCommand(session: Session, options: GraphOptions, io: Io): number {
|
|
51
|
+
const { repo, result, snapshot } = session;
|
|
52
|
+
const release = options.release === undefined ? null : parseRelease(options.release);
|
|
53
|
+
if (release !== null && result.graph.artifacts.get(release.product)?.meta.type !== "product") {
|
|
54
|
+
io.stderr(`intentset graph: --release names ${release.product}, which is not a product in this graph.\n`);
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
const envelope = exportGraph(result, repo.registries, {
|
|
58
|
+
repository: repo.config.repository,
|
|
59
|
+
commit: snapshot.commit,
|
|
60
|
+
...(snapshot.commitUnavailable === undefined ? {} : { commitUnavailable: snapshot.commitUnavailable }),
|
|
61
|
+
scope: repo.config.scope,
|
|
62
|
+
level: session.level,
|
|
63
|
+
release,
|
|
64
|
+
...(options.generatedAt === undefined ? {} : { generatedAt: options.generatedAt }),
|
|
65
|
+
includeBodies: options.includeBodies,
|
|
66
|
+
});
|
|
67
|
+
const text = `${JSON.stringify(envelope, null, 2)}\n`;
|
|
68
|
+
|
|
69
|
+
if (options.out === undefined) {
|
|
70
|
+
io.stdout(text);
|
|
71
|
+
} else {
|
|
72
|
+
const target = resolve(io.cwd, options.out);
|
|
73
|
+
const problem = outputProblem(repo, target);
|
|
74
|
+
if (problem !== null) {
|
|
75
|
+
io.stderr(`intentset graph: --out ${options.out} ${problem}; tools never rewrite those.\n`);
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(target, text);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
io.stderr(`intentset graph: could not write ${options.out}: ${(error as Error).message}\n`);
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
io.stderr(
|
|
85
|
+
`intentset graph: wrote ${options.out} (${envelope.artifacts.length} artifacts, ${envelope.validation.status})\n`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return result.ok ? 0 : 1;
|
|
89
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intentset impact <ID>` (Core §10): what depends on an artifact. Direct
|
|
3
|
+
* dependents apart from candidates further downstream, each with the path
|
|
4
|
+
* that reached it and a reason per step; the start's own rules, contracts and
|
|
5
|
+
* decisions as review context; navigation ancestors. Every report names its
|
|
6
|
+
* snapshot and says that reachability is not proof. Never writes.
|
|
7
|
+
*/
|
|
8
|
+
import { type ImpactHit, type ImpactReport, impact } from "@intentset/core";
|
|
9
|
+
import { formatDiagnostic, type Io, json, snapshotLine, TOOL } from "../output.ts";
|
|
10
|
+
import type { Session } from "../session.ts";
|
|
11
|
+
import { unresolved, validationLine, validationSummary } from "./shared.ts";
|
|
12
|
+
|
|
13
|
+
export const REACHABILITY_NOTE =
|
|
14
|
+
"Reachability through authored links marks an artifact for review; it is not proof that runtime behavior changed (Core §10).";
|
|
15
|
+
|
|
16
|
+
export function impactCommand(session: Session, id: string, asJson: boolean, io: Io): number {
|
|
17
|
+
const { graph } = session.result;
|
|
18
|
+
const level = session.level;
|
|
19
|
+
const start = graph.artifacts.get(id);
|
|
20
|
+
if (start === undefined) {
|
|
21
|
+
const diagnostic = unresolved(id, session);
|
|
22
|
+
if (asJson) {
|
|
23
|
+
io.stdout(
|
|
24
|
+
json({
|
|
25
|
+
tool: TOOL,
|
|
26
|
+
snapshot: session.snapshot,
|
|
27
|
+
validation: validationSummary(session, level),
|
|
28
|
+
start: null,
|
|
29
|
+
diagnostics: [diagnostic],
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
} else {
|
|
33
|
+
io.stderr(formatDiagnostic(diagnostic));
|
|
34
|
+
}
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const report = impact(graph, id);
|
|
39
|
+
if (asJson) {
|
|
40
|
+
io.stdout(
|
|
41
|
+
json({
|
|
42
|
+
tool: TOOL,
|
|
43
|
+
snapshot: session.snapshot,
|
|
44
|
+
validation: validationSummary(session, level),
|
|
45
|
+
note: REACHABILITY_NOTE,
|
|
46
|
+
start: { id, type: start.meta.type, title: start.meta.title, path: start.path },
|
|
47
|
+
direct: report.direct,
|
|
48
|
+
candidates: report.candidates,
|
|
49
|
+
context: report.context,
|
|
50
|
+
ancestors: report.ancestors,
|
|
51
|
+
diagnostics: [],
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
} else {
|
|
55
|
+
io.stdout(renderImpact(report, session, level, `${start.meta.type} "${start.meta.title}"`));
|
|
56
|
+
}
|
|
57
|
+
return session.result.ok ? 0 : 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderImpact(report: ImpactReport, session: Session, level: string, described: string): string {
|
|
61
|
+
const lines = [
|
|
62
|
+
`Impact of ${report.start}, ${described}`,
|
|
63
|
+
snapshotLine(session.snapshot),
|
|
64
|
+
validationLine(session, level),
|
|
65
|
+
REACHABILITY_NOTE,
|
|
66
|
+
"",
|
|
67
|
+
];
|
|
68
|
+
const section = (title: string, gloss: string, hits: ImpactHit[]) => {
|
|
69
|
+
lines.push(`${title} (${hits.length}): ${gloss}`);
|
|
70
|
+
if (hits.length === 0) lines.push(" none");
|
|
71
|
+
for (const hit of hits) {
|
|
72
|
+
lines.push(` ${hit.id} ${hit.type} ${hit.title}`);
|
|
73
|
+
lines.push(` path: ${pathText(report.start, hit)}`);
|
|
74
|
+
lines.push(` reason: ${hit.path.map((step) => step.reason).join(" ")}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push("");
|
|
77
|
+
};
|
|
78
|
+
section("Direct", "one link from the start", report.direct);
|
|
79
|
+
section("Candidates", "reachable beyond the direct set, for review", report.candidates);
|
|
80
|
+
section("Review context", "the start's own rules, contracts and decisions", report.context);
|
|
81
|
+
lines.push(`Ancestors: ${report.ancestors.length === 0 ? "none" : report.ancestors.join(" > ")}`);
|
|
82
|
+
return `${lines.join("\n")}\n`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A hit's path as authored edges, written in the direction they were authored.
|
|
87
|
+
* A reverse step (`kind←`) is an edge from the step to the node before it, so
|
|
88
|
+
* a chain reads from the hit back to the start: `C --dependsOn--> B --implements--> A`.
|
|
89
|
+
* A context step (`kind→`) is an edge from the start to the hit.
|
|
90
|
+
*/
|
|
91
|
+
export function pathText(start: string, hit: ImpactHit): string {
|
|
92
|
+
const forward = hit.path.length === 1 && hit.path[0].via.endsWith("→");
|
|
93
|
+
if (forward) return `${start} --${hit.path[0].via.slice(0, -1)}--> ${hit.id}`;
|
|
94
|
+
let text = start;
|
|
95
|
+
for (const step of hit.path) text = `${step.id} --${step.via.slice(0, -1)}--> ${text}`;
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intentset init`: the one command that writes, and it only creates. It
|
|
3
|
+
* writes `.intentset/config.yaml` and an empty-but-valid
|
|
4
|
+
* `.intentset/registries.yaml`, and with `--example` the scheduling example's
|
|
5
|
+
* records under `product/scheduling/` and its registries in place of the
|
|
6
|
+
* empty ones. Every target is checked before anything is written, and each
|
|
7
|
+
* file is opened with exclusive create, so an existing file is never
|
|
8
|
+
* overwritten, not even by a race.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { compareStrings, DEFAULT_IGNORE, readConfig, readRegistries } from "@intentset/core";
|
|
14
|
+
import type { Io } from "../output.ts";
|
|
15
|
+
import { CONFIG_PATH } from "../repository.ts";
|
|
16
|
+
|
|
17
|
+
export const REGISTRIES_PATH = ".intentset/registries.yaml";
|
|
18
|
+
export const DEFAULT_INIT_SCOPE = "product/**/*.md";
|
|
19
|
+
const EXAMPLE_TARGET = "product/scheduling";
|
|
20
|
+
|
|
21
|
+
export interface InitOptions {
|
|
22
|
+
root?: string;
|
|
23
|
+
repository?: string;
|
|
24
|
+
example: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** YAML double-quoted scalar; the strict reader takes JSON's escapes. */
|
|
28
|
+
function quote(value: string): string {
|
|
29
|
+
return JSON.stringify(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function configText(repository: string): string {
|
|
33
|
+
return [
|
|
34
|
+
"# Intentset configuration (Core §11): the repository scope a conformance claim is about.",
|
|
35
|
+
"# Written by `intentset init`. Patterns are repository-relative: literal segments, * within",
|
|
36
|
+
"# one segment, ** across segments.",
|
|
37
|
+
`repository: ${quote(repository)}`,
|
|
38
|
+
"scope:",
|
|
39
|
+
` - ${quote(DEFAULT_INIT_SCOPE)}`,
|
|
40
|
+
`registries: ${quote(REGISTRIES_PATH)}`,
|
|
41
|
+
"ignore:",
|
|
42
|
+
...DEFAULT_IGNORE.map((pattern) => ` - ${quote(pattern)}`),
|
|
43
|
+
"",
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const EMPTY_REGISTRIES_TEXT = [
|
|
48
|
+
"# Intentset registries (Core §4, §7; VSA §3): the owners, audiences, release dimensions",
|
|
49
|
+
"# and shared resources records may name. An empty registry reads as not declared, and",
|
|
50
|
+
"# validate warns (CORE005) when a record names a value it could not check.",
|
|
51
|
+
"owners: []",
|
|
52
|
+
"audiences: []",
|
|
53
|
+
"releases: []",
|
|
54
|
+
"roles: []",
|
|
55
|
+
"editions: []",
|
|
56
|
+
"flags: []",
|
|
57
|
+
"resources: []",
|
|
58
|
+
"",
|
|
59
|
+
].join("\n");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The scheduling example: this repository's own copy when running from a
|
|
63
|
+
* checkout, otherwise the one @intentset/conformance-suite ships. Null when
|
|
64
|
+
* neither is present.
|
|
65
|
+
*/
|
|
66
|
+
export async function exampleDir(): Promise<string | null> {
|
|
67
|
+
const candidates = [fileURLToPath(new URL("../../../../examples/scheduling/", import.meta.url))];
|
|
68
|
+
try {
|
|
69
|
+
const suite = (await import("@intentset/conformance-suite")) as { examplesPath?: string };
|
|
70
|
+
if (typeof suite.examplesPath === "string") candidates.push(join(suite.examplesPath, "scheduling"));
|
|
71
|
+
} catch {
|
|
72
|
+
// Not installed: the checkout's copy is the only candidate.
|
|
73
|
+
}
|
|
74
|
+
return candidates.find((dir) => existsSync(join(dir, "registries.yaml"))) ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function initCommand(options: InitOptions, io: Io): Promise<number> {
|
|
78
|
+
const root = resolve(io.cwd, options.root ?? ".");
|
|
79
|
+
const repository = options.repository ?? basename(root);
|
|
80
|
+
if (repository.trim() === "" || /[\r\n]/.test(repository)) {
|
|
81
|
+
io.stderr("intentset init: --repository must be a non-empty name on one line, such as org/name.\n");
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Everything to write, decided before anything is written.
|
|
86
|
+
const files = new Map<string, string>([[CONFIG_PATH, configText(repository)]]);
|
|
87
|
+
let registries = EMPTY_REGISTRIES_TEXT;
|
|
88
|
+
if (options.example) {
|
|
89
|
+
const dir = await exampleDir();
|
|
90
|
+
if (dir === null) {
|
|
91
|
+
io.stderr(
|
|
92
|
+
"intentset init: --example needs the scheduling example, which is in an Intentset checkout under\n" +
|
|
93
|
+
"examples/scheduling and in the @intentset/conformance-suite package. Neither was found; install\n" +
|
|
94
|
+
"@intentset/conformance-suite beside @intentset/cli, or run init without --example.\n",
|
|
95
|
+
);
|
|
96
|
+
return 2;
|
|
97
|
+
}
|
|
98
|
+
registries = readFileSync(join(dir, "registries.yaml"), "utf8");
|
|
99
|
+
for (const name of readdirSync(dir)
|
|
100
|
+
.filter((n) => n.endsWith(".md"))
|
|
101
|
+
.sort(compareStrings)) {
|
|
102
|
+
files.set(`${EXAMPLE_TARGET}/${name}`, readFileSync(join(dir, name), "utf8"));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
files.set(REGISTRIES_PATH, registries);
|
|
106
|
+
|
|
107
|
+
// What init writes must read back clean, or it has handed the user a broken repository.
|
|
108
|
+
const config = readConfig(files.get(CONFIG_PATH) as string, CONFIG_PATH);
|
|
109
|
+
const registry = readRegistries(registries, REGISTRIES_PATH);
|
|
110
|
+
if (config.diagnostics.length > 0 || registry.diagnostics.length > 0) {
|
|
111
|
+
io.stderr("intentset init: the files it would write do not read back clean; nothing was written.\n");
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ordered = [...files.keys()].sort(compareStrings);
|
|
116
|
+
const existing = ordered.filter((path) => existsSync(join(root, path)));
|
|
117
|
+
if (existing.length > 0) {
|
|
118
|
+
io.stderr(
|
|
119
|
+
`intentset init: refusing to overwrite ${existing.join(", ")}; init only creates files.\n` +
|
|
120
|
+
"Remove them first if a fresh start is what you want.\n",
|
|
121
|
+
);
|
|
122
|
+
return 2;
|
|
123
|
+
}
|
|
124
|
+
for (const path of ordered) {
|
|
125
|
+
const target = join(root, path);
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
128
|
+
writeFileSync(target, files.get(path) as string, { flag: "wx" });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
io.stderr(`intentset init: could not create ${path}: ${(error as Error).message}\n`);
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
io.stdout(`created ${path}\n`);
|
|
134
|
+
}
|
|
135
|
+
const scope = options.example ? `${files.size - 2} example records in scope` : "an empty scope";
|
|
136
|
+
io.stdout(`Initialized ${quote(repository)} with ${scope}. Next: intentset validate\n`);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intentset mcp` (Core §10): the read-only MCP server over stdio, in the
|
|
3
|
+
* mode the operator chooses here and no client can change.
|
|
4
|
+
*
|
|
5
|
+
* engineering the validated graph: lookup, context, impact and search,
|
|
6
|
+
* every result naming the snapshot and its source paths
|
|
7
|
+
* customer a publication made in memory exactly as `publish` makes
|
|
8
|
+
* one, and its retrieval chunks; the server is never given
|
|
9
|
+
* the graph, so it has no engineering record to leak
|
|
10
|
+
*
|
|
11
|
+
* stdout carries the protocol and nothing else: the startup line and every
|
|
12
|
+
* diagnostic go to stderr. A customer request that the publisher refuses,
|
|
13
|
+
* or a graph that does not validate, stops before any server starts.
|
|
14
|
+
*/
|
|
15
|
+
import { type Visibility, sortDiagnostics } from "@intentset/core";
|
|
16
|
+
import { createIntentsetServer, runStdio } from "@intentset/mcp";
|
|
17
|
+
import { type PublicationRequest, publish, toChunks } from "@intentset/publisher";
|
|
18
|
+
import { count, formatDiagnostic, type Io, snapshotLine } from "../output.ts";
|
|
19
|
+
import { openSession, type SessionOptions } from "../session.ts";
|
|
20
|
+
|
|
21
|
+
export type McpServer = ReturnType<typeof createIntentsetServer>;
|
|
22
|
+
|
|
23
|
+
export interface McpOptions {
|
|
24
|
+
mode?: string;
|
|
25
|
+
includeRestricted: boolean;
|
|
26
|
+
visibility?: string;
|
|
27
|
+
audience?: string;
|
|
28
|
+
product?: string;
|
|
29
|
+
release?: string;
|
|
30
|
+
role?: string;
|
|
31
|
+
edition?: string;
|
|
32
|
+
flags: string[];
|
|
33
|
+
authorizedInternal: boolean;
|
|
34
|
+
authorizedRestricted: boolean;
|
|
35
|
+
publishedAt?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const MCP_MODES = ["engineering", "customer"] as const;
|
|
39
|
+
|
|
40
|
+
export function mcpUsageProblem(options: McpOptions): string | null {
|
|
41
|
+
if (options.mode === undefined || !(MCP_MODES as readonly string[]).includes(options.mode)) {
|
|
42
|
+
return `--mode ${options.mode ?? "(missing)"}: the modes are engineering and customer, and one must be chosen`;
|
|
43
|
+
}
|
|
44
|
+
const request = [
|
|
45
|
+
options.visibility,
|
|
46
|
+
options.audience,
|
|
47
|
+
options.product,
|
|
48
|
+
options.release,
|
|
49
|
+
options.role,
|
|
50
|
+
options.edition,
|
|
51
|
+
].some((value) => value !== undefined);
|
|
52
|
+
if (options.mode === "engineering" && (request || options.flags.length > 0)) {
|
|
53
|
+
return "publication request options belong to --mode customer";
|
|
54
|
+
}
|
|
55
|
+
if (options.mode === "customer") {
|
|
56
|
+
if (options.includeRestricted) return "--include-restricted belongs to --mode engineering";
|
|
57
|
+
if (options.visibility !== undefined && options.visibility !== "public" && options.visibility !== "customer") {
|
|
58
|
+
return `--visibility ${options.visibility}: customer mode serves a public or customer projection only`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The server the command would run, or the exit code after saying why there is none. Writes only to stderr. */
|
|
65
|
+
export function buildMcpServer(options: McpOptions, sessionOptions: SessionOptions, io: Io): McpServer | number {
|
|
66
|
+
const session = openSession("mcp", sessionOptions, io);
|
|
67
|
+
if (typeof session === "number") return session;
|
|
68
|
+
const { result, snapshot, repo } = session;
|
|
69
|
+
const summary = `${count(result.graph.artifacts.size, "artifact")}, ${count(session.errors, "error")}, ${count(session.warnings, "warning")} at ${session.level}`;
|
|
70
|
+
|
|
71
|
+
if (options.mode === "engineering") {
|
|
72
|
+
if (!result.ok) {
|
|
73
|
+
io.stderr(`intentset mcp: validation has ${count(session.errors, "error")}; results may be incomplete.\n`);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const server = createIntentsetServer({
|
|
77
|
+
mode: "engineering",
|
|
78
|
+
graph: result.graph,
|
|
79
|
+
snapshot: { commit: snapshot.commit, graphHash: snapshot.graphHash },
|
|
80
|
+
includeRestricted: options.includeRestricted,
|
|
81
|
+
});
|
|
82
|
+
io.stderr(
|
|
83
|
+
`intentset mcp: engineering context for ${repo.config.repository} over stdio (${summary}${
|
|
84
|
+
options.includeRestricted ? ", restricted records included" : ", restricted records withheld"
|
|
85
|
+
}). ${snapshotLine(snapshot)}\n`,
|
|
86
|
+
);
|
|
87
|
+
return server;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
io.stderr(`intentset mcp: ${(error as Error).message}\n`);
|
|
90
|
+
return 2;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!result.ok) {
|
|
95
|
+
for (const d of session.diagnostics) io.stderr(formatDiagnostic(d));
|
|
96
|
+
io.stderr(`intentset mcp: refused: validation has ${count(session.errors, "error")}, so nothing is published.\n`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const request = {
|
|
100
|
+
visibility: options.visibility as Visibility,
|
|
101
|
+
audience: options.audience,
|
|
102
|
+
product: options.product,
|
|
103
|
+
release: options.release,
|
|
104
|
+
role: options.role,
|
|
105
|
+
edition: options.edition,
|
|
106
|
+
flags: options.flags,
|
|
107
|
+
authorizedInternal: options.authorizedInternal,
|
|
108
|
+
authorizedRestricted: options.authorizedRestricted,
|
|
109
|
+
} as PublicationRequest;
|
|
110
|
+
const published = publish(result.graph, repo.registries, request, {
|
|
111
|
+
snapshot: { commit: snapshot.commit, graphHash: snapshot.graphHash },
|
|
112
|
+
publishedAt: options.publishedAt ?? new Date().toISOString(),
|
|
113
|
+
});
|
|
114
|
+
for (const d of sortDiagnostics(published.diagnostics)) io.stderr(formatDiagnostic(d));
|
|
115
|
+
if (published.index.request === null) {
|
|
116
|
+
io.stderr("intentset mcp: refused: the publisher refused the request, so there is nothing to serve.\n");
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const documents = published.documents;
|
|
121
|
+
const server = createIntentsetServer({
|
|
122
|
+
mode: "customer",
|
|
123
|
+
publication: { documents, index: published.index },
|
|
124
|
+
chunks: toChunks(documents),
|
|
125
|
+
});
|
|
126
|
+
const r = published.index.request;
|
|
127
|
+
io.stderr(
|
|
128
|
+
`intentset mcp: customer knowledge over stdio: ${count(documents.length, "published document")} for a ${r.visibility} projection, audience ${r.audience}, ${r.product} ${r.release}. ${snapshotLine(snapshot)}\n`,
|
|
129
|
+
);
|
|
130
|
+
return server;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
io.stderr(`intentset mcp: ${(error as Error).message}\n`);
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function mcpCommand(options: McpOptions, sessionOptions: SessionOptions, io: Io): Promise<number> {
|
|
138
|
+
const server = buildMcpServer(options, sessionOptions, io);
|
|
139
|
+
if (typeof server === "number") return server;
|
|
140
|
+
io.signal?.addEventListener("abort", () => void server.close(), { once: true });
|
|
141
|
+
await runStdio(server);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|