@panaversity/ksor 0.0.58 → 0.0.59
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/CHANGELOG.md +167 -0
- package/README.md +8 -5
- package/dist/checker/check-main.mjs +142 -136
- package/dist/cli.mjs +810 -211
- package/dist/{gateway-api-DvM2mVtD-BXmUS4T8.mjs → gateway-api-CEsb-e8z-Ch6oMaq2.mjs} +74 -7
- package/dist/gateway.d.mts +2 -2
- package/dist/gateway.mjs +1 -1
- package/docs/building.md +157 -0
- package/docs/deploying.md +14 -2
- package/docs/index.md +5 -1
- package/docs/ingesting.md +29 -2
- package/docs/upgrading.md +15 -1
- package/package.json +3 -3
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +140 -134
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +140 -134
- package/templates/scaffold/AGENTS.md +21 -5
- package/templates/scaffold/README.md +4 -1
- package/templates/scaffold/env.example +3 -0
- package/templates/scaffold/system/site/next-no-telemetry.mjs +27 -0
- package/templates/scaffold/system/site/package.json +2 -2
- package/templates/scaffold/system/site/record/change-control.ts +319 -0
- package/templates/scaffold/system/site/record/index.ts +8 -0
- package/templates/scaffold/system/site/record/lock.ts +37 -0
- package/templates/scaffold/system/site/record/refusal.ts +1 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run Next.js with its telemetry OFF, so nothing phones home.
|
|
3
|
+
*
|
|
4
|
+
* Next.js ships with anonymous usage telemetry switched on: `next build` and
|
|
5
|
+
* `next dev` each post to telemetry.nextjs.org unless NEXT_TELEMETRY_DISABLED
|
|
6
|
+
* is set in the process environment. This project promises that nothing
|
|
7
|
+
* phones home, so `build` and `dev` run next through here instead of directly.
|
|
8
|
+
*
|
|
9
|
+
* Why the environment and not next.config.mjs: the `next dev` parent process
|
|
10
|
+
* never loads the config, and still records a session event on exit. Why a
|
|
11
|
+
* file and not a `NEXT_TELEMETRY_DISABLED=1 next …` prefix in package.json:
|
|
12
|
+
* that prefix is POSIX shell syntax, which cmd.exe refuses on Windows. This
|
|
13
|
+
* is node:child_process and nothing else — no dependency, works everywhere
|
|
14
|
+
* node does — and every argument passes through to next unchanged.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const next = fileURLToPath(new URL("dist/bin/next", import.meta.resolve("next/package.json")));
|
|
20
|
+
|
|
21
|
+
const child = spawnSync(process.execPath, [next, ...process.argv.slice(2)], {
|
|
22
|
+
stdio: "inherit",
|
|
23
|
+
env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (child.error) throw child.error;
|
|
27
|
+
process.exit(child.status ?? 1);
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"build": "next build --webpack",
|
|
8
|
-
"dev": "next dev"
|
|
7
|
+
"build": "node next-no-telemetry.mjs build --webpack",
|
|
8
|
+
"dev": "node next-no-telemetry.mjs dev"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"class-variance-authority": "0.7.1",
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KSP R23 — the first verification tooth in approval.
|
|
3
|
+
*
|
|
4
|
+
* A `stable` concept's `generated.at` dates its TEXT, and `ksor.approval.at`
|
|
5
|
+
* ratifies the text of that date. Until now the checker compared the two
|
|
6
|
+
* authored instants and nothing else (`ksor-generated-after-approval`), so
|
|
7
|
+
* whether an edit actually moved `generated.at` was the author's obligation:
|
|
8
|
+
* change a sentence, leave the stamp, and the approval that ratified the old
|
|
9
|
+
* sentence reads as ratifying the new one, with nothing red. This module
|
|
10
|
+
* compares the body in the working tree against every committed version of
|
|
11
|
+
* the same path that was `stable`, and refuses `ksor-generated-stale` when a
|
|
12
|
+
* body differs under a `generated.at` the tree has not ADVANCED past — equal,
|
|
13
|
+
* or moved backward, which changes the stamp without advancing it. Only the
|
|
14
|
+
* body: a frontmatter-only
|
|
15
|
+
* edit — a `verified` entry, a re-approval — is not a change to the text the
|
|
16
|
+
* stamp dates.
|
|
17
|
+
*
|
|
18
|
+
* What it does NOT verify, and says so: who reviewed anything. R22 and R25
|
|
19
|
+
* need an identity the platform can vouch for, and `approval.checked` stays
|
|
20
|
+
* `"policy"` (decision 21). This is the one rule in the record module that
|
|
21
|
+
* reads git, so it sits beside `git-ledger.ts` and is run by the two
|
|
22
|
+
* publishing verbs — `ksor build` and `ksor ingest` — each of which SAYS when
|
|
23
|
+
* history could not be read rather than passing a check that did not run. The
|
|
24
|
+
* emitted `check.mjs` does not run it: that is the format gate an agent runs
|
|
25
|
+
* after every edit, and a stamp is verified where the record is published.
|
|
26
|
+
*/
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
|
|
29
|
+
import { normalizeText, splitFrontmatter } from "./frontmatter";
|
|
30
|
+
import { git } from "./git-ledger";
|
|
31
|
+
import { parseInstant } from "./instant";
|
|
32
|
+
import type { Concept } from "./profile";
|
|
33
|
+
import type { Refusal } from "./refusal";
|
|
34
|
+
|
|
35
|
+
export interface CommittedVersion {
|
|
36
|
+
readonly sha: string;
|
|
37
|
+
/** The committer instant, ISO 8601 with an offset (`%cI`). */
|
|
38
|
+
readonly committedAt: string;
|
|
39
|
+
readonly text: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CommittedHistory {
|
|
43
|
+
/** False outside any repository, and false without a git binary. */
|
|
44
|
+
readonly repository: boolean;
|
|
45
|
+
/** Whether HEAD exists: a fresh `ksor init` is a repository with no commit. */
|
|
46
|
+
readonly born: boolean;
|
|
47
|
+
readonly shallow: boolean;
|
|
48
|
+
/** Record-relative path → its committed versions, newest first; null when history could not be read. */
|
|
49
|
+
readonly versions: ReadonlyMap<string, readonly CommittedVersion[]> | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The record's bundle, and the only pathspec the walk needs (relative to the cwd, which is the record root). */
|
|
53
|
+
const BUNDLE = "knowledge/";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Objects per `git cat-file --batch` call. One process per object was measured
|
|
57
|
+
* at ~53 ms a call on the machine this was built on against ~0.6 ms an object
|
|
58
|
+
* batched (2026-09-02), so a record with a few hundred committed versions is
|
|
59
|
+
* the difference between a build that finishes and one that does not. Chunked
|
|
60
|
+
* so the reply stays under the buffer ceiling `git-ledger.ts` explains.
|
|
61
|
+
*/
|
|
62
|
+
const BATCH = 256;
|
|
63
|
+
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every committed version of each path, read in three git calls plus one
|
|
67
|
+
* `cat-file --batch` per chunk. `--full-history`, for the reason the ledger
|
|
68
|
+
* walk gives: default simplification follows a merge through the parent it is
|
|
69
|
+
* TREESAME to, so a version that lived only on a branch never enters the set.
|
|
70
|
+
* `-m`, because without a diff-merges option a merge commit lists NO paths at
|
|
71
|
+
* all — verified against git 2.50 — and a conflict resolved by hand is a body
|
|
72
|
+
* that is in neither parent; the same commit then prints once per parent, so
|
|
73
|
+
* versions are keyed by (commit, path). That flag is held by the merge case in
|
|
74
|
+
* `build.integration.test.ts`'s KSP R23 describe: drop `-m` here and the build
|
|
75
|
+
* it expects to refuse exits 0 instead.
|
|
76
|
+
*/
|
|
77
|
+
export function committedVersions(root: string, paths: readonly string[]): CommittedHistory {
|
|
78
|
+
const inside = git(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
79
|
+
if (inside === null || inside.trim() !== "true") {
|
|
80
|
+
return { repository: false, born: false, shallow: false, versions: null };
|
|
81
|
+
}
|
|
82
|
+
const shallow = (git(root, ["rev-parse", "--is-shallow-repository"]) ?? "").trim() === "true";
|
|
83
|
+
const born = git(root, ["rev-parse", "--verify", "--quiet", "HEAD"]) !== null;
|
|
84
|
+
const versions = new Map<string, CommittedVersion[]>();
|
|
85
|
+
if (!born || paths.length === 0) return { repository: true, born, shallow, versions };
|
|
86
|
+
|
|
87
|
+
// `--name-only` prints paths from the REPOSITORY root, and the record may
|
|
88
|
+
// sit below it (`docs-sor/knowledge/x.md`) — the same two meanings of "path"
|
|
89
|
+
// `git-ledger.ts` records. The pathspec is cwd-relative; the names are not.
|
|
90
|
+
const prefix = (git(root, ["rev-parse", "--show-prefix"]) ?? "").trim();
|
|
91
|
+
const log = git(root, [
|
|
92
|
+
"log",
|
|
93
|
+
"--full-history",
|
|
94
|
+
"-m",
|
|
95
|
+
"--format=%x1e%H %cI",
|
|
96
|
+
"--name-only",
|
|
97
|
+
"--",
|
|
98
|
+
BUNDLE,
|
|
99
|
+
]);
|
|
100
|
+
if (log === null) return { repository: true, born, shallow, versions: null };
|
|
101
|
+
|
|
102
|
+
const wanted = new Set(paths);
|
|
103
|
+
const refs: { readonly sha: string; readonly committedAt: string; readonly path: string }[] = [];
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
for (const block of log.split("\x1e")) {
|
|
106
|
+
const [header = "", ...names] = block.split("\n");
|
|
107
|
+
const [sha = "", committedAt = ""] = header.trim().split(" ");
|
|
108
|
+
if (sha === "") continue;
|
|
109
|
+
for (const name of names) {
|
|
110
|
+
// Names the profile admits are plain ASCII (`ksor-name-unportable`), so
|
|
111
|
+
// git never C-quotes one here; a quoted stray matches no wanted path.
|
|
112
|
+
const trimmed = name.trim();
|
|
113
|
+
if (trimmed === "" || !trimmed.startsWith(prefix)) continue;
|
|
114
|
+
const rel = trimmed.slice(prefix.length);
|
|
115
|
+
if (!wanted.has(rel) || seen.has(`${sha} ${rel}`)) continue;
|
|
116
|
+
seen.add(`${sha} ${rel}`);
|
|
117
|
+
refs.push({ sha, committedAt, path: rel });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const blobs = readBlobs(
|
|
121
|
+
root,
|
|
122
|
+
refs.map((r) => `${r.sha}:${prefix}${r.path}`),
|
|
123
|
+
);
|
|
124
|
+
if (blobs === null) return { repository: true, born, shallow, versions: null };
|
|
125
|
+
refs.forEach((ref, i) => {
|
|
126
|
+
const text = blobs[i];
|
|
127
|
+
// Absent in that commit: the commit DELETED the path (a rename's old name).
|
|
128
|
+
if (text === null || text === undefined) return;
|
|
129
|
+
const list = versions.get(ref.path) ?? [];
|
|
130
|
+
list.push({ sha: ref.sha, committedAt: ref.committedAt, text });
|
|
131
|
+
versions.set(ref.path, list);
|
|
132
|
+
});
|
|
133
|
+
return { repository: true, born, shallow, versions };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `<rev>:<path>` specs → text, in order; null for a spec that names nothing,
|
|
138
|
+
* and null for the WHOLE read when git failed — a version dropped in silence
|
|
139
|
+
* would read as verified.
|
|
140
|
+
*/
|
|
141
|
+
function readBlobs(root: string, specs: readonly string[]): readonly (string | null)[] | null {
|
|
142
|
+
const out: (string | null)[] = [];
|
|
143
|
+
for (let i = 0; i < specs.length; i += BATCH) {
|
|
144
|
+
const chunk = specs.slice(i, i + BATCH);
|
|
145
|
+
const r = spawnSync("git", ["cat-file", "--batch"], {
|
|
146
|
+
cwd: root,
|
|
147
|
+
input: `${chunk.join("\n")}\n`,
|
|
148
|
+
maxBuffer: MAX_BUFFER,
|
|
149
|
+
});
|
|
150
|
+
if (r.status !== 0) return null;
|
|
151
|
+
const buf: Buffer = r.stdout;
|
|
152
|
+
let at = 0;
|
|
153
|
+
for (let k = 0; k < chunk.length; k += 1) {
|
|
154
|
+
const nl = buf.indexOf(0x0a, at);
|
|
155
|
+
if (nl === -1) return null;
|
|
156
|
+
const header = buf.subarray(at, nl).toString("utf8");
|
|
157
|
+
at = nl + 1;
|
|
158
|
+
if (header.endsWith(" missing")) {
|
|
159
|
+
out.push(null);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const [, type, sizeText] = header.split(" ");
|
|
163
|
+
const size = Number(sizeText);
|
|
164
|
+
if (type !== "blob" || !Number.isInteger(size) || at + size > buf.length) return null;
|
|
165
|
+
out.push(buf.subarray(at, at + size).toString("utf8"));
|
|
166
|
+
at += size + 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface StableVersion {
|
|
173
|
+
readonly sha: string;
|
|
174
|
+
readonly committedAt: string;
|
|
175
|
+
readonly generatedAt: number;
|
|
176
|
+
/** The stamp as that version spelled it, so the refusal quotes the file rather than a re-rendering. */
|
|
177
|
+
readonly stamp: string;
|
|
178
|
+
readonly body: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Line endings are the checkout's and a trailing blank line is nobody's edit; everything else is the text. */
|
|
182
|
+
function comparable(body: string): string {
|
|
183
|
+
return normalizeText(body).trimEnd();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A committed version as a stable, stamped text — or null: a draft's body is
|
|
188
|
+
* free, and a version the profile cannot read (no fence, broken YAML, no
|
|
189
|
+
* `generated.at`) was never a stable version under the profile.
|
|
190
|
+
*/
|
|
191
|
+
function stableVersionOf(version: CommittedVersion, path: string): StableVersion | null {
|
|
192
|
+
const split = splitFrontmatter(version.text, path);
|
|
193
|
+
if (!split.ok || split.frontmatter === null || split.frontmatter["status"] !== "stable") {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const generated = split.frontmatter["generated"];
|
|
197
|
+
if (typeof generated !== "object" || generated === null) return null;
|
|
198
|
+
const at = (generated as Readonly<Record<string, unknown>>)["at"];
|
|
199
|
+
if (typeof at !== "string") return null;
|
|
200
|
+
const generatedAt = parseInstant(at);
|
|
201
|
+
if (generatedAt === null) return null;
|
|
202
|
+
return {
|
|
203
|
+
sha: version.sha,
|
|
204
|
+
committedAt: version.committedAt,
|
|
205
|
+
generatedAt,
|
|
206
|
+
stamp: at,
|
|
207
|
+
body: comparable(split.body),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The rule, pure: for every `stable` concept whose body differs from a
|
|
213
|
+
* committed version that was `stable`, the tree's `generated.at` must be
|
|
214
|
+
* strictly LATER than that version's. Equal is the defect this rule exists to
|
|
215
|
+
* catch — edit the sentence, leave the stamp — and EARLIER is the same defect
|
|
216
|
+
* with one more keystroke: backdating the stamp changes it without advancing
|
|
217
|
+
* it, so the approval that ratified the old text still post-dates the new one.
|
|
218
|
+
* Only strictly-later clears a version, which is exactly what the refusal's
|
|
219
|
+
* own `fix` prints. All of history, not only HEAD's version — an edit
|
|
220
|
+
* committed without a bump matches HEAD exactly, and it is the version BEHIND
|
|
221
|
+
* it that tells (the shape CI sees). A path with no committed stable version
|
|
222
|
+
* passes: stable for the first time, or renamed, since path is identity.
|
|
223
|
+
* Instants are compared as instants, so two spellings of one moment are one
|
|
224
|
+
* stamp.
|
|
225
|
+
*/
|
|
226
|
+
export function checkGeneratedStale(
|
|
227
|
+
concepts: readonly Concept[],
|
|
228
|
+
files: ReadonlyMap<string, string>,
|
|
229
|
+
versions: ReadonlyMap<string, readonly CommittedVersion[]>,
|
|
230
|
+
): Refusal[] {
|
|
231
|
+
const refusals: Refusal[] = [];
|
|
232
|
+
for (const concept of concepts) {
|
|
233
|
+
if (concept.status !== "stable" || concept.generatedAt === null) continue;
|
|
234
|
+
const text = files.get(concept.path);
|
|
235
|
+
if (text === undefined) continue;
|
|
236
|
+
const split = splitFrontmatter(text, concept.path);
|
|
237
|
+
if (!split.ok) continue;
|
|
238
|
+
const body = comparable(split.body);
|
|
239
|
+
for (const version of versions.get(concept.path) ?? []) {
|
|
240
|
+
const stable = stableVersionOf(version, concept.path);
|
|
241
|
+
if (stable === null || stable.generatedAt < concept.generatedAt || stable.body === body) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const authored = (concept.frontmatter["generated"] as { readonly at?: unknown } | undefined)
|
|
245
|
+
?.at;
|
|
246
|
+
const stamp =
|
|
247
|
+
typeof authored === "string" ? authored : new Date(concept.generatedAt).toISOString();
|
|
248
|
+
const under =
|
|
249
|
+
stable.generatedAt === concept.generatedAt
|
|
250
|
+
? `under the same \`generated.at\` (${stamp})`
|
|
251
|
+
: `under \`generated.at\` ${stable.stamp}, LATER than the ${stamp} this file now carries ` +
|
|
252
|
+
"(the stamp was moved backward, which changes it without advancing it)";
|
|
253
|
+
refusals.push({
|
|
254
|
+
slug: "ksor-generated-stale",
|
|
255
|
+
path: concept.path,
|
|
256
|
+
why:
|
|
257
|
+
`the body differs from the one committed at ${stable.sha.slice(0, 7)} (${stable.committedAt}), ` +
|
|
258
|
+
`where this concept was \`stable\` ${under} — that instant dates ` +
|
|
259
|
+
"the text, so an edit to a stable concept must advance it, or the approval that ratified the old " +
|
|
260
|
+
"text reads as ratifying the new one (KSP R23)",
|
|
261
|
+
fix:
|
|
262
|
+
"set `generated.at` to an instant after this edit, then re-approve: `ksor.approval.at` must not " +
|
|
263
|
+
"precede the new `generated.at` (`ksor-generated-after-approval`)",
|
|
264
|
+
});
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return refusals;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface ChangeControl {
|
|
272
|
+
readonly refusals: readonly Refusal[];
|
|
273
|
+
/**
|
|
274
|
+
* Why the check could not run, or ran short — printed beside the verdict,
|
|
275
|
+
* never swallowed. Null when every committed version was read.
|
|
276
|
+
*/
|
|
277
|
+
readonly notice: string | null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The two callers' one entry: read history for the stable concepts, judge, and say what could not be read. */
|
|
281
|
+
export function checkChangeControl(
|
|
282
|
+
root: string,
|
|
283
|
+
concepts: readonly Concept[],
|
|
284
|
+
files: ReadonlyMap<string, string>,
|
|
285
|
+
): ChangeControl {
|
|
286
|
+
const paths = concepts.filter((c) => c.status === "stable").map((c) => c.path);
|
|
287
|
+
const history = committedVersions(root, paths);
|
|
288
|
+
const refusals =
|
|
289
|
+
history.versions === null ? [] : checkGeneratedStale(concepts, files, history.versions);
|
|
290
|
+
return { refusals, notice: changeControlNotice(history) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Honest absence in the build's own idiom — the `source: unspecified` line
|
|
295
|
+
* already says the commit is unknown; this says the same of the check that
|
|
296
|
+
* needs one. A check that could not run is never a check that passed.
|
|
297
|
+
*/
|
|
298
|
+
function changeControlNotice(history: CommittedHistory): string | null {
|
|
299
|
+
const what =
|
|
300
|
+
"so whether a stable concept's body changed under its `generated.at` (KSP R23) was not checked";
|
|
301
|
+
if (!history.repository) {
|
|
302
|
+
return `change-control: not checked — knowledge/ is not in a git repository (or git is not installed), ${what}`;
|
|
303
|
+
}
|
|
304
|
+
if (!history.born) {
|
|
305
|
+
return `change-control: not checked — the repository has no commits yet, ${what}`;
|
|
306
|
+
}
|
|
307
|
+
if (history.versions === null) {
|
|
308
|
+
return `change-control: not checked — git could not read the history of knowledge/ (\`git log -- knowledge/\` or \`git cat-file --batch\` failed), ${what}`;
|
|
309
|
+
}
|
|
310
|
+
if (history.shallow) {
|
|
311
|
+
const read = [...history.versions.values()].reduce((n, list) => n + list.length, 0);
|
|
312
|
+
return (
|
|
313
|
+
`change-control: checked against the ${read} committed version(s) this shallow clone holds — a stable ` +
|
|
314
|
+
"version beyond the shallow boundary was not read; fetch full history (`git fetch --unshallow`; in CI, " +
|
|
315
|
+
"`fetch-depth: 0`) to check all of it"
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
@@ -72,6 +72,14 @@ export {
|
|
|
72
72
|
type Drafts,
|
|
73
73
|
} from "./lock";
|
|
74
74
|
export { git, historicLedger, type HistoricLedger } from "./git-ledger";
|
|
75
|
+
export {
|
|
76
|
+
checkChangeControl,
|
|
77
|
+
checkGeneratedStale,
|
|
78
|
+
committedVersions,
|
|
79
|
+
type ChangeControl,
|
|
80
|
+
type CommittedHistory,
|
|
81
|
+
type CommittedVersion,
|
|
82
|
+
} from "./change-control";
|
|
75
83
|
export { generateIndexes, parseIndex, humanise, type IndexInput } from "./index-file";
|
|
76
84
|
export { actorKind, isIndividualActor } from "./actor";
|
|
77
85
|
export { checkFootnotes, linkTargets, resolveLink } from "./citations";
|
|
@@ -33,6 +33,9 @@ export type Drafts = "hidden" | "shown";
|
|
|
33
33
|
|
|
34
34
|
const hex64 = z.string().regex(/^[0-9a-f]{64}$/, "a sha256 hex digest");
|
|
35
35
|
const viewerList = z.array(z.string().min(1));
|
|
36
|
+
const bundleEntry = z
|
|
37
|
+
.object({ viewer: z.string().min(1), sha256: hex64, files: z.number().int().nonnegative() })
|
|
38
|
+
.strict();
|
|
36
39
|
|
|
37
40
|
const lockSchema = z
|
|
38
41
|
.object({
|
|
@@ -66,9 +69,22 @@ const lockSchema = z
|
|
|
66
69
|
companions: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
|
|
67
70
|
assets: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
|
|
68
71
|
indexes: z.array(z.object({ path: z.string().min(1), sha256: hex64 }).strict()),
|
|
72
|
+
bundles: z.array(bundleEntry),
|
|
69
73
|
})
|
|
70
74
|
.strict();
|
|
71
75
|
|
|
76
|
+
/**
|
|
77
|
+
* One OKF bundle, as `ksor build --bundles` writes it for a canonical viewer
|
|
78
|
+
* (build spec §1 step 4): the digest is sha256 over the JSON of the bundle's
|
|
79
|
+
* sorted `[path, sha256]` pairs, so a recipient holding only the directory can
|
|
80
|
+
* recompute it and find the publication it came from.
|
|
81
|
+
*/
|
|
82
|
+
export interface LockBundle {
|
|
83
|
+
readonly viewer: string;
|
|
84
|
+
readonly sha256: string;
|
|
85
|
+
readonly files: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
export interface LockDocument {
|
|
73
89
|
/** Bundle-relative, with `.md`. */
|
|
74
90
|
readonly path: string;
|
|
@@ -116,6 +132,24 @@ export interface Lock {
|
|
|
116
132
|
* stopped short of the file that lists what was published.
|
|
117
133
|
*/
|
|
118
134
|
readonly indexes: readonly { readonly path: string; readonly sha256: string }[];
|
|
135
|
+
/**
|
|
136
|
+
* One digest per canonical viewer, recorded on EVERY build and not only when
|
|
137
|
+
* `--bundles` wrote the directories: the bundle set is a function of what
|
|
138
|
+
* `build_id` already hashes, so the lock is the same lock either way, and a
|
|
139
|
+
* `pnpm build` on a host that never passes the flag records the same digests
|
|
140
|
+
* the owner's `--bundles` run did. Outside `build_id` for the same reason —
|
|
141
|
+
* the documents, their admitted sets, the companions, the assets and the
|
|
142
|
+
* instance title are already in it, so hashing the bundles again could not
|
|
143
|
+
* move it (build spec §2). Listed so a directory can be MATCHED to a
|
|
144
|
+
* publication, not to widen what the id covers. Required on read like every
|
|
145
|
+
* other field, and NOT read around: a lock an older ksor wrote lacks the key,
|
|
146
|
+
* so `ksor build` refuses it as `ksor-lock-invalid` and says to delete it —
|
|
147
|
+
* it does not regenerate one it cannot read, because the lock is also a
|
|
148
|
+
* takedown baseline and a lock nothing can read is a baseline that quietly
|
|
149
|
+
* holds nothing. `ksor migrate` offers that deletion, which is the migration
|
|
150
|
+
* decision 28 pairs the removal with.
|
|
151
|
+
*/
|
|
152
|
+
readonly bundles: readonly LockBundle[];
|
|
119
153
|
}
|
|
120
154
|
|
|
121
155
|
export type LockResult =
|
|
@@ -254,6 +288,8 @@ export interface LockInput {
|
|
|
254
288
|
/** Bundle-relative path → the §8 index text this build generated (`index.md`, `policies/index.md`). */
|
|
255
289
|
readonly indexes: readonly { readonly path: string; readonly text: string }[];
|
|
256
290
|
readonly denials: readonly Denial[];
|
|
291
|
+
/** The digest of each canonical viewer's bundle, in the order `canonicalViewers` lists them. */
|
|
292
|
+
readonly bundles: readonly LockBundle[];
|
|
257
293
|
}
|
|
258
294
|
|
|
259
295
|
export function composeLock(input: LockInput): Lock {
|
|
@@ -310,6 +346,7 @@ export function composeLock(input: LockInput): Lock {
|
|
|
310
346
|
companions,
|
|
311
347
|
assets,
|
|
312
348
|
indexes,
|
|
349
|
+
bundles: input.bundles.map((b) => ({ viewer: b.viewer, sha256: b.sha256, files: b.files })),
|
|
313
350
|
};
|
|
314
351
|
}
|
|
315
352
|
|