@intentius/chant 0.84.0 → 0.85.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/mcp/resource-handlers.d.ts +2 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +1 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/tools/composites.d.ts +44 -0
- package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
- package/dist/cli/mcp/tools/search.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +4 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -0
- package/dist/composite.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +139 -0
- package/dist/workspace/composites.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +13 -2
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +5 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +31 -3
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +27 -2
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts +7 -2
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +14 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +1 -1
- package/dist/workspace/records.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +4 -0
- package/src/cli/main.test.ts +9 -0
- package/src/cli/main.ts +8 -0
- package/src/cli/mcp/resource-handlers.ts +17 -0
- package/src/cli/mcp/server.test.ts +140 -4
- package/src/cli/mcp/server.ts +5 -1
- package/src/cli/mcp/tools/composites.ts +98 -0
- package/src/cli/mcp/tools/search.ts +47 -5
- package/src/cli/registry.ts +2 -0
- package/src/components/cli-support.test.ts +16 -0
- package/src/components/cli-support.ts +8 -2
- package/src/composite.ts +9 -0
- package/src/lexicon.ts +47 -0
- package/src/workspace/__fixtures__/contract-repo.ts +17 -0
- package/src/workspace/composites.schema.json +471 -0
- package/src/workspace/composites.test.ts +244 -0
- package/src/workspace/composites.ts +295 -0
- package/src/workspace/graph-cli.ts +32 -4
- package/src/workspace/intent-cli.ts +26 -2
- package/src/workspace/intent-joins.ts +48 -3
- package/src/workspace/intent.schema.json +80 -17
- package/src/workspace/intent.test.ts +138 -20
- package/src/workspace/intent.ts +72 -14
- package/src/workspace/member-commands.ts +11 -5
- package/src/workspace/read-contract.test.ts +31 -3
- package/src/workspace/reason-codes.test.ts +34 -1
- package/src/workspace/reason-codes.ts +22 -0
- package/src/workspace/records-contract.test.ts +22 -2
- package/src/workspace/records.schema.json +2 -2
- package/src/workspace/records.test.ts +93 -0
- package/src/workspace/records.ts +54 -10
package/src/workspace/intent.ts
CHANGED
|
@@ -22,24 +22,28 @@
|
|
|
22
22
|
* the graph pins it.
|
|
23
23
|
*
|
|
24
24
|
* Every gap the walk finds is a `finding` node with a closed code, never
|
|
25
|
-
* prose, so a reader can draw it and a test can assert it.
|
|
25
|
+
* prose, so a reader can draw it and a test can assert it. A commit made
|
|
26
|
+
* inside a decision's window is not taken as that decision's work: unless
|
|
27
|
+
* the decision's own unit made it, it is `decided-by-window`, shown for the
|
|
28
|
+
* person to judge (#2656). A plugin's `commitJoins` may add findings of its
|
|
29
|
+
* own, in its own code namespace. chant emits the
|
|
26
30
|
* graph and hud renders it (#2524 D8, D15). Git is read through a local
|
|
27
31
|
* `git` subprocess only: no fetch, no network.
|
|
28
32
|
*/
|
|
29
33
|
|
|
30
34
|
import { execFileSync } from "node:child_process";
|
|
31
35
|
import { realpathSync } from "node:fs";
|
|
32
|
-
import { relative, resolve, sep } from "node:path";
|
|
36
|
+
import { basename, relative, resolve, sep } from "node:path";
|
|
33
37
|
import { readDeclaration, readerVersion, resolveGroups, WORKSPACE_ERROR_CODES, WorkspaceReadError, type Declaration } from "./declaration";
|
|
34
38
|
import { classifyFile, declaredFilesUnder } from "./generated-files";
|
|
35
|
-
import { hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity } from "./intent-joins";
|
|
39
|
+
import { entityDecisions, hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity, type PluginFinding } from "./intent-joins";
|
|
36
40
|
import { loadKindRegistry } from "./kinds";
|
|
37
41
|
import { resolveLinks, type LinkTableRow } from "./links";
|
|
38
42
|
import { sourceMemberHandles } from "./member-handles";
|
|
39
43
|
import { constraintCovers, isWorkspacePath, memberHolding } from "./record-assets";
|
|
40
44
|
import { importKindModule, loadRecordKind, RecordReadError, type LoadedRecordKind } from "./records";
|
|
41
45
|
import { queryRecords, type RecordView } from "./records-cli";
|
|
42
|
-
import type { ReasonCode } from "./reason-codes";
|
|
46
|
+
import type { PluginCode, ReasonCode } from "./reason-codes";
|
|
43
47
|
import { joinPath, skippedDir, type WorkspaceTree } from "./tree";
|
|
44
48
|
import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
|
|
45
49
|
import { commitProvenance, policyAtBase, resolveBase } from "./trust/provenance";
|
|
@@ -167,8 +171,18 @@ export interface CommitNode {
|
|
|
167
171
|
signature: { level: ProvenanceLevel; reason: string; principal?: string };
|
|
168
172
|
/** The line ranges the commit changed in the region, in the commit's own version of the file; null for a file or directory region. */
|
|
169
173
|
lines: LineRange[] | null;
|
|
174
|
+
/**
|
|
175
|
+
* How the decisions constraining the region by path relate to the commit
|
|
176
|
+
* (#2656): `decided` when it falls inside a decision's window and that
|
|
177
|
+
* decision's own unit made it, `decided-by-window` when it only falls
|
|
178
|
+
* inside a window, `undecided` when it falls outside every window, and
|
|
179
|
+
* null when no record kind was read.
|
|
180
|
+
*/
|
|
181
|
+
state: CommitState | null;
|
|
170
182
|
}
|
|
171
183
|
|
|
184
|
+
export type CommitState = "decided" | "decided-by-window" | "undecided";
|
|
185
|
+
|
|
172
186
|
export interface JoinedNode {
|
|
173
187
|
id: string;
|
|
174
188
|
kind: "unit" | "contract" | "evidence";
|
|
@@ -237,9 +251,14 @@ export interface LinkNode {
|
|
|
237
251
|
export interface FindingNode {
|
|
238
252
|
id: string;
|
|
239
253
|
kind: "finding";
|
|
240
|
-
code
|
|
254
|
+
/** A closed code, or a plugin's own `plugin:<name>:<code>` (#2656). */
|
|
255
|
+
code: IntentFindingCode | PluginCode;
|
|
241
256
|
message: string;
|
|
242
257
|
concerns: string[];
|
|
258
|
+
/** For a plugin's finding: the kind file that returned it. */
|
|
259
|
+
plugin?: string;
|
|
260
|
+
/** For a plugin's finding: the refs as the plugin gave them. The ones that name a node in the graph are in concerns. */
|
|
261
|
+
refs?: string[];
|
|
243
262
|
}
|
|
244
263
|
|
|
245
264
|
export type IntentNode = RegionNode | FileNode | MemberNode | CommitNode | JoinedNode | EvidenceEntryNode | DecisionNode | ArtifactNode | LinkNode | FindingNode;
|
|
@@ -250,6 +269,7 @@ export type IntentEdge =
|
|
|
250
269
|
| { kind: "constrains"; from: string; to: string; granularity: Granularity; entry: string }
|
|
251
270
|
| { kind: "pins"; from: string; to: string; pinnedSha256: string | null; pinState: PinState }
|
|
252
271
|
| { kind: "touched-by"; from: string; to: string; lines: LineRange[] | null }
|
|
272
|
+
| { kind: "within"; from: string; to: string; state: "decided" | "decided-by-window" }
|
|
253
273
|
| { kind: "produced-by" | "serves" | "cites-evidence" | "supersedes" | "links"; from: string; to: string };
|
|
254
274
|
|
|
255
275
|
export interface IntentReason {
|
|
@@ -269,7 +289,7 @@ export type IntentDocument =
|
|
|
269
289
|
workspace: { name: string; root: string };
|
|
270
290
|
region: string;
|
|
271
291
|
history: { rev: string | null; follows: "line-range" | "file" | "directory"; shallow: boolean };
|
|
272
|
-
kinds: { file: string; records: string | null; joins: "function" | "data" | null }[];
|
|
292
|
+
kinds: { file: string; name: string; records: string | null; joins: "function" | "data" | null }[];
|
|
273
293
|
nodes: IntentNode[];
|
|
274
294
|
edges: IntentEdge[];
|
|
275
295
|
reasons: IntentReason[];
|
|
@@ -486,6 +506,8 @@ interface LoadedKind {
|
|
|
486
506
|
file: string;
|
|
487
507
|
/** Relative to the repository root, for the document. */
|
|
488
508
|
display: string;
|
|
509
|
+
/** The record kind's name, or the file's name without `.kind.mjs`: the namespace of its plugin findings. */
|
|
510
|
+
name: string;
|
|
489
511
|
records?: { loaded: LoadedRecordKind; views: RecordView[]; workspaceRoot: string };
|
|
490
512
|
joins?: CommitJoins;
|
|
491
513
|
}
|
|
@@ -503,12 +525,13 @@ async function loadKinds(query: IntentQuery, top: string): Promise<LoadedKind[]>
|
|
|
503
525
|
}
|
|
504
526
|
const joins = readCommitJoins(mod);
|
|
505
527
|
if (typeof joins === "string") throw new IntentError("kind-invalid", `kind file ${k} has a commitJoins export that can't be read: ${joins}`);
|
|
506
|
-
const kind: LoadedKind = { file, display, ...(joins ? { joins } : {}) };
|
|
528
|
+
const kind: LoadedKind = { file, display, name: basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
|
|
507
529
|
if (mod.recordKind !== undefined) {
|
|
508
530
|
const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd });
|
|
509
531
|
if ("error" in doc) throw new IntentError(doc.error.code, doc.error.message);
|
|
510
532
|
try {
|
|
511
533
|
kind.records = { loaded: await loadRecordKind(file), views: doc.records, workspaceRoot: doc.workspaceRoot };
|
|
534
|
+
kind.name = kind.records.loaded.kind.name;
|
|
512
535
|
} catch (err) {
|
|
513
536
|
if (err instanceof RecordReadError) throw new IntentError(err.code as IntentErrorCode, err.message);
|
|
514
537
|
throw err;
|
|
@@ -611,7 +634,15 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
611
634
|
if (!isWorkspacePath(path) || located.tree.stat(path) !== "file") return undefined;
|
|
612
635
|
return located.tree.read(path);
|
|
613
636
|
};
|
|
614
|
-
|
|
637
|
+
interface Joined {
|
|
638
|
+
unit?: string;
|
|
639
|
+
contracts: string[];
|
|
640
|
+
authorship: string[];
|
|
641
|
+
/** The record ids the commit's units and contracts say they carry out. */
|
|
642
|
+
decisions: string[];
|
|
643
|
+
}
|
|
644
|
+
const joined = new Map<string, Joined>();
|
|
645
|
+
const pluginFindings: { commit: string; plugin: string; finding: PluginFinding }[] = [];
|
|
615
646
|
const failedPlugins = new Set<string>();
|
|
616
647
|
for (const t of touched) {
|
|
617
648
|
const c = details.get(t.sha);
|
|
@@ -624,17 +655,17 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
624
655
|
: { level: "unattested" as const, reason: policy.problems.length ? policy.problems.join("; ") : `no signers file (${policy.signersPath}) at base; attestation is off` };
|
|
625
656
|
const pr = c.subject.match(/\(#([0-9]+)\)\s*$/);
|
|
626
657
|
const cid = `commit:${c.sha}`;
|
|
627
|
-
add<CommitNode>({ id: cid, kind: "commit", sha: c.sha, subject: c.subject, author: c.author, date: c.date, trailers: c.trailers, pullRequest: pr ? Number(pr[1]) : null, signature, lines: t.lines });
|
|
658
|
+
add<CommitNode>({ id: cid, kind: "commit", sha: c.sha, subject: c.subject, author: c.author, date: c.date, trailers: c.trailers, pullRequest: pr ? Number(pr[1]) : null, signature, lines: t.lines, state: null });
|
|
628
659
|
edges.push({ kind: "touched-by", from: rid, to: cid, lines: t.lines });
|
|
629
660
|
|
|
630
661
|
// 2. Each commit's origin, from the plugins.
|
|
631
|
-
const entry = { contracts: []
|
|
662
|
+
const entry: Joined = { contracts: [], authorship: [], decisions: [] };
|
|
632
663
|
joined.set(c.sha, entry);
|
|
633
664
|
for (const k of kinds) {
|
|
634
665
|
if (!k.joins) continue;
|
|
635
666
|
let result;
|
|
636
667
|
try {
|
|
637
|
-
result = await runCommitJoins(k.joins, c, { read: readAt, at: located.at });
|
|
668
|
+
result = await runCommitJoins(k.joins, c, { read: readAt, at: located.at }, k.name);
|
|
638
669
|
} catch (err) {
|
|
639
670
|
const message = `${k.display}: commitJoins failed for ${c.sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
|
|
640
671
|
if (!failedPlugins.has(message)) reasons.push({ code: "intent-plugin-failed", message });
|
|
@@ -653,6 +684,8 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
653
684
|
if (contractId) edges.push({ kind: "serves", from: unitId, to: contractId });
|
|
654
685
|
}
|
|
655
686
|
if (contractId) entry.contracts.push(result.contract!.id);
|
|
687
|
+
if (unitId) entry.decisions.push(...entityDecisions(result.unit), ...entityDecisions(result.contract));
|
|
688
|
+
for (const f of result.findings ?? []) pluginFindings.push({ commit: cid, plugin: k.display, finding: f });
|
|
656
689
|
const evidence = result.evidence === undefined ? [] : Array.isArray(result.evidence) ? result.evidence : [result.evidence];
|
|
657
690
|
for (const e of evidence) edges.push({ kind: "cites-evidence", from: unitId ?? contractId ?? cid, to: joinedNode("evidence", e) });
|
|
658
691
|
entry.authorship.push(...(result.authorship ?? []));
|
|
@@ -859,11 +892,20 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
859
892
|
return !!w && w.from.has(sha) && !w.until?.has(sha);
|
|
860
893
|
});
|
|
861
894
|
const readsRecords = kinds.some((k) => k.records);
|
|
895
|
+
// A decision's own work: a commit whose unit, or that unit's contract, names
|
|
896
|
+
// the decision, or whose unit serves a contract the decision constrains.
|
|
897
|
+
const ownWork = (j: Joined, d: DecisionNode) =>
|
|
898
|
+
!!j.unit && (j.decisions.some((x) => x === d.record || x === `${d.recordKind}/${d.record}`) || d.constrains.some((x) => x.granularity === "contract" && j.contracts.includes(x.entry)));
|
|
862
899
|
for (const t of touched) {
|
|
863
900
|
const c = nodes.get(`commit:${t.sha}`) as CommitNode | undefined;
|
|
864
901
|
if (!c) continue;
|
|
865
902
|
const j = joined.get(t.sha)!;
|
|
866
|
-
if (readsRecords
|
|
903
|
+
if (readsRecords) {
|
|
904
|
+
const inWindow = coveredAt(t.sha, ["path"]);
|
|
905
|
+
for (const w of inWindow) edges.push({ kind: "within", from: c.id, to: w.node.id, state: ownWork(j, w.node) ? "decided" : "decided-by-window" });
|
|
906
|
+
c.state = inWindow.length === 0 ? "undecided" : inWindow.some((w) => ownWork(j, w.node)) ? "decided" : "decided-by-window";
|
|
907
|
+
}
|
|
908
|
+
if (readsRecords && c.state === "undecided") {
|
|
867
909
|
find("intent-commit-undecided", `${t.sha.slice(0, 8)} changed the region when no decision constrained ${region.path} by path`, [c.id, rid]);
|
|
868
910
|
}
|
|
869
911
|
if (readsRecords && !j.unit && c.pullRequest === null && coveredAt(t.sha, ["path", "member", "contract", "issue"]).length === 0) {
|
|
@@ -915,7 +957,23 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
915
957
|
}
|
|
916
958
|
|
|
917
959
|
// Findings last, in the order of the code list, so the same walk always prints the same way.
|
|
918
|
-
findings.sort((x, y) => INTENT_FINDING_CODES.indexOf(x.code) - INTENT_FINDING_CODES.indexOf(y.code));
|
|
960
|
+
findings.sort((x, y) => INTENT_FINDING_CODES.indexOf(x.code as IntentFindingCode) - INTENT_FINDING_CODES.indexOf(y.code as IntentFindingCode));
|
|
961
|
+
// Then the plugins' findings, in commit order, each about its commit and the nodes its refs name.
|
|
962
|
+
const resolveRef = (ref: string): string | undefined => {
|
|
963
|
+
if (nodes.has(ref)) return ref;
|
|
964
|
+
for (const prefix of ["commit", "unit", "contract", "evidence", "artifact", "file", "member"]) if (nodes.has(`${prefix}:${ref}`)) return `${prefix}:${ref}`;
|
|
965
|
+
for (const n of nodes.values()) {
|
|
966
|
+
if (n.kind === "decision" && (ref === n.record || ref === `${n.recordKind}/${n.record}`)) return n.id;
|
|
967
|
+
if (n.kind === "commit" && /^[0-9a-f]{7,}$/.test(ref) && n.sha.startsWith(ref)) return n.id;
|
|
968
|
+
}
|
|
969
|
+
return undefined;
|
|
970
|
+
};
|
|
971
|
+
for (const { commit, plugin, finding } of pluginFindings) {
|
|
972
|
+
const code = finding.code as PluginCode;
|
|
973
|
+
const refs = finding.refs ?? [];
|
|
974
|
+
const concerns = [...new Set([commit, ...refs.map(resolveRef).filter((x): x is string => x !== undefined)])];
|
|
975
|
+
findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message: finding.message, concerns, plugin, refs });
|
|
976
|
+
}
|
|
919
977
|
for (const f of findings) nodes.set(f.id, f);
|
|
920
978
|
const all = [...nodes.values()];
|
|
921
979
|
return {
|
|
@@ -925,7 +983,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
925
983
|
workspace: { name: declaration.name, root: located.root },
|
|
926
984
|
region: rid,
|
|
927
985
|
history: { rev, follows: region.lines ? "line-range" : type === "file" ? "file" : "directory", shallow },
|
|
928
|
-
kinds: kinds.map((k) => ({ file: k.display, records: k.records?.loaded.kind.name ?? null, joins: k.joins?.form ?? null })),
|
|
986
|
+
kinds: kinds.map((k) => ({ file: k.display, name: k.name, records: k.records?.loaded.kind.name ?? null, joins: k.joins?.form ?? null })),
|
|
929
987
|
nodes: all,
|
|
930
988
|
edges,
|
|
931
989
|
reasons,
|
|
@@ -333,9 +333,12 @@ export function parseMemberRunOutput(stdout: string): { chant: string; results:
|
|
|
333
333
|
return header ? { chant: header.chant, results, stray: stray.join("\n") } : undefined;
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
-
|
|
336
|
+
/** A command line to run in each member instead of the verb's own, such as `graph --components` (#2662). */
|
|
337
|
+
export type MemberArgv = (unit: RunUnit) => string[];
|
|
338
|
+
|
|
339
|
+
async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string, args: ParsedArgs, argvFor?: MemberArgv): Promise<UnitResult[]> {
|
|
337
340
|
const { toolchain, units } = group;
|
|
338
|
-
const argvs = new Map(units.map((u) => [u.id, memberArgv(verb, u, args)]));
|
|
341
|
+
const argvs = new Map(units.map((u) => [u.id, argvFor ? argvFor(u) : memberArgv(verb, u, args)]));
|
|
339
342
|
for (const u of units) {
|
|
340
343
|
const o = argvs.get(u.id)!;
|
|
341
344
|
const i = o.indexOf("--output");
|
|
@@ -365,9 +368,12 @@ async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string
|
|
|
365
368
|
return out;
|
|
366
369
|
}
|
|
367
370
|
|
|
368
|
-
/**
|
|
369
|
-
|
|
370
|
-
|
|
371
|
+
/**
|
|
372
|
+
* Run every group of the plan, one process per toolchain at a time each, and
|
|
373
|
+
* return the results in plan order. `argvFor` replaces the verb's command line.
|
|
374
|
+
*/
|
|
375
|
+
export async function executePlan(plan: MemberPlan, args: ParsedArgs, argvFor?: MemberArgv): Promise<UnitResult[]> {
|
|
376
|
+
const perGroup = await Promise.all(plan.groups.map((g) => runGroup(plan.verb, g, plan.workspace.root, args, argvFor)));
|
|
371
377
|
const byId = new Map(perGroup.flat().map((r) => [r.id, r]));
|
|
372
378
|
const order = plan.groups.flatMap((g) => g.units);
|
|
373
379
|
return order.map((u) => byId.get(u.id)!);
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* `chant` member (`delivery`), three `other` members and decision records.
|
|
7
7
|
* Each read-contract command reads it here, and its output must validate
|
|
8
8
|
* against the command's schema: `ls`, `graph` (running delivery's real
|
|
9
|
-
* `chant graph`), `check`, `status` and `records`, in
|
|
10
|
-
* for `ls`, `graph` and `check`, at `HEAD` through
|
|
9
|
+
* `chant graph`), `graph --composites`, `check`, `status` and `records`, in
|
|
10
|
+
* the working tree and, for `ls`, `graph` and `check`, at `HEAD` through
|
|
11
|
+
* `--at`.
|
|
11
12
|
*
|
|
12
13
|
* The schemas themselves are checked here too: each is a draft 2020-12
|
|
13
14
|
* document under `https://intentius.io/chant/schemas/workspace/<command>/v1/`,
|
|
@@ -20,6 +21,8 @@ import { pathToFileURL } from "node:url";
|
|
|
20
21
|
import { describe, expect, test } from "vitest";
|
|
21
22
|
import { contract, git, REPO, validSchema } from "./__fixtures__/contract-repo";
|
|
22
23
|
import checkSchema from "./check.schema.json";
|
|
24
|
+
import { workspaceComposites } from "./composites";
|
|
25
|
+
import compositesSchema from "./composites.schema.json";
|
|
23
26
|
import { workspaceGraph } from "./graph-cli";
|
|
24
27
|
import graphSchema from "./graph.schema.json";
|
|
25
28
|
import { intentGraph } from "./intent";
|
|
@@ -37,7 +40,7 @@ import statusSchema from "./status.schema.json";
|
|
|
37
40
|
const FIXTURE = join(REPO, "reference-workspace");
|
|
38
41
|
const TIMEOUT = 240_000;
|
|
39
42
|
|
|
40
|
-
const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, intent: intentSchema };
|
|
43
|
+
const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, intent: intentSchema, composites: compositesSchema };
|
|
41
44
|
|
|
42
45
|
/** This checkout's chant, started the way the CLI starts it, for members with no toolchain of their own. */
|
|
43
46
|
const reader: Toolchain = {
|
|
@@ -131,6 +134,31 @@ describe("every schema against the reference workspace (#2543)", () => {
|
|
|
131
134
|
}
|
|
132
135
|
});
|
|
133
136
|
|
|
137
|
+
test(
|
|
138
|
+
"graph --composites runs delivery's own component graph, and says why the list is empty (#2662)",
|
|
139
|
+
async () => {
|
|
140
|
+
const { expectValid } = contract(compositesSchema);
|
|
141
|
+
for (const at of [undefined, "HEAD"]) {
|
|
142
|
+
const { doc, failed } = await workspaceComposites({ cwd: FIXTURE, at, reader });
|
|
143
|
+
expectValid(doc);
|
|
144
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
145
|
+
expect(failed, JSON.stringify(doc.members)).toBe(false);
|
|
146
|
+
expect(doc.at).toBe(at ? head : null);
|
|
147
|
+
expect(doc.members.map((m) => [m.name, m.status])).toEqual([
|
|
148
|
+
["app", "skipped"],
|
|
149
|
+
["delivery", "read"],
|
|
150
|
+
["design-client", "skipped"],
|
|
151
|
+
["design", "skipped"],
|
|
152
|
+
]);
|
|
153
|
+
// delivery has a docker Service and no composite or component.
|
|
154
|
+
expect(doc.composites).toEqual([]);
|
|
155
|
+
expect(doc.components).toEqual([]);
|
|
156
|
+
expect(doc.reasons.map((r) => r.code)).toEqual(["composites-none-declared", "composites-no-component"]);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
TIMEOUT,
|
|
160
|
+
);
|
|
161
|
+
|
|
134
162
|
test("records, in the working tree and at HEAD", async () => {
|
|
135
163
|
const { expectValid } = contract(recordsSchema);
|
|
136
164
|
for (const at of [undefined, "HEAD"]) {
|
|
@@ -10,12 +10,15 @@ import { join } from "node:path";
|
|
|
10
10
|
import { describe, expect, test } from "vitest";
|
|
11
11
|
import { REPO } from "./__fixtures__/contract-repo";
|
|
12
12
|
import { MEMBER_RUN_REASON_CODES } from "./compose-graph";
|
|
13
|
+
import { COMPOSITES_ERROR_CODES, COMPOSITES_REASON_CODES } from "./composites";
|
|
13
14
|
import { WORKSPACE_ERROR_CODES } from "./declaration";
|
|
14
15
|
import { GRAPH_ERROR_CODES } from "./graph-cli";
|
|
15
16
|
import { INTENT_ERROR_CODES, INTENT_FINDING_CODES, INTENT_REASON_CODES } from "./intent";
|
|
16
17
|
import { CHECK_CODES, CHECK_ERROR_CODES } from "./lineage-check";
|
|
17
18
|
import { GROUP_REASON_CODES, MEMBER_REASON_CODES } from "./ls";
|
|
18
|
-
import
|
|
19
|
+
import intentSchema from "./intent.schema.json";
|
|
20
|
+
import { isPluginCode, isReasonCode, REASON_CODES, REASONS } from "./reason-codes";
|
|
21
|
+
import { contract } from "./__fixtures__/contract-repo";
|
|
19
22
|
import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES } from "./records";
|
|
20
23
|
import { STATUS_ERROR_CODES, STATUS_REASON_CODES } from "./status";
|
|
21
24
|
|
|
@@ -37,6 +40,8 @@ const PER_COMMAND: Record<string, readonly string[]> = {
|
|
|
37
40
|
INTENT_ERROR_CODES,
|
|
38
41
|
INTENT_FINDING_CODES,
|
|
39
42
|
INTENT_REASON_CODES,
|
|
43
|
+
COMPOSITES_ERROR_CODES,
|
|
44
|
+
COMPOSITES_REASON_CODES,
|
|
40
45
|
};
|
|
41
46
|
|
|
42
47
|
/** Every string in an `enum` under a property named `code`, anywhere in a schema. */
|
|
@@ -111,6 +116,34 @@ describe("the closed list of reason codes", () => {
|
|
|
111
116
|
expect(seen).toBeGreaterThan(30);
|
|
112
117
|
});
|
|
113
118
|
|
|
119
|
+
test("a plugin's finding codes are in its own namespace, outside the list, and the intent schema accepts them (#2656)", () => {
|
|
120
|
+
expect(isPluginCode("plugin:chud:contract-criteria-changed")).toBe(true);
|
|
121
|
+
expect(isPluginCode("plugin:chud:contract-criteria-changed", "chud")).toBe(true);
|
|
122
|
+
expect(isPluginCode("plugin:chud:contract-criteria-changed", "units")).toBe(false);
|
|
123
|
+
for (const bad of ["plugin:chud", "plugin::x", "plugin:chud:Upper", "plugin:chud:a:b", "intent-commit-bare", 7]) expect(isPluginCode(bad), String(bad)).toBe(false);
|
|
124
|
+
expect(isReasonCode("plugin:chud:contract-criteria-changed")).toBe(false);
|
|
125
|
+
const { validate } = contract(intentSchema);
|
|
126
|
+
const finding = (code: string) => ({ id: `finding:${code}:1`, kind: "finding", code, message: "m", concerns: [] });
|
|
127
|
+
const doc = (code: string) => ({
|
|
128
|
+
$schema: intentSchema.$id,
|
|
129
|
+
contract: 1,
|
|
130
|
+
chant: "0.0.0",
|
|
131
|
+
at: null,
|
|
132
|
+
workspace: { name: "w", root: "." },
|
|
133
|
+
region: "region:.",
|
|
134
|
+
history: { rev: null, follows: "directory", shallow: false },
|
|
135
|
+
kinds: [],
|
|
136
|
+
nodes: [finding(code)],
|
|
137
|
+
edges: [],
|
|
138
|
+
reasons: [],
|
|
139
|
+
summary: { commits: 0, decisions: 0, artifacts: 0, findings: 1 },
|
|
140
|
+
});
|
|
141
|
+
expect(validate(doc("plugin:chud:contract-criteria-changed"))).toBe(true);
|
|
142
|
+
expect(validate(doc("intent-commit-bare"))).toBe(true);
|
|
143
|
+
expect(validate(doc("plugin:chud:Nope"))).toBe(false);
|
|
144
|
+
expect(validate(doc("made-up-code"))).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
114
147
|
test("the read-contract page documents every code", () => {
|
|
115
148
|
const page = readFileSync(join(REPO, "docs", "src", "content", "docs", "reference", "workspace-read-contract.mdx"), "utf-8");
|
|
116
149
|
for (const c of REASON_CODES) expect(page, c).toContain(`| \`${c}\` |`);
|
|
@@ -64,6 +64,7 @@ export const REASONS = {
|
|
|
64
64
|
"asset-missing": "A file the record pins by hash does not exist in the tree read.",
|
|
65
65
|
"asset-stale": "A file the record pins is unchanged at the hash a record it supersedes pinned: the decision changed and the artifact did not follow.",
|
|
66
66
|
"record-supersedes-pending": "A supersedes link from a record whose state is weaker than the record it names, so the link has no effect yet.",
|
|
67
|
+
"record-no-evidence": "The record's evidence list is empty: it cites nothing and pins no file. Information for a reviewer, never an error.",
|
|
67
68
|
// A records read that fails (records).
|
|
68
69
|
"kind-unreadable": "The record kind file is missing or could not be imported.",
|
|
69
70
|
"kind-invalid": "The record kind file exports no recordKind, or its shape is wrong.",
|
|
@@ -89,6 +90,10 @@ export const REASONS = {
|
|
|
89
90
|
"intent-evidence-unpinned": "A decision's evidence has no hash: a URL, or a path with no sha256.",
|
|
90
91
|
"intent-trailer-unverified": "A commit carries a trailer a plugin says claims authorship, and the commit is not attested.",
|
|
91
92
|
"intent-region-unconstrained": "No decision constrains the region at any granularity.",
|
|
93
|
+
// Composite instances joined to components (graph --composites, #2662): why the list is empty or has no component.
|
|
94
|
+
"composites-no-chant-member": "No member of kind chant was read, so nothing declares a composite instance or a component.",
|
|
95
|
+
"composites-none-declared": "The members read declare no composite instance.",
|
|
96
|
+
"composites-no-component": "The members read declare no component, so no composite instance has one.",
|
|
92
97
|
// The lineage lock (check).
|
|
93
98
|
"lock-invalid": "The lineage lock can't be read.",
|
|
94
99
|
"manual-step-open": "A scope in the lineage lock has an open manual step.",
|
|
@@ -102,3 +107,20 @@ export const REASON_CODES = Object.keys(REASONS) as ReasonCode[];
|
|
|
102
107
|
export function isReasonCode(value: unknown): value is ReasonCode {
|
|
103
108
|
return typeof value === "string" && Object.prototype.hasOwnProperty.call(REASONS, value);
|
|
104
109
|
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A finding code a plugin contributes to the intent graph through its
|
|
113
|
+
* `commitJoins` (#2656): `plugin:<name>:<code>`, where `<name>` is the kind's
|
|
114
|
+
* name and `<code>` is lower case words joined by dashes. These are outside the
|
|
115
|
+
* closed list: the plugin owns its namespace, and core only carries them.
|
|
116
|
+
*/
|
|
117
|
+
export type PluginCode = `plugin:${string}:${string}`;
|
|
118
|
+
|
|
119
|
+
export const PLUGIN_CODE = /^plugin:([^:\s]+):([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
120
|
+
|
|
121
|
+
/** Whether `value` is a plugin code, and, given `name`, one in that kind's namespace. */
|
|
122
|
+
export function isPluginCode(value: unknown, name?: string): value is PluginCode {
|
|
123
|
+
if (typeof value !== "string") return false;
|
|
124
|
+
const m = value.match(PLUGIN_CODE);
|
|
125
|
+
return !!m && (name === undefined || m[1] === name);
|
|
126
|
+
}
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
* doesn't exist yet, so the chant repo's own decision files stand in for it.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import Ajv2020 from "ajv/dist/2020";
|
|
12
12
|
import { afterAll, describe, expect, test } from "vitest";
|
|
13
13
|
import { queryRecords, RECORDS_CONTRACT_VERSION, RECORDS_OUTPUT_SCHEMA_ID, type RecordsDocument } from "./records-cli";
|
|
14
|
-
import { READ_ERROR_CODES, RECORD_REASON_CODES } from "./records";
|
|
14
|
+
import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES } from "./records";
|
|
15
15
|
import schema from "./records.schema.json";
|
|
16
16
|
import { PROVENANCE_LEVELS } from "./trust/attestor";
|
|
17
17
|
|
|
@@ -77,6 +77,26 @@ describe("records output schema", () => {
|
|
|
77
77
|
expect(doc.summary.invalid).toBe(2);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
test("a workspace-sourced decision with no evidence validates, with its warning (#2654)", async () => {
|
|
81
|
+
const root = copyDecisions();
|
|
82
|
+
const dir = join(root, "docs", "design", "decisions");
|
|
83
|
+
const text = readFileSync(join(dir, "ws-003-seal-scope.md"), "utf-8")
|
|
84
|
+
.replace(/^id: .*$/m, 'id: "ws-900"')
|
|
85
|
+
.replace(/^source:\n(?: .*\n)*/m, 'source:\n kind: "workspace"\n member: "app"\n')
|
|
86
|
+
.replace(/^evidence:\n(?: .*\n)*/m, "evidence: []\n");
|
|
87
|
+
writeFileSync(join(dir, "ws-900-extra.md"), text);
|
|
88
|
+
const doc = await queryRecords({ kind: KIND, current: true, cwd: root });
|
|
89
|
+
expectValid(doc);
|
|
90
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
91
|
+
const r = doc.records.find((x) => x.id === "ws-900");
|
|
92
|
+
expect(r?.valid).toBe(true);
|
|
93
|
+
expect(r?.warnings?.map((w) => w.code)).toEqual(["record-no-evidence"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("lists exactly the warning codes the code can return", () => {
|
|
97
|
+
expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES]);
|
|
98
|
+
});
|
|
99
|
+
|
|
80
100
|
test("every failure validates with its code", async () => {
|
|
81
101
|
const root = copyDecisions();
|
|
82
102
|
const docs = [
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"items": { "$ref": "#/$defs/asset" }
|
|
75
75
|
},
|
|
76
76
|
"warnings": {
|
|
77
|
-
"description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision,
|
|
77
|
+
"description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, a supersedes link that has no effect yet, or (added by #2654) an evidence list that is empty. valid and --current do not look at them.",
|
|
78
78
|
"type": "array",
|
|
79
79
|
"items": { "$ref": "#/$defs/warning" }
|
|
80
80
|
}
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"type": "object",
|
|
102
102
|
"required": ["code", "message"],
|
|
103
103
|
"properties": {
|
|
104
|
-
"code": { "enum": ["asset-drift", "asset-missing", "asset-stale", "record-supersedes-pending"] },
|
|
104
|
+
"code": { "enum": ["asset-drift", "asset-missing", "asset-stale", "record-supersedes-pending", "record-no-evidence"] },
|
|
105
105
|
"message": { "type": "string" }
|
|
106
106
|
}
|
|
107
107
|
},
|
|
@@ -93,6 +93,49 @@ describe("readRecords", () => {
|
|
|
93
93
|
expect(r.data).not.toBeNull();
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
describe("a dissent needs a reason (#2652)", () => {
|
|
97
|
+
const withReviews = (reviews: string) => decision("ws-001").replace(/^reviews: \[\]$/m, `reviews:\n${reviews}`);
|
|
98
|
+
const review = (verdict: string, extra = "") => ` - reviewer: "ana"\n verdict: "${verdict}"\n on: "2026-09-24"\n${extra}`;
|
|
99
|
+
|
|
100
|
+
for (const [label, note] of [
|
|
101
|
+
["null", " note: null\n"],
|
|
102
|
+
["empty", ' note: ""\n'],
|
|
103
|
+
["blank", ' note: " "\n'],
|
|
104
|
+
["missing", ""],
|
|
105
|
+
] as const) {
|
|
106
|
+
test(`a dissent with a ${label} note is record-schema-invalid, naming the reviewer`, async () => {
|
|
107
|
+
write("ws-001-a.md", withReviews(review("dissent", note)));
|
|
108
|
+
const [r] = (await read()).records;
|
|
109
|
+
expect(r.valid).toBe(false);
|
|
110
|
+
expect(codes(r)).toEqual(["record-schema-invalid"]);
|
|
111
|
+
expect(r.reasons[0].message).toBe("/reviews/0 A dissent needs a reason: the dissent by ana has no note.");
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
test("a dissent with a note is valid, and so are agree and abstain without one", async () => {
|
|
116
|
+
write(
|
|
117
|
+
"ws-001-a.md",
|
|
118
|
+
withReviews(
|
|
119
|
+
review("dissent", ' note: "the seal should cover the lockfile too"\n') +
|
|
120
|
+
review("agree") +
|
|
121
|
+
review("abstain", " note: null\n"),
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
const [r] = (await read()).records;
|
|
125
|
+
expect(r.reasons).toEqual([]);
|
|
126
|
+
expect(r.valid).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a dissent carries its concern's lifecycle; another verdict cannot", async () => {
|
|
130
|
+
write("ws-001-a.md", withReviews(review("dissent", ' note: "why"\n addressed_by: "INTENTIUS/chant#2652"\n withdrawn_on: "2026-09-25"\n')));
|
|
131
|
+
expect((await read()).records[0].valid).toBe(true);
|
|
132
|
+
write("ws-001-a.md", withReviews(review("agree", ' addressed_by: "ws-002"\n')));
|
|
133
|
+
const [r] = (await read()).records;
|
|
134
|
+
expect(codes(r)).toEqual(["record-schema-invalid"]);
|
|
135
|
+
expect(r.reasons[0].message).toBe("/reviews/0 Only a dissent is a concern: the agree by ana cannot carry addressed_by or withdrawn_on.");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
96
139
|
test("a supersedes link to a missing id is record-supersedes-unknown", async () => {
|
|
97
140
|
write("ws-002-b.md", decision("ws-002", "decided", ["ws-404"]));
|
|
98
141
|
const [r] = (await read()).records;
|
|
@@ -199,6 +242,56 @@ describe("readRecords", () => {
|
|
|
199
242
|
});
|
|
200
243
|
});
|
|
201
244
|
|
|
245
|
+
/**
|
|
246
|
+
* A decision made in the workspace (#2654): the workspace source form with no
|
|
247
|
+
* issue, and no evidence. `constrains` replaces ws-003's.
|
|
248
|
+
*/
|
|
249
|
+
function workspaceDecision(id: string, constrains: string[] = ["member:app"]): string {
|
|
250
|
+
const list = constrains.length === 0 ? "constrains: []\n" : `constrains:\n${constrains.map((c) => ` - "${c}"\n`).join("")}`;
|
|
251
|
+
return decision(id)
|
|
252
|
+
.replace(/^source:\n(?: .*\n)*/m, 'source:\n kind: "workspace"\n member: "app"\n')
|
|
253
|
+
.replace(/^evidence:\n(?: .*\n)*/m, "evidence: []\n")
|
|
254
|
+
.replace(/^constrains:\n(?: .*\n)*/m, list);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
describe("a decision that originates in the workspace (#2654)", () => {
|
|
258
|
+
test("validates with no issue and no evidence, is current, and carries record-no-evidence", async () => {
|
|
259
|
+
write("ws-001-a.md", workspaceDecision("ws-001"));
|
|
260
|
+
const current = await read({ current: true });
|
|
261
|
+
expect(current.records.map((r) => [r.id, r.valid, r.reasons, r.warnings.map((w) => w.code)])).toEqual([
|
|
262
|
+
["ws-001", true, [], ["record-no-evidence"]],
|
|
263
|
+
]);
|
|
264
|
+
expect(current.records[0].data?.source).toEqual({ kind: "workspace", member: "app" });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("takes a session and an issue, and nothing else", async () => {
|
|
268
|
+
const base = workspaceDecision("ws-001");
|
|
269
|
+
write("ws-001-a.md", base.replace(' member: "app"\n', ' member: "app"\n session: "S-0001"\n issue: "jhgaylor/chud#77"\n'));
|
|
270
|
+
write("ws-002-b.md", base.replace('id: "ws-001"', 'id: "ws-002"').replace(' member: "app"\n', ' member: "app"\n session: null\n'));
|
|
271
|
+
write("ws-003-c.md", base.replace('id: "ws-001"', 'id: "ws-003"').replace(' member: "app"\n', ' member: "app"\n row: "Sort order"\n'));
|
|
272
|
+
write("ws-004-d.md", base.replace('id: "ws-001"', 'id: "ws-004"').replace(' member: "app"\n', ""));
|
|
273
|
+
const records = (await read()).records;
|
|
274
|
+
expect(records.map((r) => [r.id, codes(r)])).toEqual([
|
|
275
|
+
["ws-001", []],
|
|
276
|
+
["ws-002", []],
|
|
277
|
+
["ws-003", ["record-schema-invalid"]],
|
|
278
|
+
["ws-004", ["record-schema-invalid"]],
|
|
279
|
+
]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("a record with evidence carries no record-no-evidence warning", async () => {
|
|
283
|
+
write("ws-001-a.md", decision("ws-001"));
|
|
284
|
+
expect((await read()).records[0].warnings).toEqual([]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("a record that constrains nothing is refused", async () => {
|
|
288
|
+
write("ws-001-a.md", workspaceDecision("ws-001", []));
|
|
289
|
+
const [r] = (await read()).records;
|
|
290
|
+
expect(codes(r)).toEqual(["record-schema-invalid"]);
|
|
291
|
+
expect(r.reasons[0].message).toMatch(/constrains/);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
202
295
|
describe("loadRecordKind", () => {
|
|
203
296
|
const load = () => loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
|
|
204
297
|
|