@intentius/chant 0.85.0 → 0.87.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 +13 -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 +71 -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 +47 -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 +71 -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 +171 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +244 -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/trust/seal.d.ts +85 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.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 +55 -3
- package/src/cli/registry.ts +13 -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 +376 -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-gaps.test.ts +217 -0
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +71 -19
- package/src/workspace/intent.schema.json +304 -7
- package/src/workspace/intent.test.ts +99 -0
- package/src/workspace/intent.ts +365 -46
- 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 +16 -4
- package/src/workspace/reason-codes.ts +55 -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 +308 -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 +227 -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 +765 -0
- package/src/workspace/records.schema.json +202 -9
- package/src/workspace/records.ts +700 -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/trust/seal.test.ts +232 -0
- package/src/workspace/trust/seal.ts +195 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +390 -0
- package/src/workspace/work.ts +163 -0
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;
|
|
@@ -67,12 +71,16 @@ export const INTENT_FINDING_CODES = [
|
|
|
67
71
|
"intent-pin-drifted",
|
|
68
72
|
/** A pinned artifact does not exist in the tree read. */
|
|
69
73
|
"intent-pin-missing",
|
|
74
|
+
/** A current decision's pin is unchanged at the hash a record it supersedes pinned: the decision moved on and the artifact did not (#2686). */
|
|
75
|
+
"intent-pin-stale",
|
|
70
76
|
/** An artifact that decisions in the graph pinned, and that no current decision pins. */
|
|
71
77
|
"intent-artifact-unpinned",
|
|
72
78
|
/** Every decision constraining the region is superseded. */
|
|
73
79
|
"intent-decision-superseded-live",
|
|
74
80
|
/** The current decisions constraining the region are all in states their kind does not close, such as decided and not ratified. */
|
|
75
81
|
"intent-decision-provisional",
|
|
82
|
+
/** A current decision constraining the region has an open concern: a dissent neither addressed nor withdrawn (#2686). */
|
|
83
|
+
"intent-decision-contested",
|
|
76
84
|
/** The region is constrained only at member granularity. */
|
|
77
85
|
"intent-constraint-coarse",
|
|
78
86
|
/** A decision's path constraint names a path that does not exist. */
|
|
@@ -83,6 +91,12 @@ export const INTENT_FINDING_CODES = [
|
|
|
83
91
|
"intent-trailer-unverified",
|
|
84
92
|
/** No decision constrains the region at any granularity. */
|
|
85
93
|
"intent-region-unconstrained",
|
|
94
|
+
/** 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. */
|
|
95
|
+
"intent-decision-unimplemented",
|
|
96
|
+
/** A work item constraining the region has commits in its window while its blockedBy is not empty (#2683). */
|
|
97
|
+
"intent-work-blocked",
|
|
98
|
+
/** 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). */
|
|
99
|
+
"intent-work-open-decided-code",
|
|
86
100
|
] as const satisfies readonly ReasonCode[];
|
|
87
101
|
export type IntentFindingCode = (typeof INTENT_FINDING_CODES)[number];
|
|
88
102
|
|
|
@@ -259,9 +273,55 @@ export interface FindingNode {
|
|
|
259
273
|
plugin?: string;
|
|
260
274
|
/** 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
275
|
refs?: string[];
|
|
276
|
+
/** For intent-decision-contested (#2686): how many open concerns the decision has, and whose, as its quorum reports them. */
|
|
277
|
+
openConcerns?: { count: number; principals: string[] };
|
|
278
|
+
/** With a work kind read (#2683): whether a work item addresses the finding. */
|
|
279
|
+
addressed?: boolean;
|
|
280
|
+
/** With a work kind read: the work items addressing the finding, each with its state. */
|
|
281
|
+
addressedBy?: WorkLink[];
|
|
262
282
|
}
|
|
263
283
|
|
|
264
|
-
|
|
284
|
+
/** Where a work item says it came from: the gap the intent graph reported (#2683). */
|
|
285
|
+
export interface WorkGapSource {
|
|
286
|
+
finding: string;
|
|
287
|
+
region: string;
|
|
288
|
+
decision?: string;
|
|
289
|
+
artifact?: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface WorkNode {
|
|
293
|
+
id: string;
|
|
294
|
+
kind: "work";
|
|
295
|
+
recordKind: string;
|
|
296
|
+
record: string;
|
|
297
|
+
/** The record file, from the repository root. */
|
|
298
|
+
path: string;
|
|
299
|
+
title: string | null;
|
|
300
|
+
state: string | null;
|
|
301
|
+
/** Whether the kind counts the state as closed, such as done or dropped. */
|
|
302
|
+
closed: boolean;
|
|
303
|
+
valid: boolean;
|
|
304
|
+
reasons: RecordView["reasons"];
|
|
305
|
+
provenance: { level: ProvenanceLevel; commit: string | null; reason: string };
|
|
306
|
+
owner: string | null;
|
|
307
|
+
/** As records reports it: open, and every need done. */
|
|
308
|
+
ready: boolean;
|
|
309
|
+
/** Each need that is not done, with its state. */
|
|
310
|
+
blockedBy: WorkLink[];
|
|
311
|
+
/** Each decision the item implements, with its state. */
|
|
312
|
+
implements: WorkLink[];
|
|
313
|
+
/** The ids the item's needs list names. */
|
|
314
|
+
needs: string[];
|
|
315
|
+
/** The gap the item came from, when its source names one. */
|
|
316
|
+
source: WorkGapSource | null;
|
|
317
|
+
supersededBy: string | null;
|
|
318
|
+
/** The constrains entries that cover the region, with their granularity. Empty for an item in the graph only through a link. */
|
|
319
|
+
constrains: { entry: string; granularity: Granularity }[];
|
|
320
|
+
/** The item's work warnings, as records reports them, and work-done-gap-open. */
|
|
321
|
+
warnings: { code: WorkWarningCode; message: string }[];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export type IntentNode = RegionNode | FileNode | MemberNode | CommitNode | JoinedNode | EvidenceEntryNode | DecisionNode | WorkNode | ArtifactNode | LinkNode | FindingNode;
|
|
265
325
|
|
|
266
326
|
export type Granularity = "path" | "member" | "contract" | "issue";
|
|
267
327
|
|
|
@@ -269,8 +329,8 @@ export type IntentEdge =
|
|
|
269
329
|
| { kind: "constrains"; from: string; to: string; granularity: Granularity; entry: string }
|
|
270
330
|
| { kind: "pins"; from: string; to: string; pinnedSha256: string | null; pinState: PinState }
|
|
271
331
|
| { 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 };
|
|
332
|
+
| { kind: "within"; from: string; to: string; state: "decided" | "decided-by-window" | "worked" }
|
|
333
|
+
| { kind: "produced-by" | "serves" | "cites-evidence" | "supersedes" | "links" | "implements" | "needs" | "addressed-by"; from: string; to: string };
|
|
274
334
|
|
|
275
335
|
export interface IntentReason {
|
|
276
336
|
code: IntentReasonCode;
|
|
@@ -303,7 +363,11 @@ export interface IntentQuery {
|
|
|
303
363
|
/** `path`, `path:line` or `path:start-end`, or a graph node id `<member>/<id>`. */
|
|
304
364
|
region: string;
|
|
305
365
|
at?: string;
|
|
306
|
-
/**
|
|
366
|
+
/**
|
|
367
|
+
* Kind files: record kinds, plugins with `commitJoins`, or both. Relative to
|
|
368
|
+
* `cwd`. Left out, every record kind the declaration names, in its order
|
|
369
|
+
* (#2680); an empty list reads none.
|
|
370
|
+
*/
|
|
307
371
|
kinds?: string[];
|
|
308
372
|
/**
|
|
309
373
|
* Resolve a graph node id to its source location, for a region given as a
|
|
@@ -403,6 +467,25 @@ function addingCommit(top: string, rev: string, path: string): string | null {
|
|
|
403
467
|
return out?.trim() || null;
|
|
404
468
|
}
|
|
405
469
|
|
|
470
|
+
/**
|
|
471
|
+
* The commit reachable from `rev` that moved the record at `path` into a
|
|
472
|
+
* closed state and kept it there, or null when the record is not closed at
|
|
473
|
+
* `rev` (#2683). A record added in a closed state closes in the commit that
|
|
474
|
+
* added it.
|
|
475
|
+
*/
|
|
476
|
+
function closingCommit(top: string, rev: string, path: string, stateField: string, closed: readonly string[]): string | null {
|
|
477
|
+
const out = tryGit(top, ["log", "--format=%H", rev, "--", path]);
|
|
478
|
+
let closing: string | null = null;
|
|
479
|
+
for (const sha of (out ?? "").split("\n").map((l) => l.trim()).filter(Boolean)) {
|
|
480
|
+
const text = tryGit(top, ["show", `${sha}:${path}`]);
|
|
481
|
+
const fm = text === undefined ? undefined : parseFrontMatter(text);
|
|
482
|
+
const state = fm?.ok ? fm.value[stateField] : undefined;
|
|
483
|
+
if (typeof state !== "string" || !closed.includes(state)) break;
|
|
484
|
+
closing = sha;
|
|
485
|
+
}
|
|
486
|
+
return closing;
|
|
487
|
+
}
|
|
488
|
+
|
|
406
489
|
/** `commit` and every commit between it and `rev` that has it as an ancestor. */
|
|
407
490
|
function descendants(top: string, commit: string, rev: string): Set<string> {
|
|
408
491
|
const out = new Set<string>([commit]);
|
|
@@ -469,6 +552,20 @@ function stringOr(v: unknown): string | null {
|
|
|
469
552
|
return typeof v === "string" ? v : null;
|
|
470
553
|
}
|
|
471
554
|
|
|
555
|
+
/** A work record's `source` when it names the gap it came from (#2683), or null. */
|
|
556
|
+
function gapSource(data: Record<string, unknown> | null): WorkGapSource | null {
|
|
557
|
+
const src = data?.source;
|
|
558
|
+
if (src === null || typeof src !== "object" || Array.isArray(src)) return null;
|
|
559
|
+
const s = src as Record<string, unknown>;
|
|
560
|
+
if (typeof s.finding !== "string" || typeof s.region !== "string") return null;
|
|
561
|
+
return {
|
|
562
|
+
finding: s.finding,
|
|
563
|
+
region: s.region,
|
|
564
|
+
...(typeof s.decision === "string" ? { decision: s.decision } : {}),
|
|
565
|
+
...(typeof s.artifact === "string" ? { artifact: s.artifact } : {}),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
472
569
|
function reviewSummary(data: Record<string, unknown> | null): DecisionNode["reviews"] {
|
|
473
570
|
const out = { agree: 0, dissent: 0, abstain: 0, openConcerns: 0 };
|
|
474
571
|
const list = data?.reviews;
|
|
@@ -506,7 +603,7 @@ interface LoadedKind {
|
|
|
506
603
|
file: string;
|
|
507
604
|
/** Relative to the repository root, for the document. */
|
|
508
605
|
display: string;
|
|
509
|
-
/** The record kind's name, or the file's name without `.kind.mjs
|
|
606
|
+
/** The namespace of its plugin findings: its `commitJoinsName`, the record kind's name, or the file's name without `.kind.mjs`. */
|
|
510
607
|
name: string;
|
|
511
608
|
records?: { loaded: LoadedRecordKind; views: RecordView[]; workspaceRoot: string };
|
|
512
609
|
joins?: CommitJoins;
|
|
@@ -525,13 +622,13 @@ async function loadKinds(query: IntentQuery, top: string): Promise<LoadedKind[]>
|
|
|
525
622
|
}
|
|
526
623
|
const joins = readCommitJoins(mod);
|
|
527
624
|
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 } : {}) };
|
|
625
|
+
const kind: LoadedKind = { file, display, name: joins?.name ?? basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
|
|
529
626
|
if (mod.recordKind !== undefined) {
|
|
530
|
-
const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd });
|
|
627
|
+
const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd, workGaps: false });
|
|
531
628
|
if ("error" in doc) throw new IntentError(doc.error.code, doc.error.message);
|
|
532
629
|
try {
|
|
533
630
|
kind.records = { loaded: await loadRecordKind(file), views: doc.records, workspaceRoot: doc.workspaceRoot };
|
|
534
|
-
kind.name = kind.records.loaded.kind.name;
|
|
631
|
+
kind.name = joins?.name ?? kind.records.loaded.kind.name;
|
|
535
632
|
} catch (err) {
|
|
536
633
|
if (err instanceof RecordReadError) throw new IntentError(err.code as IntentErrorCode, err.message);
|
|
537
634
|
throw err;
|
|
@@ -591,7 +688,9 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
591
688
|
const count = located.tree.read(region.path).replace(/\n$/, "").split("\n").length;
|
|
592
689
|
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
690
|
}
|
|
594
|
-
|
|
691
|
+
// Without --kind, the declared record kinds, read from the working tree as a --kind file is (#2680).
|
|
692
|
+
const kindFiles = query.kinds ?? declaredRecordKinds(declaration).map((d) => declaredKindFile(d, located.rootOnDisk));
|
|
693
|
+
const kinds = await loadKinds({ ...query, kinds: kindFiles }, top);
|
|
595
694
|
|
|
596
695
|
const nodes = new Map<string, IntentNode>();
|
|
597
696
|
const edges: IntentEdge[] = [];
|
|
@@ -634,6 +733,13 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
634
733
|
if (!isWorkspacePath(path) || located.tree.stat(path) !== "file") return undefined;
|
|
635
734
|
return located.tree.read(path);
|
|
636
735
|
};
|
|
736
|
+
// The same tree as readAt (#2663): entries directly inside dir, from the workspace root, a directory's with a trailing slash.
|
|
737
|
+
const listAt = (dir: string): string[] | undefined => {
|
|
738
|
+
const d = dir === "." ? "" : dir.replace(/\/+$/, "");
|
|
739
|
+
if (d !== "" && (!isWorkspacePath(d) || located.tree.stat(d) !== "dir")) return undefined;
|
|
740
|
+
const entries = located.tree.list(d);
|
|
741
|
+
return entries?.map((e) => `${joinPath(d, e.name)}${e.type === "dir" ? "/" : ""}`).sort();
|
|
742
|
+
};
|
|
637
743
|
interface Joined {
|
|
638
744
|
unit?: string;
|
|
639
745
|
contracts: string[];
|
|
@@ -665,7 +771,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
665
771
|
if (!k.joins) continue;
|
|
666
772
|
let result;
|
|
667
773
|
try {
|
|
668
|
-
result = await runCommitJoins(k.joins, c, { read: readAt, at: located.at }, k.name);
|
|
774
|
+
result = await runCommitJoins(k.joins, c, { read: readAt, list: listAt, at: located.at }, k.name);
|
|
669
775
|
} catch (err) {
|
|
670
776
|
const message = `${k.display}: commitJoins failed for ${c.sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
|
|
671
777
|
if (!failedPlugins.has(message)) reasons.push({ code: "intent-plugin-failed", message });
|
|
@@ -708,10 +814,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
708
814
|
const decisionId = (k: LoadedKind, id: string) => `record:${k.records!.loaded.kind.name}/${id}`;
|
|
709
815
|
const decisionNode = (k: LoadedKind, v: RecordView): DecisionNode => {
|
|
710
816
|
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
|
-
: [];
|
|
817
|
+
const supersedes = supersedesTargets(kind, v.data);
|
|
715
818
|
return add<DecisionNode>({
|
|
716
819
|
id: decisionId(k, v.id!),
|
|
717
820
|
kind: "decision",
|
|
@@ -720,7 +823,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
720
823
|
path: v.path,
|
|
721
824
|
title: stringOr(v.data?.title),
|
|
722
825
|
state: v.state,
|
|
723
|
-
closed: v.state !== null && kind.closedStates.includes(v.state),
|
|
826
|
+
closed: v.state !== null && (kind.closedStates ?? []).includes(v.state),
|
|
724
827
|
valid: v.valid,
|
|
725
828
|
reasons: v.reasons,
|
|
726
829
|
provenance: { level: v.provenance.level, commit: v.provenance.commit, reason: v.provenance.reason },
|
|
@@ -733,36 +836,41 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
733
836
|
});
|
|
734
837
|
};
|
|
735
838
|
const fileNodesUnder = (full: string) => files.filter((f) => constraintCovers(full, joinPath(workspacePrefix, f)));
|
|
839
|
+
/** The constrains entries of a record that cover the region or a file under it. Decisions and work items match the same way (#2683). */
|
|
840
|
+
const matchConstrains = (k: LoadedKind, v: RecordView): { entry: string; granularity: Granularity; to: string }[] => {
|
|
841
|
+
const field = k.records!.loaded.kind.constrains?.field;
|
|
842
|
+
const kindPrefix = k.records!.workspaceRoot === "." ? "" : k.records!.workspaceRoot;
|
|
843
|
+
const list = field ? v.data?.[field] : undefined;
|
|
844
|
+
const matched: { entry: string; granularity: Granularity; to: string }[] = [];
|
|
845
|
+
if (!Array.isArray(list)) return matched;
|
|
846
|
+
for (const entry of list) {
|
|
847
|
+
if (typeof entry !== "string") continue;
|
|
848
|
+
if (entry.startsWith("path:")) {
|
|
849
|
+
const p = entry.slice("path:".length);
|
|
850
|
+
if (!isWorkspacePath(p)) continue;
|
|
851
|
+
const full = joinPath(kindPrefix, p);
|
|
852
|
+
if (regionFull !== "" && constraintCovers(full, regionFull)) {
|
|
853
|
+
matched.push({ entry, granularity: "path", to: rid });
|
|
854
|
+
} else if (type === "dir" && full !== regionFull && (regionFull === "" || full.startsWith(`${regionFull}/`))) {
|
|
855
|
+
// A path below a directory region constrains the files under it, not the whole region.
|
|
856
|
+
for (const f of fileNodesUnder(full)) matched.push({ entry, granularity: "path", to: `file:${f}` });
|
|
857
|
+
}
|
|
858
|
+
} else if (entry.startsWith("member:")) {
|
|
859
|
+
if (member !== null && entry.slice("member:".length) === member) matched.push({ entry, granularity: "member", to: rid });
|
|
860
|
+
} else {
|
|
861
|
+
const issue = entry.match(ISSUE);
|
|
862
|
+
if (issue && origin !== null && issue[1].toLowerCase() === origin && commitRefs.has(issue[2])) matched.push({ entry, granularity: "issue", to: rid });
|
|
863
|
+
else if (contractIds.has(entry)) matched.push({ entry, granularity: "contract", to: `contract:${entry}` });
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return matched;
|
|
867
|
+
};
|
|
736
868
|
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;
|
|
869
|
+
if (!k.records || k.records.loaded.kind.work) continue;
|
|
740
870
|
for (const v of k.records.views) {
|
|
741
871
|
if (v.id === null) continue;
|
|
742
872
|
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
|
-
}
|
|
873
|
+
const matched = matchConstrains(k, v);
|
|
766
874
|
if (matched.length === 0) continue;
|
|
767
875
|
const node = decisionNode(k, v);
|
|
768
876
|
for (const m of matched) {
|
|
@@ -849,6 +957,18 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
849
957
|
edges.push({ kind: "pins", from: p.decision.id, to: aid, pinnedSha256: p.sha256, pinState: p.state });
|
|
850
958
|
if (p.state === "drifted") find("intent-pin-drifted", `${path} changed after ${p.decision.record} pinned it: sha256 ${p.sha256?.slice(0, 12)} is pinned, the file hashes to ${p.actual?.slice(0, 12)}`, [p.decision.id, aid]);
|
|
851
959
|
if (p.state === "missing") find("intent-pin-missing", `${p.decision.record} pins ${path}, which does not exist${located.tree.label}`, [p.decision.id, aid]);
|
|
960
|
+
// Stale against a current decision constraining the region (#2686): the
|
|
961
|
+
// record it supersedes pinned the same hash, and the file has not
|
|
962
|
+
// changed since. A decision in the graph only through supersession
|
|
963
|
+
// does not govern the region, so its stale pin is not raised here.
|
|
964
|
+
if (p.state === "stale" && p.decision.supersededBy === null && p.decision.constrains.length > 0) {
|
|
965
|
+
const old = pins.find((o) => o.decision.supersededBy === p.decision.record && o.decision.recordKind === p.decision.recordKind && o.sha256 === p.sha256);
|
|
966
|
+
find(
|
|
967
|
+
"intent-pin-stale",
|
|
968
|
+
`${path} is pinned by ${p.decision.record} at sha256 ${p.sha256?.slice(0, 12)}, the hash ${old ? old.decision.record : "the record it supersedes"} pinned, and it has not changed since: the decision moved on and the artifact did not`,
|
|
969
|
+
[p.decision.id, aid, ...(old ? [old.decision.id] : [])],
|
|
970
|
+
);
|
|
971
|
+
}
|
|
852
972
|
}
|
|
853
973
|
if (!worst && pins.some((p) => p.state !== "unpinned")) {
|
|
854
974
|
find("intent-artifact-unpinned", `${path} was pinned by ${[...new Set(pins.map((p) => p.decision.record))].join(", ")}, and no current decision pins it`, [aid, ...new Set(pins.map((p) => p.decision.id))]);
|
|
@@ -885,6 +1005,116 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
885
1005
|
windows.set(c.node.id, { from: added ? descendants(top, added, rev) : new Set(), until: replaced ? descendants(top, replaced, rev) : null });
|
|
886
1006
|
}
|
|
887
1007
|
}
|
|
1008
|
+
// Work items (#2683): the records of each work kind whose constrains cover
|
|
1009
|
+
// the region, the decisions they implement and the items they need, one hop
|
|
1010
|
+
// each, and each covering item's window: from the commit that added the
|
|
1011
|
+
// record to the commit that closed it, that commit included, or to the
|
|
1012
|
+
// revision read while it is open.
|
|
1013
|
+
interface WorkHit {
|
|
1014
|
+
view: RecordView;
|
|
1015
|
+
kind: LoadedKind;
|
|
1016
|
+
node?: WorkNode;
|
|
1017
|
+
}
|
|
1018
|
+
const workKinds = kinds.filter((k) => k.records?.loaded.kind.work);
|
|
1019
|
+
const readsWork = workKinds.length > 0;
|
|
1020
|
+
const workAll: WorkHit[] = [];
|
|
1021
|
+
const workByKey = new Map<string, WorkHit>();
|
|
1022
|
+
for (const k of workKinds) {
|
|
1023
|
+
for (const v of k.records!.views) {
|
|
1024
|
+
if (v.id === null) continue;
|
|
1025
|
+
const hit: WorkHit = { view: v, kind: k };
|
|
1026
|
+
workAll.push(hit);
|
|
1027
|
+
const key = `${k.records!.loaded.kind.name}/${v.id}`;
|
|
1028
|
+
if (!workByKey.has(key)) workByKey.set(key, hit);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const workSpec = (h: WorkHit) => h.kind.records!.loaded.kind.work!;
|
|
1032
|
+
const workNode = (h: WorkHit): WorkNode => {
|
|
1033
|
+
if (h.node) return h.node;
|
|
1034
|
+
const kind = h.kind.records!.loaded.kind;
|
|
1035
|
+
const v = h.view;
|
|
1036
|
+
h.node = add<WorkNode>({
|
|
1037
|
+
id: `record:${kind.name}/${v.id!}`,
|
|
1038
|
+
kind: "work",
|
|
1039
|
+
recordKind: kind.name,
|
|
1040
|
+
record: v.id!,
|
|
1041
|
+
path: v.path,
|
|
1042
|
+
title: stringOr(v.data?.title),
|
|
1043
|
+
state: v.state,
|
|
1044
|
+
closed: v.state !== null && (kind.closedStates ?? []).includes(v.state),
|
|
1045
|
+
valid: v.valid,
|
|
1046
|
+
reasons: v.reasons,
|
|
1047
|
+
provenance: { level: v.provenance.level, commit: v.provenance.commit, reason: v.provenance.reason },
|
|
1048
|
+
owner: stringOr(v.data?.owner),
|
|
1049
|
+
ready: v.ready ?? false,
|
|
1050
|
+
blockedBy: v.blockedBy ?? [],
|
|
1051
|
+
implements: v.implements ?? [],
|
|
1052
|
+
needs: idList(v.data, kind.work!.needs),
|
|
1053
|
+
source: gapSource(v.data),
|
|
1054
|
+
supersededBy: v.supersededBy,
|
|
1055
|
+
constrains: [],
|
|
1056
|
+
warnings: v.warnings.filter((w): w is { code: WorkWarningCode; message: string } => (WORK_WARNING_CODES as readonly string[]).includes(w.code)),
|
|
1057
|
+
});
|
|
1058
|
+
return h.node;
|
|
1059
|
+
};
|
|
1060
|
+
/** The decision kind a work kind names, when it is among the kinds read. */
|
|
1061
|
+
const decisionKindOf = (k: LoadedKind): LoadedKind | undefined => {
|
|
1062
|
+
const file = realpathOr(resolve(dirname(k.records!.loaded.file), k.records!.loaded.kind.work!.decisions));
|
|
1063
|
+
return kinds.find((x) => x.records && !x.records.loaded.kind.work && realpathOr(x.records.loaded.file) === file);
|
|
1064
|
+
};
|
|
1065
|
+
/** The node ids of the decisions a work item implements, among the decisions read. */
|
|
1066
|
+
const implementedIds = (h: WorkHit): string[] => {
|
|
1067
|
+
const dk = decisionKindOf(h.kind);
|
|
1068
|
+
if (!dk) return [];
|
|
1069
|
+
return idList(h.view.data, workSpec(h).implements)
|
|
1070
|
+
.map((d) => decisionById.get(`${dk.records!.loaded.kind.name}/${d}`))
|
|
1071
|
+
.filter((x): x is { view: RecordView; kind: LoadedKind } => x !== undefined)
|
|
1072
|
+
.map((x) => decisionId(x.kind, x.view.id!));
|
|
1073
|
+
};
|
|
1074
|
+
/** The work items implementing a decision, leaving out dropped ones: a closed state other than done. */
|
|
1075
|
+
const implementersOf = (d: DecisionNode): WorkHit[] =>
|
|
1076
|
+
workAll.filter((h) => {
|
|
1077
|
+
const kind = h.kind.records!.loaded.kind;
|
|
1078
|
+
const dropped = h.view.state !== null && h.view.state !== kind.work!.done && (kind.closedStates ?? []).includes(h.view.state);
|
|
1079
|
+
return !dropped && implementedIds(h).includes(d.id);
|
|
1080
|
+
});
|
|
1081
|
+
const workCovering: WorkHit[] = [];
|
|
1082
|
+
for (const h of workAll) {
|
|
1083
|
+
const matched = matchConstrains(h.kind, h.view);
|
|
1084
|
+
if (matched.length === 0) continue;
|
|
1085
|
+
const node = workNode(h);
|
|
1086
|
+
for (const m of matched) {
|
|
1087
|
+
edges.push({ kind: "constrains", from: node.id, to: m.to, granularity: m.granularity, entry: m.entry });
|
|
1088
|
+
if (m.to === rid || m.granularity === "contract") node.constrains.push({ entry: m.entry, granularity: m.granularity });
|
|
1089
|
+
}
|
|
1090
|
+
if (node.constrains.length > 0) workCovering.push(h);
|
|
1091
|
+
}
|
|
1092
|
+
for (const h of workAll.filter((x) => x.node)) {
|
|
1093
|
+
for (const id of implementedIds(h)) {
|
|
1094
|
+
const hit = decisionById.get(id.slice("record:".length))!;
|
|
1095
|
+
edges.push({ kind: "implements", from: h.node!.id, to: decisionNode(hit.kind, hit.view).id });
|
|
1096
|
+
}
|
|
1097
|
+
for (const n of idList(h.view.data, workSpec(h).needs)) {
|
|
1098
|
+
const hit = workByKey.get(`${h.kind.records!.loaded.kind.name}/${n}`);
|
|
1099
|
+
if (hit) edges.push({ kind: "needs", from: h.node!.id, to: workNode(hit).id });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const workWindows = new Map<string, { from: Set<string>; until: Set<string> | null }>();
|
|
1103
|
+
if (rev) {
|
|
1104
|
+
for (const h of workCovering) {
|
|
1105
|
+
const kind = h.kind.records!.loaded.kind;
|
|
1106
|
+
const added = addingCommit(top, rev, h.view.path);
|
|
1107
|
+
const closing = kind.stateField ? closingCommit(top, rev, h.view.path, kind.stateField, kind.closedStates ?? []) : null;
|
|
1108
|
+
const until = closing ? descendants(top, closing, rev) : null;
|
|
1109
|
+
if (closing) until!.delete(closing);
|
|
1110
|
+
workWindows.set(h.node!.id, { from: added ? descendants(top, added, rev) : new Set(), until });
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
const inWorkWindow = (h: WorkHit, sha: string) => {
|
|
1114
|
+
const w = workWindows.get(h.node!.id);
|
|
1115
|
+
return !!w && w.from.has(sha) && !w.until?.has(sha);
|
|
1116
|
+
};
|
|
1117
|
+
|
|
888
1118
|
const coveredAt = (sha: string, granularities: Granularity[]) =>
|
|
889
1119
|
covering.filter((c) => {
|
|
890
1120
|
if (!c.node.constrains.some((x) => granularities.includes(x.granularity))) return false;
|
|
@@ -905,6 +1135,10 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
905
1135
|
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
1136
|
c.state = inWindow.length === 0 ? "undecided" : inWindow.some((w) => ownWork(j, w.node)) ? "decided" : "decided-by-window";
|
|
907
1137
|
}
|
|
1138
|
+
// A commit in a work item's window is worked; its own state still comes from decisions (#2683).
|
|
1139
|
+
for (const h of workCovering) {
|
|
1140
|
+
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" });
|
|
1141
|
+
}
|
|
908
1142
|
if (readsRecords && c.state === "undecided") {
|
|
909
1143
|
find("intent-commit-undecided", `${t.sha.slice(0, 8)} changed the region when no decision constrained ${region.path} by path`, [c.id, rid]);
|
|
910
1144
|
}
|
|
@@ -927,12 +1161,57 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
927
1161
|
} else if (current.every((c) => !c.node.closed)) {
|
|
928
1162
|
find("intent-decision-provisional", `the decisions constraining ${region.path} are ${[...new Set(current.map((c) => c.node.state ?? "stateless"))].join(" or ")}, and none is in a closed state`, [rid, ...current.map((c) => c.node.id)]);
|
|
929
1163
|
}
|
|
1164
|
+
// A current decision with a dissent nobody addressed or withdrew (#2686), from its quorum.
|
|
1165
|
+
for (const c of current) {
|
|
1166
|
+
const open = c.view.quorum?.openConcerns ?? [];
|
|
1167
|
+
if (open.length === 0) continue;
|
|
1168
|
+
const principals = [...new Set(open.map((o) => o.principal))];
|
|
1169
|
+
findings.push({
|
|
1170
|
+
id: `finding:intent-decision-contested:${findings.filter((f) => f.code === "intent-decision-contested").length + 1}`,
|
|
1171
|
+
kind: "finding",
|
|
1172
|
+
code: "intent-decision-contested",
|
|
1173
|
+
message: `${c.node.record} constrains ${region.path} and has ${open.length} open ${open.length === 1 ? "concern" : "concerns"}, from ${open.map((o) => o.reviewer).join(", ")}: a dissent neither addressed nor withdrawn`,
|
|
1174
|
+
concerns: [c.node.id, rid],
|
|
1175
|
+
openConcerns: { count: open.length, principals },
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
930
1178
|
const granularities = new Set(covering.flatMap((c) => c.node.constrains.map((x) => x.granularity)));
|
|
931
1179
|
if (covering.length > 0 && !granularities.has("path") && granularities.has("member")) {
|
|
932
1180
|
find("intent-constraint-coarse", `${region.path} is constrained only through its member, ${member}`, [rid, ...covering.map((c) => c.node.id)]);
|
|
933
1181
|
}
|
|
934
1182
|
}
|
|
935
1183
|
|
|
1184
|
+
// Findings about work items (#2683).
|
|
1185
|
+
if (readsWork) {
|
|
1186
|
+
for (const c of covering) {
|
|
1187
|
+
if (c.node.supersededBy !== null || !isDecided(c.kind.records!.loaded.kind, c.node.state)) continue;
|
|
1188
|
+
if (implementersOf(c.node).length > 0) continue;
|
|
1189
|
+
const w = windows.get(c.node.id);
|
|
1190
|
+
if (w && touched.some((t) => w.from.has(t.sha) && !w.until?.has(t.sha))) continue;
|
|
1191
|
+
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]);
|
|
1192
|
+
}
|
|
1193
|
+
for (const h of workCovering) {
|
|
1194
|
+
const node = h.node!;
|
|
1195
|
+
if (node.blockedBy.length === 0) continue;
|
|
1196
|
+
const worked = touched.filter((t) => inWorkWindow(h, t.sha));
|
|
1197
|
+
if (worked.length === 0) continue;
|
|
1198
|
+
const waiting = node.blockedBy.map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
|
|
1199
|
+
const blockers = node.blockedBy.map((b) => workByKey.get(`${node.recordKind}/${b.id}`)).filter((x): x is WorkHit => x !== undefined).map((x) => workNode(x).id);
|
|
1200
|
+
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]);
|
|
1201
|
+
}
|
|
1202
|
+
for (const d of [...nodes.values()].filter((n): n is DecisionNode => n.kind === "decision")) {
|
|
1203
|
+
const own = edges.filter((e) => e.kind === "within" && e.to === d.id && e.state === "decided").map((e) => e.from);
|
|
1204
|
+
if (own.length === 0) continue;
|
|
1205
|
+
const impl = implementersOf(d);
|
|
1206
|
+
if (impl.length === 0 || impl.some((h) => h.view.state === workSpec(h).done)) continue;
|
|
1207
|
+
find(
|
|
1208
|
+
"intent-work-open-decided-code",
|
|
1209
|
+
`${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`,
|
|
1210
|
+
[d.id, ...own, ...impl.map((h) => workNode(h).id)],
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
936
1215
|
// 5. Links: the declared member links that touch the region's member, read in source.
|
|
937
1216
|
if (member) {
|
|
938
1217
|
let rows: LinkTableRow[] = [];
|
|
@@ -974,6 +1253,46 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
|
|
|
974
1253
|
const concerns = [...new Set([commit, ...refs.map(resolveRef).filter((x): x is string => x !== undefined)])];
|
|
975
1254
|
findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message: finding.message, concerns, plugin, refs });
|
|
976
1255
|
}
|
|
1256
|
+
// A finding a work item addresses (#2683): the item came from that gap on
|
|
1257
|
+
// this region, or it implements the decision a drifted, missing or stale pin, or a
|
|
1258
|
+
// plugin's finding, is about. A done item whose gap still fires here gets
|
|
1259
|
+
// work-done-gap-open.
|
|
1260
|
+
if (readsWork) {
|
|
1261
|
+
// Implementing a decision closes the gaps between it and the code or its
|
|
1262
|
+
// artifacts, not gaps in the decision record itself (its review, its
|
|
1263
|
+
// granularity, its evidence), so only these findings, and a plugin's,
|
|
1264
|
+
// count as addressed through implements.
|
|
1265
|
+
const CLOSED_BY_IMPLEMENTING = new Set<string>(["intent-pin-drifted", "intent-pin-missing", "intent-pin-stale"]);
|
|
1266
|
+
const fromGap = (h: WorkHit, code: string): boolean => {
|
|
1267
|
+
const src = gapSource(h.view.data);
|
|
1268
|
+
if (src === null || src.finding !== code) return false;
|
|
1269
|
+
const parsed = parseRegion(src.region);
|
|
1270
|
+
if ("error" in parsed) return false;
|
|
1271
|
+
const kindPrefix = h.kind.records!.workspaceRoot === "." ? "" : h.kind.records!.workspaceRoot;
|
|
1272
|
+
const full = joinPath(kindPrefix, parsed.path);
|
|
1273
|
+
const contains = (a: string, b: string) => a === b || a === "" || b.startsWith(`${a}/`);
|
|
1274
|
+
if (!contains(full, regionFull) && !contains(regionFull, full)) return false;
|
|
1275
|
+
if (full === regionFull && parsed.lines && region.lines) return parsed.lines.start <= region.lines.end && region.lines.start <= parsed.lines.end;
|
|
1276
|
+
return true;
|
|
1277
|
+
};
|
|
1278
|
+
for (const f of findings) {
|
|
1279
|
+
const by: WorkHit[] = [];
|
|
1280
|
+
for (const h of workAll) {
|
|
1281
|
+
const gap = fromGap(h, f.code);
|
|
1282
|
+
const viaDecision = (CLOSED_BY_IMPLEMENTING.has(f.code) || isPluginCode(f.code)) && implementedIds(h).some((id) => f.concerns.includes(id));
|
|
1283
|
+
if (gap || viaDecision) by.push(h);
|
|
1284
|
+
if (gap && h.view.state === workSpec(h).done) {
|
|
1285
|
+
const node = workNode(h);
|
|
1286
|
+
if (!node.warnings.some((w) => w.code === "work-done-gap-open")) {
|
|
1287
|
+
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}` });
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
f.addressed = by.length > 0;
|
|
1292
|
+
f.addressedBy = by.map((h) => ({ id: h.view.id!, state: h.view.state }));
|
|
1293
|
+
for (const h of by) edges.push({ kind: "addressed-by", from: f.id, to: workNode(h).id });
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
977
1296
|
for (const f of findings) nodes.set(f.id, f);
|
|
978
1297
|
const all = [...nodes.values()];
|
|
979
1298
|
return {
|
|
@@ -43,6 +43,11 @@
|
|
|
43
43
|
"integrity": { "type": ["string", "null"] }
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
},
|
|
47
|
+
"records": {
|
|
48
|
+
"description": "Added in contract 1 by #2680. The workspace's own record kinds, from the declaration's top-level records, in file order.",
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": { "$ref": "#/$defs/recordKind" }
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
},
|
|
@@ -89,6 +94,11 @@
|
|
|
89
94
|
"upstream": { "type": ["string", "null"] },
|
|
90
95
|
"because": { "type": ["string", "null"] },
|
|
91
96
|
"readable": { "type": "boolean", "description": "True exactly when reason is null." },
|
|
97
|
+
"records": {
|
|
98
|
+
"description": "Added in contract 1 by #2680. The record kinds the member declares, in file order. A kind that can't be loaded does not make the member unreadable.",
|
|
99
|
+
"type": "array",
|
|
100
|
+
"items": { "$ref": "#/$defs/recordKind" }
|
|
101
|
+
},
|
|
92
102
|
"reason": {
|
|
93
103
|
"oneOf": [
|
|
94
104
|
{ "type": "null" },
|
|
@@ -107,6 +117,30 @@
|
|
|
107
117
|
"then": { "type": "object", "properties": { "reason": { "type": "null" } } },
|
|
108
118
|
"else": { "type": "object", "properties": { "reason": { "type": "object" } } }
|
|
109
119
|
},
|
|
120
|
+
"recordKind": {
|
|
121
|
+
"description": "A record kind the declaration names (#2680). chant workspace records and graph --intent read every one of them when --kind is not given.",
|
|
122
|
+
"type": "object",
|
|
123
|
+
"required": ["name", "path", "kind", "reason"],
|
|
124
|
+
"properties": {
|
|
125
|
+
"name": { "type": ["string", "null"], "description": "The name the declaration gives the kind, else the kind file's own recordKind.name, else null when the file can't be loaded." },
|
|
126
|
+
"path": { "type": "string", "description": "The kind file, relative to the workspace root, with / separators." },
|
|
127
|
+
"kind": { "type": ["string", "null"], "description": "The kind file's recordKind.name, or null when it can't be loaded." },
|
|
128
|
+
"reason": {
|
|
129
|
+
"description": "Why the kind file can't be loaded, or null. The file is looked for in the tree read, the revision under --at, and loaded from the working tree.",
|
|
130
|
+
"oneOf": [
|
|
131
|
+
{ "type": "null" },
|
|
132
|
+
{
|
|
133
|
+
"type": "object",
|
|
134
|
+
"required": ["code", "message"],
|
|
135
|
+
"properties": {
|
|
136
|
+
"code": { "enum": ["kind-unreadable", "kind-invalid", "schema-unreadable", "schema-id-mismatch"] },
|
|
137
|
+
"message": { "type": "string" }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
110
144
|
"group": {
|
|
111
145
|
"type": "object",
|
|
112
146
|
"required": ["name", "kind", "glob", "matches", "skipped", "reason"],
|