@intentius/chant 0.83.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 +6 -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 +16 -1
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +21 -0
- package/dist/workspace/intent-cli.d.ts.map +1 -0
- package/dist/workspace/intent-joins.d.ts +121 -0
- package/dist/workspace/intent-joins.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +310 -0
- package/dist/workspace/intent.d.ts.map +1 -0
- 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 +29 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +7 -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 +20 -0
- package/src/cli/main.ts +18 -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 +6 -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 +39 -3
- package/src/workspace/intent-cli.ts +119 -0
- package/src/workspace/intent-joins.ts +210 -0
- package/src/workspace/intent.schema.json +1141 -0
- package/src/workspace/intent.test.ts +566 -0
- package/src/workspace/intent.ts +999 -0
- package/src/workspace/member-commands.ts +11 -5
- package/src/workspace/read-contract.test.ts +46 -3
- package/src/workspace/reason-codes.test.ts +39 -2
- package/src/workspace/reason-codes.ts +40 -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 +55 -11
|
@@ -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,8 +21,12 @@ 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";
|
|
28
|
+
import { intentGraph } from "./intent";
|
|
29
|
+
import intentSchema from "./intent.schema.json";
|
|
25
30
|
import { runChecks } from "./lineage-check";
|
|
26
31
|
import { listWorkspace } from "./ls";
|
|
27
32
|
import lsSchema from "./ls.schema.json";
|
|
@@ -35,7 +40,7 @@ import statusSchema from "./status.schema.json";
|
|
|
35
40
|
const FIXTURE = join(REPO, "reference-workspace");
|
|
36
41
|
const TIMEOUT = 240_000;
|
|
37
42
|
|
|
38
|
-
const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema };
|
|
43
|
+
const SCHEMAS = { ls: lsSchema, graph: graphSchema, check: checkSchema, status: statusSchema, records: recordsSchema, intent: intentSchema, composites: compositesSchema };
|
|
39
44
|
|
|
40
45
|
/** This checkout's chant, started the way the CLI starts it, for members with no toolchain of their own. */
|
|
41
46
|
const reader: Toolchain = {
|
|
@@ -116,6 +121,44 @@ describe("every schema against the reference workspace (#2543)", () => {
|
|
|
116
121
|
expect(doc.members.map((m) => m.name)).toEqual(["app", "delivery", "design-client", "design"]);
|
|
117
122
|
});
|
|
118
123
|
|
|
124
|
+
test("graph --intent, in the working tree and at HEAD (#2651)", async () => {
|
|
125
|
+
const { expectValid } = contract(intentSchema);
|
|
126
|
+
for (const at of [undefined, "HEAD"]) {
|
|
127
|
+
const { doc, failed } = await intentGraph({ cwd: FIXTURE, region: "app/src/server.mjs:19", at, kinds: ["decisions/decision.kind.mjs"] });
|
|
128
|
+
expectValid(doc);
|
|
129
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
130
|
+
expect(failed).toBe(false);
|
|
131
|
+
expect(doc.workspace).toEqual({ name: "reference", root: "reference-workspace" });
|
|
132
|
+
expect(doc.at).toBe(at ? head : null);
|
|
133
|
+
expect(doc.region).toBe("region:app/src/server.mjs:19");
|
|
134
|
+
}
|
|
135
|
+
});
|
|
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
|
+
|
|
119
162
|
test("records, in the working tree and at HEAD", async () => {
|
|
120
163
|
const { expectValid } = contract(recordsSchema);
|
|
121
164
|
for (const at of [undefined, "HEAD"]) {
|
|
@@ -10,11 +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";
|
|
16
|
+
import { INTENT_ERROR_CODES, INTENT_FINDING_CODES, INTENT_REASON_CODES } from "./intent";
|
|
15
17
|
import { CHECK_CODES, CHECK_ERROR_CODES } from "./lineage-check";
|
|
16
18
|
import { GROUP_REASON_CODES, MEMBER_REASON_CODES } from "./ls";
|
|
17
|
-
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";
|
|
18
22
|
import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES } from "./records";
|
|
19
23
|
import { STATUS_ERROR_CODES, STATUS_REASON_CODES } from "./status";
|
|
20
24
|
|
|
@@ -33,6 +37,11 @@ const PER_COMMAND: Record<string, readonly string[]> = {
|
|
|
33
37
|
RECORD_REASON_CODES,
|
|
34
38
|
RECORD_WARNING_CODES,
|
|
35
39
|
READ_ERROR_CODES,
|
|
40
|
+
INTENT_ERROR_CODES,
|
|
41
|
+
INTENT_FINDING_CODES,
|
|
42
|
+
INTENT_REASON_CODES,
|
|
43
|
+
COMPOSITES_ERROR_CODES,
|
|
44
|
+
COMPOSITES_REASON_CODES,
|
|
36
45
|
};
|
|
37
46
|
|
|
38
47
|
/** Every string in an `enum` under a property named `code`, anywhere in a schema. */
|
|
@@ -94,7 +103,7 @@ describe("the closed list of reason codes", () => {
|
|
|
94
103
|
});
|
|
95
104
|
|
|
96
105
|
test("no source file emits a code outside the list", () => {
|
|
97
|
-
const emitted = /(?:\bcode:\s*|(?:WorkspaceReadError|RecordReadError|StatusError)\(\s*)"([a-z0-9-]+)"/g;
|
|
106
|
+
const emitted = /(?:\bcode:\s*|(?:WorkspaceReadError|RecordReadError|StatusError|IntentError)\(\s*)"([a-z0-9-]+)"/g;
|
|
98
107
|
let seen = 0;
|
|
99
108
|
for (const file of sourceFiles(HERE)) {
|
|
100
109
|
const text = readFileSync(file, "utf-8");
|
|
@@ -107,6 +116,34 @@ describe("the closed list of reason codes", () => {
|
|
|
107
116
|
expect(seen).toBeGreaterThan(30);
|
|
108
117
|
});
|
|
109
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
|
+
|
|
110
147
|
test("the read-contract page documents every code", () => {
|
|
111
148
|
const page = readFileSync(join(REPO, "docs", "src", "content", "docs", "reference", "workspace-read-contract.mdx"), "utf-8");
|
|
112
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.",
|
|
@@ -71,6 +72,28 @@ export const REASONS = {
|
|
|
71
72
|
"schema-id-mismatch": "The schema's $id differs from the id the record kind names.",
|
|
72
73
|
"schema-invalid": "The record schema itself does not compile.",
|
|
73
74
|
"location-missing": "The records directory does not exist, in the tree or at the revision.",
|
|
75
|
+
// The intent graph (graph --intent, #2651): a read that fails.
|
|
76
|
+
"intent-region-invalid": "The region's path, or its line range, does not exist in the tree read.",
|
|
77
|
+
// The intent graph: part of the walk that can't be read. The document is still printed.
|
|
78
|
+
"intent-history-shallow": "The repository is a shallow clone, so the region's history stops at the clone's boundary.",
|
|
79
|
+
"intent-plugin-failed": "A kind file's commitJoins, which joins commits to units, contracts and evidence, failed for a commit.",
|
|
80
|
+
// The intent graph: findings, each a node in the graph.
|
|
81
|
+
"intent-commit-undecided": "A commit changed the region when no decision constrained it at path granularity.",
|
|
82
|
+
"intent-commit-bare": "A commit names no unit, no pull request and no decision covering the region at its time.",
|
|
83
|
+
"intent-pin-drifted": "A decision's pinned artifact no longer hashes to the pin.",
|
|
84
|
+
"intent-pin-missing": "A decision's pinned artifact does not exist in the tree read.",
|
|
85
|
+
"intent-artifact-unpinned": "An artifact decisions in the graph pinned, which no current decision pins.",
|
|
86
|
+
"intent-decision-superseded-live": "Every decision constraining the region is superseded.",
|
|
87
|
+
"intent-decision-provisional": "The current decisions constraining the region are all in states their kind does not close, such as decided.",
|
|
88
|
+
"intent-constraint-coarse": "The region is constrained only through its member, not by path.",
|
|
89
|
+
"intent-constraint-lost": "A decision's path constraint names a path that does not exist in the tree read.",
|
|
90
|
+
"intent-evidence-unpinned": "A decision's evidence has no hash: a URL, or a path with no sha256.",
|
|
91
|
+
"intent-trailer-unverified": "A commit carries a trailer a plugin says claims authorship, and the commit is not attested.",
|
|
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.",
|
|
74
97
|
// The lineage lock (check).
|
|
75
98
|
"lock-invalid": "The lineage lock can't be read.",
|
|
76
99
|
"manual-step-open": "A scope in the lineage lock has an open manual step.",
|
|
@@ -84,3 +107,20 @@ export const REASON_CODES = Object.keys(REASONS) as ReasonCode[];
|
|
|
84
107
|
export function isReasonCode(value: unknown): value is ReasonCode {
|
|
85
108
|
return typeof value === "string" && Object.prototype.hasOwnProperty.call(REASONS, value);
|
|
86
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
|
|
package/src/workspace/records.ts
CHANGED
|
@@ -62,6 +62,12 @@ export const RECORD_WARNING_CODES = [
|
|
|
62
62
|
"asset-stale",
|
|
63
63
|
/** A supersedes link from a record whose state is weaker than the one it names, so it has no effect yet (#2524 D4). */
|
|
64
64
|
"record-supersedes-pending",
|
|
65
|
+
/**
|
|
66
|
+
* The kind's pins field is an empty list: the record cites no evidence and
|
|
67
|
+
* pins no file. Information for a reviewer, such as a decision made in a
|
|
68
|
+
* product's own design flow with nothing to cite (#2654).
|
|
69
|
+
*/
|
|
70
|
+
"record-no-evidence",
|
|
65
71
|
] as const satisfies readonly ReasonCode[];
|
|
66
72
|
export type RecordWarningCode = (typeof RECORD_WARNING_CODES)[number];
|
|
67
73
|
|
|
@@ -187,7 +193,7 @@ export interface LoadedRecordKind {
|
|
|
187
193
|
* The kind is registered under a name no lexicon package can have (npm names
|
|
188
194
|
* hold no `:`), imported and unregistered again, so no lexicon lookup sees it.
|
|
189
195
|
*/
|
|
190
|
-
async function importKindModule(path: string): Promise<Record<string, unknown>> {
|
|
196
|
+
export async function importKindModule(path: string): Promise<Record<string, unknown>> {
|
|
191
197
|
const name = `record-kind:${path}`;
|
|
192
198
|
registerLexiconDeclarations([{ name, module: path }], dirname(path));
|
|
193
199
|
try {
|
|
@@ -344,22 +350,54 @@ export interface ReadRecordsResult {
|
|
|
344
350
|
|
|
345
351
|
type Validator = (data: unknown) => { ok: true } | { ok: false; errors: string[] };
|
|
346
352
|
|
|
353
|
+
/** One ajv error, compiled with `verbose` so the failing schema and data come with it. */
|
|
354
|
+
interface SchemaError {
|
|
355
|
+
instancePath: string;
|
|
356
|
+
schemaPath: string;
|
|
357
|
+
keyword: string;
|
|
358
|
+
message?: string;
|
|
359
|
+
params?: { failingKeyword?: string };
|
|
360
|
+
parentSchema?: Record<string, unknown>;
|
|
361
|
+
data?: unknown;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* ajv's errors as `<path> <message>` lines. A failed `if` whose `then` or
|
|
366
|
+
* `else` branch has a `description` is reported by that description alone, in
|
|
367
|
+
* place of the branch's own errors, with each `{field}` filled from the value
|
|
368
|
+
* the branch checked. The decision schema words its dissent rule this way, so
|
|
369
|
+
* the message names the reviewer (#2652). The schema stays plain JSON Schema,
|
|
370
|
+
* with no keyword a strict validator would refuse.
|
|
371
|
+
*/
|
|
372
|
+
function renderSchemaErrors(errors: readonly SchemaError[]): string[] {
|
|
373
|
+
const replaced: string[] = [];
|
|
374
|
+
const described = new Map<SchemaError, string>();
|
|
375
|
+
for (const e of errors) {
|
|
376
|
+
const branch = e.keyword === "if" ? e.params?.failingKeyword : undefined;
|
|
377
|
+
const text = branch ? (e.parentSchema?.[branch] as { description?: unknown } | undefined)?.description : undefined;
|
|
378
|
+
if (!branch || typeof text !== "string") continue;
|
|
379
|
+
const at = e.data !== null && typeof e.data === "object" ? (e.data as Record<string, unknown>) : {};
|
|
380
|
+
described.set(e, text.replace(/\{([A-Za-z0-9_]+)\}/g, (all, key: string) => (typeof at[key] === "string" ? (at[key] as string) : all)));
|
|
381
|
+
replaced.push(`${e.schemaPath.replace(/\/if$/, "")}/${branch}/`);
|
|
382
|
+
}
|
|
383
|
+
return errors
|
|
384
|
+
.filter((e) => described.has(e) || !replaced.some((prefix) => e.schemaPath.startsWith(prefix)))
|
|
385
|
+
.map((e) => `${e.instancePath || "/"} ${described.get(e) ?? e.message ?? "is invalid"}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
347
388
|
async function compileSchema(schema: Record<string, unknown>): Promise<Validator> {
|
|
348
389
|
const mod = (await import("ajv")) as unknown as { default: unknown };
|
|
349
390
|
// ajv is CommonJS; its class is the default export, or that export's own default.
|
|
350
391
|
const Ajv = ((mod.default as { default?: unknown }).default ?? mod.default) as new (opts: object) => {
|
|
351
|
-
compile(s: object): ((d: unknown) => boolean) & { errors?:
|
|
392
|
+
compile(s: object): ((d: unknown) => boolean) & { errors?: SchemaError[] | null };
|
|
352
393
|
};
|
|
353
394
|
let validate: ReturnType<InstanceType<typeof Ajv>["compile"]>;
|
|
354
395
|
try {
|
|
355
|
-
validate = new Ajv({ allErrors: true, strict: false }).compile(schema);
|
|
396
|
+
validate = new Ajv({ allErrors: true, strict: false, verbose: true }).compile(schema);
|
|
356
397
|
} catch (err) {
|
|
357
398
|
throw new RecordReadError("schema-invalid", `the kind's schema does not compile: ${message(err)}`);
|
|
358
399
|
}
|
|
359
|
-
return (data) =>
|
|
360
|
-
validate(data)
|
|
361
|
-
? { ok: true }
|
|
362
|
-
: { ok: false, errors: (validate.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message ?? "is invalid"}`) };
|
|
400
|
+
return (data) => (validate(data) ? { ok: true } : { ok: false, errors: renderSchemaErrors(validate.errors ?? []) });
|
|
363
401
|
}
|
|
364
402
|
|
|
365
403
|
/** Read every record `loaded` locates, through `options.source`. */
|
|
@@ -392,10 +430,16 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
392
430
|
if (!result.ok) {
|
|
393
431
|
entry.reasons.push({ code: "record-schema-invalid", message: result.errors.join("; ") });
|
|
394
432
|
}
|
|
395
|
-
if (kind.pins
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
433
|
+
if (kind.pins) {
|
|
434
|
+
const cited = fm.value[kind.pins.field];
|
|
435
|
+
if (Array.isArray(cited) && cited.length === 0) {
|
|
436
|
+
entry.warnings.push({ code: "record-no-evidence", message: `${kind.pins.field} is empty: the record cites nothing and pins no file` });
|
|
437
|
+
}
|
|
438
|
+
if (options.assets) {
|
|
439
|
+
const checked = checkPins(pinEntries(fm.value, kind.pins.field), options.assets);
|
|
440
|
+
entry.assets = checked.assets;
|
|
441
|
+
entry.warnings.push(...checked.warnings);
|
|
442
|
+
}
|
|
399
443
|
}
|
|
400
444
|
}
|
|
401
445
|
|