@intentius/chant 0.84.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/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 +13 -1
- 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/lifecycle/gate-ledger.d.ts +13 -0
- package/dist/lifecycle/gate-ledger.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/__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 +152 -0
- package/dist/workspace/composites.d.ts.map +1 -0
- 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 +24 -2
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +6 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +74 -9
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +90 -6
- 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/member-commands.d.ts +7 -2
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +52 -2
- 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/handlers/graph.ts +4 -0
- package/src/cli/main.test.ts +9 -0
- package/src/cli/main.ts +56 -3
- 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 +13 -1
- 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/lifecycle/gate-ledger.ts +14 -0
- package/src/workspace/__fixtures__/contract-repo.ts +17 -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 +533 -0
- package/src/workspace/composites.test.ts +334 -0
- package/src/workspace/composites.ts +316 -0
- 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 +40 -4
- package/src/workspace/intent-cli.ts +54 -7
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +117 -20
- package/src/workspace/intent.schema.json +357 -19
- package/src/workspace/intent.test.ts +235 -20
- package/src/workspace/intent.ts +396 -51
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/member-commands.ts +11 -5
- package/src/workspace/read-contract.test.ts +52 -3
- package/src/workspace/reason-codes.test.ts +48 -4
- package/src/workspace/reason-codes.ts +67 -2
- 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 +77 -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.test.ts +93 -0
- package/src/workspace/records.ts +683 -49
- 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
package/src/workspace/intent.ts
CHANGED
|
@@ -22,28 +22,36 @@
|
|
|
22
22
|
* the graph pins it.
|
|
23
23
|
*
|
|
24
24
|
* Every gap the walk finds is a `finding` node with a closed code, never
|
|
25
|
-
* prose, so a reader can draw it and a test can assert it.
|
|
25
|
+
* prose, so a reader can draw it and a test can assert it. A commit made
|
|
26
|
+
* inside a decision's window is not taken as that decision's work: unless
|
|
27
|
+
* the decision's own unit made it, it is `decided-by-window`, shown for the
|
|
28
|
+
* person to judge (#2656). A plugin's `commitJoins` may add findings of its
|
|
29
|
+
* own, in its own code namespace. 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
|
|
26
32
|
* graph and hud renders it (#2524 D8, D15). Git is read through a local
|
|
27
33
|
* `git` subprocess only: no fetch, no network.
|
|
28
34
|
*/
|
|
29
35
|
|
|
30
36
|
import { execFileSync } from "node:child_process";
|
|
31
37
|
import { realpathSync } from "node:fs";
|
|
32
|
-
import { relative, resolve, sep } from "node:path";
|
|
33
|
-
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";
|
|
34
41
|
import { classifyFile, declaredFilesUnder } from "./generated-files";
|
|
35
|
-
import { hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity } from "./intent-joins";
|
|
42
|
+
import { entityDecisions, hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity, type PluginFinding } from "./intent-joins";
|
|
36
43
|
import { loadKindRegistry } from "./kinds";
|
|
37
44
|
import { resolveLinks, type LinkTableRow } from "./links";
|
|
38
45
|
import { sourceMemberHandles } from "./member-handles";
|
|
39
46
|
import { constraintCovers, isWorkspacePath, memberHolding } from "./record-assets";
|
|
40
|
-
import { importKindModule, loadRecordKind, RecordReadError, type LoadedRecordKind } from "./records";
|
|
47
|
+
import { importKindModule, loadRecordKind, parseFrontMatter, RecordReadError, supersedesTargets, type LoadedRecordKind } from "./records";
|
|
41
48
|
import { queryRecords, type RecordView } from "./records-cli";
|
|
42
|
-
import type
|
|
49
|
+
import { isPluginCode, type PluginCode, type ReasonCode } from "./reason-codes";
|
|
43
50
|
import { joinPath, skippedDir, type WorkspaceTree } from "./tree";
|
|
44
51
|
import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
|
|
45
52
|
import { commitProvenance, policyAtBase, resolveBase } from "./trust/provenance";
|
|
46
53
|
import { locateWorkspace, type LocatedWorkspace } from "./which-chant";
|
|
54
|
+
import { idList, isDecided, WORK_WARNING_CODES, type WorkLink, type WorkWarningCode } from "./work";
|
|
47
55
|
|
|
48
56
|
/** The version of the `intent` document this chant writes. */
|
|
49
57
|
export const INTENT_CONTRACT_VERSION = 1;
|
|
@@ -79,6 +87,12 @@ export const INTENT_FINDING_CODES = [
|
|
|
79
87
|
"intent-trailer-unverified",
|
|
80
88
|
/** No decision constrains the region at any granularity. */
|
|
81
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",
|
|
82
96
|
] as const satisfies readonly ReasonCode[];
|
|
83
97
|
export type IntentFindingCode = (typeof INTENT_FINDING_CODES)[number];
|
|
84
98
|
|
|
@@ -167,8 +181,18 @@ export interface CommitNode {
|
|
|
167
181
|
signature: { level: ProvenanceLevel; reason: string; principal?: string };
|
|
168
182
|
/** The line ranges the commit changed in the region, in the commit's own version of the file; null for a file or directory region. */
|
|
169
183
|
lines: LineRange[] | null;
|
|
184
|
+
/**
|
|
185
|
+
* How the decisions constraining the region by path relate to the commit
|
|
186
|
+
* (#2656): `decided` when it falls inside a decision's window and that
|
|
187
|
+
* decision's own unit made it, `decided-by-window` when it only falls
|
|
188
|
+
* inside a window, `undecided` when it falls outside every window, and
|
|
189
|
+
* null when no record kind was read.
|
|
190
|
+
*/
|
|
191
|
+
state: CommitState | null;
|
|
170
192
|
}
|
|
171
193
|
|
|
194
|
+
export type CommitState = "decided" | "decided-by-window" | "undecided";
|
|
195
|
+
|
|
172
196
|
export interface JoinedNode {
|
|
173
197
|
id: string;
|
|
174
198
|
kind: "unit" | "contract" | "evidence";
|
|
@@ -237,12 +261,61 @@ export interface LinkNode {
|
|
|
237
261
|
export interface FindingNode {
|
|
238
262
|
id: string;
|
|
239
263
|
kind: "finding";
|
|
240
|
-
code
|
|
264
|
+
/** A closed code, or a plugin's own `plugin:<name>:<code>` (#2656). */
|
|
265
|
+
code: IntentFindingCode | PluginCode;
|
|
241
266
|
message: string;
|
|
242
267
|
concerns: string[];
|
|
268
|
+
/** For a plugin's finding: the kind file that returned it. */
|
|
269
|
+
plugin?: string;
|
|
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. */
|
|
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[];
|
|
243
276
|
}
|
|
244
277
|
|
|
245
|
-
|
|
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;
|
|
246
319
|
|
|
247
320
|
export type Granularity = "path" | "member" | "contract" | "issue";
|
|
248
321
|
|
|
@@ -250,7 +323,8 @@ export type IntentEdge =
|
|
|
250
323
|
| { kind: "constrains"; from: string; to: string; granularity: Granularity; entry: string }
|
|
251
324
|
| { kind: "pins"; from: string; to: string; pinnedSha256: string | null; pinState: PinState }
|
|
252
325
|
| { kind: "touched-by"; from: string; to: string; lines: LineRange[] | null }
|
|
253
|
-
| { kind: "
|
|
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 };
|
|
254
328
|
|
|
255
329
|
export interface IntentReason {
|
|
256
330
|
code: IntentReasonCode;
|
|
@@ -269,7 +343,7 @@ export type IntentDocument =
|
|
|
269
343
|
workspace: { name: string; root: string };
|
|
270
344
|
region: string;
|
|
271
345
|
history: { rev: string | null; follows: "line-range" | "file" | "directory"; shallow: boolean };
|
|
272
|
-
kinds: { file: string; records: string | null; joins: "function" | "data" | null }[];
|
|
346
|
+
kinds: { file: string; name: string; records: string | null; joins: "function" | "data" | null }[];
|
|
273
347
|
nodes: IntentNode[];
|
|
274
348
|
edges: IntentEdge[];
|
|
275
349
|
reasons: IntentReason[];
|
|
@@ -283,7 +357,11 @@ export interface IntentQuery {
|
|
|
283
357
|
/** `path`, `path:line` or `path:start-end`, or a graph node id `<member>/<id>`. */
|
|
284
358
|
region: string;
|
|
285
359
|
at?: string;
|
|
286
|
-
/**
|
|
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
|
+
*/
|
|
287
365
|
kinds?: string[];
|
|
288
366
|
/**
|
|
289
367
|
* Resolve a graph node id to its source location, for a region given as a
|
|
@@ -383,6 +461,25 @@ function addingCommit(top: string, rev: string, path: string): string | null {
|
|
|
383
461
|
return out?.trim() || null;
|
|
384
462
|
}
|
|
385
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
|
+
|
|
386
483
|
/** `commit` and every commit between it and `rev` that has it as an ancestor. */
|
|
387
484
|
function descendants(top: string, commit: string, rev: string): Set<string> {
|
|
388
485
|
const out = new Set<string>([commit]);
|
|
@@ -449,6 +546,20 @@ function stringOr(v: unknown): string | null {
|
|
|
449
546
|
return typeof v === "string" ? v : null;
|
|
450
547
|
}
|
|
451
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
|
+
|
|
452
563
|
function reviewSummary(data: Record<string, unknown> | null): DecisionNode["reviews"] {
|
|
453
564
|
const out = { agree: 0, dissent: 0, abstain: 0, openConcerns: 0 };
|
|
454
565
|
const list = data?.reviews;
|
|
@@ -486,6 +597,8 @@ interface LoadedKind {
|
|
|
486
597
|
file: string;
|
|
487
598
|
/** Relative to the repository root, for the document. */
|
|
488
599
|
display: string;
|
|
600
|
+
/** The namespace of its plugin findings: its `commitJoinsName`, the record kind's name, or the file's name without `.kind.mjs`. */
|
|
601
|
+
name: string;
|
|
489
602
|
records?: { loaded: LoadedRecordKind; views: RecordView[]; workspaceRoot: string };
|
|
490
603
|
joins?: CommitJoins;
|
|
491
604
|
}
|
|
@@ -503,12 +616,13 @@ async function loadKinds(query: IntentQuery, top: string): Promise<LoadedKind[]>
|
|
|
503
616
|
}
|
|
504
617
|
const joins = readCommitJoins(mod);
|
|
505
618
|
if (typeof joins === "string") throw new IntentError("kind-invalid", `kind file ${k} has a commitJoins export that can't be read: ${joins}`);
|
|
506
|
-
const kind: LoadedKind = { file, display, ...(joins ? { joins } : {}) };
|
|
619
|
+
const kind: LoadedKind = { file, display, name: joins?.name ?? basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
|
|
507
620
|
if (mod.recordKind !== undefined) {
|
|
508
621
|
const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd });
|
|
509
622
|
if ("error" in doc) throw new IntentError(doc.error.code, doc.error.message);
|
|
510
623
|
try {
|
|
511
624
|
kind.records = { loaded: await loadRecordKind(file), views: doc.records, workspaceRoot: doc.workspaceRoot };
|
|
625
|
+
kind.name = joins?.name ?? kind.records.loaded.kind.name;
|
|
512
626
|
} catch (err) {
|
|
513
627
|
if (err instanceof RecordReadError) throw new IntentError(err.code as IntentErrorCode, err.message);
|
|
514
628
|
throw err;
|
|
@@ -568,7 +682,9 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
568
682
|
const count = located.tree.read(region.path).replace(/\n$/, "").split("\n").length;
|
|
569
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`);
|
|
570
684
|
}
|
|
571
|
-
|
|
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);
|
|
572
688
|
|
|
573
689
|
const nodes = new Map<string, IntentNode>();
|
|
574
690
|
const edges: IntentEdge[] = [];
|
|
@@ -611,7 +727,22 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
611
727
|
if (!isWorkspacePath(path) || located.tree.stat(path) !== "file") return undefined;
|
|
612
728
|
return located.tree.read(path);
|
|
613
729
|
};
|
|
614
|
-
|
|
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
|
+
};
|
|
737
|
+
interface Joined {
|
|
738
|
+
unit?: string;
|
|
739
|
+
contracts: string[];
|
|
740
|
+
authorship: string[];
|
|
741
|
+
/** The record ids the commit's units and contracts say they carry out. */
|
|
742
|
+
decisions: string[];
|
|
743
|
+
}
|
|
744
|
+
const joined = new Map<string, Joined>();
|
|
745
|
+
const pluginFindings: { commit: string; plugin: string; finding: PluginFinding }[] = [];
|
|
615
746
|
const failedPlugins = new Set<string>();
|
|
616
747
|
for (const t of touched) {
|
|
617
748
|
const c = details.get(t.sha);
|
|
@@ -624,17 +755,17 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
624
755
|
: { level: "unattested" as const, reason: policy.problems.length ? policy.problems.join("; ") : `no signers file (${policy.signersPath}) at base; attestation is off` };
|
|
625
756
|
const pr = c.subject.match(/\(#([0-9]+)\)\s*$/);
|
|
626
757
|
const cid = `commit:${c.sha}`;
|
|
627
|
-
add<CommitNode>({ id: cid, kind: "commit", sha: c.sha, subject: c.subject, author: c.author, date: c.date, trailers: c.trailers, pullRequest: pr ? Number(pr[1]) : null, signature, lines: t.lines });
|
|
758
|
+
add<CommitNode>({ id: cid, kind: "commit", sha: c.sha, subject: c.subject, author: c.author, date: c.date, trailers: c.trailers, pullRequest: pr ? Number(pr[1]) : null, signature, lines: t.lines, state: null });
|
|
628
759
|
edges.push({ kind: "touched-by", from: rid, to: cid, lines: t.lines });
|
|
629
760
|
|
|
630
761
|
// 2. Each commit's origin, from the plugins.
|
|
631
|
-
const entry = { contracts: []
|
|
762
|
+
const entry: Joined = { contracts: [], authorship: [], decisions: [] };
|
|
632
763
|
joined.set(c.sha, entry);
|
|
633
764
|
for (const k of kinds) {
|
|
634
765
|
if (!k.joins) continue;
|
|
635
766
|
let result;
|
|
636
767
|
try {
|
|
637
|
-
result = await runCommitJoins(k.joins, c, { read: readAt, at: located.at });
|
|
768
|
+
result = await runCommitJoins(k.joins, c, { read: readAt, list: listAt, at: located.at }, k.name);
|
|
638
769
|
} catch (err) {
|
|
639
770
|
const message = `${k.display}: commitJoins failed for ${c.sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
|
|
640
771
|
if (!failedPlugins.has(message)) reasons.push({ code: "intent-plugin-failed", message });
|
|
@@ -653,6 +784,8 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
653
784
|
if (contractId) edges.push({ kind: "serves", from: unitId, to: contractId });
|
|
654
785
|
}
|
|
655
786
|
if (contractId) entry.contracts.push(result.contract!.id);
|
|
787
|
+
if (unitId) entry.decisions.push(...entityDecisions(result.unit), ...entityDecisions(result.contract));
|
|
788
|
+
for (const f of result.findings ?? []) pluginFindings.push({ commit: cid, plugin: k.display, finding: f });
|
|
656
789
|
const evidence = result.evidence === undefined ? [] : Array.isArray(result.evidence) ? result.evidence : [result.evidence];
|
|
657
790
|
for (const e of evidence) edges.push({ kind: "cites-evidence", from: unitId ?? contractId ?? cid, to: joinedNode("evidence", e) });
|
|
658
791
|
entry.authorship.push(...(result.authorship ?? []));
|
|
@@ -675,10 +808,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
675
808
|
const decisionId = (k: LoadedKind, id: string) => `record:${k.records!.loaded.kind.name}/${id}`;
|
|
676
809
|
const decisionNode = (k: LoadedKind, v: RecordView): DecisionNode => {
|
|
677
810
|
const kind = k.records!.loaded.kind;
|
|
678
|
-
const
|
|
679
|
-
const supersedes = Array.isArray(links)
|
|
680
|
-
? links.map((l) => (l && typeof l === "object" ? (l as Record<string, unknown>)[kind.supersedes.key] : undefined)).filter((x): x is string => typeof x === "string")
|
|
681
|
-
: [];
|
|
811
|
+
const supersedes = supersedesTargets(kind, v.data);
|
|
682
812
|
return add<DecisionNode>({
|
|
683
813
|
id: decisionId(k, v.id!),
|
|
684
814
|
kind: "decision",
|
|
@@ -687,7 +817,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
687
817
|
path: v.path,
|
|
688
818
|
title: stringOr(v.data?.title),
|
|
689
819
|
state: v.state,
|
|
690
|
-
closed: v.state !== null && kind.closedStates.includes(v.state),
|
|
820
|
+
closed: v.state !== null && (kind.closedStates ?? []).includes(v.state),
|
|
691
821
|
valid: v.valid,
|
|
692
822
|
reasons: v.reasons,
|
|
693
823
|
provenance: { level: v.provenance.level, commit: v.provenance.commit, reason: v.provenance.reason },
|
|
@@ -700,36 +830,41 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
700
830
|
});
|
|
701
831
|
};
|
|
702
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
|
+
};
|
|
703
862
|
for (const k of kinds) {
|
|
704
|
-
if (!k.records) continue;
|
|
705
|
-
const field = k.records.loaded.kind.constrains?.field;
|
|
706
|
-
const kindPrefix = k.records.workspaceRoot === "." ? "" : k.records.workspaceRoot;
|
|
863
|
+
if (!k.records || k.records.loaded.kind.work) continue;
|
|
707
864
|
for (const v of k.records.views) {
|
|
708
865
|
if (v.id === null) continue;
|
|
709
866
|
decisionById.set(`${k.records.loaded.kind.name}/${v.id}`, { view: v, kind: k });
|
|
710
|
-
const
|
|
711
|
-
if (!Array.isArray(list)) continue;
|
|
712
|
-
const matched: { entry: string; granularity: Granularity; to: string }[] = [];
|
|
713
|
-
for (const entry of list) {
|
|
714
|
-
if (typeof entry !== "string") continue;
|
|
715
|
-
if (entry.startsWith("path:")) {
|
|
716
|
-
const p = entry.slice("path:".length);
|
|
717
|
-
if (!isWorkspacePath(p)) continue;
|
|
718
|
-
const full = joinPath(kindPrefix, p);
|
|
719
|
-
if (regionFull !== "" && constraintCovers(full, regionFull)) {
|
|
720
|
-
matched.push({ entry, granularity: "path", to: rid });
|
|
721
|
-
} else if (type === "dir" && full !== regionFull && (regionFull === "" || full.startsWith(`${regionFull}/`))) {
|
|
722
|
-
// A path below a directory region constrains the files under it, not the whole region.
|
|
723
|
-
for (const f of fileNodesUnder(full)) matched.push({ entry, granularity: "path", to: `file:${f}` });
|
|
724
|
-
}
|
|
725
|
-
} else if (entry.startsWith("member:")) {
|
|
726
|
-
if (member !== null && entry.slice("member:".length) === member) matched.push({ entry, granularity: "member", to: rid });
|
|
727
|
-
} else {
|
|
728
|
-
const issue = entry.match(ISSUE);
|
|
729
|
-
if (issue && origin !== null && issue[1].toLowerCase() === origin && commitRefs.has(issue[2])) matched.push({ entry, granularity: "issue", to: rid });
|
|
730
|
-
else if (contractIds.has(entry)) matched.push({ entry, granularity: "contract", to: `contract:${entry}` });
|
|
731
|
-
}
|
|
732
|
-
}
|
|
867
|
+
const matched = matchConstrains(k, v);
|
|
733
868
|
if (matched.length === 0) continue;
|
|
734
869
|
const node = decisionNode(k, v);
|
|
735
870
|
for (const m of matched) {
|
|
@@ -852,6 +987,116 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
852
987
|
windows.set(c.node.id, { from: added ? descendants(top, added, rev) : new Set(), until: replaced ? descendants(top, replaced, rev) : null });
|
|
853
988
|
}
|
|
854
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
|
+
|
|
855
1100
|
const coveredAt = (sha: string, granularities: Granularity[]) =>
|
|
856
1101
|
covering.filter((c) => {
|
|
857
1102
|
if (!c.node.constrains.some((x) => granularities.includes(x.granularity))) return false;
|
|
@@ -859,11 +1104,24 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
859
1104
|
return !!w && w.from.has(sha) && !w.until?.has(sha);
|
|
860
1105
|
});
|
|
861
1106
|
const readsRecords = kinds.some((k) => k.records);
|
|
1107
|
+
// A decision's own work: a commit whose unit, or that unit's contract, names
|
|
1108
|
+
// the decision, or whose unit serves a contract the decision constrains.
|
|
1109
|
+
const ownWork = (j: Joined, d: DecisionNode) =>
|
|
1110
|
+
!!j.unit && (j.decisions.some((x) => x === d.record || x === `${d.recordKind}/${d.record}`) || d.constrains.some((x) => x.granularity === "contract" && j.contracts.includes(x.entry)));
|
|
862
1111
|
for (const t of touched) {
|
|
863
1112
|
const c = nodes.get(`commit:${t.sha}`) as CommitNode | undefined;
|
|
864
1113
|
if (!c) continue;
|
|
865
1114
|
const j = joined.get(t.sha)!;
|
|
866
|
-
if (readsRecords
|
|
1115
|
+
if (readsRecords) {
|
|
1116
|
+
const inWindow = coveredAt(t.sha, ["path"]);
|
|
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" });
|
|
1118
|
+
c.state = inWindow.length === 0 ? "undecided" : inWindow.some((w) => ownWork(j, w.node)) ? "decided" : "decided-by-window";
|
|
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
|
+
}
|
|
1124
|
+
if (readsRecords && c.state === "undecided") {
|
|
867
1125
|
find("intent-commit-undecided", `${t.sha.slice(0, 8)} changed the region when no decision constrained ${region.path} by path`, [c.id, rid]);
|
|
868
1126
|
}
|
|
869
1127
|
if (readsRecords && !j.unit && c.pullRequest === null && coveredAt(t.sha, ["path", "member", "contract", "issue"]).length === 0) {
|
|
@@ -891,6 +1149,37 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
891
1149
|
}
|
|
892
1150
|
}
|
|
893
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
|
+
|
|
894
1183
|
// 5. Links: the declared member links that touch the region's member, read in source.
|
|
895
1184
|
if (member) {
|
|
896
1185
|
let rows: LinkTableRow[] = [];
|
|
@@ -915,7 +1204,63 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
915
1204
|
}
|
|
916
1205
|
|
|
917
1206
|
// Findings last, in the order of the code list, so the same walk always prints the same way.
|
|
918
|
-
findings.sort((x, y) => INTENT_FINDING_CODES.indexOf(x.code) - INTENT_FINDING_CODES.indexOf(y.code));
|
|
1207
|
+
findings.sort((x, y) => INTENT_FINDING_CODES.indexOf(x.code as IntentFindingCode) - INTENT_FINDING_CODES.indexOf(y.code as IntentFindingCode));
|
|
1208
|
+
// Then the plugins' findings, in commit order, each about its commit and the nodes its refs name.
|
|
1209
|
+
const resolveRef = (ref: string): string | undefined => {
|
|
1210
|
+
if (nodes.has(ref)) return ref;
|
|
1211
|
+
for (const prefix of ["commit", "unit", "contract", "evidence", "artifact", "file", "member"]) if (nodes.has(`${prefix}:${ref}`)) return `${prefix}:${ref}`;
|
|
1212
|
+
for (const n of nodes.values()) {
|
|
1213
|
+
if (n.kind === "decision" && (ref === n.record || ref === `${n.recordKind}/${n.record}`)) return n.id;
|
|
1214
|
+
if (n.kind === "commit" && /^[0-9a-f]{7,}$/.test(ref) && n.sha.startsWith(ref)) return n.id;
|
|
1215
|
+
}
|
|
1216
|
+
return undefined;
|
|
1217
|
+
};
|
|
1218
|
+
for (const { commit, plugin, finding } of pluginFindings) {
|
|
1219
|
+
const code = finding.code as PluginCode;
|
|
1220
|
+
const refs = finding.refs ?? [];
|
|
1221
|
+
const concerns = [...new Set([commit, ...refs.map(resolveRef).filter((x): x is string => x !== undefined)])];
|
|
1222
|
+
findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message: finding.message, concerns, plugin, refs });
|
|
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
|
+
}
|
|
919
1264
|
for (const f of findings) nodes.set(f.id, f);
|
|
920
1265
|
const all = [...nodes.values()];
|
|
921
1266
|
return {
|
|
@@ -925,7 +1270,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
925
1270
|
workspace: { name: declaration.name, root: located.root },
|
|
926
1271
|
region: rid,
|
|
927
1272
|
history: { rev, follows: region.lines ? "line-range" : type === "file" ? "file" : "directory", shallow },
|
|
928
|
-
kinds: kinds.map((k) => ({ file: k.display, records: k.records?.loaded.kind.name ?? null, joins: k.joins?.form ?? null })),
|
|
1273
|
+
kinds: kinds.map((k) => ({ file: k.display, name: k.name, records: k.records?.loaded.kind.name ?? null, joins: k.joins?.form ?? null })),
|
|
929
1274
|
nodes: all,
|
|
930
1275
|
edges,
|
|
931
1276
|
reasons,
|