@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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intentset publish` (Core §9): the deny-by-default projection of reviewed
|
|
3
|
+
* knowledge for one audience, release and entitlement, as generated Markset
|
|
4
|
+
* with provenance, optional HTML, the index a customer tool may read, and
|
|
5
|
+
* retrieval chunks that carry the same filters. Refuses outright when the
|
|
6
|
+
* graph does not validate. Review pins are read as written: a real pin is a
|
|
7
|
+
* literal hash, and the fixtures' `@current` is never bound here, so an
|
|
8
|
+
* unbound one reads as changed. Writes only into the --out directory, which
|
|
9
|
+
* must be new or empty and outside the documents' scope.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
import { type Visibility, sortDiagnostics } from "@intentset/core";
|
|
14
|
+
import { type PublicationRequest, publish, toChunks } from "@intentset/publisher";
|
|
15
|
+
import { count, formatDiagnostic, type Io } from "../output.ts";
|
|
16
|
+
import { outputDirProblem, outputProblem } from "../outputs.ts";
|
|
17
|
+
import type { Session } from "../session.ts";
|
|
18
|
+
|
|
19
|
+
export interface PublishCliOptions {
|
|
20
|
+
visibility?: string;
|
|
21
|
+
audience?: string;
|
|
22
|
+
product?: string;
|
|
23
|
+
release?: string;
|
|
24
|
+
role?: string;
|
|
25
|
+
edition?: string;
|
|
26
|
+
flags: string[];
|
|
27
|
+
authorizedInternal: boolean;
|
|
28
|
+
authorizedRestricted: boolean;
|
|
29
|
+
out?: string;
|
|
30
|
+
html: boolean;
|
|
31
|
+
publishedAt?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
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})$/;
|
|
35
|
+
|
|
36
|
+
export function publishUsageProblem(options: PublishCliOptions): string | null {
|
|
37
|
+
if (options.out === undefined || options.out === "") return "--out <dir> is required";
|
|
38
|
+
if (options.publishedAt !== undefined && !ISO_8601.test(options.publishedAt)) {
|
|
39
|
+
return `--published-at ${options.publishedAt}: give an ISO 8601 time such as 2026-10-02T12:00:00Z`;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function publishCommand(session: Session, options: PublishCliOptions, io: Io): number {
|
|
45
|
+
const fail = (message: string) => {
|
|
46
|
+
io.stderr(`intentset publish: ${message}\n`);
|
|
47
|
+
return 2;
|
|
48
|
+
};
|
|
49
|
+
const { repo, result, snapshot } = session;
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
for (const d of session.diagnostics) io.stdout(formatDiagnostic(d));
|
|
52
|
+
io.stderr(
|
|
53
|
+
`intentset publish: refused: validation at ${session.level} has ${count(session.errors, "error")}, and nothing is published from a graph that does not validate.\n`,
|
|
54
|
+
);
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
const dir = resolve(io.cwd, options.out as string);
|
|
58
|
+
if (existsSync(dir) && readdirSync(dir).length > 0) {
|
|
59
|
+
return fail(`--out ${options.out} is not empty; publish into a new or empty directory so nothing stale survives.`);
|
|
60
|
+
}
|
|
61
|
+
const where = outputDirProblem(repo, dir);
|
|
62
|
+
if (where !== null) return fail(`--out ${options.out} ${where}.`);
|
|
63
|
+
|
|
64
|
+
// The request exactly as given: a dimension left out stays out, and the publisher refuses it (no implicit wildcard).
|
|
65
|
+
const request = {
|
|
66
|
+
visibility: options.visibility as Visibility,
|
|
67
|
+
audience: options.audience,
|
|
68
|
+
product: options.product,
|
|
69
|
+
release: options.release,
|
|
70
|
+
role: options.role,
|
|
71
|
+
edition: options.edition,
|
|
72
|
+
flags: options.flags,
|
|
73
|
+
authorizedInternal: options.authorizedInternal,
|
|
74
|
+
authorizedRestricted: options.authorizedRestricted,
|
|
75
|
+
} as PublicationRequest;
|
|
76
|
+
const published = publish(result.graph, repo.registries, request, {
|
|
77
|
+
snapshot: { commit: snapshot.commit, graphHash: snapshot.graphHash },
|
|
78
|
+
publishedAt: options.publishedAt ?? new Date().toISOString(),
|
|
79
|
+
renderHtml: options.html,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const outputs = new Map<string, string>();
|
|
83
|
+
if (published.index.request !== null) {
|
|
84
|
+
for (const document of published.documents) {
|
|
85
|
+
outputs.set(`${document.id}.md`, document.markset);
|
|
86
|
+
if (document.html !== undefined) outputs.set(`${document.id}.html`, document.html);
|
|
87
|
+
}
|
|
88
|
+
outputs.set("index.json", `${JSON.stringify(published.index, null, 2)}\n`);
|
|
89
|
+
const chunks = toChunks(published.documents);
|
|
90
|
+
outputs.set("chunks.jsonl", chunks.map((chunk) => `${JSON.stringify(chunk)}\n`).join(""));
|
|
91
|
+
}
|
|
92
|
+
for (const name of outputs.keys()) {
|
|
93
|
+
const problem = outputProblem(repo, join(dir, name));
|
|
94
|
+
if (problem !== null) return fail(`--out ${options.out}: ${name} ${problem}; nothing was written.`);
|
|
95
|
+
}
|
|
96
|
+
if (outputs.size > 0) mkdirSync(dir, { recursive: true });
|
|
97
|
+
for (const [name, text] of outputs) writeFileSync(join(dir, name), text, { flag: "wx" });
|
|
98
|
+
|
|
99
|
+
for (const d of sortDiagnostics(published.diagnostics)) io.stdout(formatDiagnostic(d));
|
|
100
|
+
const index = published.index;
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
if (index.request === null) {
|
|
103
|
+
lines.push("The request was refused, so nothing was projected and nothing was written.");
|
|
104
|
+
} else {
|
|
105
|
+
const r = index.request;
|
|
106
|
+
lines.push(
|
|
107
|
+
`Published ${count(index.published.length, "document")} for a ${r.visibility} projection: audience ${r.audience}, ${r.product} ${r.release}, role ${r.role}, edition ${r.edition}, flags ${r.flags.length === 0 ? "none" : r.flags.join(", ")}`,
|
|
108
|
+
...index.published.map((id) => ` ${id}`),
|
|
109
|
+
);
|
|
110
|
+
const reasons = Object.keys(index.excludedCounts);
|
|
111
|
+
lines.push(
|
|
112
|
+
`Excluded, among what this projection may see (${reasons.length === 0 ? "none" : reasons.map((reason) => `${reason} ${index.excludedCounts[reason]}`).join(", ")})`,
|
|
113
|
+
`Snapshot: ${snapshot.commit === null ? `no commit (${snapshot.commitUnavailable})` : `commit ${snapshot.commit}`}, graph ${snapshot.graphHash}`,
|
|
114
|
+
`Wrote ${[...outputs.keys()].map((name) => join(options.out as string, name)).join(", ")}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
io.stdout(`${lines.join("\n")}\n`);
|
|
118
|
+
return published.ok ? 0 : 1;
|
|
119
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intentset review` (Core §10): the local review report. Validation at the
|
|
3
|
+
* highest level the inputs allow (L2 always; L4 when run records are given
|
|
4
|
+
* or found, since L3's evidence is the only input L4 needs beyond it), the
|
|
5
|
+
* architecture summary, coverage, and the impact of the change: every file
|
|
6
|
+
* changed since the base, committed or not, mapped to the artifacts it
|
|
7
|
+
* touches, a changed record to its own artifact and a changed source file
|
|
8
|
+
* through the claiming slice to that slice's behaviors, each start's direct
|
|
9
|
+
* dependents shown apart from its candidates. Markdown by default. Read-only.
|
|
10
|
+
*/
|
|
11
|
+
import { type Graph, impact, compareStrings } from "@intentset/core";
|
|
12
|
+
import { type Changes, changedFiles, chooseBase } from "../git.ts";
|
|
13
|
+
import { matchGlob } from "../glob.ts";
|
|
14
|
+
import { count, formatDiagnostic, type Io, json, snapshotLine, TOOL } from "../output.ts";
|
|
15
|
+
import { levelJson, levelSections } from "../reports.ts";
|
|
16
|
+
import type { Session } from "../session.ts";
|
|
17
|
+
import { pathText, REACHABILITY_NOTE } from "./impact.ts";
|
|
18
|
+
|
|
19
|
+
export interface Attribution {
|
|
20
|
+
/** The artifact impact starts from. */
|
|
21
|
+
start: string;
|
|
22
|
+
/** The changed files that led to it, and how. */
|
|
23
|
+
because: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Map changed files to impact starts. A path that is a record names its artifact; a claimed source file names the claiming slices' behaviors. */
|
|
27
|
+
export function attribute(graph: Graph, changed: readonly string[]): { starts: Attribution[]; unattributed: string[] } {
|
|
28
|
+
const byPath = new Map<string, string>();
|
|
29
|
+
for (const [id, artifact] of graph.artifacts) byPath.set(artifact.path, id);
|
|
30
|
+
const slices = [...graph.artifacts.values()]
|
|
31
|
+
.filter((a) => a.meta.type === "slice" && a.meta.status !== "retired" && a.meta.slice !== undefined)
|
|
32
|
+
.sort((a, b) => compareStrings(a.meta.id, b.meta.id));
|
|
33
|
+
const starts = new Map<string, Set<string>>();
|
|
34
|
+
const add = (id: string, reason: string) => {
|
|
35
|
+
const set = starts.get(id) ?? new Set<string>();
|
|
36
|
+
set.add(reason);
|
|
37
|
+
starts.set(id, set);
|
|
38
|
+
};
|
|
39
|
+
const unattributed: string[] = [];
|
|
40
|
+
for (const path of changed) {
|
|
41
|
+
const id = byPath.get(path);
|
|
42
|
+
if (id !== undefined) {
|
|
43
|
+
add(id, `${path} is its record`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const owners = slices.filter((slice) => slice.meta.slice?.claims.some((claim) => matchGlob(claim.path, path)));
|
|
47
|
+
let attributed = false;
|
|
48
|
+
for (const slice of owners) {
|
|
49
|
+
for (const behavior of slice.meta.links.implements ?? []) {
|
|
50
|
+
if (!graph.artifacts.has(behavior)) continue;
|
|
51
|
+
add(behavior, `${path} is claimed by ${slice.meta.id}, which implements it`);
|
|
52
|
+
attributed = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!attributed) unattributed.push(path);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
starts: [...starts.keys()]
|
|
59
|
+
.sort(compareStrings)
|
|
60
|
+
.map((start) => ({ start, because: [...(starts.get(start) as Set<string>)].sort(compareStrings) })),
|
|
61
|
+
unattributed,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function reviewCommand(
|
|
66
|
+
session: Session,
|
|
67
|
+
options: { base?: string; levelReason: string },
|
|
68
|
+
asJson: boolean,
|
|
69
|
+
io: Io,
|
|
70
|
+
): number {
|
|
71
|
+
const { repo, result, snapshot } = session;
|
|
72
|
+
const graph = result.graph;
|
|
73
|
+
const chosen = chooseBase(repo.root, options.base);
|
|
74
|
+
if (chosen === null) {
|
|
75
|
+
io.stderr(`intentset review: --base ${options.base} names no commit.\n`);
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
const base = "unavailable" in chosen ? null : chosen;
|
|
79
|
+
const changes: Changes = { base, ...changedFiles(repo.root, base) };
|
|
80
|
+
if ("unavailable" in chosen) changes.baseUnavailable = chosen.unavailable;
|
|
81
|
+
const changed = [...new Set([...changes.committed, ...changes.uncommitted])].sort(compareStrings);
|
|
82
|
+
const { starts, unattributed } = attribute(graph, changed);
|
|
83
|
+
const impacts = starts.map(({ start, because }) => {
|
|
84
|
+
const artifact = graph.artifacts.get(start);
|
|
85
|
+
const report = impact(graph, start);
|
|
86
|
+
return {
|
|
87
|
+
start,
|
|
88
|
+
type: artifact?.meta.type,
|
|
89
|
+
title: artifact?.meta.title,
|
|
90
|
+
because,
|
|
91
|
+
direct: report.direct,
|
|
92
|
+
candidates: report.candidates,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (asJson) {
|
|
97
|
+
io.stdout(
|
|
98
|
+
json({
|
|
99
|
+
tool: TOOL,
|
|
100
|
+
snapshot,
|
|
101
|
+
level: session.level,
|
|
102
|
+
levelReason: options.levelReason,
|
|
103
|
+
changes,
|
|
104
|
+
validation: {
|
|
105
|
+
summary: { artifacts: graph.artifacts.size, errors: session.errors, warnings: session.warnings },
|
|
106
|
+
diagnostics: session.diagnostics,
|
|
107
|
+
},
|
|
108
|
+
...levelJson(session),
|
|
109
|
+
note: REACHABILITY_NOTE,
|
|
110
|
+
impact: impacts,
|
|
111
|
+
unattributed,
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
return session.errors > 0 ? 1 : 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const baseLine =
|
|
118
|
+
base === null
|
|
119
|
+
? `- Base: none (${changes.baseUnavailable}); only uncommitted changes are considered`
|
|
120
|
+
: `- Base: ${base.ref}, ${base.commit}`;
|
|
121
|
+
const out = [
|
|
122
|
+
"# Review",
|
|
123
|
+
"",
|
|
124
|
+
`- ${snapshotLine(snapshot)}`,
|
|
125
|
+
baseLine,
|
|
126
|
+
`- Changed: ${count(changed.length, "file")} (${changes.committed.length} committed since the base, ${changes.uncommitted.length} uncommitted)`,
|
|
127
|
+
`- Level: ${session.level}, ${options.levelReason}`,
|
|
128
|
+
"",
|
|
129
|
+
"## Validation",
|
|
130
|
+
"",
|
|
131
|
+
`${count(graph.artifacts.size, "artifact")}, ${count(session.errors, "error")}, ${count(session.warnings, "warning")} (level ${session.level}, scope: ${repo.config.scope.join(", ")})`,
|
|
132
|
+
];
|
|
133
|
+
if (session.diagnostics.length > 0) {
|
|
134
|
+
out.push("", "```text", ...session.diagnostics.map((d) => formatDiagnostic(d).trimEnd()), "```");
|
|
135
|
+
}
|
|
136
|
+
const sections = levelSections(session);
|
|
137
|
+
if (sections.length > 0) out.push("", "## Checks above L1", "", "```text", ...sections.slice(1), "```");
|
|
138
|
+
out.push("", "## Impact of the change", "", REACHABILITY_NOTE);
|
|
139
|
+
if (impacts.length === 0)
|
|
140
|
+
out.push("", "No changed file is a record or is claimed by a slice, so nothing was traversed.");
|
|
141
|
+
for (const item of impacts) {
|
|
142
|
+
out.push("", `### ${item.start}: ${item.title}`, "", ...item.because.map((reason) => `- Changed: ${reason}`), "");
|
|
143
|
+
out.push(`Direct (${item.direct.length}), one link away:`);
|
|
144
|
+
if (item.direct.length === 0) out.push("- none");
|
|
145
|
+
for (const hit of item.direct) out.push(`- ${hit.id} (${hit.type}): \`${pathText(item.start, hit)}\``);
|
|
146
|
+
out.push("", `Candidates (${item.candidates.length}), reachable beyond, for review:`);
|
|
147
|
+
if (item.candidates.length === 0) out.push("- none");
|
|
148
|
+
for (const hit of item.candidates) out.push(`- ${hit.id} (${hit.type}): \`${pathText(item.start, hit)}\``);
|
|
149
|
+
}
|
|
150
|
+
out.push("", `## Changed files no artifact accounts for (${unattributed.length})`, "");
|
|
151
|
+
if (unattributed.length === 0) out.push("None.");
|
|
152
|
+
else out.push(...unattributed.map((path) => `- ${path}`));
|
|
153
|
+
io.stdout(`${out.join("\n")}\n`);
|
|
154
|
+
return session.errors > 0 ? 1 : 0;
|
|
155
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intentset serve` (Core §10): the Atlas over HTTP on this machine. The
|
|
3
|
+
* level pipeline runs as validate's does, at the highest level the inputs
|
|
4
|
+
* allow, and the Atlas is built from what it found. Requests are answered
|
|
5
|
+
* from that in-memory build and nothing else: GET only, the Atlas's own
|
|
6
|
+
* paths and atlas.css, 404 for any other path, so there is no file system
|
|
7
|
+
* behind the server to traverse. Before answering, any file the build read
|
|
8
|
+
* that has changed since is noticed and the Atlas rebuilt, so an edited
|
|
9
|
+
* record shows on reload.
|
|
10
|
+
*
|
|
11
|
+
* `--out <dir>` writes the same files to a new or empty directory outside
|
|
12
|
+
* the documents' scope and exits. The Atlas carries internal and restricted
|
|
13
|
+
* records, and says so: it is a review surface, never a publication.
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { ARCHITECTURE_CONFIG_PATH } from "@intentset/architecture";
|
|
19
|
+
import { buildAtlas } from "@intentset/atlas";
|
|
20
|
+
import { count, type Io, snapshotLine } from "../output.ts";
|
|
21
|
+
import { outputDirProblem, outputProblem } from "../outputs.ts";
|
|
22
|
+
import { CONFIG_PATH } from "../repository.ts";
|
|
23
|
+
import { EVIDENCE_DIR, evidenceFiles, openSession, type Session, type SessionOptions } from "../session.ts";
|
|
24
|
+
import { TEXT_EXTENSIONS } from "../tree.ts";
|
|
25
|
+
|
|
26
|
+
export const NOT_FOR_PUBLICATION =
|
|
27
|
+
"The Atlas shows internal and restricted records: it is a review surface and is not for publication.";
|
|
28
|
+
|
|
29
|
+
export interface ServeOptions {
|
|
30
|
+
port: number;
|
|
31
|
+
host: string;
|
|
32
|
+
out?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TYPES: Record<string, string> = {
|
|
36
|
+
".html": "text/html; charset=utf-8",
|
|
37
|
+
".css": "text/css; charset=utf-8",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The Atlas built from a session. */
|
|
41
|
+
export function atlasOf(session: Session): Map<string, string> {
|
|
42
|
+
return buildAtlas({
|
|
43
|
+
graph: session.result.graph,
|
|
44
|
+
registries: session.repo.registries,
|
|
45
|
+
diagnostics: session.diagnostics,
|
|
46
|
+
snapshot: {
|
|
47
|
+
commit: session.snapshot.commit,
|
|
48
|
+
graphHash: session.snapshot.graphHash,
|
|
49
|
+
scope: session.repo.config.scope,
|
|
50
|
+
level: session.level,
|
|
51
|
+
},
|
|
52
|
+
evidence:
|
|
53
|
+
session.evidence === undefined
|
|
54
|
+
? null
|
|
55
|
+
: { classification: session.evidence.check.classification, coverage: session.evidence.check.coverage },
|
|
56
|
+
architecture:
|
|
57
|
+
session.architecture === undefined
|
|
58
|
+
? null
|
|
59
|
+
: {
|
|
60
|
+
diagnostics: session.architecture.diagnostics,
|
|
61
|
+
summary: session.architecture.summary as unknown as Record<string, unknown>,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every file a build read as text, and the directories holding them, absolute:
|
|
68
|
+
* a newer modification time on any of them means rebuild. A directory's time
|
|
69
|
+
* changes when a file is added to it or removed, which is how a new record is
|
|
70
|
+
* noticed.
|
|
71
|
+
*/
|
|
72
|
+
function watched(session: Session, evidence: readonly string[]): string[] {
|
|
73
|
+
const { repo } = session;
|
|
74
|
+
const files = new Set<string>([
|
|
75
|
+
join(repo.root, CONFIG_PATH),
|
|
76
|
+
join(repo.root, ARCHITECTURE_CONFIG_PATH),
|
|
77
|
+
join(repo.root, EVIDENCE_DIR),
|
|
78
|
+
...evidence,
|
|
79
|
+
...repo.inputs.map((input) => join(repo.root, input.path)),
|
|
80
|
+
// Only what a check reads as text: a log or an image beside the sources cannot change the Atlas.
|
|
81
|
+
...[...(session.tree?.paths ?? [])]
|
|
82
|
+
.filter((path) => TEXT_EXTENSIONS.some((extension) => path.endsWith(extension)))
|
|
83
|
+
.map((path) => join(repo.root, path)),
|
|
84
|
+
]);
|
|
85
|
+
if (repo.config.registries !== null) files.add(join(repo.root, repo.config.registries));
|
|
86
|
+
// A file added to or removed from a directory changes the directory's time.
|
|
87
|
+
for (const file of [...files]) {
|
|
88
|
+
for (let dir = dirname(file); dir.startsWith(repo.root); dir = dirname(dir)) {
|
|
89
|
+
files.add(dir);
|
|
90
|
+
if (dir === repo.root) break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [...files];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function changedSince(paths: readonly string[], since: number): string | null {
|
|
97
|
+
for (const path of paths) {
|
|
98
|
+
try {
|
|
99
|
+
if (statSync(path).mtimeMs > since) return path;
|
|
100
|
+
} catch {
|
|
101
|
+
// Absent now: a file the build read was removed, or an optional file never existed.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The atlas path a request names, or null when it names none. Nothing here touches the file system. */
|
|
108
|
+
export function requestPath(url: string | undefined): string | null {
|
|
109
|
+
let pathname: string;
|
|
110
|
+
try {
|
|
111
|
+
pathname = decodeURIComponent(new URL(url ?? "/", "http://localhost").pathname);
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const path = pathname.replace(/^\/+/, "");
|
|
116
|
+
return path === "" || path.endsWith("/") ? `${path}index.html` : path;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function serveCommand(options: ServeOptions, sessionOptions: SessionOptions, io: Io): Promise<number> {
|
|
120
|
+
const given = sessionOptions.evidence;
|
|
121
|
+
let builtAt = Date.now();
|
|
122
|
+
const first = openSession("serve", sessionOptions, io);
|
|
123
|
+
if (typeof first === "number") return first;
|
|
124
|
+
let session: Session = first;
|
|
125
|
+
let atlas: Map<string, string>;
|
|
126
|
+
try {
|
|
127
|
+
atlas = atlasOf(session);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
io.stderr(`intentset serve: the Atlas could not be built: ${(error as Error).message}\n`);
|
|
130
|
+
return 2;
|
|
131
|
+
}
|
|
132
|
+
const evidence = () => evidenceFiles(session.repo.root, io.cwd, given);
|
|
133
|
+
let paths = watched(session, evidence());
|
|
134
|
+
const levelLine = `${count(session.result.graph.artifacts.size, "artifact")}, ${count(session.errors, "error")}, ${count(session.warnings, "warning")} at ${session.level}${
|
|
135
|
+
session.evidence === undefined
|
|
136
|
+
? `; no run records were given or found in ${EVIDENCE_DIR}/, so L3 was not checked`
|
|
137
|
+
: ""
|
|
138
|
+
}`;
|
|
139
|
+
|
|
140
|
+
if (options.out !== undefined) return writeAtlas(session, atlas, options.out, levelLine, io);
|
|
141
|
+
|
|
142
|
+
/** Rebuild when a file the last build read has changed; keep serving the last good build if a rebuild fails. */
|
|
143
|
+
const refresh = (): string | null => {
|
|
144
|
+
const changed = changedSince(paths, builtAt);
|
|
145
|
+
if (changed === null) return null;
|
|
146
|
+
const started = Date.now();
|
|
147
|
+
const next = openSession("serve", sessionOptions, io);
|
|
148
|
+
if (typeof next === "number") return "the repository could not be read; the terminal says why";
|
|
149
|
+
try {
|
|
150
|
+
atlas = atlasOf(next);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return `the Atlas could not be built: ${(error as Error).message}`;
|
|
153
|
+
}
|
|
154
|
+
session = next;
|
|
155
|
+
builtAt = started;
|
|
156
|
+
paths = watched(session, evidence());
|
|
157
|
+
io.stdout(
|
|
158
|
+
`rebuilt: ${changed.slice(session.repo.root.length + 1) || "."} changed. ${snapshotLine(session.snapshot)}\n`,
|
|
159
|
+
);
|
|
160
|
+
return null;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const handle = (request: IncomingMessage, response: ServerResponse) => {
|
|
164
|
+
const headers = {
|
|
165
|
+
"Cache-Control": "no-store",
|
|
166
|
+
"X-Content-Type-Options": "nosniff",
|
|
167
|
+
"Content-Security-Policy":
|
|
168
|
+
"default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'none'; form-action 'none'",
|
|
169
|
+
};
|
|
170
|
+
if (request.method !== "GET") {
|
|
171
|
+
response.writeHead(405, { ...headers, Allow: "GET", "Content-Type": "text/plain; charset=utf-8" });
|
|
172
|
+
response.end("Only GET is served.\n");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const failure = refresh();
|
|
176
|
+
if (failure !== null) {
|
|
177
|
+
response.writeHead(500, { ...headers, "Content-Type": "text/plain; charset=utf-8" });
|
|
178
|
+
response.end(`The Atlas was not rebuilt: ${failure}.\n`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const path = requestPath(request.url);
|
|
182
|
+
const body = path === null ? undefined : atlas.get(path);
|
|
183
|
+
if (path === null || body === undefined) {
|
|
184
|
+
response.writeHead(404, { ...headers, "Content-Type": "text/plain; charset=utf-8" });
|
|
185
|
+
response.end("Not an Atlas page.\n");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const type = TYPES[path.slice(path.lastIndexOf("."))] ?? "application/octet-stream";
|
|
189
|
+
response.writeHead(200, { ...headers, "Content-Type": type });
|
|
190
|
+
response.end(body);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const server = createServer(handle);
|
|
194
|
+
return new Promise<number>((done) => {
|
|
195
|
+
server.once("error", (error) => {
|
|
196
|
+
io.stderr(`intentset serve: could not listen on ${options.host}:${options.port}: ${error.message}\n`);
|
|
197
|
+
done(2);
|
|
198
|
+
});
|
|
199
|
+
server.listen(options.port, options.host, () => {
|
|
200
|
+
const address = server.address();
|
|
201
|
+
const port = typeof address === "object" && address !== null ? address.port : options.port;
|
|
202
|
+
const host = options.host.includes(":") ? `[${options.host}]` : options.host;
|
|
203
|
+
io.stdout(
|
|
204
|
+
[
|
|
205
|
+
`Intentset Atlas for ${session.repo.config.repository} at http://${host}:${port}/`,
|
|
206
|
+
snapshotLine(session.snapshot),
|
|
207
|
+
levelLine,
|
|
208
|
+
NOT_FOR_PUBLICATION,
|
|
209
|
+
"Pages are rebuilt when a file they were built from changes: edit a record and reload. Interrupt to stop.",
|
|
210
|
+
"",
|
|
211
|
+
].join("\n"),
|
|
212
|
+
);
|
|
213
|
+
const stop = () => {
|
|
214
|
+
server.close(() => done(0));
|
|
215
|
+
server.closeIdleConnections();
|
|
216
|
+
};
|
|
217
|
+
if (io.signal?.aborted) stop();
|
|
218
|
+
else io.signal?.addEventListener("abort", stop, { once: true });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function writeAtlas(session: Session, atlas: Map<string, string>, out: string, levelLine: string, io: Io): number {
|
|
224
|
+
const dir = resolve(io.cwd, out);
|
|
225
|
+
let entries: string[] = [];
|
|
226
|
+
try {
|
|
227
|
+
entries = readdirSync(dir);
|
|
228
|
+
} catch {
|
|
229
|
+
// Absent: it will be created.
|
|
230
|
+
}
|
|
231
|
+
if (entries.length > 0) {
|
|
232
|
+
io.stderr(`intentset serve: --out ${out} is not empty; write the Atlas into a new or empty directory.\n`);
|
|
233
|
+
return 2;
|
|
234
|
+
}
|
|
235
|
+
const where = outputDirProblem(session.repo, dir);
|
|
236
|
+
if (where !== null) {
|
|
237
|
+
io.stderr(`intentset serve: --out ${out} ${where}.\n`);
|
|
238
|
+
return 2;
|
|
239
|
+
}
|
|
240
|
+
for (const path of atlas.keys()) {
|
|
241
|
+
const problem = outputProblem(session.repo, join(dir, path));
|
|
242
|
+
if (problem !== null) {
|
|
243
|
+
io.stderr(`intentset serve: --out ${out}: ${path} ${problem}; nothing was written.\n`);
|
|
244
|
+
return 2;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
for (const [path, text] of atlas) {
|
|
248
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true });
|
|
249
|
+
writeFileSync(join(dir, path), text, { flag: "wx" });
|
|
250
|
+
}
|
|
251
|
+
io.stdout(
|
|
252
|
+
[
|
|
253
|
+
`Wrote the Atlas, ${count(atlas.size, "file")}, to ${out}`,
|
|
254
|
+
snapshotLine(session.snapshot),
|
|
255
|
+
levelLine,
|
|
256
|
+
NOT_FOR_PUBLICATION,
|
|
257
|
+
"",
|
|
258
|
+
].join("\n"),
|
|
259
|
+
);
|
|
260
|
+
return session.errors > 0 ? 1 : 0;
|
|
261
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What impact and context say before their answer: which snapshot, whether
|
|
3
|
+
* the graph validated, and the CORE003-style refusal for an ID the graph does
|
|
4
|
+
* not hold.
|
|
5
|
+
*/
|
|
6
|
+
import { type Diagnostic, ID_PATTERN } from "@intentset/core";
|
|
7
|
+
import { count } from "../output.ts";
|
|
8
|
+
import type { Session } from "../session.ts";
|
|
9
|
+
|
|
10
|
+
/** The validation state a query ran over, so a reader knows whether the graph was whole. */
|
|
11
|
+
export function validationSummary(session: Session, level: string) {
|
|
12
|
+
return {
|
|
13
|
+
level,
|
|
14
|
+
status: session.result.ok ? ("pass" as const) : ("fail" as const),
|
|
15
|
+
artifacts: session.result.graph.artifacts.size,
|
|
16
|
+
errors: session.errors,
|
|
17
|
+
warnings: session.warnings,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validationLine(session: Session, level: string): string {
|
|
22
|
+
const v = validationSummary(session, level);
|
|
23
|
+
const line = `Validation: ${v.status} at ${level}, ${count(v.artifacts, "artifact")}, ${count(v.errors, "error")}, ${count(v.warnings, "warning")}`;
|
|
24
|
+
return v.status === "pass"
|
|
25
|
+
? line
|
|
26
|
+
: `${line}. The graph may be incomplete; run \`intentset validate\` and fix the errors first.`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Core §5 and §11: an ID that resolves to nothing, reported as CORE003 would report an unresolved link. */
|
|
30
|
+
export function unresolved(id: string, session: Session): Diagnostic {
|
|
31
|
+
const scope = session.repo.config.scope.join(", ");
|
|
32
|
+
const malformed = !ID_PATTERN.test(id);
|
|
33
|
+
return {
|
|
34
|
+
code: "CORE003",
|
|
35
|
+
severity: "error",
|
|
36
|
+
origin: "graph",
|
|
37
|
+
artifact: null,
|
|
38
|
+
path: null,
|
|
39
|
+
message: malformed
|
|
40
|
+
? `${id} is not an artifact ID; IDs are upper case, such as BEH-ASMT-SCHEDULE (Core §4).`
|
|
41
|
+
: `${id} does not resolve to an artifact in this graph (${count(session.result.graph.artifacts.size, "artifact")}, scope: ${scope}).`,
|
|
42
|
+
remediation: malformed
|
|
43
|
+
? "Give the ID as written in the record's frontmatter; IDs are case-sensitive."
|
|
44
|
+
: "Check the spelling and case, and that the record's file is inside the configured scope.",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intentset validate` (Core §11): the documents in scope through every check
|
|
3
|
+
* the requested level asks for. Diagnostics in `compareDiagnostics` order,
|
|
4
|
+
* then a summary naming the denominator, the level and the scope, then what
|
|
5
|
+
* each level above L1 adds: the architecture summary, coverage, publication
|
|
6
|
+
* readiness. Warnings never fail; errors exit 1. Never writes.
|
|
7
|
+
*/
|
|
8
|
+
import { count, formatDiagnostic, type Io, json, TOOL } from "../output.ts";
|
|
9
|
+
import { levelJson, levelSections } from "../reports.ts";
|
|
10
|
+
import type { Session } from "../session.ts";
|
|
11
|
+
|
|
12
|
+
export function validateCommand(session: Session, asJson: boolean, io: Io): number {
|
|
13
|
+
const { repo, diagnostics, errors, warnings, snapshot, level } = session;
|
|
14
|
+
const artifacts = session.result.graph.artifacts.size;
|
|
15
|
+
if (asJson) {
|
|
16
|
+
io.stdout(
|
|
17
|
+
json({
|
|
18
|
+
tool: TOOL,
|
|
19
|
+
level,
|
|
20
|
+
scope: repo.config.scope,
|
|
21
|
+
commit: snapshot.commit,
|
|
22
|
+
...(snapshot.commitUnavailable === undefined ? {} : { commitUnavailable: snapshot.commitUnavailable }),
|
|
23
|
+
graphHash: snapshot.graphHash,
|
|
24
|
+
summary: { documents: repo.inputs.length, artifacts, errors, warnings },
|
|
25
|
+
diagnostics,
|
|
26
|
+
...levelJson(session),
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
} else {
|
|
30
|
+
for (const d of diagnostics) io.stdout(formatDiagnostic(d));
|
|
31
|
+
const lines = [
|
|
32
|
+
`${count(artifacts, "artifact")}, ${count(errors, "error")}, ${count(warnings, "warning")} ` +
|
|
33
|
+
`(level ${level}, scope: ${repo.config.scope.join(", ")})`,
|
|
34
|
+
...levelSections(session),
|
|
35
|
+
];
|
|
36
|
+
io.stdout(`${lines.join("\n")}\n`);
|
|
37
|
+
}
|
|
38
|
+
return errors > 0 ? 1 : 0;
|
|
39
|
+
}
|