@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.
- package/dist/cli/main.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 +11 -4
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- 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 +4 -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 +41 -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.ts +12 -6
- 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 +32 -6
- package/src/workspace/graph-contract.test.ts +2 -1
- package/src/workspace/graph.schema.json +131 -2
- 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/reason-codes.test.ts +2 -1
- package/src/workspace/reason-codes.ts +5 -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 +115 -5
- package/src/workspace/template-pins.test.ts +69 -0
- package/src/workspace/template-pins.ts +117 -0
- package/src/workspace/tree.ts +12 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset pins and record links (#2549; #2524 D4, D6, D18).
|
|
3
|
+
*
|
|
4
|
+
* Artifact relationships are derived from decisions. A decision record pins
|
|
5
|
+
* the workspace files it rests on in `evidence`, each as `{title, path,
|
|
6
|
+
* sha256}`, and names what it governs in `constrains`, as `member:<name>` or
|
|
7
|
+
* `path:<path>`. There is no direct link from a design artifact to code or to
|
|
8
|
+
* another artifact: a reader walks from a file to the decisions whose
|
|
9
|
+
* `constrains` cover it, and from them to their pinned assets.
|
|
10
|
+
*
|
|
11
|
+
* A pin is checked against the tree a read looks at (the working tree, or a
|
|
12
|
+
* revision's git objects). A file whose bytes hash differently is `drifted`,
|
|
13
|
+
* and one that is gone is `missing`. Neither makes the record invalid: the
|
|
14
|
+
* record is still what was decided, and the drift is reported beside it.
|
|
15
|
+
*
|
|
16
|
+
* Which front-matter fields hold pins and links is the kind's data
|
|
17
|
+
* (`pins.field`, `constrains.field`), so this module never names `evidence`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { sha256Hex } from "../content-digest";
|
|
21
|
+
import type { RecordWarning } from "./records";
|
|
22
|
+
import type { WorkspaceTree } from "./tree";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A path from the workspace root: `/` separators, no leading `/`, no `.` or
|
|
26
|
+
* `..` segment, no empty segment, no backslash or control character and no
|
|
27
|
+
* trailing `/`. The decision schema holds the same pattern.
|
|
28
|
+
*/
|
|
29
|
+
export const WORKSPACE_PATH_PATTERN = String.raw`(?!/)(?!(?:[^/]*/)*\.{1,2}(?:/|$))(?!.*//)[^\\\u0000-\u001f]*[^/\\\u0000-\u001f]`;
|
|
30
|
+
const WORKSPACE_PATH = new RegExp(`^${WORKSPACE_PATH_PATTERN}$`, "u");
|
|
31
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
32
|
+
|
|
33
|
+
export function isWorkspacePath(value: unknown): value is string {
|
|
34
|
+
return typeof value === "string" && WORKSPACE_PATH.test(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `pinned`: the file hashes to the pin. `drifted`: it doesn't. `missing`: it
|
|
39
|
+
* isn't there. `stale`: it hashes to the pin, which a record this one
|
|
40
|
+
* supersedes pinned too, so the decision changed and the artifact did not.
|
|
41
|
+
*/
|
|
42
|
+
export type PinState = "pinned" | "drifted" | "missing" | "stale";
|
|
43
|
+
|
|
44
|
+
/** One pinned file, as the tree read has it. */
|
|
45
|
+
export interface AssetPin {
|
|
46
|
+
/** From the workspace root. */
|
|
47
|
+
path: string;
|
|
48
|
+
/** The hash the record pins. */
|
|
49
|
+
sha256: string;
|
|
50
|
+
/** The hash of the file in the tree read, or null when it is missing. */
|
|
51
|
+
actual: string | null;
|
|
52
|
+
state: PinState;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The well-formed pins in a record's `field` list: entries with a workspace path and a hex sha256. */
|
|
56
|
+
export function pinEntries(data: Record<string, unknown> | null, field: string): { path: string; sha256: string }[] {
|
|
57
|
+
const list = data?.[field];
|
|
58
|
+
if (!Array.isArray(list)) return [];
|
|
59
|
+
const out: { path: string; sha256: string }[] = [];
|
|
60
|
+
for (const e of list) {
|
|
61
|
+
if (e === null || typeof e !== "object" || Array.isArray(e)) continue;
|
|
62
|
+
const { path, sha256 } = e as Record<string, unknown>;
|
|
63
|
+
if (isWorkspacePath(path) && typeof sha256 === "string" && SHA256.test(sha256)) out.push({ path, sha256 });
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The hex SHA-256 of the file at `path` in `tree`, or undefined when it is not a file there. */
|
|
69
|
+
export function fileDigest(tree: WorkspaceTree, path: string): string | undefined {
|
|
70
|
+
if (tree.stat(path) !== "file") return undefined;
|
|
71
|
+
const bytes = tree.bytes ? tree.bytes(path) : Buffer.from(tree.read(path), "utf-8");
|
|
72
|
+
return sha256Hex(bytes);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Check each pin against `tree`, rooted at the workspace root. */
|
|
76
|
+
export function checkPins(pins: { path: string; sha256: string }[], tree: WorkspaceTree): { assets: AssetPin[]; warnings: RecordWarning[] } {
|
|
77
|
+
const assets: AssetPin[] = [];
|
|
78
|
+
const warnings: RecordWarning[] = [];
|
|
79
|
+
for (const pin of pins) {
|
|
80
|
+
const actual = fileDigest(tree, pin.path) ?? null;
|
|
81
|
+
const state: PinState = actual === null ? "missing" : actual === pin.sha256 ? "pinned" : "drifted";
|
|
82
|
+
assets.push({ ...pin, actual, state });
|
|
83
|
+
if (state === "missing") {
|
|
84
|
+
warnings.push({ code: "asset-missing", message: `evidence pins ${pin.path}, which does not exist${tree.label}` });
|
|
85
|
+
} else if (state === "drifted") {
|
|
86
|
+
warnings.push({
|
|
87
|
+
code: "asset-drift",
|
|
88
|
+
message: `${pin.path} changed since it was pinned: sha256 ${pin.sha256.slice(0, 12)} is pinned, the file${tree.label} hashes to ${actual!.slice(0, 12)}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { assets, warnings };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Record links ─────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** The kinds of link a record has in `chant workspace graph`. Closed. */
|
|
98
|
+
export const RECORD_LINK_KINDS = ["asset", "constrains"] as const;
|
|
99
|
+
|
|
100
|
+
/** A link from a record to a workspace path or member, a row of the graph's `links`. */
|
|
101
|
+
export interface RecordLinkRow {
|
|
102
|
+
kind: (typeof RECORD_LINK_KINDS)[number];
|
|
103
|
+
origin: "declared";
|
|
104
|
+
resolves: "source";
|
|
105
|
+
/** The record's kind, such as `decision`. */
|
|
106
|
+
recordKind: string;
|
|
107
|
+
/** The record's id. */
|
|
108
|
+
record: string;
|
|
109
|
+
/** The record file, from the repository root. */
|
|
110
|
+
recordPath: string;
|
|
111
|
+
/** For `asset`, the pinned path; for `constrains`, the entry as written (`member:<name>` or `path:<path>`). */
|
|
112
|
+
target: string;
|
|
113
|
+
/** The member the target is, or holds it; null when no member does. */
|
|
114
|
+
member: string | null;
|
|
115
|
+
/** `pinned`, `drifted`, `missing` or `stale` for an asset; `resolved` or `missing` for constrains. */
|
|
116
|
+
status: PinState | "resolved";
|
|
117
|
+
reason: string | null;
|
|
118
|
+
/** For an asset: the pinned hash and the hash in the tree read. */
|
|
119
|
+
sha256?: string;
|
|
120
|
+
actual?: string | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The member whose directory holds `path` (the deepest one), or null. */
|
|
124
|
+
export function memberHolding(path: string, members: readonly { name: string; dir: string }[]): string | null {
|
|
125
|
+
let best: { name: string; dir: string } | null = null;
|
|
126
|
+
for (const m of members) {
|
|
127
|
+
const inside = m.dir === "." || path === m.dir || path.startsWith(`${m.dir}/`);
|
|
128
|
+
if (inside && (!best || best.dir === "." || m.dir.length > best.dir.length)) best = m;
|
|
129
|
+
}
|
|
130
|
+
return best?.name ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether a `path:` constraint covers `file`: the same path, or a directory above it. */
|
|
134
|
+
export function constraintCovers(constraint: string, file: string): boolean {
|
|
135
|
+
return file === constraint || file.startsWith(`${constraint}/`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A record as the link rows read it. */
|
|
139
|
+
export interface LinkedRecord {
|
|
140
|
+
id: string | null;
|
|
141
|
+
path: string;
|
|
142
|
+
supersededBy: string | null;
|
|
143
|
+
data: Record<string, unknown> | null;
|
|
144
|
+
assets: AssetPin[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The link rows of the records of one kind: an `asset` row per pin and a
|
|
149
|
+
* `constrains` row per `member:` or `path:` entry. Superseded records and
|
|
150
|
+
* records with no id have none, since their links no longer hold. Paths
|
|
151
|
+
* resolve in `tree` (the workspace root), members in `members`.
|
|
152
|
+
*/
|
|
153
|
+
export function recordLinkRows(
|
|
154
|
+
kindName: string,
|
|
155
|
+
records: readonly LinkedRecord[],
|
|
156
|
+
constrainsField: string | undefined,
|
|
157
|
+
tree: WorkspaceTree,
|
|
158
|
+
members: readonly { name: string; dir: string }[],
|
|
159
|
+
): RecordLinkRow[] {
|
|
160
|
+
const rows: RecordLinkRow[] = [];
|
|
161
|
+
for (const r of records) {
|
|
162
|
+
if (r.id === null || r.supersededBy !== null) continue;
|
|
163
|
+
const base = { origin: "declared" as const, resolves: "source" as const, recordKind: kindName, record: r.id, recordPath: r.path };
|
|
164
|
+
for (const a of r.assets) {
|
|
165
|
+
rows.push({
|
|
166
|
+
kind: "asset",
|
|
167
|
+
...base,
|
|
168
|
+
target: a.path,
|
|
169
|
+
member: memberHolding(a.path, members),
|
|
170
|
+
status: a.state,
|
|
171
|
+
reason:
|
|
172
|
+
a.state === "missing"
|
|
173
|
+
? `${a.path} does not exist${tree.label}`
|
|
174
|
+
: a.state === "drifted"
|
|
175
|
+
? `${a.path} changed since ${r.id} pinned it`
|
|
176
|
+
: a.state === "stale"
|
|
177
|
+
? `${a.path} has not changed since a record ${r.id} supersedes pinned it`
|
|
178
|
+
: null,
|
|
179
|
+
sha256: a.sha256,
|
|
180
|
+
actual: a.actual,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const list = constrainsField ? r.data?.[constrainsField] : undefined;
|
|
184
|
+
if (!Array.isArray(list)) continue;
|
|
185
|
+
for (const entry of list) {
|
|
186
|
+
if (typeof entry !== "string") continue;
|
|
187
|
+
if (entry.startsWith("member:")) {
|
|
188
|
+
const name = entry.slice("member:".length);
|
|
189
|
+
const known = members.some((m) => m.name === name);
|
|
190
|
+
rows.push({ kind: "constrains", ...base, target: entry, member: known ? name : null, status: known ? "resolved" : "missing", reason: known ? null : `${name} is not a member of this workspace` });
|
|
191
|
+
} else if (entry.startsWith("path:")) {
|
|
192
|
+
const path = entry.slice("path:".length);
|
|
193
|
+
if (!isWorkspacePath(path)) continue;
|
|
194
|
+
const exists = tree.stat(path) !== undefined;
|
|
195
|
+
rows.push({
|
|
196
|
+
kind: "constrains",
|
|
197
|
+
...base,
|
|
198
|
+
target: entry,
|
|
199
|
+
member: memberHolding(path, members),
|
|
200
|
+
status: exists ? "resolved" : "missing",
|
|
201
|
+
reason: exists ? null : `${path} does not exist${tree.label}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return rows;
|
|
207
|
+
}
|
|
@@ -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"]));
|