@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
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* (#2651): the intent graph over one region (`intent.ts`), printed as JSON
|
|
4
4
|
* with `--json` or as a walk, one line per node, in the order of #2650
|
|
5
5
|
* section B: the region, its decisions, their artifacts, the commits, and the
|
|
6
|
-
* findings.
|
|
6
|
+
* findings. Under each decision come the commits made inside its window, and
|
|
7
|
+
* when any of them is not the decision's own work, the question #2650 B puts
|
|
8
|
+
* to the person about it (#2656).
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import { resolve } from "node:path";
|
|
@@ -25,6 +27,28 @@ function ofKind<K extends IntentNode["kind"]>(doc: Result, kind: K): Extract<Int
|
|
|
25
27
|
|
|
26
28
|
const short = (sha: string | null | undefined) => (sha ? sha.slice(0, 8) : "none");
|
|
27
29
|
|
|
30
|
+
/** What the walk asks about a commit made inside a decision's window that is not the decision's own work (#2650 B4, step 5). */
|
|
31
|
+
export const IN_WINDOW_QUESTION = "is this drift, a superseding decision nobody wrote down, or the decision being wrong?";
|
|
32
|
+
|
|
33
|
+
/** The commits inside a decision's window, under its line, then the question once when any is not its own work. */
|
|
34
|
+
function withinLines(doc: Result, d: DecisionNode): string[] {
|
|
35
|
+
const within = doc.edges.filter((e): e is Extract<IntentEdge, { kind: "within" }> => e.kind === "within" && e.to === d.id);
|
|
36
|
+
const out: string[] = [];
|
|
37
|
+
for (const e of within) {
|
|
38
|
+
const c = doc.nodes.find((n): n is CommitNode => n.kind === "commit" && n.id === e.from);
|
|
39
|
+
if (!c) continue;
|
|
40
|
+
const unit = edgesFrom(doc, c.id, "produced-by")
|
|
41
|
+
.map((p) => doc.nodes.find((n) => n.id === p.to))
|
|
42
|
+
.map((n) => (n && "ref" in n ? n.ref : undefined))
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
const by = unit.length > 0 ? `unit ${unit.join(", ")}` : "no unit";
|
|
45
|
+
const label = e.state === "decided" ? "decided " : "within ";
|
|
46
|
+
out.push(` ${label} ${short(c.sha)} ${c.subject}; ${by}${e.state === "decided" ? `, ${d.record}'s own work` : `, in ${d.record}'s window and not its work`}`);
|
|
47
|
+
}
|
|
48
|
+
if (within.some((e) => e.state === "decided-by-window")) out.push(` ask ${IN_WINDOW_QUESTION}`);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
28
52
|
function decisionLine(d: DecisionNode): string {
|
|
29
53
|
const via = d.constrains.length > 0 ? d.constrains.map((c) => `${c.entry} (${c.granularity})`).join(", ") : "through supersession only";
|
|
30
54
|
const by = d.decided_by ? `, decided by ${d.decided_by}${d.decided_on ? ` on ${d.decided_on}` : ""}` : "";
|
|
@@ -63,7 +87,7 @@ export function formatIntent(doc: Result): string {
|
|
|
63
87
|
}
|
|
64
88
|
const files = ofKind(doc, "file");
|
|
65
89
|
if (files.length > 0) out.push(`files ${files.length} under the region, ${files.filter((f) => f.generated).length} generated`);
|
|
66
|
-
for (const d of ofKind(doc, "decision")) out.push(decisionLine(d));
|
|
90
|
+
for (const d of ofKind(doc, "decision")) out.push(decisionLine(d), ...withinLines(doc, d));
|
|
67
91
|
for (const a of ofKind(doc, "artifact")) out.push(artifactLine(doc, a));
|
|
68
92
|
for (const c of ofKind(doc, "commit")) out.push(...commitLine(doc, c));
|
|
69
93
|
for (const l of ofKind(doc, "link")) {
|
|
@@ -18,9 +18,19 @@
|
|
|
18
18
|
* Either way core never parses a plugin's own trailer or record format: it
|
|
19
19
|
* reads the trailers git reports and hands them over, and a key means
|
|
20
20
|
* something only because a kind file said so.
|
|
21
|
+
*
|
|
22
|
+
* Two parts of a join's answer carry meaning core acts on (#2656). A unit or
|
|
23
|
+
* contract may list `decisions`, the ids of the decision records it carries
|
|
24
|
+
* out; a commit whose unit or contract names a decision is that decision's own
|
|
25
|
+
* work. The function form may also return `findings`, each a code in the
|
|
26
|
+
* kind's own namespace (`plugin:<name>:<code>`), a message and the refs it is
|
|
27
|
+
* about, which the graph carries as finding nodes. That is how a plugin says
|
|
28
|
+
* what it knows and core does not, such as a contract whose criteria changed
|
|
29
|
+
* in a commit that names no decision.
|
|
21
30
|
*/
|
|
22
31
|
|
|
23
32
|
import { z } from "zod";
|
|
33
|
+
import { isPluginCode } from "./reason-codes";
|
|
24
34
|
|
|
25
35
|
/** A commit as the hook sees it. */
|
|
26
36
|
export interface IntentCommit {
|
|
@@ -41,12 +51,25 @@ export interface CommitJoinContext {
|
|
|
41
51
|
at: string | null;
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* A plugin's unit, contract or evidence: an id and whatever fields the plugin
|
|
56
|
+
* records. On a unit or contract, `decisions`, a list of record ids, names the
|
|
57
|
+
* decisions it carries out (#2656).
|
|
58
|
+
*/
|
|
45
59
|
export interface JoinedEntity {
|
|
46
60
|
id: string;
|
|
47
61
|
[field: string]: unknown;
|
|
48
62
|
}
|
|
49
63
|
|
|
64
|
+
/** A finding a plugin contributes for one commit (#2656). */
|
|
65
|
+
export interface PluginFinding {
|
|
66
|
+
/** `plugin:<name>:<code>`, where `<name>` is the kind's name. */
|
|
67
|
+
code: string;
|
|
68
|
+
message: string;
|
|
69
|
+
/** What the finding is about: node ids, commit shas, record ids, unit, contract or evidence ids, or paths. */
|
|
70
|
+
refs?: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
/** What a join says about one commit. Every part is optional. */
|
|
51
74
|
export interface CommitJoin {
|
|
52
75
|
/** The unit of work that produced the commit. */
|
|
@@ -57,6 +80,8 @@ export interface CommitJoin {
|
|
|
57
80
|
evidence?: JoinedEntity | JoinedEntity[];
|
|
58
81
|
/** Trailer keys on this commit that claim who wrote it, which the plugin vouches for. */
|
|
59
82
|
authorship?: string[];
|
|
83
|
+
/** Findings about this commit, in the kind's own code namespace. Function form only. */
|
|
84
|
+
findings?: PluginFinding[];
|
|
60
85
|
}
|
|
61
86
|
|
|
62
87
|
export type CommitJoinsFunction = (commit: IntentCommit, context: CommitJoinContext) => CommitJoin | null | undefined | Promise<CommitJoin | null | undefined>;
|
|
@@ -135,8 +160,17 @@ export function joinByData(data: CommitJoinsData, commit: IntentCommit, context:
|
|
|
135
160
|
return out;
|
|
136
161
|
}
|
|
137
162
|
|
|
138
|
-
/**
|
|
139
|
-
export
|
|
163
|
+
/** The record ids a unit or contract says it carries out: its `decisions` field, when that is a list of strings. */
|
|
164
|
+
export function entityDecisions(entity: JoinedEntity | undefined): string[] {
|
|
165
|
+
const list = entity?.decisions;
|
|
166
|
+
return Array.isArray(list) ? list.filter((d): d is string => typeof d === "string" && d !== "") : [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run a kind's joins for one commit, checking what a function returns.
|
|
171
|
+
* `name` is the kind's name, the namespace its findings' codes must use.
|
|
172
|
+
*/
|
|
173
|
+
export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, context: CommitJoinContext, name: string): Promise<CommitJoin> {
|
|
140
174
|
if (joins.form === "data") return joinByData(joins.data, commit, context);
|
|
141
175
|
const result = await joins.join(commit, context);
|
|
142
176
|
if (result === null || result === undefined) return {};
|
|
@@ -161,5 +195,16 @@ export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, c
|
|
|
161
195
|
if (!Array.isArray(result.authorship) || !result.authorship.every((k) => typeof k === "string")) throw new Error("commitJoins returned authorship that is not a list of trailer keys");
|
|
162
196
|
out.authorship = result.authorship;
|
|
163
197
|
}
|
|
198
|
+
if (result.findings !== undefined && result.findings !== null) {
|
|
199
|
+
if (!Array.isArray(result.findings)) throw new Error("commitJoins returned findings that are not a list");
|
|
200
|
+
out.findings = result.findings.map((f: unknown) => {
|
|
201
|
+
if (f === null || typeof f !== "object" || Array.isArray(f)) throw new Error("commitJoins returned a finding that is not an object");
|
|
202
|
+
const { code, message, refs } = f as Record<string, unknown>;
|
|
203
|
+
if (!isPluginCode(code, name)) throw new Error(`commitJoins returned the finding code ${JSON.stringify(code)}, and a plugin's codes are plugin:${name}:<code>, with <code> in lower case words joined by dashes`);
|
|
204
|
+
if (typeof message !== "string" || message === "") throw new Error(`commitJoins returned the finding ${code} with no message`);
|
|
205
|
+
if (refs !== undefined && (!Array.isArray(refs) || !refs.every((r) => typeof r === "string"))) throw new Error(`commitJoins returned the finding ${code} with refs that are not a list of strings`);
|
|
206
|
+
return { code, message, refs: (refs as string[] | undefined) ?? [] };
|
|
207
|
+
});
|
|
208
|
+
}
|
|
164
209
|
return out;
|
|
165
210
|
}
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"error": false,
|
|
102
102
|
"kinds": {
|
|
103
103
|
"type": "array",
|
|
104
|
-
"description": "Each --kind file, in order: the record kind it reads, if any, and the form of its commitJoins export, if any.",
|
|
104
|
+
"description": "Each --kind file, in order: its name, the record kind it reads, if any, and the form of its commitJoins export, if any.",
|
|
105
105
|
"items": {
|
|
106
106
|
"type": "object",
|
|
107
107
|
"required": [
|
|
@@ -114,6 +114,10 @@
|
|
|
114
114
|
"type": "string",
|
|
115
115
|
"description": "From the repository root."
|
|
116
116
|
},
|
|
117
|
+
"name": {
|
|
118
|
+
"type": "string",
|
|
119
|
+
"description": "The kind's name: the record kind's name, or the file's name without .kind.mjs for a plugin with no records. A plugin's findings use it as their namespace, plugin:<name>:<code> (#2656). Added within version 1, so an older document may lack it."
|
|
120
|
+
},
|
|
117
121
|
"records": {
|
|
118
122
|
"type": [
|
|
119
123
|
"string",
|
|
@@ -364,7 +368,7 @@
|
|
|
364
368
|
}
|
|
365
369
|
},
|
|
366
370
|
"commit": {
|
|
367
|
-
"description": "A commit that touched the region. Linked from the region by a touched-by edge.",
|
|
371
|
+
"description": "A commit that touched the region. Linked from the region by a touched-by edge, and to each decision whose path window it falls in by a within edge.",
|
|
368
372
|
"type": "object",
|
|
369
373
|
"required": [
|
|
370
374
|
"id",
|
|
@@ -466,6 +470,15 @@
|
|
|
466
470
|
}
|
|
467
471
|
],
|
|
468
472
|
"description": "For a line-range region, the ranges the commit changed within it, numbered in the commit's own version of the file. Null otherwise."
|
|
473
|
+
},
|
|
474
|
+
"state": {
|
|
475
|
+
"enum": [
|
|
476
|
+
"decided",
|
|
477
|
+
"decided-by-window",
|
|
478
|
+
"undecided",
|
|
479
|
+
null
|
|
480
|
+
],
|
|
481
|
+
"description": "How the decisions constraining the region by path relate to the commit (#2656). decided: it falls inside a decision's window and that decision's own unit made it. decided-by-window: it falls inside a window, and it is not the decision's own work, so the person judges it. undecided: outside every window, with the finding intent-commit-undecided. null: no record kind was read. Added within version 1, so an older document may lack it."
|
|
469
482
|
}
|
|
470
483
|
}
|
|
471
484
|
},
|
|
@@ -812,7 +825,7 @@
|
|
|
812
825
|
}
|
|
813
826
|
},
|
|
814
827
|
"finding": {
|
|
815
|
-
"description": "A gap the walk found. Findings are nodes so a reader can draw them and a test can assert them; the tool asks, and never answers.",
|
|
828
|
+
"description": "A gap the walk found. Findings are nodes so a reader can draw them and a test can assert them; the tool asks, and never answers. A plugin's commitJoins may add findings of its own, with a code in its namespace (#2656).",
|
|
816
829
|
"type": "object",
|
|
817
830
|
"required": [
|
|
818
831
|
"id",
|
|
@@ -830,19 +843,28 @@
|
|
|
830
843
|
"const": "finding"
|
|
831
844
|
},
|
|
832
845
|
"code": {
|
|
833
|
-
"
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
+
"anyOf": [
|
|
847
|
+
{
|
|
848
|
+
"enum": [
|
|
849
|
+
"intent-commit-undecided",
|
|
850
|
+
"intent-commit-bare",
|
|
851
|
+
"intent-pin-drifted",
|
|
852
|
+
"intent-pin-missing",
|
|
853
|
+
"intent-artifact-unpinned",
|
|
854
|
+
"intent-decision-superseded-live",
|
|
855
|
+
"intent-decision-provisional",
|
|
856
|
+
"intent-constraint-coarse",
|
|
857
|
+
"intent-constraint-lost",
|
|
858
|
+
"intent-evidence-unpinned",
|
|
859
|
+
"intent-trailer-unverified",
|
|
860
|
+
"intent-region-unconstrained"
|
|
861
|
+
]
|
|
862
|
+
},
|
|
863
|
+
{
|
|
864
|
+
"type": "string",
|
|
865
|
+
"pattern": "^plugin:[^:\\s]+:[a-z0-9]+(-[a-z0-9]+)*$",
|
|
866
|
+
"description": "A plugin's own code, plugin:<name>:<code>, where <name> is the kind's name in kinds. Outside the closed list: the plugin owns its namespace."
|
|
867
|
+
}
|
|
846
868
|
]
|
|
847
869
|
},
|
|
848
870
|
"message": {
|
|
@@ -854,11 +876,22 @@
|
|
|
854
876
|
"type": "string"
|
|
855
877
|
},
|
|
856
878
|
"description": "The ids of the nodes the finding is about."
|
|
879
|
+
},
|
|
880
|
+
"plugin": {
|
|
881
|
+
"type": "string",
|
|
882
|
+
"description": "For a plugin's finding: the kind file that returned it, as in kinds."
|
|
883
|
+
},
|
|
884
|
+
"refs": {
|
|
885
|
+
"type": "array",
|
|
886
|
+
"items": {
|
|
887
|
+
"type": "string"
|
|
888
|
+
},
|
|
889
|
+
"description": "For a plugin's finding: the refs as the plugin gave them. Those that name a node in the graph are resolved to its id in concerns, after the commit the finding was returned for."
|
|
857
890
|
}
|
|
858
891
|
}
|
|
859
892
|
},
|
|
860
893
|
"edge": {
|
|
861
|
-
"description": "constrains: decision to region, file or contract. pins: decision to artifact. touched-by: region to commit. produced-by: commit to unit. serves: unit to contract. supersedes: the newer decision to the one it replaces. cites-evidence: unit, contract or decision to evidence. links: consumer member to producer member.",
|
|
894
|
+
"description": "constrains: decision to region, file or contract. pins: decision to artifact. touched-by: region to commit. produced-by: commit to unit. serves: unit to contract. supersedes: the newer decision to the one it replaces. cites-evidence: unit, contract or decision to evidence. links: consumer member to producer member. within: commit to a decision whose path window it falls in.",
|
|
862
895
|
"oneOf": [
|
|
863
896
|
{
|
|
864
897
|
"$ref": "#/$defs/constrainsEdge"
|
|
@@ -869,6 +902,9 @@
|
|
|
869
902
|
{
|
|
870
903
|
"$ref": "#/$defs/touchedEdge"
|
|
871
904
|
},
|
|
905
|
+
{
|
|
906
|
+
"$ref": "#/$defs/withinEdge"
|
|
907
|
+
},
|
|
872
908
|
{
|
|
873
909
|
"$ref": "#/$defs/plainEdge"
|
|
874
910
|
}
|
|
@@ -1073,6 +1109,33 @@
|
|
|
1073
1109
|
]
|
|
1074
1110
|
}
|
|
1075
1111
|
}
|
|
1112
|
+
},
|
|
1113
|
+
"withinEdge": {
|
|
1114
|
+
"description": "A commit made inside a decision's window, from the commit that added the decision's record until the one that added its successor (#2656). state is decided when the decision's own unit made the commit, and decided-by-window otherwise.",
|
|
1115
|
+
"type": "object",
|
|
1116
|
+
"required": [
|
|
1117
|
+
"kind",
|
|
1118
|
+
"from",
|
|
1119
|
+
"to",
|
|
1120
|
+
"state"
|
|
1121
|
+
],
|
|
1122
|
+
"properties": {
|
|
1123
|
+
"from": {
|
|
1124
|
+
"type": "string"
|
|
1125
|
+
},
|
|
1126
|
+
"to": {
|
|
1127
|
+
"type": "string"
|
|
1128
|
+
},
|
|
1129
|
+
"kind": {
|
|
1130
|
+
"const": "within"
|
|
1131
|
+
},
|
|
1132
|
+
"state": {
|
|
1133
|
+
"enum": [
|
|
1134
|
+
"decided",
|
|
1135
|
+
"decided-by-window"
|
|
1136
|
+
]
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1076
1139
|
}
|
|
1077
1140
|
}
|
|
1078
1141
|
}
|
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
* `path:app/server.mjs` and pins the spec by hash.
|
|
8
8
|
* - c2 adds `app/server.mjs` inside dec-001's window, with a `Unit: U-0001`
|
|
9
9
|
* trailer that the fixture plugin maps to a unit, a contract and evidence,
|
|
10
|
-
* and a `Made-By` trailer the plugin says claims authorship.
|
|
10
|
+
* and a `Made-By` trailer the plugin says claims authorship. U-0001's
|
|
11
|
+
* record names dec-001, so c2 is dec-001's own work.
|
|
12
|
+
* - c2b edits `app/server.mjs` inside dec-001's window too, from unit
|
|
13
|
+
* U-0002, whose record names no decision and which changed its contract's
|
|
14
|
+
* criteria. It is decided by the window only (#2656), and the plugin
|
|
15
|
+
* returns a finding for it in its own namespace, `plugin:units:`.
|
|
11
16
|
* - c3 adds dec-002, which supersedes dec-001, pins the spec at the same hash
|
|
12
17
|
* and constrains `member:design` only.
|
|
13
18
|
* - c4 edits `app/server.mjs` after that, with no decision covering it.
|
|
@@ -59,11 +64,15 @@ export function commitJoins(commit, context) {
|
|
|
59
64
|
if (!id) return undefined;
|
|
60
65
|
if (id === "U-BROKEN") throw new Error("no such unit");
|
|
61
66
|
const unit = JSON.parse(context.read(\`units/\${id}.json\`));
|
|
67
|
+
if (unit.badCode) return { findings: [{ code: "plugin:other:x", message: "wrong namespace" }] };
|
|
62
68
|
return {
|
|
63
|
-
unit: { id, role: unit.role, outcome: unit.outcome },
|
|
69
|
+
unit: { id, role: unit.role, outcome: unit.outcome, ...(unit.decisions ? { decisions: unit.decisions } : {}) },
|
|
64
70
|
contract: { id: unit.contract, status: "closed" },
|
|
65
71
|
evidence: [{ id: "E-1", ok: true }],
|
|
66
72
|
authorship: commit.trailers["Made-By"] ? ["Made-By"] : [],
|
|
73
|
+
findings: unit.criteriaChanged
|
|
74
|
+
? [{ code: "plugin:units:criteria-changed-undecided", message: \`\${unit.contract} changed its criteria in this commit with no decision\`, refs: [commit.sha, unit.contract, "dec-001", "no-such-thing"] }]
|
|
75
|
+
: [],
|
|
67
76
|
};
|
|
68
77
|
}
|
|
69
78
|
`;
|
|
@@ -72,7 +81,8 @@ export function commitJoins(commit, context) {
|
|
|
72
81
|
const DATA_PLUGIN = `export const commitJoins = { trailers: { unit: "Unit" }, records: { unit: "units/{id}.json" }, authorship: ["Made-By"] };\n`;
|
|
73
82
|
|
|
74
83
|
const SERVER_1 = "// the app\nexport const status = 'Running.';\nexport const port = 8080;\n";
|
|
75
|
-
const
|
|
84
|
+
const SERVER_1B = "// the app\nexport const status = 'Running.';\nexport const port = 9090;\n";
|
|
85
|
+
const SERVER_2 = "// the app\nexport const status = 'Up.';\nexport const port = 9090;\n";
|
|
76
86
|
|
|
77
87
|
let root: string;
|
|
78
88
|
const sha: Record<string, string> = {};
|
|
@@ -106,11 +116,15 @@ beforeAll(() => {
|
|
|
106
116
|
"plugins/units.kind.mjs": PLUGIN,
|
|
107
117
|
"plugins/units-data.kind.mjs": DATA_PLUGIN,
|
|
108
118
|
"plugins/empty.kind.mjs": "export const nothing = 1;\n",
|
|
109
|
-
"units/U-0001.json": JSON.stringify({ role: "implement", contract: "C-001", outcome: "done" }),
|
|
119
|
+
"units/U-0001.json": JSON.stringify({ role: "implement", contract: "C-001", outcome: "done", decisions: ["dec-001"] }),
|
|
120
|
+
"units/U-0002.json": JSON.stringify({ role: "implement", contract: "ctr-002", outcome: "done", criteriaChanged: true }),
|
|
121
|
+
"units/U-BAD.json": JSON.stringify({ badCode: true }),
|
|
110
122
|
});
|
|
111
123
|
sha.c1 = commit(["decide the server"]);
|
|
112
124
|
writeFiles(root, { "app/server.mjs": SERVER_1 });
|
|
113
125
|
sha.c2 = commit(["add the server", "Unit: U-0001\nMade-By: agent"]);
|
|
126
|
+
writeFiles(root, { "app/server.mjs": SERVER_1B });
|
|
127
|
+
sha.c2b = commit(["move the port", "Unit: U-0002"]);
|
|
114
128
|
writeFiles(root, { "decisions/dec-002-design.md": decision("dec-002", { constrains: ["member:design"], evidence: [pin], supersedes: ["dec-001"] }) });
|
|
115
129
|
sha.c3 = commit(["move the decision to the design member"]);
|
|
116
130
|
writeFiles(root, { "app/server.mjs": SERVER_2 });
|
|
@@ -139,8 +153,8 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
|
|
|
139
153
|
expect(doc).toMatchObject({ contract: 1, at: null, workspace: { name: "studio", root: "." }, region: "region:app/server.mjs" });
|
|
140
154
|
expect(doc.history).toEqual({ rev: sha.c4, follows: "file", shallow: false });
|
|
141
155
|
expect(doc.kinds).toEqual([
|
|
142
|
-
{ file: KIND, records: "decision", joins: null },
|
|
143
|
-
{ file: "plugins/units.kind.mjs", records: null, joins: "function" },
|
|
156
|
+
{ file: KIND, name: "decision", records: "decision", joins: null },
|
|
157
|
+
{ file: "plugins/units.kind.mjs", name: "units", records: null, joins: "function" },
|
|
144
158
|
]);
|
|
145
159
|
expect(doc.reasons).toEqual([]);
|
|
146
160
|
|
|
@@ -148,10 +162,13 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
|
|
|
148
162
|
"region:app/server.mjs",
|
|
149
163
|
"member:app",
|
|
150
164
|
`commit:${sha.c4}`,
|
|
165
|
+
`commit:${sha.c2b}`,
|
|
166
|
+
"unit:U-0002",
|
|
167
|
+
"contract:ctr-002",
|
|
168
|
+
"evidence:E-1",
|
|
151
169
|
`commit:${sha.c2}`,
|
|
152
170
|
"unit:U-0001",
|
|
153
171
|
"contract:C-001",
|
|
154
|
-
"evidence:E-1",
|
|
155
172
|
"record:decision/dec-001",
|
|
156
173
|
"record:decision/dec-002",
|
|
157
174
|
"artifact:design/screens/home.json",
|
|
@@ -159,6 +176,7 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
|
|
|
159
176
|
"finding:intent-commit-bare:1",
|
|
160
177
|
"finding:intent-decision-superseded-live:1",
|
|
161
178
|
"finding:intent-trailer-unverified:1",
|
|
179
|
+
"finding:plugin:units:criteria-changed-undecided:1",
|
|
162
180
|
]);
|
|
163
181
|
|
|
164
182
|
expect(node(doc, "region:app/server.mjs")).toEqual({ id: "region:app/server.mjs", kind: "region", path: "app/server.mjs", lines: null, member: "app", at: null, type: "file", generated: false, node: null });
|
|
@@ -172,9 +190,13 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
|
|
|
172
190
|
pullRequest: null,
|
|
173
191
|
signature: { level: "unattested" },
|
|
174
192
|
lines: null,
|
|
193
|
+
// U-0001's record names dec-001: the decision's own work.
|
|
194
|
+
state: "decided",
|
|
175
195
|
});
|
|
176
|
-
|
|
177
|
-
expect(node(doc,
|
|
196
|
+
// Inside dec-001's window, from a unit that names no decision (#2656).
|
|
197
|
+
expect(node(doc, `commit:${sha.c2b}`)).toMatchObject({ subject: "move the port", state: "decided-by-window" });
|
|
198
|
+
expect(node(doc, `commit:${sha.c4}`)).toMatchObject({ subject: "tweak the status line", trailers: {}, state: "undecided" });
|
|
199
|
+
expect(node(doc, "unit:U-0001")).toEqual({ id: "unit:U-0001", kind: "unit", ref: "U-0001", plugin: "plugins/units.kind.mjs", data: { role: "implement", outcome: "done", decisions: ["dec-001"] } });
|
|
178
200
|
expect(node(doc, "contract:C-001")).toMatchObject({ kind: "contract", ref: "C-001", data: { status: "closed" } });
|
|
179
201
|
expect(node(doc, "evidence:E-1")).toMatchObject({ kind: "evidence", ref: "E-1", data: { ok: true } });
|
|
180
202
|
expect(node(doc, "record:decision/dec-001")).toMatchObject({
|
|
@@ -206,6 +228,10 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
|
|
|
206
228
|
|
|
207
229
|
expect(edges(doc)).toEqual([
|
|
208
230
|
`touched-by region:app/server.mjs -> commit:${sha.c4}`,
|
|
231
|
+
`touched-by region:app/server.mjs -> commit:${sha.c2b}`,
|
|
232
|
+
`produced-by commit:${sha.c2b} -> unit:U-0002`,
|
|
233
|
+
"serves unit:U-0002 -> contract:ctr-002",
|
|
234
|
+
"cites-evidence unit:U-0002 -> evidence:E-1",
|
|
209
235
|
`touched-by region:app/server.mjs -> commit:${sha.c2}`,
|
|
210
236
|
`produced-by commit:${sha.c2} -> unit:U-0001`,
|
|
211
237
|
"serves unit:U-0001 -> contract:C-001",
|
|
@@ -214,28 +240,82 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
|
|
|
214
240
|
"supersedes record:decision/dec-002 -> record:decision/dec-001",
|
|
215
241
|
"pins record:decision/dec-001 -> artifact:design/screens/home.json (pinned)",
|
|
216
242
|
"pins record:decision/dec-002 -> artifact:design/screens/home.json (stale)",
|
|
243
|
+
`within commit:${sha.c2b} -> record:decision/dec-001`,
|
|
244
|
+
`within commit:${sha.c2} -> record:decision/dec-001`,
|
|
245
|
+
]);
|
|
246
|
+
expect(doc.edges.filter((e) => e.kind === "within")).toEqual([
|
|
247
|
+
{ kind: "within", from: `commit:${sha.c2b}`, to: "record:decision/dec-001", state: "decided-by-window" },
|
|
248
|
+
{ kind: "within", from: `commit:${sha.c2}`, to: "record:decision/dec-001", state: "decided" },
|
|
217
249
|
]);
|
|
218
250
|
|
|
219
|
-
// c2
|
|
251
|
+
// c2 and c2b fall inside dec-001's window (from c1 until c3), so neither is
|
|
252
|
+
// intent-commit-undecided; c4 comes after it. The plugin's finding is about
|
|
253
|
+
// c2b, and its refs resolve to the nodes they name, except the one that
|
|
254
|
+
// names nothing in the graph.
|
|
220
255
|
expect(findings(doc)).toEqual([
|
|
221
256
|
["intent-commit-undecided", [`commit:${sha.c4}`, "region:app/server.mjs"]],
|
|
222
257
|
["intent-commit-bare", [`commit:${sha.c4}`]],
|
|
223
258
|
["intent-decision-superseded-live", ["region:app/server.mjs", "record:decision/dec-001"]],
|
|
224
259
|
["intent-trailer-unverified", [`commit:${sha.c2}`]],
|
|
260
|
+
["plugin:units:criteria-changed-undecided", [`commit:${sha.c2b}`, "contract:ctr-002", "record:decision/dec-001"]],
|
|
225
261
|
]);
|
|
226
|
-
expect(doc
|
|
262
|
+
expect(node(doc, "finding:plugin:units:criteria-changed-undecided:1")).toEqual({
|
|
263
|
+
id: "finding:plugin:units:criteria-changed-undecided:1",
|
|
264
|
+
kind: "finding",
|
|
265
|
+
code: "plugin:units:criteria-changed-undecided",
|
|
266
|
+
message: "ctr-002 changed its criteria in this commit with no decision",
|
|
267
|
+
concerns: [`commit:${sha.c2b}`, "contract:ctr-002", "record:decision/dec-001"],
|
|
268
|
+
plugin: "plugins/units.kind.mjs",
|
|
269
|
+
refs: [sha.c2b, "ctr-002", "dec-001", "no-such-thing"],
|
|
270
|
+
});
|
|
271
|
+
expect(doc.summary).toEqual({ commits: 3, decisions: 2, artifacts: 1, findings: 5 });
|
|
227
272
|
});
|
|
228
273
|
|
|
229
|
-
test("the text walk runs region, decisions, artifacts, commits, findings, one line each", async () => {
|
|
274
|
+
test("the text walk runs region, decisions with their in-window commits, artifacts, commits, findings, one line each", async () => {
|
|
230
275
|
const text = formatIntent(await walk("app/server.mjs"));
|
|
231
276
|
const lines = text.split("\n");
|
|
232
|
-
expect(lines.map((l) => l.trim().split(/\s+/)[0])).toEqual([
|
|
277
|
+
expect(lines.map((l) => l.trim().split(/\s+/)[0])).toEqual([
|
|
278
|
+
"region",
|
|
279
|
+
"decision",
|
|
280
|
+
"within",
|
|
281
|
+
"decided",
|
|
282
|
+
"ask",
|
|
283
|
+
"decision",
|
|
284
|
+
"artifact",
|
|
285
|
+
"commit",
|
|
286
|
+
"commit",
|
|
287
|
+
"unit",
|
|
288
|
+
"contract",
|
|
289
|
+
"evidence",
|
|
290
|
+
"commit",
|
|
291
|
+
"unit",
|
|
292
|
+
"contract",
|
|
293
|
+
"evidence",
|
|
294
|
+
"finding",
|
|
295
|
+
"finding",
|
|
296
|
+
"finding",
|
|
297
|
+
"finding",
|
|
298
|
+
"finding",
|
|
299
|
+
"3",
|
|
300
|
+
]);
|
|
233
301
|
expect(lines[0]).toBe("region app/server.mjs (file, member app) in the working tree");
|
|
234
302
|
expect(lines[1]).toContain("dec-001 decided, superseded by dec-002");
|
|
235
303
|
expect(lines[1]).toContain("path:app/server.mjs (path)");
|
|
236
|
-
|
|
237
|
-
expect(lines[
|
|
238
|
-
expect(lines
|
|
304
|
+
// The in-window commits under their decision, and the question once (#2656, #2650 B).
|
|
305
|
+
expect(lines[2]).toBe(` within ${sha.c2b.slice(0, 8)} move the port; unit U-0002, in dec-001's window and not its work`);
|
|
306
|
+
expect(lines[3]).toBe(` decided ${sha.c2.slice(0, 8)} add the server; unit U-0001, dec-001's own work`);
|
|
307
|
+
expect(lines[4]).toBe(" ask is this drift, a superseding decision nobody wrote down, or the decision being wrong?");
|
|
308
|
+
expect(lines[6]).toBe(`artifact design/screens/home.json stale; pinned by dec-001 at ${HOME_SHA.slice(0, 8)} (pinned), dec-002 at ${HOME_SHA.slice(0, 8)} (stale); now ${HOME_SHA.slice(0, 8)}`);
|
|
309
|
+
expect(lines[7]).toContain(`${sha.c4.slice(0, 8)} `);
|
|
310
|
+
expect(lines.at(-2)).toBe("finding plugin:units:criteria-changed-undecided: ctr-002 changed its criteria in this commit with no decision");
|
|
311
|
+
expect(lines.at(-1)).toBe("3 commits, 2 decisions, 1 artifacts, 5 findings");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("a decision whose in-window commits are all its own work asks nothing", async () => {
|
|
315
|
+
const doc = await walk("app/server.mjs", { at: sha.c2 });
|
|
316
|
+
const text = formatIntent(doc);
|
|
317
|
+
expect(text).toContain(` decided ${sha.c2.slice(0, 8)} add the server`);
|
|
318
|
+
expect(text).not.toContain(" ask ");
|
|
239
319
|
});
|
|
240
320
|
|
|
241
321
|
test("a line range follows the lines with git log -L, and names the lines each commit changed", async () => {
|
|
@@ -250,15 +330,27 @@ describe("the intent graph of app/server.mjs (#2651)", () => {
|
|
|
250
330
|
|
|
251
331
|
test("the data form of commitJoins joins the same unit, with no plugin code", async () => {
|
|
252
332
|
const doc = await walk("app/server.mjs", { kinds: [KIND, "plugins/units-data.kind.mjs"] });
|
|
253
|
-
expect(doc.kinds[1]).toEqual({ file: "plugins/units-data.kind.mjs", records: null, joins: "data" });
|
|
254
|
-
expect(node(doc, "unit:U-0001")).toEqual({
|
|
333
|
+
expect(doc.kinds[1]).toEqual({ file: "plugins/units-data.kind.mjs", name: "units-data", records: null, joins: "data" });
|
|
334
|
+
expect(node(doc, "unit:U-0001")).toEqual({
|
|
335
|
+
id: "unit:U-0001",
|
|
336
|
+
kind: "unit",
|
|
337
|
+
ref: "U-0001",
|
|
338
|
+
plugin: "plugins/units-data.kind.mjs",
|
|
339
|
+
data: { role: "implement", contract: "C-001", outcome: "done", decisions: ["dec-001"] },
|
|
340
|
+
});
|
|
255
341
|
expect(doc.edges).toContainEqual({ kind: "produced-by", from: `commit:${sha.c2}`, to: "unit:U-0001" });
|
|
342
|
+
// The unit record's decisions field makes c2 dec-001's own work in the data form too.
|
|
343
|
+
expect(node(doc, `commit:${sha.c2}`)).toMatchObject({ state: "decided" });
|
|
344
|
+
expect(node(doc, `commit:${sha.c2b}`)).toMatchObject({ state: "decided-by-window" });
|
|
345
|
+
expect(findings(doc).map(([c]) => c)).not.toContain("plugin:units:criteria-changed-undecided");
|
|
256
346
|
expect(findings(doc).map(([c]) => c)).toContain("intent-trailer-unverified");
|
|
257
347
|
});
|
|
258
348
|
|
|
259
349
|
test("without --kind there are no decisions, so no decision findings", async () => {
|
|
260
350
|
const doc = await walk("app/server.mjs", { kinds: [] });
|
|
261
|
-
expect(doc.nodes.map((n) => n.kind)).toEqual(["region", "member", "commit", "commit"]);
|
|
351
|
+
expect(doc.nodes.map((n) => n.kind)).toEqual(["region", "member", "commit", "commit", "commit"]);
|
|
352
|
+
expect(doc.nodes.filter((n) => n.kind === "commit").map((n) => n.kind === "commit" && n.state)).toEqual([null, null, null]);
|
|
353
|
+
expect(doc.edges.filter((e) => e.kind === "within")).toEqual([]);
|
|
262
354
|
expect(findings(doc)).toEqual([]);
|
|
263
355
|
});
|
|
264
356
|
|
|
@@ -360,6 +452,17 @@ describe("findings the fixture does not raise (#2651)", () => {
|
|
|
360
452
|
),
|
|
361
453
|
);
|
|
362
454
|
|
|
455
|
+
test(
|
|
456
|
+
"a commit whose unit serves a contract the decision constrains is the decision's own work (#2656)",
|
|
457
|
+
withFile("decisions/dec-001-server.md", decision("dec-001", { constrains: ["path:app/server.mjs", "ctr-002"], evidence: [pin] }), async () => {
|
|
458
|
+
const doc = await walk("app/server.mjs");
|
|
459
|
+
expect(doc.edges).toContainEqual({ kind: "constrains", from: "record:decision/dec-001", to: "contract:ctr-002", granularity: "contract", entry: "ctr-002" });
|
|
460
|
+
expect(node(doc, `commit:${sha.c2b}`)).toMatchObject({ state: "decided" });
|
|
461
|
+
expect(doc.edges).toContainEqual({ kind: "within", from: `commit:${sha.c2b}`, to: "record:decision/dec-001", state: "decided" });
|
|
462
|
+
expect(formatIntent(doc)).not.toContain(" ask ");
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
|
|
363
466
|
test(
|
|
364
467
|
"a decision constraining an issue the region's commits name covers it at issue granularity",
|
|
365
468
|
withFile("decisions/dec-003-issue.md", decision("dec-003", { constrains: ["acme/studio#12"] }), async () => {
|
|
@@ -406,7 +509,22 @@ describe("reads that fail, and parts that can't be read (#2651)", () => {
|
|
|
406
509
|
if ("error" in doc) throw new Error(doc.error.message);
|
|
407
510
|
expect(failed).toBe(true);
|
|
408
511
|
expect(doc.reasons).toEqual([{ code: "intent-plugin-failed", message: expect.stringContaining("no such unit") }]);
|
|
409
|
-
expect(doc.summary.commits).toBe(
|
|
512
|
+
expect(doc.summary.commits).toBe(4);
|
|
513
|
+
} finally {
|
|
514
|
+
git(root, "reset", "-q", "--hard", sha.c4);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("a plugin finding outside the kind's namespace is intent-plugin-failed (#2656)", async () => {
|
|
519
|
+
writeFiles(root, { "app/server.mjs": `${SERVER_2}// bad code\n` });
|
|
520
|
+
commit(["a finding in someone else's namespace", "Unit: U-BAD"]);
|
|
521
|
+
try {
|
|
522
|
+
const { doc, failed } = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, KIND), join(root, "plugins/units.kind.mjs")] });
|
|
523
|
+
expectValid(doc);
|
|
524
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
525
|
+
expect(failed).toBe(true);
|
|
526
|
+
expect(doc.reasons).toEqual([{ code: "intent-plugin-failed", message: expect.stringContaining("plugin:units:<code>") }]);
|
|
527
|
+
expect(doc.nodes.some((n) => n.kind === "finding" && n.code === "plugin:other:x")).toBe(false);
|
|
410
528
|
} finally {
|
|
411
529
|
git(root, "reset", "-q", "--hard", sha.c4);
|
|
412
530
|
}
|