@panaversity/ksor 0.0.19 → 0.0.21
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 +501 -0
- package/dist/cli.mjs +99 -19
- package/docs/authorization.md +197 -0
- package/docs/index.md +4 -0
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +232 -9
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +232 -9
- package/templates/scaffold/AGENTS.md +52 -4
- package/templates/scaffold/instance.md +28 -20
- package/templates/scaffold/knowledge/governance-ladder.md +36 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +29 -0
- package/templates/scaffold/knowledge/surfaces/for-people.md +35 -0
- package/templates/scaffold/knowledge/surfaces/index.md +21 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +39 -0
- package/templates/scaffold/pnpm-lock.yaml +1198 -228
- package/templates/scaffold/system/site/app/(home)/layout.tsx +6 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +65 -70
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +122 -14
- package/templates/scaffold/system/site/app/docs/layout.tsx +2 -21
- package/templates/scaffold/system/site/app/global.css +552 -9
- package/templates/scaffold/system/site/app/layout.tsx +23 -4
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +4 -2
- package/templates/scaffold/system/site/app/llms.txt/route.ts +11 -9
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +51 -0
- package/templates/scaffold/system/site/components/copy-markdown.tsx +70 -0
- package/templates/scaffold/system/site/components/governance.tsx +262 -0
- package/templates/scaffold/system/site/components/home-cover.tsx +137 -0
- package/templates/scaffold/system/site/components/record-index.tsx +120 -0
- package/templates/scaffold/system/site/components/record-shell.tsx +68 -0
- package/templates/scaffold/system/site/components/record-stack.tsx +131 -0
- package/templates/scaffold/system/site/components/record-toc.tsx +160 -0
- package/templates/scaffold/system/site/components/search-dialog.tsx +130 -0
- package/templates/scaffold/system/site/components/sidebar-status.tsx +35 -0
- package/templates/scaffold/system/site/components/ui/badge.tsx +46 -0
- package/templates/scaffold/system/site/components/ui/button.tsx +62 -0
- package/templates/scaffold/system/site/components/ui/separator.tsx +28 -0
- package/templates/scaffold/system/site/components.json +25 -0
- package/templates/scaffold/system/site/lib/governance.ts +432 -0
- package/templates/scaffold/system/site/lib/layout.shared.tsx +1 -1
- package/templates/scaffold/system/site/lib/shared.ts +38 -0
- package/templates/scaffold/system/site/lib/source.ts +221 -5
- package/templates/scaffold/system/site/lib/utils.ts +6 -0
- package/templates/scaffold/system/site/package.json +9 -3
- package/templates/scaffold/knowledge/example.md +0 -23
|
@@ -115,6 +115,7 @@ function parseFrontmatter(text) {
|
|
|
115
115
|
const malformedQuote = new Map();
|
|
116
116
|
const malformed = [];
|
|
117
117
|
const duplicates = [];
|
|
118
|
+
const truncated = [];
|
|
118
119
|
const tightColons = [];
|
|
119
120
|
const tabIndents = [];
|
|
120
121
|
let current = null;
|
|
@@ -157,6 +158,12 @@ function parseFrontmatter(text) {
|
|
|
157
158
|
}
|
|
158
159
|
const nested = /^[ \t]+([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
159
160
|
if (nested && current !== null) {
|
|
161
|
+
// Same hazard as the top-level duplicate above, one level down: this Map
|
|
162
|
+
// keeps the LAST write while the surfaces read the FIRST occurrence, so
|
|
163
|
+
// the check validates one value and the build publishes the other
|
|
164
|
+
// (found 2026-08-20: `governance: nope` then `governance: false` passed
|
|
165
|
+
// the check and crashed the build).
|
|
166
|
+
if (children.get(current).has(nested[1])) duplicates.push(`${current}.${nested[1]}`);
|
|
160
167
|
children.get(current).set(nested[1], unquote(nested[2]));
|
|
161
168
|
continue;
|
|
162
169
|
}
|
|
@@ -168,7 +175,16 @@ function parseFrontmatter(text) {
|
|
|
168
175
|
// entry, and reading it as one refuses the documents instead of the
|
|
169
176
|
// list (found live 2026-08-18: `- public # the default` made every
|
|
170
177
|
// public document's visibility undeclared).
|
|
171
|
-
const
|
|
178
|
+
const quotedItem = /^["']/.test(item[1]);
|
|
179
|
+
const value = quotedItem ? item[1] : item[1].replace(/\s+#.*$/, "");
|
|
180
|
+
// A `#` is a comment to YAML and an invoice or issue number to a person.
|
|
181
|
+
// In `provenance` — free text naming a real source — the truncation is
|
|
182
|
+
// silent data loss: `- Invoice #4471 from Acme Ltd` publishes as
|
|
183
|
+
// "Invoice" (found 2026-08-20). NOT flagged for `audiences`, where a
|
|
184
|
+
// trailing comment is an intended and documented use.
|
|
185
|
+
if (!quotedItem && current === "provenance" && value !== item[1]) {
|
|
186
|
+
truncated.push({ key: current, raw: item[1].trim(), kept: value.trim() });
|
|
187
|
+
}
|
|
172
188
|
lists.get(current).push(unquote(value));
|
|
173
189
|
continue;
|
|
174
190
|
}
|
|
@@ -180,7 +196,19 @@ function parseFrontmatter(text) {
|
|
|
180
196
|
malformed.push(line.trim());
|
|
181
197
|
continue;
|
|
182
198
|
}
|
|
183
|
-
if (/^[ \t]*-([ \t]|$)/.test(line)
|
|
199
|
+
if (/^[ \t]*-([ \t]|$)/.test(line)) continue;
|
|
200
|
+
// A whole-line comment is YAML, not a wrapped value — an author annotating
|
|
201
|
+
// their own frontmatter ("# add the signed PDF once it lands") is doing
|
|
202
|
+
// nothing wrong, and refusing it turned the adopter's shipped CI red on a
|
|
203
|
+
// record both surfaces read perfectly (found 2026-08-21).
|
|
204
|
+
if (/^[ \t]*#/.test(line)) continue;
|
|
205
|
+
// An indented line that is neither a nested key nor a list item is a value
|
|
206
|
+
// WRAPPED onto a second line. YAML folds it back into the value above;
|
|
207
|
+
// this parser used to skip it, so every rule here inspected a string that
|
|
208
|
+
// was not what the surfaces publish — `effective: 2026-04-01` continued by
|
|
209
|
+
// ` 00:00:00 +05:00` passed the date rule and shipped the day before
|
|
210
|
+
// (found 2026-08-20). A value the checker cannot see is a value it cannot
|
|
211
|
+
// govern.
|
|
184
212
|
malformed.push(line.trim());
|
|
185
213
|
}
|
|
186
214
|
return {
|
|
@@ -190,6 +218,7 @@ function parseFrontmatter(text) {
|
|
|
190
218
|
quoted,
|
|
191
219
|
malformedQuote,
|
|
192
220
|
duplicates,
|
|
221
|
+
truncated,
|
|
193
222
|
malformed,
|
|
194
223
|
tightColons,
|
|
195
224
|
tabIndents,
|
|
@@ -472,9 +501,16 @@ if (!existsSync(knowledgeDir)) {
|
|
|
472
501
|
|
|
473
502
|
// frontmatter + links per document
|
|
474
503
|
const visibilityByPath = new Map();
|
|
504
|
+
const documentPaths = new Set();
|
|
475
505
|
const crossings = [];
|
|
476
506
|
for (const p of mdFiles) {
|
|
477
507
|
const rel = path.relative(root, p);
|
|
508
|
+
// Every markdown file under knowledge/ IS a document of the record, whether
|
|
509
|
+
// or not its own frontmatter parses. Counting it only after a clean parse
|
|
510
|
+
// made a correct `superseded_by` be reported as a capitalisation mistake
|
|
511
|
+
// whenever its successor was the document still being written (round 3) —
|
|
512
|
+
// two problems for one cause, and the second one false.
|
|
513
|
+
documentPaths.add(p);
|
|
478
514
|
const text = readFileSync(p, "utf8");
|
|
479
515
|
const fm = parseFrontmatter(text);
|
|
480
516
|
if (fm === null) {
|
|
@@ -490,7 +526,7 @@ if (!existsSync(knowledgeDir)) {
|
|
|
490
526
|
problem(
|
|
491
527
|
rel,
|
|
492
528
|
`unclosed or malformed frontmatter — this is not a frontmatter line: "${fm.malformed[0]}"`,
|
|
493
|
-
"an unclosed block swallows the body
|
|
529
|
+
"an unclosed block swallows the body, and a value wrapped onto a second line is folded back by YAML but invisible here — either way this check inspects something the site does not publish",
|
|
494
530
|
"close the block with --- on its own line; every line inside it is `key: value` or a `- list item` — no prose, no comments",
|
|
495
531
|
);
|
|
496
532
|
} else {
|
|
@@ -618,9 +654,28 @@ if (!existsSync(knowledgeDir)) {
|
|
|
618
654
|
"add superseded_by: ./<successor>.md",
|
|
619
655
|
);
|
|
620
656
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
657
|
+
if (successor && status !== "superseded") {
|
|
658
|
+
problem(
|
|
659
|
+
rel,
|
|
660
|
+
`superseded_by on a document that is status: ${status || "(none)"}`,
|
|
661
|
+
"the two keys are one statement — a successor pointer says this document was replaced, so a record that keeps the pointer while calling the document current contradicts itself, and the site publishes a Superseded notice over a live document",
|
|
662
|
+
"set status: superseded, or remove superseded_by if this document is still current",
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
// superseded_by must be a document pointer, and EVERY successor is
|
|
666
|
+
// validated. This was gated behind a shape test until 2026-08-20, so a
|
|
667
|
+
// value matching neither shape (`hr/refunds-2026`) skipped existence,
|
|
668
|
+
// escape-the-record AND the cross-audience rule — and then the site
|
|
669
|
+
// published the raw pointer, naming a document a lower tier must not know
|
|
670
|
+
// exists.
|
|
671
|
+
if (successor && !successor.split("#")[0].toLowerCase().endsWith(".md")) {
|
|
672
|
+
problem(
|
|
673
|
+
rel,
|
|
674
|
+
`superseded_by is not a document pointer: ${successor}`,
|
|
675
|
+
"unless it names a markdown document this check cannot tell whether the successor exists, stays inside the record, or is readable by this document's audience — and the site publishes the raw text of it",
|
|
676
|
+
"write it as a relative path to the successor, e.g. superseded_by: ./<successor>.md",
|
|
677
|
+
);
|
|
678
|
+
} else if (successor) {
|
|
624
679
|
const resolved = path.resolve(path.dirname(p), successor.split("#")[0]);
|
|
625
680
|
if (!resolved.startsWith(knowledgeDir + path.sep)) {
|
|
626
681
|
problem(
|
|
@@ -629,7 +684,7 @@ if (!existsSync(knowledgeDir)) {
|
|
|
629
684
|
"the successor is what readers are sent to instead — outside knowledge/ it is not a governed document",
|
|
630
685
|
"point superseded_by at a document inside knowledge/",
|
|
631
686
|
);
|
|
632
|
-
} else if (!existsSync(resolved)) {
|
|
687
|
+
} else if (!existsSync(resolved) || !lstatSync(resolved).isFile()) {
|
|
633
688
|
problem(
|
|
634
689
|
rel,
|
|
635
690
|
`superseded_by points at a document that does not exist: ${successor}`,
|
|
@@ -640,6 +695,76 @@ if (!existsSync(knowledgeDir)) {
|
|
|
640
695
|
crossings.push({ kind: "superseded_by", rel, from: p, to: resolved, target: successor });
|
|
641
696
|
}
|
|
642
697
|
}
|
|
698
|
+
for (const cut of fm.truncated) {
|
|
699
|
+
problem(
|
|
700
|
+
rel,
|
|
701
|
+
`a ${cut.key} entry is cut short at a #: ${cut.raw}`,
|
|
702
|
+
`YAML ends an unquoted value at " #", so the record stores only "${cut.kept}" and that is what the page publishes as the source — the rest is lost without a word`,
|
|
703
|
+
`quote it to keep the whole thing: - "${cut.raw.replace(/"/g, "'")}"`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
// Every governed key except `provenance` is ONE value. Written as a
|
|
707
|
+
// block sequence or a nested map it parses to an array or an object, and
|
|
708
|
+
// the page — which asks for text — renders nothing at all: the record
|
|
709
|
+
// declares the fact and the surface silently omits it (found 2026-08-21
|
|
710
|
+
// with `effective:` followed by ` - 2026-04-01`). `visibility` has its
|
|
711
|
+
// own list rule with a better message, so it is left to it.
|
|
712
|
+
for (const scalarKey of [
|
|
713
|
+
"title",
|
|
714
|
+
"description",
|
|
715
|
+
"status",
|
|
716
|
+
"owner",
|
|
717
|
+
"effective",
|
|
718
|
+
"superseded",
|
|
719
|
+
"superseded_by",
|
|
720
|
+
"order",
|
|
721
|
+
]) {
|
|
722
|
+
const asList = (fm.lists.get(scalarKey) ?? []).length > 0;
|
|
723
|
+
const asMap = (fm.children.get(scalarKey)?.size ?? 0) > 0;
|
|
724
|
+
if (asList || asMap) {
|
|
725
|
+
problem(
|
|
726
|
+
rel,
|
|
727
|
+
`${scalarKey} is written as ${asList ? "a list" : "a nested block"}, not a value`,
|
|
728
|
+
"the surfaces read this key as one piece of text; as a list or a map it reaches them as neither, so the page publishes nothing where the record declares something",
|
|
729
|
+
`put the value on the key's own line: ${scalarKey}: <value>`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
// `effective` is the DAY a document takes effect. Written unquoted with a
|
|
734
|
+
// time, YAML makes it a timestamp, and normalizing that to a UTC day
|
|
735
|
+
// prints the day before the record's for any positive offset (found
|
|
736
|
+
// 2026-08-20: `2026-04-01 00:00:00 +05:00` rendered 2026-03-31).
|
|
737
|
+
// `effective` is the DAY a document takes effect, and the page publishes
|
|
738
|
+
// it as fact inside <time datetime>. Unquoted it must be a calendar-valid
|
|
739
|
+
// YYYY-MM-DD and nothing else, because every other shape YAML accepts
|
|
740
|
+
// here publishes something the record does not say: `2026-06-31` rolls
|
|
741
|
+
// silently to July 1st (js-yaml's date path is `Date.UTC(y, m, d)` with
|
|
742
|
+
// no validation), a value carrying a time reads back in a timezone and
|
|
743
|
+
// can land a day early, and a bare `2026` types as a number that never
|
|
744
|
+
// reaches the page at all. Three rounds of narrower rules each leaked a
|
|
745
|
+
// different one of those, so the rule is now the whole contract: a plain
|
|
746
|
+
// date, or QUOTED text that is published verbatim and never parsed.
|
|
747
|
+
const effective = fm.keys.get("effective");
|
|
748
|
+
if (effective !== undefined && effective !== "" && !fm.quoted.has("effective")) {
|
|
749
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(effective);
|
|
750
|
+
let calendarValid = false;
|
|
751
|
+
if (iso !== null) {
|
|
752
|
+
const [year, month, day] = [Number(iso[1]), Number(iso[2]), Number(iso[3])];
|
|
753
|
+
const probe = new Date(Date.UTC(year, month - 1, day));
|
|
754
|
+
calendarValid =
|
|
755
|
+
probe.getUTCFullYear() === year &&
|
|
756
|
+
probe.getUTCMonth() === month - 1 &&
|
|
757
|
+
probe.getUTCDate() === day;
|
|
758
|
+
}
|
|
759
|
+
if (!calendarValid) {
|
|
760
|
+
problem(
|
|
761
|
+
rel,
|
|
762
|
+
`effective is not a calendar date: ${effective}`,
|
|
763
|
+
"the page publishes this as the day the document takes effect, in a <time> element a machine reads as fact — YAML rolls an impossible date to the next month without a word, reads a value carrying a time in a timezone, and types a bare year as a number that never reaches the page",
|
|
764
|
+
`write a real date (effective: 2026-04-01), or quote it to publish it as text (effective: "${effective.replace(/"/g, "'")}")`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
643
768
|
// visibility: one audience per document, from the set instance.md declares.
|
|
644
769
|
// Through scalarValue, so the comparison sees what the READERS see: a raw
|
|
645
770
|
// read reported `visibility "internal # narrowed 2026-08" is not a
|
|
@@ -684,6 +809,58 @@ if (!existsSync(knowledgeDir)) {
|
|
|
684
809
|
}
|
|
685
810
|
}
|
|
686
811
|
|
|
812
|
+
for (const { kind, rel, to, target } of crossings) {
|
|
813
|
+
if (kind !== "superseded_by" || documentPaths.has(to)) continue;
|
|
814
|
+
problem(
|
|
815
|
+
rel,
|
|
816
|
+
`superseded_by does not name a document in the record: ${target}`,
|
|
817
|
+
"it resolves to a file the record does not govern — on a case-insensitive filesystem a mis-typed capitalisation resolves happily here and then misses every rule keyed by the real path, the cross-audience check included",
|
|
818
|
+
"match the successor's path exactly as it appears under knowledge/ (ascii lowercase)",
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// A supersession must lead somewhere. A document that supersedes ITSELF, or a
|
|
823
|
+
// pair that supersede each other, published a notice telling the reader this
|
|
824
|
+
// page is replaced — by a link back to a page saying the same thing. The
|
|
825
|
+
// reader is sent in a circle and never reaches a current document (found
|
|
826
|
+
// 2026-08-20).
|
|
827
|
+
{
|
|
828
|
+
const successorOf = new Map();
|
|
829
|
+
for (const { kind, from, to } of crossings) {
|
|
830
|
+
if (kind === "superseded_by" && !successorOf.has(from)) successorOf.set(from, to);
|
|
831
|
+
}
|
|
832
|
+
const walked = new Set();
|
|
833
|
+
for (const start of successorOf.keys()) {
|
|
834
|
+
if (walked.has(start)) continue;
|
|
835
|
+
// An ORDERED path, not a set: the documents visited before the loop
|
|
836
|
+
// closes are not part of the cycle, and reporting them made the check
|
|
837
|
+
// blame a document whose pointer was correct while printing an edge the
|
|
838
|
+
// record does not contain (found 2026-08-20 — a → b → c → b was reported
|
|
839
|
+
// as "a → b → c → a").
|
|
840
|
+
const trail = [start];
|
|
841
|
+
const onTrail = new Set([start]);
|
|
842
|
+
let cursor = successorOf.get(start);
|
|
843
|
+
while (cursor !== undefined && !onTrail.has(cursor)) {
|
|
844
|
+
onTrail.add(cursor);
|
|
845
|
+
trail.push(cursor);
|
|
846
|
+
cursor = successorOf.get(cursor);
|
|
847
|
+
}
|
|
848
|
+
for (const node of trail) walked.add(node);
|
|
849
|
+
if (cursor === undefined) continue; // the chain ends at a current document
|
|
850
|
+
// The cycle is the trail from the document the walk returned to.
|
|
851
|
+
const cycle = trail.slice(trail.indexOf(cursor));
|
|
852
|
+
const names = cycle.map((file) => path.relative(root, file));
|
|
853
|
+
problem(
|
|
854
|
+
names[0],
|
|
855
|
+
cycle.length === 1
|
|
856
|
+
? "superseded_by points at this document itself"
|
|
857
|
+
: `supersession cycle: ${names.join(" → ")} → ${names[0]}`,
|
|
858
|
+
"a supersession sends the reader to the successor that replaced this one — a pointer that comes back here sends them in a circle and never reaches a current document",
|
|
859
|
+
"point superseded_by at the document that actually replaces this one, or set status back if nothing does",
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
687
864
|
// Pointers across audiences: the leak no single build can catch, because the
|
|
688
865
|
// build that publishes the pointer has already dropped its target and cannot
|
|
689
866
|
// know it ever existed. Only the whole record sees both ends.
|
|
@@ -750,7 +927,7 @@ const INSTANCE_KEYS = new Set([
|
|
|
750
927
|
"budgets",
|
|
751
928
|
]);
|
|
752
929
|
const INSTANCE_KSOR_KEYS = new Set(["requires", "scaffolded"]);
|
|
753
|
-
const INSTANCE_SITE_KEYS = new Set(["url"]);
|
|
930
|
+
const INSTANCE_SITE_KEYS = new Set(["url", "governance"]);
|
|
754
931
|
// Nested field names mirror the kernel's instance schema; the kernel validates
|
|
755
932
|
// their values (this checker stays dependency-free and cannot import it).
|
|
756
933
|
const INSTANCE_DATABASE_KEYS = new Set(["dsn_env", "tenant_id"]);
|
|
@@ -778,7 +955,7 @@ if (!existsSync(instanceMd)) {
|
|
|
778
955
|
problem(
|
|
779
956
|
"instance.md",
|
|
780
957
|
`unclosed or malformed frontmatter — this is not a frontmatter line: "${fm.malformed[0]}"`,
|
|
781
|
-
"an unclosed block swallows the identity prose and turns it into unreadable configuration",
|
|
958
|
+
"an unclosed block swallows the identity prose and turns it into unreadable configuration; a value wrapped onto a second line is folded back by YAML but invisible here, so this check governs a string the surfaces never see",
|
|
782
959
|
"close the block with --- on its own line; every line inside it is `key: value` — the identity prose belongs below it",
|
|
783
960
|
);
|
|
784
961
|
} else {
|
|
@@ -920,6 +1097,31 @@ if (!existsSync(instanceMd)) {
|
|
|
920
1097
|
"add audiences: (ordered least- to most-restricted, public first), or remove default_visibility:",
|
|
921
1098
|
);
|
|
922
1099
|
}
|
|
1100
|
+
// A group written as a flow mapping (`site: { governance: false }`) lands
|
|
1101
|
+
// as a scalar with NO children, so every nested rule below — the closed key
|
|
1102
|
+
// set included — silently skips it: the owner's setting is dropped without
|
|
1103
|
+
// a word (found 2026-08-20). The groups are block mappings, always.
|
|
1104
|
+
for (const parent of ["ksor", "site", "database", "embedding", "retrieval", "budgets"]) {
|
|
1105
|
+
// A trailing ` #` comment is part of this checker's own grammar — it is
|
|
1106
|
+
// stripped for values and for list items, and `audiences: # who may read`
|
|
1107
|
+
// passes on the same file. Reading the raw value called `site: # notes` an
|
|
1108
|
+
// inline mapping and refused a well-formed record, with a why that was
|
|
1109
|
+
// factually false: the surfaces read that file perfectly (found
|
|
1110
|
+
// 2026-08-20).
|
|
1111
|
+
const rawGroup = fm.keys.get(parent);
|
|
1112
|
+
const inline =
|
|
1113
|
+
rawGroup === undefined || rawGroup.trim().startsWith("#")
|
|
1114
|
+
? ""
|
|
1115
|
+
: rawGroup.replace(/\s+#.*$/, "").trim();
|
|
1116
|
+
if (inline !== "") {
|
|
1117
|
+
problem(
|
|
1118
|
+
"instance.md",
|
|
1119
|
+
`${parent}: has an inline value: ${inline}`,
|
|
1120
|
+
"a group written on one line is not read as a group — every key inside it is skipped by this check AND by the surfaces, so the settings the owner wrote are silently dropped",
|
|
1121
|
+
`write it as an indented block:\n ${parent}:\n <key>: <value>`,
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
923
1125
|
for (const [parent, allowed] of [
|
|
924
1126
|
["ksor", INSTANCE_KSOR_KEYS],
|
|
925
1127
|
["site", INSTANCE_SITE_KEYS],
|
|
@@ -929,6 +1131,27 @@ if (!existsSync(instanceMd)) {
|
|
|
929
1131
|
["budgets", INSTANCE_BUDGETS_KEYS],
|
|
930
1132
|
]) {
|
|
931
1133
|
for (const key of fm.children.get(parent)?.keys() ?? []) {
|
|
1134
|
+
// site.governance is a switch, so its VALUE is checked here: a typo
|
|
1135
|
+
// that silently defaulted would publish the governance the owner asked
|
|
1136
|
+
// to hide, or hide what they asked to publish.
|
|
1137
|
+
if (parent === "site" && key === "governance") {
|
|
1138
|
+
const raw = (fm.children.get("site")?.get("governance") ?? "").trim();
|
|
1139
|
+
const value = (/^["']/.test(raw) ? raw : raw.replace(/\s+#.*$/, ""))
|
|
1140
|
+
.trim()
|
|
1141
|
+
.replace(/^(['"])(.*)\1$/, "$2")
|
|
1142
|
+
// Case-folded: js-yaml reads `False` as false, and the site's own
|
|
1143
|
+
// reader lowercases before comparing. A checker stricter than both
|
|
1144
|
+
// surfaces is the very divergence this rule exists to stop.
|
|
1145
|
+
.toLowerCase();
|
|
1146
|
+
if (value !== "true" && value !== "false") {
|
|
1147
|
+
problem(
|
|
1148
|
+
"instance.md",
|
|
1149
|
+
`site.governance is "${value}" — it must be true or false`,
|
|
1150
|
+
"it decides whether pages show the owner, effective date and sources each document declares; a value nobody can read is a setting the owner believes is in effect",
|
|
1151
|
+
'write "governance: false" to keep pages plain, or remove the key (the default shows them)',
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
932
1155
|
if (!allowed.has(key)) {
|
|
933
1156
|
problem(
|
|
934
1157
|
"instance.md",
|