@openshain/core 0.4.1 → 0.5.0

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.
Files changed (45) hide show
  1. package/dist/config/schema.d.ts +6 -0
  2. package/dist/config/schema.js +13 -0
  3. package/dist/errors.d.ts +6 -1
  4. package/dist/errors.js +9 -0
  5. package/dist/index.d.ts +9 -3
  6. package/dist/index.js +8 -2
  7. package/dist/knowledge/build.d.ts +102 -0
  8. package/dist/knowledge/build.js +279 -0
  9. package/dist/knowledge/check.d.ts +30 -0
  10. package/dist/knowledge/check.js +262 -0
  11. package/dist/knowledge/schema.d.ts +93 -0
  12. package/dist/knowledge/schema.js +76 -0
  13. package/dist/knowledge/search.d.ts +26 -0
  14. package/dist/knowledge/search.js +54 -0
  15. package/dist/knowledge/store.d.ts +10 -0
  16. package/dist/knowledge/store.js +69 -0
  17. package/dist/runtime.d.ts +3 -1
  18. package/dist/runtime.js +3 -1
  19. package/dist/schemas.d.ts +1 -1
  20. package/dist/schemas.js +3 -0
  21. package/dist/tool/files.d.ts +21 -0
  22. package/dist/tool/files.js +69 -0
  23. package/dist/tool/paths.d.ts +1 -1
  24. package/dist/tool/paths.js +9 -1
  25. package/dist/tool/types.d.ts +13 -5
  26. package/dist/work/events.d.ts +23 -3
  27. package/dist/work/events.js +30 -3
  28. package/dist/work/projection.d.ts +9 -0
  29. package/dist/work/projection.js +54 -2
  30. package/package.json +1 -1
  31. package/src/config/schema.ts +25 -1
  32. package/src/errors.ts +12 -0
  33. package/src/index.ts +62 -2
  34. package/src/knowledge/build.ts +349 -0
  35. package/src/knowledge/check.ts +314 -0
  36. package/src/knowledge/schema.ts +105 -0
  37. package/src/knowledge/search.ts +74 -0
  38. package/src/knowledge/store.ts +82 -0
  39. package/src/runtime.ts +6 -2
  40. package/src/schemas.ts +14 -1
  41. package/src/tool/files.ts +79 -0
  42. package/src/tool/paths.ts +9 -1
  43. package/src/tool/types.ts +14 -2
  44. package/src/work/events.ts +39 -4
  45. package/src/work/projection.ts +57 -2
