@intentius/chant 0.85.0 → 0.86.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 +11 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +13 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +14 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +211 -0
- package/dist/workspace/conformance/index.d.ts.map +1 -0
- package/dist/workspace/conformance/vitest.d.ts +11 -0
- package/dist/workspace/conformance/vitest.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +28 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +40 -0
- package/dist/workspace/declared-kinds.d.ts +43 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +2 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +45 -8
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +66 -7
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +31 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +40 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +51 -0
- package/dist/workspace/record-sessions.d.ts.map +1 -0
- package/dist/workspace/record-source.d.ts +2 -0
- package/dist/workspace/record-source.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +65 -4
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-since.d.ts +90 -0
- package/dist/workspace/records-since.d.ts.map +1 -0
- package/dist/workspace/records-write.d.ts +164 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +202 -15
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +60 -0
- package/dist/workspace/runtimes.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts +90 -0
- package/dist/workspace/status-gates.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +17 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +56 -0
- package/dist/workspace/work.d.ts.map +1 -0
- package/package.json +19 -1
- package/src/cli/main.ts +48 -3
- package/src/cli/registry.ts +11 -1
- package/src/lifecycle/gate-ledger.ts +14 -0
- package/src/workspace/__fixtures__/sessions.ts +66 -0
- package/src/workspace/checks/records.ts +19 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +7 -1
- package/src/workspace/composites.schema.json +65 -3
- package/src/workspace/composites.test.ts +95 -5
- package/src/workspace/composites.ts +28 -7
- package/src/workspace/conformance/__fixture__/app/package.json +7 -0
- package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
- package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
- package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
- package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
- package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
- package/src/workspace/conformance/conformance.test.ts +149 -0
- package/src/workspace/conformance/index.mjs +31 -0
- package/src/workspace/conformance/index.ts +453 -0
- package/src/workspace/conformance/vitest.ts +62 -0
- package/src/workspace/declaration.schema.json +40 -0
- package/src/workspace/declaration.ts +62 -0
- package/src/workspace/declared-kinds.test.ts +321 -0
- package/src/workspace/declared-kinds.ts +76 -0
- package/src/workspace/graph-cli.ts +8 -0
- package/src/workspace/intent-cli.ts +29 -6
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +71 -19
- package/src/workspace/intent.schema.json +282 -7
- package/src/workspace/intent.test.ts +97 -0
- package/src/workspace/intent.ts +332 -45
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/read-contract.test.ts +30 -9
- package/src/workspace/reason-codes.test.ts +15 -4
- package/src/workspace/reason-codes.ts +47 -4
- package/src/workspace/record-assets.test.ts +3 -1
- package/src/workspace/record-sessions.ts +105 -0
- package/src/workspace/record-source.ts +14 -5
- package/src/workspace/records-amend.schema.json +167 -0
- package/src/workspace/records-cli.ts +246 -19
- package/src/workspace/records-contract.test.ts +57 -2
- package/src/workspace/records-formats.test.ts +640 -0
- package/src/workspace/records-new.schema.json +158 -0
- package/src/workspace/records-quorum.test.ts +196 -0
- package/src/workspace/records-review.schema.json +202 -0
- package/src/workspace/records-sessions.test.ts +108 -0
- package/src/workspace/records-since.schema.json +193 -0
- package/src/workspace/records-since.test.ts +174 -0
- package/src/workspace/records-since.ts +259 -0
- package/src/workspace/records-write-contract.test.ts +125 -0
- package/src/workspace/records-write.test.ts +373 -0
- package/src/workspace/records-write.ts +736 -0
- package/src/workspace/records.schema.json +187 -9
- package/src/workspace/records.ts +631 -41
- package/src/workspace/runtimes.ts +107 -0
- package/src/workspace/status-contract.test.ts +163 -0
- package/src/workspace/status-gates.ts +215 -0
- package/src/workspace/status.schema.json +69 -3
- package/src/workspace/status.ts +35 -2
- package/src/workspace/work.test.ts +388 -0
- package/src/workspace/work.ts +163 -0
|
@@ -564,3 +564,100 @@ describe("reads that fail, and parts that can't be read (#2651)", () => {
|
|
|
564
564
|
expect(parseRegion("a/b.ts:0")).toHaveProperty("error");
|
|
565
565
|
});
|
|
566
566
|
});
|
|
567
|
+
|
|
568
|
+
describe("commitJoins lists records and names its findings (#2663)", () => {
|
|
569
|
+
test("a plugin joins commits by listing its records, with no trailer at all (#2663)", async () => {
|
|
570
|
+
const SCAN = `
|
|
571
|
+
export function commitJoins(commit, context) {
|
|
572
|
+
const walk = (dir) => (context.list(dir) ?? []).flatMap((p) => (p.endsWith("/") ? walk(p) : [p]));
|
|
573
|
+
for (const path of walk("unit-records")) {
|
|
574
|
+
const id = path.split("/").at(-1).replace(/\\.(json|md)$/, "");
|
|
575
|
+
const text = context.read(path);
|
|
576
|
+
if (path.endsWith(".json")) {
|
|
577
|
+
const record = JSON.parse(text);
|
|
578
|
+
if (record.result?.commit !== commit.sha) continue;
|
|
579
|
+
const probe = { missing: context.list("nope"), escape: context.list(".."), file: context.list(path), root: context.list(".").includes("unit-records/") };
|
|
580
|
+
return { unit: { id, path, probe }, contract: { id: record.contract } };
|
|
581
|
+
}
|
|
582
|
+
if (text.includes(commit.sha)) return { unit: { id, path } };
|
|
583
|
+
}
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
`;
|
|
587
|
+
writeFiles(root, {
|
|
588
|
+
"plugins/scan.kind.mjs": SCAN,
|
|
589
|
+
"unit-records/U-0100.json": JSON.stringify({ contract: "C-100", result: { commit: sha.c2 } }),
|
|
590
|
+
"unit-records/archive/U-0104.md": `---\nid: U-0104\n---\n\nLanded as ${sha.c4}.\n`,
|
|
591
|
+
});
|
|
592
|
+
commit(["record the units after their commits"]);
|
|
593
|
+
try {
|
|
594
|
+
const doc = await walk("app/server.mjs", { kinds: [KIND, "plugins/scan.kind.mjs"] });
|
|
595
|
+
// c4 carries no trailer; the markdown record in a subdirectory names it.
|
|
596
|
+
expect(node(doc, `commit:${sha.c4}`)).toMatchObject({ trailers: {} });
|
|
597
|
+
expect(doc.edges).toContainEqual({ kind: "produced-by", from: `commit:${sha.c4}`, to: "unit:U-0104" });
|
|
598
|
+
expect(node(doc, "unit:U-0104")).toMatchObject({ data: { path: "unit-records/archive/U-0104.md" } });
|
|
599
|
+
expect(doc.edges).toContainEqual({ kind: "produced-by", from: `commit:${sha.c2}`, to: "unit:U-0100" });
|
|
600
|
+
expect(doc.edges).toContainEqual({ kind: "serves", from: "unit:U-0100", to: "contract:C-100" });
|
|
601
|
+
expect(node(doc, "unit:U-0100")).toMatchObject({ data: { path: "unit-records/U-0100.json", probe: { root: true } } });
|
|
602
|
+
// Not a directory there, or outside the workspace: undefined.
|
|
603
|
+
expect((node(doc, "unit:U-0100") as { data: unknown }).data).toMatchObject({ probe: { missing: undefined, escape: undefined, file: undefined, root: true } });
|
|
604
|
+
expect(node(doc, `commit:${sha.c2b}`) && doc.edges.some((e) => e.kind === "produced-by" && e.from === `commit:${sha.c2b}`)).toBe(false);
|
|
605
|
+
expect(doc.reasons).toEqual([]);
|
|
606
|
+
|
|
607
|
+
// At c4 the records were not written yet: list answers from the tree read, so nothing joins.
|
|
608
|
+
const before = await walk("app/server.mjs", { kinds: [KIND, "plugins/scan.kind.mjs"], at: sha.c4 });
|
|
609
|
+
expect(before.nodes.filter((n) => n.kind === "unit")).toEqual([]);
|
|
610
|
+
expect(before.reasons).toEqual([]);
|
|
611
|
+
} finally {
|
|
612
|
+
git(root, "reset", "-q", "--hard", sha.c4);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("commitJoinsName names a plugin's findings apart from its record kind's name (#2663)", async () => {
|
|
617
|
+
const NAMED = (name: string) => `
|
|
618
|
+
export { recordKind } from "./decision.kind.mjs";
|
|
619
|
+
export const commitJoinsName = ${JSON.stringify(name)};
|
|
620
|
+
export function commitJoins(commit) {
|
|
621
|
+
if (commit.trailers["Unit"]?.[0] !== "U-0002") return undefined;
|
|
622
|
+
return { findings: [{ code: "plugin:studio:contract-moved", message: "moved", refs: [commit.sha] }] };
|
|
623
|
+
}
|
|
624
|
+
`;
|
|
625
|
+
writeFiles(root, { "decisions/studio.kind.mjs": NAMED("studio"), "decisions/other.kind.mjs": NAMED("other") });
|
|
626
|
+
commit(["name the findings"]);
|
|
627
|
+
try {
|
|
628
|
+
const doc = await walk("app/server.mjs", { kinds: ["decisions/studio.kind.mjs"] });
|
|
629
|
+
expect(doc.kinds).toEqual([{ file: "decisions/studio.kind.mjs", name: "studio", records: "decision", joins: "function" }]);
|
|
630
|
+
expect(doc.reasons).toEqual([]);
|
|
631
|
+
expect(doc.nodes).toContainEqual(expect.objectContaining({ kind: "finding", code: "plugin:studio:contract-moved", concerns: [`commit:${sha.c2b}`] }));
|
|
632
|
+
// The decisions still come from the record kind called decision.
|
|
633
|
+
expect(node(doc, "record:decision/dec-001")).toBeDefined();
|
|
634
|
+
|
|
635
|
+
// Under another name, the same code is outside the namespace, and the record kind's name is no longer it either.
|
|
636
|
+
const { doc: other, failed } = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, "decisions/other.kind.mjs")] });
|
|
637
|
+
expectValid(other);
|
|
638
|
+
if ("error" in other) throw new Error(other.error.message);
|
|
639
|
+
expect(failed).toBe(true);
|
|
640
|
+
expect(other.reasons).toEqual([{ code: "intent-plugin-failed", message: expect.stringContaining("plugin:other:<code>") }]);
|
|
641
|
+
} finally {
|
|
642
|
+
git(root, "reset", "-q", "--hard", sha.c4);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("a malformed commitJoinsName is kind-invalid (#2663)", async () => {
|
|
647
|
+
writeFiles(root, { "plugins/badname.kind.mjs": `export const commitJoinsName = "a:b";\nexport function commitJoins() {}\n`, "plugins/nameonly.kind.mjs": `export const commitJoinsName = "x";\n` });
|
|
648
|
+
commit(["malformed names"]);
|
|
649
|
+
try {
|
|
650
|
+
for (const [file, text] of [
|
|
651
|
+
["plugins/badname.kind.mjs", "no colon or space"],
|
|
652
|
+
["plugins/nameonly.kind.mjs", "no commitJoins"],
|
|
653
|
+
]) {
|
|
654
|
+
const { doc } = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, file)] });
|
|
655
|
+
expectValid(doc);
|
|
656
|
+
expect("error" in doc && doc.error.code).toBe("kind-invalid");
|
|
657
|
+
expect("error" in doc && doc.error.message).toContain(text);
|
|
658
|
+
}
|
|
659
|
+
} finally {
|
|
660
|
+
git(root, "reset", "-q", "--hard", sha.c4);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
});
|
package/src/workspace/intent.ts
CHANGED
|
@@ -26,28 +26,32 @@
|
|
|
26
26
|
* inside a decision's window is not taken as that decision's work: unless
|
|
27
27
|
* the decision's own unit made it, it is `decided-by-window`, shown for the
|
|
28
28
|
* person to judge (#2656). A plugin's `commitJoins` may add findings of its
|
|
29
|
-
* own, in its own code namespace.
|
|
29
|
+
* own, in its own code namespace. Without `kinds`, the walk reads every
|
|
30
|
+
* record kind the declaration names (#2680), and each one's `commitJoins`
|
|
31
|
+
* runs for every commit in that order. chant emits the
|
|
30
32
|
* graph and hud renders it (#2524 D8, D15). Git is read through a local
|
|
31
33
|
* `git` subprocess only: no fetch, no network.
|
|
32
34
|
*/
|
|
33
35
|
|
|
34
36
|
import { execFileSync } from "node:child_process";
|
|
35
37
|
import { realpathSync } from "node:fs";
|
|
36
|
-
import { basename, relative, resolve, sep } from "node:path";
|
|
37
|
-
import { readDeclaration, readerVersion, resolveGroups, WORKSPACE_ERROR_CODES, WorkspaceReadError, type Declaration } from "./declaration";
|
|
38
|
+
import { basename, dirname, relative, resolve, sep } from "node:path";
|
|
39
|
+
import { declaredRecordKinds, readDeclaration, readerVersion, resolveGroups, WORKSPACE_ERROR_CODES, WorkspaceReadError, type Declaration } from "./declaration";
|
|
40
|
+
import { declaredKindFile } from "./declared-kinds";
|
|
38
41
|
import { classifyFile, declaredFilesUnder } from "./generated-files";
|
|
39
42
|
import { entityDecisions, hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity, type PluginFinding } from "./intent-joins";
|
|
40
43
|
import { loadKindRegistry } from "./kinds";
|
|
41
44
|
import { resolveLinks, type LinkTableRow } from "./links";
|
|
42
45
|
import { sourceMemberHandles } from "./member-handles";
|
|
43
46
|
import { constraintCovers, isWorkspacePath, memberHolding } from "./record-assets";
|
|
44
|
-
import { importKindModule, loadRecordKind, RecordReadError, type LoadedRecordKind } from "./records";
|
|
47
|
+
import { importKindModule, loadRecordKind, parseFrontMatter, RecordReadError, supersedesTargets, type LoadedRecordKind } from "./records";
|
|
45
48
|
import { queryRecords, type RecordView } from "./records-cli";
|
|
46
|
-
import type
|
|
49
|
+
import { isPluginCode, type PluginCode, type ReasonCode } from "./reason-codes";
|
|
47
50
|
import { joinPath, skippedDir, type WorkspaceTree } from "./tree";
|
|
48
51
|
import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
|
|
49
52
|
import { commitProvenance, policyAtBase, resolveBase } from "./trust/provenance";
|
|
50
53
|
import { locateWorkspace, type LocatedWorkspace } from "./which-chant";
|
|
54
|
+
import { idList, isDecided, WORK_WARNING_CODES, type WorkLink, type WorkWarningCode } from "./work";
|
|
51
55
|
|
|
52
56
|
/** The version of the `intent` document this chant writes. */
|
|
53
57
|
export const INTENT_CONTRACT_VERSION = 1;
|
|
@@ -83,6 +87,12 @@ export const INTENT_FINDING_CODES = [
|
|
|
83
87
|
"intent-trailer-unverified",
|
|
84
88
|
/** No decision constrains the region at any granularity. */
|
|
85
89
|
"intent-region-unconstrained",
|
|
90
|
+
/** A decided decision constrains the region, no work item that is not dropped implements it, and no commit falls in its window (#2683). Only with a work kind read. */
|
|
91
|
+
"intent-decision-unimplemented",
|
|
92
|
+
/** A work item constraining the region has commits in its window while its blockedBy is not empty (#2683). */
|
|
93
|
+
"intent-work-blocked",
|
|
94
|
+
/** Commits in the region are a decision's own work while the work item implementing it is still open: the code says done and the queue says not (#2683). */
|
|
95
|
+
"intent-work-open-decided-code",
|
|
86
96
|
] as const satisfies readonly ReasonCode[];
|
|
87
97
|
export type IntentFindingCode = (typeof INTENT_FINDING_CODES)[number];
|
|
88
98
|
|
|
@@ -259,9 +269,53 @@ export interface FindingNode {
|
|
|
259
269
|
plugin?: string;
|
|
260
270
|
/** For a plugin's finding: the refs as the plugin gave them. The ones that name a node in the graph are in concerns. */
|
|
261
271
|
refs?: string[];
|
|
272
|
+
/** With a work kind read (#2683): whether a work item addresses the finding. */
|
|
273
|
+
addressed?: boolean;
|
|
274
|
+
/** With a work kind read: the work items addressing the finding, each with its state. */
|
|
275
|
+
addressedBy?: WorkLink[];
|
|
262
276
|
}
|
|
263
277
|
|
|
264
|
-
|
|
278
|
+
/** Where a work item says it came from: the gap the intent graph reported (#2683). */
|
|
279
|
+
export interface WorkGapSource {
|
|
280
|
+
finding: string;
|
|
281
|
+
region: string;
|
|
282
|
+
decision?: string;
|
|
283
|
+
artifact?: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface WorkNode {
|
|
287
|
+
id: string;
|
|
288
|
+
kind: "work";
|
|
289
|
+
recordKind: string;
|
|
290
|
+
record: string;
|
|
291
|
+
/** The record file, from the repository root. */
|
|
292
|
+
path: string;
|
|
293
|
+
title: string | null;
|
|
294
|
+
state: string | null;
|
|
295
|
+
/** Whether the kind counts the state as closed, such as done or dropped. */
|
|
296
|
+
closed: boolean;
|
|
297
|
+
valid: boolean;
|
|
298
|
+
reasons: RecordView["reasons"];
|
|
299
|
+
provenance: { level: ProvenanceLevel; commit: string | null; reason: string };
|
|
300
|
+
owner: string | null;
|
|
301
|
+
/** As records reports it: open, and every need done. */
|
|
302
|
+
ready: boolean;
|
|
303
|
+
/** Each need that is not done, with its state. */
|
|
304
|
+
blockedBy: WorkLink[];
|
|
305
|
+
/** Each decision the item implements, with its state. */
|
|
306
|
+
implements: WorkLink[];
|
|
307
|
+
/** The ids the item's needs list names. */
|
|
308
|
+
needs: string[];
|
|
309
|
+
/** The gap the item came from, when its source names one. */
|
|
310
|
+
source: WorkGapSource | null;
|
|
311
|
+
supersededBy: string | null;
|
|
312
|
+
/** The constrains entries that cover the region, with their granularity. Empty for an item in the graph only through a link. */
|
|
313
|
+
constrains: { entry: string; granularity: Granularity }[];
|
|
314
|
+
/** The item's work warnings, as records reports them, and work-done-gap-open. */
|
|
315
|
+
warnings: { code: WorkWarningCode; message: string }[];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export type IntentNode = RegionNode | FileNode | MemberNode | CommitNode | JoinedNode | EvidenceEntryNode | DecisionNode | WorkNode | ArtifactNode | LinkNode | FindingNode;
|
|
265
319
|
|
|
266
320
|
export type Granularity = "path" | "member" | "contract" | "issue";
|
|
267
321
|
|
|
@@ -269,8 +323,8 @@ export type IntentEdge =
|
|
|
269
323
|
| { kind: "constrains"; from: string; to: string; granularity: Granularity; entry: string }
|
|
270
324
|
| { kind: "pins"; from: string; to: string; pinnedSha256: string | null; pinState: PinState }
|
|
271
325
|
| { kind: "touched-by"; from: string; to: string; lines: LineRange[] | null }
|
|
272
|
-
| { kind: "within"; from: string; to: string; state: "decided" | "decided-by-window" }
|
|
273
|
-
| { kind: "produced-by" | "serves" | "cites-evidence" | "supersedes" | "links"; from: string; to: string };
|
|
326
|
+
| { kind: "within"; from: string; to: string; state: "decided" | "decided-by-window" | "worked" }
|
|
327
|
+
| { kind: "produced-by" | "serves" | "cites-evidence" | "supersedes" | "links" | "implements" | "needs" | "addressed-by"; from: string; to: string };
|
|
274
328
|
|
|
275
329
|
export interface IntentReason {
|
|
276
330
|
code: IntentReasonCode;
|
|
@@ -303,7 +357,11 @@ export interface IntentQuery {
|
|
|
303
357
|
/** `path`, `path:line` or `path:start-end`, or a graph node id `<member>/<id>`. */
|
|
304
358
|
region: string;
|
|
305
359
|
at?: string;
|
|
306
|
-
/**
|
|
360
|
+
/**
|
|
361
|
+
* Kind files: record kinds, plugins with `commitJoins`, or both. Relative to
|
|
362
|
+
* `cwd`. Left out, every record kind the declaration names, in its order
|
|
363
|
+
* (#2680); an empty list reads none.
|
|
364
|
+
*/
|
|
307
365
|
kinds?: string[];
|
|
308
366
|
/**
|
|
309
367
|
* Resolve a graph node id to its source location, for a region given as a
|
|
@@ -403,6 +461,25 @@ function addingCommit(top: string, rev: string, path: string): string | null {
|
|
|
403
461
|
return out?.trim() || null;
|
|
404
462
|
}
|
|
405
463
|
|
|
464
|
+
/**
|
|
465
|
+
* The commit reachable from `rev` that moved the record at `path` into a
|
|
466
|
+
* closed state and kept it there, or null when the record is not closed at
|
|
467
|
+
* `rev` (#2683). A record added in a closed state closes in the commit that
|
|
468
|
+
* added it.
|
|
469
|
+
*/
|
|
470
|
+
function closingCommit(top: string, rev: string, path: string, stateField: string, closed: readonly string[]): string | null {
|
|
471
|
+
const out = tryGit(top, ["log", "--format=%H", rev, "--", path]);
|
|
472
|
+
let closing: string | null = null;
|
|
473
|
+
for (const sha of (out ?? "").split("\n").map((l) => l.trim()).filter(Boolean)) {
|
|
474
|
+
const text = tryGit(top, ["show", `${sha}:${path}`]);
|
|
475
|
+
const fm = text === undefined ? undefined : parseFrontMatter(text);
|
|
476
|
+
const state = fm?.ok ? fm.value[stateField] : undefined;
|
|
477
|
+
if (typeof state !== "string" || !closed.includes(state)) break;
|
|
478
|
+
closing = sha;
|
|
479
|
+
}
|
|
480
|
+
return closing;
|
|
481
|
+
}
|
|
482
|
+
|
|
406
483
|
/** `commit` and every commit between it and `rev` that has it as an ancestor. */
|
|
407
484
|
function descendants(top: string, commit: string, rev: string): Set<string> {
|
|
408
485
|
const out = new Set<string>([commit]);
|
|
@@ -469,6 +546,20 @@ function stringOr(v: unknown): string | null {
|
|
|
469
546
|
return typeof v === "string" ? v : null;
|
|
470
547
|
}
|
|
471
548
|
|
|
549
|
+
/** A work record's `source` when it names the gap it came from (#2683), or null. */
|
|
550
|
+
function gapSource(data: Record<string, unknown> | null): WorkGapSource | null {
|
|
551
|
+
const src = data?.source;
|
|
552
|
+
if (src === null || typeof src !== "object" || Array.isArray(src)) return null;
|
|
553
|
+
const s = src as Record<string, unknown>;
|
|
554
|
+
if (typeof s.finding !== "string" || typeof s.region !== "string") return null;
|
|
555
|
+
return {
|
|
556
|
+
finding: s.finding,
|
|
557
|
+
region: s.region,
|
|
558
|
+
...(typeof s.decision === "string" ? { decision: s.decision } : {}),
|
|
559
|
+
...(typeof s.artifact === "string" ? { artifact: s.artifact } : {}),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
472
563
|
function reviewSummary(data: Record<string, unknown> | null): DecisionNode["reviews"] {
|
|
473
564
|
const out = { agree: 0, dissent: 0, abstain: 0, openConcerns: 0 };
|
|
474
565
|
const list = data?.reviews;
|
|
@@ -506,7 +597,7 @@ interface LoadedKind {
|
|
|
506
597
|
file: string;
|
|
507
598
|
/** Relative to the repository root, for the document. */
|
|
508
599
|
display: string;
|
|
509
|
-
/** The record kind's name, or the file's name without `.kind.mjs
|
|
600
|
+
/** The namespace of its plugin findings: its `commitJoinsName`, the record kind's name, or the file's name without `.kind.mjs`. */
|
|
510
601
|
name: string;
|
|
511
602
|
records?: { loaded: LoadedRecordKind; views: RecordView[]; workspaceRoot: string };
|
|
512
603
|
joins?: CommitJoins;
|
|
@@ -525,13 +616,13 @@ async function loadKinds(query: IntentQuery, top: string): Promise<LoadedKind[]>
|
|
|
525
616
|
}
|
|
526
617
|
const joins = readCommitJoins(mod);
|
|
527
618
|
if (typeof joins === "string") throw new IntentError("kind-invalid", `kind file ${k} has a commitJoins export that can't be read: ${joins}`);
|
|
528
|
-
const kind: LoadedKind = { file, display, name: basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
|
|
619
|
+
const kind: LoadedKind = { file, display, name: joins?.name ?? basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
|
|
529
620
|
if (mod.recordKind !== undefined) {
|
|
530
621
|
const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd });
|
|
531
622
|
if ("error" in doc) throw new IntentError(doc.error.code, doc.error.message);
|
|
532
623
|
try {
|
|
533
624
|
kind.records = { loaded: await loadRecordKind(file), views: doc.records, workspaceRoot: doc.workspaceRoot };
|
|
534
|
-
kind.name = kind.records.loaded.kind.name;
|
|
625
|
+
kind.name = joins?.name ?? kind.records.loaded.kind.name;
|
|
535
626
|
} catch (err) {
|
|
536
627
|
if (err instanceof RecordReadError) throw new IntentError(err.code as IntentErrorCode, err.message);
|
|
537
628
|
throw err;
|
|
@@ -591,7 +682,9 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
591
682
|
const count = located.tree.read(region.path).replace(/\n$/, "").split("\n").length;
|
|
592
683
|
if (region.lines.end > count) throw new IntentError("intent-region-invalid", `${region.path} has ${count} lines${located.tree.label}, so ${region.lines.start}-${region.lines.end} is not in it`);
|
|
593
684
|
}
|
|
594
|
-
|
|
685
|
+
// Without --kind, the declared record kinds, read from the working tree as a --kind file is (#2680).
|
|
686
|
+
const kindFiles = query.kinds ?? declaredRecordKinds(declaration).map((d) => declaredKindFile(d, located.rootOnDisk));
|
|
687
|
+
const kinds = await loadKinds({ ...query, kinds: kindFiles }, top);
|
|
595
688
|
|
|
596
689
|
const nodes = new Map<string, IntentNode>();
|
|
597
690
|
const edges: IntentEdge[] = [];
|
|
@@ -634,6 +727,13 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
634
727
|
if (!isWorkspacePath(path) || located.tree.stat(path) !== "file") return undefined;
|
|
635
728
|
return located.tree.read(path);
|
|
636
729
|
};
|
|
730
|
+
// The same tree as readAt (#2663): entries directly inside dir, from the workspace root, a directory's with a trailing slash.
|
|
731
|
+
const listAt = (dir: string): string[] | undefined => {
|
|
732
|
+
const d = dir === "." ? "" : dir.replace(/\/+$/, "");
|
|
733
|
+
if (d !== "" && (!isWorkspacePath(d) || located.tree.stat(d) !== "dir")) return undefined;
|
|
734
|
+
const entries = located.tree.list(d);
|
|
735
|
+
return entries?.map((e) => `${joinPath(d, e.name)}${e.type === "dir" ? "/" : ""}`).sort();
|
|
736
|
+
};
|
|
637
737
|
interface Joined {
|
|
638
738
|
unit?: string;
|
|
639
739
|
contracts: string[];
|
|
@@ -665,7 +765,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
665
765
|
if (!k.joins) continue;
|
|
666
766
|
let result;
|
|
667
767
|
try {
|
|
668
|
-
result = await runCommitJoins(k.joins, c, { read: readAt, at: located.at }, k.name);
|
|
768
|
+
result = await runCommitJoins(k.joins, c, { read: readAt, list: listAt, at: located.at }, k.name);
|
|
669
769
|
} catch (err) {
|
|
670
770
|
const message = `${k.display}: commitJoins failed for ${c.sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
|
|
671
771
|
if (!failedPlugins.has(message)) reasons.push({ code: "intent-plugin-failed", message });
|
|
@@ -708,10 +808,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
708
808
|
const decisionId = (k: LoadedKind, id: string) => `record:${k.records!.loaded.kind.name}/${id}`;
|
|
709
809
|
const decisionNode = (k: LoadedKind, v: RecordView): DecisionNode => {
|
|
710
810
|
const kind = k.records!.loaded.kind;
|
|
711
|
-
const
|
|
712
|
-
const supersedes = Array.isArray(links)
|
|
713
|
-
? links.map((l) => (l && typeof l === "object" ? (l as Record<string, unknown>)[kind.supersedes.key] : undefined)).filter((x): x is string => typeof x === "string")
|
|
714
|
-
: [];
|
|
811
|
+
const supersedes = supersedesTargets(kind, v.data);
|
|
715
812
|
return add<DecisionNode>({
|
|
716
813
|
id: decisionId(k, v.id!),
|
|
717
814
|
kind: "decision",
|
|
@@ -720,7 +817,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
720
817
|
path: v.path,
|
|
721
818
|
title: stringOr(v.data?.title),
|
|
722
819
|
state: v.state,
|
|
723
|
-
closed: v.state !== null && kind.closedStates.includes(v.state),
|
|
820
|
+
closed: v.state !== null && (kind.closedStates ?? []).includes(v.state),
|
|
724
821
|
valid: v.valid,
|
|
725
822
|
reasons: v.reasons,
|
|
726
823
|
provenance: { level: v.provenance.level, commit: v.provenance.commit, reason: v.provenance.reason },
|
|
@@ -733,36 +830,41 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
733
830
|
});
|
|
734
831
|
};
|
|
735
832
|
const fileNodesUnder = (full: string) => files.filter((f) => constraintCovers(full, joinPath(workspacePrefix, f)));
|
|
833
|
+
/** The constrains entries of a record that cover the region or a file under it. Decisions and work items match the same way (#2683). */
|
|
834
|
+
const matchConstrains = (k: LoadedKind, v: RecordView): { entry: string; granularity: Granularity; to: string }[] => {
|
|
835
|
+
const field = k.records!.loaded.kind.constrains?.field;
|
|
836
|
+
const kindPrefix = k.records!.workspaceRoot === "." ? "" : k.records!.workspaceRoot;
|
|
837
|
+
const list = field ? v.data?.[field] : undefined;
|
|
838
|
+
const matched: { entry: string; granularity: Granularity; to: string }[] = [];
|
|
839
|
+
if (!Array.isArray(list)) return matched;
|
|
840
|
+
for (const entry of list) {
|
|
841
|
+
if (typeof entry !== "string") continue;
|
|
842
|
+
if (entry.startsWith("path:")) {
|
|
843
|
+
const p = entry.slice("path:".length);
|
|
844
|
+
if (!isWorkspacePath(p)) continue;
|
|
845
|
+
const full = joinPath(kindPrefix, p);
|
|
846
|
+
if (regionFull !== "" && constraintCovers(full, regionFull)) {
|
|
847
|
+
matched.push({ entry, granularity: "path", to: rid });
|
|
848
|
+
} else if (type === "dir" && full !== regionFull && (regionFull === "" || full.startsWith(`${regionFull}/`))) {
|
|
849
|
+
// A path below a directory region constrains the files under it, not the whole region.
|
|
850
|
+
for (const f of fileNodesUnder(full)) matched.push({ entry, granularity: "path", to: `file:${f}` });
|
|
851
|
+
}
|
|
852
|
+
} else if (entry.startsWith("member:")) {
|
|
853
|
+
if (member !== null && entry.slice("member:".length) === member) matched.push({ entry, granularity: "member", to: rid });
|
|
854
|
+
} else {
|
|
855
|
+
const issue = entry.match(ISSUE);
|
|
856
|
+
if (issue && origin !== null && issue[1].toLowerCase() === origin && commitRefs.has(issue[2])) matched.push({ entry, granularity: "issue", to: rid });
|
|
857
|
+
else if (contractIds.has(entry)) matched.push({ entry, granularity: "contract", to: `contract:${entry}` });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return matched;
|
|
861
|
+
};
|
|
736
862
|
for (const k of kinds) {
|
|
737
|
-
if (!k.records) continue;
|
|
738
|
-
const field = k.records.loaded.kind.constrains?.field;
|
|
739
|
-
const kindPrefix = k.records.workspaceRoot === "." ? "" : k.records.workspaceRoot;
|
|
863
|
+
if (!k.records || k.records.loaded.kind.work) continue;
|
|
740
864
|
for (const v of k.records.views) {
|
|
741
865
|
if (v.id === null) continue;
|
|
742
866
|
decisionById.set(`${k.records.loaded.kind.name}/${v.id}`, { view: v, kind: k });
|
|
743
|
-
const
|
|
744
|
-
if (!Array.isArray(list)) continue;
|
|
745
|
-
const matched: { entry: string; granularity: Granularity; to: string }[] = [];
|
|
746
|
-
for (const entry of list) {
|
|
747
|
-
if (typeof entry !== "string") continue;
|
|
748
|
-
if (entry.startsWith("path:")) {
|
|
749
|
-
const p = entry.slice("path:".length);
|
|
750
|
-
if (!isWorkspacePath(p)) continue;
|
|
751
|
-
const full = joinPath(kindPrefix, p);
|
|
752
|
-
if (regionFull !== "" && constraintCovers(full, regionFull)) {
|
|
753
|
-
matched.push({ entry, granularity: "path", to: rid });
|
|
754
|
-
} else if (type === "dir" && full !== regionFull && (regionFull === "" || full.startsWith(`${regionFull}/`))) {
|
|
755
|
-
// A path below a directory region constrains the files under it, not the whole region.
|
|
756
|
-
for (const f of fileNodesUnder(full)) matched.push({ entry, granularity: "path", to: `file:${f}` });
|
|
757
|
-
}
|
|
758
|
-
} else if (entry.startsWith("member:")) {
|
|
759
|
-
if (member !== null && entry.slice("member:".length) === member) matched.push({ entry, granularity: "member", to: rid });
|
|
760
|
-
} else {
|
|
761
|
-
const issue = entry.match(ISSUE);
|
|
762
|
-
if (issue && origin !== null && issue[1].toLowerCase() === origin && commitRefs.has(issue[2])) matched.push({ entry, granularity: "issue", to: rid });
|
|
763
|
-
else if (contractIds.has(entry)) matched.push({ entry, granularity: "contract", to: `contract:${entry}` });
|
|
764
|
-
}
|
|
765
|
-
}
|
|
867
|
+
const matched = matchConstrains(k, v);
|
|
766
868
|
if (matched.length === 0) continue;
|
|
767
869
|
const node = decisionNode(k, v);
|
|
768
870
|
for (const m of matched) {
|
|
@@ -885,6 +987,116 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
885
987
|
windows.set(c.node.id, { from: added ? descendants(top, added, rev) : new Set(), until: replaced ? descendants(top, replaced, rev) : null });
|
|
886
988
|
}
|
|
887
989
|
}
|
|
990
|
+
// Work items (#2683): the records of each work kind whose constrains cover
|
|
991
|
+
// the region, the decisions they implement and the items they need, one hop
|
|
992
|
+
// each, and each covering item's window: from the commit that added the
|
|
993
|
+
// record to the commit that closed it, that commit included, or to the
|
|
994
|
+
// revision read while it is open.
|
|
995
|
+
interface WorkHit {
|
|
996
|
+
view: RecordView;
|
|
997
|
+
kind: LoadedKind;
|
|
998
|
+
node?: WorkNode;
|
|
999
|
+
}
|
|
1000
|
+
const workKinds = kinds.filter((k) => k.records?.loaded.kind.work);
|
|
1001
|
+
const readsWork = workKinds.length > 0;
|
|
1002
|
+
const workAll: WorkHit[] = [];
|
|
1003
|
+
const workByKey = new Map<string, WorkHit>();
|
|
1004
|
+
for (const k of workKinds) {
|
|
1005
|
+
for (const v of k.records!.views) {
|
|
1006
|
+
if (v.id === null) continue;
|
|
1007
|
+
const hit: WorkHit = { view: v, kind: k };
|
|
1008
|
+
workAll.push(hit);
|
|
1009
|
+
const key = `${k.records!.loaded.kind.name}/${v.id}`;
|
|
1010
|
+
if (!workByKey.has(key)) workByKey.set(key, hit);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
const workSpec = (h: WorkHit) => h.kind.records!.loaded.kind.work!;
|
|
1014
|
+
const workNode = (h: WorkHit): WorkNode => {
|
|
1015
|
+
if (h.node) return h.node;
|
|
1016
|
+
const kind = h.kind.records!.loaded.kind;
|
|
1017
|
+
const v = h.view;
|
|
1018
|
+
h.node = add<WorkNode>({
|
|
1019
|
+
id: `record:${kind.name}/${v.id!}`,
|
|
1020
|
+
kind: "work",
|
|
1021
|
+
recordKind: kind.name,
|
|
1022
|
+
record: v.id!,
|
|
1023
|
+
path: v.path,
|
|
1024
|
+
title: stringOr(v.data?.title),
|
|
1025
|
+
state: v.state,
|
|
1026
|
+
closed: v.state !== null && (kind.closedStates ?? []).includes(v.state),
|
|
1027
|
+
valid: v.valid,
|
|
1028
|
+
reasons: v.reasons,
|
|
1029
|
+
provenance: { level: v.provenance.level, commit: v.provenance.commit, reason: v.provenance.reason },
|
|
1030
|
+
owner: stringOr(v.data?.owner),
|
|
1031
|
+
ready: v.ready ?? false,
|
|
1032
|
+
blockedBy: v.blockedBy ?? [],
|
|
1033
|
+
implements: v.implements ?? [],
|
|
1034
|
+
needs: idList(v.data, kind.work!.needs),
|
|
1035
|
+
source: gapSource(v.data),
|
|
1036
|
+
supersededBy: v.supersededBy,
|
|
1037
|
+
constrains: [],
|
|
1038
|
+
warnings: v.warnings.filter((w): w is { code: WorkWarningCode; message: string } => (WORK_WARNING_CODES as readonly string[]).includes(w.code)),
|
|
1039
|
+
});
|
|
1040
|
+
return h.node;
|
|
1041
|
+
};
|
|
1042
|
+
/** The decision kind a work kind names, when it is among the kinds read. */
|
|
1043
|
+
const decisionKindOf = (k: LoadedKind): LoadedKind | undefined => {
|
|
1044
|
+
const file = realpathOr(resolve(dirname(k.records!.loaded.file), k.records!.loaded.kind.work!.decisions));
|
|
1045
|
+
return kinds.find((x) => x.records && !x.records.loaded.kind.work && realpathOr(x.records.loaded.file) === file);
|
|
1046
|
+
};
|
|
1047
|
+
/** The node ids of the decisions a work item implements, among the decisions read. */
|
|
1048
|
+
const implementedIds = (h: WorkHit): string[] => {
|
|
1049
|
+
const dk = decisionKindOf(h.kind);
|
|
1050
|
+
if (!dk) return [];
|
|
1051
|
+
return idList(h.view.data, workSpec(h).implements)
|
|
1052
|
+
.map((d) => decisionById.get(`${dk.records!.loaded.kind.name}/${d}`))
|
|
1053
|
+
.filter((x): x is { view: RecordView; kind: LoadedKind } => x !== undefined)
|
|
1054
|
+
.map((x) => decisionId(x.kind, x.view.id!));
|
|
1055
|
+
};
|
|
1056
|
+
/** The work items implementing a decision, leaving out dropped ones: a closed state other than done. */
|
|
1057
|
+
const implementersOf = (d: DecisionNode): WorkHit[] =>
|
|
1058
|
+
workAll.filter((h) => {
|
|
1059
|
+
const kind = h.kind.records!.loaded.kind;
|
|
1060
|
+
const dropped = h.view.state !== null && h.view.state !== kind.work!.done && (kind.closedStates ?? []).includes(h.view.state);
|
|
1061
|
+
return !dropped && implementedIds(h).includes(d.id);
|
|
1062
|
+
});
|
|
1063
|
+
const workCovering: WorkHit[] = [];
|
|
1064
|
+
for (const h of workAll) {
|
|
1065
|
+
const matched = matchConstrains(h.kind, h.view);
|
|
1066
|
+
if (matched.length === 0) continue;
|
|
1067
|
+
const node = workNode(h);
|
|
1068
|
+
for (const m of matched) {
|
|
1069
|
+
edges.push({ kind: "constrains", from: node.id, to: m.to, granularity: m.granularity, entry: m.entry });
|
|
1070
|
+
if (m.to === rid || m.granularity === "contract") node.constrains.push({ entry: m.entry, granularity: m.granularity });
|
|
1071
|
+
}
|
|
1072
|
+
if (node.constrains.length > 0) workCovering.push(h);
|
|
1073
|
+
}
|
|
1074
|
+
for (const h of workAll.filter((x) => x.node)) {
|
|
1075
|
+
for (const id of implementedIds(h)) {
|
|
1076
|
+
const hit = decisionById.get(id.slice("record:".length))!;
|
|
1077
|
+
edges.push({ kind: "implements", from: h.node!.id, to: decisionNode(hit.kind, hit.view).id });
|
|
1078
|
+
}
|
|
1079
|
+
for (const n of idList(h.view.data, workSpec(h).needs)) {
|
|
1080
|
+
const hit = workByKey.get(`${h.kind.records!.loaded.kind.name}/${n}`);
|
|
1081
|
+
if (hit) edges.push({ kind: "needs", from: h.node!.id, to: workNode(hit).id });
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const workWindows = new Map<string, { from: Set<string>; until: Set<string> | null }>();
|
|
1085
|
+
if (rev) {
|
|
1086
|
+
for (const h of workCovering) {
|
|
1087
|
+
const kind = h.kind.records!.loaded.kind;
|
|
1088
|
+
const added = addingCommit(top, rev, h.view.path);
|
|
1089
|
+
const closing = kind.stateField ? closingCommit(top, rev, h.view.path, kind.stateField, kind.closedStates ?? []) : null;
|
|
1090
|
+
const until = closing ? descendants(top, closing, rev) : null;
|
|
1091
|
+
if (closing) until!.delete(closing);
|
|
1092
|
+
workWindows.set(h.node!.id, { from: added ? descendants(top, added, rev) : new Set(), until });
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const inWorkWindow = (h: WorkHit, sha: string) => {
|
|
1096
|
+
const w = workWindows.get(h.node!.id);
|
|
1097
|
+
return !!w && w.from.has(sha) && !w.until?.has(sha);
|
|
1098
|
+
};
|
|
1099
|
+
|
|
888
1100
|
const coveredAt = (sha: string, granularities: Granularity[]) =>
|
|
889
1101
|
covering.filter((c) => {
|
|
890
1102
|
if (!c.node.constrains.some((x) => granularities.includes(x.granularity))) return false;
|
|
@@ -905,6 +1117,10 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
905
1117
|
for (const w of inWindow) edges.push({ kind: "within", from: c.id, to: w.node.id, state: ownWork(j, w.node) ? "decided" : "decided-by-window" });
|
|
906
1118
|
c.state = inWindow.length === 0 ? "undecided" : inWindow.some((w) => ownWork(j, w.node)) ? "decided" : "decided-by-window";
|
|
907
1119
|
}
|
|
1120
|
+
// A commit in a work item's window is worked; its own state still comes from decisions (#2683).
|
|
1121
|
+
for (const h of workCovering) {
|
|
1122
|
+
if (h.node!.constrains.some((x) => x.granularity === "path") && inWorkWindow(h, t.sha)) edges.push({ kind: "within", from: c.id, to: h.node!.id, state: "worked" });
|
|
1123
|
+
}
|
|
908
1124
|
if (readsRecords && c.state === "undecided") {
|
|
909
1125
|
find("intent-commit-undecided", `${t.sha.slice(0, 8)} changed the region when no decision constrained ${region.path} by path`, [c.id, rid]);
|
|
910
1126
|
}
|
|
@@ -933,6 +1149,37 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
933
1149
|
}
|
|
934
1150
|
}
|
|
935
1151
|
|
|
1152
|
+
// Findings about work items (#2683).
|
|
1153
|
+
if (readsWork) {
|
|
1154
|
+
for (const c of covering) {
|
|
1155
|
+
if (c.node.supersededBy !== null || !isDecided(c.kind.records!.loaded.kind, c.node.state)) continue;
|
|
1156
|
+
if (implementersOf(c.node).length > 0) continue;
|
|
1157
|
+
const w = windows.get(c.node.id);
|
|
1158
|
+
if (w && touched.some((t) => w.from.has(t.sha) && !w.until?.has(t.sha))) continue;
|
|
1159
|
+
find("intent-decision-unimplemented", `${c.node.record} is ${c.node.state} and constrains ${region.path}, and no work item that is not dropped implements it, and no commit changed the region in its window`, [c.node.id, rid]);
|
|
1160
|
+
}
|
|
1161
|
+
for (const h of workCovering) {
|
|
1162
|
+
const node = h.node!;
|
|
1163
|
+
if (node.blockedBy.length === 0) continue;
|
|
1164
|
+
const worked = touched.filter((t) => inWorkWindow(h, t.sha));
|
|
1165
|
+
if (worked.length === 0) continue;
|
|
1166
|
+
const waiting = node.blockedBy.map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
|
|
1167
|
+
const blockers = node.blockedBy.map((b) => workByKey.get(`${node.recordKind}/${b.id}`)).filter((x): x is WorkHit => x !== undefined).map((x) => workNode(x).id);
|
|
1168
|
+
find("intent-work-blocked", `${node.record} has ${worked.length} ${worked.length === 1 ? "commit" : "commits"} in its window while it waits on ${waiting}`, [node.id, ...worked.map((t) => `commit:${t.sha}`), ...blockers]);
|
|
1169
|
+
}
|
|
1170
|
+
for (const d of [...nodes.values()].filter((n): n is DecisionNode => n.kind === "decision")) {
|
|
1171
|
+
const own = edges.filter((e) => e.kind === "within" && e.to === d.id && e.state === "decided").map((e) => e.from);
|
|
1172
|
+
if (own.length === 0) continue;
|
|
1173
|
+
const impl = implementersOf(d);
|
|
1174
|
+
if (impl.length === 0 || impl.some((h) => h.view.state === workSpec(h).done)) continue;
|
|
1175
|
+
find(
|
|
1176
|
+
"intent-work-open-decided-code",
|
|
1177
|
+
`${own.length} ${own.length === 1 ? "commit" : "commits"} in ${region.path} ${own.length === 1 ? "is" : "are"} ${d.record}'s own work, and ${impl.map((h) => `${h.view.id} is ${h.view.state ?? "stateless"}`).join(", ")}: the code says done and the queue says not`,
|
|
1178
|
+
[d.id, ...own, ...impl.map((h) => workNode(h).id)],
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
936
1183
|
// 5. Links: the declared member links that touch the region's member, read in source.
|
|
937
1184
|
if (member) {
|
|
938
1185
|
let rows: LinkTableRow[] = [];
|
|
@@ -974,6 +1221,46 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
974
1221
|
const concerns = [...new Set([commit, ...refs.map(resolveRef).filter((x): x is string => x !== undefined)])];
|
|
975
1222
|
findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message: finding.message, concerns, plugin, refs });
|
|
976
1223
|
}
|
|
1224
|
+
// A finding a work item addresses (#2683): the item came from that gap on
|
|
1225
|
+
// this region, or it implements the decision a drifted or missing pin, or a
|
|
1226
|
+
// plugin's finding, is about. A done item whose gap still fires here gets
|
|
1227
|
+
// work-done-gap-open.
|
|
1228
|
+
if (readsWork) {
|
|
1229
|
+
// Implementing a decision closes the gaps between it and the code or its
|
|
1230
|
+
// artifacts, not gaps in the decision record itself (its review, its
|
|
1231
|
+
// granularity, its evidence), so only these findings, and a plugin's,
|
|
1232
|
+
// count as addressed through implements.
|
|
1233
|
+
const CLOSED_BY_IMPLEMENTING = new Set<string>(["intent-pin-drifted", "intent-pin-missing"]);
|
|
1234
|
+
const fromGap = (h: WorkHit, code: string): boolean => {
|
|
1235
|
+
const src = gapSource(h.view.data);
|
|
1236
|
+
if (src === null || src.finding !== code) return false;
|
|
1237
|
+
const parsed = parseRegion(src.region);
|
|
1238
|
+
if ("error" in parsed) return false;
|
|
1239
|
+
const kindPrefix = h.kind.records!.workspaceRoot === "." ? "" : h.kind.records!.workspaceRoot;
|
|
1240
|
+
const full = joinPath(kindPrefix, parsed.path);
|
|
1241
|
+
const contains = (a: string, b: string) => a === b || a === "" || b.startsWith(`${a}/`);
|
|
1242
|
+
if (!contains(full, regionFull) && !contains(regionFull, full)) return false;
|
|
1243
|
+
if (full === regionFull && parsed.lines && region.lines) return parsed.lines.start <= region.lines.end && region.lines.start <= parsed.lines.end;
|
|
1244
|
+
return true;
|
|
1245
|
+
};
|
|
1246
|
+
for (const f of findings) {
|
|
1247
|
+
const by: WorkHit[] = [];
|
|
1248
|
+
for (const h of workAll) {
|
|
1249
|
+
const gap = fromGap(h, f.code);
|
|
1250
|
+
const viaDecision = (CLOSED_BY_IMPLEMENTING.has(f.code) || isPluginCode(f.code)) && implementedIds(h).some((id) => f.concerns.includes(id));
|
|
1251
|
+
if (gap || viaDecision) by.push(h);
|
|
1252
|
+
if (gap && h.view.state === workSpec(h).done) {
|
|
1253
|
+
const node = workNode(h);
|
|
1254
|
+
if (!node.warnings.some((w) => w.code === "work-done-gap-open")) {
|
|
1255
|
+
node.warnings.push({ code: "work-done-gap-open", message: `${node.record} is ${h.view.state}, and ${f.code}, the gap it came from, still fires on ${region.path}` });
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
f.addressed = by.length > 0;
|
|
1260
|
+
f.addressedBy = by.map((h) => ({ id: h.view.id!, state: h.view.state }));
|
|
1261
|
+
for (const h of by) edges.push({ kind: "addressed-by", from: f.id, to: workNode(h).id });
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
977
1264
|
for (const f of findings) nodes.set(f.id, f);
|
|
978
1265
|
const all = [...nodes.values()];
|
|
979
1266
|
return {
|