@intentius/chant 0.82.0 → 0.84.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 +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/content-digest.d.ts +2 -0
- package/dist/content-digest.d.ts.map +1 -1
- package/dist/workspace/checks/records.d.ts +33 -0
- package/dist/workspace/checks/records.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +8 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/compose-graph.d.ts +22 -6
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/graph-cli.d.ts +15 -4
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +17 -0
- package/dist/workspace/intent-cli.d.ts.map +1 -0
- package/dist/workspace/intent-joins.d.ts +93 -0
- package/dist/workspace/intent-joins.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +285 -0
- package/dist/workspace/intent.d.ts.map +1 -0
- package/dist/workspace/lineage-check.d.ts +5 -2
- package/dist/workspace/lineage-check.d.ts.map +1 -1
- package/dist/workspace/lineage-init.d.ts.map +1 -1
- package/dist/workspace/lineage-lock.d.ts +8 -0
- package/dist/workspace/lineage-lock.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +19 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts +108 -0
- package/dist/workspace/record-assets.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +32 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +47 -0
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/template-pins.d.ts +33 -0
- package/dist/workspace/template-pins.d.ts.map +1 -0
- package/dist/workspace/tree.d.ts +5 -0
- package/dist/workspace/tree.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/main.test.ts +11 -0
- package/src/cli/main.ts +22 -6
- package/src/cli/registry.ts +4 -0
- package/src/content-digest.ts +5 -0
- package/src/workspace/checks/records.ts +83 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +13 -3
- package/src/workspace/compose-graph.test.ts +2 -1
- package/src/workspace/compose-graph.ts +23 -6
- package/src/workspace/graph-cli.ts +40 -6
- package/src/workspace/graph-contract.test.ts +2 -1
- package/src/workspace/graph.schema.json +131 -2
- package/src/workspace/intent-cli.ts +95 -0
- package/src/workspace/intent-joins.ts +165 -0
- package/src/workspace/intent.schema.json +1078 -0
- package/src/workspace/intent.test.ts +448 -0
- package/src/workspace/intent.ts +941 -0
- package/src/workspace/lineage-check.ts +22 -5
- package/src/workspace/lineage-init.ts +5 -1
- package/src/workspace/lineage-lock.ts +7 -0
- package/src/workspace/lineage-upgrade.test.ts +29 -0
- package/src/workspace/lineage-upgrade.ts +21 -3
- package/src/workspace/member-commands.ts +1 -1
- package/src/workspace/read-contract.test.ts +16 -1
- package/src/workspace/reason-codes.test.ts +7 -2
- package/src/workspace/reason-codes.ts +23 -0
- package/src/workspace/record-assets.test.ts +319 -0
- package/src/workspace/record-assets.ts +207 -0
- package/src/workspace/records-cli.ts +117 -14
- package/src/workspace/records.schema.json +33 -1
- package/src/workspace/records.test.ts +50 -0
- package/src/workspace/records.ts +116 -6
- package/src/workspace/template-pins.test.ts +69 -0
- package/src/workspace/template-pins.ts +117 -0
- package/src/workspace/tree.ts +12 -0
|
@@ -12,12 +12,16 @@
|
|
|
12
12
|
* nothing is inferred (#2525 rule 1).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
15
16
|
import { realpathSync } from "node:fs";
|
|
16
|
-
import { relative } from "node:path";
|
|
17
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
17
18
|
import { formatError } from "../cli/format";
|
|
18
19
|
import type { CommandContext } from "../cli/registry";
|
|
20
|
+
import { findWorkspaceRoot } from "../project-root";
|
|
21
|
+
import { fileDigest, isWorkspacePath } from "./record-assets";
|
|
19
22
|
import { gitRevisionSource, gitRoot, resolveRevision, workingTreeSource } from "./record-source";
|
|
20
|
-
import { loadRecordKind, readRecords, RecordReadError, type ReadErrorCode, type RecordEntry } from "./records";
|
|
23
|
+
import { loadRecordKind, readRecords, RecordReadError, type LoadedRecordKind, type ReadErrorCode, type ReadRecordsResult, type RecordEntry, type RecordHistory } from "./records";
|
|
24
|
+
import { gitTree, workingTree } from "./tree";
|
|
21
25
|
import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
|
|
22
26
|
import { policyAtBase, recordProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./trust/provenance";
|
|
23
27
|
|
|
@@ -27,7 +31,7 @@ export const RECORDS_CONTRACT_VERSION = 1;
|
|
|
27
31
|
/** `$id` of the JSON Schema for the `--json` output, shipped beside this file. */
|
|
28
32
|
export const RECORDS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json";
|
|
29
33
|
|
|
30
|
-
const USAGE = "chant workspace records --kind <kind file> [--current] [--at <rev>] [--base <rev>] [--require attested] [--json]";
|
|
34
|
+
const USAGE = "chant workspace records --kind <kind file> [--current] [--at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records pin <path>";
|
|
31
35
|
|
|
32
36
|
/** Exit code when the read worked and a record falls below `--require`. */
|
|
33
37
|
export const EXIT_BELOW_REQUIRED = 2;
|
|
@@ -63,6 +67,8 @@ export type RecordsDocument =
|
|
|
63
67
|
contract: number;
|
|
64
68
|
kind: { name: string; schema: string; file: string };
|
|
65
69
|
at: string | null;
|
|
70
|
+
/** The directory pinned paths resolve in, from the repository root: the workspace holding the kind file, or the repository root (#2549). */
|
|
71
|
+
workspaceRoot: string;
|
|
66
72
|
current: boolean;
|
|
67
73
|
trust: TrustView;
|
|
68
74
|
records: RecordView[];
|
|
@@ -70,23 +76,84 @@ export type RecordsDocument =
|
|
|
70
76
|
}
|
|
71
77
|
| { $schema: string; contract: number; error: { code: ReadErrorCode; message: string } };
|
|
72
78
|
|
|
73
|
-
/**
|
|
74
|
-
export
|
|
79
|
+
/** A records read, before provenance. */
|
|
80
|
+
export interface RecordsRead {
|
|
81
|
+
loaded: LoadedRecordKind;
|
|
82
|
+
/** The repository root, or the working directory outside git. Record paths are relative to it. */
|
|
83
|
+
root: string;
|
|
84
|
+
/** The git top, or undefined outside git. */
|
|
85
|
+
top: string | undefined;
|
|
86
|
+
at: string | null;
|
|
87
|
+
/** Where pinned paths resolve, relative to `root` ("." for the root itself). */
|
|
88
|
+
workspaceRoot: string;
|
|
89
|
+
result: ReadRecordsResult;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Where a kind's pinned paths resolve: the workspace whose declaration sits
|
|
94
|
+
* nearest above the kind file, when it is inside the repository, or else the
|
|
95
|
+
* repository root. Relative to `root`, with / separators.
|
|
96
|
+
*/
|
|
97
|
+
function pinRoot(kindFile: string, root: string): string {
|
|
98
|
+
const found = findWorkspaceRoot(dirname(kindFile));
|
|
99
|
+
if (!found) return ".";
|
|
100
|
+
const rel = relative(root, realpathOr(found.dir)).split(sep).join("/");
|
|
101
|
+
return rel === "" || rel.startsWith("..") ? "." : rel;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Commit times from the history of `rev` in the repository at `top`, asked
|
|
106
|
+
* only for a pin that might be stale. `prefix` is the workspace root from the
|
|
107
|
+
* repository root.
|
|
108
|
+
*/
|
|
109
|
+
function gitHistory(top: string, rev: string, prefix: string): RecordHistory {
|
|
110
|
+
const times = (args: string[]): number[] => {
|
|
111
|
+
try {
|
|
112
|
+
const out = execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
|
|
113
|
+
return out.split("\n").filter(Boolean).map(Number);
|
|
114
|
+
} catch {
|
|
115
|
+
// No commits yet, or a path git doesn't know.
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
fileChanged: (path) => times(["log", "-1", "--format=%ct", rev, "--", prefix === "." ? path : `${prefix}/${path}`])[0] ?? null,
|
|
121
|
+
// The latest commit that added the file: a record deleted and written again counts from the second time.
|
|
122
|
+
recorded: (path) => times(["log", "--diff-filter=A", "--format=%ct", rev, "--", path])[0] ?? null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Load the kind and read its records, in the working tree or at `query.at`,
|
|
128
|
+
* with each pin checked in the same tree. Throws a {@link RecordReadError}.
|
|
129
|
+
* `chant workspace graph` and `check` read records through this too.
|
|
130
|
+
*/
|
|
131
|
+
export async function readRecordsFor(query: Omit<RecordsQuery, "base">): Promise<RecordsRead> {
|
|
75
132
|
// git reports its top through symlinks resolved (/var is /private/var on
|
|
76
133
|
// macOS), so the directory has to be too, or record paths leave the repository.
|
|
77
134
|
const cwd = realpathOr(query.cwd);
|
|
78
135
|
const top = gitRoot(cwd);
|
|
79
136
|
const root = top ?? cwd;
|
|
137
|
+
const loaded = await loadRecordKind(query.kind, cwd);
|
|
138
|
+
const workspaceRoot = pinRoot(loaded.file, root);
|
|
139
|
+
let at: string | null = null;
|
|
140
|
+
let source = workingTreeSource(root);
|
|
141
|
+
let assets = workingTree(workspaceRoot === "." ? root : join(root, ...workspaceRoot.split("/")));
|
|
142
|
+
if (query.at !== undefined) {
|
|
143
|
+
if (!top) throw new RecordReadError("not-a-git-repository", "--at reads git objects, and this directory is not in a git repository");
|
|
144
|
+
at = resolveRevision(top, query.at);
|
|
145
|
+
source = gitRevisionSource(top, at);
|
|
146
|
+
assets = gitTree(top, at, workspaceRoot === "." ? "" : workspaceRoot);
|
|
147
|
+
}
|
|
148
|
+
const history = top ? gitHistory(top, at ?? "HEAD", workspaceRoot) : undefined;
|
|
149
|
+
const result = await readRecords(loaded, { root, source, current: !!query.current, assets, ...(history ? { history } : {}) });
|
|
150
|
+
return { loaded, root, top, at, workspaceRoot, result };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Run the query and build the document `--json` prints. Never throws a {@link RecordReadError}. */
|
|
154
|
+
export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument> {
|
|
80
155
|
try {
|
|
81
|
-
const loaded = await
|
|
82
|
-
let at: string | null = null;
|
|
83
|
-
let source = workingTreeSource(root);
|
|
84
|
-
if (query.at !== undefined) {
|
|
85
|
-
if (!top) throw new RecordReadError("not-a-git-repository", "--at reads git objects, and this directory is not in a git repository");
|
|
86
|
-
at = resolveRevision(top, query.at);
|
|
87
|
-
source = gitRevisionSource(top, at);
|
|
88
|
-
}
|
|
89
|
-
const result = await readRecords(loaded, { root, source, current: !!query.current });
|
|
156
|
+
const { loaded, root, top, at, workspaceRoot, result } = await readRecordsFor(query);
|
|
90
157
|
// Provenance, judged by the policy at base and never by the tree read (#2547).
|
|
91
158
|
const base = top ? resolveBase(top, query.base) : { commit: null, from: null };
|
|
92
159
|
const policy = top ? policyAtBase(top, base) : policyAtBase(root, base);
|
|
@@ -106,6 +173,7 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
106
173
|
file: relative(root, loaded.file).split("\\").join("/"),
|
|
107
174
|
},
|
|
108
175
|
at,
|
|
176
|
+
workspaceRoot,
|
|
109
177
|
current: !!query.current,
|
|
110
178
|
trust: { base: base.commit, baseFrom: base.from, active: policy.active, signersPath: policy.signersPath, problems: policy.problems },
|
|
111
179
|
records: result.records.map((r) => ({ ...r, provenance: provenance.get(r.path)! })),
|
|
@@ -117,8 +185,42 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
117
185
|
}
|
|
118
186
|
}
|
|
119
187
|
|
|
188
|
+
/**
|
|
189
|
+
* `chant workspace records pin <path>`: the `{path, sha256}` a decision's
|
|
190
|
+
* evidence entry holds for a file, with the path from the workspace root
|
|
191
|
+
* (the nearest declaration above the file, or the repository root).
|
|
192
|
+
*/
|
|
193
|
+
export function pinFile(file: string, cwd: string): { path: string; sha256: string } | { error: string } {
|
|
194
|
+
const abs = realpathOr(resolve(cwd, file));
|
|
195
|
+
const top = gitRoot(dirname(abs));
|
|
196
|
+
const ws = findWorkspaceRoot(dirname(abs));
|
|
197
|
+
const base = ws ? realpathOr(ws.dir) : top ? realpathOr(top) : realpathOr(cwd);
|
|
198
|
+
const path = relative(base, abs).split(sep).join("/");
|
|
199
|
+
if (path.startsWith("..") || !isWorkspacePath(path)) return { error: `${file} is not a file path inside the workspace at ${base}` };
|
|
200
|
+
const sha256 = fileDigest(workingTree(base), path);
|
|
201
|
+
if (sha256 === undefined) return { error: `${file} is not a file` };
|
|
202
|
+
return { path, sha256 };
|
|
203
|
+
}
|
|
204
|
+
|
|
120
205
|
export async function runWorkspaceRecords(ctx: CommandContext): Promise<number> {
|
|
121
206
|
const { args } = ctx;
|
|
207
|
+
if (args.extraPositional === "pin") {
|
|
208
|
+
if (!args.extraPositional2) {
|
|
209
|
+
console.error(formatError({ message: "pin needs the path of a file", hint: USAGE }));
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
const pin = pinFile(args.extraPositional2, process.cwd());
|
|
213
|
+
if ("error" in pin) {
|
|
214
|
+
console.error(formatError({ message: pin.error, hint: USAGE }));
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
console.log(JSON.stringify(pin, null, 2));
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
if (args.extraPositional) {
|
|
221
|
+
console.error(formatError({ message: `chant workspace records takes no argument but pin (got ${args.extraPositional})`, hint: USAGE }));
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
122
224
|
if (!args.kind) {
|
|
123
225
|
console.error(formatError({ message: "--kind <kind file> is required", hint: USAGE }));
|
|
124
226
|
return 1;
|
|
@@ -178,6 +280,7 @@ function formatRecords(records: RecordView[], summary: { total: number; valid: n
|
|
|
178
280
|
const attested = r.provenance.level === "attested" ? ` attested by ${r.provenance.principal}` : "";
|
|
179
281
|
lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${attested}${flag}`);
|
|
180
282
|
for (const reason of r.reasons) lines.push(`${" ".repeat(idWidth + 2)}${reason.code}: ${reason.message} (${r.path})`);
|
|
283
|
+
for (const warning of r.warnings) lines.push(`${" ".repeat(idWidth + 2)}warning ${warning.code}: ${warning.message} (${r.path})`);
|
|
181
284
|
}
|
|
182
285
|
lines.push(
|
|
183
286
|
`${summary.total} records${at ? ` at ${at.slice(0, 8)}` : ""}: ${summary.valid} valid, ${summary.invalid} invalid, ${summary.superseded} superseded`,
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"type": ["string", "null"],
|
|
27
27
|
"pattern": "^[0-9a-f]{40,64}$"
|
|
28
28
|
},
|
|
29
|
+
"workspaceRoot": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "Added in contract 1 by #2549. The directory a record's pinned paths resolve in, from the repository root with / separators: the workspace whose declaration sits nearest above the kind file, or \".\" for the repository root."
|
|
32
|
+
},
|
|
29
33
|
"error": false,
|
|
30
34
|
"current": { "type": "boolean", "description": "Whether superseded records were left out (--current)." },
|
|
31
35
|
"trust": { "$ref": "#/$defs/trust" },
|
|
@@ -63,7 +67,17 @@
|
|
|
63
67
|
"type": ["object", "null"],
|
|
64
68
|
"description": "The front matter as JSON, following the kind's schema when valid is true. Null when it could not be parsed."
|
|
65
69
|
},
|
|
66
|
-
"provenance": { "$ref": "#/$defs/provenance" }
|
|
70
|
+
"provenance": { "$ref": "#/$defs/provenance" },
|
|
71
|
+
"assets": {
|
|
72
|
+
"description": "Added in contract 1 by #2549. Each workspace file the record pins by hash, checked against the tree read: the working tree, or the revision under --at. Empty for a kind that pins nothing.",
|
|
73
|
+
"type": "array",
|
|
74
|
+
"items": { "$ref": "#/$defs/asset" }
|
|
75
|
+
},
|
|
76
|
+
"warnings": {
|
|
77
|
+
"description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, or a supersedes link that has no effect yet. valid and --current do not look at them.",
|
|
78
|
+
"type": "array",
|
|
79
|
+
"items": { "$ref": "#/$defs/warning" }
|
|
80
|
+
}
|
|
67
81
|
},
|
|
68
82
|
"allOf": [
|
|
69
83
|
{
|
|
@@ -73,6 +87,24 @@
|
|
|
73
87
|
}
|
|
74
88
|
]
|
|
75
89
|
},
|
|
90
|
+
"asset": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"required": ["path", "sha256", "actual", "state"],
|
|
93
|
+
"properties": {
|
|
94
|
+
"path": { "type": "string", "description": "From the workspace root (workspaceRoot), with / separators." },
|
|
95
|
+
"sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "The hash the record pins." },
|
|
96
|
+
"actual": { "type": ["string", "null"], "pattern": "^[0-9a-f]{64}$", "description": "The hash of the file's bytes in the tree read, or null when the file is missing." },
|
|
97
|
+
"state": { "enum": ["pinned", "drifted", "missing", "stale"], "description": "pinned when the hashes match; drifted when they differ (a warning asset-drift); missing when the file is not there (a warning asset-missing); stale when the hashes match and a record this one supersedes pinned the same hash, with the file unchanged since this record was recorded (a warning asset-stale)." }
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"warning": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"required": ["code", "message"],
|
|
103
|
+
"properties": {
|
|
104
|
+
"code": { "enum": ["asset-drift", "asset-missing", "asset-stale", "record-supersedes-pending"] },
|
|
105
|
+
"message": { "type": "string" }
|
|
106
|
+
}
|
|
107
|
+
},
|
|
76
108
|
"trust": {
|
|
77
109
|
"description": "Added in contract 1 by #2547. Where provenance was judged from: the policy read at the base revision, never the tree read.",
|
|
78
110
|
"type": "object",
|
|
@@ -133,6 +133,56 @@ describe("readRecords", () => {
|
|
|
133
133
|
]);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
test("a decided record supersedes a decided one, under an equal approval rule (#2524 D4)", async () => {
|
|
137
|
+
write("ws-001-a.md", decision("ws-001", "decided"));
|
|
138
|
+
write("ws-002-b.md", decision("ws-002", "decided", ["ws-001"]));
|
|
139
|
+
const all = await read();
|
|
140
|
+
expect(all.records.map((r) => [r.id, r.supersededBy, r.valid, r.warnings])).toEqual([
|
|
141
|
+
["ws-001", "ws-002", true, []],
|
|
142
|
+
["ws-002", null, true, []],
|
|
143
|
+
]);
|
|
144
|
+
const current = await read({ current: true });
|
|
145
|
+
expect(current.records.map((r) => r.id)).toEqual(["ws-002"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a proposed record supersedes nothing: the link is pending, as a warning on the new record", async () => {
|
|
149
|
+
write("ws-001-a.md", decision("ws-001", "decided"));
|
|
150
|
+
write("ws-002-b.md", decision("ws-002", "proposed", ["ws-001"]).replace(/^choice:\n option: .*\n reason: .*\n/m, "choice: null\n"));
|
|
151
|
+
const all = await read();
|
|
152
|
+
expect(all.records.map((r) => [r.id, r.supersededBy, r.valid])).toEqual([
|
|
153
|
+
["ws-001", null, true],
|
|
154
|
+
["ws-002", null, true],
|
|
155
|
+
]);
|
|
156
|
+
expect(all.records[1].warnings.map((w) => w.code)).toEqual(["record-supersedes-pending"]);
|
|
157
|
+
expect((await read({ current: true })).records.map((r) => r.id)).toEqual(["ws-001", "ws-002"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("a decided record can't supersede a ratified one; a ratified record supersedes any", async () => {
|
|
161
|
+
write("ws-001-a.md", decision("ws-001", "ratified"));
|
|
162
|
+
write("ws-002-b.md", decision("ws-002", "decided", ["ws-001"]));
|
|
163
|
+
write("ws-003-c.md", decision("ws-003", "decided"));
|
|
164
|
+
write("ws-004-d.md", decision("ws-004", "ratified", ["ws-003"]));
|
|
165
|
+
const all = await read();
|
|
166
|
+
expect(all.records.map((r) => [r.id, r.supersededBy, r.warnings.map((w) => w.code)])).toEqual([
|
|
167
|
+
["ws-001", null, []],
|
|
168
|
+
["ws-002", null, ["record-supersedes-pending"]],
|
|
169
|
+
["ws-003", "ws-004", []],
|
|
170
|
+
["ws-004", null, []],
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a kind without approval ranks keeps the closed-state rule", async () => {
|
|
175
|
+
const kind = readFileSync(join(dir, "decisions", "decision.kind.mjs"), "utf-8").replace(/^ approval: .*\n/m, "");
|
|
176
|
+
writeFileSync(join(dir, "decisions", "decision.kind.mjs"), kind);
|
|
177
|
+
write("ws-001-a.md", decision("ws-001", "decided"));
|
|
178
|
+
write("ws-002-b.md", decision("ws-002", "decided", ["ws-001"]));
|
|
179
|
+
const all = await read();
|
|
180
|
+
expect(all.records.map((r) => [r.id, r.supersededBy, r.warnings])).toEqual([
|
|
181
|
+
["ws-001", null, []],
|
|
182
|
+
["ws-002", null, []],
|
|
183
|
+
]);
|
|
184
|
+
});
|
|
185
|
+
|
|
136
186
|
test("a record superseded twice keeps the first and flags the second", async () => {
|
|
137
187
|
write("ws-001-a.md", decision("ws-001", "ratified"));
|
|
138
188
|
write("ws-002-b.md", decision("ws-002", "ratified", ["ws-001"]));
|
package/src/workspace/records.ts
CHANGED
|
@@ -22,7 +22,9 @@ import yaml from "js-yaml";
|
|
|
22
22
|
import { z } from "zod";
|
|
23
23
|
import { importLexiconModule, registerLexiconDeclarations } from "../lexicon-module";
|
|
24
24
|
import type { ReasonCode } from "./reason-codes";
|
|
25
|
+
import { checkPins, pinEntries, type AssetPin } from "./record-assets";
|
|
25
26
|
import type { RecordSource } from "./record-source";
|
|
27
|
+
import type { WorkspaceTree } from "./tree";
|
|
26
28
|
|
|
27
29
|
// ── Reason codes ─────────────────────────────────────────────────────────────
|
|
28
30
|
|
|
@@ -44,6 +46,30 @@ export const RECORD_REASON_CODES = [
|
|
|
44
46
|
] as const satisfies readonly ReasonCode[];
|
|
45
47
|
export type RecordReasonCode = (typeof RECORD_REASON_CODES)[number];
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Why a record carries a warning. Closed, like the reason codes. A warning
|
|
51
|
+
* never makes a record invalid, and `--current` still lists the record.
|
|
52
|
+
*/
|
|
53
|
+
export const RECORD_WARNING_CODES = [
|
|
54
|
+
/** A pinned file's bytes no longer hash to the pinned sha256 (#2549). */
|
|
55
|
+
"asset-drift",
|
|
56
|
+
/** A pinned file does not exist in the tree read (#2549). */
|
|
57
|
+
"asset-missing",
|
|
58
|
+
/**
|
|
59
|
+
* A pinned file is unchanged since a record this one supersedes pinned it at
|
|
60
|
+
* the same hash: the decision changed and the artifact did not follow (#2549).
|
|
61
|
+
*/
|
|
62
|
+
"asset-stale",
|
|
63
|
+
/** A supersedes link from a record whose state is weaker than the one it names, so it has no effect yet (#2524 D4). */
|
|
64
|
+
"record-supersedes-pending",
|
|
65
|
+
] as const satisfies readonly ReasonCode[];
|
|
66
|
+
export type RecordWarningCode = (typeof RECORD_WARNING_CODES)[number];
|
|
67
|
+
|
|
68
|
+
export interface RecordWarning {
|
|
69
|
+
code: RecordWarningCode;
|
|
70
|
+
message: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
47
73
|
/**
|
|
48
74
|
* Why the read as a whole failed. Also closed. The command exits 1 with one of
|
|
49
75
|
* these and returns no records.
|
|
@@ -114,11 +140,34 @@ export const recordKindSchema = z
|
|
|
114
140
|
closedStates: z.array(z.string().min(1)),
|
|
115
141
|
/** The front-matter list of links to superseded records, and the key in each entry that holds the target id. */
|
|
116
142
|
supersedes: z.object({ field: z.string().min(1), key: z.string().min(1) }).strict(),
|
|
143
|
+
/**
|
|
144
|
+
* How strongly each state is approved (#2524 D4). With it, a supersedes
|
|
145
|
+
* link takes effect when the new record's rank is above 0 and at least the
|
|
146
|
+
* old record's, "under an equal or stricter approval rule". A state it
|
|
147
|
+
* leaves out ranks 0. Without it, a link takes effect only from a record
|
|
148
|
+
* in a closed state.
|
|
149
|
+
*/
|
|
150
|
+
approval: z.record(z.string(), z.number().int().min(0)).optional(),
|
|
151
|
+
/**
|
|
152
|
+
* The front-matter list whose entries may pin a workspace file, as
|
|
153
|
+
* `{path, sha256}` (#2549). Optional: a kind without it pins nothing.
|
|
154
|
+
*/
|
|
155
|
+
pins: z.object({ field: z.string().min(1) }).strict().optional(),
|
|
156
|
+
/**
|
|
157
|
+
* The front-matter list of what a record governs. Its `member:<name>` and
|
|
158
|
+
* `path:<path>` entries are the record's links in `chant workspace graph`
|
|
159
|
+
* (#2549). Optional.
|
|
160
|
+
*/
|
|
161
|
+
constrains: z.object({ field: z.string().min(1) }).strict().optional(),
|
|
117
162
|
})
|
|
118
163
|
.strict()
|
|
119
164
|
.refine((k) => k.closedStates.every((s) => k.states.includes(s)), {
|
|
120
165
|
message: "every closed state must be listed in states",
|
|
121
166
|
path: ["closedStates"],
|
|
167
|
+
})
|
|
168
|
+
.refine((k) => Object.keys(k.approval ?? {}).every((s) => k.states.includes(s)), {
|
|
169
|
+
message: "every state approval ranks must be listed in states",
|
|
170
|
+
path: ["approval"],
|
|
122
171
|
});
|
|
123
172
|
|
|
124
173
|
export type RecordKind = z.infer<typeof recordKindSchema>;
|
|
@@ -138,7 +187,7 @@ export interface LoadedRecordKind {
|
|
|
138
187
|
* The kind is registered under a name no lexicon package can have (npm names
|
|
139
188
|
* hold no `:`), imported and unregistered again, so no lexicon lookup sees it.
|
|
140
189
|
*/
|
|
141
|
-
async function importKindModule(path: string): Promise<Record<string, unknown>> {
|
|
190
|
+
export async function importKindModule(path: string): Promise<Record<string, unknown>> {
|
|
142
191
|
const name = `record-kind:${path}`;
|
|
143
192
|
registerLexiconDeclarations([{ name, module: path }], dirname(path));
|
|
144
193
|
try {
|
|
@@ -255,6 +304,10 @@ export interface RecordEntry {
|
|
|
255
304
|
supersededBy: string | null;
|
|
256
305
|
/** The front matter as JSON, or null when it could not be parsed. */
|
|
257
306
|
data: Record<string, unknown> | null;
|
|
307
|
+
/** Each workspace file the record pins, checked against the tree read (#2549). Empty when nothing was checked. */
|
|
308
|
+
assets: AssetPin[];
|
|
309
|
+
/** Findings that leave the record valid, such as a pinned file that changed (#2549). */
|
|
310
|
+
warnings: RecordWarning[];
|
|
258
311
|
}
|
|
259
312
|
|
|
260
313
|
export interface ReadRecordsOptions {
|
|
@@ -263,6 +316,25 @@ export interface ReadRecordsOptions {
|
|
|
263
316
|
source: RecordSource;
|
|
264
317
|
/** Leave out records a closed record supersedes. */
|
|
265
318
|
current?: boolean;
|
|
319
|
+
/**
|
|
320
|
+
* The workspace root the kind's pins resolve in: the working tree, or the
|
|
321
|
+
* revision read (#2549). Without it no pin is checked.
|
|
322
|
+
*/
|
|
323
|
+
assets?: WorkspaceTree;
|
|
324
|
+
/**
|
|
325
|
+
* When files last changed and records were recorded, in the history of the
|
|
326
|
+
* revision read, for `asset-stale` (#2549). Without it only the hashes are
|
|
327
|
+
* compared.
|
|
328
|
+
*/
|
|
329
|
+
history?: RecordHistory;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Commit times, in seconds since the epoch, read from git. */
|
|
333
|
+
export interface RecordHistory {
|
|
334
|
+
/** The last commit that changed `path` (from the workspace root), or null when unknown. */
|
|
335
|
+
fileChanged(path: string): number | null;
|
|
336
|
+
/** The commit that added the record at `path` (from the repository root), or null when it is not committed. */
|
|
337
|
+
recorded(path: string): number | null;
|
|
266
338
|
}
|
|
267
339
|
|
|
268
340
|
export interface ReadRecordsResult {
|
|
@@ -304,7 +376,7 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
304
376
|
const entries: RecordEntry[] = [];
|
|
305
377
|
for (const name of names.filter((n) => match.test(n)).sort()) {
|
|
306
378
|
const path = dirRel === "." ? name : `${dirRel}/${name}`;
|
|
307
|
-
const entry: RecordEntry = { id: null, path, state: null, valid: true, reasons: [], supersededBy: null, data: null };
|
|
379
|
+
const entry: RecordEntry = { id: null, path, state: null, valid: true, reasons: [], supersededBy: null, data: null, assets: [], warnings: [] };
|
|
308
380
|
entries.push(entry);
|
|
309
381
|
const fm = parseFrontMatter(options.source.read(path));
|
|
310
382
|
if (!fm.ok) {
|
|
@@ -320,6 +392,11 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
320
392
|
if (!result.ok) {
|
|
321
393
|
entry.reasons.push({ code: "record-schema-invalid", message: result.errors.join("; ") });
|
|
322
394
|
}
|
|
395
|
+
if (kind.pins && options.assets) {
|
|
396
|
+
const checked = checkPins(pinEntries(fm.value, kind.pins.field), options.assets);
|
|
397
|
+
entry.assets = checked.assets;
|
|
398
|
+
entry.warnings = checked.warnings;
|
|
399
|
+
}
|
|
323
400
|
}
|
|
324
401
|
|
|
325
402
|
// Ids: the first file in path order keeps an id; later ones are flagged.
|
|
@@ -332,10 +409,14 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
332
409
|
}
|
|
333
410
|
|
|
334
411
|
// Supersession comes from the new record's links, never from the old record.
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
// (#2524 D4).
|
|
412
|
+
// With approval ranks, a link takes effect under an equal or stricter
|
|
413
|
+
// approval rule: from a record ranked above 0 and at least as high as the
|
|
414
|
+
// one it names (#2524 D4). Without them, only from a closed record (#2555).
|
|
415
|
+
// A record is superseded at most once.
|
|
338
416
|
const closed = new Set(kind.closedStates);
|
|
417
|
+
const rank = (state: string | null): number => (state === null ? 0 : (kind.approval?.[state] ?? 0));
|
|
418
|
+
const takesEffect = (from: RecordEntry, to: RecordEntry): boolean =>
|
|
419
|
+
kind.approval ? rank(from.state) > 0 && rank(from.state) >= rank(to.state) : from.state !== null && closed.has(from.state);
|
|
339
420
|
for (const e of entries) {
|
|
340
421
|
const links = e.data?.[kind.supersedes.field];
|
|
341
422
|
if (!Array.isArray(links)) continue;
|
|
@@ -348,7 +429,16 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
348
429
|
e.reasons.push({ code: "record-supersedes-unknown", message: `supersedes ${target}, which no record has` });
|
|
349
430
|
continue;
|
|
350
431
|
}
|
|
351
|
-
if (
|
|
432
|
+
if (old === e) continue;
|
|
433
|
+
if (!takesEffect(e, old)) {
|
|
434
|
+
if (kind.approval) {
|
|
435
|
+
e.warnings.push({
|
|
436
|
+
code: "record-supersedes-pending",
|
|
437
|
+
message: `supersedes ${target}, which is ${old.state ?? "stateless"}; a ${e.state ?? "stateless"} record can't supersede it, so the link takes effect once this record is approved at least as strongly`,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
352
442
|
if (old.supersededBy !== null && old.supersededBy !== e.id) {
|
|
353
443
|
e.reasons.push({
|
|
354
444
|
code: "record-supersedes-conflict",
|
|
@@ -360,6 +450,26 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
360
450
|
}
|
|
361
451
|
}
|
|
362
452
|
|
|
453
|
+
// A pin that still matches, at the hash a record this one supersedes
|
|
454
|
+
// pinned, while the file has not changed since this record was recorded:
|
|
455
|
+
// the decision moved on and the artifact did not (#2549).
|
|
456
|
+
for (const e of entries) {
|
|
457
|
+
for (const a of e.assets) {
|
|
458
|
+
if (a.state !== "pinned") continue;
|
|
459
|
+
const old = entries.find((o) => o.supersededBy !== null && o.supersededBy === e.id && o.assets.some((p) => p.path === a.path && p.sha256 === a.sha256));
|
|
460
|
+
if (!old) continue;
|
|
461
|
+
const changed = options.history?.fileChanged(a.path) ?? null;
|
|
462
|
+
const recorded = options.history ? options.history.recorded(e.path) : null;
|
|
463
|
+
// A record not yet committed is recorded now, after every commit.
|
|
464
|
+
if (changed !== null && recorded !== null && changed > recorded) continue;
|
|
465
|
+
a.state = "stale";
|
|
466
|
+
e.warnings.push({
|
|
467
|
+
code: "asset-stale",
|
|
468
|
+
message: `${a.path} is pinned at the hash ${old.id} pinned, and it has not changed since: ${e.id} supersedes ${old.id}, and the artifact did not follow`,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
363
473
|
for (const e of entries) e.valid = e.reasons.length === 0;
|
|
364
474
|
const records = options.current ? entries.filter((e) => e.supersededBy === null) : entries;
|
|
365
475
|
return {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-pinning records after template substitution (#2549): a pin that held in
|
|
3
|
+
* the template follows the substituted file, one that didn't stays, and the
|
|
4
|
+
* rest of the record is kept byte for byte.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { repinSubstituted } from "./template-pins";
|
|
10
|
+
|
|
11
|
+
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
|
|
12
|
+
const buf = (s: string) => Buffer.from(s, "utf-8");
|
|
13
|
+
|
|
14
|
+
function record(pins: [string, string][]): string {
|
|
15
|
+
const evidence = pins.map(([path, hash]) => ` - title: "t"\n path: "${path}"\n sha256: "${hash}"\n`).join("");
|
|
16
|
+
return `---\nschema: 1\nid: "ref-002"\nevidence:\n - title: "a link"\n url: "https://example.com"\n${evidence}constrains:\n - "member:design"\n---\n\n# Body mentions ${pins[0]?.[1] ?? ""}\n`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("repinSubstituted", () => {
|
|
20
|
+
const before = "title {{chant:name}}\n";
|
|
21
|
+
const after = "title Acme\n";
|
|
22
|
+
const other = "unchanged\n";
|
|
23
|
+
|
|
24
|
+
test("a pin that held on a substituted file gets the new hash; nothing else in the file changes", () => {
|
|
25
|
+
const text = record([["design/home.json", sha(before)], ["design/other.json", sha(other)]]);
|
|
26
|
+
const original = new Map([
|
|
27
|
+
["chant.workspace.json", buf("{}")],
|
|
28
|
+
["design/home.json", buf(before)],
|
|
29
|
+
["design/other.json", buf(other)],
|
|
30
|
+
["decisions/ref-002-x.md", buf(text)],
|
|
31
|
+
]);
|
|
32
|
+
const substituted = new Map(original).set("design/home.json", buf(after));
|
|
33
|
+
const { files, repinned } = repinSubstituted(original, substituted, ["design/home.json"]);
|
|
34
|
+
expect(repinned).toEqual([{ record: "decisions/ref-002-x.md", paths: ["design/home.json"] }]);
|
|
35
|
+
const expected = text.replace(` sha256: "${sha(before)}"`, ` sha256: "${sha(after)}"`);
|
|
36
|
+
expect(files.get("decisions/ref-002-x.md")!.toString("utf-8")).toBe(expected);
|
|
37
|
+
// The body's copy of the old hash is not front matter, so it stays.
|
|
38
|
+
expect(expected).toContain(`# Body mentions ${sha(before)}`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a pin that was already drifted in the template stays drifted", () => {
|
|
42
|
+
const text = record([["design/home.json", sha("something else")]]);
|
|
43
|
+
const original = new Map([
|
|
44
|
+
["design/home.json", buf(before)],
|
|
45
|
+
["decisions/ref-002-x.md", buf(text)],
|
|
46
|
+
]);
|
|
47
|
+
const substituted = new Map(original).set("design/home.json", buf(after));
|
|
48
|
+
const { files, repinned } = repinSubstituted(original, substituted, ["design/home.json"]);
|
|
49
|
+
expect(repinned).toEqual([]);
|
|
50
|
+
expect(files.get("decisions/ref-002-x.md")!.toString("utf-8")).toBe(text);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("paths resolve from the workspace declaration nearest above the record", () => {
|
|
54
|
+
const text = record([["design/home.json", sha(before)]]);
|
|
55
|
+
const original = new Map([
|
|
56
|
+
["ws/chant.workspace.json", buf("{}")],
|
|
57
|
+
["ws/design/home.json", buf(before)],
|
|
58
|
+
["ws/decisions/ref-002-x.md", buf(text)],
|
|
59
|
+
]);
|
|
60
|
+
const substituted = new Map(original).set("ws/design/home.json", buf(after));
|
|
61
|
+
const { repinned } = repinSubstituted(original, substituted, ["ws/design/home.json"]);
|
|
62
|
+
expect(repinned).toEqual([{ record: "ws/decisions/ref-002-x.md", paths: ["design/home.json"] }]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("nothing substituted, nothing re-pinned", () => {
|
|
66
|
+
const original = new Map([["decisions/ref-002-x.md", buf(record([["design/home.json", sha(before)]]))]]);
|
|
67
|
+
expect(repinSubstituted(original, original, []).repinned).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|