@@ -0,0 +1,349 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { readWorkspaceTextIfAny } from "../tool/files.ts";
5
+ import type { Checked } from "./check.ts";
6
+ import { KNOWLEDGE_DIR_NAME } from "./check.ts";
7
+ import type { LoadedRule, Scope, Source } from "./schema.ts";
8
+ import { readKnowledgeFile, writeKnowledgeFile } from "./store.ts";
9
+
10
+ /**
11
+ * Turning what a person wrote into the index the runtime serves. The index is a build artifact:
12
+ * the same input always makes the same bytes, and the manifest says which input it came from and
13
+ * what the index itself hashes to, so a rewritten index is not served.
14
+ */
15
+
16
+ /** Raised when the index is read by a runtime that indexes differently than the one that wrote it. */
17
+ export const INDEX_FORMAT_VERSION = 1;
18
+
19
+ const BUILD_DIR = "build";
20
+ const INDEX_FILE = "index.json";
21
+ const MANIFEST_FILE = "manifest.json";
22
+
23
+ /** One thing the search can return: a rule, or one section of a source. */
24
+ export interface IndexUnit {
25
+ /** `rule:<id>` or `source:<id>#<heading>`; unique in the index. */
26
+ key: string;
27
+ kind: "rule" | "source";
28
+ /** The id a person wrote, which citations name. */
29
+ ref: string;
30
+ heading: string;
31
+ /** What the unit says: the statement of a rule, or the text of a section. */
32
+ text: string;
33
+ scope: Scope | null;
34
+ /** The professions a rule is for, or null for every profession. A source is for all of them. */
35
+ professions: string[] | null;
36
+ expertise: string;
37
+ from: string;
38
+ to: string | null;
39
+ /** For a rule, the source it cites. */
40
+ source?: { id: string; section?: string };
41
+ /** For a source, where it came from. */
42
+ provenance?: { publisher: string; title: string; version?: string; retrieved_at: string };
43
+ }
44
+
45
+ export interface KnowledgeIndex {
46
+ format: number;
47
+ units: IndexUnit[];
48
+ /**
49
+ * The groups of characters to the units that contain them. Two-character groups answer a
50
+ * question too short to have three.
51
+ */
52
+ postings: { pairs: Record<string, number[]>; triples: Record<string, number[]> };
53
+ /** How many distinct three-character grams each unit has, for the length correction. */
54
+ sizes: number[];
55
+ }
56
+
57
+ export interface Manifest {
58
+ format: number;
59
+ input_sha256: string;
60
+ index_sha256: string;
61
+ units: number;
62
+ rules: number;
63
+ sources: number;
64
+ built_at: string;
65
+ }
66
+
67
+ /** Text as the index compares it: full width and half width alike, one case, no spaces. */
68
+ export function normalize(text: string): string {
69
+ return text.normalize("NFKC").toLowerCase().replace(/\s+/gu, "");
70
+ }
71
+
72
+ /** The distinct groups of `n` characters in the text. */
73
+ export function grams(text: string, n: number): Set<string> {
74
+ const chars = [...normalize(text)];
75
+ const out = new Set<string>();
76
+ for (let i = 0; i + n <= chars.length; i++) out.add(chars.slice(i, i + n).join(""));
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * The index of a checked set. A rule that another rule replaces is closed the day before the
82
+ * newer one starts, so the two are never in effect together.
83
+ */
84
+ export function buildIndex(checked: Checked): KnowledgeIndex {
85
+ const closed = closeSuperseded(checked.rules);
86
+ const units: IndexUnit[] = [
87
+ ...closed.map(ruleUnit),
88
+ ...checked.sources.flatMap((source) => sectionUnits(source)),
89
+ ].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
90
+
91
+ const postings = { pairs: {}, triples: {} } satisfies KnowledgeIndex["postings"];
92
+ const sizes: number[] = [];
93
+ for (const [at, unit] of units.entries()) {
94
+ // What a unit is matched on: its own words, and its heading when that is not the words
95
+ // themselves. The id is left out; it would only make a unit look longer than it reads.
96
+ // Aliases are already part of a rule's text, and nothing else bridges words that share no
97
+ // characters with it.
98
+ const matter = unit.heading === unit.text ? unit.text : `${unit.text} ${unit.heading}`;
99
+ add(postings.pairs, grams(matter, 2), at);
100
+ add(postings.triples, grams(matter, 3), at);
101
+ sizes.push(grams(matter, 3).size);
102
+ }
103
+ return {
104
+ format: INDEX_FORMAT_VERSION,
105
+ units,
106
+ postings: { pairs: settled(postings.pairs), triples: settled(postings.triples) },
107
+ sizes,
108
+ };
109
+ }
110
+
111
+ /** Notes that the unit at `at` holds each of these groups. */
112
+ function add(postings: Record<string, number[]>, of: Set<string>, at: number): void {
113
+ for (const gram of of) {
114
+ const units = postings[gram];
115
+ if (units) units.push(at);
116
+ else postings[gram] = [at];
117
+ }
118
+ }
119
+
120
+ /** The same postings in a fixed order, so that the same input writes the same bytes. */
121
+ function settled(postings: Record<string, number[]>): Record<string, number[]> {
122
+ return Object.fromEntries(
123
+ Object.entries(postings)
124
+ .sort(([a], [b]) => (a < b ? -1 : 1))
125
+ .map(([gram, units]) => [gram, [...units].sort((x, y) => x - y)]),
126
+ );
127
+ }
128
+
129
+ /** A rule replaced by another ends the day before that one begins. */
130
+ function closeSuperseded(rules: LoadedRule[]): LoadedRule[] {
131
+ const replacedBy = new Map<string, LoadedRule>();
132
+ for (const rule of rules) if (rule.supersedes) replacedBy.set(rule.supersedes, rule);
133
+ return rules.map((rule) => {
134
+ const next = replacedBy.get(rule.id);
135
+ if (!next) return rule;
136
+ const end = dayBefore(next.effective_from);
137
+ return {
138
+ ...rule,
139
+ effective_to: rule.effective_to === null ? end : minDay(rule.effective_to, end),
140
+ };
141
+ });
142
+ }
143
+
144
+ function dayBefore(day: string): string {
145
+ const at = new Date(`${day}T00:00:00Z`);
146
+ at.setUTCDate(at.getUTCDate() - 1);
147
+ return at.toISOString().slice(0, 10);
148
+ }
149
+
150
+ const minDay = (a: string, b: string) => (a < b ? a : b);
151
+
152
+ function ruleUnit(rule: LoadedRule): IndexUnit {
153
+ return {
154
+ key: `rule:${rule.id}`,
155
+ kind: "rule",
156
+ ref: rule.id,
157
+ heading: rule.statement,
158
+ text: [rule.statement, ...(rule.aliases ?? [])].join(" "),
159
+ scope: rule.scope ?? null,
160
+ professions: rule.applies_to?.profession ?? null,
161
+ expertise: rule.expertise,
162
+ from: rule.effective_from,
163
+ to: rule.effective_to,
164
+ source: {
165
+ id: rule.source.id,
166
+ ...(rule.source.section !== undefined && { section: rule.source.section }),
167
+ },
168
+ };
169
+ }
170
+
171
+ /** A source becomes one unit per heading; a source with no heading becomes one unit. */
172
+ function sectionUnits(source: Source): IndexUnit[] {
173
+ const provenance = {
174
+ publisher: source.publisher,
175
+ title: source.title,
176
+ ...(source.version !== undefined && { version: source.version }),
177
+ retrieved_at: source.retrieved_at,
178
+ };
179
+ const common = {
180
+ kind: "source" as const,
181
+ ref: source.id,
182
+ scope: source.scope ?? null,
183
+ professions: null,
184
+ expertise: source.expertise,
185
+ from: source.effective_from,
186
+ to: source.effective_to,
187
+ provenance,
188
+ };
189
+ const sections = split(source.body);
190
+ if (sections.length === 0) {
191
+ return [{ ...common, key: `source:${source.id}#`, heading: source.title, text: source.body }];
192
+ }
193
+ // Two sections of one document may carry the same heading. A key names one unit, so the
194
+ // second one of a name says which it is.
195
+ const seen = new Map<string, number>();
196
+ return sections.map((section) => {
197
+ const nth = (seen.get(section.heading) ?? 0) + 1;
198
+ seen.set(section.heading, nth);
199
+ return {
200
+ ...common,
201
+ key: `source:${source.id}#${section.heading}${nth === 1 ? "" : ` (${nth})`}`,
202
+ heading: section.heading,
203
+ text: section.text,
204
+ };
205
+ });
206
+ }
207
+
208
+ /** The body cut at its markdown headings. Text before the first heading joins the first section. */
209
+ function split(body: string): { heading: string; text: string }[] {
210
+ const lines = body.split("\n");
211
+ const sections: { heading: string; text: string[] }[] = [];
212
+ for (const line of lines) {
213
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
214
+ if (heading) sections.push({ heading: heading[2] as string, text: [] });
215
+ else sections.at(-1)?.text.push(line);
216
+ }
217
+ return sections.map((section) => ({
218
+ heading: section.heading,
219
+ text: section.text.join("\n").trim(),
220
+ }));
221
+ }
222
+
223
+ /** The same bytes for the same index: keys in a fixed order, two spaces, a newline at the end. */
224
+ export function serializeIndex(index: KnowledgeIndex): string {
225
+ const units = index.units.map((unit) => ordered(unit as unknown as Record<string, unknown>));
226
+ const body = {
227
+ format: index.format,
228
+ postings: index.postings,
229
+ sizes: index.sizes,
230
+ units,
231
+ };
232
+ return `${JSON.stringify(body, null, 2)}\n`;
233
+ }
234
+
235
+ /** An object with its keys in code point order, all the way down. */
236
+ function ordered(value: Record<string, unknown>): Record<string, unknown> {
237
+ const out: Record<string, unknown> = {};
238
+ for (const key of Object.keys(value).sort()) {
239
+ const inner = value[key];
240
+ out[key] =
241
+ inner && typeof inner === "object" && !Array.isArray(inner)
242
+ ? ordered(inner as Record<string, unknown>)
243
+ : inner;
244
+ }
245
+ return out;
246
+ }
247
+
248
+ const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");
249
+
250
+ /**
251
+ * The hash of what a person wrote, read from the files themselves rather than from what was
252
+ * parsed out of them. The runtime recomputes this before it trusts an index.
253
+ */
254
+ export async function hashKnowledgeInput(workspaceRoot: string): Promise<string> {
255
+ const dir = join(workspaceRoot, KNOWLEDGE_DIR_NAME);
256
+ const parts: string[] = [];
257
+ for (const [sub, extension] of [
258
+ ["rules", ".yaml"],
259
+ ["sources", ".md"],
260
+ ] as const) {
261
+ let names: string[];
262
+ try {
263
+ names = (await readdir(join(dir, sub))).filter((n) => n.endsWith(extension)).sort();
264
+ } catch {
265
+ continue;
266
+ }
267
+ for (const name of names) {
268
+ // The same guarded read the checks use, so hashing and checking see one set of files: a
269
+ // file too large to read, or a link that leads out, is refused here as it is there.
270
+ const text = await readWorkspaceTextIfAny(dir, join(sub, name));
271
+ parts.push(`${sub}/${name}\n${text === undefined ? "unreadable" : sha256(text)}`);
272
+ }
273
+ }
274
+ return sha256(parts.join("\n"));
275
+ }
276
+
277
+ /**
278
+ * Writes the index and then the manifest, each through a temporary file. The manifest lands last
279
+ * and is the mark that the index beside it is whole: a reader that finds a manifest finds an
280
+ * index that was fully written.
281
+ */
282
+ export async function writeIndex(
283
+ workspaceRoot: string,
284
+ index: KnowledgeIndex,
285
+ input: { hash: string; rules: number; sources: number },
286
+ now: Date = new Date(),
287
+ ): Promise<Manifest> {
288
+ const serialized = serializeIndex(index);
289
+ const manifest: Manifest = {
290
+ format: INDEX_FORMAT_VERSION,
291
+ input_sha256: input.hash,
292
+ index_sha256: sha256(serialized),
293
+ units: index.units.length,
294
+ rules: input.rules,
295
+ sources: input.sources,
296
+ built_at: now.toISOString(),
297
+ };
298
+ await writeKnowledgeFile(workspaceRoot, [BUILD_DIR, INDEX_FILE], serialized);
299
+ await writeKnowledgeFile(
300
+ workspaceRoot,
301
+ [BUILD_DIR, MANIFEST_FILE],
302
+ `${JSON.stringify(manifest, null, 2)}\n`,
303
+ );
304
+ return manifest;
305
+ }
306
+
307
+ /** What the runtime reads before it serves knowledge, or a reason not to serve any. */
308
+ export type IndexState =
309
+ | { ok: true; index: KnowledgeIndex; manifest: Manifest }
310
+ | { ok: false; reason: string };
311
+
312
+ /**
313
+ * Reads the index only if it is the one the manifest describes and the manifest describes the
314
+ * files that are there now. An index rewritten on its own, a manifest rewritten on its own, and
315
+ * an index built by another version of the runtime all come back as a reason not to serve it.
316
+ */
317
+ export async function readIndex(workspaceRoot: string): Promise<IndexState> {
318
+ const stale = {
319
+ ok: false as const,
320
+ reason: "the index does not match the files it was built from; run `openshain knowledge build`",
321
+ };
322
+ let manifest: Manifest;
323
+ let serialized: string;
324
+ try {
325
+ // Read nothing before knowing its size: these two files are as writable as any other in the
326
+ // folder, and an index of a company's knowledge is far below this.
327
+ const manifestText = await readKnowledgeFile(workspaceRoot, [BUILD_DIR, MANIFEST_FILE]);
328
+ const indexText = await readKnowledgeFile(workspaceRoot, [BUILD_DIR, INDEX_FILE]);
329
+ if (manifestText === undefined || indexText === undefined) throw new Error("no index");
330
+ manifest = JSON.parse(manifestText) as Manifest;
331
+ serialized = indexText;
332
+ } catch {
333
+ return { ok: false, reason: "there is no index; run `openshain knowledge build`" };
334
+ }
335
+ if (manifest.format !== INDEX_FORMAT_VERSION) {
336
+ return {
337
+ ok: false,
338
+ reason:
339
+ "the index was built by another version of openshain; run `openshain knowledge build`",
340
+ };
341
+ }
342
+ if (sha256(serialized) !== manifest.index_sha256) return stale;
343
+ if ((await hashKnowledgeInput(workspaceRoot)) !== manifest.input_sha256) return stale;
344
+ try {
345
+ return { ok: true, index: JSON.parse(serialized) as KnowledgeIndex, manifest };
346
+ } catch {
347
+ return stale;
348
+ }
349
+ }
@@ -0,0 +1,314 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parseYamlFile } from "../config/yaml.ts";
4
+ import { isOpenshainError } from "../errors.ts";
5
+ import { readWorkspaceTextIfAny } from "../tool/files.ts";
6
+ import { resolveWorkspacePath } from "../tool/paths.ts";
7
+ import {
8
+ type LoadedRule,
9
+ RulesFileSchema,
10
+ type Scope,
11
+ type Source,
12
+ SourceFrontMatterSchema,
13
+ } from "./schema.ts";
14
+
15
+ /**
16
+ * Reading and checking what a person wrote under `knowledge/`. Every problem is collected, never
17
+ * thrown at the first one: a person fixing a set of files should see all of it in one pass.
18
+ */
19
+
20
+ export const KNOWLEDGE_DIR_NAME = "knowledge";
21
+
22
+ /** How much a build reads in total, and how many files it reads, whatever is in the folder. */
23
+ export const MAX_KNOWLEDGE_FILES = 2000;
24
+ export const MAX_KNOWLEDGE_BYTES = 64 * 1024 * 1024;
25
+ const RULES_DIR = "rules";
26
+ const SOURCES_DIR = "sources";
27
+
28
+ export interface Checked {
29
+ rules: LoadedRule[];
30
+ sources: Source[];
31
+ /** Everything wrong with what was read, each as `file:line:col field: message` or `file: message`. */
32
+ problems: string[];
33
+ }
34
+
35
+ /** True when the workspace has a `knowledge/` directory to read at all. */
36
+ export async function hasKnowledge(workspaceRoot: string): Promise<boolean> {
37
+ try {
38
+ await readdir(join(workspaceRoot, KNOWLEDGE_DIR_NAME));
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Reads `knowledge/rules/*.yaml` and `knowledge/sources/*.md` and checks them against each other.
47
+ * The rules that come back are the ones a build would index; when `problems` is not empty, nothing
48
+ * should be written.
49
+ */
50
+ export async function checkKnowledge(
51
+ workspaceRoot: string,
52
+ options: {
53
+ /**
54
+ * Rules to check as though they were already written, with the file they would go in. A
55
+ * command that adds one asks this first, so that a rule which does not hold up is never
56
+ * written at all.
57
+ */
58
+ adding?: LoadedRule[];
59
+ } = {},
60
+ ): Promise<Checked> {
61
+ const dir = join(workspaceRoot, KNOWLEDGE_DIR_NAME);
62
+ const problems: string[] = [];
63
+ const budget = { files: MAX_KNOWLEDGE_FILES, bytes: MAX_KNOWLEDGE_BYTES };
64
+ const rules = [...(await readRules(dir, problems, budget)), ...(options.adding ?? [])];
65
+ const sources = await readSources(dir, problems, budget);
66
+
67
+ await checkPaths(workspaceRoot, sources, problems);
68
+ crossCheck(rules, sources, problems);
69
+ return { rules, sources, problems: problems.sort() };
70
+ }
71
+
72
+ /** Files of a directory in code point order; an unreadable directory is simply empty. */
73
+ async function filesOf(dir: string, extension: string): Promise<string[]> {
74
+ try {
75
+ return (await readdir(dir))
76
+ .filter((name) => name.endsWith(extension) && !name.startsWith("."))
77
+ .sort();
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ /** What a read may still take. A build reads a bounded amount, whatever the folder holds. */
84
+ interface Budget {
85
+ files: number;
86
+ bytes: number;
87
+ }
88
+
89
+ /** The text of a file, or a reason it was not read. Spends the budget as it goes. */
90
+ async function within(
91
+ dir: string,
92
+ relative: string,
93
+ file: string,
94
+ budget: Budget,
95
+ problems: string[],
96
+ ): Promise<string | undefined> {
97
+ if (budget.files <= 0 || budget.bytes <= 0) {
98
+ problems.push(`${file}: not read; a build reads at most ${MAX_KNOWLEDGE_FILES} files`);
99
+ return undefined;
100
+ }
101
+ budget.files -= 1;
102
+ const text = await readWorkspaceTextIfAny(dir, relative);
103
+ if (text === undefined) {
104
+ problems.push(`${file}: cannot read the file`);
105
+ return undefined;
106
+ }
107
+ budget.bytes -= Buffer.byteLength(text, "utf8");
108
+ return text;
109
+ }
110
+
111
+ async function readRules(dir: string, problems: string[], budget: Budget): Promise<LoadedRule[]> {
112
+ const rules: LoadedRule[] = [];
113
+ for (const name of await filesOf(join(dir, RULES_DIR), ".yaml")) {
114
+ const file = `${KNOWLEDGE_DIR_NAME}/${RULES_DIR}/${name}`;
115
+ const text = await within(dir, join(RULES_DIR, name), file, budget, problems);
116
+ if (text === undefined) continue;
117
+ try {
118
+ const { data } = parseYamlFile(text, RulesFileSchema, file);
119
+ for (const rule of data.rules) rules.push({ ...rule, file });
120
+ } catch (err) {
121
+ problems.push(...messageOf(err, file));
122
+ }
123
+ }
124
+ return rules;
125
+ }
126
+
127
+ async function readSources(dir: string, problems: string[], budget: Budget): Promise<Source[]> {
128
+ const sources: Source[] = [];
129
+ for (const name of await filesOf(join(dir, SOURCES_DIR), ".md")) {
130
+ const file = `${KNOWLEDGE_DIR_NAME}/${SOURCES_DIR}/${name}`;
131
+ const text = await within(dir, join(SOURCES_DIR, name), file, budget, problems);
132
+ if (text === undefined) continue;
133
+ const split = frontMatter(text);
134
+ if (!split) {
135
+ problems.push(`${file}: the file must start with front matter between --- lines`);
136
+ continue;
137
+ }
138
+ try {
139
+ const { data } = parseYamlFile(split.head, SourceFrontMatterSchema, file);
140
+ sources.push({ ...data, body: split.body, file });
141
+ } catch (err) {
142
+ problems.push(...messageOf(err, file));
143
+ }
144
+ }
145
+ return sources;
146
+ }
147
+
148
+ /** The YAML between the opening `---` and the next one, and everything after it. */
149
+ function frontMatter(text: string): { head: string; body: string } | undefined {
150
+ const lines = text.split("\n");
151
+ if (lines[0]?.trim() !== "---") return undefined;
152
+ const end = lines.indexOf("---", 1);
153
+ if (end < 0) return undefined;
154
+ return {
155
+ head: lines.slice(1, end).join("\n"),
156
+ body: lines
157
+ .slice(end + 1)
158
+ .join("\n")
159
+ .trim(),
160
+ };
161
+ }
162
+
163
+ function messageOf(err: unknown, file: string): string[] {
164
+ if (isOpenshainError(err)) return err.message.split("\n");
165
+ return [`${file}: ${err instanceof Error ? err.message : String(err)}`];
166
+ }
167
+
168
+ /**
169
+ * A source that names a file names it inside the company folder. The guard the tools run under
170
+ * answers this, so a source cannot pull `authority/`, a principal's record or anything outside
171
+ * the folder into the index.
172
+ */
173
+ async function checkPaths(
174
+ workspaceRoot: string,
175
+ sources: Source[],
176
+ problems: string[],
177
+ ): Promise<void> {
178
+ for (const source of sources) {
179
+ if (source.path === undefined) continue;
180
+ try {
181
+ await resolveWorkspacePath(workspaceRoot, source.path);
182
+ } catch (err) {
183
+ problems.push(
184
+ `${source.file}: ${source.id} points at ${source.path}, which the runtime does not read (${err instanceof Error ? err.message : String(err)})`,
185
+ );
186
+ }
187
+ }
188
+ }
189
+
190
+ /** The checks that need more than one file: references, scope, effective days. */
191
+ function crossCheck(rules: LoadedRule[], sources: Source[], problems: string[]): void {
192
+ const byId = new Map(sources.map((source) => [source.id, source]));
193
+ for (const source of duplicates(sources)) {
194
+ problems.push(`${source.file}: source ${source.id} is defined more than once`);
195
+ }
196
+ for (const rule of duplicates(rules)) {
197
+ problems.push(`${rule.file}: rule ${rule.id} is defined more than once`);
198
+ }
199
+ for (const item of [...sources, ...rules]) endsAfterItStarts(item, problems);
200
+ for (const rule of rules) againstItsSource(rule, byId.get(rule.source.id), problems);
201
+ overlaps(rules, problems);
202
+ }
203
+
204
+ /** A day of the calendar comes before another; a rule or a source that ends first says nothing. */
205
+ function endsAfterItStarts(
206
+ item: { id: string; file: string; effective_from: string; effective_to: string | null },
207
+ problems: string[],
208
+ ): void {
209
+ if (item.effective_to !== null && item.effective_to < item.effective_from) {
210
+ problems.push(`${item.file}: ${item.id} ends before it starts`);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * A rule stands on the source it cites, so it may not exist without it, outlive it, begin before
216
+ * it, or be read by people who may not read it.
217
+ */
218
+ function againstItsSource(rule: LoadedRule, source: Source | undefined, problems: string[]): void {
219
+ if (!source) {
220
+ problems.push(`${rule.file}: ${rule.id} cites ${rule.source.id}, which no source declares`);
221
+ return;
222
+ }
223
+ if (rule.effective_from < source.effective_from) {
224
+ problems.push(
225
+ `${rule.file}: ${rule.id} starts before ${source.id}, the source it cites, is in effect`,
226
+ );
227
+ }
228
+ if (source.effective_to !== null && (rule.effective_to ?? FOREVER) > source.effective_to) {
229
+ problems.push(
230
+ `${rule.file}: ${rule.id} outlives ${source.id}, the source it cites; close it on ${source.effective_to} or cite a newer source`,
231
+ );
232
+ }
233
+ if (!covers(source.scope, rule.scope)) {
234
+ problems.push(
235
+ `${rule.file}: ${rule.id} may be read by more people than ${source.id}, the source it cites; its citation would name a source they cannot read`,
236
+ );
237
+ }
238
+ }
239
+
240
+ /** A day later than any a person would write, for a rule or a source with no end. */
241
+ const FOREVER = "9999-12-31";
242
+
243
+ /** The items whose id was already taken by an earlier item. */
244
+ function duplicates<T extends { id: string }>(items: T[]): T[] {
245
+ const seen = new Set<string>();
246
+ const again: T[] = [];
247
+ for (const item of items) {
248
+ if (seen.has(item.id)) again.push(item);
249
+ else seen.add(item.id);
250
+ }
251
+ return again;
252
+ }
253
+
254
+ /** The people a scope names, and how it names them. Company-wide names everyone. */
255
+ function named(scope: Scope | undefined): {
256
+ kind: "everyone" | "principals" | "roles";
257
+ who: string[];
258
+ } {
259
+ if (scope === undefined || "visibility" in scope) return { kind: "everyone", who: [] };
260
+ if ("principals" in scope) return { kind: "principals", who: scope.principals };
261
+ return { kind: "roles", who: scope.roles };
262
+ }
263
+
264
+ /** Whether everyone who may read `inner` may also read `outer`. */
265
+ function covers(outer: Scope | undefined, inner: Scope | undefined): boolean {
266
+ const wider = named(outer);
267
+ const narrower = named(inner);
268
+ if (wider.kind === "everyone") return true;
269
+ if (narrower.kind === "everyone") return false;
270
+ // Two lists of different kinds cannot be compared without knowing who holds which role.
271
+ if (wider.kind !== narrower.kind) return false;
272
+ const allowed = new Set(wider.who);
273
+ return narrower.who.every((name) => allowed.has(name));
274
+ }
275
+
276
+ /**
277
+ * Two rules drawn from the very same passage, both in effect, are two answers to one question
278
+ * unless one says it replaces the other or they are for different people or professions.
279
+ *
280
+ * The passage, not the document: one policy document backs many rules — receipts over one
281
+ * amount, an approval over another — and that is how a company writes. Only rules that name the
282
+ * same section of the same source are compared, so ordinary writing is never refused.
283
+ */
284
+ function overlaps(rules: LoadedRule[], problems: string[]): void {
285
+ const replaced = new Set(rules.map((rule) => rule.supersedes).filter(Boolean));
286
+ for (const [i, rule] of rules.entries()) {
287
+ for (const other of rules.slice(i + 1)) {
288
+ if (rule.source.id !== other.source.id) continue;
289
+ if (rule.source.section === undefined || rule.source.section !== other.source.section) {
290
+ continue;
291
+ }
292
+ if (replaced.has(rule.id) || replaced.has(other.id)) continue;
293
+ if (!inEffectTogether(rule, other)) continue;
294
+ if (disjoint(rule, other)) continue;
295
+ problems.push(
296
+ `${other.file}: ${other.id} and ${rule.id} both cite ${rule.source.id} の ${rule.source.section} and are in effect at the same time; close one with effective_to, or say supersedes`,
297
+ );
298
+ }
299
+ }
300
+ }
301
+
302
+ function inEffectTogether(a: LoadedRule, b: LoadedRule): boolean {
303
+ const end = (rule: LoadedRule) => rule.effective_to ?? FOREVER;
304
+ return a.effective_from <= end(b) && b.effective_from <= end(a);
305
+ }
306
+
307
+ /** Rules that no one person and no one profession sees together do not contradict each other. */
308
+ function disjoint(a: LoadedRule, b: LoadedRule): boolean {
309
+ const professions = (rule: LoadedRule) => rule.applies_to?.profession;
310
+ const pa = professions(a);
311
+ const pb = professions(b);
312
+ if (pa && pb && !pa.some((name) => pb.includes(name))) return true;
313
+ return !covers(a.scope, b.scope) && !covers(b.scope, a.scope);
314
+ }