@intentius/chant 0.85.0 → 0.87.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/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +13 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +13 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +14 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +211 -0
- package/dist/workspace/conformance/index.d.ts.map +1 -0
- package/dist/workspace/conformance/vitest.d.ts +11 -0
- package/dist/workspace/conformance/vitest.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +28 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +40 -0
- package/dist/workspace/declared-kinds.d.ts +43 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +2 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +45 -8
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +71 -7
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +31 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +47 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +51 -0
- package/dist/workspace/record-sessions.d.ts.map +1 -0
- package/dist/workspace/record-source.d.ts +2 -0
- package/dist/workspace/record-source.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +71 -4
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-since.d.ts +90 -0
- package/dist/workspace/records-since.d.ts.map +1 -0
- package/dist/workspace/records-write.d.ts +171 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +244 -15
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +60 -0
- package/dist/workspace/runtimes.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts +90 -0
- package/dist/workspace/status-gates.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +17 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +85 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +56 -0
- package/dist/workspace/work.d.ts.map +1 -0
- package/package.json +19 -1
- package/src/cli/main.ts +55 -3
- package/src/cli/registry.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +14 -0
- package/src/workspace/__fixtures__/sessions.ts +66 -0
- package/src/workspace/checks/records.ts +19 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +7 -1
- package/src/workspace/composites.schema.json +65 -3
- package/src/workspace/composites.test.ts +95 -5
- package/src/workspace/composites.ts +28 -7
- package/src/workspace/conformance/__fixture__/app/package.json +7 -0
- package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +376 -0
- package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
- package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
- package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
- package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
- package/src/workspace/conformance/conformance.test.ts +149 -0
- package/src/workspace/conformance/index.mjs +31 -0
- package/src/workspace/conformance/index.ts +453 -0
- package/src/workspace/conformance/vitest.ts +62 -0
- package/src/workspace/declaration.schema.json +40 -0
- package/src/workspace/declaration.ts +62 -0
- package/src/workspace/declared-kinds.test.ts +321 -0
- package/src/workspace/declared-kinds.ts +76 -0
- package/src/workspace/graph-cli.ts +8 -0
- package/src/workspace/intent-cli.ts +29 -6
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +71 -19
- package/src/workspace/intent.schema.json +304 -7
- package/src/workspace/intent.test.ts +99 -0
- package/src/workspace/intent.ts +365 -46
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/read-contract.test.ts +30 -9
- package/src/workspace/reason-codes.test.ts +16 -4
- package/src/workspace/reason-codes.ts +55 -4
- package/src/workspace/record-assets.test.ts +3 -1
- package/src/workspace/record-sessions.ts +105 -0
- package/src/workspace/record-source.ts +14 -5
- package/src/workspace/records-amend.schema.json +167 -0
- package/src/workspace/records-cli.ts +308 -19
- package/src/workspace/records-contract.test.ts +57 -2
- package/src/workspace/records-formats.test.ts +640 -0
- package/src/workspace/records-new.schema.json +158 -0
- package/src/workspace/records-quorum.test.ts +196 -0
- package/src/workspace/records-review.schema.json +227 -0
- package/src/workspace/records-sessions.test.ts +108 -0
- package/src/workspace/records-since.schema.json +193 -0
- package/src/workspace/records-since.test.ts +174 -0
- package/src/workspace/records-since.ts +259 -0
- package/src/workspace/records-write-contract.test.ts +125 -0
- package/src/workspace/records-write.test.ts +373 -0
- package/src/workspace/records-write.ts +765 -0
- package/src/workspace/records.schema.json +202 -9
- package/src/workspace/records.ts +700 -41
- package/src/workspace/runtimes.ts +107 -0
- package/src/workspace/status-contract.test.ts +163 -0
- package/src/workspace/status-gates.ts +215 -0
- package/src/workspace/status.schema.json +69 -3
- package/src/workspace/status.ts +35 -2
- package/src/workspace/trust/seal.test.ts +232 -0
- package/src/workspace/trust/seal.ts +195 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +390 -0
- package/src/workspace/work.ts +163 -0
|
@@ -128,6 +128,23 @@ export interface LinkDeclaration {
|
|
|
128
128
|
pointer: string;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* A record kind the declaration names (#2680): the workspace's own, in the
|
|
133
|
+
* top-level `records`, or a member's, in that member's `records`.
|
|
134
|
+
*/
|
|
135
|
+
export interface RecordKindDeclaration {
|
|
136
|
+
/** The kind file as the entry writes it: relative to the member's directory, or to the workspace root for the workspace's own. */
|
|
137
|
+
kind: string;
|
|
138
|
+
/** The kind file from the workspace root, with / separators. */
|
|
139
|
+
path: string;
|
|
140
|
+
/** The name the entry gives the kind, or null when readers use the kind file's own `recordKind.name`. */
|
|
141
|
+
name: string | null;
|
|
142
|
+
/** The member that declares it, or null for the workspace's own. */
|
|
143
|
+
member: string | null;
|
|
144
|
+
/** The entry's JSON Pointer in the file, for messages. */
|
|
145
|
+
pointer: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
export interface Member {
|
|
132
149
|
type: "member";
|
|
133
150
|
name: string;
|
|
@@ -141,6 +158,8 @@ export interface Member {
|
|
|
141
158
|
outputs: string[] | null;
|
|
142
159
|
/** The links this member states as a consumer, in file order (#2539). */
|
|
143
160
|
links: LinkDeclaration[];
|
|
161
|
+
/** The record kinds this member declares, in file order (#2680). */
|
|
162
|
+
records: RecordKindDeclaration[];
|
|
144
163
|
upstream: string | null;
|
|
145
164
|
because: string | null;
|
|
146
165
|
suppress: Suppression[];
|
|
@@ -179,6 +198,10 @@ export interface Declaration {
|
|
|
179
198
|
pins: Pin[];
|
|
180
199
|
/** Severities set for declaration checks, keyed by `WSP` id (#2535). */
|
|
181
200
|
checks: Record<string, CheckSeverity>;
|
|
201
|
+
/** How many verdicts besides the decider's a record needs, or null when the declaration names none (#2671). */
|
|
202
|
+
quorum: number | null;
|
|
203
|
+
/** The workspace's own record kinds, from the top-level `records`, in file order (#2680). A member's are on the member. */
|
|
204
|
+
records: RecordKindDeclaration[];
|
|
182
205
|
/** The file, relative to the workspace root's tree (`chant.workspace.json` or `.jsonc`). */
|
|
183
206
|
file: string;
|
|
184
207
|
}
|
|
@@ -390,6 +413,7 @@ export function parseDeclaration(text: string, file: string, reader: string = re
|
|
|
390
413
|
kind: l.kind ?? null,
|
|
391
414
|
pointer: `${pointer}/links/${j}`,
|
|
392
415
|
}));
|
|
416
|
+
const records = recordKindsOf(e.records, e.dir as string, e.name as string, `${pointer}/records`);
|
|
393
417
|
return {
|
|
394
418
|
type: "member",
|
|
395
419
|
name: e.name as string,
|
|
@@ -399,6 +423,7 @@ export function parseDeclaration(text: string, file: string, reader: string = re
|
|
|
399
423
|
generated,
|
|
400
424
|
outputs: e.outputs === undefined ? null : [...(e.outputs as string[])],
|
|
401
425
|
links,
|
|
426
|
+
records,
|
|
402
427
|
upstream: (e.upstream as string | undefined) ?? null,
|
|
403
428
|
because: (e.because as string | undefined) ?? null,
|
|
404
429
|
suppress,
|
|
@@ -468,6 +493,21 @@ export function parseDeclaration(text: string, file: string, reader: string = re
|
|
|
468
493
|
}
|
|
469
494
|
}
|
|
470
495
|
|
|
496
|
+
// A kind file is declared once, and a name the declaration gives a kind is
|
|
497
|
+
// given once (#2680): readers key the kinds by both.
|
|
498
|
+
const ownRecords = recordKindsOf(obj.records, ".", null, "/records");
|
|
499
|
+
const byPath = new Map<string, RecordKindDeclaration>();
|
|
500
|
+
const byName = new Map<string, RecordKindDeclaration>();
|
|
501
|
+
for (const r of [...ownRecords, ...members.flatMap((m) => m.records)]) {
|
|
502
|
+
const first = byPath.get(r.path);
|
|
503
|
+
if (first) throw new WorkspaceReadError("declaration-invalid", `the record kind ${r.path} is already declared at ${first.pointer}`, at(`${r.pointer}/kind`));
|
|
504
|
+
byPath.set(r.path, r);
|
|
505
|
+
if (r.name === null) continue;
|
|
506
|
+
const named = byName.get(r.name);
|
|
507
|
+
if (named) throw new WorkspaceReadError("declaration-invalid", `the record kind name ${JSON.stringify(r.name)} is already given at ${named.pointer}`, at(`${r.pointer}/name`));
|
|
508
|
+
byName.set(r.name, r);
|
|
509
|
+
}
|
|
510
|
+
|
|
471
511
|
const pins = ((obj.pins as Record<string, string>[] | undefined) ?? []).map((p) => ({
|
|
472
512
|
package: p.package ?? null,
|
|
473
513
|
version: p.version ?? null,
|
|
@@ -484,10 +524,32 @@ export function parseDeclaration(text: string, file: string, reader: string = re
|
|
|
484
524
|
groups: entries.filter((e): e is Group => e.type === "group"),
|
|
485
525
|
pins,
|
|
486
526
|
checks: { ...((obj.checks as Record<string, CheckSeverity> | undefined) ?? {}) },
|
|
527
|
+
quorum: typeof obj.quorum === "number" ? obj.quorum : null,
|
|
528
|
+
records: ownRecords,
|
|
487
529
|
file,
|
|
488
530
|
};
|
|
489
531
|
}
|
|
490
532
|
|
|
533
|
+
/** The `records` list at `pointer`, already validated, with each kind file's path from the workspace root. */
|
|
534
|
+
function recordKindsOf(raw: unknown, dir: string, member: string | null, pointer: string): RecordKindDeclaration[] {
|
|
535
|
+
return ((raw as { kind: string; name?: string }[] | undefined) ?? []).map((r, i) => ({
|
|
536
|
+
kind: r.kind,
|
|
537
|
+
path: dir === "." ? r.kind : `${dir}/${r.kind}`,
|
|
538
|
+
name: r.name ?? null,
|
|
539
|
+
member,
|
|
540
|
+
pointer: `${pointer}/${i}`,
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Every record kind the declaration names (#2680), in the order readers use
|
|
546
|
+
* them: the workspace's own first, then each member's, members in file order
|
|
547
|
+
* and each list in its own order.
|
|
548
|
+
*/
|
|
549
|
+
export function declaredRecordKinds(declaration: Declaration): RecordKindDeclaration[] {
|
|
550
|
+
return [...declaration.records, ...declaration.members.flatMap((m) => m.records)];
|
|
551
|
+
}
|
|
552
|
+
|
|
491
553
|
/** Whether `path` is `dir` or sits under it. Both are tree-relative; `"."` is the root. */
|
|
492
554
|
export function isInside(path: string, dir: string): boolean {
|
|
493
555
|
if (dir === ".") return true;
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chant #2680: the declaration names its record kinds. The declaration reads
|
|
3
|
+
* them, `ls --json` lists them, `check` fails on one that doesn't load
|
|
4
|
+
* (WSP115), and `records` and `graph --intent` read every one of them when
|
|
5
|
+
* `--kind` is not given, while `--kind` still overrides.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
11
|
+
import { parseArgs } from "../cli/main";
|
|
12
|
+
import { cleanScratch, commitAll, contract, git, repo, writeFiles } from "./__fixtures__/contract-repo";
|
|
13
|
+
import { runDeclarationChecks } from "./checks";
|
|
14
|
+
import { declaredRecordKinds, parseDeclaration, WorkspaceReadError } from "./declaration";
|
|
15
|
+
import { intentGraph } from "./intent";
|
|
16
|
+
import intentSchema from "./intent.schema.json";
|
|
17
|
+
import { listWorkspace, listWorkspaceWithKinds, type LsDocument } from "./ls";
|
|
18
|
+
import lsSchema from "./ls.schema.json";
|
|
19
|
+
import { declaredKindFiles, queryDeclaredRecords, runWorkspaceRecords } from "./records-cli";
|
|
20
|
+
import recordsSchema from "./records.schema.json";
|
|
21
|
+
import recordsSinceSchema from "./records-since.schema.json";
|
|
22
|
+
import { runRecordsWrite } from "./records-write";
|
|
23
|
+
|
|
24
|
+
afterAll(cleanScratch);
|
|
25
|
+
|
|
26
|
+
const REF = join(import.meta.dirname, "..", "..", "..", "..", "reference-workspace", "decisions");
|
|
27
|
+
const DECISION_KIND = readFileSync(join(REF, "decision.kind.mjs"), "utf-8");
|
|
28
|
+
const DECISION_SCHEMA = readFileSync(join(REF, "decision.schema.json"), "utf-8");
|
|
29
|
+
const REF_001 = readFileSync(join(REF, "ref-001-how-the-app-is-deployed.md"), "utf-8");
|
|
30
|
+
|
|
31
|
+
/** The decision kind under another name, reading only `note-*.md`, so two kinds never read the same file. */
|
|
32
|
+
const NOTE_KIND = DECISION_KIND.replace('name: "decision"', 'name: "note"').replace(/match: "[^"]*"/, 'match: "^note-[0-9]+\\\\.md$"');
|
|
33
|
+
|
|
34
|
+
const members = (extra: Record<string, unknown> = {}) => [
|
|
35
|
+
{ name: "app", dir: "app", kind: "other", because: "a plain Node server" },
|
|
36
|
+
{ name: "design", dir: "design", kind: "other", because: "the design data member", ...extra },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const decl = (m: unknown[], extra: Record<string, unknown> = {}) => JSON.stringify({ name: "studio", schema: 1, members: m, ...extra }, null, 2);
|
|
40
|
+
|
|
41
|
+
const BASE_FILES = {
|
|
42
|
+
"app/server.mjs": "export const port = 8080;\n",
|
|
43
|
+
"decisions/decision.kind.mjs": DECISION_KIND,
|
|
44
|
+
"decisions/decision.schema.json": DECISION_SCHEMA,
|
|
45
|
+
"decisions/ref-001-how-the-app-is-deployed.md": REF_001,
|
|
46
|
+
"design/notes/note.kind.mjs": NOTE_KIND,
|
|
47
|
+
"design/notes/decision.schema.json": DECISION_SCHEMA,
|
|
48
|
+
"design/notes/note-1.md": REF_001,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A workspace whose own records hold the decision kind, and whose design member holds the note kind. */
|
|
52
|
+
function workspace(extraFiles: Record<string, string> = {}, designRecords: unknown[] = [{ kind: "notes/note.kind.mjs", name: "notes" }]): string {
|
|
53
|
+
return repo(
|
|
54
|
+
{
|
|
55
|
+
"chant.workspace.json": decl(members({ records: designRecords }), { records: [{ kind: "decisions/decision.kind.mjs" }] }),
|
|
56
|
+
...BASE_FILES,
|
|
57
|
+
...extraFiles,
|
|
58
|
+
},
|
|
59
|
+
true,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("the declaration's records (#2680)", () => {
|
|
64
|
+
test("the workspace's own and each member's, with paths from the workspace root, in reading order", () => {
|
|
65
|
+
const d = parseDeclaration(
|
|
66
|
+
decl(members({ records: [{ kind: "notes/note.kind.mjs", name: "notes" }] }), { records: [{ kind: "decisions/decision.kind.mjs" }] }),
|
|
67
|
+
"chant.workspace.json",
|
|
68
|
+
);
|
|
69
|
+
expect(d.records).toEqual([{ kind: "decisions/decision.kind.mjs", path: "decisions/decision.kind.mjs", name: null, member: null, pointer: "/records/0" }]);
|
|
70
|
+
expect(d.members.find((m) => m.name === "design")!.records).toEqual([
|
|
71
|
+
{ kind: "notes/note.kind.mjs", path: "design/notes/note.kind.mjs", name: "notes", member: "design", pointer: "/members/1/records/0" },
|
|
72
|
+
]);
|
|
73
|
+
expect(d.members.find((m) => m.name === "app")!.records).toEqual([]);
|
|
74
|
+
expect(declaredRecordKinds(d).map((k) => k.path)).toEqual(["decisions/decision.kind.mjs", "design/notes/note.kind.mjs"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a path outside the member, a path declared twice, a name given twice and an unknown field are declaration-invalid", () => {
|
|
78
|
+
const refused = (text: string) => {
|
|
79
|
+
try {
|
|
80
|
+
parseDeclaration(text, "chant.workspace.json");
|
|
81
|
+
} catch (err) {
|
|
82
|
+
expect(err).toBeInstanceOf(WorkspaceReadError);
|
|
83
|
+
return `${(err as WorkspaceReadError).code}: ${(err as WorkspaceReadError).message}`;
|
|
84
|
+
}
|
|
85
|
+
throw new Error("read");
|
|
86
|
+
};
|
|
87
|
+
expect(refused(decl(members({ records: [{ kind: "../decisions/decision.kind.mjs" }] })))).toMatch(/^declaration-invalid: .* is not a path inside the member/);
|
|
88
|
+
expect(refused(decl([{ name: "root", dir: ".", kind: "other", because: "x", records: [{ kind: "decisions/d.kind.mjs" }] }], { records: [{ kind: "decisions/d.kind.mjs" }] }))).toBe(
|
|
89
|
+
"declaration-invalid: the record kind decisions/d.kind.mjs is already declared at /records/0",
|
|
90
|
+
);
|
|
91
|
+
expect(refused(decl(members({ records: [{ kind: "a.kind.mjs", name: "x" }] }), { records: [{ kind: "b.kind.mjs", name: "x" }] }))).toBe(
|
|
92
|
+
'declaration-invalid: the record kind name "x" is already given at /records/0',
|
|
93
|
+
);
|
|
94
|
+
expect(refused(decl(members(), { records: [{ kind: "a.kind.mjs", file: "b" }] }))).toMatch(/^declaration-invalid: unknown field "file"/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("workspace ls lists the declared kinds (#2680)", () => {
|
|
99
|
+
const { expectValid } = contract(lsSchema);
|
|
100
|
+
const result = (doc: LsDocument) => {
|
|
101
|
+
expectValid(doc);
|
|
102
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
103
|
+
return doc;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
test("with each kind file's name when it loads, and the reason when it doesn't", async () => {
|
|
107
|
+
const root = workspace({ "design/broken.kind.mjs": "export const nothing = 1;\n" }, [
|
|
108
|
+
{ kind: "notes/note.kind.mjs", name: "notes" },
|
|
109
|
+
{ kind: "gone.kind.mjs" },
|
|
110
|
+
{ kind: "broken.kind.mjs" },
|
|
111
|
+
]);
|
|
112
|
+
const doc = result(await listWorkspaceWithKinds({ cwd: root }));
|
|
113
|
+
expect(doc.contract).toBe(1);
|
|
114
|
+
expect(doc.workspace.records).toEqual([{ name: "decision", path: "decisions/decision.kind.mjs", kind: "decision", reason: null }]);
|
|
115
|
+
const design = doc.members.find((m) => m.name === "design")!;
|
|
116
|
+
expect(design.readable).toBe(true);
|
|
117
|
+
expect(design.records).toEqual([
|
|
118
|
+
{ name: "notes", path: "design/notes/note.kind.mjs", kind: "note", reason: null },
|
|
119
|
+
{ name: null, path: "design/gone.kind.mjs", kind: null, reason: { code: "kind-unreadable", message: "kind file design/gone.kind.mjs does not exist" } },
|
|
120
|
+
{ name: null, path: "design/broken.kind.mjs", kind: null, reason: { code: "kind-invalid", message: "kind file design/broken.kind.mjs is not a record kind: it has no recordKind export" } },
|
|
121
|
+
]);
|
|
122
|
+
expect(doc.members.find((m) => m.name === "app")!.records).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("listWorkspace lists them unloaded, and a workspace that names none lists empty arrays", async () => {
|
|
126
|
+
const doc = result(listWorkspace({ cwd: workspace() }));
|
|
127
|
+
expect(doc.workspace.records).toEqual([{ name: null, path: "decisions/decision.kind.mjs", kind: null, reason: null }]);
|
|
128
|
+
const none = result(await listWorkspaceWithKinds({ cwd: repo({ "chant.workspace.json": decl(members()), "app/x": "", "design/x": "" }) }));
|
|
129
|
+
expect(none.workspace.records).toEqual([]);
|
|
130
|
+
expect(none.members.map((m) => m.records)).toEqual([[], []]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("--at looks for the kind file in the revision", async () => {
|
|
134
|
+
const root = workspace();
|
|
135
|
+
const at = git(root, "rev-parse", "HEAD");
|
|
136
|
+
git(root, "rm", "-q", "design/notes/note.kind.mjs");
|
|
137
|
+
commitAll(root, "drop the note kind");
|
|
138
|
+
const now = result(await listWorkspaceWithKinds({ cwd: root }));
|
|
139
|
+
expect(now.members.find((m) => m.name === "design")!.records[0].reason?.code).toBe("kind-unreadable");
|
|
140
|
+
// The revision still has the file; it loads from the working tree, where it is gone.
|
|
141
|
+
const then = result(await listWorkspaceWithKinds({ cwd: root, at }));
|
|
142
|
+
expect(then.members.find((m) => m.name === "design")!.records[0].reason?.code).toBe("kind-unreadable");
|
|
143
|
+
expect(then.members.find((m) => m.name === "design")!.records[0].reason?.message).toContain("could not be loaded");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("WSP115: a declared kind that doesn't load (#2680)", () => {
|
|
148
|
+
test("fails check with the reason, pointing at the entry", async () => {
|
|
149
|
+
const root = workspace({ "design/broken.kind.mjs": "export const nothing = 1;\n" }, [{ kind: "gone.kind.mjs" }, { kind: "broken.kind.mjs" }]);
|
|
150
|
+
const report = await runDeclarationChecks(root);
|
|
151
|
+
const wsp115 = report.diagnostics.filter((d) => d.ruleId === "WSP115");
|
|
152
|
+
expect(wsp115.map((d) => [d.entity, d.message])).toEqual([
|
|
153
|
+
["design", "member design declares the record kind gone.kind.mjs, which can't be loaded: kind-unreadable: kind file design/gone.kind.mjs does not exist"],
|
|
154
|
+
["design", "member design declares the record kind broken.kind.mjs, which can't be loaded: kind-invalid: kind file design/broken.kind.mjs is not a record kind: it has no recordKind export"],
|
|
155
|
+
]);
|
|
156
|
+
expect(wsp115.every((d) => d.severity === "error" && d.line > 1)).toBe(true);
|
|
157
|
+
expect(report.ok).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("finds nothing when every declared kind loads, and the check can't be suppressed", async () => {
|
|
161
|
+
expect((await runDeclarationChecks(workspace())).diagnostics.filter((d) => d.ruleId === "WSP115")).toEqual([]);
|
|
162
|
+
const root = workspace({}, [{ kind: "gone.kind.mjs" }]);
|
|
163
|
+
writeFiles(root, {
|
|
164
|
+
"chant.workspace.json": decl(members({ records: [{ kind: "gone.kind.mjs" }], suppress: [{ check: "WSP115", because: "later" }] })),
|
|
165
|
+
});
|
|
166
|
+
const ids = (await runDeclarationChecks(root)).diagnostics.map((d) => d.ruleId);
|
|
167
|
+
expect(ids).toContain("WSP115");
|
|
168
|
+
expect(ids).toContain("WSP011");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("records without --kind reads every declared kind (#2680)", () => {
|
|
173
|
+
const { expectValid } = contract(recordsSchema);
|
|
174
|
+
let out: string[];
|
|
175
|
+
let err: string[];
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
out = [];
|
|
178
|
+
err = [];
|
|
179
|
+
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => void out.push(a.join(" ")));
|
|
180
|
+
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => void err.push(a.join(" ")));
|
|
181
|
+
});
|
|
182
|
+
afterEach(() => vi.restoreAllMocks());
|
|
183
|
+
const run = (cwd: string, ...argv: string[]) => {
|
|
184
|
+
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
|
185
|
+
return runWorkspaceRecords({ args: parseArgs(["workspace", "records", ...argv]), plugins: [] } as never);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
test("one document per kind in the declaration's order, as a set that validates", async () => {
|
|
189
|
+
const root = workspace();
|
|
190
|
+
const set = await queryDeclaredRecords(declaredKindFiles(root), { cwd: root, current: true });
|
|
191
|
+
expectValid(set);
|
|
192
|
+
expect(set.kinds.map((k) => ["error" in k ? k.error.code : k.kind.name, k.declared])).toEqual([
|
|
193
|
+
["decision", { member: null, path: "decisions/decision.kind.mjs", name: null }],
|
|
194
|
+
["note", { member: "design", path: "design/notes/note.kind.mjs", name: "notes" }],
|
|
195
|
+
]);
|
|
196
|
+
expect(await run(root, "--json")).toBe(0);
|
|
197
|
+
const printed = JSON.parse(out.join("\n"));
|
|
198
|
+
expectValid(printed);
|
|
199
|
+
expect(printed.kinds.map((k: { records: { id: string }[] }) => k.records.map((r) => r.id))).toEqual([["ref-001"], ["ref-001"]]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a kind that can't be read is listed with its error, the others are read, and the exit code is 1", async () => {
|
|
203
|
+
const root = workspace({}, [{ kind: "gone.kind.mjs" }]);
|
|
204
|
+
expect(await run(root, "--json")).toBe(1);
|
|
205
|
+
const printed = JSON.parse(out.join("\n"));
|
|
206
|
+
expectValid(printed);
|
|
207
|
+
expect(printed.kinds.map((k: { error?: { code: string }; records?: unknown[] }) => k.error?.code ?? k.records!.length)).toEqual([1, "kind-unreadable"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("--kind overrides, and with neither --kind nor declared kinds the message is today's", async () => {
|
|
211
|
+
const root = workspace();
|
|
212
|
+
expect(await run(root, "--kind", "design/notes/note.kind.mjs", "--json")).toBe(0);
|
|
213
|
+
const one = JSON.parse(out.join("\n"));
|
|
214
|
+
expect(one.kind.name).toBe("note");
|
|
215
|
+
expect(one.kinds).toBeUndefined();
|
|
216
|
+
|
|
217
|
+
const bare = repo({ "chant.workspace.json": decl(members()), "app/x": "", "design/x": "" });
|
|
218
|
+
expect(await run(bare)).toBe(1);
|
|
219
|
+
expect(err.join("\n")).toContain("--kind <kind file> is required");
|
|
220
|
+
err.length = 0;
|
|
221
|
+
expect(await run(repo({ "x.md": "" }))).toBe(1);
|
|
222
|
+
expect(err.join("\n")).toContain("--kind <kind file> is required");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("--since compares every declared kind too, one records-since document per kind (#2673)", async () => {
|
|
226
|
+
const root = workspace();
|
|
227
|
+
writeFiles(root, { "decisions/ref-009-another.md": REF_001.replace('id: "ref-001"', 'id: "ref-009"') });
|
|
228
|
+
expect(await run(root, "--since", "HEAD", "--json")).toBe(0);
|
|
229
|
+
const printed = JSON.parse(out.join("\n"));
|
|
230
|
+
contract(recordsSinceSchema).expectValid(printed);
|
|
231
|
+
expect(printed.kinds.map((k: { kind: { name: string }; declared: { path: string }; changes: { change: string; id: string }[] }) => [k.declared.path, k.kind.name, k.changes.map((c) => `${c.change} ${c.id}`)])).toEqual([
|
|
232
|
+
["decisions/decision.kind.mjs", "decision", ["new ref-009"]],
|
|
233
|
+
["design/notes/note.kind.mjs", "note", []],
|
|
234
|
+
]);
|
|
235
|
+
out.length = 0;
|
|
236
|
+
expect(await run(root, "--since", "HEAD", "--kind", "design/notes/note.kind.mjs", "--json")).toBe(0);
|
|
237
|
+
expect(JSON.parse(out.join("\n")).kind.name).toBe("note");
|
|
238
|
+
expect(await run(root, "--since", "HEAD", "--current")).toBe(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("text output heads each kind with its name and file", async () => {
|
|
242
|
+
expect(await run(workspace(), "--current")).toBe(0);
|
|
243
|
+
expect(out.filter((l) => l.includes("("))).toEqual(["decision (decisions/decision.kind.mjs)", "notes (design/notes/note.kind.mjs)"]);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("records new, amend and review without --kind (#2680)", () => {
|
|
248
|
+
let out: string[];
|
|
249
|
+
beforeEach(() => {
|
|
250
|
+
out = [];
|
|
251
|
+
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => void out.push(a.join(" ")));
|
|
252
|
+
});
|
|
253
|
+
afterEach(() => vi.restoreAllMocks());
|
|
254
|
+
const run = async (cwd: string, ...argv: string[]) => {
|
|
255
|
+
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
|
256
|
+
const status = await runRecordsWrite({ args: parseArgs(["workspace", "records", ...argv]), plugins: [] } as never);
|
|
257
|
+
return { status, doc: JSON.parse(out.join("\n")) };
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
test("write through the one kind the declaration names", async () => {
|
|
261
|
+
const root = workspace({}, []);
|
|
262
|
+
const reviewed = await run(root, "review", "ref-001", "--verdict", "agree", "--by", "bob", "--dry-run");
|
|
263
|
+
expect(reviewed).toMatchObject({ status: 0, doc: { path: expect.stringContaining("decisions/ref-001-"), review: { reviewer: "bob", verdict: "agree" }, dryRun: true } });
|
|
264
|
+
out.length = 0;
|
|
265
|
+
writeFiles(root, { "patch.json": JSON.stringify({ title: "Changed" }) });
|
|
266
|
+
const amended = await run(root, "amend", "ref-001", "--set", join(root, "patch.json"), "--dry-run");
|
|
267
|
+
expect(amended.doc.error?.code).not.toBe("write-usage-invalid");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("are refused with the kinds named when the declaration names several, and --kind picks one", async () => {
|
|
271
|
+
const root = workspace();
|
|
272
|
+
const refused = await run(root, "review", "ref-001", "--verdict", "agree", "--by", "bob", "--dry-run");
|
|
273
|
+
expect(refused.status).toBe(1);
|
|
274
|
+
expect(refused.doc.error.code).toBe("write-usage-invalid");
|
|
275
|
+
expect(refused.doc.error.message).toContain("the declaration names 2 record kinds (decisions/decision.kind.mjs, design/notes/note.kind.mjs), so name the one to write with --kind");
|
|
276
|
+
out.length = 0;
|
|
277
|
+
const picked = await run(root, "review", "ref-001", "--kind", join(root, "decisions/decision.kind.mjs"), "--verdict", "agree", "--by", "bob", "--dry-run");
|
|
278
|
+
expect(picked.status).toBe(0);
|
|
279
|
+
out.length = 0;
|
|
280
|
+
const bare = repo({ "chant.workspace.json": decl(members()), "app/x": "", "design/x": "" });
|
|
281
|
+
const none = await run(bare, "review", "ref-001", "--verdict", "agree", "--by", "bob");
|
|
282
|
+
expect(none.doc.error.message).toMatch(/^--kind <kind file> is required/);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("graph --intent without --kind reads every declared kind (#2680)", () => {
|
|
287
|
+
const { expectValid } = contract(intentSchema);
|
|
288
|
+
/** Both kinds join a commit's Unit trailer: each to its own unit, and both to one contract. */
|
|
289
|
+
const joins = (unit: string, status: string) =>
|
|
290
|
+
`\nexport function commitJoins(commit) {\n const id = commit.trailers["Unit"]?.[0];\n if (!id) return undefined;\n return { unit: { id: "${unit}-" + id }, contract: { id: "C-1", status: "${status}" } };\n}\n`;
|
|
291
|
+
|
|
292
|
+
test("in the declaration's order, and when two kinds join one commit both joins apply and the first kind's node data wins", async () => {
|
|
293
|
+
const root = workspace({
|
|
294
|
+
"decisions/decision.kind.mjs": DECISION_KIND + joins("D", "from-decisions"),
|
|
295
|
+
"design/notes/note.kind.mjs": NOTE_KIND + joins("N", "from-notes"),
|
|
296
|
+
});
|
|
297
|
+
writeFiles(root, { "app/server.mjs": "export const port = 9090;\n" });
|
|
298
|
+
git(root, "add", "-A");
|
|
299
|
+
git(root, "commit", "-q", "-m", "move the port", "-m", "Unit: U-1");
|
|
300
|
+
const sha = git(root, "rev-parse", "HEAD");
|
|
301
|
+
|
|
302
|
+
const { doc } = await intentGraph({ cwd: root, region: "app/server.mjs" });
|
|
303
|
+
expectValid(doc);
|
|
304
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
305
|
+
expect(doc.kinds).toEqual([
|
|
306
|
+
{ file: "decisions/decision.kind.mjs", name: "decision", records: "decision", joins: "function" },
|
|
307
|
+
{ file: "design/notes/note.kind.mjs", name: "note", records: "note", joins: "function" },
|
|
308
|
+
]);
|
|
309
|
+
const produced = doc.edges.filter((e) => e.kind === "produced-by" && e.from === `commit:${sha}`).map((e) => e.to);
|
|
310
|
+
expect(produced).toEqual(["unit:D-U-1", "unit:N-U-1"]);
|
|
311
|
+
const contractNode = doc.nodes.find((n) => n.id === "contract:C-1") as unknown as { plugin: string; data: { status: string } };
|
|
312
|
+
expect(contractNode.plugin).toBe("decisions/decision.kind.mjs");
|
|
313
|
+
expect(contractNode.data.status).toBe("from-decisions");
|
|
314
|
+
|
|
315
|
+
// --kind overrides the declaration, and an empty list reads none.
|
|
316
|
+
const one = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, "design/notes/note.kind.mjs")] });
|
|
317
|
+
expect("error" in one.doc ? [] : one.doc.kinds.map((k) => k.name)).toEqual(["note"]);
|
|
318
|
+
const none = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [] });
|
|
319
|
+
expect("error" in none.doc ? null : none.doc.kinds).toEqual([]);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record kinds a declaration names, loaded (#2680).
|
|
3
|
+
*
|
|
4
|
+
* `chant.workspace.json` may name the workspace's record kinds, in the
|
|
5
|
+
* top-level `records` and in each member's `records`, so a reader never has to
|
|
6
|
+
* guess which kind files exist. `chant workspace ls` lists them, `check` fails
|
|
7
|
+
* on one that doesn't load (WSP115), and `records` and `graph --intent` read
|
|
8
|
+
* every one of them when `--kind` is not given.
|
|
9
|
+
*
|
|
10
|
+
* A kind file is looked for in the tree read, which is the revision under
|
|
11
|
+
* `--at`, and loaded from the working tree, as `records --kind` loads one.
|
|
12
|
+
* Loading imports the file, so it runs only where a command already runs a
|
|
13
|
+
* kind file.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { declaredRecordKinds, type Declaration, type RecordKindDeclaration } from "./declaration";
|
|
18
|
+
import type { ReasonCode } from "./reason-codes";
|
|
19
|
+
import type { WorkspaceTree } from "./tree";
|
|
20
|
+
|
|
21
|
+
/** Why a declared kind can't be loaded: the codes `loadRecordKind` fails with. Closed, like every list of codes. */
|
|
22
|
+
export const DECLARED_KIND_REASON_CODES = ["kind-unreadable", "kind-invalid", "schema-unreadable", "schema-id-mismatch"] as const satisfies readonly ReasonCode[];
|
|
23
|
+
export type DeclaredKindReasonCode = (typeof DECLARED_KIND_REASON_CODES)[number];
|
|
24
|
+
|
|
25
|
+
export interface DeclaredKind {
|
|
26
|
+
declared: RecordKindDeclaration;
|
|
27
|
+
/** The kind file on disk, absolute. */
|
|
28
|
+
file: string;
|
|
29
|
+
/** The kind file's `recordKind.name`, or null when it wasn't loaded. */
|
|
30
|
+
kind: string | null;
|
|
31
|
+
/** Why it can't be loaded, or null. */
|
|
32
|
+
reason: { code: DeclaredKindReasonCode; message: string } | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The kind file of `declared` on disk, under the workspace root `rootOnDisk`. */
|
|
36
|
+
export function declaredKindFile(declared: RecordKindDeclaration, rootOnDisk: string): string {
|
|
37
|
+
return join(rootOnDisk, ...declared.path.split("/"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every kind the declaration names, in {@link declaredRecordKinds} order, each
|
|
42
|
+
* with its name or the reason it can't be loaded. A kind file missing from
|
|
43
|
+
* `tree` is `kind-unreadable` without a load. With `load: false`, nothing is
|
|
44
|
+
* imported and only that is checked.
|
|
45
|
+
*/
|
|
46
|
+
export async function loadDeclaredKinds(
|
|
47
|
+
declaration: Declaration,
|
|
48
|
+
tree: WorkspaceTree,
|
|
49
|
+
rootOnDisk: string,
|
|
50
|
+
options: { load?: boolean } = {},
|
|
51
|
+
): Promise<DeclaredKind[]> {
|
|
52
|
+
const declared = declaredRecordKinds(declaration);
|
|
53
|
+
if (declared.length === 0) return [];
|
|
54
|
+
const records = options.load === false ? undefined : await import("./records");
|
|
55
|
+
const out: DeclaredKind[] = [];
|
|
56
|
+
for (const d of declared) {
|
|
57
|
+
const file = declaredKindFile(d, rootOnDisk);
|
|
58
|
+
if (tree.stat(d.path) !== "file") {
|
|
59
|
+
out.push({ declared: d, file, kind: null, reason: { code: "kind-unreadable", message: `kind file ${d.path} does not exist${tree.label}` } });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!records) {
|
|
63
|
+
out.push({ declared: d, file, kind: null, reason: null });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const loaded = await records.loadRecordKind(file);
|
|
68
|
+
out.push({ declared: d, file, kind: loaded.kind.name, reason: null });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (!(err instanceof records.RecordReadError)) throw err;
|
|
71
|
+
// loadRecordKind names the file as it was given, here an absolute path: name it from the workspace root instead.
|
|
72
|
+
out.push({ declared: d, file, kind: null, reason: { code: err.code as DeclaredKindReasonCode, message: err.message.split(file).join(d.path) } });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
@@ -85,6 +85,13 @@ export interface GraphQuery {
|
|
|
85
85
|
* in {@link GraphResult.components}. The document is unchanged.
|
|
86
86
|
*/
|
|
87
87
|
components?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Called once the members have run, with the directory they were read from
|
|
90
|
+
* (the exported tree for `--at`, before it is removed) and the declared
|
|
91
|
+
* members the read covers. `--composites` reads each member's runtimes
|
|
92
|
+
* here (#2674).
|
|
93
|
+
*/
|
|
94
|
+
inTree?: (root: string, members: readonly { name: string; dir: string; kind: string }[]) => Promise<void>;
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
export interface GraphResult {
|
|
@@ -198,6 +205,7 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
|
|
|
198
205
|
executePlan(plan, args),
|
|
199
206
|
query.components ? executePlan(plan, args, () => componentGraphArgv(args)) : Promise.resolve(undefined),
|
|
200
207
|
]);
|
|
208
|
+
if (query.inTree) await query.inTree(exported ?? located.rootOnDisk, declaration.members.filter((m) => !query.members?.length || query.members.includes(m.name)));
|
|
201
209
|
for (const r of components ?? []) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
|
|
202
210
|
for (const r of results) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
|
|
203
211
|
const { inputs, failed } = compose(plan, results, query.members, declaration.members);
|
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* section B: the region, its decisions, their artifacts, the commits, and the
|
|
6
6
|
* findings. Under each decision come the commits made inside its window, and
|
|
7
7
|
* when any of them is not the decision's own work, the question #2650 B puts
|
|
8
|
-
* to the person about it (#2656).
|
|
8
|
+
* to the person about it (#2656). Without `--kind`, the walk reads every
|
|
9
|
+
* record kind the declaration names (#2680).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { resolve } from "node:path";
|
|
12
13
|
import { formatError } from "../cli/format";
|
|
13
14
|
import type { CommandContext } from "../cli/registry";
|
|
14
|
-
import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode } from "./intent";
|
|
15
|
+
import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode, type WorkNode } from "./intent";
|
|
15
16
|
|
|
16
17
|
const USAGE = "chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
|
|
17
18
|
|
|
@@ -57,6 +58,22 @@ function decisionLine(d: DecisionNode): string {
|
|
|
57
58
|
return `decision ${d.record} ${d.state ?? "stateless"}${superseded}: ${d.title ?? d.path}; constrains ${via}${by}; ${reviews}; ${d.provenance.level}${d.valid ? "" : `; invalid: ${d.reasons.map((r) => r.code).join(", ")}`}`;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** A work item, then the commits made inside its window (#2683). */
|
|
62
|
+
function workLines(doc: Result, w: WorkNode): string[] {
|
|
63
|
+
const via = w.constrains.length > 0 ? w.constrains.map((c) => `${c.entry} (${c.granularity})`).join(", ") : "through a link only";
|
|
64
|
+
const implemented = w.implements.length > 0 ? `; implements ${w.implements.map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ")}` : "";
|
|
65
|
+
const readiness = w.ready ? "; ready" : w.blockedBy.length > 0 ? `; blocked by ${w.blockedBy.map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ")}` : "";
|
|
66
|
+
const owner = w.owner ? `, owned by ${w.owner}` : "";
|
|
67
|
+
const from = w.source ? `; from ${w.source.finding} on ${w.source.region}` : "";
|
|
68
|
+
const out = [`work ${w.record} ${w.state ?? "stateless"}${owner}: ${w.title ?? w.path}; constrains ${via}${implemented}${readiness}${from}`];
|
|
69
|
+
for (const e of doc.edges.filter((x) => x.kind === "within" && x.to === w.id)) {
|
|
70
|
+
const c = doc.nodes.find((n): n is CommitNode => n.kind === "commit" && n.id === e.from);
|
|
71
|
+
if (c) out.push(` worked ${short(c.sha)} ${c.subject}; in ${w.record}'s window`);
|
|
72
|
+
}
|
|
73
|
+
for (const x of w.warnings) out.push(` warning ${x.code}: ${x.message}`);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
function artifactLine(doc: Result, a: ArtifactNode): string {
|
|
61
78
|
const by = doc.edges
|
|
62
79
|
.filter((e): e is Extract<IntentEdge, { kind: "pins" }> => e.kind === "pins" && e.to === a.id)
|
|
@@ -88,16 +105,21 @@ export function formatIntent(doc: Result): string {
|
|
|
88
105
|
const files = ofKind(doc, "file");
|
|
89
106
|
if (files.length > 0) out.push(`files ${files.length} under the region, ${files.filter((f) => f.generated).length} generated`);
|
|
90
107
|
for (const d of ofKind(doc, "decision")) out.push(decisionLine(d), ...withinLines(doc, d));
|
|
108
|
+
for (const w of ofKind(doc, "work")) out.push(...workLines(doc, w));
|
|
91
109
|
for (const a of ofKind(doc, "artifact")) out.push(artifactLine(doc, a));
|
|
92
110
|
for (const c of ofKind(doc, "commit")) out.push(...commitLine(doc, c));
|
|
93
111
|
for (const l of ofKind(doc, "link")) {
|
|
94
112
|
const r = l.row;
|
|
95
113
|
out.push(`link ${r.consumer} reads ${"producer" in r ? `${r.producer} ${r.output}` : r.input} (${r.status})`);
|
|
96
114
|
}
|
|
97
|
-
for (const f of ofKind(doc, "finding"))
|
|
115
|
+
for (const f of ofKind(doc, "finding")) {
|
|
116
|
+
const by = f.addressedBy && f.addressedBy.length > 0 ? `; addressed by ${f.addressedBy.map((w) => `${w.id} (${w.state ?? "unknown"})`).join(", ")}` : "";
|
|
117
|
+
out.push(`finding ${f.code}: ${f.message}${by}`);
|
|
118
|
+
}
|
|
98
119
|
for (const r of doc.reasons) out.push(`reason ${r.code}: ${r.message}`);
|
|
99
120
|
const kinds = doc.kinds.length === 0 ? "; no --kind, so no decisions were read" : "";
|
|
100
|
-
|
|
121
|
+
const work = ofKind(doc, "work").length;
|
|
122
|
+
out.push(`${doc.summary.commits} commits, ${doc.summary.decisions} decisions, ${work > 0 ? `${work} work ${work === 1 ? "item" : "items"}, ` : ""}${doc.summary.artifacts} artifacts, ${doc.summary.findings} findings${kinds}`);
|
|
101
123
|
return out.join("\n");
|
|
102
124
|
}
|
|
103
125
|
|
|
@@ -107,8 +129,9 @@ export async function runWorkspaceIntent(ctx: CommandContext, cwd: string): Prom
|
|
|
107
129
|
console.error(formatError({ message: "--intent needs a region: a path, path:line or path:start-end", hint: USAGE }));
|
|
108
130
|
return 1;
|
|
109
131
|
}
|
|
110
|
-
|
|
111
|
-
const
|
|
132
|
+
// No --kind: undefined, so the walk reads the kinds the declaration names (#2680).
|
|
133
|
+
const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : undefined);
|
|
134
|
+
const { doc, failed } = await intentGraph({ cwd, region: args.intent, at: args.at, kinds: kinds?.map((k) => resolve(k)) });
|
|
112
135
|
if (args.json) console.log(JSON.stringify(doc, null, 2));
|
|
113
136
|
if ("error" in doc) {
|
|
114
137
|
console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
|