@intentset/cli 0.3.0 → 0.4.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/agents.d.ts +5 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +146 -0
- package/dist/agents.js.map +1 -0
- package/dist/commands/context.d.ts +14 -1
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +34 -1
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +44 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/review.d.ts +20 -0
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +52 -8
- package/dist/commands/review.js.map +1 -1
- package/dist/git.d.ts +8 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +15 -0
- package/dist/git.js.map +1 -1
- package/dist/main.d.ts +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +25 -13
- package/dist/main.js.map +1 -1
- package/dist/slices.d.ts +24 -0
- package/dist/slices.d.ts.map +1 -0
- package/dist/slices.js +48 -0
- package/dist/slices.js.map +1 -0
- package/package.json +9 -9
- package/src/agents.ts +150 -0
- package/src/commands/context.ts +39 -1
- package/src/commands/init.ts +45 -10
- package/src/commands/review.ts +66 -9
- package/src/git.ts +15 -0
- package/src/main.ts +33 -13
- package/src/slices.ts +54 -0
package/src/commands/init.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `intentset init`: the one command that writes, and it only creates. It
|
|
3
|
-
* writes `.intentset/config.yaml
|
|
4
|
-
* `.intentset/registries.yaml
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* writes `.intentset/config.yaml`, an empty-but-valid
|
|
4
|
+
* `.intentset/registries.yaml` and `.intentset/agents.md`, the guide coding
|
|
5
|
+
* agents follow to keep the model current, and with `--example` the
|
|
6
|
+
* scheduling example's records under `product/scheduling/` and its
|
|
7
|
+
* registries in place of the empty ones. `--agents` writes the guide alone,
|
|
8
|
+
* for a repository initialized before there was one, with the scope its
|
|
9
|
+
* config already names. Every target is checked before anything is written,
|
|
10
|
+
* and each file is opened with exclusive create, so an existing file is never
|
|
8
11
|
* overwritten, not even by a race.
|
|
9
12
|
*/
|
|
10
13
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
14
|
import { basename, dirname, join, resolve } from "node:path";
|
|
12
15
|
import { fileURLToPath } from "node:url";
|
|
13
16
|
import { compareStrings, DEFAULT_IGNORE, readConfig, readRegistries } from "@intentset/core";
|
|
17
|
+
import { AGENT_GUIDE_PATH, AGENT_POINTERS, agentGuide } from "../agents.ts";
|
|
14
18
|
import type { Io } from "../output.ts";
|
|
15
19
|
import { CONFIG_PATH } from "../repository.ts";
|
|
16
20
|
|
|
@@ -22,6 +26,8 @@ export interface InitOptions {
|
|
|
22
26
|
root?: string;
|
|
23
27
|
repository?: string;
|
|
24
28
|
example: boolean;
|
|
29
|
+
/** Write only the agent guide. */
|
|
30
|
+
agentsOnly?: boolean;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/** YAML double-quoted scalar; the strict reader takes JSON's escapes. */
|
|
@@ -82,6 +88,8 @@ export async function initCommand(options: InitOptions, io: Io): Promise<number>
|
|
|
82
88
|
return 2;
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
if (options.agentsOnly === true) return writeGuideOnly(root, io);
|
|
92
|
+
|
|
85
93
|
// Everything to write, decided before anything is written.
|
|
86
94
|
const files = new Map<string, string>([[CONFIG_PATH, configText(repository)]]);
|
|
87
95
|
let registries = EMPTY_REGISTRIES_TEXT;
|
|
@@ -103,6 +111,7 @@ export async function initCommand(options: InitOptions, io: Io): Promise<number>
|
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
113
|
files.set(REGISTRIES_PATH, registries);
|
|
114
|
+
files.set(AGENT_GUIDE_PATH, agentGuide([DEFAULT_INIT_SCOPE]));
|
|
106
115
|
|
|
107
116
|
// What init writes must read back clean, or it has handed the user a broken repository.
|
|
108
117
|
const config = readConfig(files.get(CONFIG_PATH) as string, CONFIG_PATH);
|
|
@@ -112,6 +121,28 @@ export async function initCommand(options: InitOptions, io: Io): Promise<number>
|
|
|
112
121
|
return 2;
|
|
113
122
|
}
|
|
114
123
|
|
|
124
|
+
if (!create(root, files, io)) return 2;
|
|
125
|
+
const scope = options.example ? `${files.size - 3} example records in scope` : "an empty scope";
|
|
126
|
+
io.stdout(`Initialized ${quote(repository)} with ${scope}. Next: intentset validate\n`);
|
|
127
|
+
pointers(io);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `init --agents`: the guide alone, for the scope the repository's config names, or the default with no config. */
|
|
132
|
+
function writeGuideOnly(root: string, io: Io): number {
|
|
133
|
+
let scope = [DEFAULT_INIT_SCOPE];
|
|
134
|
+
const configFile = join(root, CONFIG_PATH);
|
|
135
|
+
if (existsSync(configFile)) {
|
|
136
|
+
const { config, diagnostics } = readConfig(readFileSync(configFile, "utf8"), CONFIG_PATH);
|
|
137
|
+
if (diagnostics.length === 0 && config.scope.length > 0) scope = config.scope;
|
|
138
|
+
}
|
|
139
|
+
if (!create(root, new Map([[AGENT_GUIDE_PATH, agentGuide(scope)]]), io)) return 2;
|
|
140
|
+
pointers(io);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Create every file or none: refuse if any exists, then write each with exclusive create. */
|
|
145
|
+
function create(root: string, files: Map<string, string>, io: Io): boolean {
|
|
115
146
|
const ordered = [...files.keys()].sort(compareStrings);
|
|
116
147
|
const existing = ordered.filter((path) => existsSync(join(root, path)));
|
|
117
148
|
if (existing.length > 0) {
|
|
@@ -119,7 +150,7 @@ export async function initCommand(options: InitOptions, io: Io): Promise<number>
|
|
|
119
150
|
`intentset init: refusing to overwrite ${existing.join(", ")}; init only creates files.\n` +
|
|
120
151
|
"Remove them first if a fresh start is what you want.\n",
|
|
121
152
|
);
|
|
122
|
-
return
|
|
153
|
+
return false;
|
|
123
154
|
}
|
|
124
155
|
for (const path of ordered) {
|
|
125
156
|
const target = join(root, path);
|
|
@@ -128,11 +159,15 @@ export async function initCommand(options: InitOptions, io: Io): Promise<number>
|
|
|
128
159
|
writeFileSync(target, files.get(path) as string, { flag: "wx" });
|
|
129
160
|
} catch (error) {
|
|
130
161
|
io.stderr(`intentset init: could not create ${path}: ${(error as Error).message}\n`);
|
|
131
|
-
return
|
|
162
|
+
return false;
|
|
132
163
|
}
|
|
133
164
|
io.stdout(`created ${path}\n`);
|
|
134
165
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** How to point a repository's agents at the guide. init never edits a file it did not create, so it says rather than does. */
|
|
170
|
+
function pointers(io: Io): void {
|
|
171
|
+
io.stdout(`\nPoint your coding agents at ${AGENT_GUIDE_PATH} with one line in the file they read:\n`);
|
|
172
|
+
for (const [file, line] of AGENT_POINTERS) io.stdout(` ${file}: ${line}\n`);
|
|
138
173
|
}
|
package/src/commands/review.ts
CHANGED
|
@@ -7,13 +7,19 @@
|
|
|
7
7
|
* touches, a changed record to its own artifact and a changed source file
|
|
8
8
|
* through the claiming slice to that slice's behaviors, each start's direct
|
|
9
9
|
* dependents shown apart from its candidates. Markdown by default. Read-only.
|
|
10
|
+
*
|
|
11
|
+
* It also lists every slice whose code changed while none of the records that
|
|
12
|
+
* describe it did (Core §10: an agent updates affected semantics in the same
|
|
13
|
+
* review). A refactor changes code and no behavior, so the list is a prompt,
|
|
14
|
+
* not an error: a commit says so with an Intentset-Unchanged trailer, and
|
|
15
|
+
* `--fail-on-drift` fails only on slices no commit has spoken for.
|
|
10
16
|
*/
|
|
11
17
|
import { type Graph, impact, compareStrings } from "@intentset/core";
|
|
12
|
-
import { type Changes, changedFiles, chooseBase } from "../git.ts";
|
|
13
|
-
import { matchGlob } from "../glob.ts";
|
|
18
|
+
import { acknowledgedSlices, type Changes, changedFiles, chooseBase, UNCHANGED_TRAILER } from "../git.ts";
|
|
14
19
|
import { count, formatDiagnostic, type Io, json, snapshotLine, TOOL } from "../output.ts";
|
|
15
20
|
import { levelJson, levelSections } from "../reports.ts";
|
|
16
21
|
import type { Session } from "../session.ts";
|
|
22
|
+
import { activeSlices, claimingSlices, describingRecords, isSliceCode } from "../slices.ts";
|
|
17
23
|
import { pathText, REACHABILITY_NOTE } from "./impact.ts";
|
|
18
24
|
|
|
19
25
|
export interface Attribution {
|
|
@@ -27,9 +33,6 @@ export interface Attribution {
|
|
|
27
33
|
export function attribute(graph: Graph, changed: readonly string[]): { starts: Attribution[]; unattributed: string[] } {
|
|
28
34
|
const byPath = new Map<string, string>();
|
|
29
35
|
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
36
|
const starts = new Map<string, Set<string>>();
|
|
34
37
|
const add = (id: string, reason: string) => {
|
|
35
38
|
const set = starts.get(id) ?? new Set<string>();
|
|
@@ -43,7 +46,7 @@ export function attribute(graph: Graph, changed: readonly string[]): { starts: A
|
|
|
43
46
|
add(id, `${path} is its record`);
|
|
44
47
|
continue;
|
|
45
48
|
}
|
|
46
|
-
const owners =
|
|
49
|
+
const owners = claimingSlices(graph, path);
|
|
47
50
|
let attributed = false;
|
|
48
51
|
for (const slice of owners) {
|
|
49
52
|
for (const behavior of slice.meta.links.implements ?? []) {
|
|
@@ -62,9 +65,46 @@ export function attribute(graph: Graph, changed: readonly string[]): { starts: A
|
|
|
62
65
|
};
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
export interface Drift {
|
|
69
|
+
slice: string;
|
|
70
|
+
title: string;
|
|
71
|
+
/** The slice's changed code: files its source, backend or contract claims match. */
|
|
72
|
+
code: string[];
|
|
73
|
+
/** The records that describe the slice, none of which changed. */
|
|
74
|
+
records: string[];
|
|
75
|
+
/** True when a commit after the base names the slice in an Intentset-Unchanged trailer. */
|
|
76
|
+
acknowledged: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const DRIFT_NOTE =
|
|
80
|
+
"Slices whose code changed while none of the records that describe them did: the slice, its behaviors, their " +
|
|
81
|
+
"rules, scenarios and verifications, its contracts and their decisions. If behavior changed, update those records " +
|
|
82
|
+
`in this change. If it did not, say so in a commit with the trailer \`${UNCHANGED_TRAILER}: <slice ID>\`.`;
|
|
83
|
+
|
|
84
|
+
/** Each slice whose code is among the changed files while none of its describing records is, sorted by slice ID. */
|
|
85
|
+
export function drift(graph: Graph, changed: readonly string[], acknowledged: ReadonlySet<string>): Drift[] {
|
|
86
|
+
const records = new Set([...graph.artifacts.values()].map((artifact) => artifact.path));
|
|
87
|
+
const touched = new Set(changed);
|
|
88
|
+
const out: Drift[] = [];
|
|
89
|
+
for (const slice of activeSlices(graph)) {
|
|
90
|
+
const code = changed.filter((path) => !records.has(path) && isSliceCode(slice, path));
|
|
91
|
+
if (code.length === 0) continue;
|
|
92
|
+
const describing = describingRecords(graph, slice);
|
|
93
|
+
if (describing.some((id) => touched.has(graph.artifacts.get(id)?.path ?? ""))) continue;
|
|
94
|
+
out.push({
|
|
95
|
+
slice: slice.meta.id,
|
|
96
|
+
title: slice.meta.title,
|
|
97
|
+
code,
|
|
98
|
+
records: describing,
|
|
99
|
+
acknowledged: acknowledged.has(slice.meta.id),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
65
105
|
export function reviewCommand(
|
|
66
106
|
session: Session,
|
|
67
|
-
options: { base?: string; levelReason: string },
|
|
107
|
+
options: { base?: string; levelReason: string; failOnDrift?: boolean },
|
|
68
108
|
asJson: boolean,
|
|
69
109
|
io: Io,
|
|
70
110
|
): number {
|
|
@@ -80,6 +120,9 @@ export function reviewCommand(
|
|
|
80
120
|
if ("unavailable" in chosen) changes.baseUnavailable = chosen.unavailable;
|
|
81
121
|
const changed = [...new Set([...changes.committed, ...changes.uncommitted])].sort(compareStrings);
|
|
82
122
|
const { starts, unattributed } = attribute(graph, changed);
|
|
123
|
+
const drifted = drift(graph, changed, new Set(acknowledgedSlices(repo.root, base)));
|
|
124
|
+
const failing = options.failOnDrift === true && drifted.some((item) => !item.acknowledged);
|
|
125
|
+
const exit = session.errors > 0 || failing ? 1 : 0;
|
|
83
126
|
const impacts = starts.map(({ start, because }) => {
|
|
84
127
|
const artifact = graph.artifacts.get(start);
|
|
85
128
|
const report = impact(graph, start);
|
|
@@ -108,10 +151,11 @@ export function reviewCommand(
|
|
|
108
151
|
...levelJson(session),
|
|
109
152
|
note: REACHABILITY_NOTE,
|
|
110
153
|
impact: impacts,
|
|
154
|
+
drift: drifted,
|
|
111
155
|
unattributed,
|
|
112
156
|
}),
|
|
113
157
|
);
|
|
114
|
-
return
|
|
158
|
+
return exit;
|
|
115
159
|
}
|
|
116
160
|
|
|
117
161
|
const baseLine =
|
|
@@ -147,9 +191,22 @@ export function reviewCommand(
|
|
|
147
191
|
if (item.candidates.length === 0) out.push("- none");
|
|
148
192
|
for (const hit of item.candidates) out.push(`- ${hit.id} (${hit.type}): \`${pathText(item.start, hit)}\``);
|
|
149
193
|
}
|
|
194
|
+
const open = drifted.filter((item) => !item.acknowledged).length;
|
|
195
|
+
out.push("", `## Code changed, records unchanged (${drifted.length}, ${open} not acknowledged)`, "", DRIFT_NOTE);
|
|
196
|
+
if (drifted.length === 0) out.push("", "None.");
|
|
197
|
+
for (const item of drifted) {
|
|
198
|
+
out.push("", `### ${item.slice}: ${item.title}`, "");
|
|
199
|
+
out.push(
|
|
200
|
+
item.acknowledged
|
|
201
|
+
? `- Acknowledged: a commit since the base says this changes no behavior (${UNCHANGED_TRAILER}).`
|
|
202
|
+
: "- Not acknowledged: update the records below, or say in a commit that no behavior changed.",
|
|
203
|
+
);
|
|
204
|
+
out.push(...item.code.map((path) => `- Code changed: ${path}`));
|
|
205
|
+
out.push(`- Records describing it, none changed: ${item.records.join(", ")}`);
|
|
206
|
+
}
|
|
150
207
|
out.push("", `## Changed files no artifact accounts for (${unattributed.length})`, "");
|
|
151
208
|
if (unattributed.length === 0) out.push("None.");
|
|
152
209
|
else out.push(...unattributed.map((path) => `- ${path}`));
|
|
153
210
|
io.stdout(`${out.join("\n")}\n`);
|
|
154
|
-
return
|
|
211
|
+
return exit;
|
|
155
212
|
}
|
package/src/git.ts
CHANGED
|
@@ -79,3 +79,18 @@ export function changedFiles(root: string, base: Base | null): Omit<Changes, "ba
|
|
|
79
79
|
uncommitted: [...new Set(uncommitted)].sort(compareStrings),
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The trailer a commit carries to say it changes a slice's code without
|
|
85
|
+
* changing its behavior: `Intentset-Unchanged: SLICE-A, SLICE-B`. A reviewer
|
|
86
|
+
* reads it in the commit, so the claim is made where it can be questioned.
|
|
87
|
+
*/
|
|
88
|
+
export const UNCHANGED_TRAILER = "Intentset-Unchanged";
|
|
89
|
+
|
|
90
|
+
/** The slice IDs named in an Intentset-Unchanged trailer of any commit after the base, sorted. */
|
|
91
|
+
export function acknowledgedSlices(root: string, base: Base | null): string[] {
|
|
92
|
+
if (base === null) return [];
|
|
93
|
+
const out = git(root, ["log", `--format=%(trailers:key=${UNCHANGED_TRAILER},valueonly)`, `${base.commit}..HEAD`]);
|
|
94
|
+
if (out === null) return [];
|
|
95
|
+
return [...new Set(out.split(/[\s,]+/).filter((id) => id !== ""))].sort(compareStrings);
|
|
96
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -33,10 +33,12 @@ export type { Io } from "./output.ts";
|
|
|
33
33
|
export const USAGE = `usage: intentset <command> [options]
|
|
34
34
|
|
|
35
35
|
commands
|
|
36
|
-
init [--repository <name>] [--example]
|
|
37
|
-
create .intentset/config.yaml
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
init [--repository <name>] [--example] [--agents]
|
|
37
|
+
create .intentset/config.yaml, an empty .intentset/registries.yaml and
|
|
38
|
+
.intentset/agents.md, the guide that tells coding agents how to keep the
|
|
39
|
+
model current; --example adds the scheduling example under
|
|
40
|
+
product/scheduling/; --agents writes only the guide, for a repository
|
|
41
|
+
already initialized. Never overwrites a file.
|
|
40
42
|
validate every check the level asks for; exit 1 when any diagnostic is an error
|
|
41
43
|
graph [--format json] [--include-bodies] [--release <product>:<label>] [--out <file>]
|
|
42
44
|
[--generated-at <time>] [--report evidence|knowledge|impact|ownership|all]...
|
|
@@ -52,9 +54,14 @@ commands
|
|
|
52
54
|
[--tool-version <v>]
|
|
53
55
|
turn a test report into run records bound to this commit and graph
|
|
54
56
|
impact <ID> what depends on an artifact: direct, candidates, review context, ancestors
|
|
55
|
-
context <ID> [--include-restricted]
|
|
56
|
-
the bounded engineering context of an artifact, with paths and bodies
|
|
57
|
-
|
|
57
|
+
context <ID|path> [--include-restricted]
|
|
58
|
+
the bounded engineering context of an artifact, with paths and bodies;
|
|
59
|
+
given a file a slice claims, the context of that slice
|
|
60
|
+
review [--base <ref>] [--fail-on-drift]
|
|
61
|
+
the local review report: checks, coverage, the impact of what changed,
|
|
62
|
+
and each slice whose code changed while its records did not;
|
|
63
|
+
--fail-on-drift exits 1 for any such slice no commit acknowledged with
|
|
64
|
+
an Intentset-Unchanged trailer
|
|
58
65
|
publish --visibility <v> --audience <a> --product <ID> --release <r> --role <r> --edition <e>
|
|
59
66
|
[--flag <f>]... [--authorized-internal] [--authorized-restricted] --out <dir> [--html]
|
|
60
67
|
[--published-at <time>]
|
|
@@ -84,7 +91,8 @@ options
|
|
|
84
91
|
(for mcp, --mode names the server's mode instead)
|
|
85
92
|
-h, --help show this help
|
|
86
93
|
|
|
87
|
-
exit codes: 0 no errors, 1 validation errors,
|
|
94
|
+
exit codes: 0 no errors, 1 validation errors (or, with review --fail-on-drift, unacknowledged drift),
|
|
95
|
+
2 invocation or tool failure`;
|
|
88
96
|
|
|
89
97
|
const COMMANDS = [
|
|
90
98
|
"init",
|
|
@@ -103,7 +111,7 @@ const COMMANDS = [
|
|
|
103
111
|
const GLOBAL = ["root", "json", "carrier", "level", "help"];
|
|
104
112
|
const LEVEL_OPTIONS = ["evidence", "product", "release", "mode", "baseline"];
|
|
105
113
|
const COMMAND_OPTIONS: Record<string, string[]> = {
|
|
106
|
-
init: ["repository", "example"],
|
|
114
|
+
init: ["repository", "example", "agents"],
|
|
107
115
|
validate: LEVEL_OPTIONS,
|
|
108
116
|
graph: [
|
|
109
117
|
"format",
|
|
@@ -131,7 +139,7 @@ const COMMAND_OPTIONS: Record<string, string[]> = {
|
|
|
131
139
|
],
|
|
132
140
|
impact: LEVEL_OPTIONS,
|
|
133
141
|
context: ["include-restricted", ...LEVEL_OPTIONS],
|
|
134
|
-
review: ["base", ...LEVEL_OPTIONS],
|
|
142
|
+
review: ["base", "fail-on-drift", ...LEVEL_OPTIONS],
|
|
135
143
|
serve: ["port", "host", "out", ...LEVEL_OPTIONS],
|
|
136
144
|
mcp: [
|
|
137
145
|
"mode",
|
|
@@ -168,7 +176,7 @@ const POSITIONALS: Record<string, { sub?: string; count: number; what?: string }
|
|
|
168
176
|
architecture: { sub: "check", count: 0 },
|
|
169
177
|
evidence: { sub: "import", count: 1, what: "a report file" },
|
|
170
178
|
impact: { count: 1, what: "an artifact ID" },
|
|
171
|
-
context: { count: 1, what: "an artifact ID" },
|
|
179
|
+
context: { count: 1, what: "an artifact ID or a file path" },
|
|
172
180
|
};
|
|
173
181
|
|
|
174
182
|
export async function main(argv: string[], io: Io): Promise<number> {
|
|
@@ -228,7 +236,12 @@ export async function main(argv: string[], io: Io): Promise<number> {
|
|
|
228
236
|
return usage(`--mode ${values.mode}: the modes are migration and strict`);
|
|
229
237
|
}
|
|
230
238
|
if (command === "init") {
|
|
231
|
-
|
|
239
|
+
if (values.agents && values.example)
|
|
240
|
+
return usage("--agents writes only the guide; it does not combine with --example");
|
|
241
|
+
return initCommand(
|
|
242
|
+
{ root: values.root, repository: values.repository, example: values.example, agentsOnly: values.agents },
|
|
243
|
+
io,
|
|
244
|
+
);
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
// Evidence scope: --product with --release, or graph's --release <product>:<label>.
|
|
@@ -321,7 +334,12 @@ export async function main(argv: string[], io: Io): Promise<number> {
|
|
|
321
334
|
}
|
|
322
335
|
const session = openSession(command, sessionOptions(level), io);
|
|
323
336
|
if (typeof session === "number") return session;
|
|
324
|
-
return reviewCommand(
|
|
337
|
+
return reviewCommand(
|
|
338
|
+
session,
|
|
339
|
+
{ base: values.base, levelReason: reason, failOnDrift: values["fail-on-drift"] },
|
|
340
|
+
values.json,
|
|
341
|
+
io,
|
|
342
|
+
);
|
|
325
343
|
}
|
|
326
344
|
case "serve": {
|
|
327
345
|
const port = values.port === undefined ? 3000 : Number(values.port);
|
|
@@ -391,6 +409,8 @@ function parse(argv: string[]) {
|
|
|
391
409
|
help: { type: "boolean", short: "h", default: false },
|
|
392
410
|
repository: { type: "string" },
|
|
393
411
|
example: { type: "boolean", default: false },
|
|
412
|
+
agents: { type: "boolean", default: false },
|
|
413
|
+
"fail-on-drift": { type: "boolean", default: false },
|
|
394
414
|
format: { type: "string" },
|
|
395
415
|
"include-bodies": { type: "boolean", default: false },
|
|
396
416
|
release: { type: "string" },
|
package/src/slices.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slices, the code they claim and the records that describe them, for
|
|
3
|
+
* `review`'s list of slices whose code changed while their records did not
|
|
4
|
+
* (Core §10) and for `context <path>`.
|
|
5
|
+
*
|
|
6
|
+
* A slice's code is what its `source`, `backend` and `contract` claims match;
|
|
7
|
+
* its `verification` and `documentation` claims are checks and prose, not
|
|
8
|
+
* implementation. The records that describe a slice are the slice itself, the
|
|
9
|
+
* behaviors it implements, the rules that govern them, the scenarios that
|
|
10
|
+
* illustrate them, the verifications of any of those, the contracts it
|
|
11
|
+
* exposes, and the decisions that inform the slice, its behaviors or its
|
|
12
|
+
* contracts. Knowledge is left out: it explains the model to an audience, and
|
|
13
|
+
* review pins already mark it for review when the model changes.
|
|
14
|
+
*/
|
|
15
|
+
import { type Artifact, compareStrings, type Graph } from "@intentset/core";
|
|
16
|
+
import { matchGlob } from "./glob.ts";
|
|
17
|
+
|
|
18
|
+
const CODE_CLAIMS = new Set(["source", "backend", "contract"]);
|
|
19
|
+
|
|
20
|
+
/** Every slice that is not retired and has slice metadata, sorted by ID. */
|
|
21
|
+
export function activeSlices(graph: Graph): Artifact[] {
|
|
22
|
+
return [...graph.artifacts.values()]
|
|
23
|
+
.filter((a) => a.meta.type === "slice" && a.meta.status !== "retired" && a.meta.slice !== undefined)
|
|
24
|
+
.sort((a, b) => compareStrings(a.meta.id, b.meta.id));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The slices with any claim matching `path`, sorted by ID. */
|
|
28
|
+
export function claimingSlices(graph: Graph, path: string): Artifact[] {
|
|
29
|
+
return activeSlices(graph).filter((slice) => slice.meta.slice?.claims.some((claim) => matchGlob(claim.path, path)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True when one of the slice's source, backend or contract claims matches `path`. */
|
|
33
|
+
export function isSliceCode(slice: Artifact, path: string): boolean {
|
|
34
|
+
return slice.meta.slice?.claims.some((claim) => CODE_CLAIMS.has(claim.kind) && matchGlob(claim.path, path)) ?? false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The IDs of the records that describe a slice, the slice included, sorted. Only IDs present in the graph. */
|
|
38
|
+
export function describingRecords(graph: Graph, slice: Artifact): string[] {
|
|
39
|
+
const out = new Set<string>([slice.meta.id]);
|
|
40
|
+
const targets = (id: string, kind: string) =>
|
|
41
|
+
(graph.out.get(id) ?? []).filter((edge) => edge.kind === kind).map((edge) => edge.to);
|
|
42
|
+
const sources = (id: string, kind: string) =>
|
|
43
|
+
(graph.in.get(id) ?? []).filter((edge) => edge.kind === kind).map((edge) => edge.from);
|
|
44
|
+
|
|
45
|
+
const behaviors = targets(slice.meta.id, "implements");
|
|
46
|
+
const rules = behaviors.flatMap((id) => targets(id, "governedBy"));
|
|
47
|
+
const scenarios = behaviors.flatMap((id) => sources(id, "illustrates"));
|
|
48
|
+
const claims = [...behaviors, ...rules, ...scenarios];
|
|
49
|
+
const verifications = claims.flatMap((id) => sources(id, "verifies"));
|
|
50
|
+
const contracts = targets(slice.meta.id, "exposes");
|
|
51
|
+
const decisions = [slice.meta.id, ...behaviors, ...contracts].flatMap((id) => targets(id, "informedBy"));
|
|
52
|
+
for (const id of [...claims, ...verifications, ...contracts, ...decisions]) out.add(id);
|
|
53
|
+
return [...out].filter((id) => graph.artifacts.has(id)).sort(compareStrings);
|
|
54
|
+
}
|