@intentius/chant 0.95.1 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +9 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/run-live.d.ts +127 -0
- package/dist/op/run-live.d.ts.map +1 -0
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/workspace/checks/diagrams.d.ts +5 -0
- package/dist/workspace/checks/diagrams.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +5 -5
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +50 -7
- package/dist/workspace/intent-cli.d.ts +6 -0
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-record.d.ts +145 -0
- package/dist/workspace/intent-record.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +46 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +2 -2
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/patch.d.ts +122 -0
- package/dist/workspace/patch.d.ts.map +1 -0
- package/dist/workspace/points-cli.d.ts +8 -0
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-decided.d.ts +36 -0
- package/dist/workspace/record-decided.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +8 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +79 -12
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/main.test.ts +16 -0
- package/src/cli/main.ts +34 -3
- package/src/cli/registry.ts +9 -1
- package/src/lifecycle/run-ledger.ts +7 -1
- package/src/op/activities/shell.ts +4 -1
- package/src/op/index.ts +2 -0
- package/src/op/local-executor.ts +51 -3
- package/src/op/operator.ts +2 -11
- package/src/op/run-live.test.ts +111 -0
- package/src/op/run-live.ts +305 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/steward-points.test.ts +81 -0
- package/src/workspace/checks/diagrams.test.ts +24 -0
- package/src/workspace/checks/diagrams.ts +10 -3
- package/src/workspace/declaration.schema.json +50 -7
- package/src/workspace/declaration.test.ts +18 -0
- package/src/workspace/declaration.ts +7 -7
- package/src/workspace/intent-cli.ts +47 -1
- package/src/workspace/intent-record.schema.json +529 -0
- package/src/workspace/intent-record.test.ts +257 -0
- package/src/workspace/intent-record.ts +371 -0
- package/src/workspace/intent.schema.json +33 -1
- package/src/workspace/intent.ts +29 -12
- package/src/workspace/ls-contract.test.ts +14 -0
- package/src/workspace/ls.schema.json +3 -3
- package/src/workspace/ls.ts +3 -3
- package/src/workspace/patch.schema.json +321 -0
- package/src/workspace/patch.test.ts +198 -0
- package/src/workspace/patch.ts +377 -0
- package/src/workspace/points-cli.ts +18 -0
- package/src/workspace/read-contract.test.ts +29 -1
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/record-decided.ts +107 -0
- package/src/workspace/records-cli.ts +12 -0
- package/src/workspace/records.schema.json +15 -0
- package/src/workspace/status-contract.test.ts +76 -2
- package/src/workspace/status-stewards.ts +149 -27
- package/src/workspace/status.schema.json +69 -6
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commit that decided a record, and the intent walk for one record, on a
|
|
3
|
+
* workspace shaped like the studio's: a root member, a smoke member with a
|
|
4
|
+
* member nested in it, a docs member, decisions that constrain a member and a
|
|
5
|
+
* file at once, a work item, and a plugin that joins commits to units.
|
|
6
|
+
*
|
|
7
|
+
* - c0 adds the workspace, smoke/run.sh, docs/claims.md and s-001, proposed,
|
|
8
|
+
* which constrains member:smoke and path:docs/claims.md, and s-004,
|
|
9
|
+
* proposed, which constrains the root member.
|
|
10
|
+
* - c1 edits docs/claims.md while s-001 is proposed.
|
|
11
|
+
* - c2 decides s-001: its window opens here.
|
|
12
|
+
* - c3 edits smoke/run.sh from unit U-0001, whose record names s-001: own work.
|
|
13
|
+
* - c4 adds W-001, open and implementing s-001, and edits smoke/lib.sh: worked.
|
|
14
|
+
* - c5 closes W-001 and adds s-002, decided, which constrains path:docs.
|
|
15
|
+
* - c6 edits docs/claims.md: inside s-002's window too.
|
|
16
|
+
* - c7 edits README.md, in the root member: outside s-001's region.
|
|
17
|
+
* - c8 edits smoke/run.sh with nothing to account for it.
|
|
18
|
+
* - c9 adds s-003, which supersedes s-001: s-001's window closes here.
|
|
19
|
+
* - c10 edits smoke/run.sh after the window.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
25
|
+
import { cleanScratch, contract, git, REPO, repo, writeFiles } from "./__fixtures__/contract-repo";
|
|
26
|
+
import { intentGraph, type CommitNode, type DecisionNode } from "./intent";
|
|
27
|
+
import { formatIntentRecord } from "./intent-cli";
|
|
28
|
+
import { intentRecord, type IntentRecordDocument } from "./intent-record";
|
|
29
|
+
import intentRecordSchema from "./intent-record.schema.json";
|
|
30
|
+
import intentSchema from "./intent.schema.json";
|
|
31
|
+
import { parseFrontMatter } from "./records";
|
|
32
|
+
import { queryRecords } from "./records-cli";
|
|
33
|
+
import recordsSchema from "./records.schema.json";
|
|
34
|
+
|
|
35
|
+
const REF = join(REPO, "reference-workspace");
|
|
36
|
+
const BASE = (() => {
|
|
37
|
+
const fm = parseFrontMatter(readFileSync(join(REF, "decisions", "ref-001-how-the-app-is-deployed.md"), "utf-8"));
|
|
38
|
+
if (!fm.ok) throw new Error(fm.message);
|
|
39
|
+
return fm.value;
|
|
40
|
+
})();
|
|
41
|
+
|
|
42
|
+
function decision(id: string, fields: Record<string, unknown>): string {
|
|
43
|
+
const data = { ...BASE, id, title: `Decision ${id}`, state: "decided", supersedes: [], evidence: [], reviews: [], ...fields };
|
|
44
|
+
return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function work(id: string, fields: Record<string, unknown>): string {
|
|
48
|
+
const data = { schema: 1, id, title: `Work ${id}`, state: "open", implements: [], needs: [], constrains: [], evidence: [], opened_on: "2026-09-24", supersedes: [], ...fields };
|
|
49
|
+
return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n\nWhat the work is.\n`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PLUGIN = `
|
|
53
|
+
export function commitJoins(commit, context) {
|
|
54
|
+
const id = commit.trailers["Unit"]?.[0];
|
|
55
|
+
if (!id) return undefined;
|
|
56
|
+
const unit = JSON.parse(context.read(\`units/\${id}.json\`));
|
|
57
|
+
return { unit: { id, decisions: unit.decisions }, contract: { id: unit.contract } };
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
const S001 = { constrains: ["member:smoke", "path:docs/claims.md"] };
|
|
62
|
+
|
|
63
|
+
let root: string;
|
|
64
|
+
const sha: Record<string, string> = {};
|
|
65
|
+
let tick = 0;
|
|
66
|
+
|
|
67
|
+
function commit(message: string[]): string {
|
|
68
|
+
// One second apart, so commit dates order the history.
|
|
69
|
+
const date = `2026-09-26T12:00:${String(tick++).padStart(2, "0")}Z`;
|
|
70
|
+
process.env.GIT_AUTHOR_DATE = date;
|
|
71
|
+
process.env.GIT_COMMITTER_DATE = date;
|
|
72
|
+
try {
|
|
73
|
+
git(root, "add", "-A");
|
|
74
|
+
git(root, "commit", "-q", ...message.flatMap((m) => ["-m", m]));
|
|
75
|
+
} finally {
|
|
76
|
+
delete process.env.GIT_AUTHOR_DATE;
|
|
77
|
+
delete process.env.GIT_COMMITTER_DATE;
|
|
78
|
+
}
|
|
79
|
+
return git(root, "rev-parse", "HEAD");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const KINDS = ["decisions/decision.kind.mjs", "work/work.kind.mjs", "plugins/units.kind.mjs"];
|
|
83
|
+
|
|
84
|
+
beforeAll(() => {
|
|
85
|
+
root = repo({
|
|
86
|
+
"chant.workspace.json": JSON.stringify({
|
|
87
|
+
name: "studio",
|
|
88
|
+
schema: 1,
|
|
89
|
+
members: [
|
|
90
|
+
{ name: "studio", dir: ".", kind: "other", because: "the repo itself" },
|
|
91
|
+
{ name: "smoke", dir: "smoke", kind: "other", because: "the smoke test" },
|
|
92
|
+
{ name: "docs", dir: "docs", kind: "other", because: "prose" },
|
|
93
|
+
],
|
|
94
|
+
}),
|
|
95
|
+
"decisions/decision.kind.mjs": readFileSync(join(REF, "decisions", "decision.kind.mjs"), "utf-8"),
|
|
96
|
+
"decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
|
|
97
|
+
"work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
|
|
98
|
+
"work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
|
|
99
|
+
"plugins/units.kind.mjs": PLUGIN,
|
|
100
|
+
"units/U-0001.json": JSON.stringify({ contract: "C-001", decisions: ["s-001"] }),
|
|
101
|
+
"README.md": "The studio.\n",
|
|
102
|
+
"smoke/run.sh": "echo run\n",
|
|
103
|
+
"docs/claims.md": "# Claims\n",
|
|
104
|
+
"decisions/s-001-smoke.md": decision("s-001", { ...S001, state: "proposed" }),
|
|
105
|
+
"decisions/s-004-root.md": decision("s-004", { constrains: ["member:studio"], state: "proposed" }),
|
|
106
|
+
});
|
|
107
|
+
sha.c0 = commit(["the workspace, and s-001 proposed"]);
|
|
108
|
+
writeFiles(root, { "docs/claims.md": "# Claims\n\nOne.\n" });
|
|
109
|
+
sha.c1 = commit(["a claim while s-001 is proposed"]);
|
|
110
|
+
writeFiles(root, { "decisions/s-001-smoke.md": decision("s-001", S001) });
|
|
111
|
+
sha.c2 = commit(["decide s-001"]);
|
|
112
|
+
writeFiles(root, { "smoke/run.sh": "echo run twice\n" });
|
|
113
|
+
sha.c3 = commit(["run twice", "Unit: U-0001"]);
|
|
114
|
+
writeFiles(root, { "work/W-001-lib.md": work("W-001", { implements: ["s-001"] }), "smoke/lib.sh": "echo lib\n" });
|
|
115
|
+
sha.c4 = commit(["W-001 and the lib"]);
|
|
116
|
+
writeFiles(root, {
|
|
117
|
+
"work/W-001-lib.md": work("W-001", { implements: ["s-001"], state: "done", closed_on: "2026-09-26", evidence: [{ title: "The run", url: "https://example.com/run" }] }),
|
|
118
|
+
"decisions/s-002-docs.md": decision("s-002", { constrains: ["path:docs"] }),
|
|
119
|
+
});
|
|
120
|
+
sha.c5 = commit(["W-001 done, and s-002 for the docs"]);
|
|
121
|
+
writeFiles(root, { "docs/claims.md": "# Claims\n\nOne.\nTwo.\n" });
|
|
122
|
+
sha.c6 = commit(["a second claim"]);
|
|
123
|
+
writeFiles(root, { "README.md": "The studio, again.\n" });
|
|
124
|
+
sha.c7 = commit(["the readme"]);
|
|
125
|
+
writeFiles(root, { "smoke/run.sh": "echo run three times\n" });
|
|
126
|
+
sha.c8 = commit(["run three times"]);
|
|
127
|
+
writeFiles(root, { "decisions/s-003-smoke.md": decision("s-003", { ...S001, supersedes: [{ decision: "s-001" }] }) });
|
|
128
|
+
sha.c9 = commit(["s-003 supersedes s-001"]);
|
|
129
|
+
writeFiles(root, { "smoke/run.sh": "echo run four times\n" });
|
|
130
|
+
sha.c10 = commit(["run four times"]);
|
|
131
|
+
});
|
|
132
|
+
afterAll(cleanScratch);
|
|
133
|
+
|
|
134
|
+
const recordContract = contract(intentRecordSchema);
|
|
135
|
+
type RecordResult = Exclude<IntentRecordDocument, { error: unknown }>;
|
|
136
|
+
|
|
137
|
+
async function walkRecord(record: string, options: { kinds?: string[]; at?: string } = {}): Promise<RecordResult> {
|
|
138
|
+
const { doc } = await intentRecord({ cwd: root, record, at: options.at, kinds: (options.kinds ?? KINDS).map((k) => join(root, k)) });
|
|
139
|
+
recordContract.expectValid(doc);
|
|
140
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
141
|
+
return doc;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("the commit that decided a record", () => {
|
|
145
|
+
test("records --json names it: the commit that moved the record into an approved state, not the one that added it", async () => {
|
|
146
|
+
const doc = await queryRecords({ kind: join(root, "decisions/decision.kind.mjs"), cwd: root });
|
|
147
|
+
contract(recordsSchema).expectValid(doc);
|
|
148
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
149
|
+
const decided = Object.fromEntries(doc.records.map((r) => [r.id, r.decidedIn?.sha ?? null]));
|
|
150
|
+
expect(decided).toEqual({ "s-001": sha.c2, "s-002": sha.c5, "s-003": sha.c9, "s-004": null });
|
|
151
|
+
expect(doc.records.find((r) => r.id === "s-001")!.decidedIn).toEqual({ sha: sha.c2, date: "2026-09-26T12:00:02Z", subject: "decide s-001" });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a proposed record, or one read before it was decided, has none; a work kind carries no decidedIn", async () => {
|
|
155
|
+
const before = await queryRecords({ kind: join(root, "decisions/decision.kind.mjs"), cwd: root, at: sha.c1 });
|
|
156
|
+
if ("error" in before) throw new Error(before.error.message);
|
|
157
|
+
expect(before.records.map((r) => [r.id, r.decidedIn])).toEqual([
|
|
158
|
+
["s-001", null],
|
|
159
|
+
["s-004", null],
|
|
160
|
+
]);
|
|
161
|
+
const w = await queryRecords({ kind: join(root, "work/work.kind.mjs"), cwd: root });
|
|
162
|
+
if ("error" in w) throw new Error(w.error.message);
|
|
163
|
+
expect(w.records.every((r) => !("decidedIn" in r))).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("graph --intent carries it on the decision node, and the window opens there: the edit made while s-001 was proposed is outside it", async () => {
|
|
167
|
+
const { doc } = await intentGraph({ cwd: root, region: "docs/claims.md", kinds: KINDS.map((k) => join(root, k)) });
|
|
168
|
+
contract(intentSchema).expectValid(doc);
|
|
169
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
170
|
+
const s001 = doc.nodes.find((n): n is DecisionNode => n.kind === "decision" && n.record === "s-001")!;
|
|
171
|
+
expect(s001.decidedIn).toEqual({ sha: sha.c2, date: "2026-09-26T12:00:02Z", subject: "decide s-001" });
|
|
172
|
+
const state = (s: string) => doc.nodes.find((n): n is CommitNode => n.kind === "commit" && n.sha === s)!.state;
|
|
173
|
+
expect(state(sha.c1)).toBe("undecided");
|
|
174
|
+
expect(state(sha.c6)).toBe("decided-by-window");
|
|
175
|
+
expect(doc.edges.filter((e) => e.kind === "within" && e.from === `commit:${sha.c6}`).map((e) => e.to)).toEqual(["record:decision/s-001", "record:decision/s-002"]);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("graph --intent --record", () => {
|
|
180
|
+
test("walks every path and member entry once, keeps the commits in the window, and buckets each against the record", async () => {
|
|
181
|
+
const doc = await walkRecord("s-001");
|
|
182
|
+
expect(doc.record).toEqual({
|
|
183
|
+
id: "record:decision/s-001",
|
|
184
|
+
recordKind: "decision",
|
|
185
|
+
record: "s-001",
|
|
186
|
+
path: "decisions/s-001-smoke.md",
|
|
187
|
+
title: "Decision s-001",
|
|
188
|
+
state: "decided",
|
|
189
|
+
supersededBy: "s-003",
|
|
190
|
+
decidedIn: { sha: sha.c2, date: "2026-09-26T12:00:02Z", subject: "decide s-001" },
|
|
191
|
+
constrains: [
|
|
192
|
+
{ entry: "member:smoke", granularity: "member", path: "smoke", exists: true, walked: true },
|
|
193
|
+
{ entry: "path:docs/claims.md", granularity: "path", path: "docs/claims.md", exists: true, walked: true },
|
|
194
|
+
],
|
|
195
|
+
});
|
|
196
|
+
expect(doc.window).toEqual({ from: sha.c2, until: sha.c9 });
|
|
197
|
+
expect(doc.commits.map((c) => [c.sha, c.bucket, c.files, c.entries])).toEqual([
|
|
198
|
+
[sha.c8, "unexplained", ["smoke/run.sh"], ["member:smoke"]],
|
|
199
|
+
[sha.c6, "within-other", ["docs/claims.md"], ["path:docs/claims.md"]],
|
|
200
|
+
[sha.c4, "worked", ["smoke/lib.sh"], ["member:smoke"]],
|
|
201
|
+
[sha.c3, "own", ["smoke/run.sh"], ["member:smoke"]],
|
|
202
|
+
]);
|
|
203
|
+
const by = Object.fromEntries(doc.commits.map((c) => [c.sha, { unit: c.unit, workedBy: c.workedBy, alsoWithin: c.alsoWithin }]));
|
|
204
|
+
expect(by[sha.c3]).toEqual({ unit: "U-0001", workedBy: [], alsoWithin: [] });
|
|
205
|
+
expect(by[sha.c4]).toEqual({ unit: null, workedBy: [{ recordKind: "work", record: "W-001", state: "done" }], alsoWithin: [] });
|
|
206
|
+
expect(by[sha.c6]).toEqual({ unit: null, workedBy: [], alsoWithin: [{ recordKind: "decision", record: "s-002", state: "decided" }] });
|
|
207
|
+
// c0, c1 and c10 changed the region outside the window; c7 changed only the root member.
|
|
208
|
+
expect(doc.counts).toEqual({ commits: 4, own: 1, worked: 1, withinOther: 1, unexplained: 1, outsideWindow: 3 });
|
|
209
|
+
expect(doc.reasons).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("the text walk prints the record, its window, each commit with its bucket, and the counts", async () => {
|
|
213
|
+
const text = formatIntentRecord(await walkRecord("decision/s-001"));
|
|
214
|
+
expect(text.split("\n")[0]).toBe("record s-001 decided, superseded by s-003: Decision s-001");
|
|
215
|
+
expect(text).toContain(`window from ${sha.c2.slice(0, 8)} until ${sha.c9.slice(0, 8)}; decided in ${sha.c2.slice(0, 8)}`);
|
|
216
|
+
expect(text).toContain(`own ${sha.c3.slice(0, 8)} 2026-09-26 run twice; unit U-0001; smoke/run.sh`);
|
|
217
|
+
expect(text).toContain(`worked ${sha.c4.slice(0, 8)} 2026-09-26 W-001 and the lib; worked by W-001; smoke/lib.sh`);
|
|
218
|
+
expect(text).toContain(`within-other ${sha.c6.slice(0, 8)} 2026-09-26 a second claim; within s-002; docs/claims.md`);
|
|
219
|
+
expect(text.split("\n").at(-1)).toBe("4 commits in the window: 1 own, 1 worked, 1 within another record's window, 1 unexplained; 3 outside it");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a current record's window runs to the revision read; --at reads the history as it was", async () => {
|
|
223
|
+
const s003 = await walkRecord("s-003");
|
|
224
|
+
expect(s003.window).toEqual({ from: sha.c9, until: null });
|
|
225
|
+
expect(s003.commits.map((c) => [c.sha, c.bucket])).toEqual([[sha.c10, "unexplained"]]);
|
|
226
|
+
const early = await walkRecord("s-001", { at: sha.c4 });
|
|
227
|
+
expect(early.window).toEqual({ from: sha.c2, until: null });
|
|
228
|
+
expect(early.commits.map((c) => [c.sha, c.bucket])).toEqual([
|
|
229
|
+
[sha.c4, "worked"],
|
|
230
|
+
[sha.c3, "own"],
|
|
231
|
+
]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("the root member's history leaves out the members inside it, and a proposed record's window opens where it was added", async () => {
|
|
235
|
+
const doc = await walkRecord("s-004");
|
|
236
|
+
expect(doc.record.decidedIn).toBeNull();
|
|
237
|
+
expect(doc.window).toEqual({ from: sha.c0, until: null });
|
|
238
|
+
expect(doc.commits.map((c) => c.sha)).toEqual([sha.c9, sha.c7, sha.c5, sha.c4, sha.c2, sha.c0]);
|
|
239
|
+
expect(doc.commits.flatMap((c) => c.files).filter((f) => f.startsWith("smoke/") || f.startsWith("docs/"))).toEqual([]);
|
|
240
|
+
expect(doc.commits.find((c) => c.sha === sha.c7)!.files).toEqual(["README.md"]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("without the plugin nothing joins c3 to a unit, so it is unexplained; without the work kind c4 is too", async () => {
|
|
244
|
+
const doc = await walkRecord("s-001", { kinds: ["decisions/decision.kind.mjs"] });
|
|
245
|
+
expect(doc.commits.map((c) => c.bucket)).toEqual(["unexplained", "within-other", "unexplained", "unexplained"]);
|
|
246
|
+
expect(doc.counts).toMatchObject({ own: 0, worked: 0, withinOther: 1, unexplained: 3 });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("an id no decision has is intent-record-unknown, and so is a work item's", async () => {
|
|
250
|
+
for (const id of ["s-404", "W-001"]) {
|
|
251
|
+
const { doc, failed } = await intentRecord({ cwd: root, record: id, kinds: KINDS.map((k) => join(root, k)) });
|
|
252
|
+
recordContract.expectValid(doc);
|
|
253
|
+
expect(failed).toBe(true);
|
|
254
|
+
expect("error" in doc && doc.error.code).toBe("intent-record-unknown");
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent walk for one record: `chant workspace graph --intent --record
|
|
3
|
+
* <id>`.
|
|
4
|
+
*
|
|
5
|
+
* `graph --intent <path>` answers for one region. A decision's `constrains`
|
|
6
|
+
* may list several `path:` and `member:` entries, and a reader drawing the
|
|
7
|
+
* decision's drift wants every commit that changed any of them while the
|
|
8
|
+
* decision stood. This walk reads the history of each entry the way the
|
|
9
|
+
* region walk does (`git log --follow` for a file, `git log` for a
|
|
10
|
+
* directory, and for a member, its directory less the members nested in it),
|
|
11
|
+
* keeps each commit once, and keeps the commits inside the record's window:
|
|
12
|
+
* from the commit that decided it, or added it when it is not approved
|
|
13
|
+
* (`record-decided.ts`), until the commit that opened the window of the
|
|
14
|
+
* record superseding it.
|
|
15
|
+
*
|
|
16
|
+
* Each commit gets one bucket, relative to this record, in this order:
|
|
17
|
+
*
|
|
18
|
+
* - `own`: the record's own work, as the region walk judges it: a commit a
|
|
19
|
+
* plugin joins to a unit whose unit or contract names the record, or that
|
|
20
|
+
* serves a contract the record constrains.
|
|
21
|
+
* - `worked`: inside the window of a work item that is not dropped and that
|
|
22
|
+
* implements the record, or whose `path:` entries cover a file the commit
|
|
23
|
+
* changed in the record's region.
|
|
24
|
+
* - `within-other`: inside the window of another decision whose `path:`
|
|
25
|
+
* entries cover a file the commit changed in the record's region.
|
|
26
|
+
* - `unexplained`: none of these.
|
|
27
|
+
*
|
|
28
|
+
* `workedBy` and `alsoWithin` list every work item and other decision that
|
|
29
|
+
* matched, whatever the bucket. `issue` and `contract` entries are listed and
|
|
30
|
+
* not walked: they name no path. Git is read through a local `git`
|
|
31
|
+
* subprocess only: no fetch, no network.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { declaredRecordKinds, readDeclaration, readerVersion, WorkspaceReadError } from "./declaration";
|
|
35
|
+
import { declaredKindFile } from "./declared-kinds";
|
|
36
|
+
import { entityDecisions, runCommitJoins } from "./intent-joins";
|
|
37
|
+
import {
|
|
38
|
+
addingCommit,
|
|
39
|
+
closingCommit,
|
|
40
|
+
commitDetails,
|
|
41
|
+
descendants,
|
|
42
|
+
IntentError,
|
|
43
|
+
loadKinds,
|
|
44
|
+
regionHistory,
|
|
45
|
+
tryGit,
|
|
46
|
+
windowOpening,
|
|
47
|
+
type IntentErrorCode,
|
|
48
|
+
type IntentReason,
|
|
49
|
+
type LoadedKind,
|
|
50
|
+
} from "./intent";
|
|
51
|
+
import { constraintCovers, isWorkspacePath, memberHolding } from "./record-assets";
|
|
52
|
+
import type { DecidedIn } from "./record-decided";
|
|
53
|
+
import { RecordReadError } from "./records";
|
|
54
|
+
import type { RecordView } from "./records-cli";
|
|
55
|
+
import { joinPath } from "./tree";
|
|
56
|
+
import { locateWorkspace } from "./which-chant";
|
|
57
|
+
import { idList } from "./work";
|
|
58
|
+
|
|
59
|
+
/** The version of the `intent-record` document this chant writes. */
|
|
60
|
+
export const INTENT_RECORD_CONTRACT_VERSION = 1;
|
|
61
|
+
|
|
62
|
+
/** `$id` of the JSON Schema for the document, shipped beside this file. */
|
|
63
|
+
export const INTENT_RECORD_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/intent-record/v1/intent-record.schema.json";
|
|
64
|
+
|
|
65
|
+
export const INTENT_RECORD_BUCKETS = ["own", "worked", "within-other", "unexplained"] as const;
|
|
66
|
+
export type IntentRecordBucket = (typeof INTENT_RECORD_BUCKETS)[number];
|
|
67
|
+
|
|
68
|
+
export interface RecordConstraint {
|
|
69
|
+
entry: string;
|
|
70
|
+
granularity: "path" | "member" | "contract" | "issue";
|
|
71
|
+
/** For a path entry, from the workspace root; for a member entry, the member's directory. Null otherwise. */
|
|
72
|
+
path: string | null;
|
|
73
|
+
/** For a path entry, whether the path is in the tree read; for a member entry, whether the declaration names the member. Null for an entry that is not walked. */
|
|
74
|
+
exists: boolean | null;
|
|
75
|
+
/** Whether its history was read. */
|
|
76
|
+
walked: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RecordRef {
|
|
80
|
+
recordKind: string;
|
|
81
|
+
record: string;
|
|
82
|
+
state: string | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface RecordCommit {
|
|
86
|
+
sha: string;
|
|
87
|
+
subject: string;
|
|
88
|
+
author: { name: string; email: string };
|
|
89
|
+
date: string;
|
|
90
|
+
trailers: Record<string, string[]>;
|
|
91
|
+
pullRequest: number | null;
|
|
92
|
+
/** The unit a plugin joined the commit to, or null. */
|
|
93
|
+
unit: string | null;
|
|
94
|
+
/** The record's constrains entries whose history listed the commit. */
|
|
95
|
+
entries: string[];
|
|
96
|
+
/** The files the commit changed in the record's region, from the workspace root. Empty when git lists none, as for a merge that changed nothing against its first parent there. */
|
|
97
|
+
files: string[];
|
|
98
|
+
bucket: IntentRecordBucket;
|
|
99
|
+
/** Every work item whose window holds the commit and that implements the record or covers one of its files by path. */
|
|
100
|
+
workedBy: RecordRef[];
|
|
101
|
+
/** Every other decision whose window holds the commit and whose path entries cover one of its files. */
|
|
102
|
+
alsoWithin: RecordRef[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface Head {
|
|
106
|
+
$schema: string;
|
|
107
|
+
contract: number;
|
|
108
|
+
chant: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type IntentRecordDocument =
|
|
112
|
+
| (Head & {
|
|
113
|
+
at: string | null;
|
|
114
|
+
workspace: { name: string; root: string };
|
|
115
|
+
record: {
|
|
116
|
+
id: string;
|
|
117
|
+
recordKind: string;
|
|
118
|
+
record: string;
|
|
119
|
+
path: string;
|
|
120
|
+
title: string | null;
|
|
121
|
+
state: string | null;
|
|
122
|
+
supersededBy: string | null;
|
|
123
|
+
decidedIn: DecidedIn | null;
|
|
124
|
+
constrains: RecordConstraint[];
|
|
125
|
+
};
|
|
126
|
+
history: { rev: string | null; shallow: boolean };
|
|
127
|
+
/** The commit the window opens at, and the one it closes at (the superseding record's opening), or null. */
|
|
128
|
+
window: { from: string | null; until: string | null };
|
|
129
|
+
kinds: { file: string; name: string; records: string | null; joins: "function" | "data" | null }[];
|
|
130
|
+
commits: RecordCommit[];
|
|
131
|
+
counts: { commits: number; own: number; worked: number; withinOther: number; unexplained: number; outsideWindow: number };
|
|
132
|
+
reasons: IntentReason[];
|
|
133
|
+
})
|
|
134
|
+
| (Head & { error: { code: IntentErrorCode; message: string } });
|
|
135
|
+
|
|
136
|
+
export interface IntentRecordQuery {
|
|
137
|
+
cwd: string;
|
|
138
|
+
/** The record's id, or `<kind name>/<id>`. */
|
|
139
|
+
record: string;
|
|
140
|
+
at?: string;
|
|
141
|
+
kinds?: string[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface IntentRecordResult {
|
|
145
|
+
doc: IntentRecordDocument;
|
|
146
|
+
failed: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const stringOr = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
150
|
+
|
|
151
|
+
/** Build the document. Never throws an {@link IntentError}, {@link WorkspaceReadError} or {@link RecordReadError}. */
|
|
152
|
+
export async function intentRecord(query: IntentRecordQuery): Promise<IntentRecordResult> {
|
|
153
|
+
const head: Head = { $schema: INTENT_RECORD_OUTPUT_SCHEMA_ID, contract: INTENT_RECORD_CONTRACT_VERSION, chant: readerVersion() };
|
|
154
|
+
try {
|
|
155
|
+
return await walk(query, head);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err instanceof IntentError || err instanceof WorkspaceReadError || err instanceof RecordReadError) {
|
|
158
|
+
return { doc: { ...head, error: { code: err.code as IntentErrorCode, message: err.message } }, failed: true };
|
|
159
|
+
}
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function walk(query: IntentRecordQuery, head: Head): Promise<IntentRecordResult> {
|
|
165
|
+
const located = locateWorkspace(query.cwd, query.at);
|
|
166
|
+
const top = located.top;
|
|
167
|
+
if (!top) throw new IntentError("not-a-git-repository", "the intent walk reads git history, and this directory is not in a git repository");
|
|
168
|
+
const declaration = readDeclaration(located.tree, "", { rootChant: true });
|
|
169
|
+
const kindFiles = query.kinds ?? declaredRecordKinds(declaration).map((d) => declaredKindFile(d, located.rootOnDisk));
|
|
170
|
+
const kinds = await loadKinds({ cwd: query.cwd, region: "", at: query.at, kinds: kindFiles }, top);
|
|
171
|
+
const workspacePrefix = located.root === "." ? "" : located.root;
|
|
172
|
+
const kindPrefix = (k: LoadedKind) => (k.records!.workspaceRoot === "." ? "" : k.records!.workspaceRoot);
|
|
173
|
+
const fromWorkspace = (full: string) => (workspacePrefix === "" ? full : full.startsWith(`${workspacePrefix}/`) ? full.slice(workspacePrefix.length + 1) : full === workspacePrefix ? "." : full);
|
|
174
|
+
|
|
175
|
+
// The record, among the decision kinds read.
|
|
176
|
+
const slash = query.record.indexOf("/");
|
|
177
|
+
const [wantKind, wantId] = slash > 0 ? [query.record.slice(0, slash), query.record.slice(slash + 1)] : [null, query.record];
|
|
178
|
+
const decisionKinds = kinds.filter((k) => k.records && !k.records.loaded.kind.work);
|
|
179
|
+
const hit = decisionKinds
|
|
180
|
+
.filter((k) => wantKind === null || k.records!.loaded.kind.name === wantKind)
|
|
181
|
+
.flatMap((k) => k.records!.views.filter((v) => v.id === wantId).map((v) => ({ kind: k, view: v })))[0];
|
|
182
|
+
if (!hit) {
|
|
183
|
+
const kindsRead = decisionKinds.map((k) => k.records!.loaded.kind.name);
|
|
184
|
+
throw new IntentError("intent-record-unknown", `no record of ${kindsRead.length > 0 ? `the decision kinds read (${kindsRead.join(", ")})` : "a decision kind: none was read"} has the id ${query.record}`);
|
|
185
|
+
}
|
|
186
|
+
const kind = hit.kind.records!.loaded.kind;
|
|
187
|
+
const view = hit.view;
|
|
188
|
+
|
|
189
|
+
const rev = located.at ?? (tryGit(top, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])?.trim() || null);
|
|
190
|
+
const shallow = tryGit(top, ["rev-parse", "--is-shallow-repository"])?.trim() === "true";
|
|
191
|
+
const reasons: IntentReason[] = [];
|
|
192
|
+
if (shallow) reasons.push({ code: "intent-history-shallow", message: "this is a shallow clone, so the history stops at its boundary and the oldest commit listed may stand for many" });
|
|
193
|
+
|
|
194
|
+
// Each constrains entry, and the commits its history lists.
|
|
195
|
+
const constrains: RecordConstraint[] = [];
|
|
196
|
+
const pathEntries: { entry: string; full: string }[] = [];
|
|
197
|
+
const memberEntries: { entry: string; name: string }[] = [];
|
|
198
|
+
const listed = new Map<string, string[]>();
|
|
199
|
+
const list = kind.constrains?.field ? view.data?.[kind.constrains.field] : undefined;
|
|
200
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
201
|
+
if (typeof entry !== "string") continue;
|
|
202
|
+
if (entry.startsWith("path:")) {
|
|
203
|
+
const p = entry.slice("path:".length);
|
|
204
|
+
if (!isWorkspacePath(p)) {
|
|
205
|
+
constrains.push({ entry, granularity: "path", path: null, exists: null, walked: false });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const full = joinPath(kindPrefix(hit.kind), p);
|
|
209
|
+
const type = located.tree.stat(fromWorkspace(full));
|
|
210
|
+
constrains.push({ entry, granularity: "path", path: fromWorkspace(full), exists: type !== undefined, walked: rev !== null });
|
|
211
|
+
pathEntries.push({ entry, full });
|
|
212
|
+
if (!rev) continue;
|
|
213
|
+
for (const t of regionHistory(top, rev, full, type === "file" ? "file" : "dir", null)) (listed.get(t.sha) ?? listed.set(t.sha, []).get(t.sha)!).push(entry);
|
|
214
|
+
} else if (entry.startsWith("member:")) {
|
|
215
|
+
const name = entry.slice("member:".length);
|
|
216
|
+
const m = declaration.members.find((x) => x.name === name);
|
|
217
|
+
constrains.push({ entry, granularity: "member", path: m ? m.dir : null, exists: !!m, walked: !!m && rev !== null });
|
|
218
|
+
if (!m) continue;
|
|
219
|
+
memberEntries.push({ entry, name });
|
|
220
|
+
if (!rev) continue;
|
|
221
|
+
// The member's directory, less the members nested inside it.
|
|
222
|
+
const dir = joinPath(workspacePrefix, m.dir);
|
|
223
|
+
const nested = declaration.members.filter((x) => x.name !== m.name && x.dir !== m.dir && (m.dir === "." || x.dir.startsWith(`${m.dir}/`))).map((x) => `:(exclude)${joinPath(workspacePrefix, x.dir)}`);
|
|
224
|
+
const out = tryGit(top, ["log", "--format=%H", rev, "--", dir === "" ? "." : dir, ...nested]);
|
|
225
|
+
for (const sha of (out ?? "").split("\n").map((s) => s.trim()).filter(Boolean)) (listed.get(sha) ?? listed.set(sha, []).get(sha)!).push(entry);
|
|
226
|
+
} else {
|
|
227
|
+
constrains.push({ entry, granularity: /^[^/\s]+\/[^/\s]+#[0-9]+$/.test(entry) ? "issue" : "contract", path: null, exists: null, walked: false });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The record's window, and each other decision's, opened the same way.
|
|
232
|
+
const decisionIndex = new Map<string, { kind: LoadedKind; view: RecordView }>();
|
|
233
|
+
for (const k of decisionKinds) for (const v of k.records!.views) if (v.id !== null) decisionIndex.set(`${k.records!.loaded.kind.name}/${v.id}`, { kind: k, view: v });
|
|
234
|
+
const decisionWindow = (k: LoadedKind, v: RecordView) => {
|
|
235
|
+
const from = rev ? windowOpening(top, rev, v) : null;
|
|
236
|
+
const successor = v.supersededBy ? decisionIndex.get(`${k.records!.loaded.kind.name}/${v.supersededBy}`) : undefined;
|
|
237
|
+
const until = rev && successor ? windowOpening(top, rev, successor.view) : null;
|
|
238
|
+
return { from, until, fromSet: from && rev ? descendants(top, from, rev) : new Set<string>(), untilSet: until && rev ? descendants(top, until, rev) : null };
|
|
239
|
+
};
|
|
240
|
+
const inWindow = (w: { fromSet: Set<string>; untilSet: Set<string> | null }, sha: string) => w.fromSet.has(sha) && !w.untilSet?.has(sha);
|
|
241
|
+
const own = decisionWindow(hit.kind, view);
|
|
242
|
+
const shas = [...listed.keys()].filter((sha) => inWindow(own, sha));
|
|
243
|
+
const details = commitDetails(top, shas);
|
|
244
|
+
// Newest first by commit date, as git log lists them.
|
|
245
|
+
const order = (shas.length > 0 ? tryGit(top, ["rev-list", "--no-walk=sorted", "--stdin"], `${shas.join("\n")}\n`) : "")?.split("\n").map((s) => s.trim()).filter(Boolean) ?? shas;
|
|
246
|
+
shas.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
|
247
|
+
|
|
248
|
+
// The files each commit changed in the record's region.
|
|
249
|
+
const inRegion = (f: string) => pathEntries.some((p) => constraintCovers(p.full, f)) || memberEntries.some((m) => memberHolding(fromWorkspace(f), declaration.members) === m.name);
|
|
250
|
+
const files = new Map<string, string[]>();
|
|
251
|
+
if (shas.length > 0) {
|
|
252
|
+
const text = tryGit(top, ["log", "--no-walk=unsorted", "--stdin", "--format=%x00%H", "--name-only", "--no-renames", "--diff-merges=first-parent"], `${shas.join("\n")}\n`) ?? "";
|
|
253
|
+
for (const chunk of text.split("\0")) {
|
|
254
|
+
if (!chunk) continue;
|
|
255
|
+
const [sha, ...names] = chunk.split("\n").map((s) => s.trim());
|
|
256
|
+
files.set(sha, [...new Set(names.filter((f) => f !== "" && inRegion(f)))].map(fromWorkspace));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Other decisions with path entries, and work items, with their windows, read once each.
|
|
261
|
+
const pathsOf = (k: LoadedKind, v: RecordView): string[] => {
|
|
262
|
+
const field = k.records!.loaded.kind.constrains?.field;
|
|
263
|
+
const entries = field ? v.data?.[field] : undefined;
|
|
264
|
+
return (Array.isArray(entries) ? entries : []).filter((e): e is string => typeof e === "string" && e.startsWith("path:") && isWorkspacePath(e.slice(5))).map((e) => joinPath(kindPrefix(k), e.slice(5)));
|
|
265
|
+
};
|
|
266
|
+
const allFiles = [...new Set([...files.values()].flat())].map((f) => joinPath(workspacePrefix, f));
|
|
267
|
+
const others: { ref: RecordRef; paths: string[]; window: ReturnType<typeof decisionWindow> }[] = [];
|
|
268
|
+
for (const [, o] of decisionIndex) {
|
|
269
|
+
if (o.view === view) continue;
|
|
270
|
+
const paths = pathsOf(o.kind, o.view);
|
|
271
|
+
if (!paths.some((p) => allFiles.some((f) => constraintCovers(p, f)))) continue;
|
|
272
|
+
others.push({ ref: { recordKind: o.kind.records!.loaded.kind.name, record: o.view.id!, state: o.view.state }, paths, window: decisionWindow(o.kind, o.view) });
|
|
273
|
+
}
|
|
274
|
+
const works: { ref: RecordRef; paths: string[]; implementsRecord: boolean; window: { fromSet: Set<string>; untilSet: Set<string> | null } }[] = [];
|
|
275
|
+
for (const k of kinds.filter((x) => x.records?.loaded.kind.work)) {
|
|
276
|
+
const wk = k.records!.loaded.kind;
|
|
277
|
+
for (const v of k.records!.views) {
|
|
278
|
+
if (v.id === null) continue;
|
|
279
|
+
const dropped = v.state !== null && v.state !== wk.work!.done && (wk.closedStates ?? []).includes(v.state);
|
|
280
|
+
if (dropped) continue;
|
|
281
|
+
const implementsRecord = idList(v.data, wk.work!.implements).includes(view.id!);
|
|
282
|
+
const paths = pathsOf(k, v);
|
|
283
|
+
if (!implementsRecord && !paths.some((p) => allFiles.some((f) => constraintCovers(p, f)))) continue;
|
|
284
|
+
const added = rev ? addingCommit(top, rev, v.path) : null;
|
|
285
|
+
const closing = rev && wk.stateField ? closingCommit(top, rev, v.path, wk.stateField, wk.closedStates ?? []) : null;
|
|
286
|
+
const untilSet = closing && rev ? descendants(top, closing, rev) : null;
|
|
287
|
+
if (closing) untilSet!.delete(closing);
|
|
288
|
+
works.push({ ref: { recordKind: wk.name, record: v.id, state: v.state }, paths, implementsRecord, window: { fromSet: added && rev ? descendants(top, added, rev) : new Set(), untilSet } });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Each commit's origin, from the plugins, and its bucket.
|
|
293
|
+
const readAt = (path: string): string | undefined => (isWorkspacePath(path) && located.tree.stat(path) === "file" ? located.tree.read(path) : undefined);
|
|
294
|
+
const listAt = (dir: string): string[] | undefined => {
|
|
295
|
+
const d = dir === "." ? "" : dir.replace(/\/+$/, "");
|
|
296
|
+
if (d !== "" && (!isWorkspacePath(d) || located.tree.stat(d) !== "dir")) return undefined;
|
|
297
|
+
return located.tree.list(d)?.map((e) => `${joinPath(d, e.name)}${e.type === "dir" ? "/" : ""}`).sort();
|
|
298
|
+
};
|
|
299
|
+
const failedPlugins = new Set<string>();
|
|
300
|
+
const constrainedContracts = constrains.filter((c) => c.granularity === "contract").map((c) => c.entry);
|
|
301
|
+
const commits: RecordCommit[] = [];
|
|
302
|
+
for (const sha of shas) {
|
|
303
|
+
const c = details.get(sha);
|
|
304
|
+
if (!c) continue;
|
|
305
|
+
let unit: string | null = null;
|
|
306
|
+
let isOwn = false;
|
|
307
|
+
for (const k of kinds) {
|
|
308
|
+
if (!k.joins) continue;
|
|
309
|
+
let result;
|
|
310
|
+
try {
|
|
311
|
+
result = await runCommitJoins(k.joins, c, { read: readAt, list: listAt, at: located.at }, k.name);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const message = `${k.display}: commitJoins failed for ${sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
|
|
314
|
+
if (!failedPlugins.has(message)) reasons.push({ code: "intent-plugin-failed", message });
|
|
315
|
+
failedPlugins.add(message);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!result.unit) continue;
|
|
319
|
+
unit ??= result.unit.id;
|
|
320
|
+
const named = [...entityDecisions(result.unit), ...entityDecisions(result.contract)];
|
|
321
|
+
if (named.some((x) => x === view.id || x === `${kind.name}/${view.id}`)) isOwn = true;
|
|
322
|
+
if (result.contract && constrainedContracts.includes(result.contract.id)) isOwn = true;
|
|
323
|
+
}
|
|
324
|
+
const changed = (files.get(sha) ?? []).map((f) => joinPath(workspacePrefix, f));
|
|
325
|
+
const covers = (paths: string[]) => paths.some((p) => changed.some((f) => constraintCovers(p, f)));
|
|
326
|
+
const workedBy = works.filter((w) => inWindow(w.window, sha) && (w.implementsRecord || covers(w.paths))).map((w) => w.ref);
|
|
327
|
+
const alsoWithin = others.filter((o) => inWindow(o.window, sha) && covers(o.paths)).map((o) => o.ref);
|
|
328
|
+
const pr = c.subject.match(/\(#([0-9]+)\)\s*$/);
|
|
329
|
+
commits.push({
|
|
330
|
+
sha,
|
|
331
|
+
subject: c.subject,
|
|
332
|
+
author: c.author,
|
|
333
|
+
date: c.date,
|
|
334
|
+
trailers: c.trailers,
|
|
335
|
+
pullRequest: pr ? Number(pr[1]) : null,
|
|
336
|
+
unit,
|
|
337
|
+
entries: listed.get(sha)!,
|
|
338
|
+
files: files.get(sha) ?? [],
|
|
339
|
+
bucket: isOwn ? "own" : workedBy.length > 0 ? "worked" : alsoWithin.length > 0 ? "within-other" : "unexplained",
|
|
340
|
+
workedBy,
|
|
341
|
+
alsoWithin,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const count = (b: IntentRecordBucket) => commits.filter((c) => c.bucket === b).length;
|
|
346
|
+
return {
|
|
347
|
+
doc: {
|
|
348
|
+
...head,
|
|
349
|
+
at: located.at,
|
|
350
|
+
workspace: { name: declaration.name, root: located.root },
|
|
351
|
+
record: {
|
|
352
|
+
id: `record:${kind.name}/${view.id}`,
|
|
353
|
+
recordKind: kind.name,
|
|
354
|
+
record: view.id!,
|
|
355
|
+
path: view.path,
|
|
356
|
+
title: stringOr(view.data?.title),
|
|
357
|
+
state: view.state,
|
|
358
|
+
supersededBy: view.supersededBy,
|
|
359
|
+
decidedIn: view.decidedIn ?? null,
|
|
360
|
+
constrains,
|
|
361
|
+
},
|
|
362
|
+
history: { rev, shallow },
|
|
363
|
+
window: { from: own.from, until: own.until },
|
|
364
|
+
kinds: kinds.map((k) => ({ file: k.display, name: k.name, records: k.records?.loaded.kind.name ?? null, joins: k.joins?.form ?? null })),
|
|
365
|
+
commits,
|
|
366
|
+
counts: { commits: commits.length, own: count("own"), worked: count("worked"), withinOther: count("within-other"), unexplained: count("unexplained"), outsideWindow: listed.size - shas.length },
|
|
367
|
+
reasons,
|
|
368
|
+
},
|
|
369
|
+
failed: reasons.some((r) => r.code === "intent-plugin-failed"),
|
|
370
|
+
};
|
|
371
|
+
}
|