@panaversity/ksor 0.0.39 → 0.0.41
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 +965 -0
- package/README.md +29 -9
- package/dist/checker/check-main.mjs +14049 -0
- package/dist/cli.mjs +11528 -5213
- package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
- package/dist/gateway.d.mts +52 -13
- package/dist/gateway.mjs +2 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
- package/docs/authorization.md +8 -6
- package/docs/deploying.md +36 -25
- package/docs/index.md +26 -13
- package/docs/ingesting.md +70 -22
- package/docs/tool-surface.md +69 -16
- package/package.json +4 -3
- package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
- package/schema/schema.sql +77 -14
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.github/workflows/validate.yml +9 -1
- package/templates/scaffold/.ksor/governance.yaml +17 -0
- package/templates/scaffold/AGENTS.md +293 -119
- package/templates/scaffold/Dockerfile +5 -1
- package/templates/scaffold/README.md +160 -42
- package/templates/scaffold/env.example +37 -6
- package/templates/scaffold/gitignore +14 -6
- package/templates/scaffold/instance.md +21 -17
- package/templates/scaffold/knowledge/governance-ladder.md +11 -2
- package/templates/scaffold/knowledge/index.md +9 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
- package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
- package/templates/scaffold/knowledge/surfaces/index.md +4 -20
- package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
- package/templates/scaffold/package.json +3 -4
- package/templates/scaffold/pnpm-lock.yaml +3 -0
- package/templates/scaffold/system/gateways/content.ts +13 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
- package/templates/scaffold/system/site/app/global.css +198 -6
- package/templates/scaffold/system/site/app/layout.tsx +8 -3
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
- package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
- package/templates/scaffold/system/site/components/code-block.tsx +87 -0
- package/templates/scaffold/system/site/components/embed.tsx +276 -0
- package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
- package/templates/scaffold/system/site/components/governance.tsx +219 -87
- package/templates/scaffold/system/site/components/mdx.tsx +10 -2
- package/templates/scaffold/system/site/components/record-index.tsx +5 -5
- package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
- package/templates/scaffold/system/site/components/record-views.tsx +6 -17
- package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
- package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
- package/templates/scaffold/system/site/lib/attachments.ts +0 -28
- package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
- package/templates/scaffold/system/site/lib/audience.ts +42 -146
- package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
- package/templates/scaffold/system/site/lib/governance.ts +339 -225
- package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
- package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
- package/templates/scaffold/system/site/lib/lock.ts +282 -0
- package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
- package/templates/scaffold/system/site/lib/record-href.ts +68 -0
- package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
- package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
- package/templates/scaffold/system/site/lib/shared.ts +67 -104
- package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
- package/templates/scaffold/system/site/lib/source.ts +256 -186
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
- package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
- package/templates/scaffold/system/site/package.json +1 -0
- package/templates/scaffold/system/site/record/actor.ts +23 -0
- package/templates/scaffold/system/site/record/check.ts +571 -0
- package/templates/scaffold/system/site/record/citations.ts +312 -0
- package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
- package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
- package/templates/scaffold/system/site/record/hygiene.ts +320 -0
- package/templates/scaffold/system/site/record/index-file.ts +150 -0
- package/templates/scaffold/system/site/record/index.ts +103 -0
- package/templates/scaffold/system/site/record/instance.ts +257 -0
- package/templates/scaffold/system/site/record/instant.ts +43 -0
- package/templates/scaffold/system/site/record/ledger.ts +694 -0
- package/templates/scaffold/system/site/record/load.ts +129 -0
- package/templates/scaffold/system/site/record/lock.ts +306 -0
- package/templates/scaffold/system/site/record/near-miss.ts +37 -0
- package/templates/scaffold/system/site/record/policy.ts +414 -0
- package/templates/scaffold/system/site/record/profile.ts +535 -0
- package/templates/scaffold/system/site/record/refusal.ts +106 -0
- package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
- package/templates/scaffold/system/site/source.config.ts +109 -23
- package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
- package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
- package/templates/scaffold/system/site/lib/page-order.ts +0 -93
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* From a directory's regenerated `index.md` (OKF §8 form, parsed by the
|
|
3
|
+
* record's `parseIndex`) to the routes this site serves — the folder page's
|
|
4
|
+
* listing and the one reading order every surface shares.
|
|
5
|
+
*
|
|
6
|
+
* The index IS the listing: every projection regenerates its indexes from the
|
|
7
|
+
* tree it was filtered to (record spec §1), so rendering the staged index is
|
|
8
|
+
* rendering exactly what this viewer may see, in the generator's order — one
|
|
9
|
+
* bullet list, concepts and folders interleaved by `order:` and ties broken on
|
|
10
|
+
* the name. The sidebar, `llms.txt` and the folder pages all take their order
|
|
11
|
+
* from here, which is what makes it ONE reading order rather than three.
|
|
12
|
+
*
|
|
13
|
+
* Pure: no framework, no filesystem, so the rule is unit-tested where the
|
|
14
|
+
* package tests live.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One bullet of a parsed index: exactly the three fields this module reads.
|
|
19
|
+
*
|
|
20
|
+
* Structural rather than the record's own `IndexEntry`, so this rule keeps no
|
|
21
|
+
* relative import at all — it is unit-tested from the package
|
|
22
|
+
* (packages/ksor/src/index-routes.test.ts), whose Node-ESM program cannot
|
|
23
|
+
* typecheck the extensionless specifiers the site's modules must use, and the
|
|
24
|
+
* record's `parseIndex` output satisfies this by shape. Widening is impossible
|
|
25
|
+
* in the dangerous direction: a field dropped from the record's type fails at
|
|
26
|
+
* the call site.
|
|
27
|
+
*/
|
|
28
|
+
export interface IndexBullet {
|
|
29
|
+
readonly title: string;
|
|
30
|
+
readonly href: string;
|
|
31
|
+
readonly description: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Listing {
|
|
35
|
+
readonly kind: "concept" | "folder";
|
|
36
|
+
readonly title: string;
|
|
37
|
+
/** The route, without any base path: `/docs/policies/x` or `/docs/policies`. */
|
|
38
|
+
readonly url: string;
|
|
39
|
+
/** Bundle-relative: `policies/x.md` for a concept, `policies` for a folder. */
|
|
40
|
+
readonly path: string;
|
|
41
|
+
readonly description: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The route of a bundle-relative directory: `""` → `/docs`, `a/b` → `/docs/a/b`. */
|
|
45
|
+
export function folderRoute(dir: string): string {
|
|
46
|
+
return dir === "" ? "/docs" : `/docs/${dir}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The route of a bundle-relative concept path: `a/b.md` → `/docs/a/b`. */
|
|
50
|
+
export function conceptRoute(conceptPath: string): string {
|
|
51
|
+
return `/docs/${conceptPath.replace(/\.md$/, "")}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The bundle-relative directory a route names, or null when it is not under `/docs`. */
|
|
55
|
+
export function dirOfRoute(url: string): string | null {
|
|
56
|
+
if (url === "/docs") return "";
|
|
57
|
+
return url.startsWith("/docs/") ? url.slice("/docs/".length).replace(/\/$/, "") : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The listing of one directory from its parsed index. `dir` is bundle-relative
|
|
62
|
+
* (`""` at the root). A bullet's href is either `file.md` (a concept) or
|
|
63
|
+
* `name/` (a folder) — the two shapes the generator writes.
|
|
64
|
+
*/
|
|
65
|
+
export function listingOf(dir: string, entries: readonly IndexBullet[]): Listing[] {
|
|
66
|
+
const prefix = dir === "" ? "" : `${dir}/`;
|
|
67
|
+
return entries.flatMap((entry): Listing[] => {
|
|
68
|
+
const href = decodeHref(entry.href);
|
|
69
|
+
if (href.endsWith("/")) {
|
|
70
|
+
const path = `${prefix}${href.slice(0, -1)}`;
|
|
71
|
+
return [
|
|
72
|
+
{ kind: "folder", title: entry.title, url: folderRoute(path), path, description: null },
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
if (!href.endsWith(".md")) return [];
|
|
76
|
+
const path = `${prefix}${href}`;
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
kind: "concept",
|
|
80
|
+
title: entry.title,
|
|
81
|
+
url: conceptRoute(path),
|
|
82
|
+
path,
|
|
83
|
+
description: entry.description,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A href is percent-encoded by SOME index writers and not by ours, so it is
|
|
91
|
+
* decoded before use — and an undecodable one is the href itself, never a
|
|
92
|
+
* throw. `decodeURIComponent` rejects a bare `%` (`50%-off.md` → URIError),
|
|
93
|
+
* which took down `next build` with a message naming no file at all (found
|
|
94
|
+
* live, 2026-08-25); the record now refuses that name outright
|
|
95
|
+
* (`ksor-name-unportable`), so this is the second half of the same fix rather
|
|
96
|
+
* than a substitute for it — a bundle from another OKF producer still reaches
|
|
97
|
+
* here, and it must render the listing it can rather than none.
|
|
98
|
+
* The record's `resolveLink` guards the identical call for the identical
|
|
99
|
+
* reason.
|
|
100
|
+
*/
|
|
101
|
+
function decodeHref(href: string): string {
|
|
102
|
+
try {
|
|
103
|
+
return decodeURIComponent(href);
|
|
104
|
+
} catch {
|
|
105
|
+
return href;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Every route in reading order, root first, folders entered where their bullet
|
|
111
|
+
* sits: the depth-first walk of the indexes. `indexes` maps a bundle-relative
|
|
112
|
+
* directory to its parsed index; a folder bullet whose index is missing is
|
|
113
|
+
* listed and not entered.
|
|
114
|
+
*/
|
|
115
|
+
export function readingOrder(indexes: ReadonlyMap<string, readonly IndexBullet[]>): string[] {
|
|
116
|
+
const out: string[] = [];
|
|
117
|
+
const walk = (dir: string): void => {
|
|
118
|
+
for (const item of listingOf(dir, indexes.get(dir) ?? [])) {
|
|
119
|
+
out.push(item.url);
|
|
120
|
+
if (item.kind === "folder") walk(item.path);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
walk("");
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle by surface (record spec §2.5) — ONE rule for the page, sidebar
|
|
3
|
+
* and on-site search (human) and for `llms.txt`, the twins, `server.json`,
|
|
4
|
+
* bundles and the door (machine). Evaluated at the build's `as_of` for
|
|
5
|
+
* static output and at request time on the door; the two can disagree on a
|
|
6
|
+
* concept that crosses a boundary in between, which `LIFECYCLE_CASES` pins
|
|
7
|
+
* as a row rather than hides. No imports: a leaf, safe to copy.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type Surface = "human" | "machine";
|
|
11
|
+
export type LifecycleStatus = "draft" | "stable" | "deprecated";
|
|
12
|
+
|
|
13
|
+
export interface LifecycleDoc {
|
|
14
|
+
readonly status: LifecycleStatus;
|
|
15
|
+
/** Epoch ms, or null when unset. */
|
|
16
|
+
readonly effectiveFrom: number | null;
|
|
17
|
+
readonly staleAfter: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* May `surface` publish `doc` at instant `at`? `drafts` is the build's drafts switch,
|
|
22
|
+
* which admits drafts to HUMAN surfaces only.
|
|
23
|
+
*/
|
|
24
|
+
export function admitsLifecycle(
|
|
25
|
+
doc: LifecycleDoc,
|
|
26
|
+
surface: Surface,
|
|
27
|
+
at: number,
|
|
28
|
+
drafts: "hidden" | "shown",
|
|
29
|
+
): boolean {
|
|
30
|
+
if (doc.status === "draft") return surface === "human" && drafts === "shown";
|
|
31
|
+
if (surface === "human") return true;
|
|
32
|
+
if (doc.status === "deprecated") return false;
|
|
33
|
+
if (doc.effectiveFrom !== null && doc.effectiveFrom > at) return false;
|
|
34
|
+
if (doc.staleAfter !== null && doc.staleAfter <= at) return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The word a human surface shows for a state the machine surfaces decline. */
|
|
39
|
+
export type LifecycleBadge = "draft" | "effective-from" | "stale" | "deprecated";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Why the machine surfaces decline `doc` at `at`, in one word — or null when
|
|
43
|
+
* they admit it. Independent of `drafts`: a draft is a draft whether or not a
|
|
44
|
+
* preview happens to show it.
|
|
45
|
+
*/
|
|
46
|
+
export function lifecycleBadge(doc: LifecycleDoc, at: number): LifecycleBadge | null {
|
|
47
|
+
if (doc.status === "draft") return "draft";
|
|
48
|
+
if (doc.status === "deprecated") return "deprecated";
|
|
49
|
+
if (doc.effectiveFrom !== null && doc.effectiveFrom > at) return "effective-from";
|
|
50
|
+
if (doc.staleAfter !== null && doc.staleAfter <= at) return "stale";
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { refuse } from "./audience";
|
|
8
|
+
import { RULES_VERSION } from "./rules-version";
|
|
9
|
+
import { parseInstant } from "../record/instant";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `build.lock.json` as the site reads it (build spec §2–§3): the record that
|
|
13
|
+
* `ksor build` checked this tree, the `as_of` every lifecycle decision in this
|
|
14
|
+
* build is evaluated at, the registry a viewer is validated against, and the
|
|
15
|
+
* stamps every machine artefact carries (R14).
|
|
16
|
+
*
|
|
17
|
+
* Outside development the site refuses to build without a FRESH lock — one
|
|
18
|
+
* whose document hashes match the tree — because a projection of a tree
|
|
19
|
+
* nothing checked is a projection nothing governs. `pnpm dev` is the review
|
|
20
|
+
* surface and needs none (decision 7); its artefacts say so.
|
|
21
|
+
*
|
|
22
|
+
* Freshness is asked in two halves, `readLock` then `assertLockCoversTree`,
|
|
23
|
+
* with the record's own checker between them: everything answerable about the
|
|
24
|
+
* lock alone comes first, and the file-by-file comparison comes after the tree
|
|
25
|
+
* has been shown to be a record at all.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const hex64 = z.string().regex(/^[0-9a-f]{64}$/, "a sha256 hex digest");
|
|
29
|
+
const hashed = z.object({ path: z.string().min(1), sha256: hex64 });
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `as_of` and `ksor_version` are VALIDATED here, not merely required to be
|
|
33
|
+
* non-empty, because both fail open downstream when they are unreadable: an
|
|
34
|
+
* `as_of` that does not parse made every lifecycle comparison `NaN`-false, so a
|
|
35
|
+
* policy not effective until 2030 was published as current and carried no
|
|
36
|
+
* badge; a `ksor_version` the semver regex misses slipped past the
|
|
37
|
+
* `ksor-site-outdated` gate and was then stamped verbatim into every machine
|
|
38
|
+
* artefact. A lock the site cannot read cannot say what was checked.
|
|
39
|
+
*/
|
|
40
|
+
const instant = z
|
|
41
|
+
.string()
|
|
42
|
+
.refine((v) => parseInstant(v) !== null, "an ISO 8601 instant with an explicit offset");
|
|
43
|
+
const semver = z.string().regex(/^v?\d+\.\d+\.\d+/, "a version this site can compare");
|
|
44
|
+
|
|
45
|
+
const lockSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
format: z.literal(1),
|
|
48
|
+
build_id: z.string().min(1),
|
|
49
|
+
ksor_version: semver,
|
|
50
|
+
source_commit: z.string().nullable(),
|
|
51
|
+
dirty: z.boolean(),
|
|
52
|
+
as_of: instant,
|
|
53
|
+
drafts: z.enum(["hidden", "shown"]),
|
|
54
|
+
instance_sha256: hex64,
|
|
55
|
+
policy_sha256: hex64,
|
|
56
|
+
ledger_sha256: hex64,
|
|
57
|
+
ledger_entries: z.array(z.object({ id: z.string().min(1), digest: hex64 }).loose()),
|
|
58
|
+
audiences: z.object({ registry: z.array(z.string().min(1)) }).loose(),
|
|
59
|
+
documents: z.array(hashed.loose()),
|
|
60
|
+
companions: z.array(hashed.loose()),
|
|
61
|
+
assets: z.array(hashed.loose()),
|
|
62
|
+
indexes: z.array(hashed.loose()),
|
|
63
|
+
})
|
|
64
|
+
.loose();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The three files that hold the record's governance, hashed the way
|
|
68
|
+
* `composeLock` hashes them: over the TEXT, with the empty string standing for
|
|
69
|
+
* a ledger that does not exist.
|
|
70
|
+
*/
|
|
71
|
+
export interface ControlTexts {
|
|
72
|
+
readonly instance: string;
|
|
73
|
+
readonly policy: string;
|
|
74
|
+
/** Null when `.ksor/takedowns.yaml` is not there — an empty ledger. */
|
|
75
|
+
readonly ledger: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sha256Text(text: string): string {
|
|
79
|
+
return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type BuildLock = z.infer<typeof lockSchema>;
|
|
83
|
+
|
|
84
|
+
export const LOCK_FILE = "build.lock.json";
|
|
85
|
+
|
|
86
|
+
/** sha256 of a file's raw bytes, hex — what `documents[].sha256` holds. */
|
|
87
|
+
export function sha256Of(file: string): string {
|
|
88
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The lock itself: present, readable, and describing THIS instance's
|
|
93
|
+
* governance and this build's switches. Refuses `ksor-lock-missing` when
|
|
94
|
+
* absent, `ksor-lock-stale` when unreadable or when a control file has changed
|
|
95
|
+
* since it was written, and `ksor-site-outdated` when it was written by a
|
|
96
|
+
* newer ksor than the rule modules this site carries.
|
|
97
|
+
*
|
|
98
|
+
* Everything here is answerable WITHOUT looking at the record's tree, which is
|
|
99
|
+
* why it is separate from `assertLockCoversTree`: the caller checks the record
|
|
100
|
+
* in between, so a tree that is not a legal record is refused by the rule it
|
|
101
|
+
* breaks instead of by a freshness claim it was never eligible for.
|
|
102
|
+
*/
|
|
103
|
+
export function readLock(
|
|
104
|
+
root: string,
|
|
105
|
+
control: ControlTexts,
|
|
106
|
+
options: { readonly draftsRequested: boolean },
|
|
107
|
+
): BuildLock {
|
|
108
|
+
const file = path.join(root, LOCK_FILE);
|
|
109
|
+
if (!existsSync(file)) {
|
|
110
|
+
refuse(
|
|
111
|
+
"ksor-lock-missing",
|
|
112
|
+
`${LOCK_FILE} is not there`,
|
|
113
|
+
"the site projects what `ksor build` checked — without its lock this build cannot tell a checked record from an unchecked one, and a projection of an unchecked record is one nothing governs",
|
|
114
|
+
"run `ksor build` first (`pnpm build` does), then build the site; `pnpm dev` needs no lock",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
let parsed: unknown;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
120
|
+
} catch {
|
|
121
|
+
parsed = undefined;
|
|
122
|
+
}
|
|
123
|
+
const lock = lockSchema.safeParse(parsed);
|
|
124
|
+
if (!lock.success) {
|
|
125
|
+
refuse(
|
|
126
|
+
"ksor-lock-stale",
|
|
127
|
+
`${LOCK_FILE} cannot be read as a build lock (${lock.error.issues[0]?.path.join(".") || "root"}: ${lock.error.issues[0]?.message ?? "unreadable"})`,
|
|
128
|
+
"a lock this site cannot read cannot say what was checked",
|
|
129
|
+
"run `ksor build` again; if the lock was written by a newer ksor, upgrade the site with `ksor migrate --write-site`",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
// The control files before anything else about this record, the record's own
|
|
133
|
+
// rules included: a stale LEDGER or POLICY is a takedown that was lifted or
|
|
134
|
+
// an authority that was rewritten, and the lock's `ledger_entries` are the
|
|
135
|
+
// baseline the checker is then handed for `ksor-ledger-amended` — so a ledger
|
|
136
|
+
// the lock never saw is refused HERE, before anything is judged against it.
|
|
137
|
+
// The file-by-file comparison is `assertLockCoversTree`, after the checker.
|
|
138
|
+
for (const [file, want, have] of [
|
|
139
|
+
["instance.md", lock.data.instance_sha256, sha256Text(control.instance)],
|
|
140
|
+
[".ksor/governance.yaml", lock.data.policy_sha256, sha256Text(control.policy)],
|
|
141
|
+
[".ksor/takedowns.yaml", lock.data.ledger_sha256, sha256Text(control.ledger ?? "")],
|
|
142
|
+
] as const) {
|
|
143
|
+
if (want === have) continue;
|
|
144
|
+
refuse(
|
|
145
|
+
"ksor-lock-stale",
|
|
146
|
+
`${file} changed since ${LOCK_FILE} was written`,
|
|
147
|
+
"the lock's build_id is a hash over the record AND the three files that govern it, so a projection under a control file the lock never saw publishes what nothing checked — a denial lifted by deleting a line would otherwise leave the lock valid",
|
|
148
|
+
"run `ksor build` again and commit the lock with the change; lift a denial with `ksor takedown --revoke <id>`, never by editing the ledger",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
// Both directions. The reverse — a `drafts: shown` lock and no KSOR_DRAFTS —
|
|
152
|
+
// is the dangerous one: one preview build accidentally committed publishes
|
|
153
|
+
// every draft on every later production deploy, with no environment signal
|
|
154
|
+
// and nothing red (`noindex` is a crawler hint, not a control).
|
|
155
|
+
if (options.draftsRequested !== (lock.data.drafts === "shown")) {
|
|
156
|
+
refuse(
|
|
157
|
+
"ksor-lock-stale",
|
|
158
|
+
options.draftsRequested
|
|
159
|
+
? `KSOR_DRAFTS=show was requested, but ${LOCK_FILE} was built with drafts hidden`
|
|
160
|
+
: `${LOCK_FILE} was built with drafts SHOWN, and this build did not ask for them`,
|
|
161
|
+
"the lock's build_id covers the drafts switch, so a site and a lock that disagree about it would stamp every artefact with an id that does not describe it — and a preview lock is not a publishing lock",
|
|
162
|
+
options.draftsRequested
|
|
163
|
+
? "run `KSOR_DRAFTS=show ksor build` before the site build, or build without KSOR_DRAFTS"
|
|
164
|
+
: "run `ksor build` (without KSOR_DRAFTS) and commit the lock, or build the preview with `KSOR_DRAFTS=show`",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (outdated(lock.data.ksor_version, RULES_VERSION)) {
|
|
168
|
+
refuse(
|
|
169
|
+
"ksor-site-outdated",
|
|
170
|
+
`${LOCK_FILE} was built by ksor ${lock.data.ksor_version}, and this site carries rule modules from ${RULES_VERSION}`,
|
|
171
|
+
"the site would project the record with rules older than the ones that checked it — a document the newer checker admits under a rule this site does not know is a document this build gets wrong",
|
|
172
|
+
"upgrade the site's rule modules: `ksor migrate --write-site` offers the byte-copied modules as diffs (decision 4)",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return lock.data;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Does the lock describe THIS tree, file by file? Refuses `ksor-lock-stale`
|
|
180
|
+
* naming the first path that disagrees.
|
|
181
|
+
*
|
|
182
|
+
* Run AFTER the record's own checker, not before it, because this comparison
|
|
183
|
+
* cannot tell a document that changed from a file the record may not hold at
|
|
184
|
+
* all — and it answers both the same way. An `.mdx` dropped into `knowledge/`
|
|
185
|
+
* is refused by name (`ksor-file-type`: the bundle is CommonMark), so no
|
|
186
|
+
* `ksor build` can ever have listed it here; reporting it as "asset notes.mdx
|
|
187
|
+
* is in the tree and not in the lock" told the operator to re-run the build
|
|
188
|
+
* that refuses the file, and named neither the rule nor the fix (found in
|
|
189
|
+
* review, 2026-08-25). The record is judged by its own rules first; the lock's
|
|
190
|
+
* claim about the tree is asked once the tree is a record.
|
|
191
|
+
*
|
|
192
|
+
* Nothing is staged in between, so the ordering costs a refused build one
|
|
193
|
+
* checker pass and can never publish anything.
|
|
194
|
+
*/
|
|
195
|
+
export function assertLockCoversTree(
|
|
196
|
+
lock: BuildLock,
|
|
197
|
+
files: {
|
|
198
|
+
readonly documents: ReadonlyMap<string, string>;
|
|
199
|
+
readonly companions: ReadonlyMap<string, string>;
|
|
200
|
+
readonly assets: ReadonlyMap<string, string>;
|
|
201
|
+
/** The COMMITTED `knowledge/**\/index.md`, not the ones this build will stage. */
|
|
202
|
+
readonly indexes: ReadonlyMap<string, string>;
|
|
203
|
+
},
|
|
204
|
+
): void {
|
|
205
|
+
const stale = firstStale(lock, files);
|
|
206
|
+
if (stale === null) return;
|
|
207
|
+
refuse(
|
|
208
|
+
"ksor-lock-stale",
|
|
209
|
+
`${LOCK_FILE} does not match the tree: ${stale}`,
|
|
210
|
+
"the lock records the exact record `ksor build` checked; a document that changed since was never checked, so this build would publish what nothing governs",
|
|
211
|
+
"run `ksor build` again and commit the lock with the change",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The first path whose presence or hash disagrees between lock and tree, described; null when none. */
|
|
216
|
+
function firstStale(
|
|
217
|
+
lock: BuildLock,
|
|
218
|
+
files: {
|
|
219
|
+
readonly documents: ReadonlyMap<string, string>;
|
|
220
|
+
readonly companions: ReadonlyMap<string, string>;
|
|
221
|
+
readonly assets: ReadonlyMap<string, string>;
|
|
222
|
+
readonly indexes: ReadonlyMap<string, string>;
|
|
223
|
+
},
|
|
224
|
+
): string | null {
|
|
225
|
+
for (const [kind, entries, tree] of [
|
|
226
|
+
["document", lock.documents, files.documents],
|
|
227
|
+
["companion", lock.companions, files.companions],
|
|
228
|
+
// Assets last and never omitted: the site publishes their bytes, and for a
|
|
229
|
+
// record whose diagrams carry the substance a lock that stops at the
|
|
230
|
+
// markdown does not cover what the build actually serves.
|
|
231
|
+
["asset", lock.assets, files.assets],
|
|
232
|
+
// …and the INDEXES, which are the one thing under `knowledge/` the build
|
|
233
|
+
// WRITES rather than reads. The site never copies them — it regenerates a
|
|
234
|
+
// per-viewer set — so this comparison is the only thing that can see a
|
|
235
|
+
// committed index drift away from the bytes `ksor build` wrote (a merge
|
|
236
|
+
// resolution, and nobody re-ran the build). `ksor ingest` has always
|
|
237
|
+
// compared them; the site published what the door then refused.
|
|
238
|
+
//
|
|
239
|
+
// Against the COMMITTED bytes, never against the staged ones: the lock
|
|
240
|
+
// records the WHOLE record's indexes, and a per-viewer stage regenerates a
|
|
241
|
+
// legitimately shorter one, so comparing what the stage is about to write
|
|
242
|
+
// would refuse every correct build of a record that restricts anything.
|
|
243
|
+
["index", lock.indexes, files.indexes],
|
|
244
|
+
] as const) {
|
|
245
|
+
const locked = new Map(entries.map((e) => [e.path, e.sha256] as const));
|
|
246
|
+
for (const [rel, abs] of tree) {
|
|
247
|
+
const want = locked.get(rel);
|
|
248
|
+
if (want === undefined) return `${kind} ${rel} is in the tree and not in the lock`;
|
|
249
|
+
if (want !== sha256Of(abs)) return `${kind} ${rel} changed since the lock was written`;
|
|
250
|
+
}
|
|
251
|
+
for (const rel of locked.keys()) {
|
|
252
|
+
if (!tree.has(rel)) return `${kind} ${rel} is in the lock and no longer in the tree`;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Is a lock written by `lockVersion` newer than the rules this site carries?
|
|
260
|
+
* Numeric semver, pre-release tags ignored. NEITHER unparsable side is
|
|
261
|
+
* "current": a SITE stamp that is not a version is the template's literal, so
|
|
262
|
+
* `ksor init` never finished; a LOCK version that is not a version is a lock
|
|
263
|
+
* this site cannot compare against at all. Both count as outdated — this used
|
|
264
|
+
* to return false for the lock half, which let `"999"` past the gate and then
|
|
265
|
+
* stamped it into every machine artefact. `lockSchema` now refuses that shape
|
|
266
|
+
* first, so this branch is the second lock on the same door.
|
|
267
|
+
*/
|
|
268
|
+
export function outdated(lockVersion: string, rulesVersion: string): boolean {
|
|
269
|
+
const a = parts(lockVersion);
|
|
270
|
+
const b = parts(rulesVersion);
|
|
271
|
+
if (a === null) return true;
|
|
272
|
+
if (b === null) return true;
|
|
273
|
+
for (let i = 0; i < 3; i += 1) {
|
|
274
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parts(version: string): number[] | null {
|
|
280
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
281
|
+
return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
282
|
+
}
|
|
@@ -36,6 +36,21 @@
|
|
|
36
36
|
* - case. The kernel lowercased the tie key and the site did not, so
|
|
37
37
|
* `apple.md` and `Banana.md` came out in opposite orders.
|
|
38
38
|
*
|
|
39
|
+
* Two more, found in the OKF-native review and fixed the same way:
|
|
40
|
+
*
|
|
41
|
+
* - folders. The index generator emitted every concept bullet and THEN every
|
|
42
|
+
* folder bullet, so a folder could never sort between two documents, while
|
|
43
|
+
* the tree adapter sorted concepts and directories in one list. The site
|
|
44
|
+
* takes its whole reading order from the generated indexes, so an agent
|
|
45
|
+
* asking `outline` "what do I read first" and a reader on the site were
|
|
46
|
+
* told different documents. The ratified row has always said they
|
|
47
|
+
* interleave.
|
|
48
|
+
* - the folder's own key. The generator folded over every concept BENEATH a
|
|
49
|
+
* directory; the adapter folded over the directory's OWN concepts only, so
|
|
50
|
+
* a folder whose ordered documents live one level deeper was unordered on
|
|
51
|
+
* one surface and first on the other. {@link folderOrder} is now the one
|
|
52
|
+
* answer both call.
|
|
53
|
+
*
|
|
39
54
|
* No imports: a leaf, so it is testable in isolation and safe to copy.
|
|
40
55
|
*/
|
|
41
56
|
|
|
@@ -105,3 +120,25 @@ export function compareSiblings(a: Sibling, b: Sibling): number {
|
|
|
105
120
|
if (a.order !== b.order) return a.order < b.order ? -1 : 1;
|
|
106
121
|
return codePointCompare(a.tie, b.tie);
|
|
107
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A directory's sort key: the lowest order among the concepts anywhere BENEATH
|
|
126
|
+
* it, descendants included — a folder sorts where its first concept does.
|
|
127
|
+
*
|
|
128
|
+
* Descendants and not just the directory's own concepts, because a folder whose
|
|
129
|
+
* documents live one level deeper (`alpha/deep/a.md`) still has a first thing to
|
|
130
|
+
* read, and calling it unordered files the whole folder behind every ordered
|
|
131
|
+
* sibling. `ordersByDir` maps a bundle-relative directory to the orders of the
|
|
132
|
+
* concepts sitting DIRECTLY in it, which is the shape both callers already hold.
|
|
133
|
+
*/
|
|
134
|
+
export function folderOrder(
|
|
135
|
+
ordersByDir: Iterable<readonly [string, readonly number[]]>,
|
|
136
|
+
dir: string,
|
|
137
|
+
): number {
|
|
138
|
+
let min = UNORDERED;
|
|
139
|
+
for (const [d, orders] of ordersByDir) {
|
|
140
|
+
if (d !== dir && !d.startsWith(`${dir}/`)) continue;
|
|
141
|
+
for (const o of orders) if (o < min) min = o;
|
|
142
|
+
}
|
|
143
|
+
return min;
|
|
144
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a link inside a document names, as a route of THIS build.
|
|
3
|
+
*
|
|
4
|
+
* OKF §6.1 allows two forms and the record spec (§2.3) carries both:
|
|
5
|
+
* bundle-absolute (`/policies/x.md`, resolved against `knowledge/`) and
|
|
6
|
+
* relative (`x.md`, `./x.md`, `../x.md`, against the document's own
|
|
7
|
+
* directory), with `.md` optional in each. The shell resolves only the `./`
|
|
8
|
+
* and `../` forms (fumadocs-core 16.14.5, `resolveHref` returns anything else
|
|
9
|
+
* untouched), so a bundle-absolute link left the record's frame entirely and a
|
|
10
|
+
* bare `x.md` was resolved by the browser against the page's ROUTE rather than
|
|
11
|
+
* its directory: both 404'd from every page, found live 2026-08-25 as prefetch
|
|
12
|
+
* failures in the console.
|
|
13
|
+
*
|
|
14
|
+
* So the record's OWN resolver decides — `resolveLink` is the same function the
|
|
15
|
+
* checker uses for the widening rule, which is what makes a link the checker
|
|
16
|
+
* accepted a link this site can serve.
|
|
17
|
+
*
|
|
18
|
+
* Pure, and the whole decision, so it is testable without a site install
|
|
19
|
+
* (packages/ksor/src/record-href.integration.test.ts).
|
|
20
|
+
*/
|
|
21
|
+
import { resolveLink } from "../record/citations";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `href` as this build serves it: a route when the link names a concept of this
|
|
25
|
+
* build, and the author's own text otherwise.
|
|
26
|
+
*
|
|
27
|
+
* Otherwise the href is handed back UNCHANGED, which is what carries assets,
|
|
28
|
+
* external urls and same-page anchors through untouched.
|
|
29
|
+
*
|
|
30
|
+
* Two cases fall through to a link this build cannot serve, and both are
|
|
31
|
+
* deliberate. A per-viewer build stages a SUBSET, so a link to a concept this
|
|
32
|
+
* viewer may not see must not be rewritten — inventing a route would publish
|
|
33
|
+
* the existence of a document the viewer was not given, which is exactly what
|
|
34
|
+
* the audience rule withholds. And a link to a concept that exists in no build
|
|
35
|
+
* is an authoring mistake the CHECKER owns: rewriting it here would hide it
|
|
36
|
+
* from the one thing that can name the file and the line.
|
|
37
|
+
*/
|
|
38
|
+
export function recordHref(
|
|
39
|
+
href: string | undefined,
|
|
40
|
+
sourceId: string,
|
|
41
|
+
routes: ReadonlyMap<string, string>,
|
|
42
|
+
): string | undefined {
|
|
43
|
+
if (href === undefined) return href;
|
|
44
|
+
// Classified on the value a BROWSER sees. Parsing a URL strips leading C0
|
|
45
|
+
// controls and spaces (WHATWG URL §4.4), so `\tjavascript:…` is a scheme to
|
|
46
|
+
// everything that follows the link and was not one to this test. An href
|
|
47
|
+
// carrying a scheme was therefore read as a record link — and one whose
|
|
48
|
+
// mangled path happened to resolve (`\tjavascript:x/../policies/travel`) was
|
|
49
|
+
// rewritten into a route of this build.
|
|
50
|
+
//
|
|
51
|
+
// Defence in depth rather than a live hole: the same regex runs in
|
|
52
|
+
// `record/citations.ts`, over the raw markdown, so a link written this way is
|
|
53
|
+
// classified as a record link there too, resolves to nothing, and the build
|
|
54
|
+
// refuses it `ksor-link-dead` before the page exists. This makes the guard
|
|
55
|
+
// mean what it says on the value it is guarding.
|
|
56
|
+
// eslint-disable-next-line no-control-regex -- the control range is the point
|
|
57
|
+
const probe = href.replace(/^[\u0000-\u0020]+/, "");
|
|
58
|
+
// A same-page anchor, a protocol-relative url, and anything carrying a
|
|
59
|
+
// scheme (`https:`, `mailto:`) are not record links and are never touched.
|
|
60
|
+
if (probe === "" || probe.startsWith("#") || probe.startsWith("//")) return href;
|
|
61
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(probe)) return href;
|
|
62
|
+
const id = resolveLink(sourceId, href);
|
|
63
|
+
if (id === null) return href;
|
|
64
|
+
const url = routes.get(id);
|
|
65
|
+
if (url === undefined) return href;
|
|
66
|
+
const hash = href.indexOf("#");
|
|
67
|
+
return hash === -1 ? url : `${url}${href.slice(hash)}`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ComponentProps, FC } from "react";
|
|
2
|
+
import type { LoaderConfig, LoaderOutput, Page } from "fumadocs-core/source";
|
|
3
|
+
import { createRelativeLink } from "fumadocs-ui/mdx";
|
|
4
|
+
|
|
5
|
+
import { conceptIdOfPath } from "./governance";
|
|
6
|
+
import { recordHref } from "./record-href";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The MDX anchor for a governed document: the shell's relative link, with the
|
|
10
|
+
* record's own link rule in front of it (lib/record-href.ts, which carries the
|
|
11
|
+
* why).
|
|
12
|
+
*
|
|
13
|
+
* The routes are the pages of THIS build, so the subset a per-viewer build
|
|
14
|
+
* staged is exactly the set a link may resolve into.
|
|
15
|
+
*/
|
|
16
|
+
export function recordLink<C extends LoaderConfig>(
|
|
17
|
+
source: LoaderOutput<C>,
|
|
18
|
+
page: Page | C["page"],
|
|
19
|
+
): FC<ComponentProps<"a">> {
|
|
20
|
+
const Relative = createRelativeLink(source, page);
|
|
21
|
+
const routes = new Map(source.getPages().map((p) => [conceptIdOfPath(p.path), p.url] as const));
|
|
22
|
+
const sourceId = conceptIdOfPath(page.path);
|
|
23
|
+
return function RecordLink(props: ComponentProps<"a">) {
|
|
24
|
+
return <Relative {...props} href={recordHref(props.href, sourceId, routes)} />;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ksor version whose rule modules this site carries — stamped by
|
|
3
|
+
* `ksor init` and offered as a diff by `ksor migrate --write-site` (decision 4:
|
|
4
|
+
* the site is adopter-owned, so nothing here updates itself).
|
|
5
|
+
*
|
|
6
|
+
* `build.lock.json` records the version that built it. When that is NEWER than
|
|
7
|
+
* this stamp, the site would project a record with rules older than the ones
|
|
8
|
+
* that checked it — the visibility leak's fifth door — so the build refuses
|
|
9
|
+
* `ksor-site-outdated` instead (build spec §3).
|
|
10
|
+
*/
|
|
11
|
+
export const RULES_VERSION: string = "KSOR-STAMP-VERSION";
|