@panaversity/ksor 0.0.39 → 0.0.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +965 -0
- package/README.md +29 -9
- package/dist/checker/check-main.mjs +14049 -0
- package/dist/cli.mjs +11528 -5213
- package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
- package/dist/gateway.d.mts +52 -13
- package/dist/gateway.mjs +2 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
- package/docs/authorization.md +8 -6
- package/docs/deploying.md +36 -25
- package/docs/index.md +26 -13
- package/docs/ingesting.md +70 -22
- package/docs/tool-surface.md +69 -16
- package/package.json +4 -3
- package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
- package/schema/schema.sql +77 -14
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.github/workflows/validate.yml +9 -1
- package/templates/scaffold/.ksor/governance.yaml +17 -0
- package/templates/scaffold/AGENTS.md +293 -119
- package/templates/scaffold/Dockerfile +5 -1
- package/templates/scaffold/README.md +160 -42
- package/templates/scaffold/env.example +37 -6
- package/templates/scaffold/gitignore +14 -6
- package/templates/scaffold/instance.md +21 -17
- package/templates/scaffold/knowledge/governance-ladder.md +11 -2
- package/templates/scaffold/knowledge/index.md +9 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
- package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
- package/templates/scaffold/knowledge/surfaces/index.md +4 -20
- package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
- package/templates/scaffold/package.json +3 -4
- package/templates/scaffold/pnpm-lock.yaml +3 -0
- package/templates/scaffold/system/gateways/content.ts +13 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
- package/templates/scaffold/system/site/app/global.css +198 -6
- package/templates/scaffold/system/site/app/layout.tsx +8 -3
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
- package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
- package/templates/scaffold/system/site/components/code-block.tsx +87 -0
- package/templates/scaffold/system/site/components/embed.tsx +276 -0
- package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
- package/templates/scaffold/system/site/components/governance.tsx +219 -87
- package/templates/scaffold/system/site/components/mdx.tsx +10 -2
- package/templates/scaffold/system/site/components/record-index.tsx +5 -5
- package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
- package/templates/scaffold/system/site/components/record-views.tsx +6 -17
- package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
- package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
- package/templates/scaffold/system/site/lib/attachments.ts +0 -28
- package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
- package/templates/scaffold/system/site/lib/audience.ts +42 -146
- package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
- package/templates/scaffold/system/site/lib/governance.ts +339 -225
- package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
- package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
- package/templates/scaffold/system/site/lib/lock.ts +282 -0
- package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
- package/templates/scaffold/system/site/lib/record-href.ts +68 -0
- package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
- package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
- package/templates/scaffold/system/site/lib/shared.ts +67 -104
- package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
- package/templates/scaffold/system/site/lib/source.ts +256 -186
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
- package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
- package/templates/scaffold/system/site/package.json +1 -0
- package/templates/scaffold/system/site/record/actor.ts +23 -0
- package/templates/scaffold/system/site/record/check.ts +571 -0
- package/templates/scaffold/system/site/record/citations.ts +312 -0
- package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
- package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
- package/templates/scaffold/system/site/record/hygiene.ts +320 -0
- package/templates/scaffold/system/site/record/index-file.ts +150 -0
- package/templates/scaffold/system/site/record/index.ts +103 -0
- package/templates/scaffold/system/site/record/instance.ts +257 -0
- package/templates/scaffold/system/site/record/instant.ts +43 -0
- package/templates/scaffold/system/site/record/ledger.ts +694 -0
- package/templates/scaffold/system/site/record/load.ts +129 -0
- package/templates/scaffold/system/site/record/lock.ts +306 -0
- package/templates/scaffold/system/site/record/near-miss.ts +37 -0
- package/templates/scaffold/system/site/record/policy.ts +414 -0
- package/templates/scaffold/system/site/record/profile.ts +535 -0
- package/templates/scaffold/system/site/record/refusal.ts +106 -0
- package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
- package/templates/scaffold/system/site/source.config.ts +109 -23
- package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
- package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
- package/templates/scaffold/system/site/lib/page-order.ts +0 -93
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What makes a blockquote a CALLOUT: GitHub's alert syntax, unchanged.
|
|
3
|
+
*
|
|
4
|
+
* > [!WARNING]
|
|
5
|
+
* > A withdrawn document is still cited by answers that were given while
|
|
6
|
+
* > it was published.
|
|
7
|
+
*
|
|
8
|
+
* The reason this syntax and not `:::warning`: a blockquote is CommonMark. The
|
|
9
|
+
* record is CommonMark by rule (critical rule 3 keeps `knowledge/` free of any
|
|
10
|
+
* grammar a plain markdown reader has to learn), and this one is already read
|
|
11
|
+
* by the two places a record is looked at OUTSIDE this site — GitHub renders it
|
|
12
|
+
* as a styled alert, and every other viewer renders an ordinary blockquote
|
|
13
|
+
* carrying a visible `[!WARNING]` label. Nobody is misled and nothing is lost.
|
|
14
|
+
* A `:::` directive is a grammar: it renders as the literal characters, and it
|
|
15
|
+
* would reach `/md/`, `llms.txt` and `llms-full.txt`, where an agent would have
|
|
16
|
+
* to know our dialect to read the record.
|
|
17
|
+
*
|
|
18
|
+
* The set is GitHub's five, exactly. Adding a sixth would mean a record that
|
|
19
|
+
* renders here and not there, which is the whole thing this choice buys.
|
|
20
|
+
*
|
|
21
|
+
* A LEAF: no imports, so the remark plugin and the tests share one rule.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* GitHub's marker -> the fumadocs Callout it becomes.
|
|
26
|
+
*
|
|
27
|
+
* `type` is one of fumadocs' `CalloutType` (`info` | `warn` | `error` |
|
|
28
|
+
* `success` | `warning` | `idea`); anything else renders as plain `info` with
|
|
29
|
+
* nothing going red, so these are checked against the shipped page.
|
|
30
|
+
*
|
|
31
|
+
* NOTE and IMPORTANT share `info` because fumadocs has no fifth colour, and
|
|
32
|
+
* inventing one would drift from GitHub. The `title` is what tells them apart,
|
|
33
|
+
* which is also how GitHub distinguishes them.
|
|
34
|
+
*/
|
|
35
|
+
export const ALERT_KINDS = [
|
|
36
|
+
{ marker: "NOTE", type: "info", title: "Note" },
|
|
37
|
+
{ marker: "TIP", type: "idea", title: "Tip" },
|
|
38
|
+
{ marker: "IMPORTANT", type: "info", title: "Important" },
|
|
39
|
+
{ marker: "WARNING", type: "warn", title: "Warning" },
|
|
40
|
+
{ marker: "CAUTION", type: "error", title: "Caution" },
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
export type AlertKind = (typeof ALERT_KINDS)[number];
|
|
44
|
+
|
|
45
|
+
export interface AlertMatch {
|
|
46
|
+
readonly kind: AlertKind;
|
|
47
|
+
/** What is left of the leading text once the marker's own line is removed. */
|
|
48
|
+
readonly rest: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The alert this blockquote opens with, or null when it is an ordinary quote.
|
|
53
|
+
*
|
|
54
|
+
* `leadingText` is the value of the first text node of the blockquote's first
|
|
55
|
+
* paragraph — the marker has to be the very start of the quote, and has to be
|
|
56
|
+
* the WHOLE of its first line. Both are GitHub's rules, and following them is
|
|
57
|
+
* the point: a quote that renders as a callout here and as a quote there would
|
|
58
|
+
* make the site and the record disagree about the same bytes.
|
|
59
|
+
*
|
|
60
|
+
* Case-insensitive, because GitHub accepts `[!note]` and rendering it plain
|
|
61
|
+
* here would be exactly that disagreement.
|
|
62
|
+
*/
|
|
63
|
+
export function matchAlert(leadingText: string): AlertMatch | null {
|
|
64
|
+
if (!leadingText.startsWith("[!")) return null;
|
|
65
|
+
|
|
66
|
+
const close = leadingText.indexOf("]");
|
|
67
|
+
if (close === -1) return null;
|
|
68
|
+
|
|
69
|
+
const marker = leadingText.slice(2, close).toUpperCase();
|
|
70
|
+
const kind = ALERT_KINDS.find((entry) => entry.marker === marker);
|
|
71
|
+
if (!kind) return null;
|
|
72
|
+
|
|
73
|
+
const after = leadingText.slice(close + 1);
|
|
74
|
+
const newline = after.indexOf("\n");
|
|
75
|
+
const restOfLine = newline === -1 ? after : after.slice(0, newline);
|
|
76
|
+
// Anything else on the marker's line means the author wrote a quote that
|
|
77
|
+
// happens to start with a bracket, not an alert.
|
|
78
|
+
if (restOfLine.trim() !== "") return null;
|
|
79
|
+
|
|
80
|
+
return { kind, rest: newline === -1 ? "" : after.slice(newline + 1) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The cases the rule is held to.
|
|
85
|
+
*
|
|
86
|
+
* A table rather than prose assertions, because the interesting half is what
|
|
87
|
+
* this must REFUSE: every refusal here is a blockquote an author wrote meaning
|
|
88
|
+
* a blockquote, and turning one into a coloured panel is a change to the
|
|
89
|
+
* record's meaning that nothing else would catch.
|
|
90
|
+
*/
|
|
91
|
+
export const ALERT_CASES = [
|
|
92
|
+
// The five, as GitHub documents them.
|
|
93
|
+
{ text: "[!NOTE]\nThe record is the source of truth.", type: "info", title: "Note" },
|
|
94
|
+
{ text: "[!TIP]\nStart at level 0.", type: "idea", title: "Tip" },
|
|
95
|
+
{ text: "[!IMPORTANT]\nCitations pin a generation.", type: "info", title: "Important" },
|
|
96
|
+
{ text: "[!WARNING]\nThis document is superseded.", type: "warn", title: "Warning" },
|
|
97
|
+
{ text: "[!CAUTION]\nA takedown does not unsay an answer.", type: "error", title: "Caution" },
|
|
98
|
+
// Lowercase renders as an alert on GitHub, so it renders as one here.
|
|
99
|
+
{ text: "[!note]\nStill an alert.", type: "info", title: "Note" },
|
|
100
|
+
{ text: "[!Warning]\nStill an alert.", type: "warn", title: "Warning" },
|
|
101
|
+
// The marker alone, with the body in later nodes or later blocks.
|
|
102
|
+
{ text: "[!NOTE]", type: "info", title: "Note" },
|
|
103
|
+
{ text: "[!NOTE]\n", type: "info", title: "Note" },
|
|
104
|
+
// Trailing spaces on the marker's line are invisible; they may not decide.
|
|
105
|
+
{ text: "[!NOTE] \nBody.", type: "info", title: "Note" },
|
|
106
|
+
// Ordinary blockquotes, which must stay blockquotes.
|
|
107
|
+
{ text: "A quote about something.", type: null, title: null },
|
|
108
|
+
{ text: "[!NOTES]\nNot a marker.", type: null, title: null },
|
|
109
|
+
{ text: "[!]\nEmpty marker.", type: null, title: null },
|
|
110
|
+
{ text: "[NOTE]\nNo bang.", type: null, title: null },
|
|
111
|
+
{ text: " [!NOTE]\nLeading space.", type: null, title: null },
|
|
112
|
+
{ text: "[!NOTE\nUnclosed.", type: null, title: null },
|
|
113
|
+
// The marker has to own its line. Text beside it means the author quoted it.
|
|
114
|
+
{ text: "[!NOTE] see below", type: null, title: null },
|
|
115
|
+
{ text: "[!NOTE] see below\nBody.", type: null, title: null },
|
|
116
|
+
] as const;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The slice of hast this touches, written structurally rather than imported.
|
|
120
|
+
*
|
|
121
|
+
* `@types/hast` would be a dependency for five field names, and this only ever
|
|
122
|
+
* reads `type`/`tagName`, walks `children`, and edits the `value` of a text
|
|
123
|
+
* node. Typing what is used keeps this file a leaf.
|
|
124
|
+
*/
|
|
125
|
+
interface AlertNode {
|
|
126
|
+
type: string;
|
|
127
|
+
tagName?: string;
|
|
128
|
+
children?: AlertNode[];
|
|
129
|
+
value?: string;
|
|
130
|
+
name?: string;
|
|
131
|
+
attributes?: { type: "mdxJsxAttribute"; name: string; value: string }[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The `<Callout>` this blockquote becomes, or null when it stays a blockquote.
|
|
136
|
+
*
|
|
137
|
+
* Mutates the quote's own first paragraph to drop the marker line — the marker
|
|
138
|
+
* is syntax, and leaving it in the rendered panel would show the reader the
|
|
139
|
+
* plumbing.
|
|
140
|
+
*/
|
|
141
|
+
function calloutFor(node: AlertNode): AlertNode | null {
|
|
142
|
+
if (node.type !== "element" || node.tagName !== "blockquote") return null;
|
|
143
|
+
|
|
144
|
+
// hast keeps the source's whitespace between block children, so the first
|
|
145
|
+
// paragraph is the first ELEMENT rather than the first child.
|
|
146
|
+
const paragraph = node.children?.find((child) => child.type === "element");
|
|
147
|
+
if (!paragraph || paragraph.tagName !== "p") return null;
|
|
148
|
+
|
|
149
|
+
const lead = paragraph.children?.[0];
|
|
150
|
+
if (!lead || lead.type !== "text" || typeof lead.value !== "string") return null;
|
|
151
|
+
|
|
152
|
+
const match = matchAlert(lead.value);
|
|
153
|
+
if (!match) return null;
|
|
154
|
+
|
|
155
|
+
if (match.rest === "") {
|
|
156
|
+
// The marker was the whole text node. Drop it, and drop the paragraph too
|
|
157
|
+
// when the marker was all it held — `> [!NOTE]` on a line of its own.
|
|
158
|
+
paragraph.children?.shift();
|
|
159
|
+
if (paragraph.children?.length === 0) {
|
|
160
|
+
node.children = node.children?.filter((child) => child !== paragraph);
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
lead.value = match.rest;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
type: "mdxJsxFlowElement",
|
|
168
|
+
name: "Callout",
|
|
169
|
+
attributes: [
|
|
170
|
+
{ type: "mdxJsxAttribute", name: "type", value: match.kind.type },
|
|
171
|
+
{ type: "mdxJsxAttribute", name: "title", value: match.kind.title },
|
|
172
|
+
],
|
|
173
|
+
children: node.children ?? [],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Depth-first, so an alert nested inside a list or another quote converts. */
|
|
178
|
+
function convertAlerts(node: AlertNode): void {
|
|
179
|
+
const children = node.children;
|
|
180
|
+
if (!children) return;
|
|
181
|
+
|
|
182
|
+
for (let i = 0; i < children.length; i++) {
|
|
183
|
+
const child = children[i];
|
|
184
|
+
if (!child) continue;
|
|
185
|
+
convertAlerts(child);
|
|
186
|
+
const callout = calloutFor(child);
|
|
187
|
+
if (callout) children[i] = callout;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* REHYPE, deliberately — and this is the load-bearing half of the design.
|
|
193
|
+
*
|
|
194
|
+
* As a remark plugin this works and is wrong: fumadocs serializes the record's
|
|
195
|
+
* markdown from the mdast (`includeProcessedMarkdown` -> `remarkLLMs`), so a
|
|
196
|
+
* blockquote rewritten there reaches `/md/` and `llms-full.txt` as
|
|
197
|
+
* `<Callout type="warn" title="Warning">` — the agent surface served this
|
|
198
|
+
* site's React component in place of the author's blockquote. Measured, not
|
|
199
|
+
* assumed: that is exactly what the first build of this emitted.
|
|
200
|
+
*
|
|
201
|
+
* By the rehype phase the markdown is already captured, so the page gets the
|
|
202
|
+
* callout and every agent-facing surface keeps the record's own shape. This is
|
|
203
|
+
* product principle 2 — one source, two surfaces — and the reason the syntax
|
|
204
|
+
* is GitHub's rather than a directive in the first place.
|
|
205
|
+
*
|
|
206
|
+
* The cost, recorded because it is real: `remarkStructure` also runs in the
|
|
207
|
+
* remark phase, so the search index contains the literal `[!NOTE]` alongside
|
|
208
|
+
* the passage. Noise in one index is the cheaper half of this trade.
|
|
209
|
+
*/
|
|
210
|
+
export function rehypeGithubAlerts(): (tree: AlertNode) => void {
|
|
211
|
+
return (tree: AlertNode): void => {
|
|
212
|
+
convertAlerts(tree);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -110,7 +110,12 @@ export const ATTACHMENT_CASES = [
|
|
|
110
110
|
{ name: "returns.flashcards.yaml", kind: "deck", parent: "returns.md" },
|
|
111
111
|
{ name: "returns.quiz.yaml", kind: "quiz", parent: "returns.md" },
|
|
112
112
|
{ name: "returns.slides.yaml", kind: "slides", parent: "returns.md" },
|
|
113
|
-
|
|
113
|
+
// No `index.summary.md` row: `index.md` is GENERATED (record spec §1) — no
|
|
114
|
+
// route, no node, no governance of its own — so nothing attaches to it, and
|
|
115
|
+
// decision 27 retires this row with the authored index. The NAME is still an
|
|
116
|
+
// attachment by this rule (it has a stem and a known suffix); what refuses it
|
|
117
|
+
// is `ksor-attachment-of-index` in the checker, which is where "who may be a
|
|
118
|
+
// parent" belongs — this file only answers "is this name an attachment".
|
|
114
119
|
// A stem containing dots keeps every one of them: the parent is the same
|
|
115
120
|
// name with the attachment suffix removed, never "up to the first dot".
|
|
116
121
|
{ name: "v1.2.policy.summary.md", kind: "summary", parent: "v1.2.policy.md" },
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { decks, quizzes, slides, summaries } from "collections/server";
|
|
2
2
|
|
|
3
|
-
import { ATTACHMENT_SUFFIXES } from "./attachment-rule";
|
|
4
3
|
import { cardHash, type Card, type Deck } from "./deck";
|
|
5
|
-
import { newCard, type CardSchedule } from "./srs";
|
|
6
4
|
import { type Question, type Quiz } from "./quiz";
|
|
7
5
|
import { type Slide, type Slides } from "./slides";
|
|
8
6
|
import { embedUrlFor, providerOf } from "./slides-embed";
|
|
@@ -162,29 +160,3 @@ export function slidesFor(documentPath: string): SlidesEntry | null {
|
|
|
162
160
|
path: parsed.info.path,
|
|
163
161
|
};
|
|
164
162
|
}
|
|
165
|
-
|
|
166
|
-
/** True when a document has ANY attachment — the presence gate for the UI. */
|
|
167
|
-
export function hasAttachments(documentPath: string): boolean {
|
|
168
|
-
return (
|
|
169
|
-
summaryFor(documentPath) !== null ||
|
|
170
|
-
deckFor(documentPath) !== null ||
|
|
171
|
-
quizFor(documentPath) !== null ||
|
|
172
|
-
slidesFor(documentPath) !== null
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* A fresh schedule for every card in a deck, all due now.
|
|
178
|
-
*
|
|
179
|
-
* Exported so the deck's first render and its reset path agree by construction
|
|
180
|
-
* rather than by two similar-looking object literals.
|
|
181
|
-
*/
|
|
182
|
-
export function freshSchedules(
|
|
183
|
-
cards: readonly DeckCard[],
|
|
184
|
-
now: number,
|
|
185
|
-
): Record<string, CardSchedule> {
|
|
186
|
-
return Object.fromEntries(cards.map((card) => [card.hash, newCard(card.hash, now)]));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Every attachment suffix, for the surfaces that need the list rather than the rule. */
|
|
190
|
-
export const ATTACHMENT_SUFFIX_LIST: readonly string[] = ATTACHMENT_SUFFIXES.map((e) => e.suffix);
|
|
@@ -16,29 +16,23 @@
|
|
|
16
16
|
* this file is the shared implementation of the TypeScript half.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The overlap rule (record spec §2.4): a concept holds a LIST of audience
|
|
21
|
+
* identifiers, a viewer holds a list that always includes `public`, and the
|
|
22
|
+
* concept is visible when the two overlap. Rank moves to the viewer,
|
|
23
|
+
* membership stays on the document. Omission is a refusal upstream, never a
|
|
24
|
+
* default here — an empty list on either side is visible to nobody.
|
|
25
|
+
*/
|
|
26
|
+
export function overlaps(viewer: readonly string[], audience: readonly string[]): boolean {
|
|
27
|
+
return audience.some((a) => viewer.includes(a));
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
+
* The widening rule: a link, a `ksor.superseded_by` pointer or a companion
|
|
32
|
+
* body may reach a target whose audience contains `public` or contains every
|
|
33
|
+
* identifier in the source's — then every reader of the source can read the
|
|
34
|
+
* target. `[internal]` → `[public]` passes; `[public]` → `[internal]` refuses.
|
|
31
35
|
*/
|
|
32
|
-
export function
|
|
33
|
-
|
|
34
|
-
audience: string,
|
|
35
|
-
visibility: string | null,
|
|
36
|
-
): boolean {
|
|
37
|
-
if (model === null) return true;
|
|
38
|
-
const value = visibility === null || visibility === "" ? model.defaultVisibility : visibility;
|
|
39
|
-
const rank = model.audiences.indexOf(value);
|
|
40
|
-
// An undeclared visibility is refused, never published: a value no build
|
|
41
|
-
// understands is a typo, and a typo reads as a restriction.
|
|
42
|
-
if (rank === -1) return false;
|
|
43
|
-
return rank <= model.audiences.indexOf(audience);
|
|
36
|
+
export function mayReach(source: readonly string[], target: readonly string[]): boolean {
|
|
37
|
+
return target.includes("public") || source.every((a) => target.includes(a));
|
|
44
38
|
}
|
|
@@ -1,168 +1,64 @@
|
|
|
1
|
-
import { decideVisible, type AudienceModel } from "./audience-rule";
|
|
2
|
-
import { instanceFrontmatter } from "./shared";
|
|
3
|
-
|
|
4
1
|
/**
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
2
|
+
* The viewer this build publishes for (record spec §2.4, build spec §3).
|
|
3
|
+
*
|
|
4
|
+
* A concept holds a LIST of audience identifiers; a viewer holds a list that
|
|
5
|
+
* always includes `public`; the concept is visible when the two overlap. The
|
|
6
|
+
* rule itself is `overlaps` in `./audience-rule` — the kernel's copy, asserted
|
|
7
|
+
* byte-identical — and this module only reads the viewer from the environment:
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
* - public
|
|
10
|
-
* - internal
|
|
11
|
-
* - restricted
|
|
12
|
-
* default_visibility: public
|
|
9
|
+
* KSOR_AUDIENCE=public,internal pnpm build
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* the behaviour of every instance written before this key existed.
|
|
11
|
+
* Unset means `[public]`, the only default that cannot leak. Whether each
|
|
12
|
+
* identifier is REGISTERED is the staging's question, because the registry
|
|
13
|
+
* lives in the policy (or the lock) and this module reads neither.
|
|
18
14
|
*/
|
|
19
|
-
export type { AudienceModel };
|
|
20
|
-
export { decideVisible };
|
|
21
|
-
|
|
22
|
-
function unquote(raw: string): string {
|
|
23
|
-
const trimmed = raw.trim();
|
|
24
|
-
return /^(['"])(.*)\1$/.exec(trimmed)?.[2] ?? trimmed;
|
|
25
|
-
}
|
|
26
15
|
|
|
27
|
-
/** Every refusal
|
|
16
|
+
/** Every refusal the site makes: a slug a pipeline can match, then the remedy. */
|
|
28
17
|
export function refuse(slug: string, what: string, why: string, fix: string): never {
|
|
29
18
|
// The slug leads, so a pipeline can match on it, and the three lines below
|
|
30
19
|
// it are the whole remedy — an operator never has to read this file.
|
|
31
20
|
throw new Error(`${slug}: ${what}\n why: ${why}\n fix: ${fix}`);
|
|
32
21
|
}
|
|
33
22
|
|
|
34
|
-
|
|
35
|
-
const block = instanceFrontmatter();
|
|
36
|
-
// Top-level key only: `^` under /m cannot match an indented child.
|
|
37
|
-
if (!/^audiences:/m.test(block)) return null;
|
|
38
|
-
|
|
39
|
-
// The grammar mirrors the checker's exactly — CRLF-tolerant, list items at
|
|
40
|
-
// ANY indent (YAML allows unindented block sequences), and a ` #` comment
|
|
41
|
-
// ends an unquoted entry (all three found live 2026-08-18: records the
|
|
42
|
-
// checker blessed either failed this build or silently lost a tier).
|
|
43
|
-
const stripComment = (value: string): string =>
|
|
44
|
-
/^["']/.test(value.trim()) ? value : value.replace(/\s+#.*$/, "");
|
|
45
|
-
// A line scanner, not a block regex: a blank line among the items or a
|
|
46
|
-
// comment on the key line broke the block capture and refused every build
|
|
47
|
-
// of a checker-green record (review finding, 2026-08-19).
|
|
48
|
-
const flow = /^audiences:[ \t]*\[(.*)\][ \t]*(?:#.*)?$/m.exec(block)?.[1];
|
|
49
|
-
let items: string[] = [];
|
|
50
|
-
if (flow !== undefined) {
|
|
51
|
-
items = flow.split(",");
|
|
52
|
-
} else {
|
|
53
|
-
const lines = block.split("\n");
|
|
54
|
-
const start = lines.findIndex((line) => /^audiences:[ \t]*(?:#.*)?$/.test(line));
|
|
55
|
-
if (start !== -1) {
|
|
56
|
-
for (const line of lines.slice(start + 1)) {
|
|
57
|
-
if (line.trim() === "") continue;
|
|
58
|
-
const item = /^[ \t]*-[ \t]+(.*)$/.exec(line);
|
|
59
|
-
if (item === null) break;
|
|
60
|
-
items.push(item[1] ?? "");
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
const audiences = items
|
|
65
|
-
.map(stripComment)
|
|
66
|
-
.map(unquote)
|
|
67
|
-
.filter((value) => value !== "");
|
|
68
|
-
|
|
69
|
-
// A declared-but-unreadable model must never read as "no model": that is
|
|
70
|
-
// the one parse failure that publishes the whole record.
|
|
71
|
-
if (audiences.length === 0) {
|
|
72
|
-
refuse(
|
|
73
|
-
"ksor-audiences-unreadable",
|
|
74
|
-
"instance.md declares `audiences:` but no audience could be read from it",
|
|
75
|
-
"an unreadable model reads as no model, and no model publishes every document — the one parse failure that leaks",
|
|
76
|
-
"write the audiences as a list, least-restricted first:\n audiences:\n - public\n - internal",
|
|
77
|
-
);
|
|
78
|
-
}
|
|
23
|
+
let resolved: readonly string[] | null = null;
|
|
79
24
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
if (new Set(audiences).size !== audiences.length) {
|
|
92
|
-
refuse(
|
|
93
|
-
"ksor-audiences-duplicate",
|
|
94
|
-
`audiences: declares a tier twice (${audiences.join(", ")})`,
|
|
95
|
-
"a duplicated tier has two positions in the ordering, and which one a build honours is undefined",
|
|
96
|
-
"remove the duplicate entry",
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
const defaultVisibility = unquote(
|
|
100
|
-
stripComment(/^default_visibility:[ \t]*(.*)$/m.exec(block)?.[1] ?? ""),
|
|
101
|
-
);
|
|
102
|
-
if (defaultVisibility === "") {
|
|
103
|
-
refuse(
|
|
104
|
-
"ksor-default-visibility-missing",
|
|
105
|
-
"instance.md declares `audiences:` without `default_visibility:`",
|
|
106
|
-
"there is no safe guess: assuming the widest tier leaks on the first document that forgets the key, assuming the narrowest hides the record",
|
|
107
|
-
`add the tier a document without a visibility: key belongs to, e.g. default_visibility: ${audiences[0]}`,
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
if (!audiences.includes(defaultVisibility)) {
|
|
111
|
-
refuse(
|
|
112
|
-
"ksor-default-visibility-undeclared",
|
|
113
|
-
`default_visibility: ${defaultVisibility} is not one of the declared audiences (${audiences.join(", ")})`,
|
|
114
|
-
"every document without a visibility: key belongs to this tier — a tier no build understands is a record no build can publish honestly",
|
|
115
|
-
`set default_visibility: to one of ${audiences.join(", ")}, or declare ${defaultVisibility} in audiences:`,
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return { audiences, defaultVisibility };
|
|
25
|
+
/**
|
|
26
|
+
* The viewer list, `public` always among it. A function rather than a
|
|
27
|
+
* module constant so the refusal is raised where staging can clean up after
|
|
28
|
+
* it: thrown at import time it escaped the stage lock and left the previous,
|
|
29
|
+
* wider stage on disk (found by the staging suite, 2026-08-25).
|
|
30
|
+
*/
|
|
31
|
+
export function viewer(): readonly string[] {
|
|
32
|
+
if (resolved === null) resolved = readViewer();
|
|
33
|
+
return resolved;
|
|
120
34
|
}
|
|
121
35
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
"ksor-audiences-not-declared",
|
|
131
|
-
`KSOR_AUDIENCE="${requested}" was requested, but instance.md declares no audiences`,
|
|
132
|
-
"this build would publish every document — a build that cannot filter must never look like one that did",
|
|
133
|
-
"declare the model in instance.md (audiences: + default_visibility:), or build without KSOR_AUDIENCE",
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
return "";
|
|
137
|
-
}
|
|
138
|
-
// Unset means the least-restricted tier: the only default that cannot leak,
|
|
139
|
-
// so `pnpm build` keeps publishing the public site out of the box.
|
|
140
|
-
if (requested === "") return model.audiences[0] as string;
|
|
141
|
-
if (!model.audiences.includes(requested)) {
|
|
36
|
+
function readViewer(): readonly string[] {
|
|
37
|
+
const raw = process.env.KSOR_AUDIENCE?.trim() ?? "";
|
|
38
|
+
if (raw === "") return ["public"];
|
|
39
|
+
const list = raw
|
|
40
|
+
.split(",")
|
|
41
|
+
.map((item) => item.trim())
|
|
42
|
+
.filter((item) => item !== "");
|
|
43
|
+
if (!list.includes("public")) {
|
|
142
44
|
refuse(
|
|
143
|
-
"ksor-
|
|
144
|
-
`KSOR_AUDIENCE="${
|
|
145
|
-
"
|
|
146
|
-
`build with
|
|
45
|
+
"ksor-viewer-omits-public",
|
|
46
|
+
`KSOR_AUDIENCE="${raw}" does not include public`,
|
|
47
|
+
"a viewer list always includes public — every reader of a restricted build is also a reader of the open one, and a build for a restricted audience alone would silently drop every public concept",
|
|
48
|
+
`build with KSOR_AUDIENCE=public,${list.join(",")}`,
|
|
147
49
|
);
|
|
148
50
|
}
|
|
149
|
-
return
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** The audience this build publishes for; "" when the record has no model. */
|
|
153
|
-
export const buildAudience: string = resolveBuildAudience(audienceModel);
|
|
154
|
-
|
|
155
|
-
/** Whether a document of this visibility belongs in THIS build. */
|
|
156
|
-
export function visibleInBuild(visibility: string | null): boolean {
|
|
157
|
-
return decideVisible(audienceModel, buildAudience, visibility);
|
|
51
|
+
return [...new Set(list)];
|
|
158
52
|
}
|
|
159
53
|
|
|
160
54
|
/**
|
|
161
|
-
* What a
|
|
162
|
-
* screenshot of an internal site says which
|
|
163
|
-
* public build
|
|
55
|
+
* What a restricted build calls itself, in the site chrome — so a leaked
|
|
56
|
+
* screenshot of an internal site says which audiences it was built for. The
|
|
57
|
+
* public build says nothing new.
|
|
164
58
|
*/
|
|
165
|
-
export
|
|
166
|
-
|
|
59
|
+
export function audienceNotice(): string | null {
|
|
60
|
+
const list = viewer();
|
|
61
|
+
return list.length === 1
|
|
167
62
|
? null
|
|
168
|
-
: `${
|
|
63
|
+
: `${list.filter((a) => a !== "public").join(", ")} build — not for publication`;
|
|
64
|
+
}
|