@panaversity/ksor 0.0.40 → 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 +869 -0
- package/README.md +11 -7
- 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 +234 -113
- package/templates/scaffold/Dockerfile +5 -1
- package/templates/scaffold/README.md +160 -42
- package/templates/scaffold/env.example +37 -6
- package/templates/scaffold/gitignore +13 -8
- package/templates/scaffold/instance.md +21 -17
- package/templates/scaffold/knowledge/governance-ladder.md +6 -2
- package/templates/scaffold/knowledge/index.md +9 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +7 -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 +126 -91
- package/templates/scaffold/system/site/app/global.css +13 -5
- 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/footer-mark.tsx +3 -2
- package/templates/scaffold/system/site/components/governance.tsx +205 -87
- 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/sidebar-status.tsx +19 -18
- 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 +9 -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 +566 -492
- 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 +77 -22
- 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
|
@@ -3,387 +3,430 @@ import {
|
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
5
5
|
readFileSync,
|
|
6
|
+
lstatSync,
|
|
6
7
|
readdirSync,
|
|
7
8
|
rmSync,
|
|
9
|
+
// `statSync` is here for ONE caller, `publishSims`, and only because it walks
|
|
10
|
+
// the STAGE — a tree this file wrote, which holds no symlink for stat to
|
|
11
|
+
// follow. Everything that touches the RECORD uses `lstatSync` for the reason
|
|
12
|
+
// recorded at `assetTarget`, and that rule is unchanged.
|
|
8
13
|
statSync,
|
|
9
14
|
watch,
|
|
10
15
|
writeFileSync,
|
|
11
16
|
} from "node:fs";
|
|
12
17
|
import path from "node:path";
|
|
13
18
|
|
|
14
|
-
import { ATTACHMENT_SUFFIXES, isAttachment
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
19
|
+
import { ATTACHMENT_SUFFIXES, isAttachment } from "./attachment-rule";
|
|
20
|
+
import { publicSimPath, SIM_SUFFIX } from "./sim-rule";
|
|
21
|
+
import { refuse, viewer } from "./audience";
|
|
22
|
+
import { overlaps } from "./audience-rule";
|
|
23
|
+
import { admitsLifecycle, lifecycleBadge } from "./lifecycle-rule";
|
|
24
|
+
import { assertLockCoversTree, readLock } from "./lock";
|
|
25
|
+
import { appDescription, appName, appTitle, projectRoot } from "./shared";
|
|
26
|
+
import {
|
|
27
|
+
STAGE_DIR,
|
|
28
|
+
STAGE_MANIFEST,
|
|
29
|
+
type StageManifest,
|
|
30
|
+
type StagePage,
|
|
31
|
+
type StageStamps,
|
|
32
|
+
} from "./stage-manifest";
|
|
33
|
+
import { checkRecord } from "../record/check";
|
|
34
|
+
import { linkTargets } from "../record/citations";
|
|
35
|
+
import { splitFrontmatter } from "../record/frontmatter";
|
|
36
|
+
import { historicLedger } from "../record/git-ledger";
|
|
37
|
+
import { generateIndexes } from "../record/index-file";
|
|
38
|
+
import {
|
|
39
|
+
inForce,
|
|
40
|
+
denies,
|
|
41
|
+
parseLedger,
|
|
42
|
+
type LedgerBaseline,
|
|
43
|
+
type LedgerBaselineEntry,
|
|
44
|
+
} from "../record/ledger";
|
|
45
|
+
import { loadRecord } from "../record/load";
|
|
46
|
+
import type { Refusal } from "../record/refusal";
|
|
47
|
+
|
|
48
|
+
const KNOWLEDGE = "knowledge/";
|
|
49
|
+
const LEDGER_PATH = ".ksor/takedowns.yaml";
|
|
50
|
+
const POLICY_PATH = ".ksor/governance.yaml";
|
|
40
51
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
52
|
+
* Everything this build may publish, as bytes at bundle-relative paths: the
|
|
53
|
+
* admitted concepts (copied), their companions (copied), ONLY the assets those
|
|
54
|
+
* concepts reference (copied — an image referenced by nothing published would
|
|
55
|
+
* otherwise ship its bytes into every build, research/visibility.md §7), and
|
|
56
|
+
* every directory's `index.md` REGENERATED from this filtered tree — never the
|
|
57
|
+
* committed one, which lists every status and every audience (record spec §1).
|
|
45
58
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (match === null) return null;
|
|
50
|
-
const raw = (match[1] ?? "").replace(/\s+#.*$/, "").trim();
|
|
51
|
-
const value = /^(['"])(.*)\1$/.exec(raw)?.[2] ?? raw;
|
|
52
|
-
return value === "" ? UNREADABLE : value;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function walkFiles(dir: string): string[] {
|
|
56
|
-
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
57
|
-
const p = path.join(dir, entry.name);
|
|
58
|
-
return entry.isDirectory() ? walkFiles(p) : [p];
|
|
59
|
-
});
|
|
59
|
+
interface StageEntry {
|
|
60
|
+
readonly rel: string;
|
|
61
|
+
readonly bytes: () => Buffer;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
* Strips fenced blocks and inline code spans (per paragraph: CommonMark
|
|
66
|
-
* spans may cross lines, and a document-wide strip lets one stray backtick
|
|
67
|
-
* pair with another pages later).
|
|
68
|
-
*/
|
|
69
|
-
function stripCode(text: string): string {
|
|
70
|
-
const kept: string[] = [];
|
|
71
|
-
let fence: { char: string; length: number } | null = null;
|
|
72
|
-
let blank = true;
|
|
73
|
-
let indented = false;
|
|
74
|
-
for (const line of text.replaceAll("\r\n", "\n").split("\n")) {
|
|
75
|
-
if (fence) {
|
|
76
|
-
const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
77
|
-
if (close && close[1]?.[0] === fence.char && (close[1]?.length ?? 0) >= fence.length) {
|
|
78
|
-
fence = null;
|
|
79
|
-
}
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
83
|
-
if (open?.[1]) {
|
|
84
|
-
fence = { char: open[1][0] as string, length: open[1].length };
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
// An indented run opened after a blank line is a code block — unless it
|
|
88
|
-
// starts a list item, which sits at exactly this indent and carries real
|
|
89
|
-
// links.
|
|
90
|
-
if (/^(?: {4}|\t)/.test(line) && !/^[ \t]+(?:[-*+]|\d+[.)])\s/.test(line)) {
|
|
91
|
-
if (blank || indented) {
|
|
92
|
-
indented = true;
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
} else if (line.trim() !== "") {
|
|
96
|
-
indented = false;
|
|
97
|
-
}
|
|
98
|
-
blank = line.trim() === "";
|
|
99
|
-
kept.push(line);
|
|
100
|
-
}
|
|
101
|
-
return kept
|
|
102
|
-
.join("\n")
|
|
103
|
-
.split(/\n{2,}/)
|
|
104
|
-
.map((paragraph) => paragraph.replace(/(`+)[^`]*?\1/g, " "))
|
|
105
|
-
.join("\n\n");
|
|
64
|
+
interface StagePlan {
|
|
65
|
+
readonly entries: readonly StageEntry[];
|
|
66
|
+
readonly manifest: StageManifest;
|
|
106
67
|
}
|
|
107
68
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
/\[[^\]]*\]\(\s*(<[^<>\n]*>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
|
|
113
|
-
const REFERENCE_DEFINITION =
|
|
114
|
-
/^[ \t]{0,3}\[[^\]]+\]:[ \t]*(<[^<>\n]*>|\S+)[ \t]*(?:"[^"]*"|'[^']*'|\([^)]*\))?[ \t]*$/gm;
|
|
115
|
-
|
|
116
|
-
function linkTargets(body: string): string[] {
|
|
117
|
-
const raw: string[] = [];
|
|
118
|
-
for (const match of body.matchAll(INLINE_LINK)) if (match[1]) raw.push(match[1]);
|
|
119
|
-
for (const match of body.matchAll(REFERENCE_DEFINITION)) if (match[1]) raw.push(match[1]);
|
|
120
|
-
// <…> exists so a destination may contain spaces; the brackets are syntax.
|
|
121
|
-
return raw.map((t) => (t.startsWith("<") && t.endsWith(">") ? t.slice(1, -1).trim() : t));
|
|
69
|
+
/** The checker's refusals, printed the way every refusal here is: slug first, then the remedy. */
|
|
70
|
+
function refuseRecord(refusals: readonly Refusal[]): never {
|
|
71
|
+
const lines = refusals.map((r) => `${r.slug}: ${r.path} — ${r.why}\n fix: ${r.fix}`);
|
|
72
|
+
throw new Error(lines.join("\n"));
|
|
122
73
|
}
|
|
123
74
|
|
|
124
75
|
/**
|
|
125
|
-
* The asset a link points at, or null when it points anywhere else
|
|
126
|
-
*
|
|
76
|
+
* The asset a link points at, or null when it points anywhere else. Both OKF
|
|
77
|
+
* §6.1 link forms: bundle-absolute against `knowledge/`, relative against the
|
|
78
|
+
* document's directory. `.md`/`.mdx` never ride in as assets — both render as
|
|
79
|
+
* pages, and a restricted `plan.mdx` staged that way once published untiered
|
|
80
|
+
* (review finding, 2026-08-18).
|
|
81
|
+
*
|
|
82
|
+
* Neither does an ATTACHMENT, for the same reason one level down. An attachment is
|
|
83
|
+
* staged with its parent or not at all — that is how it inherits its parent's
|
|
84
|
+
* audience, lifecycle and takedown (decision 24) — and this function probes the
|
|
85
|
+
* FILESYSTEM, which knows nothing about any of them. So a document linking
|
|
86
|
+
* `./x.flashcards.yaml` reached the deck by a second path: harmless when `x`
|
|
87
|
+
* was published too (the same bytes, staged twice, which is what made
|
|
88
|
+
* `stageHolds` answer false forever), and a governance escape when it was not —
|
|
89
|
+
* a link to a TAKEN-DOWN document's deck staged the deck, because the link
|
|
90
|
+
* rules judge a companion by its parent's AUDIENCE and the ledger is not an
|
|
91
|
+
* audience. Both reproduced, 2026-08-25.
|
|
127
92
|
*/
|
|
128
|
-
function assetTarget(recordDir: string,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
93
|
+
function assetTarget(recordDir: string, documentRel: string, target: string): string | null {
|
|
94
|
+
const clean = target.split("#")[0] ?? "";
|
|
95
|
+
const resolved = clean.startsWith("/")
|
|
96
|
+
? path.resolve(recordDir, clean.slice(1))
|
|
97
|
+
: path.resolve(recordDir, path.dirname(documentRel), clean);
|
|
132
98
|
if (!resolved.startsWith(recordDir + path.sep)) return null;
|
|
133
|
-
// .md AND .mdx: both render as pages, so neither may ride in as an
|
|
134
|
-
// "asset" — a restricted plan.mdx staged that way published untiered
|
|
135
|
-
// (review finding, 2026-08-18). The record bans .mdx, but staging never
|
|
136
|
-
// depends on the checker having run.
|
|
137
99
|
if (/\.mdx?$/i.test(resolved)) return null;
|
|
100
|
+
if (isAttachment(path.basename(resolved))) return null;
|
|
138
101
|
try {
|
|
139
|
-
|
|
102
|
+
// lstat, never stat: `statSync` FOLLOWS a symlink, and `readFileSync` below
|
|
103
|
+
// follows it too, so `knowledge/guides/leak.png -> /etc/secret` published
|
|
104
|
+
// whatever the build could read, under the record's own name. The checker
|
|
105
|
+
// refuses a symlink under knowledge/ by name (`ksor-symlink`) and runs
|
|
106
|
+
// before this, so the state is unreachable — this is the second lock on the
|
|
107
|
+
// same door, and the one that is local to the code that would publish it.
|
|
108
|
+
return lstatSync(resolved).isFile() ? resolved : null;
|
|
140
109
|
} catch {
|
|
141
110
|
return null;
|
|
142
111
|
}
|
|
143
112
|
}
|
|
144
113
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
interface StagePlan {
|
|
152
|
-
/** Documents and assets to copy, in that order. */
|
|
153
|
-
readonly files: readonly string[];
|
|
154
|
-
readonly documents: number;
|
|
155
|
-
/** Every document in the record, whatever its tier. */
|
|
156
|
-
readonly total: number;
|
|
114
|
+
/** The record-relative paths of every companion of `conceptPath` the record holds. */
|
|
115
|
+
function companionPathsOf(conceptPath: string, companions: ReadonlyMap<string, string>): string[] {
|
|
116
|
+
const rel = conceptPath.slice(KNOWLEDGE.length);
|
|
117
|
+
return ATTACHMENT_SUFFIXES.map(({ suffix }) => rel.replace(/\.md$/, "") + suffix)
|
|
118
|
+
.filter((c) => companions.has(c))
|
|
119
|
+
.map((c) => `${KNOWLEDGE}${c}`);
|
|
157
120
|
}
|
|
158
121
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return /^database:/m.test(instanceFrontmatter());
|
|
199
|
-
}
|
|
122
|
+
function planStage(recordDir: string, development: boolean): StagePlan {
|
|
123
|
+
const record = loadRecord(projectRoot);
|
|
124
|
+
|
|
125
|
+
const documents = new Map<string, string>();
|
|
126
|
+
const companions = new Map<string, string>();
|
|
127
|
+
// The COMMITTED indexes. Nothing else here reads them — the stage regenerates
|
|
128
|
+
// its own — so they exist in this map for one reason: the lock says which
|
|
129
|
+
// bytes `ksor build` wrote into them, and a build looking at different ones is
|
|
130
|
+
// looking at a record nothing checked.
|
|
131
|
+
const indexFiles = new Map<string, string>();
|
|
132
|
+
for (const file of record.files.keys()) {
|
|
133
|
+
if (!file.startsWith(KNOWLEDGE)) continue;
|
|
134
|
+
const rel = file.slice(KNOWLEDGE.length);
|
|
135
|
+
const name = path.basename(rel);
|
|
136
|
+
// The CANONICAL rule, never a copy of it (decision 18). The regex that
|
|
137
|
+
// used to be here was the fifth hand copy of the suffix list: it claimed
|
|
138
|
+
// byte-identity with the checker, had stopped being that when the checker
|
|
139
|
+
// moved to `attachmentKindOf`, and was missing `.summary.mdx` — so the
|
|
140
|
+
// stage and the lock writer disagreed about the companion list.
|
|
141
|
+
if (isAttachment(name)) companions.set(rel, path.join(recordDir, rel));
|
|
142
|
+
else if (name === "index.md") indexFiles.set(rel, path.join(recordDir, rel));
|
|
143
|
+
else if (name.endsWith(".md")) documents.set(rel, path.join(recordDir, rel));
|
|
144
|
+
}
|
|
145
|
+
// The lock covers every asset, because this build publishes its bytes — so
|
|
146
|
+
// the stage's asset set must be the SET THE LOCK WAS BUILT FROM, not a second
|
|
147
|
+
// opinion about it. It is therefore taken from the record the loader already
|
|
148
|
+
// read, the same one `composeLock` reads. Re-walking the directory instead
|
|
149
|
+
// was two walkers with two answers, and both differences were live bugs: the
|
|
150
|
+
// loader skips OS junk, so a `.DS_Store` that Finder writes the first time an
|
|
151
|
+
// adopter opens `knowledge/` was in the tree, never in the lock, and refused
|
|
152
|
+
// `ksor-lock-stale` — unfixable, because the remedy that refusal prescribes
|
|
153
|
+
// writes the identical lock. And the loader reads no symlink as bytes, so a
|
|
154
|
+
// symlinked asset read stale here before ever reaching its own `ksor-symlink`.
|
|
155
|
+
const assetFiles = new Map<string, string>();
|
|
156
|
+
for (const file of record.assets.keys()) {
|
|
157
|
+
if (!file.startsWith(KNOWLEDGE)) continue;
|
|
158
|
+
const rel = file.slice(KNOWLEDGE.length);
|
|
159
|
+
assetFiles.set(rel, path.join(recordDir, rel));
|
|
160
|
+
}
|
|
200
161
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
162
|
+
const draftsRequested = process.env.KSOR_DRAFTS === "show";
|
|
163
|
+
// Three questions, in the order that makes each one answerable.
|
|
164
|
+
//
|
|
165
|
+
// FIRST the lock itself: is there one, can it be read, does it still describe
|
|
166
|
+
// this instance's governance and this build's switches? It is read before the
|
|
167
|
+
// checker because the checker needs one of the two `ksor-ledger-amended`
|
|
168
|
+
// baselines out of it — the lock records each ledger entry's DIGEST, which is
|
|
169
|
+
// the only thing that can see an entry retargeted in place (same id, same
|
|
170
|
+
// actor, a different `stable_id`) — and `readLock` has already refused a
|
|
171
|
+
// ledger the lock never saw, so the baseline is one the lock stands behind.
|
|
172
|
+
const lock = development
|
|
173
|
+
? null
|
|
174
|
+
: readLock(
|
|
175
|
+
projectRoot,
|
|
176
|
+
{
|
|
177
|
+
instance: record.files.get("instance.md") ?? "",
|
|
178
|
+
policy: record.files.get(POLICY_PATH) ?? "",
|
|
179
|
+
ledger: record.files.get(LEDGER_PATH) ?? null,
|
|
180
|
+
},
|
|
181
|
+
{ draftsRequested },
|
|
218
182
|
);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
)
|
|
183
|
+
|
|
184
|
+
// THEN the record, by its own rules — ONE rule set: the same checker
|
|
185
|
+
// `ksor build` and `ksor ingest` run, over the same in-memory tree. Staging
|
|
186
|
+
// never depends on the checker having run elsewhere — a red record refuses
|
|
187
|
+
// HERE, by its slug, before any byte moves.
|
|
188
|
+
const checked = checkRecord(record, {
|
|
189
|
+
mode: "build",
|
|
190
|
+
ledgerBaselines: lock === null ? [] : ledgerBaselines(lock.ledger_entries),
|
|
191
|
+
});
|
|
192
|
+
if (checked.refusals.length > 0 || checked.policy === null) refuseRecord(checked.refusals);
|
|
193
|
+
// LAST, whether the lock describes this tree file by file. A tree that is not
|
|
194
|
+
// a legal record is not eligible for that question: it was refused above by
|
|
195
|
+
// the rule it actually breaks (see `assertLockCoversTree`).
|
|
196
|
+
if (lock !== null) {
|
|
197
|
+
assertLockCoversTree(lock, {
|
|
198
|
+
documents,
|
|
199
|
+
companions,
|
|
200
|
+
assets: assetFiles,
|
|
201
|
+
indexes: indexFiles,
|
|
202
|
+
});
|
|
227
203
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
204
|
+
const policy = checked.policy;
|
|
205
|
+
// Lifecycle is evaluated at the lock's `as_of` for a build (staleness leaves
|
|
206
|
+
// the open web on the next build; a scheduled rebuild is the operator's
|
|
207
|
+
// obligation) and at now in development, where nothing is published.
|
|
208
|
+
const asOf = lock === null ? Date.now() : Date.parse(lock.as_of);
|
|
209
|
+
const drafts: "hidden" | "shown" = lock === null ? "shown" : lock.drafts;
|
|
210
|
+
const registry = lock === null ? policy.audiences : lock.audiences.registry;
|
|
211
|
+
const stamps: StageStamps =
|
|
212
|
+
lock === null
|
|
213
|
+
? { build_id: null, source_commit: null, dirty: false, ksor_version: null, unstamped: true }
|
|
214
|
+
: {
|
|
215
|
+
build_id: lock.build_id,
|
|
216
|
+
source_commit: lock.source_commit,
|
|
217
|
+
dirty: lock.dirty,
|
|
218
|
+
ksor_version: lock.ksor_version,
|
|
219
|
+
unstamped: false,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const audiences = viewer();
|
|
223
|
+
for (const id of audiences) {
|
|
224
|
+
if (id === "public" || registry.includes(id)) continue;
|
|
232
225
|
refuse(
|
|
233
|
-
"ksor-
|
|
234
|
-
|
|
235
|
-
"an
|
|
236
|
-
`
|
|
226
|
+
"ksor-viewer-unregistered",
|
|
227
|
+
`KSOR_AUDIENCE names "${id}", which the record's registry does not declare (registered: ${registry.join(", ") || "none"})`,
|
|
228
|
+
"an unknown identifier is a typo, and a typo in a viewer would silently build the public site under a name that promised more",
|
|
229
|
+
`build with public and registered audiences only, or register "${id}" in .ksor/governance.yaml and run ksor build`,
|
|
237
230
|
);
|
|
238
231
|
}
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
232
|
+
|
|
233
|
+
// Denials from the ledger, in ledger order: in force and unrevoked. The
|
|
234
|
+
// checker already validated every entry's actor against the policy.
|
|
235
|
+
const ledger = parseLedger(record.files.get(LEDGER_PATH) ?? null, LEDGER_PATH);
|
|
236
|
+
if (!ledger.ok) refuseRecord(ledger.refusals);
|
|
237
|
+
const denials = inForce(ledger.ledger);
|
|
238
|
+
|
|
239
|
+
// Keyed by the staged path, so ONE entry exists per file the stage holds
|
|
240
|
+
// however many rules asked for it. `stageHolds` compares the plan's LENGTH
|
|
241
|
+
// to the file count before it compares any bytes, so a path emitted twice
|
|
242
|
+
// made it answer false forever — and that is not a lost optimisation, it is
|
|
243
|
+
// the freshness check that stands between a build and the half-written stage
|
|
244
|
+
// `withStageLock` records (27 of 48 runs, published short and silent). A rel
|
|
245
|
+
// determines its own bytes, so collapsing by it can never pick a side.
|
|
246
|
+
const entries = new Map<string, StageEntry>();
|
|
247
|
+
const assets = new Set<string>();
|
|
248
|
+
const pages: Record<string, StagePage> = {};
|
|
249
|
+
const admitted: { id: string; title: string; description: string; order: number | null }[] = [];
|
|
250
|
+
const copy = (rel: string, from: string): void => {
|
|
251
|
+
entries.set(rel, { rel, bytes: () => readFileSync(from) });
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
for (const concept of checked.concepts) {
|
|
255
|
+
// A takedown beats every other consideration, on every surface and for
|
|
256
|
+
// every viewer; then the overlap rule; then the §2.5 table.
|
|
257
|
+
if (denies(denials, concept.id)) continue;
|
|
258
|
+
if (!overlaps(audiences, concept.audience)) continue;
|
|
259
|
+
const doc = {
|
|
260
|
+
status: concept.status,
|
|
261
|
+
effectiveFrom: concept.effectiveFrom,
|
|
262
|
+
staleAfter: concept.staleAfter,
|
|
263
|
+
};
|
|
264
|
+
if (!admitsLifecycle(doc, "human", asOf, drafts)) continue;
|
|
265
|
+
|
|
266
|
+
const rel = concept.path.slice(KNOWLEDGE.length);
|
|
267
|
+
copy(rel, path.join(recordDir, rel));
|
|
268
|
+
pages[rel] = {
|
|
269
|
+
machine: admitsLifecycle(doc, "machine", asOf, drafts),
|
|
270
|
+
badge: lifecycleBadge(doc, asOf),
|
|
271
|
+
status: concept.status,
|
|
272
|
+
supersededBy: concept.supersededBy,
|
|
273
|
+
audience: concept.audience,
|
|
274
|
+
};
|
|
275
|
+
admitted.push({
|
|
276
|
+
id: concept.id,
|
|
277
|
+
title: concept.title,
|
|
278
|
+
description: concept.description,
|
|
279
|
+
order: concept.order,
|
|
280
|
+
});
|
|
281
|
+
// The parent survived every filter, so its companions may be published.
|
|
282
|
+
// Reached only here: there is no path on which a companion is staged
|
|
283
|
+
// without its parent — governance inheritance obtained by POSITION.
|
|
284
|
+
for (const { suffix } of ATTACHMENT_SUFFIXES) {
|
|
285
|
+
const companion = rel.replace(/\.md$/, "") + suffix;
|
|
286
|
+
if (companions.has(companion)) copy(companion, companions.get(companion)!);
|
|
287
|
+
}
|
|
288
|
+
// The concept's own links AND its companions': the checker validates a
|
|
289
|
+
// summary's links against the parent's audience (record/check.ts), so an
|
|
290
|
+
// image referenced only from `<doc>.summary.md` is in the lock, inside
|
|
291
|
+
// `build_id`, and demanded to exist — while the stage never copied it and
|
|
292
|
+
// the export died with "Module not found" against a generated
|
|
293
|
+
// `.staged-knowledge/*.js` path, naming no record file. The checker's link
|
|
294
|
+
// set and the stage's copy set are the same set.
|
|
295
|
+
const bodies = [concept.path, ...companionPathsOf(concept.path, companions)];
|
|
296
|
+
for (const file of bodies) {
|
|
297
|
+
const text = record.files.get(file) ?? "";
|
|
298
|
+
const split = splitFrontmatter(text, file);
|
|
299
|
+
for (const target of linkTargets(split.ok ? split.body : text)) {
|
|
300
|
+
const asset = assetTarget(recordDir, rel, target);
|
|
301
|
+
if (asset !== null) assets.add(asset);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
258
304
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
"ksor-denylist-format",
|
|
262
|
-
`${DENYLIST_FILE} declares format ${JSON.stringify(parsed.format)}, which this site cannot read`,
|
|
263
|
-
"a manifest shape this build does not understand cannot be trusted to say what is withdrawn",
|
|
264
|
-
"upgrade the site, or re-export with a matching ksor version",
|
|
265
|
-
);
|
|
305
|
+
for (const asset of assets) {
|
|
306
|
+
copy(path.relative(recordDir, asset).split(path.sep).join("/"), asset);
|
|
266
307
|
}
|
|
267
|
-
|
|
308
|
+
|
|
309
|
+
// Three states, and only the middle one is a mistake. An empty RECORD is
|
|
310
|
+
// refused upstream (`ksor-record-empty`). A record nobody has approved yet —
|
|
311
|
+
// every concept a draft, which is where an owner lands after replacing the
|
|
312
|
+
// samples with their own first documents — BUILDS, publishing nothing (build
|
|
313
|
+
// spec §4, acceptance 4): the first governance act is one conversational turn
|
|
314
|
+
// away and a wall here would meet the adopter before the record does. An
|
|
315
|
+
// empty VIEWER over a record that HAS approved knowledge is the
|
|
316
|
+
// misconfiguration, and it would otherwise surface as "the record has no
|
|
317
|
+
// documents" against a record full of them.
|
|
318
|
+
const approved = checked.concepts.some((c) => c.status === "stable");
|
|
319
|
+
if (admitted.length === 0 && checked.concepts.length > 0 && approved) {
|
|
268
320
|
refuse(
|
|
269
|
-
"ksor-
|
|
270
|
-
|
|
271
|
-
"a
|
|
272
|
-
|
|
321
|
+
"ksor-audience-empty",
|
|
322
|
+
`no concept in the record is admitted for the [${audiences.join(", ")}] viewer at ${new Date(asOf).toISOString()} (${checked.concepts.length} concept${checked.concepts.length === 1 ? "" : "s"}, none stable, effective, in-audience and undenied)`,
|
|
323
|
+
"a site with nothing on it is a deploy that looks successful and serves nobody — and the record is not empty, this viewer's slice of it is",
|
|
324
|
+
"build a wider viewer with KSOR_AUDIENCE, approve a draft, or check the ledger",
|
|
273
325
|
);
|
|
274
326
|
}
|
|
275
|
-
|
|
327
|
+
|
|
328
|
+
const indexes = generateIndexes({
|
|
329
|
+
title: appTitle,
|
|
330
|
+
concepts: admitted,
|
|
331
|
+
dirs: record.dirs.filter((d) => d.startsWith(KNOWLEDGE)).map((d) => d.slice(KNOWLEDGE.length)),
|
|
332
|
+
});
|
|
333
|
+
for (const [rel, text] of indexes) entries.set(rel, { rel, bytes: () => Buffer.from(text) });
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
entries: [...entries.values()],
|
|
337
|
+
manifest: {
|
|
338
|
+
format: 1,
|
|
339
|
+
name: appName,
|
|
340
|
+
title: appTitle,
|
|
341
|
+
description: appDescription,
|
|
342
|
+
viewer: [...audiences],
|
|
343
|
+
asOf: lock === null ? new Date(asOf).toISOString() : lock.as_of,
|
|
344
|
+
drafts,
|
|
345
|
+
stamps,
|
|
346
|
+
pages,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
276
349
|
}
|
|
277
350
|
|
|
278
351
|
/**
|
|
279
|
-
*
|
|
280
|
-
* takedown governs.
|
|
281
|
-
*
|
|
282
|
-
* `ksor takedown --export` expands a `--subtree` denial to its actual
|
|
283
|
-
* descendants by walking parent_id, where the tree lives. Interpreting SCOPE
|
|
284
|
-
* here meant prefix-matching stable_ids, and a section's stable_id ends in
|
|
285
|
-
* `/index` (or `#section`), so the prefix never matched its children and every
|
|
286
|
-
* descendant kept publishing — the failure decision 14 records as the reason
|
|
287
|
-
* its own walk uses parent_id rather than a prefix (round-2 review of #43).
|
|
288
|
-
*
|
|
289
|
-
* But an expanded list can only name what the ACTIVE GENERATION contains, and
|
|
290
|
-
* this build reads DISK. A document added under a withdrawn section after the
|
|
291
|
-
* last ingest is on disk and not in the database, so it published to /docs and
|
|
292
|
-
* llms.txt under a section that had been explicitly withdrawn — while decision
|
|
293
|
-
* 14 states outright that a subtree deny must cover descendants a future
|
|
294
|
-
* re-ingest adds (round-5 review of #43).
|
|
352
|
+
* BOTH baselines the ledger is judged against — the lock's, and git history's.
|
|
295
353
|
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* The record's stable_id and its record-frame path, for the denial check.
|
|
354
|
+
* The lock alone is not enough here, and the reason is the reason the emitted
|
|
355
|
+
* checker reads history too: the lock is hand-editable and travels in the SAME
|
|
356
|
+
* change as the ledger, so deleting an entry, recomputing `ledger_sha256` and
|
|
357
|
+
* emptying `ledger_entries` leaves the two agreeing about a denial that is
|
|
358
|
+
* gone. Walked: the denied document was staged again, exit 0.
|
|
303
359
|
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
360
|
+
* History it cannot read is SAID, not assumed away. A build that refused every
|
|
361
|
+
* shallow CI checkout would be turned off, and `ksor build` refuses that state
|
|
362
|
+
* outright — so this is a note beside the verdict, not a second refusal.
|
|
306
363
|
*/
|
|
307
|
-
function
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
364
|
+
function ledgerBaselines(fromLock: readonly LedgerBaselineEntry[]): LedgerBaseline[] {
|
|
365
|
+
// Accepted: a passing `ksor build` wrote this lock, so its entries were
|
|
366
|
+
// judged against the policy of the day and are history now. The git-history
|
|
367
|
+
// baseline below stays unaccepted — committing is not passing.
|
|
368
|
+
const lockBaseline: LedgerBaseline = {
|
|
369
|
+
source: "build.lock.json",
|
|
370
|
+
entries: fromLock,
|
|
371
|
+
accepted: true,
|
|
372
|
+
};
|
|
373
|
+
const history = historicLedger(projectRoot);
|
|
374
|
+
if (!history.repository) return [lockBaseline];
|
|
375
|
+
if (history.entries === null) {
|
|
376
|
+
console.error(
|
|
377
|
+
"ksor-ledger-unverifiable: .ksor/takedowns.yaml — the ledger's history could not be read " +
|
|
378
|
+
`(${history.unreadable === "shallow" ? "this is a shallow clone" : "git could not read the file's log"}), ` +
|
|
379
|
+
"so this build checked the ledger against the committed lock alone — an artefact that travels in the same change.\n" +
|
|
380
|
+
" fix: `git fetch --unshallow` (or check out with fetch-depth: 0) and build again; `ksor build` refuses this state outright",
|
|
381
|
+
);
|
|
382
|
+
return [lockBaseline];
|
|
383
|
+
}
|
|
384
|
+
return [{ source: "git history", entries: history.entries }, lockBaseline];
|
|
321
385
|
}
|
|
322
386
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
|
|
330
|
-
const documents: string[] = [];
|
|
331
|
-
const assets = new Set<string>();
|
|
332
|
-
let total = 0;
|
|
333
|
-
for (const file of walkFiles(recordDir)) {
|
|
334
|
-
// An attachment is not a document: it is neither counted nor filtered on
|
|
335
|
-
// its own terms. It rides in below, with the parent that survived — which
|
|
336
|
-
// is the whole of governance inheritance, obtained by POSITION rather than
|
|
337
|
-
// by a second rule that could disagree with this one.
|
|
338
|
-
if (isAttachment(path.basename(file))) continue;
|
|
339
|
-
if (!file.toLowerCase().endsWith(".md")) continue;
|
|
340
|
-
total += 1;
|
|
341
|
-
const text = readFileSync(file, "utf8");
|
|
342
|
-
// An undeclared tier reads as a restriction and the document appears in
|
|
343
|
-
// no build at all — fail closed here, and `pnpm check` (which CI runs) is
|
|
344
|
-
// what names the typo.
|
|
345
|
-
if (!visibleInBuild(visibilityOf(text))) continue;
|
|
346
|
-
// A takedown beats every other consideration, on every surface.
|
|
347
|
-
if (isDenied(denied, stableIdOf(recordDir, file, text), recordPathOf(recordDir, file)))
|
|
348
|
-
continue;
|
|
349
|
-
documents.push(file);
|
|
350
|
-
// The parent survived BOTH filters, so its attachments may be published.
|
|
351
|
-
// Reached only here: a filtered or denied parent never gets this far, so
|
|
352
|
-
// there is no path on which an attachment is staged without its parent.
|
|
353
|
-
for (const suffix of ATTACHMENT_SUFFIXES_FOR_STAGE) {
|
|
354
|
-
const attachment = file.replace(/\.mdx?$/i, "") + suffix;
|
|
355
|
-
if (existsSync(attachment)) assets.add(attachment);
|
|
356
|
-
}
|
|
357
|
-
// Body only: frontmatter carries no links in the record grammar, and
|
|
358
|
-
// scanning it here while the other shell strips it staged different
|
|
359
|
-
// asset sets from one record (review finding, 2026-08-18).
|
|
360
|
-
const block = frontmatterBlock(text);
|
|
361
|
-
const body = block === "" ? text : text.slice(text.indexOf(block) + block.length);
|
|
362
|
-
for (const target of linkTargets(stripCode(body))) {
|
|
363
|
-
const asset = assetTarget(recordDir, file, target);
|
|
364
|
-
if (asset !== null) assets.add(asset);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
return { files: [...documents, ...assets], documents: documents.length, total };
|
|
387
|
+
function walkFiles(dir: string): string[] {
|
|
388
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
389
|
+
const p = path.join(dir, entry.name);
|
|
390
|
+
return entry.isDirectory() ? walkFiles(p) : [p];
|
|
391
|
+
});
|
|
368
392
|
}
|
|
369
393
|
|
|
370
394
|
/** How often a waiter looks again. */
|
|
371
395
|
const LOCK_POLL_MS = 25;
|
|
372
396
|
/** How long a wait goes unexplained. A build that looks hung must say why. */
|
|
373
397
|
const LOCK_ANNOUNCE_MS = 10_000;
|
|
398
|
+
/**
|
|
399
|
+
* How long a lock may be held before a waiter stops believing in its holder.
|
|
400
|
+
*
|
|
401
|
+
* Enormously generous against the real contended case, which is what this bound
|
|
402
|
+
* has to clear: the other holder is another evaluation of the SAME build,
|
|
403
|
+
* staging the SAME record, and that is milliseconds for the records measured
|
|
404
|
+
* here — seven overlapping evaluations of a 150-document record still finish
|
|
405
|
+
* inside a second. Two minutes is not a guess at how long staging takes; it is
|
|
406
|
+
* long enough that reaching it means the holder is not staging at all.
|
|
407
|
+
*/
|
|
408
|
+
const LOCK_GIVE_UP_MS = 120_000;
|
|
374
409
|
|
|
375
410
|
/** Synchronous, because everything on this path is: a bundler cannot await. */
|
|
376
411
|
function sleepSync(ms: number): void {
|
|
377
412
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
378
413
|
}
|
|
379
414
|
|
|
380
|
-
|
|
415
|
+
/**
|
|
416
|
+
* What a signal-0 probe can honestly say about a pid. Three answers, because
|
|
417
|
+
* the middle one is not proof of anything: EPERM says something with that id
|
|
418
|
+
* exists and is not ours to signal, which is exactly what a RECYCLED pid owned
|
|
419
|
+
* by another user looks like. Folding it into "alive" is what made a waiter
|
|
420
|
+
* believe in a holder that had been dead for hours.
|
|
421
|
+
*/
|
|
422
|
+
type Liveness = "alive" | "not-ours" | "gone";
|
|
423
|
+
|
|
424
|
+
function probePid(pid: number): Liveness {
|
|
381
425
|
try {
|
|
382
426
|
process.kill(pid, 0);
|
|
383
|
-
return
|
|
427
|
+
return "alive";
|
|
384
428
|
} catch (error) {
|
|
385
|
-
|
|
386
|
-
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
429
|
+
return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-ours" : "gone";
|
|
387
430
|
}
|
|
388
431
|
}
|
|
389
432
|
|
|
@@ -405,12 +448,57 @@ function lockIsAbandoned(lockFile: string): boolean {
|
|
|
405
448
|
return false;
|
|
406
449
|
}
|
|
407
450
|
const pid = Number(stamp);
|
|
408
|
-
if (Number.isInteger(pid) && pid > 0) return
|
|
451
|
+
if (Number.isInteger(pid) && pid > 0) return probePid(pid) === "gone";
|
|
409
452
|
if (look === 0) sleepSync(LOCK_POLL_MS * 2);
|
|
410
453
|
}
|
|
411
454
|
return true;
|
|
412
455
|
}
|
|
413
456
|
|
|
457
|
+
/** How long this lock file says it has been held; 0 once it is gone. */
|
|
458
|
+
function lockHeldForMs(lockFile: string): number {
|
|
459
|
+
try {
|
|
460
|
+
return Math.max(0, Date.now() - statSync(lockFile).mtimeMs);
|
|
461
|
+
} catch {
|
|
462
|
+
// Released while we looked: the next acquire attempt takes it.
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Stop waiting, and say everything that is known about why.
|
|
469
|
+
*
|
|
470
|
+
* NOT "break the lock and carry on", which is the obvious alternative and is
|
|
471
|
+
* unsafe here: `fillStage` removes the stage and refills it IN PLACE, so a lock
|
|
472
|
+
* broken under a holder that IS still working hands the next reader a stage
|
|
473
|
+
* that is neither the old set nor the new one — the silent, published,
|
|
474
|
+
* 27-of-48 partial stage this file exists to prevent. A refusal an operator can
|
|
475
|
+
* act on is the honest end of an unbounded wait; publishing a short record is
|
|
476
|
+
* not.
|
|
477
|
+
*/
|
|
478
|
+
function refuseStuckLock(lockFile: string, heldMs: number): never {
|
|
479
|
+
let stamp = "";
|
|
480
|
+
try {
|
|
481
|
+
stamp = readFileSync(lockFile, "utf8").trim();
|
|
482
|
+
} catch {
|
|
483
|
+
// Released as we read it — say so rather than inventing a holder.
|
|
484
|
+
}
|
|
485
|
+
const pid = Number(stamp);
|
|
486
|
+
const evidence =
|
|
487
|
+
Number.isInteger(pid) && pid > 0
|
|
488
|
+
? {
|
|
489
|
+
alive: `process ${pid} is alive to a signal-0 probe — but a RECYCLED pid is alive too, so that is not proof this holder is the one that took the lock`,
|
|
490
|
+
"not-ours": `signalling process ${pid} raised EPERM: something with that id exists and is not ours to signal, which is also what a RECYCLED pid owned by another user produces`,
|
|
491
|
+
gone: `process ${pid} is gone, and this lock should already have been broken`,
|
|
492
|
+
}[probePid(pid)]
|
|
493
|
+
: `the file records no usable pid (${stamp === "" ? "it is empty" : JSON.stringify(stamp)})`;
|
|
494
|
+
refuse(
|
|
495
|
+
"ksor-stage-locked",
|
|
496
|
+
`${path.basename(lockFile)} has been held for ${Math.round(heldMs / 1000)}s`,
|
|
497
|
+
`one evaluation writes the stage at a time, and a holder still holding after ${Math.round(LOCK_GIVE_UP_MS / 1000)}s is not staging — it was killed before it could release (Ctrl-C, a cancelled job, an OOM: none of them run the code that removes this file). The lock is not broken automatically because the stage is removed and refilled IN PLACE, so breaking one a live holder still holds would publish a half-written record. Evidence: ${evidence}`,
|
|
498
|
+
`if no build is running, delete ${lockFile} and build again`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
414
502
|
/**
|
|
415
503
|
* Hold the stage lock for the duration of `work`: ONE evaluation writes the
|
|
416
504
|
* stage at a time, and this file says which.
|
|
@@ -420,10 +508,11 @@ function lockIsAbandoned(lockFile: string): boolean {
|
|
|
420
508
|
* (2026-08-23) — and staging was destructive on every evaluation: delete the
|
|
421
509
|
* whole stage, refill it. Two of those overlapping is not a rare interleaving,
|
|
422
510
|
* it is what seven of them do — six concurrent evaluations of a 150-document
|
|
423
|
-
* record failed 42 of 48 runs, in four shapes: `ENOENT` and `EINVAL` out of
|
|
424
|
-
* issue #100), `ENOTEMPTY` out of `rmSync`
|
|
425
|
-
* place, and — 27 of the 48, the majority — no
|
|
426
|
-
* success and handed the build a stage a third
|
|
511
|
+
* record failed 42 of 48 runs, in four shapes: `ENOENT` and `EINVAL` out of
|
|
512
|
+
* `copyFileSync` (the reported one, issue #100), `ENOTEMPTY` out of `rmSync`
|
|
513
|
+
* *with* its retries already in place, and — 27 of the 48, the majority — no
|
|
514
|
+
* error at all: staging returned success and handed the build a stage a third
|
|
515
|
+
* of the record short.
|
|
427
516
|
*
|
|
428
517
|
* The silent shape is why this is a lock and not another retry. A crash fails
|
|
429
518
|
* a build; a short stage PUBLISHES one, with documents missing from /docs,
|
|
@@ -431,12 +520,17 @@ function lockIsAbandoned(lockFile: string): boolean {
|
|
|
431
520
|
*
|
|
432
521
|
* `wx` is the whole primitive: create-if-absent, atomically, on every
|
|
433
522
|
* filesystem Node supports — and it stamps the holder's pid in the same call,
|
|
434
|
-
* so a waiter can tell a live holder from a killed one.
|
|
523
|
+
* so a waiter can tell a live holder from a killed one. Only from a KILLED one,
|
|
524
|
+
* though: a pid says nothing once it has been recycled, which is why the pid
|
|
525
|
+
* decides whether to break the lock and the CLOCK decides when to give up.
|
|
435
526
|
*
|
|
436
|
-
* Waiting on a
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
527
|
+
* Waiting on a live holder is the point: it is another evaluation of the same
|
|
528
|
+
* build, staging the same bytes from the same record, and this build is not
|
|
529
|
+
* finished until it has. The wait is BOUNDED all the same, because a build tool
|
|
530
|
+
* may not hang — and this one did, live, on `pnpm dev` against a lock whose
|
|
531
|
+
* holder had been dead for hours (`LOCK_GIVE_UP_MS`, `refuseStuckLock`). A wait
|
|
532
|
+
* long enough to look like a hang names what it is waiting for; a wait long
|
|
533
|
+
* enough to BE one refuses and says what it knows.
|
|
440
534
|
*/
|
|
441
535
|
function withStageLock<T>(stageDir: string, work: () => T): T {
|
|
442
536
|
const lockFile = `${stageDir}.lock`;
|
|
@@ -465,6 +559,13 @@ function withStageLock<T>(stageDir: string, work: () => T): T {
|
|
|
465
559
|
rmSync(lockFile, { force: true });
|
|
466
560
|
continue;
|
|
467
561
|
}
|
|
562
|
+
// Two ways to have waited too long, and both are real: THIS build has
|
|
563
|
+
// waited past the bound, or the lock has been held past it by a holder
|
|
564
|
+
// that may have been gone before this build started.
|
|
565
|
+
const held = lockHeldForMs(lockFile);
|
|
566
|
+
if (waited >= LOCK_GIVE_UP_MS || held >= LOCK_GIVE_UP_MS) {
|
|
567
|
+
refuseStuckLock(lockFile, Math.max(held, waited));
|
|
568
|
+
}
|
|
468
569
|
sleepSync(LOCK_POLL_MS);
|
|
469
570
|
waited += LOCK_POLL_MS;
|
|
470
571
|
if (waited >= LOCK_ANNOUNCE_MS && !announced) {
|
|
@@ -492,11 +593,12 @@ function withStageLock<T>(stageDir: string, work: () => T): T {
|
|
|
492
593
|
* is set (it defaults to zero). Those are what a Windows indexer or an
|
|
493
594
|
* antivirus scanner holding a handle looks like — not ksor, and not something
|
|
494
595
|
* the lock can serialise. Losing that race is safe: the stage is a
|
|
495
|
-
* deterministic function of the record and the
|
|
596
|
+
* deterministic function of the record, the ledger and the lock, so redoing it
|
|
496
597
|
* produces the same bytes.
|
|
497
598
|
*/
|
|
498
599
|
function removeStage(stageDir: string): void {
|
|
499
600
|
rmSync(stageDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
601
|
+
rmSync(path.resolve(path.dirname(stageDir), STAGE_MANIFEST), { force: true });
|
|
500
602
|
}
|
|
501
603
|
|
|
502
604
|
/**
|
|
@@ -504,184 +606,132 @@ function removeStage(stageDir: string): void {
|
|
|
504
606
|
*
|
|
505
607
|
* The wipe-and-refill is the destructive half of staging, and it is pure waste
|
|
506
608
|
* whenever the answer is yes — which is every evaluation after the first in
|
|
507
|
-
* one build, since the plan is a deterministic function of
|
|
508
|
-
*
|
|
509
|
-
*
|
|
609
|
+
* one build, since the plan is a deterministic function of its inputs.
|
|
610
|
+
* Skipping it is not an optimisation: while a wipe is running there is a
|
|
611
|
+
* window in which the stage is not the record, and an evaluation that has
|
|
510
612
|
* already returned is reading it. The lock stops two writers colliding; this
|
|
511
613
|
* stops the second writer existing at all.
|
|
512
614
|
*
|
|
513
615
|
* Bytes, not names and not timestamps: the alternative is serving a previous
|
|
514
616
|
* build's copy of a document that has since been edited.
|
|
515
617
|
*/
|
|
516
|
-
function stageHolds(
|
|
618
|
+
function stageHolds(stageDir: string, plan: StagePlan): boolean {
|
|
517
619
|
let staged: string[];
|
|
518
620
|
try {
|
|
519
621
|
staged = walkFiles(stageDir);
|
|
520
622
|
} catch {
|
|
521
623
|
return false;
|
|
522
624
|
}
|
|
523
|
-
if (staged.length !== plan.
|
|
524
|
-
const expected = new Map(
|
|
525
|
-
plan.files.map((from) => [path.join(stageDir, path.relative(recordDir, from)), from]),
|
|
526
|
-
);
|
|
625
|
+
if (staged.length !== plan.entries.length) return false;
|
|
626
|
+
const expected = new Map(plan.entries.map((e) => [path.join(stageDir, e.rel), e] as const));
|
|
527
627
|
for (const file of staged) {
|
|
528
|
-
const
|
|
529
|
-
if (
|
|
530
|
-
if (!
|
|
628
|
+
const entry = expected.get(file);
|
|
629
|
+
if (entry === undefined) return false;
|
|
630
|
+
if (!entry.bytes().equals(readFileSync(file))) return false;
|
|
531
631
|
}
|
|
532
632
|
return true;
|
|
533
633
|
}
|
|
534
634
|
|
|
635
|
+
function writeManifest(stageDir: string, manifest: StageManifest): void {
|
|
636
|
+
writeFileSync(
|
|
637
|
+
path.resolve(path.dirname(stageDir), STAGE_MANIFEST),
|
|
638
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
535
642
|
/** Fill a clean stage with exactly the set this build may publish. */
|
|
536
|
-
function fillStage(recordDir: string, stageDir: string,
|
|
643
|
+
function fillStage(recordDir: string, stageDir: string, development: boolean): void {
|
|
537
644
|
withStageLock(stageDir, () => {
|
|
538
645
|
let plan: StagePlan;
|
|
539
646
|
try {
|
|
540
|
-
plan = planStage(recordDir,
|
|
541
|
-
// An empty record is its own problem, reported by the page that renders
|
|
542
|
-
// it; an empty AUDIENCE is a misconfiguration that would otherwise
|
|
543
|
-
// surface as "the record has no documents" against a record full of them.
|
|
544
|
-
if (plan.documents === 0 && plan.total > 0) {
|
|
545
|
-
refuse(
|
|
546
|
-
"ksor-audience-empty",
|
|
547
|
-
`no document in the record is visible to the ${buildAudience} build (${plan.total} document${plan.total === 1 ? "" : "s"}, all above that tier)`,
|
|
548
|
-
"a site with nothing on it is a deploy that looks successful and serves nobody — and the record is not empty, this audience's slice of it is",
|
|
549
|
-
"build a wider audience with KSOR_AUDIENCE, lower default_visibility in instance.md, or give at least one document this tier",
|
|
550
|
-
);
|
|
551
|
-
}
|
|
647
|
+
plan = planStage(recordDir, development);
|
|
552
648
|
} catch (error) {
|
|
553
649
|
// No refusal may leave the previous, more permissive stage on disk: it
|
|
554
650
|
// hands the next careless build a filtered copy nothing governs (review
|
|
555
|
-
// finding, 2026-08-19).
|
|
556
|
-
// why nothing could ask whether the stage was already correct.
|
|
651
|
+
// finding, 2026-08-19).
|
|
557
652
|
removeStage(stageDir);
|
|
558
653
|
throw error;
|
|
559
654
|
}
|
|
560
|
-
if (stageHolds(
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
mkdirSync(path.dirname(to), { recursive: true });
|
|
568
|
-
copyFileSync(from, to);
|
|
655
|
+
if (!stageHolds(stageDir, plan)) {
|
|
656
|
+
removeStage(stageDir);
|
|
657
|
+
for (const entry of plan.entries) {
|
|
658
|
+
const to = path.join(stageDir, entry.rel);
|
|
659
|
+
mkdirSync(path.dirname(to), { recursive: true });
|
|
660
|
+
writeFileSync(to, entry.bytes());
|
|
661
|
+
}
|
|
569
662
|
}
|
|
663
|
+
// The manifest carries `as_of`, which moves in development, so it is
|
|
664
|
+
// written on every evaluation — cheap, and never the reason a stage is.
|
|
665
|
+
writeManifest(stageDir, plan.manifest);
|
|
666
|
+
// A sim is SERVED, not bundled: `public/` is where a framed page needs a
|
|
667
|
+
// url. Published on both paths — a held stage still has to have them.
|
|
570
668
|
publishSims(stageDir);
|
|
571
669
|
});
|
|
572
670
|
}
|
|
573
671
|
|
|
574
672
|
/**
|
|
575
|
-
*
|
|
576
|
-
* document publishes, including one whose author marked it restricted. The
|
|
577
|
-
* checker refuses this record-wide; the build refuses it too, because a
|
|
578
|
-
* deleted or mistyped `audiences:` block would otherwise publish every
|
|
579
|
-
* restricted document on a green build (vis-docusaurus, 2026-08-18).
|
|
580
|
-
*/
|
|
581
|
-
/**
|
|
582
|
-
* Attachments the record cannot publish, refused at the BUILD.
|
|
583
|
-
*
|
|
584
|
-
* Staging never depends on the checker having run, so both rules need a home
|
|
585
|
-
* here as well as in `pnpm check` — the checker is where they get a good
|
|
586
|
-
* message, this is where they are guaranteed.
|
|
587
|
-
*
|
|
588
|
-
* Runs on every path, including the level-0 fast path that stages nothing:
|
|
589
|
-
* an orphan is a governance hole whether or not this record declares
|
|
590
|
-
* audiences.
|
|
591
|
-
*/
|
|
592
|
-
function assertAttachmentsWellFormed(recordDir: string): void {
|
|
593
|
-
for (const file of walkFiles(recordDir)) {
|
|
594
|
-
const base = path.basename(file);
|
|
595
|
-
if (!isAttachment(base)) continue;
|
|
596
|
-
const rel = path.relative(recordDir, file);
|
|
597
|
-
|
|
598
|
-
const parent = parentDocumentOf(base);
|
|
599
|
-
if (parent !== null && !existsSync(path.join(path.dirname(file), parent))) {
|
|
600
|
-
refuse(
|
|
601
|
-
"ksor-attachment-orphan",
|
|
602
|
-
`${rel} is an attachment of ${parent}, which is not in the record`,
|
|
603
|
-
"an attachment inherits its parent's governance — with no parent there is nothing to inherit, so it would be published under no tier and covered by no takedown",
|
|
604
|
-
`add ${path.join(path.dirname(rel), parent)}, or remove ${rel}`,
|
|
605
|
-
);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// No frontmatter, at all. One rule kills the whole widening class:
|
|
609
|
-
// no `visibility:` claiming a tier the parent does not have, no `sor_id:`
|
|
610
|
-
// escaping the parent's takedown, no `status:`/`owner:` claiming
|
|
611
|
-
// governance a thing with no id cannot carry.
|
|
612
|
-
if (base.toLowerCase().endsWith(".md") || base.toLowerCase().endsWith(".mdx")) {
|
|
613
|
-
const text = readFileSync(file, "utf8")
|
|
614
|
-
.replace(/^\uFEFF/, "")
|
|
615
|
-
.replaceAll("\r\n", "\n");
|
|
616
|
-
if (text.startsWith("---\n")) {
|
|
617
|
-
refuse(
|
|
618
|
-
"ksor-attachment-frontmatter",
|
|
619
|
-
`${rel} declares frontmatter`,
|
|
620
|
-
"an attachment is part of its parent and carries none of its own governance — a key here would look like it governs something and would govern nothing",
|
|
621
|
-
`remove the frontmatter block from ${rel}; ${parent ?? "its parent"} is what carries the governance`,
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
function refuseVisibilityWithoutAudiences(recordDir: string): void {
|
|
629
|
-
for (const file of walkFiles(recordDir)) {
|
|
630
|
-
if (!file.toLowerCase().endsWith(".md")) continue;
|
|
631
|
-
const visibility = visibilityOf(readFileSync(file, "utf8"));
|
|
632
|
-
if (visibility === null) continue;
|
|
633
|
-
refuse(
|
|
634
|
-
"ksor-visibility-without-audiences",
|
|
635
|
-
`${path.relative(recordDir, file)} declares visibility: ${visibility}, but instance.md declares no audiences`,
|
|
636
|
-
"without a model every document is published — this build would publish a document its author restricted, and the key saying otherwise would be the only trace",
|
|
637
|
-
"declare the model in instance.md (audiences: least-restricted first, plus default_visibility:), or remove the visibility: key",
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* Dev only: carry edits into the documents the stage already holds, so
|
|
673
|
+
* Dev only: carry edits into the files the stage already holds, so
|
|
644
674
|
* `pnpm dev` shows the record as the owner is writing it rather than as it
|
|
645
|
-
* stood when the server started
|
|
675
|
+
* stood when the server started — the regenerated indexes included, so a
|
|
676
|
+
* retitled document is retitled in its folder's listing too.
|
|
646
677
|
*
|
|
647
678
|
* Edits only — never adds, never removals. fumadocs' own watcher cannot see
|
|
648
679
|
* a dot-prefixed collection directory (measured 2026-08-18: adding a file to
|
|
649
680
|
* the stage regenerated nothing, and removing one left the generated imports
|
|
650
681
|
* pointing at a file that was gone), so a document that ARRIVES or changes
|
|
651
|
-
*
|
|
682
|
+
* audience needs the restart `pnpm dev` already needs for instance.md. Leaving
|
|
652
683
|
* that to a restart keeps dev honest in the direction that matters: the
|
|
653
684
|
* published build is always staged from scratch.
|
|
654
685
|
*/
|
|
655
|
-
function refreshStage(recordDir: string, stageDir: string
|
|
686
|
+
function refreshStage(recordDir: string, stageDir: string): void {
|
|
656
687
|
// Under the lock like every other write here: a save landing while another
|
|
657
688
|
// evaluation is refilling the stage is the same race from the other side.
|
|
658
689
|
withStageLock(stageDir, () => {
|
|
659
|
-
const
|
|
690
|
+
const plan = planStage(recordDir, true);
|
|
691
|
+
const permitted = new Map(plan.entries.map((e) => [path.join(stageDir, e.rel), e] as const));
|
|
660
692
|
for (const staged of walkFiles(stageDir)) {
|
|
661
|
-
const
|
|
662
|
-
if (
|
|
663
|
-
|
|
664
|
-
|
|
693
|
+
const entry = permitted.get(staged);
|
|
694
|
+
if (entry === undefined) continue;
|
|
695
|
+
const bytes = entry.bytes();
|
|
696
|
+
if (bytes.equals(readFileSync(staged))) continue;
|
|
697
|
+
writeFileSync(staged, bytes);
|
|
665
698
|
}
|
|
699
|
+
writeManifest(stageDir, plan.manifest);
|
|
666
700
|
});
|
|
667
701
|
}
|
|
668
702
|
|
|
669
703
|
let watching = false;
|
|
670
704
|
|
|
671
705
|
/**
|
|
672
|
-
* Watch the record in development, never in a build — and
|
|
673
|
-
*
|
|
706
|
+
* Watch the record in development, never in a build — and NEVER the reason a
|
|
707
|
+
* process refuses to exit.
|
|
708
|
+
*
|
|
709
|
+
* `persistent: false` is what makes that true, and `unref()` alone did not.
|
|
710
|
+
* On macOS and Windows a recursive watch is native and `unref()` unrefs the
|
|
711
|
+
* one handle behind it; everywhere else — Linux, so every container and every
|
|
712
|
+
* CI runner — Node substitutes a JS implementation
|
|
713
|
+
* (`internal/fs/recursive_watch`) that opens one watcher PER DIRECTORY and
|
|
714
|
+
* whose `unref()` walks a map of `Stats` objects unrefing anything that is
|
|
715
|
+
* `instanceof StatWatcher`. Nothing in that map ever is, so `unref()` is a
|
|
716
|
+
* silent no-op there and every one of those watchers — created `persistent`,
|
|
717
|
+
* because that is `fs.watch`'s default — holds the event loop open forever.
|
|
718
|
+
*
|
|
719
|
+
* Measured as a build that never ends: an evaluation with NODE_ENV=development
|
|
720
|
+
* exits in milliseconds on macOS and never exits on Linux, so `spawnSync`
|
|
721
|
+
* waited on it and one CI job died at its 15-minute timeout with no file named
|
|
722
|
+
* (2026-08-25). A non-persistent watcher still delivers every event while the
|
|
723
|
+
* dev server holds the process open, which is the only time this runs.
|
|
674
724
|
*/
|
|
675
725
|
function watchRecord(recordDir: string, stageDir: string): void {
|
|
676
726
|
if (process.env.NODE_ENV !== "development" || watching) return;
|
|
677
727
|
watching = true;
|
|
678
728
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
679
|
-
const watcher = watch(recordDir, { recursive: true }, () => {
|
|
729
|
+
const watcher = watch(recordDir, { recursive: true, persistent: false }, () => {
|
|
680
730
|
if (pending !== null) clearTimeout(pending);
|
|
681
731
|
// Debounced: one save is several filesystem events.
|
|
682
732
|
pending = setTimeout(() => {
|
|
683
733
|
try {
|
|
684
|
-
refreshStage(recordDir, stageDir
|
|
734
|
+
refreshStage(recordDir, stageDir);
|
|
685
735
|
} catch {
|
|
686
736
|
// An editor saving atomically, or a file being moved, is a record
|
|
687
737
|
// that is briefly incomplete — the next event re-runs this, and a
|
|
@@ -694,9 +744,11 @@ function watchRecord(recordDir: string, stageDir: string): void {
|
|
|
694
744
|
}
|
|
695
745
|
|
|
696
746
|
/**
|
|
697
|
-
* The directory the docs collection reads:
|
|
698
|
-
*
|
|
699
|
-
*
|
|
747
|
+
* The directory the docs collection reads: ALWAYS a staged projection of the
|
|
748
|
+
* record for this build's viewer, never the record itself. The level-0 fast
|
|
749
|
+
* path that served `knowledge/` raw is gone, because no record is safe to
|
|
750
|
+
* serve raw any more: every one has drafts, a ledger, and indexes that list
|
|
751
|
+
* what this viewer may not see (build spec §3).
|
|
700
752
|
*
|
|
701
753
|
* Every surface reads the record through that one collection, so filtering
|
|
702
754
|
* the directory behind it filters all of them at once — pages, page tree,
|
|
@@ -717,20 +769,19 @@ function watchRecord(recordDir: string, stageDir: string): void {
|
|
|
717
769
|
* or carries a takedown, and most records declare neither, so a sim hung off
|
|
718
770
|
* it published for the rare record and silently vanished for the common one
|
|
719
771
|
* (found live 2026-08-24: nothing reached `public/` on the level-0 path).
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
772
|
+
*
|
|
773
|
+
* That fast path is gone (decision 27: no record is safe to serve raw once
|
|
774
|
+
* drafts and lifecycle decide what publishes), so this now always walks the
|
|
775
|
+
* STAGED directory — which means it inherits the audience filter AND the
|
|
776
|
+
* staging plan's rule that only a REFERENCED asset ships. The level-0 caveat
|
|
777
|
+
* this comment used to carry no longer has a case that reaches it.
|
|
726
778
|
*/
|
|
727
|
-
|
|
728
|
-
function hasSims(dir: string): boolean {
|
|
729
|
-
return walkFiles(dir).some((file) => file.endsWith(SIM_SUFFIX));
|
|
730
|
-
}
|
|
779
|
+
const PUBLIC_SIM_DIR = "./public/sims";
|
|
731
780
|
|
|
732
781
|
function publishSims(sourceDir: string): void {
|
|
733
782
|
const target = path.resolve(process.cwd(), PUBLIC_SIM_DIR);
|
|
783
|
+
/** Absolute paths this build publishes — everything else under `target` is last build's. */
|
|
784
|
+
const published = new Set<string>();
|
|
734
785
|
|
|
735
786
|
const walk = (dir: string, rel: string): void => {
|
|
736
787
|
let entries;
|
|
@@ -748,6 +799,7 @@ function publishSims(sourceDir: string): void {
|
|
|
748
799
|
}
|
|
749
800
|
if (!entry.name.endsWith(SIM_SUFFIX)) continue;
|
|
750
801
|
const to = path.join(target, publicSimPath(next));
|
|
802
|
+
published.add(path.resolve(to));
|
|
751
803
|
// Same size AND same mtime is this file's own definition of unchanged
|
|
752
804
|
// (see `stageHolds`). Skipping the write is what keeps the common
|
|
753
805
|
// build from touching the tree at all.
|
|
@@ -771,44 +823,66 @@ function publishSims(sourceDir: string): void {
|
|
|
771
823
|
// rethrown. Green on macOS and Linux, red on Windows CI, from a pass that
|
|
772
824
|
// was correct about needing the lock and wrong about taking it again.
|
|
773
825
|
walk(sourceDir, "");
|
|
826
|
+
pruneSims(target, published);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Everything under `public/sims/` that THIS build did not publish, removed.
|
|
831
|
+
*
|
|
832
|
+
* Copying without pruning made the directory cumulative, and it is the one
|
|
833
|
+
* place where that is a governance leak rather than stale bytes: a build with
|
|
834
|
+
* `KSOR_AUDIENCE=public,internal` publishes an internal document's sim, the
|
|
835
|
+
* next plain `pnpm build` stages only public documents — correctly — and the
|
|
836
|
+
* internal sim is still sitting in `public/`, which static export ships
|
|
837
|
+
* verbatim, at a live URL. `.gitignore` hides the directory, so it accumulates
|
|
838
|
+
* unseen, and every existing assertion read the STAGE, which was right in both
|
|
839
|
+
* builds. Same shape for takedown: deny a document, rebuild, its published sim
|
|
840
|
+
* survives. Found by the 2026-08-25 review and reproduced before this was
|
|
841
|
+
* written.
|
|
842
|
+
*
|
|
843
|
+
* The directory is build-owned (`system/site/public/sims/` is gitignored, and
|
|
844
|
+
* nothing else writes it), so what is not published now does not belong.
|
|
845
|
+
*/
|
|
846
|
+
function pruneSims(target: string, published: ReadonlySet<string>): void {
|
|
847
|
+
const walk = (dir: string): boolean => {
|
|
848
|
+
let entries;
|
|
849
|
+
try {
|
|
850
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
851
|
+
} catch {
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
let empty = true;
|
|
855
|
+
for (const entry of entries) {
|
|
856
|
+
const here = path.join(dir, entry.name);
|
|
857
|
+
if (entry.isDirectory()) {
|
|
858
|
+
if (walk(here)) rmSync(here, { recursive: true, force: true });
|
|
859
|
+
else empty = false;
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (published.has(path.resolve(here))) {
|
|
863
|
+
empty = false;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
rmSync(here, { force: true });
|
|
867
|
+
}
|
|
868
|
+
return empty;
|
|
869
|
+
};
|
|
870
|
+
walk(target);
|
|
774
871
|
}
|
|
775
872
|
|
|
776
873
|
export function knowledgeSourceDir(): string {
|
|
777
874
|
const stageDir = path.resolve(process.cwd(), STAGE_DIR);
|
|
778
|
-
const recordDir = path.
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (audienceModel === null && (denied.denied ?? []).length === 0) {
|
|
787
|
-
// Nothing to filter — serve the record itself, the level-0 fast path.
|
|
788
|
-
// A stage left behind by an earlier model would be a filtered copy of the
|
|
789
|
-
// record nothing governs any more — removed before the refusal below can
|
|
790
|
-
// throw, so a refused build never leaves one behind either. Under the lock,
|
|
791
|
-
// because two evaluations removing one tree is the `ENOTEMPTY` shape of the
|
|
792
|
-
// same race; the existence check keeps a record that never stages from
|
|
793
|
-
// taking a lock on every build.
|
|
794
|
-
refuseVisibilityWithoutAudiences(recordDir);
|
|
795
|
-
assertAttachmentsWellFormed(recordDir);
|
|
796
|
-
// ONE acquisition on this path too, doing both jobs — and keyed on the
|
|
797
|
-
// stage path rather than the record's, because `${recordDir}.lock` would
|
|
798
|
-
// drop a lock file beside `knowledge/`, in the adopter's repo, for a build
|
|
799
|
-
// that never stages anything.
|
|
800
|
-
const stale = existsSync(stageDir);
|
|
801
|
-
if (stale || hasSims(recordDir)) {
|
|
802
|
-
withStageLock(stageDir, () => {
|
|
803
|
-
if (stale) removeStage(stageDir);
|
|
804
|
-
publishSims(recordDir);
|
|
805
|
-
});
|
|
806
|
-
}
|
|
807
|
-
return RECORD_DIR;
|
|
875
|
+
const recordDir = path.join(projectRoot, "knowledge");
|
|
876
|
+
if (!existsSync(recordDir)) {
|
|
877
|
+
refuse(
|
|
878
|
+
"ksor-record-missing",
|
|
879
|
+
`${recordDir} does not exist`,
|
|
880
|
+
"the record is the bundle under knowledge/; a site with nothing to project has nothing to build",
|
|
881
|
+
"restore knowledge/ from git history, or add the first document",
|
|
882
|
+
);
|
|
808
883
|
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
fillStage(recordDir, stageDir, denied);
|
|
884
|
+
const development = process.env.NODE_ENV === "development";
|
|
885
|
+
fillStage(recordDir, stageDir, development);
|
|
812
886
|
watchRecord(recordDir, stageDir);
|
|
813
887
|
return STAGE_DIR;
|
|
814
888
|
}
|