@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.
@@ -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
 
@@ -16,6 +16,7 @@ export const REFUSAL_SLUGS = [
16
16
  "ksor-stable-unapproved",
17
17
  "ksor-approver-unauthorised",
18
18
  "ksor-generated-after-approval",
19
+ "ksor-generated-stale",
19
20
  "ksor-deprecated-unattributed",
20
21
  "ksor-deprecator-unauthorised",
21
22
  "ksor-reserved-type-unsourced",