@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
package/src/outputs.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit outputs (`graph --out`, `architecture check --write-baseline`,
|
|
3
|
+
* `evidence import --out`, `publish --out`) may write only what they are
|
|
4
|
+
* named to write, and never a file the repository is made of: a document in
|
|
5
|
+
* scope, the configuration, the registries, the architecture configuration
|
|
6
|
+
* or an exception record, a source file, a manifest. Tools never rewrite
|
|
7
|
+
* those (invariant 1). A target outside the repository is the caller's
|
|
8
|
+
* business.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { isAbsolute, relative, sep } from "node:path";
|
|
12
|
+
import { ARCHITECTURE_CONFIG_PATH, EXCEPTIONS_PATTERNS } from "@intentset/architecture";
|
|
13
|
+
import { matchAny } from "./glob.ts";
|
|
14
|
+
import { CONFIG_PATH, type Repository } from "./repository.ts";
|
|
15
|
+
|
|
16
|
+
const SOURCE = /\.(?:[cm]?[jt]sx?|md|ya?ml)$/;
|
|
17
|
+
const MANIFEST = /(?:^|\/)(?:package\.json|tsconfig[^/]*\.json)$/;
|
|
18
|
+
|
|
19
|
+
/** The target as a repository-relative POSIX path, "" for the root itself, or null when it lies outside the root. */
|
|
20
|
+
export function insideRoot(root: string, target: string): string | null {
|
|
21
|
+
const path = relative(root, target);
|
|
22
|
+
if (path === "") return "";
|
|
23
|
+
if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return null;
|
|
24
|
+
return path.split(sep).join("/");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Why `target` (absolute) may not be written, or null when it may. A document
|
|
29
|
+
* that would land in scope is refused even when it does not exist yet, since
|
|
30
|
+
* the next run would read it as canonical.
|
|
31
|
+
*/
|
|
32
|
+
export function outputProblem(repo: Repository, target: string): string | null {
|
|
33
|
+
const path = insideRoot(repo.root, target);
|
|
34
|
+
if (path === null) return null;
|
|
35
|
+
if (path === "") return "is the repository root";
|
|
36
|
+
const reads = new Set([
|
|
37
|
+
CONFIG_PATH,
|
|
38
|
+
ARCHITECTURE_CONFIG_PATH,
|
|
39
|
+
...(repo.config.registries === null ? [] : [repo.config.registries]),
|
|
40
|
+
...repo.inputs.map((input) => input.path),
|
|
41
|
+
]);
|
|
42
|
+
if (reads.has(path) || matchAny(EXCEPTIONS_PATTERNS, path)) return "is a file the repository reads";
|
|
43
|
+
if (matchAny(repo.config.scope, path) && !matchAny(repo.config.ignore, path)) {
|
|
44
|
+
return "is inside the documents' scope, where it would be read as canonical";
|
|
45
|
+
}
|
|
46
|
+
if (existsSync(target) && (SOURCE.test(path) || MANIFEST.test(path))) return "is a source file or manifest";
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Why a directory may not receive generated output, or null when it may: it
|
|
52
|
+
* is the root, or a Markdown file written into it would fall inside the
|
|
53
|
+
* documents' scope, where generated files would sooner or later be read as
|
|
54
|
+
* canonical. An ignored directory, such as dist/, is the place for them.
|
|
55
|
+
*/
|
|
56
|
+
export function outputDirProblem(repo: Repository, dir: string): string | null {
|
|
57
|
+
const path = insideRoot(repo.root, dir);
|
|
58
|
+
if (path === null) return null;
|
|
59
|
+
if (path === "") return "is the repository root";
|
|
60
|
+
const probe = `${path}/generated.md`;
|
|
61
|
+
if (matchAny(repo.config.scope, probe) && !matchAny(repo.config.ignore, probe)) {
|
|
62
|
+
return "is inside the documents' scope; write generated output to an ignored directory such as dist/";
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
package/src/reports.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sections a level adds to a report, as text and as JSON. Every count
|
|
3
|
+
* says what it counts and over what; what could not be checked is listed as
|
|
4
|
+
* not checked, never folded into a pass (invariant 6); coverage is counts
|
|
5
|
+
* and never a percentage (Core §8).
|
|
6
|
+
*/
|
|
7
|
+
import type { ArchitectureResult } from "@intentset/architecture";
|
|
8
|
+
import type { ClaimCounts } from "@intentset/verification";
|
|
9
|
+
import { count } from "./output.ts";
|
|
10
|
+
import type { EvidenceRun, Session } from "./session.ts";
|
|
11
|
+
|
|
12
|
+
export function architectureLines(result: ArchitectureResult, skipped: readonly string[]): string[] {
|
|
13
|
+
const s = result.summary;
|
|
14
|
+
const lines = [
|
|
15
|
+
`Architecture (${s.level}, ${s.mode} mode): ${count(s.slices, "active slice")}; ` +
|
|
16
|
+
`${count(s.filesClaimed, "file")} claimed, ${s.filesUnclaimed} unclaimed, ${s.outOfScope} out of scope (counted, not reported); ` +
|
|
17
|
+
`${count(s.edges, "import edge")}, ${s.crossSliceEdges} across slices; ${count(s.cycles, "cycle")}; ` +
|
|
18
|
+
`${s.excepted} excepted, ${s.baselined} baselined, ${count(s.baselineStale, "stale baseline entry", "stale baseline entries")}`,
|
|
19
|
+
`Not checked, so not a pass (${s.unresolved.length}):${s.unresolved.length === 0 ? " none" : ""}`,
|
|
20
|
+
...s.unresolved.map((item) => ` - ${item}`),
|
|
21
|
+
`Left to review, which this check cannot decide from source (${s.reviewRequired.length}):`,
|
|
22
|
+
...s.reviewRequired.map((item) => ` - ${item}`),
|
|
23
|
+
];
|
|
24
|
+
if (skipped.length > 0) {
|
|
25
|
+
lines.push(`Not read, over 1 MiB (${skipped.length}):`, ...skipped.map((path) => ` - ${path}`));
|
|
26
|
+
}
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ofCounts(part: ClaimCounts, whole: ClaimCounts): string {
|
|
31
|
+
return [
|
|
32
|
+
`${part.behaviors} of ${count(whole.behaviors, "behavior")}`,
|
|
33
|
+
`${part.rules} of ${count(whole.rules, "rule")}`,
|
|
34
|
+
`${part.scenarios} of ${count(whole.scenarios, "scenario")}`,
|
|
35
|
+
].join(", ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function evidenceLines(run: EvidenceRun): string[] {
|
|
39
|
+
const { coverage, classification } = run.check;
|
|
40
|
+
const snap = run.snapshot;
|
|
41
|
+
const scope =
|
|
42
|
+
snap.scope === null || snap.scope === undefined
|
|
43
|
+
? "every product and release"
|
|
44
|
+
: `${snap.scope.product} ${snap.scope.release}`;
|
|
45
|
+
const from = run.files.length === 0 ? "no evidence files" : run.files.join(", ");
|
|
46
|
+
const d = coverage.denominator;
|
|
47
|
+
const lines = [
|
|
48
|
+
`Evidence: ${count(run.records, "run record")} from ${from}; assessed at ${snap.commit === null ? "no commit" : `commit ${snap.commit}`}, graph ${snap.graphHash}, for ${scope}`,
|
|
49
|
+
`Coverage over ${count(d.behaviors, "behavior")}, ${count(d.rules, "rule")} and ${count(d.scenarios, "scenario")} that are not retired:`,
|
|
50
|
+
` linked: ${ofCounts(coverage.linked, d)} (${coverage.measures.linked})`,
|
|
51
|
+
` verified: ${ofCounts(coverage.verified, d)} (${coverage.measures.verified})`,
|
|
52
|
+
` manual: linked ${ofCounts(coverage.manual.linked, d)}; verified ${ofCounts(coverage.manual.verified, d)}`,
|
|
53
|
+
` automated: linked ${ofCounts(coverage.automated.linked, d)}; verified ${ofCounts(coverage.automated.verified, d)}`,
|
|
54
|
+
"Verifications at this snapshot:",
|
|
55
|
+
];
|
|
56
|
+
const ids = Object.keys(classification.verifications);
|
|
57
|
+
if (ids.length === 0) lines.push(" none");
|
|
58
|
+
for (const id of ids) {
|
|
59
|
+
const v = classification.verifications[id];
|
|
60
|
+
lines.push(` ${id} ${v.method} ${v.status}${v.note === undefined ? "" : ` (${v.note})`}`);
|
|
61
|
+
}
|
|
62
|
+
lines.push(
|
|
63
|
+
`Not checked, so not a pass (${coverage.unresolved.length}):${coverage.unresolved.length === 0 ? " none" : ""}`,
|
|
64
|
+
);
|
|
65
|
+
for (const item of coverage.unresolved) lines.push(` - ${item.subject}: ${item.reason}`);
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function publicationLines(checked: readonly string[]): string[] {
|
|
70
|
+
return [
|
|
71
|
+
`Publication readiness: ${count(checked.length, "active knowledge artifact")} checked for a current review and availability${
|
|
72
|
+
checked.length === 0 ? "" : ` (${checked.join(", ")})`
|
|
73
|
+
}`,
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every section the session's level added, as text lines, blank-line separated. */
|
|
78
|
+
export function levelSections(session: Session): string[] {
|
|
79
|
+
const sections: string[][] = [];
|
|
80
|
+
if (session.architecture !== undefined)
|
|
81
|
+
sections.push(architectureLines(session.architecture, session.tree?.skipped ?? []));
|
|
82
|
+
if (session.evidence !== undefined) sections.push(evidenceLines(session.evidence));
|
|
83
|
+
if (session.publication !== undefined) sections.push(publicationLines(session.publication.checked));
|
|
84
|
+
return sections.flatMap((lines) => ["", ...lines]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The same sections as JSON members. */
|
|
88
|
+
export function levelJson(session: Session): Record<string, unknown> {
|
|
89
|
+
const out: Record<string, unknown> = {};
|
|
90
|
+
if (session.architecture !== undefined) {
|
|
91
|
+
out.architecture = { summary: session.architecture.summary, skipped: session.tree?.skipped ?? [] };
|
|
92
|
+
}
|
|
93
|
+
if (session.evidence !== undefined) {
|
|
94
|
+
const { files, records, snapshot, check } = session.evidence;
|
|
95
|
+
const verifications: Record<string, unknown> = {};
|
|
96
|
+
for (const [id, v] of Object.entries(check.classification.verifications)) {
|
|
97
|
+
verifications[id] = { method: v.method, status: v.status, ...(v.note === undefined ? {} : { note: v.note }) };
|
|
98
|
+
}
|
|
99
|
+
out.evidence = { files, records, snapshot, coverage: check.coverage, verifications };
|
|
100
|
+
}
|
|
101
|
+
if (session.publication !== undefined) out.publication = { checked: session.publication.checked };
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading a repository: the one place the CLI reads the tree. Everything here
|
|
3
|
+
* is a read. The commit comes from `git rev-parse HEAD`, which reads the
|
|
4
|
+
* object store and writes nothing; any failure becomes `commit: null` with
|
|
5
|
+
* the reason, because the export and every report must say which snapshot
|
|
6
|
+
* they describe and a missing commit is a fact to state, not to invent.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
type Config,
|
|
13
|
+
compareStrings,
|
|
14
|
+
type Diagnostic,
|
|
15
|
+
type DocumentInput,
|
|
16
|
+
EMPTY_REGISTRIES,
|
|
17
|
+
type Registries,
|
|
18
|
+
hasErrors,
|
|
19
|
+
plainCarrier,
|
|
20
|
+
readConfig,
|
|
21
|
+
readRegistries,
|
|
22
|
+
} from "@intentset/core";
|
|
23
|
+
import { marksetCarrier } from "@intentset/markset-adapter";
|
|
24
|
+
import { matchAny } from "./glob.ts";
|
|
25
|
+
|
|
26
|
+
/** Where the configuration lives, relative to the repository root. Its presence is what makes a directory a root. */
|
|
27
|
+
export const CONFIG_PATH = ".intentset/config.yaml";
|
|
28
|
+
|
|
29
|
+
export const CARRIERS = {
|
|
30
|
+
markset: marksetCarrier,
|
|
31
|
+
plain: plainCarrier,
|
|
32
|
+
} as const;
|
|
33
|
+
export type CarrierName = keyof typeof CARRIERS;
|
|
34
|
+
|
|
35
|
+
export interface Repository {
|
|
36
|
+
/** Absolute path of the repository root. */
|
|
37
|
+
root: string;
|
|
38
|
+
config: Config;
|
|
39
|
+
/** One per document in scope, sorted by repository-relative path. */
|
|
40
|
+
inputs: DocumentInput[];
|
|
41
|
+
registries: Registries;
|
|
42
|
+
/** CFG001 from the configuration and CFG002 from the registries, sorted as core sorts. */
|
|
43
|
+
diagnostics: Diagnostic[];
|
|
44
|
+
/** False when the configuration itself has an error: then no scope is known and nothing was enumerated. */
|
|
45
|
+
configOk: boolean;
|
|
46
|
+
commit: string | null;
|
|
47
|
+
commitUnavailable?: string;
|
|
48
|
+
/** True when tracked files differ from that commit, so the commit alone does not describe what was read. */
|
|
49
|
+
uncommitted?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The repository root: `explicit` when given (it must hold the configuration),
|
|
54
|
+
* otherwise the nearest directory at or above `cwd` that does. Null when none.
|
|
55
|
+
*/
|
|
56
|
+
export function findRoot(cwd: string, explicit: string | undefined): string | null {
|
|
57
|
+
if (explicit !== undefined) {
|
|
58
|
+
const root = resolve(cwd, explicit);
|
|
59
|
+
return existsSync(join(root, CONFIG_PATH)) ? root : null;
|
|
60
|
+
}
|
|
61
|
+
let dir = resolve(cwd);
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (existsSync(join(dir, CONFIG_PATH))) return dir;
|
|
64
|
+
const parent = dirname(dir);
|
|
65
|
+
if (parent === dir) return null;
|
|
66
|
+
dir = parent;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Core §11: read the configuration, the documents in its scope with the chosen carrier, the registries and the commit. */
|
|
71
|
+
export function loadRepository(root: string, carrier: CarrierName): Repository {
|
|
72
|
+
const read = readConfig(readFileSync(join(root, CONFIG_PATH), "utf8"), CONFIG_PATH);
|
|
73
|
+
const config = read.config;
|
|
74
|
+
const diagnostics = [...read.diagnostics];
|
|
75
|
+
const configOk = !hasErrors(read.diagnostics);
|
|
76
|
+
const inputs: DocumentInput[] = [];
|
|
77
|
+
let registries: Registries = { ...EMPTY_REGISTRIES, resources: [] };
|
|
78
|
+
|
|
79
|
+
if (configOk) {
|
|
80
|
+
const parse = CARRIERS[carrier];
|
|
81
|
+
for (const path of enumerate(root, config)) {
|
|
82
|
+
inputs.push(parse(path, readFileSync(join(root, path), "utf8")));
|
|
83
|
+
}
|
|
84
|
+
if (config.registries !== null) {
|
|
85
|
+
const file = join(root, config.registries);
|
|
86
|
+
const text = isFile(file) ? readFileSync(file, "utf8") : null;
|
|
87
|
+
const loaded = readRegistries(text, config.registries);
|
|
88
|
+
registries = loaded.registries;
|
|
89
|
+
diagnostics.push(...loaded.diagnostics);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { root, config, inputs, registries, diagnostics, configOk, ...readCommit(root) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Core §11 scope: every file `listFiles` returns that matches a `scope`
|
|
97
|
+
* pattern, as sorted repository-relative POSIX paths.
|
|
98
|
+
*/
|
|
99
|
+
export function enumerate(root: string, config: Pick<Config, "scope" | "ignore">): string[] {
|
|
100
|
+
return listFiles(root, config.ignore).filter((path) => matchAny(config.scope, path));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every regular file under `root` that no `ignore` pattern matches, sorted.
|
|
105
|
+
* In a git repository the list is git's own (`ls-files --cached --others
|
|
106
|
+
* --exclude-standard`): tracked files and untracked ones .gitignore does not
|
|
107
|
+
* exclude, so build output and untracked worktrees stay out. Elsewhere it is
|
|
108
|
+
* a directory walk. Either way symbolic links are left out, so nothing read
|
|
109
|
+
* lies outside the repository, and a tracked file deleted from the working
|
|
110
|
+
* tree is not listed.
|
|
111
|
+
*/
|
|
112
|
+
export function listFiles(root: string, ignore: readonly string[]): string[] {
|
|
113
|
+
const listed = gitFiles(root) ?? walk(root, ignore);
|
|
114
|
+
const nested = nestedCheckouts(root);
|
|
115
|
+
return listed.filter(
|
|
116
|
+
(path) => !matchAny(ignore, path) && !ignoredDirectory(ignore, path) && !nested(path) && isFile(join(root, path)),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A directory with its own `.git` is another checkout (a nested repository or
|
|
122
|
+
* a worktree), not part of this one. git lists an untracked worktree's files
|
|
123
|
+
* one by one, so without this a repository with worktrees under it is read
|
|
124
|
+
* twice: Streamlane's `.claude/worktrees` made every package appear twice.
|
|
125
|
+
*/
|
|
126
|
+
function nestedCheckouts(root: string): (path: string) => boolean {
|
|
127
|
+
const cache = new Map<string, boolean>();
|
|
128
|
+
return (path) => {
|
|
129
|
+
const parts = path.split("/");
|
|
130
|
+
for (let i = 1; i < parts.length; i++) {
|
|
131
|
+
const dir = parts.slice(0, i).join("/");
|
|
132
|
+
let found = cache.get(dir);
|
|
133
|
+
if (found === undefined) {
|
|
134
|
+
found = existsSync(join(root, dir, ".git"));
|
|
135
|
+
cache.set(dir, found);
|
|
136
|
+
}
|
|
137
|
+
if (found) return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True when some directory containing `path` is ignored, as the walk would never have entered it. */
|
|
144
|
+
function ignoredDirectory(ignore: readonly string[], path: string): boolean {
|
|
145
|
+
const parts = path.split("/");
|
|
146
|
+
for (let i = 1; i < parts.length; i++) if (matchAny(ignore, parts.slice(0, i).join("/"))) return true;
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function gitFiles(root: string): string[] | null {
|
|
151
|
+
try {
|
|
152
|
+
const out = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
153
|
+
cwd: root,
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
156
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
157
|
+
});
|
|
158
|
+
return [...new Set(out.split("\0").filter((path) => path !== ""))].sort(compareStrings);
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Every regular file under `root` that no `ignore` pattern matches, by walking; ignored directories are not entered. */
|
|
165
|
+
export function walk(root: string, ignore: readonly string[]): string[] {
|
|
166
|
+
const out: string[] = [];
|
|
167
|
+
const visit = (relative: string): void => {
|
|
168
|
+
const entries = readdirSync(relative === "" ? root : join(root, relative), { withFileTypes: true });
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
const path = relative === "" ? entry.name : `${relative}/${entry.name}`;
|
|
171
|
+
if (matchAny(ignore, path)) continue;
|
|
172
|
+
if (entry.isDirectory() && existsSync(join(root, path, ".git"))) continue;
|
|
173
|
+
if (entry.isDirectory()) visit(path);
|
|
174
|
+
else if (entry.isFile()) out.push(path);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
visit("");
|
|
178
|
+
return out.sort(compareStrings);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The commit at HEAD in `root`, or null and the reason none could be read. */
|
|
182
|
+
export function readCommit(root: string): { commit: string | null; commitUnavailable?: string; uncommitted?: boolean } {
|
|
183
|
+
try {
|
|
184
|
+
const commit = execFileSync("git", ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], {
|
|
185
|
+
cwd: root,
|
|
186
|
+
encoding: "utf8",
|
|
187
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
188
|
+
}).trim();
|
|
189
|
+
if (/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(commit)) {
|
|
190
|
+
// --no-optional-locks: asking whether the tree is clean must not refresh git's index either.
|
|
191
|
+
const status = execFileSync("git", ["--no-optional-locks", "status", "--porcelain", "--untracked-files=no"], {
|
|
192
|
+
cwd: root,
|
|
193
|
+
encoding: "utf8",
|
|
194
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
195
|
+
});
|
|
196
|
+
return status.trim() === "" ? { commit } : { commit, uncommitted: true };
|
|
197
|
+
}
|
|
198
|
+
return { commit: null, commitUnavailable: `git rev-parse printed "${commit}", which is not a commit ID.` };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return { commit: null, commitUnavailable: commitFailure(error) };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function commitFailure(error: unknown): string {
|
|
205
|
+
const failure = error as { code?: string; status?: number | null; stderr?: string };
|
|
206
|
+
if (failure.code === "ENOENT") return "git is not installed, so no commit could be read.";
|
|
207
|
+
const detail = (failure.stderr ?? "").trim().split("\n")[0] ?? "";
|
|
208
|
+
if (/not a git repository/i.test(detail)) return "The repository root is not inside a git repository.";
|
|
209
|
+
if (failure.status === 1 && detail === "") return "The git repository has no commit at HEAD yet.";
|
|
210
|
+
return detail === "" ? "git rev-parse HEAD failed." : `git rev-parse HEAD failed: ${detail}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A regular file, not a symbolic link to one. */
|
|
214
|
+
function isFile(path: string): boolean {
|
|
215
|
+
try {
|
|
216
|
+
return lstatSync(path).isFile();
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared front half of every reading command: find the root, load the
|
|
3
|
+
* repository, and run every check the requested level asks for, each level
|
|
4
|
+
* including the ones below it (Core §11):
|
|
5
|
+
*
|
|
6
|
+
* L1 core: parse, identity, fields, typed links, hierarchy, lifecycle, narrative
|
|
7
|
+
* L2 + ownership (core) and the architecture check over the repository tree
|
|
8
|
+
* L3 + run records classified against this snapshot, and current-pass coverage
|
|
9
|
+
* L4 + publication readiness: active knowledge is review-current and carries availability
|
|
10
|
+
* L5 is a claim about continuous CI, which one run cannot check; main refuses it
|
|
11
|
+
*
|
|
12
|
+
* Returns an exit code instead when the command cannot proceed, having said
|
|
13
|
+
* why. Nothing here writes.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
type ArchitectureResult,
|
|
19
|
+
type BaselineEntry,
|
|
20
|
+
checkArchitecture,
|
|
21
|
+
type Mode,
|
|
22
|
+
parseBaseline,
|
|
23
|
+
} from "@intentset/architecture";
|
|
24
|
+
import {
|
|
25
|
+
type Artifact,
|
|
26
|
+
compareStrings,
|
|
27
|
+
type Diagnostic,
|
|
28
|
+
type Graph,
|
|
29
|
+
graphHash,
|
|
30
|
+
hasErrors,
|
|
31
|
+
LEVELS,
|
|
32
|
+
type Level,
|
|
33
|
+
sortDiagnostics,
|
|
34
|
+
type ValidationResult,
|
|
35
|
+
validate,
|
|
36
|
+
} from "@intentset/core";
|
|
37
|
+
import { reviewStatus } from "@intentset/publisher";
|
|
38
|
+
import {
|
|
39
|
+
checkEvidence,
|
|
40
|
+
type EvidenceCheck,
|
|
41
|
+
type EvidenceSnapshot,
|
|
42
|
+
type RunRecord,
|
|
43
|
+
type RunScope,
|
|
44
|
+
readRunRecords,
|
|
45
|
+
} from "@intentset/verification";
|
|
46
|
+
import { insideRoot } from "./outputs.ts";
|
|
47
|
+
import { formatDiagnostic, type Io, type Snapshot } from "./output.ts";
|
|
48
|
+
import { type CarrierName, CONFIG_PATH, findRoot, loadRepository, type Repository } from "./repository.ts";
|
|
49
|
+
import { readTree, type Tree } from "./tree.ts";
|
|
50
|
+
|
|
51
|
+
/** Where run records are read from when no --evidence is given. Local output: not to be committed. */
|
|
52
|
+
export const EVIDENCE_DIR = ".intentset/evidence";
|
|
53
|
+
|
|
54
|
+
export interface SessionOptions {
|
|
55
|
+
root?: string;
|
|
56
|
+
carrier: CarrierName;
|
|
57
|
+
level: Level;
|
|
58
|
+
/** L2 and above: architecture mode and baseline. */
|
|
59
|
+
mode?: Mode;
|
|
60
|
+
baseline?: string;
|
|
61
|
+
/** L3 and above: evidence files as given (relative to cwd); undefined reads EVIDENCE_DIR. */
|
|
62
|
+
evidence?: string[];
|
|
63
|
+
/** L3 and above: the exact product and release evidence is assessed for; null considers every scope. */
|
|
64
|
+
scope?: RunScope | null;
|
|
65
|
+
/** The day exceptions expire against; default today, UTC. */
|
|
66
|
+
today?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EvidenceRun {
|
|
70
|
+
/** The files read, as repository-relative paths where they lie inside the root. */
|
|
71
|
+
files: string[];
|
|
72
|
+
records: number;
|
|
73
|
+
check: EvidenceCheck;
|
|
74
|
+
snapshot: EvidenceSnapshot;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Session {
|
|
78
|
+
repo: Repository;
|
|
79
|
+
level: Level;
|
|
80
|
+
/** Core's result with every level's diagnostics folded in, so `ok` and the export carry them all. */
|
|
81
|
+
result: ValidationResult;
|
|
82
|
+
diagnostics: Diagnostic[];
|
|
83
|
+
errors: number;
|
|
84
|
+
warnings: number;
|
|
85
|
+
snapshot: Snapshot;
|
|
86
|
+
tree?: Tree;
|
|
87
|
+
architecture?: ArchitectureResult;
|
|
88
|
+
evidence?: EvidenceRun;
|
|
89
|
+
/** L4: the active knowledge artifacts checked for publication readiness, by ID. */
|
|
90
|
+
publication?: { checked: string[] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const at = (level: Level, floor: Level) => LEVELS.indexOf(level) >= LEVELS.indexOf(floor);
|
|
94
|
+
|
|
95
|
+
/** Load and check at the level, or return the exit code (2) after reporting why the repository could not be read. */
|
|
96
|
+
export function openSession(command: string, options: SessionOptions, io: Io): Session | number {
|
|
97
|
+
const root = findRoot(io.cwd, options.root);
|
|
98
|
+
if (root === null) {
|
|
99
|
+
const where = options.root === undefined ? "this directory or any above it" : options.root;
|
|
100
|
+
io.stderr(
|
|
101
|
+
`intentset ${command}: no ${CONFIG_PATH} in ${where}.\n` +
|
|
102
|
+
"Run `intentset init` at the repository root to create one, or pass --root <dir>.\n",
|
|
103
|
+
);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
const repo = loadRepository(root, options.carrier);
|
|
107
|
+
if (!repo.configOk) {
|
|
108
|
+
io.stderr(`intentset ${command}: the configuration has errors, so the scope is unknown.\n`);
|
|
109
|
+
for (const d of sortDiagnostics(repo.diagnostics)) io.stderr(formatDiagnostic(d));
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
const level = options.level;
|
|
113
|
+
const validated = validate(repo.inputs, repo.registries, { level });
|
|
114
|
+
const graph = validated.graph;
|
|
115
|
+
const snapshot: Snapshot = { commit: repo.commit, graphHash: graphHash(graph) };
|
|
116
|
+
if (repo.commitUnavailable !== undefined) snapshot.commitUnavailable = repo.commitUnavailable;
|
|
117
|
+
if (repo.uncommitted === true) snapshot.uncommitted = true;
|
|
118
|
+
const all: Diagnostic[] = [...validated.diagnostics, ...repo.diagnostics];
|
|
119
|
+
const session: Omit<Session, "result" | "diagnostics" | "errors" | "warnings"> = { repo, level, snapshot };
|
|
120
|
+
|
|
121
|
+
if (at(level, "L2")) {
|
|
122
|
+
let baseline: BaselineEntry[] = [];
|
|
123
|
+
if (options.baseline !== undefined) {
|
|
124
|
+
const read = readBaseline(resolve(io.cwd, options.baseline));
|
|
125
|
+
if (typeof read === "string") {
|
|
126
|
+
io.stderr(`intentset ${command}: --baseline ${options.baseline}: ${read}\n`);
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
baseline = read;
|
|
130
|
+
}
|
|
131
|
+
const tree = readTree(root, repo.config);
|
|
132
|
+
const architecture = checkArchitecture(
|
|
133
|
+
graph,
|
|
134
|
+
repo.registries,
|
|
135
|
+
{ files: tree.files },
|
|
136
|
+
{
|
|
137
|
+
level,
|
|
138
|
+
mode: options.mode ?? "migration",
|
|
139
|
+
baseline,
|
|
140
|
+
today: options.today ?? new Date().toISOString().slice(0, 10),
|
|
141
|
+
...(repo.config.registries === null ? {} : { registriesPath: repo.config.registries }),
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
session.tree = tree;
|
|
145
|
+
session.architecture = architecture;
|
|
146
|
+
all.push(...architecture.diagnostics);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (at(level, "L3")) {
|
|
150
|
+
const files = evidenceFiles(root, io.cwd, options.evidence);
|
|
151
|
+
const records: RunRecord[] = [];
|
|
152
|
+
const seen = new Set<string>();
|
|
153
|
+
const shown: string[] = [];
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
const path = insideRoot(root, file) ?? file;
|
|
156
|
+
let json: unknown;
|
|
157
|
+
try {
|
|
158
|
+
json = JSON.parse(readFileSync(file, "utf8"));
|
|
159
|
+
} catch (error) {
|
|
160
|
+
io.stderr(`intentset ${command}: evidence ${path} is not readable JSON: ${(error as Error).message}\n`);
|
|
161
|
+
return 2;
|
|
162
|
+
}
|
|
163
|
+
const read = readRunRecords(json, path);
|
|
164
|
+
all.push(...read.diagnostics);
|
|
165
|
+
// The same record imported into two files is one run, not two.
|
|
166
|
+
for (const record of read.records) {
|
|
167
|
+
const key = JSON.stringify(record);
|
|
168
|
+
if (seen.has(key)) continue;
|
|
169
|
+
seen.add(key);
|
|
170
|
+
records.push(record);
|
|
171
|
+
}
|
|
172
|
+
shown.push(path);
|
|
173
|
+
}
|
|
174
|
+
const evidenceSnapshot: EvidenceSnapshot = {
|
|
175
|
+
commit: snapshot.commit,
|
|
176
|
+
graphHash: snapshot.graphHash,
|
|
177
|
+
scope: options.scope ?? null,
|
|
178
|
+
};
|
|
179
|
+
const check = checkEvidence(graph, records, evidenceSnapshot, {
|
|
180
|
+
level,
|
|
181
|
+
files: session.tree?.paths,
|
|
182
|
+
evidencePath: shown.length === 1 ? shown[0] : null,
|
|
183
|
+
});
|
|
184
|
+
session.evidence = { files: shown, records: records.length, check, snapshot: evidenceSnapshot };
|
|
185
|
+
all.push(...check.diagnostics);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (at(level, "L4")) {
|
|
189
|
+
const readiness = publicationReadiness(graph);
|
|
190
|
+
session.publication = { checked: readiness.checked };
|
|
191
|
+
all.push(...readiness.diagnostics);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const diagnostics = sortDiagnostics(all);
|
|
195
|
+
const errors = diagnostics.filter((d) => d.severity === "error").length;
|
|
196
|
+
const result: ValidationResult = { graph, diagnostics, ok: !hasErrors(diagnostics) };
|
|
197
|
+
return { ...session, result, diagnostics, errors, warnings: diagnostics.length - errors };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A baseline file's entries, or why it could not be read. */
|
|
201
|
+
function readBaseline(file: string): BaselineEntry[] | string {
|
|
202
|
+
let text: string;
|
|
203
|
+
try {
|
|
204
|
+
text = readFileSync(file, "utf8");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return `could not be read: ${(error as Error).message}`;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
return parseBaseline(text);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return (error as Error).message;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The evidence files to read: those given, else every `*.json` in EVIDENCE_DIR, sorted. Absolute paths. */
|
|
216
|
+
export function evidenceFiles(root: string, cwd: string, given: string[] | undefined): string[] {
|
|
217
|
+
if (given !== undefined && given.length > 0) return given.map((file) => resolve(cwd, file));
|
|
218
|
+
const dir = join(root, EVIDENCE_DIR);
|
|
219
|
+
if (!existsSync(dir)) return [];
|
|
220
|
+
return readdirSync(dir)
|
|
221
|
+
.filter((name) => name.endsWith(".json"))
|
|
222
|
+
.sort(compareStrings)
|
|
223
|
+
.map((name) => join(dir, name));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Core §9 and §11 at L4: every knowledge artifact that is neither draft nor
|
|
228
|
+
* retired is current against the sources it was reviewed against, and says
|
|
229
|
+
* where it applies. Anything else is CORE008, because it could not be
|
|
230
|
+
* published as current.
|
|
231
|
+
*/
|
|
232
|
+
export function publicationReadiness(graph: Graph): { checked: string[]; diagnostics: Diagnostic[] } {
|
|
233
|
+
const checked: string[] = [];
|
|
234
|
+
const diagnostics: Diagnostic[] = [];
|
|
235
|
+
for (const id of [...graph.artifacts.keys()].sort(compareStrings)) {
|
|
236
|
+
const artifact = graph.artifacts.get(id) as Artifact;
|
|
237
|
+
const { meta } = artifact;
|
|
238
|
+
if (meta.type !== "knowledge" || meta.status === "draft" || meta.status === "retired") continue;
|
|
239
|
+
checked.push(id);
|
|
240
|
+
const report = (field: string, message: string, remediation: string) =>
|
|
241
|
+
diagnostics.push({
|
|
242
|
+
code: "CORE008",
|
|
243
|
+
severity: "error",
|
|
244
|
+
origin: "publication",
|
|
245
|
+
artifact: id,
|
|
246
|
+
path: artifact.path,
|
|
247
|
+
field,
|
|
248
|
+
message,
|
|
249
|
+
remediation,
|
|
250
|
+
});
|
|
251
|
+
if (meta.availability === undefined) {
|
|
252
|
+
report(
|
|
253
|
+
"/intentset/availability",
|
|
254
|
+
`${meta.status} knowledge ${id} has no availability, so no release could publish it (Core §7: missing access information fails closed).`,
|
|
255
|
+
"Add availability with the products, releases, roles, editions and flags the guidance applies to.",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const review = reviewStatus(graph, artifact);
|
|
259
|
+
if (review.status === "current") continue;
|
|
260
|
+
const reasons: string[] = [];
|
|
261
|
+
if (review.changed.length > 0) reasons.push(`${review.changed.join(", ")} changed since review`);
|
|
262
|
+
if (review.missing.length > 0) reasons.push(`no review pin for ${review.missing.join(", ")}`);
|
|
263
|
+
if (review.reviewer === null) reasons.push("it names no reviewer");
|
|
264
|
+
if (review.reviewedAt === null) reasons.push("it gives no review date");
|
|
265
|
+
report(
|
|
266
|
+
review.reviewer === null
|
|
267
|
+
? "/intentset/reviewedBy"
|
|
268
|
+
: review.reviewedAt === null
|
|
269
|
+
? "/intentset/reviewedAt"
|
|
270
|
+
: "/intentset/extensions/intentset.org~1review",
|
|
271
|
+
`${meta.status} knowledge ${id} needs review: ${reasons.join("; ")} (Core §9).`,
|
|
272
|
+
"Review the knowledge against its current sources, then update reviewedBy, reviewedAt and the intentset.org/review pins (Core §9).",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return { checked, diagnostics };
|
|
276
|
+
}
|