@intentius/chant 0.82.0 → 0.83.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 (57) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/content-digest.d.ts +2 -0
  3. package/dist/content-digest.d.ts.map +1 -1
  4. package/dist/workspace/checks/records.d.ts +33 -0
  5. package/dist/workspace/checks/records.d.ts.map +1 -0
  6. package/dist/workspace/checks.d.ts +8 -0
  7. package/dist/workspace/checks.d.ts.map +1 -1
  8. package/dist/workspace/compose-graph.d.ts +22 -6
  9. package/dist/workspace/compose-graph.d.ts.map +1 -1
  10. package/dist/workspace/graph-cli.d.ts +11 -4
  11. package/dist/workspace/graph-cli.d.ts.map +1 -1
  12. package/dist/workspace/lineage-check.d.ts +5 -2
  13. package/dist/workspace/lineage-check.d.ts.map +1 -1
  14. package/dist/workspace/lineage-init.d.ts.map +1 -1
  15. package/dist/workspace/lineage-lock.d.ts +8 -0
  16. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  17. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  18. package/dist/workspace/reason-codes.d.ts +4 -0
  19. package/dist/workspace/reason-codes.d.ts.map +1 -1
  20. package/dist/workspace/record-assets.d.ts +108 -0
  21. package/dist/workspace/record-assets.d.ts.map +1 -0
  22. package/dist/workspace/records-cli.d.ts +32 -1
  23. package/dist/workspace/records-cli.d.ts.map +1 -1
  24. package/dist/workspace/records.d.ts +41 -0
  25. package/dist/workspace/records.d.ts.map +1 -1
  26. package/dist/workspace/template-pins.d.ts +33 -0
  27. package/dist/workspace/template-pins.d.ts.map +1 -0
  28. package/dist/workspace/tree.d.ts +5 -0
  29. package/dist/workspace/tree.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/cli/main.ts +12 -6
  32. package/src/content-digest.ts +5 -0
  33. package/src/workspace/checks/records.ts +83 -0
  34. package/src/workspace/checks.test.ts +2 -0
  35. package/src/workspace/checks.ts +13 -3
  36. package/src/workspace/compose-graph.test.ts +2 -1
  37. package/src/workspace/compose-graph.ts +23 -6
  38. package/src/workspace/graph-cli.ts +32 -6
  39. package/src/workspace/graph-contract.test.ts +2 -1
  40. package/src/workspace/graph.schema.json +131 -2
  41. package/src/workspace/lineage-check.ts +22 -5
  42. package/src/workspace/lineage-init.ts +5 -1
  43. package/src/workspace/lineage-lock.ts +7 -0
  44. package/src/workspace/lineage-upgrade.test.ts +29 -0
  45. package/src/workspace/lineage-upgrade.ts +21 -3
  46. package/src/workspace/member-commands.ts +1 -1
  47. package/src/workspace/reason-codes.test.ts +2 -1
  48. package/src/workspace/reason-codes.ts +5 -0
  49. package/src/workspace/record-assets.test.ts +319 -0
  50. package/src/workspace/record-assets.ts +207 -0
  51. package/src/workspace/records-cli.ts +117 -14
  52. package/src/workspace/records.schema.json +33 -1
  53. package/src/workspace/records.test.ts +50 -0
  54. package/src/workspace/records.ts +115 -5
  55. package/src/workspace/template-pins.test.ts +69 -0
  56. package/src/workspace/template-pins.ts +117 -0
  57. package/src/workspace/tree.ts +12 -0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Re-pinning records after template parameters are substituted (#2549, #2627).
3
+ *
4
+ * A template's decision records may pin a file of the template by hash. When
5
+ * `chant init --from` or `chant workspace upgrade` fills that file's
6
+ * `{{chant:<name>}}` placeholders, the copy's bytes differ from the
7
+ * template's, and every copy would report the pin as drifted on day one. So
8
+ * after substitution, each pin that held in the template and names a
9
+ * substituted file gets the hash of the substituted content.
10
+ *
11
+ * A record is any Markdown file whose front matter has an `evidence` list
12
+ * with path pins. Its paths resolve from the nearest directory above it that
13
+ * holds a workspace declaration, as `chant workspace records` resolves them.
14
+ * A pin that did not hold in the template stays as it is: re-pinning follows
15
+ * the substitution and never hides drift the template already had. Only the
16
+ * `sha256` value on the pin's line changes; the rest of the file is kept byte
17
+ * for byte.
18
+ */
19
+ /** A record whose pins were rewritten: its path in the template, and the pinned paths, from its workspace root. */
20
+ export interface RepinnedRecord {
21
+ record: string;
22
+ paths: string[];
23
+ }
24
+ /**
25
+ * Rewrite, in `substituted`, the pins of every record that name a file in
26
+ * `changed` and held against `original`. Returns the files with the records
27
+ * rewritten, and which records were.
28
+ */
29
+ export declare function repinSubstituted(original: ReadonlyMap<string, Buffer>, substituted: ReadonlyMap<string, Buffer>, changed: readonly string[]): {
30
+ files: Map<string, Buffer>;
31
+ repinned: RepinnedRecord[];
32
+ };
33
+ //# sourceMappingURL=template-pins.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"template-pins.d.ts","sourceRoot":"","sources":["../../src/workspace/template-pins.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AASH,mHAAmH;AACnH,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAYD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACrC,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACxC,OAAO,EAAE,SAAS,MAAM,EAAE,GACzB;IAAE,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,QAAQ,EAAE,cAAc,EAAE,CAAA;CAAE,CA+B5D"}
@@ -17,6 +17,11 @@ export interface WorkspaceTree {
17
17
  }[] | undefined;
18
18
  /** The text of the file at `path`. Throws when it can't be read. */
19
19
  read(path: string): string;
20
+ /**
21
+ * The bytes of the file at `path`, for hashing (#2549). Throws when it can't
22
+ * be read. A tree without it is read as UTF-8 text.
23
+ */
24
+ bytes?(path: string): Uint8Array;
20
25
  }
