@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,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record checker — ONE rule set (record spec §6), run by `ksor build`,
|
|
3
|
+
* `ksor ingest` and the emitted `check.mjs`. It reads an in-memory tree so
|
|
4
|
+
* the same function judges a checkout, a staged projection and a test
|
|
5
|
+
* fixture identically; `load.ts` is the one place the filesystem is touched.
|
|
6
|
+
*/
|
|
7
|
+
import { ATTACHMENT_SUFFIXES, attachmentKindOf, parentDocumentOf } from "../lib/attachment-rule";
|
|
8
|
+
import { mayReach } from "../lib/audience-rule";
|
|
9
|
+
import { checkFootnotes, linkTargets, resolveLink } from "./citations";
|
|
10
|
+
import { splitFrontmatter } from "./frontmatter";
|
|
11
|
+
import { checkHygiene } from "./hygiene";
|
|
12
|
+
import { generateIndexes } from "./index-file";
|
|
13
|
+
import { parseInstanceDocument } from "./instance";
|
|
14
|
+
import {
|
|
15
|
+
checkLedgerActors,
|
|
16
|
+
checkLedgerAgainstTree,
|
|
17
|
+
checkLedgerAppendOnly,
|
|
18
|
+
ledgerDigests,
|
|
19
|
+
parseLedger,
|
|
20
|
+
type LedgerBaseline,
|
|
21
|
+
} from "./ledger";
|
|
22
|
+
import { parsePolicy, resolveApprovers, resolveOwner, type Policy } from "./policy";
|
|
23
|
+
import { conceptIdOf, parseConcept, type Concept } from "./profile";
|
|
24
|
+
import { sortRefusals, type Refusal } from "./refusal";
|
|
25
|
+
|
|
26
|
+
export interface RecordFiles {
|
|
27
|
+
/** Record-relative path → text, for every file the checker reads (`knowledge/**`, `.ksor/*.yaml`, `instance.md`). */
|
|
28
|
+
readonly files: ReadonlyMap<string, string>;
|
|
29
|
+
/** Record-relative directories under `knowledge/`, empty ones included. */
|
|
30
|
+
readonly dirs: readonly string[];
|
|
31
|
+
/** Every other file under `knowledge/` (images, strays) with its bytes; OS junk excluded. */
|
|
32
|
+
readonly assets?: ReadonlyMap<string, Uint8Array>;
|
|
33
|
+
/** Symbolic links the loader met and did not follow. */
|
|
34
|
+
readonly symlinks?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CheckOptions {
|
|
38
|
+
/** `check` is read-only and refuses a stale index; `build` regenerates and never does. */
|
|
39
|
+
readonly mode: "check" | "build";
|
|
40
|
+
/** What the ledger must still contain, entry by entry (git history, the committed lock). */
|
|
41
|
+
/** Required, not optional: a caller that forgets these silently gets the STRICT rule, which is how ingest came to refuse a departed authority the site had published. */
|
|
42
|
+
readonly ledgerBaselines: readonly LedgerBaseline[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CheckResult {
|
|
46
|
+
readonly refusals: readonly Refusal[];
|
|
47
|
+
/** Record-relative index path → bytes, generated from the tree. */
|
|
48
|
+
readonly indexes: ReadonlyMap<string, string>;
|
|
49
|
+
readonly concepts: readonly Concept[];
|
|
50
|
+
/** `(id, digest)` per entry, in file order — what the lock records so the next build can compare text. */
|
|
51
|
+
readonly ledgerEntries: readonly { readonly id: string; readonly digest: string }[];
|
|
52
|
+
readonly policy: Policy | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const KNOWLEDGE = "knowledge/";
|
|
56
|
+
const POLICY_PATH = ".ksor/governance.yaml";
|
|
57
|
+
const LEDGER_PATH = ".ksor/takedowns.yaml";
|
|
58
|
+
const INSTANCE_PATH = "instance.md";
|
|
59
|
+
/** What a link may resolve to besides a concept: companions, assets, directories, indexes, the root. */
|
|
60
|
+
interface LinkTargets {
|
|
61
|
+
readonly concepts: ReadonlyMap<string, Concept>;
|
|
62
|
+
readonly exists: (id: string) => boolean;
|
|
63
|
+
/** Bundle-relative ids of the record's asset files — `secret/org-chart.png`. */
|
|
64
|
+
readonly assets: ReadonlySet<string>;
|
|
65
|
+
/** Bundle-relative directories — `secret`. A link may name one, and it inherits by POSITION. */
|
|
66
|
+
readonly directories: ReadonlySet<string>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function checkRecord(record: RecordFiles, options: CheckOptions): CheckResult {
|
|
70
|
+
const refusals: Refusal[] = [];
|
|
71
|
+
const paths = [...record.files.keys()].sort();
|
|
72
|
+
|
|
73
|
+
const policyResult = parsePolicy(record.files.get(POLICY_PATH) ?? null, POLICY_PATH);
|
|
74
|
+
const policy = policyResult.ok ? policyResult.policy : null;
|
|
75
|
+
if (!policyResult.ok) refusals.push(...policyResult.refusals);
|
|
76
|
+
|
|
77
|
+
// `null` when instance.md could not be read: the index generator still needs
|
|
78
|
+
// a heading, and the staleness block still needs to know it is guessing.
|
|
79
|
+
const instanceTitle = checkInstance(record.files.get(INSTANCE_PATH) ?? null, refusals);
|
|
80
|
+
const title = instanceTitle ?? "Index";
|
|
81
|
+
|
|
82
|
+
const assets = record.assets ?? new Map<string, Uint8Array>();
|
|
83
|
+
|
|
84
|
+
// ── the bundle: concepts, companions, reserved names ───────────────────
|
|
85
|
+
// A document that fails to parse is not a concept, so the indexes generated
|
|
86
|
+
// below are the indexes of a DIFFERENT tree — see the staleness block. Its
|
|
87
|
+
// id is kept, because every rule that asks "is this in the tree?" must not
|
|
88
|
+
// read the absence of a CONCEPT as the absence of a DOCUMENT: the ledger and
|
|
89
|
+
// the supersession pointer both did, and both fabricated a refusal about a
|
|
90
|
+
// file still sitting in the checkout (2026-08-25 review).
|
|
91
|
+
const unreadable = new Set<string>();
|
|
92
|
+
const concepts = new Map<string, Concept>();
|
|
93
|
+
const bodies = new Map<string, string>();
|
|
94
|
+
for (const path of paths) {
|
|
95
|
+
if (!path.startsWith(KNOWLEDGE)) continue;
|
|
96
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
97
|
+
if (name === "log.md" || name === "README.md") {
|
|
98
|
+
refusals.push({
|
|
99
|
+
slug: "ksor-reserved-name",
|
|
100
|
+
path,
|
|
101
|
+
why: `\`${name}\` is reserved — \`log.md\` by OKF §9 and \`README.md\` by this profile; neither is a concept`,
|
|
102
|
+
fix: "move the prose into a named concept such as `overview.md` and delete the file",
|
|
103
|
+
});
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (name === "index.md" || attachmentKindOf(name) !== null || !name.endsWith(".md")) continue;
|
|
107
|
+
// `.mdx` and every other stray are the hygiene rules' to name.
|
|
108
|
+
const text = record.files.get(path) ?? "";
|
|
109
|
+
const split = splitFrontmatter(text, path);
|
|
110
|
+
if (!split.ok) {
|
|
111
|
+
refusals.push(split.refusal);
|
|
112
|
+
unreadable.add(conceptIdOf(path));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const parsed = parseConcept(path, split.frontmatter ?? {});
|
|
116
|
+
if (!parsed.ok) {
|
|
117
|
+
refusals.push(...parsed.refusals);
|
|
118
|
+
unreadable.add(conceptIdOf(path));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
concepts.set(parsed.concept.id, parsed.concept);
|
|
122
|
+
bodies.set(parsed.concept.id, split.body);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── indexes ────────────────────────────────────────────────────────────
|
|
126
|
+
const dirs = record.dirs
|
|
127
|
+
.filter((d) => d.startsWith(KNOWLEDGE))
|
|
128
|
+
.map((d) => d.slice(KNOWLEDGE.length));
|
|
129
|
+
const generated = generateIndexes({
|
|
130
|
+
title,
|
|
131
|
+
concepts: [...concepts.values()].map((c) => ({
|
|
132
|
+
id: c.id,
|
|
133
|
+
title: c.title,
|
|
134
|
+
description: c.description,
|
|
135
|
+
order: c.order,
|
|
136
|
+
})),
|
|
137
|
+
dirs,
|
|
138
|
+
});
|
|
139
|
+
const indexes = new Map([...generated].map(([p, text]) => [`${KNOWLEDGE}${p}`, text]));
|
|
140
|
+
const dirSet = new Set(record.dirs);
|
|
141
|
+
const targets: LinkTargets = {
|
|
142
|
+
concepts,
|
|
143
|
+
assets: new Set([...assets.keys()].map((p) => p.slice(KNOWLEDGE.length))),
|
|
144
|
+
directories: new Set(dirs),
|
|
145
|
+
exists: (id) =>
|
|
146
|
+
id === "" ||
|
|
147
|
+
concepts.has(id) ||
|
|
148
|
+
record.files.has(`${KNOWLEDGE}${id}.md`) ||
|
|
149
|
+
record.files.has(`${KNOWLEDGE}${id}`) ||
|
|
150
|
+
assets.has(`${KNOWLEDGE}${id}`) ||
|
|
151
|
+
dirSet.has(`${KNOWLEDGE}${id}`) ||
|
|
152
|
+
indexes.has(`${KNOWLEDGE}${id}.md`),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
for (const path of paths) {
|
|
156
|
+
if (!path.startsWith(KNOWLEDGE)) continue;
|
|
157
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
158
|
+
// The canonical rule (`lib/attachment-rule.ts`), never a copy of it. The
|
|
159
|
+
// regex that used to sit here was a THIRD hand-written list and had already
|
|
160
|
+
// drifted — `.summary.mdx` was in the canonical one and not in it, so an
|
|
161
|
+
// `.mdx` summary got no orphan check, no `type: Summary` check and none of
|
|
162
|
+
// its parent's governance. `hygiene.ts` happens to refuse every `.mdx`, but
|
|
163
|
+
// the mask was in another module from the drift, which is the arrangement
|
|
164
|
+
// decision 18 exists to end.
|
|
165
|
+
const kind = attachmentKindOf(base);
|
|
166
|
+
if (kind === null) continue;
|
|
167
|
+
const dir = path.slice(0, path.lastIndexOf("/") + 1);
|
|
168
|
+
const parentName = parentDocumentOf(base)!;
|
|
169
|
+
const parentId = conceptIdOf(`${dir}${parentName}`);
|
|
170
|
+
// A generated index is not a document (record spec §1): no route, no node,
|
|
171
|
+
// no llms.txt line, no governance of its own — so nothing can attach to it,
|
|
172
|
+
// and decision 27 retires the `index.summary.md` row from the canonical
|
|
173
|
+
// table with the authored index. Refused BY NAME rather than left to the
|
|
174
|
+
// orphan rule, which cannot see it: the orphan rule asks whether the parent
|
|
175
|
+
// FILE is in the tree, and the generated `index.md` is committed, so it
|
|
176
|
+
// passed. Everything downstream then declined to publish it — staging
|
|
177
|
+
// gathers companions of admitted CONCEPTS only, and `index.md` is not one —
|
|
178
|
+
// so the file was accepted, stamped into the lock's `companions[]` and into
|
|
179
|
+
// `build_id`, and rendered nowhere, ever, in silence.
|
|
180
|
+
if (parentName === "index.md") {
|
|
181
|
+
refusals.push({
|
|
182
|
+
slug: "ksor-attachment-of-index",
|
|
183
|
+
path,
|
|
184
|
+
why: `\`${dir}index.md\` is a GENERATED index, not a document — it has no route, no node and no governance of its own, so nothing can be attached to it; this file would be accepted here, stamped into \`build.lock.json\` and published on no surface at all`,
|
|
185
|
+
fix: `attach it to a document instead: move the prose into a named concept such as \`${dir}overview.md\` and rename this to \`${dir}overview.summary.md\`, or delete it`,
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!concepts.has(parentId) && !record.files.has(`${KNOWLEDGE}${parentId}.md`)) {
|
|
190
|
+
refusals.push({
|
|
191
|
+
slug: "ksor-attachment-orphan",
|
|
192
|
+
path,
|
|
193
|
+
why: `no \`${parentId}.md\` exists for this attachment to belong to — it has no identity of its own`,
|
|
194
|
+
fix: "restore the parent document, or delete the attachment",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (kind !== "summary") continue;
|
|
198
|
+
const split = splitFrontmatter(record.files.get(path) ?? "", path);
|
|
199
|
+
if (!split.ok) {
|
|
200
|
+
refusals.push(split.refusal);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const fm = split.frontmatter;
|
|
204
|
+
const keys = fm === null ? [] : Object.keys(fm);
|
|
205
|
+
if (fm === null || keys.length !== 1 || fm["type"] !== "Summary") {
|
|
206
|
+
refusals.push({
|
|
207
|
+
slug: "ksor-attachment-frontmatter",
|
|
208
|
+
path,
|
|
209
|
+
why: `a summary's frontmatter is exactly \`type: Summary\` — it inherits its parent's audience, status and takedown, and any other key would claim governance a non-node cannot carry (found: ${keys.length === 0 ? "none" : keys.join(", ")})`,
|
|
210
|
+
fix: "write exactly these three lines:\n---\ntype: Summary\n---",
|
|
211
|
+
});
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const parent = concepts.get(parentId);
|
|
215
|
+
if (parent !== undefined)
|
|
216
|
+
checkLinks(path, parent.audience, split.body, parentId, targets, refusals);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
refusals.push(
|
|
220
|
+
...checkHygiene({
|
|
221
|
+
textPaths: paths.filter((p) => p.startsWith(KNOWLEDGE)),
|
|
222
|
+
assets,
|
|
223
|
+
dirs: record.dirs,
|
|
224
|
+
symlinks: record.symlinks ?? [],
|
|
225
|
+
conceptIds: new Set(concepts.keys()),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// ── rules that need the policy ─────────────────────────────────────────
|
|
230
|
+
for (const concept of concepts.values()) {
|
|
231
|
+
const body = bodies.get(concept.id) ?? "";
|
|
232
|
+
refusals.push(...checkFootnotes(concept.path, body, concept.sourceIds));
|
|
233
|
+
checkLinks(concept.path, concept.audience, body, concept.id, targets, refusals);
|
|
234
|
+
checkSupersession(concept, concepts, unreadable, refusals);
|
|
235
|
+
if (policy !== null) checkAgainstPolicy(concept, policy, refusals);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Staleness is only answerable when the generator's inputs were ALL readable
|
|
239
|
+
// — every document, and the instance whose title is the root index's heading.
|
|
240
|
+
// A refused document is not a concept, so its directory generates a different
|
|
241
|
+
// index or none at all, and comparing against that produced one extra refusal
|
|
242
|
+
// per affected directory AND per ancestor — each prescribing "run `ksor
|
|
243
|
+
// build`", which refuses on the real error and writes nothing, and each
|
|
244
|
+
// saying of a correct index that it belongs to "a directory that earns none".
|
|
245
|
+
// A refused instance did the same to the root index through the fallback
|
|
246
|
+
// heading, so a single typo in instance.md printed two errors and sent the
|
|
247
|
+
// operator at the one they cannot act on (2026-08-25 review). One bad input,
|
|
248
|
+
// one problem; fix it and the next run answers this honestly.
|
|
249
|
+
if (options.mode === "check" && unreadable.size === 0 && instanceTitle !== null) {
|
|
250
|
+
const expected = new Set(indexes.keys());
|
|
251
|
+
const committed = paths.filter((p) => p.startsWith(KNOWLEDGE) && p.endsWith("/index.md"));
|
|
252
|
+
for (const path of new Set([...expected, ...committed])) {
|
|
253
|
+
if (record.files.get(path) === indexes.get(path)) continue;
|
|
254
|
+
refusals.push({
|
|
255
|
+
slug: "ksor-index-stale",
|
|
256
|
+
path,
|
|
257
|
+
why: indexes.has(path)
|
|
258
|
+
? "the committed index does not match what the tree generates — an index is never authored"
|
|
259
|
+
: "an index exists for a directory that earns none",
|
|
260
|
+
fix: "run `ksor build`, which regenerates every index, and commit the result",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── the ledger ─────────────────────────────────────────────────────────
|
|
266
|
+
const ledgerResult = parseLedger(record.files.get(LEDGER_PATH) ?? null, LEDGER_PATH);
|
|
267
|
+
let ledgerEntries: readonly { readonly id: string; readonly digest: string }[] = [];
|
|
268
|
+
if (!ledgerResult.ok) {
|
|
269
|
+
refusals.push(...ledgerResult.refusals);
|
|
270
|
+
} else {
|
|
271
|
+
const ledger = ledgerResult.ledger;
|
|
272
|
+
ledgerEntries = ledgerDigests(ledger);
|
|
273
|
+
// The baselines are the WHOLE of the departed-authority rule, and this call
|
|
274
|
+
// used to omit them — two arguments, so `baselines` took its default and the
|
|
275
|
+
// accepted set was always empty, while `options.ledgerBaselines` sat right
|
|
276
|
+
// here and was forwarded to `checkLedgerAppendOnly` one line below. The
|
|
277
|
+
// BOTH the parameter and `CheckOptions.ledgerBaselines` are required now,
|
|
278
|
+
// so a caller that forgets does not compile. Requiring only the inner
|
|
279
|
+
// parameter left the public seam optional, and the next caller did forget:
|
|
280
|
+
// ingest passed `{ mode: "build" }` and got the strict rule in silence.
|
|
281
|
+
if (policy !== null) {
|
|
282
|
+
refusals.push(...checkLedgerActors(ledger, policy.takedownActors, options.ledgerBaselines));
|
|
283
|
+
}
|
|
284
|
+
refusals.push(
|
|
285
|
+
...checkLedgerAgainstTree(ledger, {
|
|
286
|
+
documentIds: new Set([...concepts.keys(), ...unreadable]),
|
|
287
|
+
dirs: new Set(dirs),
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
refusals.push(...checkLedgerAppendOnly(ledger, options.ledgerBaselines));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
refusals: sortRefusals(refusals),
|
|
295
|
+
indexes,
|
|
296
|
+
concepts: [...concepts.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
297
|
+
ledgerEntries,
|
|
298
|
+
policy,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The instance title for the root index, or `null` when the instance could not
|
|
304
|
+
* be read — a distinction the caller needs, not decoration: the fallback
|
|
305
|
+
* heading generates a root index nothing in the tree matches, and reporting
|
|
306
|
+
* THAT as staleness prints a second refusal whose remedy cannot run.
|
|
307
|
+
* Refuses a pre-profile instance (one reader: `record/instance.ts`).
|
|
308
|
+
*/
|
|
309
|
+
function checkInstance(text: string | null, refusals: Refusal[]): string | null {
|
|
310
|
+
if (text === null) {
|
|
311
|
+
refusals.push({
|
|
312
|
+
slug: "ksor-instance-format",
|
|
313
|
+
path: INSTANCE_PATH,
|
|
314
|
+
why: "instance.md is missing — it says what this record is authoritative for; without it nothing states the record's scope and the MCP server has no instructions",
|
|
315
|
+
fix: "restore instance.md from git history, or run the intake-interview skill to write it",
|
|
316
|
+
});
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
const parsed = parseInstanceDocument(text, INSTANCE_PATH);
|
|
320
|
+
if (!parsed.ok) {
|
|
321
|
+
refusals.push(...parsed.refusals);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
return parsed.instance.title;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function checkLinks(
|
|
328
|
+
path: string,
|
|
329
|
+
audience: readonly string[],
|
|
330
|
+
body: string,
|
|
331
|
+
sourceId: string,
|
|
332
|
+
targets: LinkTargets,
|
|
333
|
+
refusals: Refusal[],
|
|
334
|
+
): void {
|
|
335
|
+
const seen = new Set<string>();
|
|
336
|
+
for (const target of linkTargets(body)) {
|
|
337
|
+
const id = resolveLink(sourceId, target);
|
|
338
|
+
if (id === null) {
|
|
339
|
+
if (seen.has(target)) continue;
|
|
340
|
+
seen.add(target);
|
|
341
|
+
refusals.push({
|
|
342
|
+
slug: "ksor-link-escapes",
|
|
343
|
+
path,
|
|
344
|
+
why: `\`${target}\` leaves the record — the record must survive without the system, and an outward link breaks the walk-away promise`,
|
|
345
|
+
fix: "move the file into knowledge/ beside the document, or use an absolute URL",
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (seen.has(id)) continue;
|
|
350
|
+
seen.add(id);
|
|
351
|
+
if (!targets.exists(id)) {
|
|
352
|
+
refusals.push({
|
|
353
|
+
slug: "ksor-link-dead",
|
|
354
|
+
path,
|
|
355
|
+
why: `dead link \`${target}\` — nothing at \`knowledge/${id}\`; a record with dead internal links serves different truths by path`,
|
|
356
|
+
fix: "fix the path (it resolves against this document's directory, or against knowledge/ when it starts with `/`) or remove the link",
|
|
357
|
+
});
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const found = targets.concepts.get(id);
|
|
361
|
+
if (found === undefined) {
|
|
362
|
+
nonConceptWidens(path, audience, id, targets, refusals);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (mayReach(audience, found.audience)) continue;
|
|
366
|
+
refusals.push({
|
|
367
|
+
slug: "ksor-link-widens",
|
|
368
|
+
path,
|
|
369
|
+
why: `links to \`${id}\` (audience [${found.audience.join(", ")}]), which not every reader of this document (audience [${audience.join(", ")}]) may read`,
|
|
370
|
+
fix: "widen the target's audience, narrow this document's, or remove the link",
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* A companion's id → its parent concept's, in both shapes `resolveLink`
|
|
377
|
+
* produces: it strips a trailing `.md` and nothing else (`citations.ts`), so
|
|
378
|
+
* `x.summary.md` arrives as `x.summary` and `x.flashcards.yaml` arrives whole.
|
|
379
|
+
*
|
|
380
|
+
* DERIVED from the canonical suffix list for the same reason the companion pass
|
|
381
|
+
* above is — this was the FOURTH hand-written copy of "what is a companion" and
|
|
382
|
+
* it carried the same `.summary.mdx` gap.
|
|
383
|
+
*/
|
|
384
|
+
const COMPANION_TARGET = new RegExp(
|
|
385
|
+
`(${[...new Set(ATTACHMENT_SUFFIXES.map((e) => e.suffix.replace(/\.md$/, "")))]
|
|
386
|
+
.map((s) => s.replace(/\./g, "\\."))
|
|
387
|
+
.join("|")})$`,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Every link target that is NOT a concept, judged by the same audience rule a
|
|
392
|
+
* concept target is — because `targets.exists` admits five kinds and only one
|
|
393
|
+
* of them used to be judged at all.
|
|
394
|
+
*
|
|
395
|
+
* A COMPANION inherits its parent's audience entirely (decision 24), so a link
|
|
396
|
+
* to `secret/plan.summary.md` is a link to `secret/plan` under another name;
|
|
397
|
+
* refusing the second while publishing the first was one branch's worth of
|
|
398
|
+
* difference in a public build (found live: the id and the directory name both
|
|
399
|
+
* reached the page HTML, the `/md/` twin and `llms-full.txt`).
|
|
400
|
+
*
|
|
401
|
+
* Everything else declares no audience, so it inherits one by POSITION — the
|
|
402
|
+
* directory it names or sits in. If every concept under that directory is out
|
|
403
|
+
* of the linking document's reach, then linking it publishes the directory's
|
|
404
|
+
* NAME (and, for an asset, its bytes) into a build that excludes everything
|
|
405
|
+
* else in it. Reproduced: a public policy with ``
|
|
406
|
+
* put `secret/org-chart.png` in the public `out/`, past a sweep that asserts no
|
|
407
|
+
* byte of `secret/` appears.
|
|
408
|
+
*
|
|
409
|
+
* A directory holding NO concept says nothing about audience — an `images/`
|
|
410
|
+
* folder is shared furniture — so the question is passed UP to the nearest
|
|
411
|
+
* ancestor that does hold one. Found live: checking only the immediate
|
|
412
|
+
* directory was defeated by nesting the asset one level deeper
|
|
413
|
+
* (`secret/img/chart.svg`) — that directory holds no concept, so the rule said
|
|
414
|
+
* nothing and the public build carried `secret/img/` and its bytes. The walk
|
|
415
|
+
* stops below the bundle root, which stays furniture like `images/`: the root
|
|
416
|
+
* holds the linking document itself, so testing it could only ever pass.
|
|
417
|
+
*/
|
|
418
|
+
function nonConceptWidens(
|
|
419
|
+
path: string,
|
|
420
|
+
audience: readonly string[],
|
|
421
|
+
id: string,
|
|
422
|
+
targets: LinkTargets,
|
|
423
|
+
refusals: Refusal[],
|
|
424
|
+
): void {
|
|
425
|
+
// The bundle root is the linking document's own directory.
|
|
426
|
+
if (id === "") return;
|
|
427
|
+
|
|
428
|
+
const parentId = id.replace(COMPANION_TARGET, "");
|
|
429
|
+
const parent = parentId === id ? undefined : targets.concepts.get(parentId);
|
|
430
|
+
if (parent !== undefined) {
|
|
431
|
+
if (mayReach(audience, parent.audience)) return;
|
|
432
|
+
refusals.push({
|
|
433
|
+
slug: "ksor-link-widens",
|
|
434
|
+
path,
|
|
435
|
+
why: `links to \`${id}\`, which is the companion of \`${parentId}\` (audience [${parent.audience.join(", ")}]) and inherits its audience entirely, which not every reader of this document (audience [${audience.join(", ")}]) may read`,
|
|
436
|
+
fix: "widen the parent's audience, narrow this document's, or remove the link",
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Where the target sits: a directory (or its generated index) IS its own
|
|
442
|
+
// position; anything else takes the directory it lives in.
|
|
443
|
+
const asset = targets.assets.has(id);
|
|
444
|
+
const dirOfId = id.slice(0, Math.max(id.lastIndexOf("/"), 0));
|
|
445
|
+
const named = targets.directories.has(id);
|
|
446
|
+
const indexDir = id.endsWith("/index") ? id.slice(0, -"/index".length) : null;
|
|
447
|
+
const start = named ? id : (indexDir ?? dirOfId);
|
|
448
|
+
const what = named
|
|
449
|
+
? `the directory \`${id}/\``
|
|
450
|
+
: indexDir !== null
|
|
451
|
+
? `the generated index of \`${indexDir}/\``
|
|
452
|
+
: asset
|
|
453
|
+
? `the asset \`${id}\``
|
|
454
|
+
: `the file \`${id}\``;
|
|
455
|
+
const carries = asset ? "that directory's name and its bytes" : "that directory's name";
|
|
456
|
+
|
|
457
|
+
const concepts = [...targets.concepts.values()];
|
|
458
|
+
for (let dir = start; dir !== ""; dir = dir.slice(0, Math.max(dir.lastIndexOf("/"), 0))) {
|
|
459
|
+
const inside = concepts.filter((c) => c.id === dir || c.id.startsWith(`${dir}/`));
|
|
460
|
+
if (inside.length === 0) continue;
|
|
461
|
+
if (inside.some((c) => mayReach(audience, c.audience))) return;
|
|
462
|
+
refusals.push({
|
|
463
|
+
slug: "ksor-link-widens",
|
|
464
|
+
path,
|
|
465
|
+
why: `links to ${what} — \`${dir}/\` holds ${inside.length} concept${inside.length === 1 ? "" : "s"} and not one this document's readers (audience [${audience.join(", ")}]) may read, so publishing it puts ${carries} in a build that excludes everything else in it`,
|
|
466
|
+
fix: `move it beside this document (or into a directory its readers may enter), or widen something under \`${dir}/\``,
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function checkSupersession(
|
|
473
|
+
concept: Concept,
|
|
474
|
+
concepts: ReadonlyMap<string, Concept>,
|
|
475
|
+
unreadable: ReadonlySet<string>,
|
|
476
|
+
refusals: Refusal[],
|
|
477
|
+
): void {
|
|
478
|
+
if (concept.supersededBy === null) return;
|
|
479
|
+
if (concept.status !== "deprecated") {
|
|
480
|
+
// The key goes "with deprecated" (§2.2). On a live concept it announces a
|
|
481
|
+
// replacement no surface shows and no reader follows — the old checker
|
|
482
|
+
// refused it, and a silent acceptance would be a governance claim nothing
|
|
483
|
+
// enforces.
|
|
484
|
+
refusals.push({
|
|
485
|
+
slug: "ksor-supersession-strands",
|
|
486
|
+
path: concept.path,
|
|
487
|
+
why: `\`ksor.superseded_by: ${concept.supersededBy}\` on a \`${concept.status}\` concept — supersession is what \`deprecated\` means, so no surface will show this pointer and no reader will follow it`,
|
|
488
|
+
fix: "set `status: deprecated` with `ksor.deprecated: { by, at }`, or drop the pointer",
|
|
489
|
+
});
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
// The successor's document is right there and merely unreadable, so "names
|
|
493
|
+
// no concept" would be false and its remedy — drop the pointer — throws away
|
|
494
|
+
// a correct one. Its own parse refusal is the error, and it is already
|
|
495
|
+
// pushed; the next run judges this pointer against a real successor.
|
|
496
|
+
if (unreadable.has(concept.supersededBy)) return;
|
|
497
|
+
const target = concepts.get(concept.supersededBy);
|
|
498
|
+
const reason =
|
|
499
|
+
target === undefined
|
|
500
|
+
? "names no concept"
|
|
501
|
+
: target.status !== "stable"
|
|
502
|
+
? `is \`${target.status}\`, not \`stable\``
|
|
503
|
+
: !mayReach(concept.audience, target.audience)
|
|
504
|
+
? `has audience [${target.audience.join(", ")}], which not every reader of this document may read`
|
|
505
|
+
: null;
|
|
506
|
+
if (reason === null) return;
|
|
507
|
+
refusals.push({
|
|
508
|
+
slug: "ksor-supersession-strands",
|
|
509
|
+
path: concept.path,
|
|
510
|
+
why: `\`ksor.superseded_by: ${concept.supersededBy}\` ${reason} — a reader sent to the successor would be stranded`,
|
|
511
|
+
fix: "point at a stable successor every reader of this document may read, or drop the pointer",
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function checkAgainstPolicy(concept: Concept, policy: Policy, refusals: Refusal[]): void {
|
|
516
|
+
for (const a of concept.audience) {
|
|
517
|
+
if (a === "public" || policy.audiences.includes(a)) continue;
|
|
518
|
+
refusals.push({
|
|
519
|
+
slug: "ksor-audience-unregistered",
|
|
520
|
+
path: concept.path,
|
|
521
|
+
why: `\`ksor.audience\` names \`${a}\`, which the policy's registry does not declare — an unknown identifier is a typo, and a typo reads as a restriction`,
|
|
522
|
+
fix: `use \`public\` or a registered audience (${policy.audiences.join(", ") || "none registered"}), or register it in \`.ksor/governance.yaml\``,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
if (concept.approval !== null) {
|
|
526
|
+
const approvers = resolveApprovers(policy, concept.id, concept.type);
|
|
527
|
+
if (!approvers.ok) refusals.push(approvers.refusal);
|
|
528
|
+
else if (!approvers.actors.includes(concept.approval.by)) {
|
|
529
|
+
refusals.push({
|
|
530
|
+
slug: "ksor-approver-unauthorised",
|
|
531
|
+
path: concept.path,
|
|
532
|
+
why: `\`ksor.approval.by: ${concept.approval.by}\` is not in the approval authority set the policy resolves for this concept (${approvers.actors.join(", ")})`,
|
|
533
|
+
fix: "record an approval by an authorised actor, or extend the policy in a reviewed change",
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (concept.deprecated !== null) {
|
|
538
|
+
const resolved = resolveOwner(policy, concept.id, concept.type);
|
|
539
|
+
if (!resolved.ok) {
|
|
540
|
+
refusals.push(resolved.refusal);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const by = concept.deprecated.by;
|
|
544
|
+
// The owner is whoever the POLICY resolves, and ONLY that. It used to fall
|
|
545
|
+
// back to `concept.owner` when no `ownership` rule matched — but
|
|
546
|
+
// `ksor.owner` is free text the DOCUMENT writes about itself (the profile
|
|
547
|
+
// does not even form-check it), so `ksor.owner: human:mallory` beside
|
|
548
|
+
// `ksor.deprecated.by: human:mallory` withdrew a document on nobody's
|
|
549
|
+
// authority but its own, in any record whose policy declares no
|
|
550
|
+
// `ownership:` at all — which is the default shape `ksor migrate` emits.
|
|
551
|
+
// Withdrawal is a governance act; an act a document attests for itself is
|
|
552
|
+
// not one. This is the same rule `resolveApprovers` has always enforced by
|
|
553
|
+
// REFUSING when no rule matches, and decision 21's — a slot that records
|
|
554
|
+
// WHO is never filled from ambient state, the document included.
|
|
555
|
+
if (by !== resolved.owner && !policy.takedownActors.includes(by)) {
|
|
556
|
+
const authorities = policy.takedownActors.join(", ");
|
|
557
|
+
refusals.push({
|
|
558
|
+
slug: "ksor-deprecator-unauthorised",
|
|
559
|
+
path: concept.path,
|
|
560
|
+
why:
|
|
561
|
+
resolved.owner === null
|
|
562
|
+
? `\`ksor.deprecated.by: ${by}\` is not a takedown authority (${authorities}), and no \`ownership\` rule in \`${POLICY_PATH}\` binds this concept — so the record names no owner who could withdraw it. \`ksor.owner\` is not that owner: it is a string this document writes about itself`
|
|
563
|
+
: `\`ksor.deprecated.by: ${by}\` is neither the owner the policy resolves (${resolved.owner}) nor a takedown authority (${authorities})`,
|
|
564
|
+
fix:
|
|
565
|
+
resolved.owner === null
|
|
566
|
+
? `record the deprecation by a takedown authority, or add an \`ownership:\` rule to \`${POLICY_PATH}\` naming who owns this path (R23)`
|
|
567
|
+
: "record the deprecation by the owner or a takedown authority (R23)",
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|