@intentset/cli 0.10.0 → 0.12.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.map +1 -1
- package/dist/agents.js +43 -2
- package/dist/agents.js.map +1 -1
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +18 -3
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/graph.js +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +10 -8
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/list.d.ts +49 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +295 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/shared.d.ts +8 -0
- package/dist/commands/shared.d.ts.map +1 -1
- package/dist/commands/shared.js +15 -5
- package/dist/commands/shared.js.map +1 -1
- package/dist/glob.d.ts +4 -13
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +4 -57
- package/dist/glob.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +78 -5
- package/dist/main.js.map +1 -1
- package/dist/session.d.ts +1 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +2 -3
- package/dist/session.js.map +1 -1
- package/dist/slices.d.ts +3 -21
- package/dist/slices.d.ts.map +1 -1
- package/dist/slices.js +3 -45
- package/dist/slices.js.map +1 -1
- package/dist/subject.d.ts +2 -0
- package/dist/subject.d.ts.map +1 -1
- package/dist/subject.js +11 -1
- package/dist/subject.js.map +1 -1
- package/package.json +9 -9
- package/src/agents.ts +43 -2
- package/src/commands/context.ts +22 -2
- package/src/commands/graph.ts +1 -1
- package/src/commands/init.ts +10 -8
- package/src/commands/list.ts +346 -0
- package/src/commands/shared.ts +20 -0
- package/src/glob.ts +4 -56
- package/src/index.ts +2 -0
- package/src/main.ts +85 -6
- package/src/session.ts +2 -3
- package/src/slices.ts +3 -52
- package/src/subject.ts +13 -1
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `intentset list` (spec/list.md, ADR 0016): the records that match a selector, from the repository's tree or from
|
|
3
|
+
* an export, as a table, as the `intentset/list/0.1` result, or as IDs. The options compile to the selector `select`
|
|
4
|
+
* in core reads, or `--where` gives it whole. Never writes. Exit 0 whenever it answered, a failed validation included
|
|
5
|
+
* (the result says so); 1 with --check when a record matched or is undetermined; 2 for an invocation error.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
COUNT_FIELDS,
|
|
11
|
+
type CountField,
|
|
12
|
+
exportListMeta,
|
|
13
|
+
type ListOutput,
|
|
14
|
+
listOutput,
|
|
15
|
+
readExport,
|
|
16
|
+
readSelector,
|
|
17
|
+
type Selector,
|
|
18
|
+
type SelectorProblem,
|
|
19
|
+
select,
|
|
20
|
+
} from "@intentset/core";
|
|
21
|
+
import { count, type Io, json, snapshotLine } from "../output.ts";
|
|
22
|
+
import type { Session } from "../session.ts";
|
|
23
|
+
import { validationSummary } from "./shared.ts";
|
|
24
|
+
|
|
25
|
+
export const LIST_FORMATS = ["table", "json", "ids"] as const;
|
|
26
|
+
export type ListFormat = (typeof LIST_FORMATS)[number];
|
|
27
|
+
|
|
28
|
+
/** Each field option and the selector field it fills. */
|
|
29
|
+
const FIELD_OPTIONS: Record<string, string> = {
|
|
30
|
+
id: "ids",
|
|
31
|
+
type: "types",
|
|
32
|
+
status: "statuses",
|
|
33
|
+
delivery: "deliveries",
|
|
34
|
+
owner: "owners",
|
|
35
|
+
audience: "audiences",
|
|
36
|
+
visibility: "visibilities",
|
|
37
|
+
under: "under",
|
|
38
|
+
"has-extension": "hasExtension",
|
|
39
|
+
describing: "describing",
|
|
40
|
+
claims: "claims",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Each availability option and the dimension it fills. */
|
|
44
|
+
const AVAILABILITY_OPTIONS: Record<string, string> = {
|
|
45
|
+
product: "products",
|
|
46
|
+
release: "releases",
|
|
47
|
+
"not-release": "notReleases",
|
|
48
|
+
role: "roles",
|
|
49
|
+
edition: "editions",
|
|
50
|
+
flag: "flags",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Every option that adds to the selector, `--has` included. */
|
|
54
|
+
export const SELECTOR_OPTIONS = [...Object.keys(FIELD_OPTIONS), ...Object.keys(AVAILABILITY_OPTIONS), "has"];
|
|
55
|
+
|
|
56
|
+
export interface ListOptions {
|
|
57
|
+
/** The selector options as given, in order, each repetition kept. */
|
|
58
|
+
given: Array<{ name: string; value: string }>;
|
|
59
|
+
where?: string;
|
|
60
|
+
from?: string;
|
|
61
|
+
countBy?: string;
|
|
62
|
+
format?: string;
|
|
63
|
+
json: boolean;
|
|
64
|
+
check: boolean;
|
|
65
|
+
includeRestricted: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const HAS_SHAPE =
|
|
69
|
+
"write it as [some:|none:|all:]<out|in>:<kind>[=<status>,...], such as out:governedBy=draft or none:in:verifies";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The checks that need no repository, so a typo costs nothing: the format, the count field, and the selector,
|
|
73
|
+
* compiled from the options or read from --where. Returns the selector and format, or a message.
|
|
74
|
+
*/
|
|
75
|
+
export function listUsageProblem(
|
|
76
|
+
options: ListOptions,
|
|
77
|
+
io: Io,
|
|
78
|
+
): { problem: string } | { selector: Selector; format: ListFormat; countBy: CountField | null } {
|
|
79
|
+
const format = options.format ?? (options.json ? "json" : "table");
|
|
80
|
+
if (!(LIST_FORMATS as readonly string[]).includes(format)) {
|
|
81
|
+
return { problem: `--format ${format}: the formats are ${LIST_FORMATS.join(", ")}` };
|
|
82
|
+
}
|
|
83
|
+
if (options.json && format !== "json") {
|
|
84
|
+
return { problem: `--json is --format json; it does not combine with --format ${format}` };
|
|
85
|
+
}
|
|
86
|
+
if (options.countBy !== undefined && !(COUNT_FIELDS as readonly string[]).includes(options.countBy)) {
|
|
87
|
+
return { problem: `--count-by ${options.countBy}: the fields are ${COUNT_FIELDS.join(", ")}` };
|
|
88
|
+
}
|
|
89
|
+
if (options.from !== undefined && options.includeRestricted) {
|
|
90
|
+
return { problem: "--include-restricted reads the tree; an export withheld what it withheld when it was written" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let value: unknown;
|
|
94
|
+
let where: ((problem: SelectorProblem) => string) | undefined;
|
|
95
|
+
if (options.where !== undefined) {
|
|
96
|
+
if (options.given.length > 0) {
|
|
97
|
+
const first = options.given[0].name;
|
|
98
|
+
return { problem: `--where gives the whole selector; it does not combine with --${first}` };
|
|
99
|
+
}
|
|
100
|
+
const file = options.where;
|
|
101
|
+
try {
|
|
102
|
+
value = JSON.parse(readFileSync(resolve(io.cwd, file), "utf8"));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return { problem: `--where ${file}: ${(error as Error).message}` };
|
|
105
|
+
}
|
|
106
|
+
where = (p) => `--where ${file}: ${p.pointer === "" ? "the selector" : p.pointer}: ${p.message}`;
|
|
107
|
+
} else {
|
|
108
|
+
const compiled = compile(options.given);
|
|
109
|
+
if ("problem" in compiled) return compiled;
|
|
110
|
+
value = compiled.selector;
|
|
111
|
+
where = (p) => `${optionFor(p.pointer)}: ${p.message}`;
|
|
112
|
+
}
|
|
113
|
+
const read = readSelector(value);
|
|
114
|
+
if (!read.ok) return { problem: read.problems.map(where).join("\n ") };
|
|
115
|
+
return {
|
|
116
|
+
selector: read.selector,
|
|
117
|
+
format: format as ListFormat,
|
|
118
|
+
countBy: (options.countBy ?? null) as CountField | null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The options as a selector: a value may be a comma-separated list (one path for --claims), and repeats add values. */
|
|
123
|
+
function compile(given: ListOptions["given"]): { selector: Record<string, unknown> } | { problem: string } {
|
|
124
|
+
const selector: Record<string, unknown> = {};
|
|
125
|
+
const availability: Record<string, string[]> = {};
|
|
126
|
+
const links: unknown[] = [];
|
|
127
|
+
const add = (target: Record<string, unknown>, key: string, values: string[]) => {
|
|
128
|
+
target[key] = [...((target[key] as string[] | undefined) ?? []), ...values];
|
|
129
|
+
};
|
|
130
|
+
for (const { name, value } of given) {
|
|
131
|
+
const values = name === "claims" ? [value] : value.split(",").map((part) => part.trim());
|
|
132
|
+
if (name === "has") {
|
|
133
|
+
const link = parseHas(value);
|
|
134
|
+
if (link === null) return { problem: `--has ${value}: ${HAS_SHAPE}` };
|
|
135
|
+
links.push(link);
|
|
136
|
+
} else if (Object.hasOwn(AVAILABILITY_OPTIONS, name)) {
|
|
137
|
+
add(availability, AVAILABILITY_OPTIONS[name], values);
|
|
138
|
+
} else {
|
|
139
|
+
add(selector, FIELD_OPTIONS[name], values);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (Object.keys(availability).length > 0) selector.availability = availability;
|
|
143
|
+
if (links.length > 0) selector.links = links;
|
|
144
|
+
return { selector };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `[quantifier:]direction:kind[=status,...]`; the parts are checked by readSelector, the shape here. */
|
|
148
|
+
function parseHas(value: string): Record<string, unknown> | null {
|
|
149
|
+
const equals = value.indexOf("=");
|
|
150
|
+
const head = equals === -1 ? value : value.slice(0, equals);
|
|
151
|
+
const parts = head.split(":");
|
|
152
|
+
if (parts.length < 2 || parts.length > 3 || parts.some((part) => part === "")) return null;
|
|
153
|
+
const [quantifier, direction, kind] = parts.length === 3 ? parts : ["some", ...parts];
|
|
154
|
+
const link: Record<string, unknown> = { direction, kind, quantifier };
|
|
155
|
+
if (equals !== -1)
|
|
156
|
+
link.match = {
|
|
157
|
+
statuses: value
|
|
158
|
+
.slice(equals + 1)
|
|
159
|
+
.split(",")
|
|
160
|
+
.map((part) => part.trim()),
|
|
161
|
+
};
|
|
162
|
+
return link;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The option a compiled selector's pointer came from, for a message in the command line's words. */
|
|
166
|
+
function optionFor(pointer: string): string {
|
|
167
|
+
const [, field, second] = pointer.split("/");
|
|
168
|
+
if (field === "links") return "--has";
|
|
169
|
+
if (field === "availability") {
|
|
170
|
+
return `--${Object.keys(AVAILABILITY_OPTIONS).find((name) => AVAILABILITY_OPTIONS[name] === second) ?? "availability"}`;
|
|
171
|
+
}
|
|
172
|
+
return `--${Object.keys(FIELD_OPTIONS).find((name) => FIELD_OPTIONS[name] === field) ?? field}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The list from the repository's tree, over the session's validation. */
|
|
176
|
+
export function listCommand(
|
|
177
|
+
session: Session,
|
|
178
|
+
checked: { selector: Selector; format: ListFormat; countBy: CountField | null },
|
|
179
|
+
options: ListOptions,
|
|
180
|
+
io: Io,
|
|
181
|
+
): number {
|
|
182
|
+
const { repo, result, snapshot } = session;
|
|
183
|
+
const selection = select(result.graph, checked.selector, {
|
|
184
|
+
includeRestricted: options.includeRestricted,
|
|
185
|
+
registries: repo.registries,
|
|
186
|
+
});
|
|
187
|
+
const v = validationSummary(session, session.level);
|
|
188
|
+
const output = listOutput(
|
|
189
|
+
selection,
|
|
190
|
+
{
|
|
191
|
+
source: "tree",
|
|
192
|
+
snapshot: {
|
|
193
|
+
repository: repo.config.repository,
|
|
194
|
+
commit: snapshot.commit,
|
|
195
|
+
...(snapshot.commitUnavailable === undefined ? {} : { commitUnavailable: snapshot.commitUnavailable }),
|
|
196
|
+
uncommitted: snapshot.uncommitted === true,
|
|
197
|
+
graphHash: snapshot.graphHash,
|
|
198
|
+
},
|
|
199
|
+
validation: { level: session.level, status: v.status, errors: v.errors, warnings: v.warnings },
|
|
200
|
+
},
|
|
201
|
+
checked.countBy,
|
|
202
|
+
);
|
|
203
|
+
return print(output, checked.format, options, io, "the repository's tree");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The list from an export, read as a consumer reads one (spec/export.md §5), with no repository I/O. */
|
|
207
|
+
export function listFromExportCommand(
|
|
208
|
+
checked: { selector: Selector; format: ListFormat; countBy: CountField | null },
|
|
209
|
+
options: ListOptions & { from: string },
|
|
210
|
+
io: Io,
|
|
211
|
+
): number {
|
|
212
|
+
let bytes: Uint8Array;
|
|
213
|
+
try {
|
|
214
|
+
bytes = readFileSync(resolve(io.cwd, options.from));
|
|
215
|
+
} catch (error) {
|
|
216
|
+
io.stderr(`intentset list: --from ${options.from}: ${(error as Error).message}\n`);
|
|
217
|
+
return 2;
|
|
218
|
+
}
|
|
219
|
+
// The file is one the caller named, so its size is not a reason to refuse it.
|
|
220
|
+
const read = readExport(bytes, { maxBytes: Math.max(bytes.length, 1) });
|
|
221
|
+
if (!read.ok) {
|
|
222
|
+
const problems = read.problems.slice(0, 5).map((p) => ` ${p.pointer || "/"} ${p.message}`);
|
|
223
|
+
io.stderr(`intentset list: --from ${options.from} is not an export this reader accepts (${read.category}):\n`);
|
|
224
|
+
io.stderr(`${problems.join("\n")}\n`);
|
|
225
|
+
return 2;
|
|
226
|
+
}
|
|
227
|
+
const output = listOutput(
|
|
228
|
+
select(read.envelope, checked.selector),
|
|
229
|
+
{ source: "export", ...exportListMeta(read.envelope) },
|
|
230
|
+
checked.countBy,
|
|
231
|
+
);
|
|
232
|
+
return print(output, checked.format, options, io, `the export ${options.from}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function print(output: ListOutput, format: ListFormat, options: ListOptions, io: Io, source: string): number {
|
|
236
|
+
if (format === "json") io.stdout(json(output));
|
|
237
|
+
else if (format === "ids") {
|
|
238
|
+
io.stdout(output.rows.map((row) => `${row.id}\n`).join(""));
|
|
239
|
+
for (const line of cautions(output)) io.stderr(`intentset list: ${line}\n`);
|
|
240
|
+
} else io.stdout(renderTable(output, source));
|
|
241
|
+
const flagged = output.counts.matched + output.counts.undetermined;
|
|
242
|
+
return options.check && flagged > 0 ? 1 : 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** What a reader of bare IDs should know, said on stderr so it never lands in a captured list. */
|
|
246
|
+
function cautions(output: ListOutput): string[] {
|
|
247
|
+
const lines: string[] = [];
|
|
248
|
+
const { counts, validation } = output;
|
|
249
|
+
if (counts.undetermined > 0) {
|
|
250
|
+
lines.push(
|
|
251
|
+
`${count(counts.undetermined, "record")} undetermined, because the answer depends on a link to a withheld artifact: ${output.undetermined.join(", ")}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (validation.status === "fail") {
|
|
255
|
+
lines.push(
|
|
256
|
+
`validation failed at ${validation.level} with ${count(validation.errors, "error")}, so the graph may be incomplete; run \`intentset validate\`.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return [...lines, ...output.notes];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function renderTable(output: ListOutput, source: string): string {
|
|
263
|
+
const { counts, validation, snapshot } = output;
|
|
264
|
+
const lines = [
|
|
265
|
+
snapshotLine({
|
|
266
|
+
commit: snapshot.commit,
|
|
267
|
+
...(snapshot.commitUnavailable === undefined ? {} : { commitUnavailable: snapshot.commitUnavailable }),
|
|
268
|
+
uncommitted: snapshot.uncommitted,
|
|
269
|
+
graphHash: snapshot.graphHash,
|
|
270
|
+
}),
|
|
271
|
+
`Source: ${source}${snapshot.generatedAt === undefined ? "" : `, generated ${snapshot.generatedAt}`}`,
|
|
272
|
+
`Validation: ${validation.status} at ${validation.level}, ${count(validation.errors, "error")}, ${count(validation.warnings, "warning")}` +
|
|
273
|
+
(validation.status === "fail" ? ". The graph may be incomplete; run `intentset validate`." : ""),
|
|
274
|
+
`Selector: ${describeSelector(output.selector)}`,
|
|
275
|
+
`Considered ${counts.considered} (${counts.withheld} withheld), matched ${counts.matched}, undetermined ${counts.undetermined}`,
|
|
276
|
+
"",
|
|
277
|
+
];
|
|
278
|
+
if (output.countBy !== null) {
|
|
279
|
+
const groups = output.countBy.groups.map((group) => [group.value ?? "(none)", String(group.count)]);
|
|
280
|
+
lines.push(...columns([[output.countBy.field, "count"], ...groups], [false, true]));
|
|
281
|
+
if (output.countBy.multiValued) {
|
|
282
|
+
lines.push(
|
|
283
|
+
"",
|
|
284
|
+
`A record counts once under each ${output.countBy.field} it names, so the counts can sum to more than ${counts.matched}.`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
} else if (output.rows.length === 0) {
|
|
288
|
+
lines.push("No record matches.");
|
|
289
|
+
} else {
|
|
290
|
+
const rows = output.rows.map((row) => [row.id, row.type, row.status, row.owner, row.title]);
|
|
291
|
+
lines.push(...columns([["ID", "type", "status", "owner", "title"], ...rows], []));
|
|
292
|
+
}
|
|
293
|
+
if (output.undetermined.length > 0) {
|
|
294
|
+
lines.push(
|
|
295
|
+
"",
|
|
296
|
+
`Undetermined, because the answer depends on a link to a withheld artifact (${output.undetermined.length}):`,
|
|
297
|
+
...output.undetermined.map((id) => ` ${id}`),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (output.notes.length > 0) lines.push("", ...output.notes.map((note) => `Note: ${note}`));
|
|
301
|
+
return `${lines.join("\n")}\n`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Columns padded to their widest cell, the last left ragged; `right` aligns a column to the right. */
|
|
305
|
+
function columns(table: string[][], right: boolean[]): string[] {
|
|
306
|
+
const widths = table[0].map((_, i) => Math.max(...table.map((row) => row[i].length)));
|
|
307
|
+
return table.map((row) =>
|
|
308
|
+
row
|
|
309
|
+
.map((cell, i) => {
|
|
310
|
+
if (i === row.length - 1 && !right[i]) return cell;
|
|
311
|
+
return right[i] ? cell.padStart(widths[i]) : cell.padEnd(widths[i]);
|
|
312
|
+
})
|
|
313
|
+
.join(" "),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The normalized selector in a line: fields joined with "and", values with "or". */
|
|
318
|
+
export function describeSelector(selector: Selector): string {
|
|
319
|
+
const parts: string[] = [];
|
|
320
|
+
const values = (list: readonly string[]) => list.join(" or ");
|
|
321
|
+
for (const [field, label] of [
|
|
322
|
+
["ids", "id"],
|
|
323
|
+
["types", "type"],
|
|
324
|
+
["statuses", "status"],
|
|
325
|
+
["deliveries", "delivery"],
|
|
326
|
+
["owners", "owner"],
|
|
327
|
+
["audiences", "audience"],
|
|
328
|
+
["visibilities", "visibility"],
|
|
329
|
+
["under", "under"],
|
|
330
|
+
["hasExtension", "extension"],
|
|
331
|
+
] as const) {
|
|
332
|
+
const list = selector[field];
|
|
333
|
+
if (list !== undefined) parts.push(`${label} ${values(list)}`);
|
|
334
|
+
}
|
|
335
|
+
for (const [name, dimension] of Object.entries(AVAILABILITY_OPTIONS)) {
|
|
336
|
+
const list = selector.availability?.[dimension as keyof NonNullable<Selector["availability"]>];
|
|
337
|
+
if (list !== undefined) parts.push(`${name.replace("-", " ")} ${values(list)}`);
|
|
338
|
+
}
|
|
339
|
+
for (const link of selector.links ?? []) {
|
|
340
|
+
const match = link.match === undefined ? "" : ` matching ${describeSelector(link.match)}`;
|
|
341
|
+
parts.push(`${link.quantifier} ${link.direction} ${link.kind}${match}`);
|
|
342
|
+
}
|
|
343
|
+
if (selector.describing !== undefined) parts.push(`describing ${values(selector.describing)}`);
|
|
344
|
+
if (selector.claims !== undefined) parts.push(`claiming ${values(selector.claims)}`);
|
|
345
|
+
return parts.length === 0 ? "every record" : parts.join(", and ");
|
|
346
|
+
}
|
package/src/commands/shared.ts
CHANGED
|
@@ -3,10 +3,30 @@
|
|
|
3
3
|
* the graph validated, and the CORE003-style refusal for an ID the graph does
|
|
4
4
|
* not hold.
|
|
5
5
|
*/
|
|
6
|
+
import type { TypeScriptMissingError } from "@intentset/architecture";
|
|
6
7
|
import { type Diagnostic, ID_PATTERN } from "@intentset/core";
|
|
7
8
|
import { count } from "../output.ts";
|
|
8
9
|
import type { Session } from "../session.ts";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* What the CLI says when a check needed TypeScript 7's scanner and the `typescript` the repository gave
|
|
13
|
+
* @intentset/architecture is older or absent: the version found and the command that runs the toolchain with its
|
|
14
|
+
* own TypeScript, for the arguments that were given, with pnpm (which resolves the peer to the repository's own
|
|
15
|
+
* copy) and with npm (which installs the peer beside the CLI, so it rarely gets here).
|
|
16
|
+
*/
|
|
17
|
+
export function typescriptMissing(error: TypeScriptMissingError, argv: readonly string[]): string {
|
|
18
|
+
const args = argv.length === 0 ? "" : ` ${argv.join(" ")}`;
|
|
19
|
+
const found =
|
|
20
|
+
error.found === null
|
|
21
|
+
? "no `typescript` resolves from @intentset/architecture"
|
|
22
|
+
: `the \`typescript\` this repository gives @intentset/architecture is ${error.found}`;
|
|
23
|
+
return (
|
|
24
|
+
`intentset: the architecture check needs TypeScript 7, and ${found}. Run the toolchain without installing it, ` +
|
|
25
|
+
`as \`pnpm dlx --package=@intentset/cli --package=typescript@7 intentset${args}\`, or with npm as ` +
|
|
26
|
+
`\`npx -p @intentset/cli -p typescript@7 intentset${args}\`; or give the repository TypeScript 7.`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
/** The validation state a query ran over, so a reader knows whether the graph was whole. */
|
|
11
31
|
export function validationSummary(session: Session, level: string) {
|
|
12
32
|
return {
|
package/src/glob.ts
CHANGED
|
@@ -1,58 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pattern language of `.intentset/config.yaml` scope and ignore lists
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* is special: no `?`, no classes, no braces, no negation, so there is no
|
|
6
|
-
* precedence to explain. A pattern and a path match segment by segment, and
|
|
7
|
-
* `*` never crosses a `/`.
|
|
8
|
-
*
|
|
9
|
-
* The architecture package has its own copy for claims. Duplicating thirty
|
|
10
|
-
* lines keeps the CLI's core commands independent of it.
|
|
2
|
+
* The pattern language of `.intentset/config.yaml` scope and ignore lists and of slice claims (VSA §3). It moved to
|
|
3
|
+
* `@intentset/core` in 0.11.0 with the slice helpers that use it (ADR 0016), and the CLI re-exports it, so
|
|
4
|
+
* `matchGlob` and `matchAny` stay on its public surface.
|
|
11
5
|
*/
|
|
12
|
-
|
|
13
|
-
/** VSA §3: true when the repository-relative POSIX `path` matches `pattern`. */
|
|
14
|
-
export function matchGlob(pattern: string, path: string): boolean {
|
|
15
|
-
const p = normalize(pattern).split("/");
|
|
16
|
-
const s = path.split("/");
|
|
17
|
-
// Memo of (pattern index, path index) pairs already known not to match, so
|
|
18
|
-
// several `**` in one pattern stay polynomial rather than exponential.
|
|
19
|
-
const failed = new Set<number>();
|
|
20
|
-
const width = s.length + 1;
|
|
21
|
-
const match = (i: number, j: number): boolean => {
|
|
22
|
-
if (failed.has(i * width + j)) return false;
|
|
23
|
-
let ok: boolean;
|
|
24
|
-
if (i === p.length) ok = j === s.length;
|
|
25
|
-
else if (p[i] === "**") ok = match(i + 1, j) || (j < s.length && match(i, j + 1));
|
|
26
|
-
else ok = j < s.length && matchSegment(p[i], s[j]) && match(i + 1, j + 1);
|
|
27
|
-
if (!ok) failed.add(i * width + j);
|
|
28
|
-
return ok;
|
|
29
|
-
};
|
|
30
|
-
return match(0, 0);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** True when any of `patterns` matches `path`. */
|
|
34
|
-
export function matchAny(patterns: readonly string[], path: string): boolean {
|
|
35
|
-
return patterns.some((pattern) => matchGlob(pattern, path));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** A leading `./` names the same path; repeated `/` separate nothing. */
|
|
39
|
-
function normalize(pattern: string): string {
|
|
40
|
-
return pattern.replace(/^(?:\.\/)+/, "").replace(/\/{2,}/g, "/");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const SEGMENTS = new Map<string, RegExp>();
|
|
44
|
-
|
|
45
|
-
/** One segment: literal text, with each `*` standing for any run of characters other than `/`. */
|
|
46
|
-
function matchSegment(pattern: string, name: string): boolean {
|
|
47
|
-
if (!pattern.includes("*")) return pattern === name;
|
|
48
|
-
let regex = SEGMENTS.get(pattern);
|
|
49
|
-
if (regex === undefined) {
|
|
50
|
-
const body = pattern
|
|
51
|
-
.split("*")
|
|
52
|
-
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
53
|
-
.join("[^/]*");
|
|
54
|
-
regex = new RegExp(`^${body}$`);
|
|
55
|
-
SEGMENTS.set(pattern, regex);
|
|
56
|
-
}
|
|
57
|
-
return regex.test(name);
|
|
58
|
-
}
|
|
6
|
+
export { matchAny, matchGlob } from "@intentset/core";
|
package/src/index.ts
CHANGED
|
@@ -7,4 +7,6 @@ export { main, USAGE } from "./main.ts";
|
|
|
7
7
|
export { AGENT_GUIDE_PATH, AGENT_POINTERS, agentGuide, type PackageManager } from "./agents.ts";
|
|
8
8
|
export type { Io } from "./output.ts";
|
|
9
9
|
export { matchAny, matchGlob } from "./glob.ts";
|
|
10
|
+
/** @deprecated Moved to `@intentset/core` in 0.11.0 (ADR 0016); import them from there. Re-exported until 0.13. */
|
|
11
|
+
export { activeSlices, claimingSlices, describingRecords, isSliceCode } from "./slices.ts";
|
|
10
12
|
export { CONFIG_PATH, enumerate, findRoot, loadRepository, readCommit, type Repository } from "./repository.ts";
|
package/src/main.ts
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
* exiting, so tests run it in-process with captured io.
|
|
5
5
|
*
|
|
6
6
|
* Exit codes (Core §11, ADR 0004): 0 no errors, 1 validation errors, 2
|
|
7
|
-
* invocation or tool failure
|
|
8
|
-
*
|
|
7
|
+
* invocation or tool failure (list exits 0 whenever it answered, and 1 only
|
|
8
|
+
* with --check). validate, graph, architecture check, impact, context, list,
|
|
9
|
+
* review, evidence stale, serve, mcp and guide never write; graph --out, architecture
|
|
9
10
|
* check --write-baseline, evidence import --out, publish --out and serve
|
|
10
11
|
* --out write only what they name; init only creates; migrate rewrites the
|
|
11
12
|
* two version values of each record's frontmatter and nothing else.
|
|
12
13
|
*/
|
|
13
14
|
import { parseArgs } from "node:util";
|
|
14
|
-
import type
|
|
15
|
+
import { type Mode, TypeScriptMissingError } from "@intentset/architecture";
|
|
15
16
|
import { EVIDENCE_BINDINGS, type EvidenceBinding, LEVELS, type Level } from "@intentset/core";
|
|
16
17
|
import type { RunScope } from "@intentset/verification";
|
|
17
18
|
import { architectureCommand } from "./commands/architecture.ts";
|
|
@@ -22,6 +23,13 @@ import { graphCommand, graphUsageProblem, parseRelease } from "./commands/graph.
|
|
|
22
23
|
import { guideCommand } from "./commands/guide.ts";
|
|
23
24
|
import { impactCommand } from "./commands/impact.ts";
|
|
24
25
|
import { initCommand } from "./commands/init.ts";
|
|
26
|
+
import {
|
|
27
|
+
listCommand,
|
|
28
|
+
listFromExportCommand,
|
|
29
|
+
type ListOptions,
|
|
30
|
+
listUsageProblem,
|
|
31
|
+
SELECTOR_OPTIONS,
|
|
32
|
+
} from "./commands/list.ts";
|
|
25
33
|
import { migrateCommand, migrateUsageProblem } from "./commands/migrate.ts";
|
|
26
34
|
import { mcpCommand, mcpUsageProblem } from "./commands/mcp.ts";
|
|
27
35
|
import { publishCommand, publishUsageProblem } from "./commands/publish.ts";
|
|
@@ -29,6 +37,7 @@ import { reviewCommand } from "./commands/review.ts";
|
|
|
29
37
|
import { serveCommand } from "./commands/serve.ts";
|
|
30
38
|
import { validateCommand } from "./commands/validate.ts";
|
|
31
39
|
import { type Io, TOOL } from "./output.ts";
|
|
40
|
+
import { typescriptMissing } from "./commands/shared.ts";
|
|
32
41
|
import { type CarrierName, CARRIERS, findRoot } from "./repository.ts";
|
|
33
42
|
import { EVIDENCE_DIR, evidenceFiles, openSession, type SessionOptions } from "./session.ts";
|
|
34
43
|
|
|
@@ -50,7 +59,7 @@ commands
|
|
|
50
59
|
graph [--format json] [--include-bodies] [--release <product>:<label>] [--out <file>]
|
|
51
60
|
[--generated-at <time>] [--report evidence|knowledge|impact|ownership|all]...
|
|
52
61
|
[--include-restricted]
|
|
53
|
-
print the intentset/export/0.
|
|
62
|
+
print the intentset/export/0.5 envelope, or write it to --out;
|
|
54
63
|
each --report adds a section (ownership needs L2, evidence L3; all
|
|
55
64
|
is every one the level reads). Restricted artifacts are withheld
|
|
56
65
|
and counted unless --include-restricted
|
|
@@ -69,6 +78,19 @@ commands
|
|
|
69
78
|
exits 1 when any needs a run
|
|
70
79
|
impact <ID> what depends on an artifact: direct, candidates, review context,
|
|
71
80
|
ancestors
|
|
81
|
+
list [--id <ID|prefix*>] [--type <t>] [--status <s>] [--delivery <d>] [--owner <o>]
|
|
82
|
+
[--audience <a>] [--visibility <v>] [--under <ID>] [--product <ID>] [--release <label>]
|
|
83
|
+
[--not-release <label>] [--role <r>] [--edition <e>] [--flag <f>]
|
|
84
|
+
[--has-extension <key>] [--has [some:|none:|all:]<out|in>:<kind>[=<status>,...]]
|
|
85
|
+
[--describing <slice>] [--claims <path>] [--where <file.json>] [--from <export.json>]
|
|
86
|
+
[--include-restricted] [--count-by <field>] [--check] [--format table|json|ids]
|
|
87
|
+
the records that match: fields combine with AND, the values of one
|
|
88
|
+
field (comma-separated or repeated) with OR, and matching is exact
|
|
89
|
+
(a trailing * on an ID is a prefix). --where gives the selector as
|
|
90
|
+
JSON instead; --from reads an export, not the tree. A record whose
|
|
91
|
+
answer depends on a withheld link is undetermined, listed apart.
|
|
92
|
+
Never writes; exits 0 whenever it answered, and with --check 1 when
|
|
93
|
+
any record matched or is undetermined (spec/list.md)
|
|
72
94
|
context <ID|path> [--include-restricted]
|
|
73
95
|
the bounded engineering context of an artifact, with paths and
|
|
74
96
|
bodies; given a file a slice claims, the context of that slice
|
|
@@ -126,7 +148,8 @@ options
|
|
|
126
148
|
|
|
127
149
|
exit codes: 0 no errors, 1 validation errors (or, with review --fail-on-drift,
|
|
128
150
|
unacknowledged drift; with --fail-on-baseline-growth, a baseline
|
|
129
|
-
entry the base did not hold
|
|
151
|
+
entry the base did not hold; with list --check, a record matched or
|
|
152
|
+
undetermined), 2 invocation or tool failure`;
|
|
130
153
|
|
|
131
154
|
const COMMANDS = [
|
|
132
155
|
"init",
|
|
@@ -137,6 +160,7 @@ const COMMANDS = [
|
|
|
137
160
|
"evidence",
|
|
138
161
|
"impact",
|
|
139
162
|
"context",
|
|
163
|
+
"list",
|
|
140
164
|
"review",
|
|
141
165
|
"publish",
|
|
142
166
|
"serve",
|
|
@@ -182,6 +206,7 @@ const COMMAND_OPTIONS: Record<string, string[]> = {
|
|
|
182
206
|
],
|
|
183
207
|
impact: LEVEL_OPTIONS,
|
|
184
208
|
context: ["include-restricted", ...LEVEL_OPTIONS],
|
|
209
|
+
list: [...SELECTOR_OPTIONS, "where", "from", "count-by", "check", "format", "include-restricted"],
|
|
185
210
|
review: ["base", "fail-on-drift", "fail-on-baseline-growth", ...LEVEL_OPTIONS],
|
|
186
211
|
serve: ["port", "host", "out", ...LEVEL_OPTIONS],
|
|
187
212
|
mcp: [
|
|
@@ -223,6 +248,21 @@ const POSITIONALS: Record<string, { subs?: Record<string, number>; count: number
|
|
|
223
248
|
};
|
|
224
249
|
|
|
225
250
|
export async function main(argv: string[], io: Io): Promise<number> {
|
|
251
|
+
try {
|
|
252
|
+
return await dispatch(argv, io);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
// The one failure a command raises rather than reports: the scanner's TypeScript is not there. It arrives from
|
|
255
|
+
// the first check that needs it (architecture check, validate at L2 and above, review, a subject-bound
|
|
256
|
+
// evidence read), and every other command has already run without it.
|
|
257
|
+
if (error instanceof TypeScriptMissingError) {
|
|
258
|
+
io.stderr(`${typescriptMissing(error, argv)}\n`);
|
|
259
|
+
return 2;
|
|
260
|
+
}
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function dispatch(argv: string[], io: Io): Promise<number> {
|
|
226
266
|
let parsed: ReturnType<typeof parse>;
|
|
227
267
|
try {
|
|
228
268
|
parsed = parse(argv);
|
|
@@ -336,7 +376,7 @@ export async function main(argv: string[], io: Io): Promise<number> {
|
|
|
336
376
|
|
|
337
377
|
// Evidence scope: --product with --release, or graph's --release <product>:<label>.
|
|
338
378
|
let scope: RunScope | null = null;
|
|
339
|
-
if ((command !== "evidence" || sub === "stale") && command !== "publish" && command !== "mcp") {
|
|
379
|
+
if ((command !== "evidence" || sub === "stale") && command !== "publish" && command !== "mcp" && command !== "list") {
|
|
340
380
|
if (command === "graph" || command === "evidence") {
|
|
341
381
|
const release = values.release === undefined ? null : parseRelease(values.release);
|
|
342
382
|
scope = release === null ? null : { product: release.product, release: release.label };
|
|
@@ -425,6 +465,32 @@ export async function main(argv: string[], io: Io): Promise<number> {
|
|
|
425
465
|
const session = openSession(command, sessionOptions(levelGiven ?? "L1"), io);
|
|
426
466
|
return typeof session === "number" ? session : impactCommand(session, args[0], values.json, io);
|
|
427
467
|
}
|
|
468
|
+
case "list": {
|
|
469
|
+
const options: ListOptions = {
|
|
470
|
+
given: tokens.flatMap((token) =>
|
|
471
|
+
token.kind === "option" && SELECTOR_OPTIONS.includes(token.name)
|
|
472
|
+
? [{ name: token.name, value: token.value ?? "" }]
|
|
473
|
+
: [],
|
|
474
|
+
),
|
|
475
|
+
where: values.where,
|
|
476
|
+
from: values.from,
|
|
477
|
+
countBy: values["count-by"],
|
|
478
|
+
format: values.format,
|
|
479
|
+
json: values.json,
|
|
480
|
+
check: values.check,
|
|
481
|
+
includeRestricted: values["include-restricted"],
|
|
482
|
+
};
|
|
483
|
+
const checked = listUsageProblem(options, io);
|
|
484
|
+
if ("problem" in checked) return usage(checked.problem);
|
|
485
|
+
if (options.from !== undefined) {
|
|
486
|
+
const tree = (["root", "carrier", "level"] as const).find((name) => values[name] !== undefined);
|
|
487
|
+
if (tree !== undefined)
|
|
488
|
+
return usage(`--${tree} reads the tree; --from reads the records from the export alone`);
|
|
489
|
+
return listFromExportCommand(checked, { ...options, from: options.from }, io);
|
|
490
|
+
}
|
|
491
|
+
const session = openSession(command, sessionOptions(levelGiven ?? "L1"), io);
|
|
492
|
+
return typeof session === "number" ? session : listCommand(session, checked, options, io);
|
|
493
|
+
}
|
|
428
494
|
case "context": {
|
|
429
495
|
const session = openSession(command, sessionOptions(levelGiven ?? "L1"), io);
|
|
430
496
|
if (typeof session === "number") return session;
|
|
@@ -567,6 +633,19 @@ function parse(argv: string[]) {
|
|
|
567
633
|
host: { type: "string" },
|
|
568
634
|
to: { type: "string" },
|
|
569
635
|
"allow-dirty": { type: "boolean", default: false },
|
|
636
|
+
id: { type: "string", multiple: true },
|
|
637
|
+
type: { type: "string", multiple: true },
|
|
638
|
+
status: { type: "string", multiple: true },
|
|
639
|
+
delivery: { type: "string", multiple: true },
|
|
640
|
+
owner: { type: "string", multiple: true },
|
|
641
|
+
under: { type: "string", multiple: true },
|
|
642
|
+
"not-release": { type: "string", multiple: true },
|
|
643
|
+
has: { type: "string", multiple: true },
|
|
644
|
+
"has-extension": { type: "string", multiple: true },
|
|
645
|
+
describing: { type: "string", multiple: true },
|
|
646
|
+
claims: { type: "string", multiple: true },
|
|
647
|
+
where: { type: "string" },
|
|
648
|
+
"count-by": { type: "string" },
|
|
570
649
|
},
|
|
571
650
|
});
|
|
572
651
|
}
|
package/src/session.ts
CHANGED
|
@@ -48,11 +48,10 @@ import {
|
|
|
48
48
|
import { insideRoot } from "./outputs.ts";
|
|
49
49
|
import { formatDiagnostic, type Io, type Snapshot } from "./output.ts";
|
|
50
50
|
import { type CarrierName, CONFIG_PATH, findRoot, loadRepository, type Repository } from "./repository.ts";
|
|
51
|
-
import { bindingFor, subjectsOf } from "./subject.ts";
|
|
51
|
+
import { bindingFor, EVIDENCE_DIR, subjectsOf } from "./subject.ts";
|
|
52
52
|
import { readTree, type Tree } from "./tree.ts";
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
export const EVIDENCE_DIR = ".intentset/evidence";
|
|
54
|
+
export { EVIDENCE_DIR } from "./subject.ts";
|
|
56
55
|
|
|
57
56
|
export interface SessionOptions {
|
|
58
57
|
root?: string;
|