@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.
Files changed (129) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +13 -1
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  7. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  8. package/dist/workspace/checks/records.d.ts +1 -0
  9. package/dist/workspace/checks/records.d.ts.map +1 -1
  10. package/dist/workspace/checks.d.ts +4 -0
  11. package/dist/workspace/checks.d.ts.map +1 -1
  12. package/dist/workspace/composites.d.ts +14 -1
  13. package/dist/workspace/composites.d.ts.map +1 -1
  14. package/dist/workspace/conformance/index.d.ts +211 -0
  15. package/dist/workspace/conformance/index.d.ts.map +1 -0
  16. package/dist/workspace/conformance/vitest.d.ts +11 -0
  17. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  18. package/dist/workspace/declaration.d.ts +28 -0
  19. package/dist/workspace/declaration.d.ts.map +1 -1
  20. package/dist/workspace/declaration.schema.json +40 -0
  21. package/dist/workspace/declared-kinds.d.ts +43 -0
  22. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  23. package/dist/workspace/graph-cli.d.ts +11 -0
  24. package/dist/workspace/graph-cli.d.ts.map +1 -1
  25. package/dist/workspace/intent-cli.d.ts +2 -1
  26. package/dist/workspace/intent-cli.d.ts.map +1 -1
  27. package/dist/workspace/intent-joins.d.ts +45 -8
  28. package/dist/workspace/intent-joins.d.ts.map +1 -1
  29. package/dist/workspace/intent.d.ts +71 -7
  30. package/dist/workspace/intent.d.ts.map +1 -1
  31. package/dist/workspace/ls.d.ts +31 -1
  32. package/dist/workspace/ls.d.ts.map +1 -1
  33. package/dist/workspace/reason-codes.d.ts +47 -4
  34. package/dist/workspace/reason-codes.d.ts.map +1 -1
  35. package/dist/workspace/record-sessions.d.ts +51 -0
  36. package/dist/workspace/record-sessions.d.ts.map +1 -0
  37. package/dist/workspace/record-source.d.ts +2 -0
  38. package/dist/workspace/record-source.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +71 -4
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-since.d.ts +90 -0
  42. package/dist/workspace/records-since.d.ts.map +1 -0
  43. package/dist/workspace/records-write.d.ts +171 -0
  44. package/dist/workspace/records-write.d.ts.map +1 -0
  45. package/dist/workspace/records.d.ts +244 -15
  46. package/dist/workspace/records.d.ts.map +1 -1
  47. package/dist/workspace/runtimes.d.ts +60 -0
  48. package/dist/workspace/runtimes.d.ts.map +1 -0
  49. package/dist/workspace/status-gates.d.ts +90 -0
  50. package/dist/workspace/status-gates.d.ts.map +1 -0
  51. package/dist/workspace/status.d.ts +17 -0
  52. package/dist/workspace/status.d.ts.map +1 -1
  53. package/dist/workspace/trust/seal.d.ts +85 -0
  54. package/dist/workspace/trust/seal.d.ts.map +1 -0
  55. package/dist/workspace/trust/ssh-commit.d.ts +7 -0
  56. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  57. package/dist/workspace/work.d.ts +56 -0
  58. package/dist/workspace/work.d.ts.map +1 -0
  59. package/package.json +19 -1
  60. package/src/cli/main.ts +55 -3
  61. package/src/cli/registry.ts +13 -1
  62. package/src/lifecycle/gate-ledger.ts +14 -0
  63. package/src/workspace/__fixtures__/sessions.ts +66 -0
  64. package/src/workspace/checks/records.ts +19 -0
  65. package/src/workspace/checks.test.ts +2 -0
  66. package/src/workspace/checks.ts +7 -1
  67. package/src/workspace/composites.schema.json +65 -3
  68. package/src/workspace/composites.test.ts +95 -5
  69. package/src/workspace/composites.ts +28 -7
  70. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  71. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  72. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  73. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +376 -0
  74. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  75. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  76. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  77. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  78. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  79. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  80. package/src/workspace/conformance/conformance.test.ts +149 -0
  81. package/src/workspace/conformance/index.mjs +31 -0
  82. package/src/workspace/conformance/index.ts +453 -0
  83. package/src/workspace/conformance/vitest.ts +62 -0
  84. package/src/workspace/declaration.schema.json +40 -0
  85. package/src/workspace/declaration.ts +62 -0
  86. package/src/workspace/declared-kinds.test.ts +321 -0
  87. package/src/workspace/declared-kinds.ts +76 -0
  88. package/src/workspace/graph-cli.ts +8 -0
  89. package/src/workspace/intent-cli.ts +29 -6
  90. package/src/workspace/intent-gaps.test.ts +217 -0
  91. package/src/workspace/intent-joins.test.ts +60 -0
  92. package/src/workspace/intent-joins.ts +71 -19
  93. package/src/workspace/intent.schema.json +304 -7
  94. package/src/workspace/intent.test.ts +99 -0
  95. package/src/workspace/intent.ts +365 -46
  96. package/src/workspace/ls.schema.json +34 -0
  97. package/src/workspace/ls.ts +69 -4
  98. package/src/workspace/read-contract.test.ts +30 -9
  99. package/src/workspace/reason-codes.test.ts +16 -4
  100. package/src/workspace/reason-codes.ts +55 -4
  101. package/src/workspace/record-assets.test.ts +3 -1
  102. package/src/workspace/record-sessions.ts +105 -0
  103. package/src/workspace/record-source.ts +14 -5
  104. package/src/workspace/records-amend.schema.json +167 -0
  105. package/src/workspace/records-cli.ts +308 -19
  106. package/src/workspace/records-contract.test.ts +57 -2
  107. package/src/workspace/records-formats.test.ts +640 -0
  108. package/src/workspace/records-new.schema.json +158 -0
  109. package/src/workspace/records-quorum.test.ts +196 -0
  110. package/src/workspace/records-review.schema.json +227 -0
  111. package/src/workspace/records-sessions.test.ts +108 -0
  112. package/src/workspace/records-since.schema.json +193 -0
  113. package/src/workspace/records-since.test.ts +174 -0
  114. package/src/workspace/records-since.ts +259 -0
  115. package/src/workspace/records-write-contract.test.ts +125 -0
  116. package/src/workspace/records-write.test.ts +373 -0
  117. package/src/workspace/records-write.ts +765 -0
  118. package/src/workspace/records.schema.json +202 -9
  119. package/src/workspace/records.ts +700 -41
  120. package/src/workspace/runtimes.ts +107 -0
  121. package/src/workspace/status-contract.test.ts +163 -0
  122. package/src/workspace/status-gates.ts +215 -0
  123. package/src/workspace/status.schema.json +69 -3
  124. package/src/workspace/status.ts +35 -2
  125. package/src/workspace/trust/seal.test.ts +232 -0
  126. package/src/workspace/trust/seal.ts +195 -0
  127. package/src/workspace/trust/ssh-commit.ts +2 -2
  128. package/src/workspace/work.test.ts +390 -0
  129. package/src/workspace/work.ts +163 -0
@@ -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. chant emits the
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 { PluginCode, ReasonCode } from "./reason-codes";
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
- export type IntentNode = RegionNode | FileNode | MemberNode | CommitNode | JoinedNode | EvidenceEntryNode | DecisionNode | ArtifactNode | LinkNode | FindingNode;
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
- /** Kind files: record kinds, plugins with `commitJoins`, or both. Relative to `cwd`. */
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`: the namespace of its plugin findings. */
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
- const kinds = await loadKinds(query, top);
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 links = v.data?.[kind.supersedes.field];
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 list = field ? v.data?.[field] : undefined;
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"],