@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
|
@@ -3,384 +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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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";
|
|
37
51
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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).
|
|
42
58
|
*/
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (match === null) return null;
|
|
47
|
-
const raw = (match[1] ?? "").replace(/\s+#.*$/, "").trim();
|
|
48
|
-
const value = /^(['"])(.*)\1$/.exec(raw)?.[2] ?? raw;
|
|
49
|
-
return value === "" ? UNREADABLE : value;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function walkFiles(dir: string): string[] {
|
|
53
|
-
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
54
|
-
const p = path.join(dir, entry.name);
|
|
55
|
-
return entry.isDirectory() ? walkFiles(p) : [p];
|
|
56
|
-
});
|
|
59
|
+
interface StageEntry {
|
|
60
|
+
readonly rel: string;
|
|
61
|
+
readonly bytes: () => Buffer;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
* Strips fenced blocks and inline code spans (per paragraph: CommonMark
|
|
63
|
-
* spans may cross lines, and a document-wide strip lets one stray backtick
|
|
64
|
-
* pair with another pages later).
|
|
65
|
-
*/
|
|
66
|
-
function stripCode(text: string): string {
|
|
67
|
-
const kept: string[] = [];
|
|
68
|
-
let fence: { char: string; length: number } | null = null;
|
|
69
|
-
let blank = true;
|
|
70
|
-
let indented = false;
|
|
71
|
-
for (const line of text.replaceAll("\r\n", "\n").split("\n")) {
|
|
72
|
-
if (fence) {
|
|
73
|
-
const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
74
|
-
if (close && close[1]?.[0] === fence.char && (close[1]?.length ?? 0) >= fence.length) {
|
|
75
|
-
fence = null;
|
|
76
|
-
}
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
80
|
-
if (open?.[1]) {
|
|
81
|
-
fence = { char: open[1][0] as string, length: open[1].length };
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
// An indented run opened after a blank line is a code block — unless it
|
|
85
|
-
// starts a list item, which sits at exactly this indent and carries real
|
|
86
|
-
// links.
|
|
87
|
-
if (/^(?: {4}|\t)/.test(line) && !/^[ \t]+(?:[-*+]|\d+[.)])\s/.test(line)) {
|
|
88
|
-
if (blank || indented) {
|
|
89
|
-
indented = true;
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
} else if (line.trim() !== "") {
|
|
93
|
-
indented = false;
|
|
94
|
-
}
|
|
95
|
-
blank = line.trim() === "";
|
|
96
|
-
kept.push(line);
|
|
97
|
-
}
|
|
98
|
-
return kept
|
|
99
|
-
.join("\n")
|
|
100
|
-
.split(/\n{2,}/)
|
|
101
|
-
.map((paragraph) => paragraph.replace(/(`+)[^`]*?\1/g, " "))
|
|
102
|
-
.join("\n\n");
|
|
64
|
+
interface StagePlan {
|
|
65
|
+
readonly entries: readonly StageEntry[];
|
|
66
|
+
readonly manifest: StageManifest;
|
|
103
67
|
}
|
|
104
68
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
/\[[^\]]*\]\(\s*(<[^<>\n]*>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*\)/g;
|
|
110
|
-
const REFERENCE_DEFINITION =
|
|
111
|
-
/^[ \t]{0,3}\[[^\]]+\]:[ \t]*(<[^<>\n]*>|\S+)[ \t]*(?:"[^"]*"|'[^']*'|\([^)]*\))?[ \t]*$/gm;
|
|
112
|
-
|
|
113
|
-
function linkTargets(body: string): string[] {
|
|
114
|
-
const raw: string[] = [];
|
|
115
|
-
for (const match of body.matchAll(INLINE_LINK)) if (match[1]) raw.push(match[1]);
|
|
116
|
-
for (const match of body.matchAll(REFERENCE_DEFINITION)) if (match[1]) raw.push(match[1]);
|
|
117
|
-
// <…> exists so a destination may contain spaces; the brackets are syntax.
|
|
118
|
-
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"));
|
|
119
73
|
}
|
|
120
74
|
|
|
121
75
|
/**
|
|
122
|
-
* The asset a link points at, or null when it points anywhere else
|
|
123
|
-
*
|
|
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.
|
|
124
92
|
*/
|
|
125
|
-
function assetTarget(recordDir: string,
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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);
|
|
129
98
|
if (!resolved.startsWith(recordDir + path.sep)) return null;
|
|
130
|
-
// .md AND .mdx: both render as pages, so neither may ride in as an
|
|
131
|
-
// "asset" — a restricted plan.mdx staged that way published untiered
|
|
132
|
-
// (review finding, 2026-08-18). The record bans .mdx, but staging never
|
|
133
|
-
// depends on the checker having run.
|
|
134
99
|
if (/\.mdx?$/i.test(resolved)) return null;
|
|
100
|
+
if (isAttachment(path.basename(resolved))) return null;
|
|
135
101
|
try {
|
|
136
|
-
|
|
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;
|
|
137
109
|
} catch {
|
|
138
110
|
return null;
|
|
139
111
|
}
|
|
140
112
|
}
|
|
141
113
|
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
interface StagePlan {
|
|
149
|
-
/** Documents and assets to copy, in that order. */
|
|
150
|
-
readonly files: readonly string[];
|
|
151
|
-
readonly documents: number;
|
|
152
|
-
/** Every document in the record, whatever its tier. */
|
|
153
|
-
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}`);
|
|
154
120
|
}
|
|
155
121
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
return /^database:/m.test(instanceFrontmatter());
|
|
196
|
-
}
|
|
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
|
+
}
|
|
197
161
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 },
|
|
215
182
|
);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
)
|
|
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
|
+
});
|
|
224
203
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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;
|
|
229
225
|
refuse(
|
|
230
|
-
"ksor-
|
|
231
|
-
|
|
232
|
-
"an
|
|
233
|
-
`
|
|
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`,
|
|
234
230
|
);
|
|
235
231
|
}
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
+
}
|
|
255
304
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
"ksor-denylist-format",
|
|
259
|
-
`${DENYLIST_FILE} declares format ${JSON.stringify(parsed.format)}, which this site cannot read`,
|
|
260
|
-
"a manifest shape this build does not understand cannot be trusted to say what is withdrawn",
|
|
261
|
-
"upgrade the site, or re-export with a matching ksor version",
|
|
262
|
-
);
|
|
305
|
+
for (const asset of assets) {
|
|
306
|
+
copy(path.relative(recordDir, asset).split(path.sep).join("/"), asset);
|
|
263
307
|
}
|
|
264
|
-
|
|
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) {
|
|
265
320
|
refuse(
|
|
266
|
-
"ksor-
|
|
267
|
-
|
|
268
|
-
"a
|
|
269
|
-
|
|
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",
|
|
270
325
|
);
|
|
271
326
|
}
|
|
272
|
-
|
|
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
|
+
};
|
|
273
349
|
}
|
|
274
350
|
|
|
275
351
|
/**
|
|
276
|
-
*
|
|
277
|
-
* takedown governs.
|
|
352
|
+
* BOTH baselines the ledger is judged against — the lock's, and git history's.
|
|
278
353
|
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
* its own walk uses parent_id rather than a prefix (round-2 review of #43).
|
|
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.
|
|
285
359
|
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* llms.txt under a section that had been explicitly withdrawn — while decision
|
|
290
|
-
* 14 states outright that a subtree deny must cover descendants a future
|
|
291
|
-
* re-ingest adds (round-5 review of #43).
|
|
292
|
-
*
|
|
293
|
-
* So subtree denials also arrive as DIRECTORIES. That is not the rejected
|
|
294
|
-
* prefix match: these paths come from `sources.origin_path`, so they are real
|
|
295
|
-
* locations on disk, and a document's location cannot be decoupled from itself
|
|
296
|
-
* by a frontmatter `sor_id:` the way its id can.
|
|
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.
|
|
297
363
|
*/
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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];
|
|
318
385
|
}
|
|
319
386
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
function planStage(recordDir: string, denied: DenylistManifest): StagePlan {
|
|
327
|
-
const documents: string[] = [];
|
|
328
|
-
const assets = new Set<string>();
|
|
329
|
-
let total = 0;
|
|
330
|
-
for (const file of walkFiles(recordDir)) {
|
|
331
|
-
// An attachment is not a document: it is neither counted nor filtered on
|
|
332
|
-
// its own terms. It rides in below, with the parent that survived — which
|
|
333
|
-
// is the whole of governance inheritance, obtained by POSITION rather than
|
|
334
|
-
// by a second rule that could disagree with this one.
|
|
335
|
-
if (isAttachment(path.basename(file))) continue;
|
|
336
|
-
if (!file.toLowerCase().endsWith(".md")) continue;
|
|
337
|
-
total += 1;
|
|
338
|
-
const text = readFileSync(file, "utf8");
|
|
339
|
-
// An undeclared tier reads as a restriction and the document appears in
|
|
340
|
-
// no build at all — fail closed here, and `pnpm check` (which CI runs) is
|
|
341
|
-
// what names the typo.
|
|
342
|
-
if (!visibleInBuild(visibilityOf(text))) continue;
|
|
343
|
-
// A takedown beats every other consideration, on every surface.
|
|
344
|
-
if (isDenied(denied, stableIdOf(recordDir, file, text), recordPathOf(recordDir, file)))
|
|
345
|
-
continue;
|
|
346
|
-
documents.push(file);
|
|
347
|
-
// The parent survived BOTH filters, so its attachments may be published.
|
|
348
|
-
// Reached only here: a filtered or denied parent never gets this far, so
|
|
349
|
-
// there is no path on which an attachment is staged without its parent.
|
|
350
|
-
for (const suffix of ATTACHMENT_SUFFIXES_FOR_STAGE) {
|
|
351
|
-
const attachment = file.replace(/\.mdx?$/i, "") + suffix;
|
|
352
|
-
if (existsSync(attachment)) assets.add(attachment);
|
|
353
|
-
}
|
|
354
|
-
// Body only: frontmatter carries no links in the record grammar, and
|
|
355
|
-
// scanning it here while the other shell strips it staged different
|
|
356
|
-
// asset sets from one record (review finding, 2026-08-18).
|
|
357
|
-
const block = frontmatterBlock(text);
|
|
358
|
-
const body = block === "" ? text : text.slice(text.indexOf(block) + block.length);
|
|
359
|
-
for (const target of linkTargets(stripCode(body))) {
|
|
360
|
-
const asset = assetTarget(recordDir, file, target);
|
|
361
|
-
if (asset !== null) assets.add(asset);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
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
|
+
});
|
|
365
392
|
}
|
|
366
393
|
|
|
367
394
|
/** How often a waiter looks again. */
|
|
368
395
|
const LOCK_POLL_MS = 25;
|
|
369
396
|
/** How long a wait goes unexplained. A build that looks hung must say why. */
|
|
370
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;
|
|
371
409
|
|
|
372
410
|
/** Synchronous, because everything on this path is: a bundler cannot await. */
|
|
373
411
|
function sleepSync(ms: number): void {
|
|
374
412
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
375
413
|
}
|
|
376
414
|
|
|
377
|
-
|
|
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 {
|
|
378
425
|
try {
|
|
379
426
|
process.kill(pid, 0);
|
|
380
|
-
return
|
|
427
|
+
return "alive";
|
|
381
428
|
} catch (error) {
|
|
382
|
-
|
|
383
|
-
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
429
|
+
return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-ours" : "gone";
|
|
384
430
|
}
|
|
385
431
|
}
|
|
386
432
|
|
|
@@ -402,12 +448,57 @@ function lockIsAbandoned(lockFile: string): boolean {
|
|
|
402
448
|
return false;
|
|
403
449
|
}
|
|
404
450
|
const pid = Number(stamp);
|
|
405
|
-
if (Number.isInteger(pid) && pid > 0) return
|
|
451
|
+
if (Number.isInteger(pid) && pid > 0) return probePid(pid) === "gone";
|
|
406
452
|
if (look === 0) sleepSync(LOCK_POLL_MS * 2);
|
|
407
453
|
}
|
|
408
454
|
return true;
|
|
409
455
|
}
|
|
410
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
|
+
|
|
411
502
|
/**
|
|
412
503
|
* Hold the stage lock for the duration of `work`: ONE evaluation writes the
|
|
413
504
|
* stage at a time, and this file says which.
|
|
@@ -417,10 +508,11 @@ function lockIsAbandoned(lockFile: string): boolean {
|
|
|
417
508
|
* (2026-08-23) — and staging was destructive on every evaluation: delete the
|
|
418
509
|
* whole stage, refill it. Two of those overlapping is not a rare interleaving,
|
|
419
510
|
* it is what seven of them do — six concurrent evaluations of a 150-document
|
|
420
|
-
* record failed 42 of 48 runs, in four shapes: `ENOENT` and `EINVAL` out of
|
|
421
|
-
* issue #100), `ENOTEMPTY` out of `rmSync`
|
|
422
|
-
* place, and — 27 of the 48, the majority — no
|
|
423
|
-
* 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.
|
|
424
516
|
*
|
|
425
517
|
* The silent shape is why this is a lock and not another retry. A crash fails
|
|
426
518
|
* a build; a short stage PUBLISHES one, with documents missing from /docs,
|
|
@@ -428,12 +520,17 @@ function lockIsAbandoned(lockFile: string): boolean {
|
|
|
428
520
|
*
|
|
429
521
|
* `wx` is the whole primitive: create-if-absent, atomically, on every
|
|
430
522
|
* filesystem Node supports — and it stamps the holder's pid in the same call,
|
|
431
|
-
* 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.
|
|
432
526
|
*
|
|
433
|
-
* Waiting on a
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
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.
|
|
437
534
|
*/
|
|
438
535
|
function withStageLock<T>(stageDir: string, work: () => T): T {
|
|
439
536
|
const lockFile = `${stageDir}.lock`;
|
|
@@ -444,11 +541,31 @@ function withStageLock<T>(stageDir: string, work: () => T): T {
|
|
|
444
541
|
writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
445
542
|
break;
|
|
446
543
|
} catch (error) {
|
|
447
|
-
|
|
544
|
+
// EEXIST is "someone holds it". EPERM is the SAME THING on Windows: a
|
|
545
|
+
// create against a path whose file is in the pending-delete state — the
|
|
546
|
+
// window between another process calling `rmSync` and the filesystem
|
|
547
|
+
// actually releasing the name — raises EPERM, not EEXIST. Rethrowing it
|
|
548
|
+
// failed the build for the ordinary contended case, and only on Windows,
|
|
549
|
+
// and only sometimes: green on five CI runs of this same code and red on
|
|
550
|
+
// the next two, because it depends on landing inside a window a few
|
|
551
|
+
// milliseconds wide (2026-08-25, `Init acceptance (Windows)`).
|
|
552
|
+
//
|
|
553
|
+
// Waiting is safe for both: a holder that has died leaves a lock
|
|
554
|
+
// `lockIsAbandoned` breaks, so neither code can wait forever on a
|
|
555
|
+
// process that is gone.
|
|
556
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
557
|
+
if (code !== "EEXIST" && code !== "EPERM") throw error;
|
|
448
558
|
if (lockIsAbandoned(lockFile)) {
|
|
449
559
|
rmSync(lockFile, { force: true });
|
|
450
560
|
continue;
|
|
451
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
|
+
}
|
|
452
569
|
sleepSync(LOCK_POLL_MS);
|
|
453
570
|
waited += LOCK_POLL_MS;
|
|
454
571
|
if (waited >= LOCK_ANNOUNCE_MS && !announced) {
|
|
@@ -476,11 +593,12 @@ function withStageLock<T>(stageDir: string, work: () => T): T {
|
|
|
476
593
|
* is set (it defaults to zero). Those are what a Windows indexer or an
|
|
477
594
|
* antivirus scanner holding a handle looks like — not ksor, and not something
|
|
478
595
|
* the lock can serialise. Losing that race is safe: the stage is a
|
|
479
|
-
* deterministic function of the record and the
|
|
596
|
+
* deterministic function of the record, the ledger and the lock, so redoing it
|
|
480
597
|
* produces the same bytes.
|
|
481
598
|
*/
|
|
482
599
|
function removeStage(stageDir: string): void {
|
|
483
600
|
rmSync(stageDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
|
|
601
|
+
rmSync(path.resolve(path.dirname(stageDir), STAGE_MANIFEST), { force: true });
|
|
484
602
|
}
|
|
485
603
|
|
|
486
604
|
/**
|
|
@@ -488,180 +606,132 @@ function removeStage(stageDir: string): void {
|
|
|
488
606
|
*
|
|
489
607
|
* The wipe-and-refill is the destructive half of staging, and it is pure waste
|
|
490
608
|
* whenever the answer is yes — which is every evaluation after the first in
|
|
491
|
-
* one build, since the plan is a deterministic function of
|
|
492
|
-
*
|
|
493
|
-
*
|
|
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
|
|
494
612
|
* already returned is reading it. The lock stops two writers colliding; this
|
|
495
613
|
* stops the second writer existing at all.
|
|
496
614
|
*
|
|
497
615
|
* Bytes, not names and not timestamps: the alternative is serving a previous
|
|
498
616
|
* build's copy of a document that has since been edited.
|
|
499
617
|
*/
|
|
500
|
-
function stageHolds(
|
|
618
|
+
function stageHolds(stageDir: string, plan: StagePlan): boolean {
|
|
501
619
|
let staged: string[];
|
|
502
620
|
try {
|
|
503
621
|
staged = walkFiles(stageDir);
|
|
504
622
|
} catch {
|
|
505
623
|
return false;
|
|
506
624
|
}
|
|
507
|
-
if (staged.length !== plan.
|
|
508
|
-
const expected = new Map(
|
|
509
|
-
plan.files.map((from) => [path.join(stageDir, path.relative(recordDir, from)), from]),
|
|
510
|
-
);
|
|
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));
|
|
511
627
|
for (const file of staged) {
|
|
512
|
-
const
|
|
513
|
-
if (
|
|
514
|
-
if (!
|
|
628
|
+
const entry = expected.get(file);
|
|
629
|
+
if (entry === undefined) return false;
|
|
630
|
+
if (!entry.bytes().equals(readFileSync(file))) return false;
|
|
515
631
|
}
|
|
516
632
|
return true;
|
|
517
633
|
}
|
|
518
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
|
+
|
|
519
642
|
/** Fill a clean stage with exactly the set this build may publish. */
|
|
520
|
-
function fillStage(recordDir: string, stageDir: string,
|
|
643
|
+
function fillStage(recordDir: string, stageDir: string, development: boolean): void {
|
|
521
644
|
withStageLock(stageDir, () => {
|
|
522
645
|
let plan: StagePlan;
|
|
523
646
|
try {
|
|
524
|
-
plan = planStage(recordDir,
|
|
525
|
-
// An empty record is its own problem, reported by the page that renders
|
|
526
|
-
// it; an empty AUDIENCE is a misconfiguration that would otherwise
|
|
527
|
-
// surface as "the record has no documents" against a record full of them.
|
|
528
|
-
if (plan.documents === 0 && plan.total > 0) {
|
|
529
|
-
refuse(
|
|
530
|
-
"ksor-audience-empty",
|
|
531
|
-
`no document in the record is visible to the ${buildAudience} build (${plan.total} document${plan.total === 1 ? "" : "s"}, all above that tier)`,
|
|
532
|
-
"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",
|
|
533
|
-
"build a wider audience with KSOR_AUDIENCE, lower default_visibility in instance.md, or give at least one document this tier",
|
|
534
|
-
);
|
|
535
|
-
}
|
|
647
|
+
plan = planStage(recordDir, development);
|
|
536
648
|
} catch (error) {
|
|
537
649
|
// No refusal may leave the previous, more permissive stage on disk: it
|
|
538
650
|
// hands the next careless build a filtered copy nothing governs (review
|
|
539
|
-
// finding, 2026-08-19).
|
|
540
|
-
// why nothing could ask whether the stage was already correct.
|
|
651
|
+
// finding, 2026-08-19).
|
|
541
652
|
removeStage(stageDir);
|
|
542
653
|
throw error;
|
|
543
654
|
}
|
|
544
|
-
if (stageHolds(
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* With no audience model, `visibility:` is a promise nothing keeps: every
|
|
556
|
-
* document publishes, including one whose author marked it restricted. The
|
|
557
|
-
* checker refuses this record-wide; the build refuses it too, because a
|
|
558
|
-
* deleted or mistyped `audiences:` block would otherwise publish every
|
|
559
|
-
* restricted document on a green build (vis-docusaurus, 2026-08-18).
|
|
560
|
-
*/
|
|
561
|
-
/**
|
|
562
|
-
* Attachments the record cannot publish, refused at the BUILD.
|
|
563
|
-
*
|
|
564
|
-
* Staging never depends on the checker having run, so both rules need a home
|
|
565
|
-
* here as well as in `pnpm check` — the checker is where they get a good
|
|
566
|
-
* message, this is where they are guaranteed.
|
|
567
|
-
*
|
|
568
|
-
* Runs on every path, including the level-0 fast path that stages nothing:
|
|
569
|
-
* an orphan is a governance hole whether or not this record declares
|
|
570
|
-
* audiences.
|
|
571
|
-
*/
|
|
572
|
-
function assertAttachmentsWellFormed(recordDir: string): void {
|
|
573
|
-
for (const file of walkFiles(recordDir)) {
|
|
574
|
-
const base = path.basename(file);
|
|
575
|
-
if (!isAttachment(base)) continue;
|
|
576
|
-
const rel = path.relative(recordDir, file);
|
|
577
|
-
|
|
578
|
-
const parent = parentDocumentOf(base);
|
|
579
|
-
if (parent !== null && !existsSync(path.join(path.dirname(file), parent))) {
|
|
580
|
-
refuse(
|
|
581
|
-
"ksor-attachment-orphan",
|
|
582
|
-
`${rel} is an attachment of ${parent}, which is not in the record`,
|
|
583
|
-
"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",
|
|
584
|
-
`add ${path.join(path.dirname(rel), parent)}, or remove ${rel}`,
|
|
585
|
-
);
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// No frontmatter, at all. One rule kills the whole widening class:
|
|
589
|
-
// no `visibility:` claiming a tier the parent does not have, no `sor_id:`
|
|
590
|
-
// escaping the parent's takedown, no `status:`/`owner:` claiming
|
|
591
|
-
// governance a thing with no id cannot carry.
|
|
592
|
-
if (base.toLowerCase().endsWith(".md") || base.toLowerCase().endsWith(".mdx")) {
|
|
593
|
-
const text = readFileSync(file, "utf8")
|
|
594
|
-
.replace(/^\uFEFF/, "")
|
|
595
|
-
.replaceAll("\r\n", "\n");
|
|
596
|
-
if (text.startsWith("---\n")) {
|
|
597
|
-
refuse(
|
|
598
|
-
"ksor-attachment-frontmatter",
|
|
599
|
-
`${rel} declares frontmatter`,
|
|
600
|
-
"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",
|
|
601
|
-
`remove the frontmatter block from ${rel}; ${parent ?? "its parent"} is what carries the governance`,
|
|
602
|
-
);
|
|
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());
|
|
603
661
|
}
|
|
604
662
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
if (visibility === null) continue;
|
|
613
|
-
refuse(
|
|
614
|
-
"ksor-visibility-without-audiences",
|
|
615
|
-
`${path.relative(recordDir, file)} declares visibility: ${visibility}, but instance.md declares no audiences`,
|
|
616
|
-
"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",
|
|
617
|
-
"declare the model in instance.md (audiences: least-restricted first, plus default_visibility:), or remove the visibility: key",
|
|
618
|
-
);
|
|
619
|
-
}
|
|
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.
|
|
668
|
+
publishSims(stageDir);
|
|
669
|
+
});
|
|
620
670
|
}
|
|
621
671
|
|
|
622
672
|
/**
|
|
623
|
-
* Dev only: carry edits into the
|
|
673
|
+
* Dev only: carry edits into the files the stage already holds, so
|
|
624
674
|
* `pnpm dev` shows the record as the owner is writing it rather than as it
|
|
625
|
-
* 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.
|
|
626
677
|
*
|
|
627
678
|
* Edits only — never adds, never removals. fumadocs' own watcher cannot see
|
|
628
679
|
* a dot-prefixed collection directory (measured 2026-08-18: adding a file to
|
|
629
680
|
* the stage regenerated nothing, and removing one left the generated imports
|
|
630
681
|
* pointing at a file that was gone), so a document that ARRIVES or changes
|
|
631
|
-
*
|
|
682
|
+
* audience needs the restart `pnpm dev` already needs for instance.md. Leaving
|
|
632
683
|
* that to a restart keeps dev honest in the direction that matters: the
|
|
633
684
|
* published build is always staged from scratch.
|
|
634
685
|
*/
|
|
635
|
-
function refreshStage(recordDir: string, stageDir: string
|
|
686
|
+
function refreshStage(recordDir: string, stageDir: string): void {
|
|
636
687
|
// Under the lock like every other write here: a save landing while another
|
|
637
688
|
// evaluation is refilling the stage is the same race from the other side.
|
|
638
689
|
withStageLock(stageDir, () => {
|
|
639
|
-
const
|
|
690
|
+
const plan = planStage(recordDir, true);
|
|
691
|
+
const permitted = new Map(plan.entries.map((e) => [path.join(stageDir, e.rel), e] as const));
|
|
640
692
|
for (const staged of walkFiles(stageDir)) {
|
|
641
|
-
const
|
|
642
|
-
if (
|
|
643
|
-
|
|
644
|
-
|
|
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);
|
|
645
698
|
}
|
|
699
|
+
writeManifest(stageDir, plan.manifest);
|
|
646
700
|
});
|
|
647
701
|
}
|
|
648
702
|
|
|
649
703
|
let watching = false;
|
|
650
704
|
|
|
651
705
|
/**
|
|
652
|
-
* Watch the record in development, never in a build — and
|
|
653
|
-
*
|
|
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.
|
|
654
724
|
*/
|
|
655
725
|
function watchRecord(recordDir: string, stageDir: string): void {
|
|
656
726
|
if (process.env.NODE_ENV !== "development" || watching) return;
|
|
657
727
|
watching = true;
|
|
658
728
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
659
|
-
const watcher = watch(recordDir, { recursive: true }, () => {
|
|
729
|
+
const watcher = watch(recordDir, { recursive: true, persistent: false }, () => {
|
|
660
730
|
if (pending !== null) clearTimeout(pending);
|
|
661
731
|
// Debounced: one save is several filesystem events.
|
|
662
732
|
pending = setTimeout(() => {
|
|
663
733
|
try {
|
|
664
|
-
refreshStage(recordDir, stageDir
|
|
734
|
+
refreshStage(recordDir, stageDir);
|
|
665
735
|
} catch {
|
|
666
736
|
// An editor saving atomically, or a file being moved, is a record
|
|
667
737
|
// that is briefly incomplete — the next event re-runs this, and a
|
|
@@ -674,9 +744,11 @@ function watchRecord(recordDir: string, stageDir: string): void {
|
|
|
674
744
|
}
|
|
675
745
|
|
|
676
746
|
/**
|
|
677
|
-
* The directory the docs collection reads:
|
|
678
|
-
*
|
|
679
|
-
*
|
|
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).
|
|
680
752
|
*
|
|
681
753
|
* Every surface reads the record through that one collection, so filtering
|
|
682
754
|
* the directory behind it filters all of them at once — pages, page tree,
|
|
@@ -686,32 +758,131 @@ function watchRecord(recordDir: string, stageDir: string): void {
|
|
|
686
758
|
* per-request filter leaked on the fifth and sixth consumer of the record
|
|
687
759
|
* its own author had not enumerated (research/visibility.md §4–§5).
|
|
688
760
|
*/
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* A sim is the one asset that has to be SERVED rather than bundled: it is a
|
|
764
|
+
* page, and a page needs a url before anything can frame it. Next copies
|
|
765
|
+
* `public/` into the export as-is, so that is where it goes.
|
|
766
|
+
*
|
|
767
|
+
* A PASS OF ITS OWN, over the directory the collection actually reads — not a
|
|
768
|
+
* rider on staging. Staging runs only for a record that declares `audiences:`
|
|
769
|
+
* or carries a takedown, and most records declare neither, so a sim hung off
|
|
770
|
+
* it published for the rare record and silently vanished for the common one
|
|
771
|
+
* (found live 2026-08-24: nothing reached `public/` on the level-0 path).
|
|
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.
|
|
778
|
+
*/
|
|
779
|
+
const PUBLIC_SIM_DIR = "./public/sims";
|
|
780
|
+
|
|
781
|
+
function publishSims(sourceDir: string): void {
|
|
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>();
|
|
785
|
+
|
|
786
|
+
const walk = (dir: string, rel: string): void => {
|
|
787
|
+
let entries;
|
|
788
|
+
try {
|
|
789
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
790
|
+
} catch {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
for (const entry of entries) {
|
|
794
|
+
const from = path.join(dir, entry.name);
|
|
795
|
+
const next = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
796
|
+
if (entry.isDirectory()) {
|
|
797
|
+
walk(from, next);
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (!entry.name.endsWith(SIM_SUFFIX)) continue;
|
|
801
|
+
const to = path.join(target, publicSimPath(next));
|
|
802
|
+
published.add(path.resolve(to));
|
|
803
|
+
// Same size AND same mtime is this file's own definition of unchanged
|
|
804
|
+
// (see `stageHolds`). Skipping the write is what keeps the common
|
|
805
|
+
// build from touching the tree at all.
|
|
806
|
+
try {
|
|
807
|
+
const source = statSync(from);
|
|
808
|
+
const published = statSync(to);
|
|
809
|
+
if (published.size === source.size && published.mtimeMs >= source.mtimeMs) continue;
|
|
810
|
+
} catch {
|
|
811
|
+
// Not published yet, which is the ordinary first-build case.
|
|
812
|
+
}
|
|
813
|
+
mkdirSync(path.dirname(to), { recursive: true });
|
|
814
|
+
copyFileSync(from, to);
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// The CALLER holds the stage lock, and this takes none of its own — for the
|
|
819
|
+
// reason `withStageLock` records at length, plus one this change learned on
|
|
820
|
+
// Windows: taking it a SECOND time per evaluation doubles the create/delete
|
|
821
|
+
// churn on one lock file, and `wx` create against a file in Windows'
|
|
822
|
+
// pending-delete state fails as `EPERM`, which is not `EEXIST` and so is
|
|
823
|
+
// rethrown. Green on macOS and Linux, red on Windows CI, from a pass that
|
|
824
|
+
// was correct about needing the lock and wrong about taking it again.
|
|
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);
|
|
871
|
+
}
|
|
872
|
+
|
|
689
873
|
export function knowledgeSourceDir(): string {
|
|
690
874
|
const stageDir = path.resolve(process.cwd(), STAGE_DIR);
|
|
691
|
-
const recordDir = path.
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
if (audienceModel === null && (denied.denied ?? []).length === 0) {
|
|
700
|
-
// Nothing to filter — serve the record itself, the level-0 fast path.
|
|
701
|
-
// A stage left behind by an earlier model would be a filtered copy of the
|
|
702
|
-
// record nothing governs any more — removed before the refusal below can
|
|
703
|
-
// throw, so a refused build never leaves one behind either. Under the lock,
|
|
704
|
-
// because two evaluations removing one tree is the `ENOTEMPTY` shape of the
|
|
705
|
-
// same race; the existence check keeps a record that never stages from
|
|
706
|
-
// taking a lock on every build.
|
|
707
|
-
if (existsSync(stageDir)) withStageLock(stageDir, () => removeStage(stageDir));
|
|
708
|
-
refuseVisibilityWithoutAudiences(recordDir);
|
|
709
|
-
assertAttachmentsWellFormed(recordDir);
|
|
710
|
-
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
|
+
);
|
|
711
883
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
fillStage(recordDir, stageDir, denied);
|
|
884
|
+
const development = process.env.NODE_ENV === "development";
|
|
885
|
+
fillStage(recordDir, stageDir, development);
|
|
715
886
|
watchRecord(recordDir, stageDir);
|
|
716
887
|
return STAGE_DIR;
|
|
717
888
|
}
|