21
26
  /** Join tree-relative path parts, leaving out `""` and `"."`. */
22
27
  export declare function joinPath(...parts: string[]): string;
@@ -1 +1 @@
1
- {"version":3,"file":"tree.d.ts","sourceRoot":"","sources":["../../src/workspace/tree.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,MAAM,WAAW,aAAa;IAC5B,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;IACd,yDAAyD;IACzD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,KAAK,GAAG,SAAS,CAAC;IAC/C,kGAAkG;IAClG,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAA;KAAE,EAAE,GAAG,SAAS,CAAC;IACzE,oEAAoE;IACpE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;CAC5B;AAED,iEAAiE;AACjE,wBAAgB,QAAQ,CAAC,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAEnD;AAED,qCAAqC;AACrC,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAqCvD;AAMD,6EAA6E;AAC7E,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMtD;AAED,+EAA+E;AAC/E,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAO1E;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,SAAK,GAAG,aAAa,CA+B/E;AAED,qDAAqD;AACrD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD"}
1
+ {"version":3,"file":"tree.d.ts","sourceRoot":"","sources":["../../src/workspace/tree.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAMH,MAAM,WAAW,aAAa;IAC5B,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;IACd,yDAAyD;IACzD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,KAAK,GAAG,SAAS,CAAC;IAC/C,kGAAkG;IAClG,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,KAAK,CAAA;KAAE,EAAE,GAAG,SAAS,CAAC;IACzE,oEAAoE;IACpE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B;;;OAGG;IACH,KAAK,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAAC;CAClC;AAED,iEAAiE;AACjE,wBAAgB,QAAQ,CAAC,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAEnD;AAED,qCAAqC;AACrC,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAwCvD;AAMD,6EAA6E;AAC7E,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMtD;AAED,+EAA+E;AAC/E,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAO1E;AAED;;;GAGG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,SAAK,GAAG,aAAa,CAmC/E;AAED,qDAAqD;AACrD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.82.0",
3
+ "version": "0.83.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
package/src/cli/main.ts CHANGED
@@ -360,7 +360,7 @@ export function parseArgs(args: string[]): ParsedArgs {
360
360
  } else if (arg === "--at") {
361
361
  result.at = args[++i];
362
362
  } else if (arg === "--kind") {
363
- // `chant workspace records --kind <kind file>` (#2546)
363
+ // `chant workspace records|graph|check --kind <kind file>` (#2546, #2549)
364
364
  result.kind = args[++i];
365
365
  if (!result.kind || result.kind.startsWith("-")) throw new Error("--kind needs a kind file: --kind <path>");
366
366
  } else if (arg === "--current") {
@@ -719,7 +719,11 @@ Workspace (level 1, #2524):
719
719
  Each record reports its provenance level, judged by
720
720
  the signers at --base (default: the target branch);
721
721
  --require attested exits 2 if any record is not
722
- attested
722
+ attested. A pinned file that changed is a warning,
723
+ asset-drift or asset-missing
724
+ workspace records pin <path>
725
+ Print the path from the workspace root and the
726
+ sha256 of a file, for a decision's evidence pin
723
727
  workspace verify [--base <rev>] [--head <rev>] [--require attested]
724
728
  Check the commits in base..head against the signers
725
729
  and roles read from base. A change to the signers file
@@ -737,12 +741,13 @@ Workspace (level 1, #2524):
737
741
  lint and workspace check there, then gate on the digest
738
742
  of the patch (chant approve workspace-upgrade <scope>).
739
743
  A second run with the approval applies the patch
740
- workspace check [--at <rev>] [--json] [--format stylish|json|sarif] [--generated]
744
+ workspace check [--at <rev>] [--json] [--format stylish|json|sarif] [--generated] [--kind <kind file>]
741
745
  Fail on an unreadable lineage lock or an open manual
742
746
  step, and, in a declared workspace, on a WSP check of
743
747
  the declaration, member ledgers, pipelines or
744
748
  generated files. --generated runs declared generators
745
- and compares their output. Needs no workspace file.
749
+ and compares their output. --kind warns on records
750
+ whose pinned files changed. Needs no workspace file.
746
751
  --at reads a commit's git objects; --format json
747
752
  prints the read-contract document
748
753
  workspace build [dir] [--member <name>] [-o <dir>] [--dry-run]
@@ -756,11 +761,12 @@ Workspace (level 1, #2524):
756
761
  workspace audit [dir] [--json] [--member <name>]
757
762
  Audit each chant member with its own .chant-audit.json;
758
763
  every finding carries a member field
759
- workspace graph [dir] [--at <rev>] [--member <name>] [-o <file>]
764
+ workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>]
760
765
  Compose each chant member's chant graph into one IR,
761
766
  with <member>/<id> ids and groups.byMember: the
762
767
  read-contract document. --at <rev> runs each member's
763
- source as it was at that commit
768
+ source as it was at that commit; --kind adds the
769
+ records' asset and constrains links
764
770
 
765
771
  Lifecycle (alias: lc):
766
772
  lifecycle snapshot <env> Query API, save metadata to orphan branch
@@ -19,3 +19,8 @@ import { createHash } from "node:crypto";
19
19
  export function contentDigest(input: string): string {
20
20
  return `sha256:${createHash("sha256").update(input, "utf8").digest("hex")}`;
21
21
  }
22
+
23
+ /** Lowercase hex SHA-256 of `bytes`, with no prefix: the form a decision's evidence pin holds (#2549). */
24
+ export function sha256Hex(bytes: Uint8Array): string {
25
+ return createHash("sha256").update(bytes).digest("hex");
26
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The record checks, WSP111 to WSP114 (#2549; #2524 D4, D18).
3
+ *
4
+ * With `--kind <kind file>`, `chant workspace check` reads the records the
5
+ * kind locates, in the tree it checks, and reports the files they pin by hash
6
+ * that have changed or gone since. A decision stays valid when its evidence
7
+ * drifts, so drift is a warning: it asks for the decision to be looked at
8
+ * again, and the declaration's `checks` can raise it to an error.
9
+ *
10
+ * | Id | Finds |
11
+ * |---|---|
12
+ * | WSP111 | a pinned file whose bytes no longer hash to the pinned sha256 |
13
+ * | WSP112 | a pinned file that does not exist |
14
+ * | WSP113 | a pinned file that a superseding record pins at the old hash: the artifact did not follow the decision |
15
+ * | WSP114 | the records of `--kind` can't be read (fixed) |
16
+ */
17
+
18
+ import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
19
+ import type { ReadErrorCode, RecordEntry } from "../records";
20
+
21
+ /** The records `check --kind` read, or why they could not be read. */
22
+ export type RecordFacts =
23
+ | { kind: string; records: readonly (RecordEntry & { file: string })[] }
24
+ | { kind: string; error: { code: ReadErrorCode; message: string } };
25
+
26
+ function warningFindings(check: WorkspaceCheck, facts: RecordFacts | undefined, code: "asset-drift" | "asset-missing" | "asset-stale"): WorkspaceDiagnostic[] {
27
+ if (!facts || "error" in facts) return [];
28
+ const out: WorkspaceDiagnostic[] = [];
29
+ for (const r of facts.records) {
30
+ // A superseded record's evidence no longer backs anything current.
31
+ if (r.supersededBy !== null) continue;
32
+ for (const w of r.warnings) {
33
+ if (w.code !== code) continue;
34
+ out.push({ checkId: check.id, severity: check.severity, message: `${r.id ?? r.path}: ${w.message}`, pointer: "", file: r.file });
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ export const RECORD_CHECKS: readonly WorkspaceCheck[] = [
41
+ {
42
+ id: "WSP111",
43
+ name: "record-asset-drift",
44
+ description: "Every file a current record pins by hash still hashes to the pinned sha256. A changed file asks for the record to be revisited.",
45
+ severity: "warning",
46
+ configurable: true,
47
+ check(ctx) {
48
+ return warningFindings(this, ctx.facts?.records, "asset-drift");
49
+ },
50
+ },
51
+ {
52
+ id: "WSP112",
53
+ name: "record-asset-missing",
54
+ description: "Every file a current record pins by hash exists.",
55
+ severity: "warning",
56
+ configurable: true,
57
+ check(ctx) {
58
+ return warningFindings(this, ctx.facts?.records, "asset-missing");
59
+ },
60
+ },
61
+ {
62
+ id: "WSP113",
63
+ name: "record-asset-stale",
64
+ description: "No current record pins a file at the same hash as a record it supersedes while the file is unchanged since: when a decision changes, the artifacts it rests on follow.",
65
+ severity: "warning",
66
+ configurable: true,
67
+ check(ctx) {
68
+ return warningFindings(this, ctx.facts?.records, "asset-stale");
69
+ },
70
+ },
71
+ {
72
+ id: "WSP114",
73
+ name: "records-unreadable",
74
+ description: "The records of the kind named with --kind can be read.",
75
+ severity: "error",
76
+ configurable: false,
77
+ check(ctx) {
78
+ const facts = ctx.facts?.records;
79
+ if (!facts || !("error" in facts)) return [];
80
+ return [{ checkId: this.id, severity: this.severity, message: `--kind ${facts.kind}: ${facts.error.code}: ${facts.error.message}`, pointer: "" }];
81
+ },
82
+ },
83
+ ];
@@ -56,6 +56,8 @@ describe("the WSP catalog", () => {
56
56
  "flat-ledger-environment-shared",
57
57
  "link-target-unknown",
58
58
  "link-kind-unknown",
59
+ // --kind names a record kind, and records that can't be read check nothing (#2549).
60
+ "records-unreadable",
59
61
  ]);
60
62
  });
61
63
 
@@ -26,6 +26,7 @@
26
26
  * | WSP081 to WSP083 | recorded pipelines |
27
27
  * | WSP091 to WSP097 | member links (#2539) |
28
28
  * | WSP101 to WSP106 | generated files |
29
+ * | WSP111 to WSP114 | records read with `--kind` (#2549) |
29
30
  */
30
31
 
31
32
  import { realpathSync } from "node:fs";
@@ -42,6 +43,7 @@ import type { LinkTableRow } from "./links";
42
43
  import { gatherGeneratedFacts, GENERATED_CHECKS, type GeneratedFileFacts } from "./checks/generated";
43
44
  import { gatherLedgerFacts, LEDGER_CHECKS, type MemberLedgerFacts } from "./checks/ledgers";
44
45
  import { gatherPipelineFacts, PIPELINE_CHECKS, type MemberPipelineFacts } from "./checks/pipelines";
46
+ import { RECORD_CHECKS, type RecordFacts } from "./checks/records";
45
47
 
46
48
  /**
47
49
  * What the checks beyond the declaration read, gathered from the checkout
@@ -55,6 +57,8 @@ export interface WorkspaceFacts {
55
57
  pipelines?: readonly MemberPipelineFacts[];
56
58
  /** Each declared or implicit generated file, with what its generator produced. */
57
59
  generated?: readonly GeneratedFileFacts[];
60
+ /** The records of the kind named with `--kind`, with their pins checked (#2549). */
61
+ records?: RecordFacts;
58
62
  }
59
63
 
60
64
  /** What every declaration check reads. */
@@ -77,6 +81,8 @@ export interface WorkspaceCheckContext {
77
81
  export interface WorkspaceDiagnostic extends PostSynthDiagnostic {
78
82
  /** A JSON Pointer into the declaration. */
79
83
  pointer: string;
84
+ /** An absolute path, for a finding about a file other than the declaration, such as a record (#2549). It is reported at its first line. */
85
+ file?: string;
80
86
  }
81
87
 
82
88
  export interface WorkspaceCheck {
@@ -295,6 +301,8 @@ export const WORKSPACE_CHECKS: readonly WorkspaceCheck[] = [
295
301
  ...LINK_CHECKS,
296
302
  // Generated files (#2541).
297
303
  ...GENERATED_CHECKS,
304
+ // Records read with --kind (#2549).
305
+ ...RECORD_CHECKS,
298
306
  ];
299
307
 
300
308
  const BY_ID = new Map(WORKSPACE_CHECKS.map((c) => [c.id, c]));
@@ -370,6 +378,8 @@ export interface DeclarationCheckOptions {
370
378
  * gathered: they describe the working tree, not the revision.
371
379
  */
372
380
  tree?: WorkspaceTree;
381
+ /** The records read with `--kind`, in the same tree (#2549). Given with or without `tree`. */
382
+ records?: RecordFacts;
373
383
  }
374
384
 
375
385
  /**
@@ -433,7 +443,8 @@ export async function runDeclarationChecks(
433
443
  return { file: display(location.file), diagnostics: [diagnostic], suppressed: [], links: [], ok: false };
434
444
  }
435
445
  const { registry, problems } = loadKindRegistry(declaration.pins, root);
436
- const facts = options.gather === false || options.tree ? {} : await gatherWorkspaceFacts(root, declaration, options);
446
+ const gathered = options.gather === false || options.tree ? {} : await gatherWorkspaceFacts(root, declaration, options);
447
+ const facts: WorkspaceFacts = options.records ? { ...gathered, records: options.records } : gathered;
437
448
  const ctx: WorkspaceCheckContext = { declaration, tree, groups, kinds: registry, kindProblems: problems, facts };
438
449
  const findings = runWorkspaceChecks(ctx);
439
450
  const { active, suppressed } = applyCheckSettings(declaration, findings);
@@ -442,8 +453,7 @@ export async function runDeclarationChecks(
442
453
  const locate = (pointer: string): TextLocation => (parsed.ok ? parsed.locate(pointer) : { line: 1, column: 1 });
443
454
  const file = display(declaration.file);
444
455
  const toFinding = (d: WorkspaceDiagnostic): WorkspaceFinding => ({
445
- file,
446
- ...locate(d.pointer),
456
+ ...(d.file !== undefined ? { file: display(relative(root, d.file).split(sep).join("/")), line: 1, column: 1 } : { file, ...locate(d.pointer) }),
447
457
  ruleId: d.checkId,
448
458
  severity: d.severity,
449
459
  message: d.message,
@@ -7,6 +7,7 @@ import { describe, expect, test } from "vitest";
7
7
  import { GRAPH_IR_VERSION, type GraphIR } from "../graph-ir";
8
8
  import { parseDeclaration } from "./declaration";
9
9
  import { composeWorkspaceGraph, readMemberIr, WORKSPACE_GRAPH_VERSION, type ComposedMember } from "./compose-graph";
10
+ import type { LinkTableRow } from "./links";
10
11
 
11
12
  const member = (name: string, dir: string): ComposedMember => ({
12
13
  name,
@@ -136,7 +137,7 @@ describe("the links section (#2539)", () => {
136
137
  ];
137
138
  const doc = composeWorkspaceGraph({ name: "acme", root: "/w" }, inputs, { declaration });
138
139
  expect(
139
- doc.links.map((r) => (r.status === "ambiguous" ? "" : `${r.origin} ${r.label} ${r.from ?? r.consumer} -> ${r.to}`)),
140
+ (doc.links as LinkTableRow[]).map((r) => (r.status === "ambiguous" ? "" : `${r.origin} ${r.label} ${r.from ?? r.consumer} -> ${r.to}`)),
140
141
  ).toEqual([
141
142
  "declared exact app -> web/Bucket",
142
143
  "inferred:joinKey folded jobs/BUCKET_ARN -> web/Bucket",
@@ -16,8 +16,9 @@
16
16
  * - `links` holds the member links (#2524 D6, #2539): the declared links and
17
17
  * the joins inferred from members' `imports` and `exports` with the core
18
18
  * `joinKey()`, labelled `exact` or `folded` (`./links.ts`). A declared link
19
- * suppresses the inferred edge it covers. `records` is the section record
20
- * kinds fill (#2524 D4), empty so far.
19
+ * suppresses the inferred edge it covers. `records` holds the records
20
+ * read through a record kind (`--kind`, #2549), and their `asset` and
21
+ * `constrains` links follow the member links in `links`.
21
22
  *
22
23
  * A member's IR with no `version` field comes from a chant older than #2529.
23
24
  * It is version 1, and it is upgraded in place by stamping that version. An IR
@@ -31,6 +32,7 @@
31
32
  import type { Declaration } from "./declaration";
32
33
  import type { KindRegistry } from "./kinds";
33
34
  import { graphLinks, type LinkTableRow } from "./links";
35
+ import type { RecordLinkRow } from "./record-assets";
34
36
  import type { ReasonCode } from "./reason-codes";
35
37
  import { GRAPH_IR_VERSION, type GraphIR, type IRExport, type IRGroups, type IRImport, type IREdge, type IRNode } from "../graph-ir";
36
38
 
@@ -92,10 +94,25 @@ export interface WorkspaceGraph {
92
94
  exports: (IRExport & { member: string })[];
93
95
  imports: (IRImport & { member: string })[];
94
96
  derivedAttrs?: Record<string, string[]>;
95
- /** Member links (#2524 D6, #2539), declared and inferred. Empty when no declaration is given. */
96
- links: LinkTableRow[];
97
- /** Record sections (#2524 D4). Empty until record kinds join the graph. */
98
- records: unknown[];
97
+ /**
98
+ * Member links (#2524 D6, #2539), declared and inferred, then the links of
99
+ * the records read with `--kind` (#2549). Empty when no declaration is given.
100
+ */
101
+ links: (LinkTableRow | RecordLinkRow)[];
102
+ /** The records read with `--kind` (#2524 D4, #2549). Empty without it. */
103
+ records: GraphRecord[];
104
+ }
105
+
106
+ /** A record in the composed graph: enough to name it from a link row. */
107
+ export interface GraphRecord {
108
+ /** The record kind's name, such as `decision`. */
109
+ kind: string;
110
+ id: string | null;
111
+ /** From the repository root. */
112
+ path: string;
113
+ state: string | null;
114
+ valid: boolean;
115
+ supersededBy: string | null;
99
116
  }
100
117
 
101
118
  /** Read one member's `chant graph --format ir` output, upgrading an unversioned (v1) IR in place. */
@@ -1,8 +1,9 @@
1
1
  /**
2
- * `chant workspace graph [dir] [--at <rev>] [--member <name>] [-o <file>]
3
- * [--env <env>] [--dry-run]` (#2537, #2536): every `chant` member's IR, read
4
- * through the member's own toolchain and composed into one document
5
- * (`compose-graph.ts`).
2
+ * `chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>]
3
+ * [-o <file>] [--env <env>] [--dry-run]` (#2537, #2536): every `chant`
4
+ * member's IR, read through the member's own toolchain and composed into one
5
+ * document (`compose-graph.ts`). With `--kind`, the records of that kind and
6
+ * their asset and constrains links join it (#2549, `record-assets.ts`).
6
7
  *
7
8
  * The document is part of the read contract, described by `graph.schema.json`
8
9
  * beside this file. It is printed for a failure too, with the error's reason
@@ -28,6 +29,8 @@ import { composeWorkspaceGraph, readMemberIr, type ComposeInput, type WorkspaceG
28
29
  import { readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
29
30
  import { describePlan, emitDocument, executePlan, memberStatus, planJson, planMembers, type MemberPlan, type Toolchain, type UnitResult } from "./member-commands";
30
31
  import { loadKindRegistry } from "./kinds";
32
+ import { recordLinkRows } from "./record-assets";
33
+ import { RecordReadError } from "./records";
31
34
  import { workingTree } from "./tree";
32
35
  import { handToRootChant, locateWorkspace, type LocatedWorkspace } from "./which-chant";
33
36
 
@@ -40,7 +43,7 @@ export const GRAPH_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/worksp
40
43
  /** Why the graph couldn't be read at all: the declaration's codes, `--at`'s included. */
41
44
  export const GRAPH_ERROR_CODES = WORKSPACE_ERROR_CODES;
42
45
 
43
- const USAGE = "chant workspace graph [dir] [--at <rev>] [--member <name>] [-o <file>] [--env <env>] [--dry-run]";
46
+ const USAGE = "chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run]";
44
47
 
45
48
  interface Head {
46
49
  $schema: string;
@@ -64,6 +67,12 @@ export interface GraphQuery {
64
67
  reader?: Toolchain;
65
68
  /** Called with each member's stderr, so the command can pass it on. */
66
69
  onStderr?: (text: string) => void;
70
+ /**
71
+ * A record kind file, absolute or relative to `cwd` (#2549). Its records
72
+ * fill `records`, and their asset pins and `constrains` entries become
73
+ * rows of `links`.
74
+ */
75
+ kind?: string;
67
76
  }
68
77
 
69
78
  export interface GraphResult {
@@ -171,7 +180,23 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
171
180
  // Links (#2539) resolve against the declaration that was read, the revision's for --at, and the kinds installed now.
172
181
  const kinds = loadKindRegistry(declaration.pins, located.rootOnDisk).registry;
173
182
  const graph = composeWorkspaceGraph({ name: declaration.name, root: located.root }, inputs, { declaration, kinds });
174
- return { doc: { ...head, at: located.at, ...graph }, failed };
183
+ let recordsFailed = false;
184
+ if (query.kind !== undefined) {
185
+ // Artifact relationships come from records (#2549): a record's pins and
186
+ // what it constrains, read at the same revision as the declaration.
187
+ const { readRecordsFor } = await import("./records-cli");
188
+ try {
189
+ const read = await readRecordsFor({ kind: query.kind, cwd: query.cwd, at: query.at });
190
+ const name = read.loaded.kind.name;
191
+ graph.records = read.result.records.map((r) => ({ kind: name, id: r.id, path: r.path, state: r.state, valid: r.valid, supersededBy: r.supersededBy }));
192
+ graph.links.push(...recordLinkRows(name, read.result.records, read.loaded.kind.constrains?.field, located.tree, declaration.members));
193
+ } catch (err) {
194
+ if (!(err instanceof RecordReadError)) throw err;
195
+ recordsFailed = true;
196
+ query.onStderr?.(`${formatError({ message: `--kind ${query.kind}: ${err.code}: ${err.message}`, hint: USAGE })}\n`);
197
+ }
198
+ }
199
+ return { doc: { ...head, at: located.at, ...graph }, failed: failed || recordsFailed };
175
200
  } catch (err) {
176
201
  if (!(err instanceof WorkspaceReadError)) throw err;
177
202
  return { doc: { ...head, error: { code: err.code, message: err.message, location: err.location ?? null } }, failed: true };
@@ -209,6 +234,7 @@ export async function runWorkspaceGraph(ctx: CommandContext): Promise<number> {
209
234
  at: args.at,
210
235
  members: args.members,
211
236
  args,
237
+ ...(args.kind !== undefined ? { kind: resolve(args.kind) } : {}),
212
238
  onStderr: (text) => process.stderr.write(text),
213
239
  });
214
240
  emitDocument(doc, args.output);
@@ -18,6 +18,7 @@ import { cleanScratch, commitAll, contract, declaration, declaration as declarat
18
18
  import type { GraphIR } from "../graph-ir";
19
19
  import { composeWorkspaceGraph, MEMBER_RUN_REASON_CODES } from "./compose-graph";
20
20
  import { parseDeclaration, WORKSPACE_ERROR_CODES } from "./declaration";
21
+ import type { LinkTableRow } from "./links";
21
22
  import { GRAPH_CONTRACT_VERSION, GRAPH_ERROR_CODES, GRAPH_OUTPUT_SCHEMA_ID, workspaceGraph, type GraphDocument } from "./graph-cli";
22
23
  import schema from "./graph.schema.json";
23
24
 
@@ -101,7 +102,7 @@ describe("the links section (#2539)", () => {
101
102
  );
102
103
  const doc = { $schema: GRAPH_OUTPUT_SCHEMA_ID, contract: 1, chant: "0.81.0", at: null, ...graph };
103
104
  expectValid(doc);
104
- expect(doc.links.map((r) => `${r.consumer} ${r.origin} ${r.status}`)).toEqual(["app declared resolved", "app declared missing", "jobs inferred:joinKey ambiguous"]);
105
+ expect((doc.links as LinkTableRow[]).map((r) => `${r.consumer} ${r.origin} ${r.status}`)).toEqual(["app declared resolved", "app declared missing", "jobs inferred:joinKey ambiguous"]);
105
106
  });
106
107
  });
107
108
 
@@ -218,14 +218,17 @@
218
218
  },
219
219
  "links": {
220
220
  "type": "array",
221
- "description": "Member links (#2524 D6, #2539): the ones declared on a consumer and the ones inferred from join keys, resolved against the composed graph. A reader draws a row from its `from` node to its `to` node when both are set.",
221
+ "description": "Member links (#2524 D6, #2539): the ones declared on a consumer and the ones inferred from join keys, resolved against the composed graph. A reader draws a row from its `from` node to its `to` node when both are set. With --kind, the links of the records read follow (#2549): an `asset` row for each file a record pins by hash, and a `constrains` row for each member or path it governs. These rows have `record` and `target` instead of `consumer` and `producer`.",
222
222
  "items": {
223
223
  "$ref": "#/$defs/link"
224
224
  }
225
225
  },
226
226
  "records": {
227
227
  "type": "array",
228
- "description": "Record sections (#2524 D4). Empty until record kinds join the graph."
228
+ "description": "The records read with --kind (#2524 D4, #2549), sorted by path; empty without it. A record link in `links` names its record by id.",
229
+ "items": {
230
+ "$ref": "#/$defs/record"
231
+ }
229
232
  }
230
233
  }
231
234
  },
@@ -655,8 +658,134 @@
655
658
  }
656
659
  }
657
660
  }
661
+ },
662
+ {
663
+ "type": "object",
664
+ "description": "A record link (#2549): from a current record to a file it pins (kind asset) or to a member or path it constrains (kind constrains). Superseded records have none. A reader finds the artifacts behind a file by taking the decisions whose constrains cover it and then their asset rows.",
665
+ "required": [
666
+ "kind",
667
+ "origin",
668
+ "resolves",
669
+ "recordKind",
670
+ "record",
671
+ "recordPath",
672
+ "target",
673
+ "member",
674
+ "status",
675
+ "reason"
676
+ ],
677
+ "properties": {
678
+ "kind": {
679
+ "enum": [
680
+ "asset",
681
+ "constrains"
682
+ ]
683
+ },
684
+ "origin": {
685
+ "const": "declared"
686
+ },
687
+ "resolves": {
688
+ "const": "source"
689
+ },
690
+ "recordKind": {
691
+ "type": "string",
692
+ "description": "The record's kind, such as decision."
693
+ },
694
+ "record": {
695
+ "type": "string",
696
+ "description": "The record's id."
697
+ },
698
+ "recordPath": {
699
+ "type": "string",
700
+ "description": "The record file, from the repository root."
701
+ },
702
+ "target": {
703
+ "type": "string",
704
+ "description": "For asset, the pinned path from the workspace root. For constrains, the entry as written: member:<name> or path:<path>."
705
+ },
706
+ "member": {
707
+ "type": [
708
+ "string",
709
+ "null"
710
+ ],
711
+ "description": "The member the target names or sits in; null when no member does."
712
+ },
713
+ "status": {
714
+ "enum": [
715
+ "pinned",
716
+ "drifted",
717
+ "missing",
718
+ "stale",
719
+ "resolved"
720
+ ],
721
+ "description": "For asset: pinned, drifted, missing or stale, as records reports the pin. For constrains: resolved, or missing when the member is not declared or the path does not exist."
722
+ },
723
+ "reason": {
724
+ "type": [
725
+ "string",
726
+ "null"
727
+ ],
728
+ "description": "Why the row is not pinned or resolved, or null."
729
+ },
730
+ "sha256": {
731
+ "type": "string",
732
+ "pattern": "^[0-9a-f]{64}$",
733
+ "description": "For asset: the pinned hash."
734
+ },
735
+ "actual": {
736
+ "type": [
737
+ "string",
738
+ "null"
739
+ ],
740
+ "pattern": "^[0-9a-f]{64}$",
741
+ "description": "For asset: the hash of the file in the tree read, or null when it is missing."
742
+ }
743
+ }
658
744
  }
659
745
  ]
746
+ },
747
+ "record": {
748
+ "type": "object",
749
+ "required": [
750
+ "kind",
751
+ "id",
752
+ "path",
753
+ "state",
754
+ "valid",
755
+ "supersededBy"
756
+ ],
757
+ "properties": {
758
+ "kind": {
759
+ "type": "string",
760
+ "description": "The record kind's name, such as decision."
761
+ },
762
+ "id": {
763
+ "type": [
764
+ "string",
765
+ "null"
766
+ ]
767
+ },
768
+ "path": {
769
+ "type": "string",
770
+ "description": "The record file, from the repository root, with / separators."
771
+ },
772
+ "state": {
773
+ "type": [
774
+ "string",
775
+ "null"
776
+ ]
777
+ },
778
+ "valid": {
779
+ "type": "boolean",
780
+ "description": "As chant workspace records reports it."
781
+ },
782
+ "supersededBy": {
783
+ "type": [
784
+ "string",
785
+ "null"
786
+ ]
787
+ }
788
+ }
660
789
  }
661
790
  }
662
791
  }