@ctcstack/ctcdocs-core 0.14.0 → 0.16.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.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Which readers each folder and document has, grouped into access classes
3
+ * (ADR-039).
4
+ *
5
+ * A document's readers are the groups every rule on its folder chain names:
6
+ * the set intersection of those rules, where `"*"` names every member and
7
+ * leaves the set as it is. A chain with no rule at all is closed to everyone
8
+ * but the admin groups. Documents with the same readers share a class, and a
9
+ * class is what the build files, search bundles and, later, the Worker work
10
+ * with.
11
+ *
12
+ * The model is a pure function of the configuration and the corpus, so the
13
+ * same input always yields the same classes and the same identifiers.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+
17
+ import {
18
+ EVERY_MEMBER,
19
+ type AccessConfiguration,
20
+ type AccessRule,
21
+ } from './access-configuration.js';
22
+ import type { CorpusStructure } from './corpus-structure.js';
23
+
24
+ /** Every signed-in member may read it. */
25
+ export const MEMBERS_CLASS = 'members';
26
+ /** Only the admin groups may read it: closed, or with no reader left. */
27
+ export const ADMINS_CLASS = 'admins';
28
+
29
+ /** `"*"`, or the sorted group addresses that may read; empty means none. */
30
+ export type Readers = typeof EVERY_MEMBER | readonly string[];
31
+
32
+ export interface AccessClass {
33
+ readonly id: string;
34
+ readonly readers: Readers;
35
+ }
36
+
37
+ export interface AccessModel {
38
+ /** Whether the project has an `access` section at all. */
39
+ readonly enabled: boolean;
40
+ readonly admins: readonly string[];
41
+ /** Every class in use, by identifier. */
42
+ readonly classes: Readonly<Record<string, AccessClass>>;
43
+ /** Class of every folder in the corpus, by Drive ID. */
44
+ readonly folders: Readonly<Record<string, string>>;
45
+ /** Class of every document in the corpus, by Drive ID. */
46
+ readonly documents: Readonly<Record<string, string>>;
47
+ }
48
+
49
+ /** What the rules on a folder's chain amount to. */
50
+ export type ChainReaders =
51
+ | { readonly kind: 'no-rule' }
52
+ | { readonly kind: 'broken' }
53
+ | { readonly kind: 'readers'; readonly readers: Readers };
54
+
55
+ const CLASS_ID_DOMAIN = 'ctcdocs-access-class/v1\n';
56
+ const CLASS_ID_LENGTH = 8;
57
+
58
+ /** The groups both reader sets admit; `"*"` admits every group. */
59
+ export function intersectReaders(left: Readers, right: Readers): Readers {
60
+ return intersect(left, right);
61
+ }
62
+
63
+ /** Whether `current` admits a group that `previous` did not. */
64
+ export function widensReaders(current: Readers, previous: Readers): boolean {
65
+ if (previous === EVERY_MEMBER) {
66
+ return false;
67
+ }
68
+ if (current === EVERY_MEMBER) {
69
+ return true;
70
+ }
71
+ return current.some((group) => !previous.includes(group));
72
+ }
73
+
74
+ function intersect(left: Readers, right: Readers): Readers {
75
+ if (left === EVERY_MEMBER) {
76
+ return right;
77
+ }
78
+ if (right === EVERY_MEMBER) {
79
+ return left;
80
+ }
81
+ const keep = new Set(right);
82
+ return left.filter((group) => keep.has(group));
83
+ }
84
+
85
+ function readersOf(rule: AccessRule): Readers {
86
+ return rule.readers.includes(EVERY_MEMBER) ? EVERY_MEMBER : rule.readers;
87
+ }
88
+
89
+ /**
90
+ * The rules on a folder's chain, from the folder up to the root. A chain that
91
+ * loops or names a parent the corpus does not have is `broken`, and decides
92
+ * nothing: a folder whose place is unknown is closed.
93
+ */
94
+ export function chainReaders(
95
+ folderId: string | null,
96
+ rules: ReadonlyMap<string, AccessRule>,
97
+ corpus: CorpusStructure,
98
+ ): ChainReaders {
99
+ let readers: Readers | undefined;
100
+ const seen = new Set<string>();
101
+ let current = folderId ?? corpus.rootFolderId;
102
+ while (current !== null) {
103
+ if (seen.has(current)) {
104
+ return { kind: 'broken' };
105
+ }
106
+ seen.add(current);
107
+ const rule = rules.get(current);
108
+ if (rule) {
109
+ readers =
110
+ readers === undefined
111
+ ? readersOf(rule)
112
+ : intersect(readers, readersOf(rule));
113
+ }
114
+ const folder = corpus.folders.get(current);
115
+ if (!folder) {
116
+ if (current === corpus.rootFolderId) {
117
+ break;
118
+ }
119
+ return { kind: 'broken' };
120
+ }
121
+ current = folder.parentId;
122
+ }
123
+ return readers === undefined
124
+ ? { kind: 'no-rule' }
125
+ : { kind: 'readers', readers };
126
+ }
127
+
128
+ /**
129
+ * The folders on a folder's chain, from the folder up to the root, as far as
130
+ * the corpus knows them. Two documents in the same place have the same chain;
131
+ * a move in Drive changes it, and a change to the rules does not.
132
+ */
133
+ export function folderChain(
134
+ folderId: string | null,
135
+ corpus: CorpusStructure,
136
+ ): string[] {
137
+ const chain: string[] = [];
138
+ let current = folderId ?? corpus.rootFolderId;
139
+ while (current !== null && !chain.includes(current)) {
140
+ chain.push(current);
141
+ current = corpus.folders.get(current)?.parentId ?? null;
142
+ }
143
+ return chain;
144
+ }
145
+
146
+ /**
147
+ * The access rules on a chain as one comparable value: adding, removing or
148
+ * changing the readers of any of them changes it, and a label does not.
149
+ */
150
+ export function chainRules(
151
+ chain: readonly string[],
152
+ rules: ReadonlyMap<string, AccessRule>,
153
+ ): string {
154
+ const named = chain.flatMap((folder) => {
155
+ const rule = rules.get(folder);
156
+ return rule ? [`${folder}=${[...rule.readers].sort().join(',')}`] : [];
157
+ });
158
+ return createHash('sha256')
159
+ .update(`ctcdocs-chain-rules/v1\n${named.join('\n')}`)
160
+ .digest('hex')
161
+ .slice(0, 16);
162
+ }
163
+
164
+ /** The readers a document was last published with, and from where. */
165
+ export interface PublishedReaders {
166
+ readonly readers: Readers;
167
+ /** Its folder chain when it was published there. */
168
+ readonly chain: readonly string[];
169
+ /**
170
+ * Set while a move that would widen its readers waits: the chain it moved
171
+ * to, and the rules on that chain when it arrived.
172
+ */
173
+ readonly held?: { readonly chain: readonly string[]; readonly rules: string };
174
+ }
175
+
176
+ function sameChain(left: readonly string[], right: readonly string[]): boolean {
177
+ return (
178
+ left.length === right.length &&
179
+ left.every((folder, index) => folder === right[index])
180
+ );
181
+ }
182
+
183
+ /**
184
+ * The readers a document is published with this time (ADR-039).
185
+ *
186
+ * A change to the rules is reviewed where the configuration is, so it takes
187
+ * effect at once, wider or narrower. A move in Drive is not reviewed: one that
188
+ * would give the document readers it did not have keeps the readers both
189
+ * places allow, until a rule on the new chain is added or changed. A move that
190
+ * narrows, or a move back, takes effect at once.
191
+ */
192
+ export function nextPublishedReaders(
193
+ current: {
194
+ readonly readers: Readers;
195
+ readonly chain: readonly string[];
196
+ readonly rules: string;
197
+ },
198
+ previous: Partial<PublishedReaders> | undefined,
199
+ ): { readonly published: PublishedReaders; readonly held: boolean } {
200
+ const settled = {
201
+ published: { readers: current.readers, chain: current.chain },
202
+ held: false,
203
+ };
204
+ if (previous?.readers === undefined || previous.chain === undefined) {
205
+ return settled;
206
+ }
207
+ if (
208
+ sameChain(current.chain, previous.chain) ||
209
+ !widensReaders(current.readers, previous.readers)
210
+ ) {
211
+ return settled;
212
+ }
213
+ const waiting =
214
+ previous.held !== undefined && sameChain(previous.held.chain, current.chain)
215
+ ? previous.held
216
+ : undefined;
217
+ if (waiting && waiting.rules !== current.rules) {
218
+ return settled;
219
+ }
220
+ return {
221
+ published: {
222
+ readers: intersect(current.readers, previous.readers),
223
+ chain: previous.chain,
224
+ held: waiting ?? { chain: current.chain, rules: current.rules },
225
+ },
226
+ held: true,
227
+ };
228
+ }
229
+
230
+ function readerKey(readers: readonly string[]): string {
231
+ return readers.join('\n');
232
+ }
233
+
234
+ function fullDigest(readers: readonly string[]): string {
235
+ return createHash('sha256')
236
+ .update(CLASS_ID_DOMAIN + readerKey(readers))
237
+ .digest('hex');
238
+ }
239
+
240
+ /**
241
+ * Short, stable identifiers for reader sets. Each is the start of a digest of
242
+ * the set; when two sets share a start, both are lengthened until they
243
+ * differ, so every other identifier is unaffected.
244
+ */
245
+ export function classIdentifiers(
246
+ readerSets: readonly (readonly string[])[],
247
+ ): Map<string, string> {
248
+ const digests = new Map<string, string>();
249
+ for (const readers of readerSets) {
250
+ digests.set(readerKey(readers), fullDigest(readers));
251
+ }
252
+ const ids = new Map<string, string>();
253
+ const keys = [...digests.keys()];
254
+ for (const key of keys) {
255
+ const digest = digests.get(key) as string;
256
+ let length = CLASS_ID_LENGTH;
257
+ for (const other of keys) {
258
+ if (other === key) {
259
+ continue;
260
+ }
261
+ const otherDigest = digests.get(other) as string;
262
+ while (
263
+ length < digest.length &&
264
+ digest.slice(0, length) === otherDigest.slice(0, length)
265
+ ) {
266
+ length += 1;
267
+ }
268
+ }
269
+ ids.set(key, digest.slice(0, length));
270
+ }
271
+ return ids;
272
+ }
273
+
274
+ function sortedRecord<T>(entries: Iterable<[string, T]>): Record<string, T> {
275
+ return Object.fromEntries(
276
+ [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
277
+ );
278
+ }
279
+
280
+ /**
281
+ * Classes for every folder and document. Without an `access` section the whole
282
+ * corpus is the members class, as it was before access rules existed.
283
+ */
284
+ export function computeAccessModel(
285
+ access: AccessConfiguration | undefined,
286
+ corpus: CorpusStructure,
287
+ ): AccessModel {
288
+ const membersOnly: AccessClass = { id: MEMBERS_CLASS, readers: EVERY_MEMBER };
289
+ if (!access) {
290
+ return {
291
+ enabled: false,
292
+ admins: [],
293
+ classes: { [MEMBERS_CLASS]: membersOnly },
294
+ folders: sortedRecord(
295
+ [...corpus.folders.keys()].map((id) => [id, MEMBERS_CLASS]),
296
+ ),
297
+ documents: sortedRecord(
298
+ [...corpus.documents.keys()].map((id) => [id, MEMBERS_CLASS]),
299
+ ),
300
+ };
301
+ }
302
+
303
+ const rules = new Map(access.rules.map((rule) => [rule.folder, rule]));
304
+ const folderReaders = new Map<string, Readers>();
305
+ const readersFor = (folderId: string | null): Readers => {
306
+ const key = folderId ?? corpus.rootFolderId ?? '';
307
+ const cached = folderReaders.get(key);
308
+ if (cached !== undefined) {
309
+ return cached;
310
+ }
311
+ const chain = chainReaders(folderId, rules, corpus);
312
+ const readers = chain.kind === 'readers' ? chain.readers : [];
313
+ folderReaders.set(key, readers);
314
+ return readers;
315
+ };
316
+
317
+ const folderSets = new Map<string, Readers>();
318
+ for (const id of corpus.folders.keys()) {
319
+ folderSets.set(id, readersFor(id));
320
+ }
321
+ const documentSets = new Map<string, Readers>();
322
+ for (const document of corpus.documents.values()) {
323
+ const readers = readersFor(document.parentId);
324
+ documentSets.set(
325
+ document.id,
326
+ document.publishedReaders === undefined
327
+ ? readers
328
+ : intersect(readers, document.publishedReaders),
329
+ );
330
+ }
331
+
332
+ const groupSets = [...folderSets.values(), ...documentSets.values()].filter(
333
+ (readers): readers is readonly string[] =>
334
+ readers !== EVERY_MEMBER && readers.length > 0,
335
+ );
336
+ const ids = classIdentifiers(groupSets);
337
+ const classes = new Map<string, AccessClass>([[MEMBERS_CLASS, membersOnly]]);
338
+ const classOf = (readers: Readers): string => {
339
+ if (readers === EVERY_MEMBER) {
340
+ return MEMBERS_CLASS;
341
+ }
342
+ if (readers.length === 0) {
343
+ classes.set(ADMINS_CLASS, { id: ADMINS_CLASS, readers: [] });
344
+ return ADMINS_CLASS;
345
+ }
346
+ const id = ids.get(readerKey(readers)) as string;
347
+ classes.set(id, { id, readers });
348
+ return id;
349
+ };
350
+
351
+ return {
352
+ enabled: true,
353
+ admins: access.admins,
354
+ folders: sortedRecord(
355
+ [...folderSets].map(([id, readers]) => [id, classOf(readers)]),
356
+ ),
357
+ documents: sortedRecord(
358
+ [...documentSets].map(([id, readers]) => [id, classOf(readers)]),
359
+ ),
360
+ classes: sortedRecord(classes),
361
+ };
362
+ }
363
+
364
+ /**
365
+ * A document's class. A document the model does not know — written after the
366
+ * manifest was read — is closed when rules exist and open when they do not.
367
+ */
368
+ export function documentClass(
369
+ model: AccessModel,
370
+ documentId: string | undefined,
371
+ ): string {
372
+ const known =
373
+ documentId === undefined ? undefined : model.documents[documentId];
374
+ return known ?? (model.enabled ? ADMINS_CLASS : MEMBERS_CLASS);
375
+ }
376
+
377
+ /** A folder's class, closed when unknown under rules, as for documents. */
378
+ export function folderClass(
379
+ model: AccessModel,
380
+ folderId: string | undefined,
381
+ ): string {
382
+ const known = folderId === undefined ? undefined : model.folders[folderId];
383
+ return known ?? (model.enabled ? ADMINS_CLASS : MEMBERS_CLASS);
384
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Who may read which folder (ADR-039).
3
+ *
4
+ * A project names its admin groups and, per Drive folder, the Google groups
5
+ * that may read it. The folder is named by its Drive ID, so a rename moves
6
+ * nothing, and the rule carries the folder's name as a label a reviewer can
7
+ * read; the label is checked against the corpus and reported when it drifts,
8
+ * never failing a sync.
9
+ *
10
+ * Group addresses are compared case-insensitively by Google, so they are
11
+ * lowercased here once and compared exactly everywhere else.
12
+ */
13
+
14
+ /** A reader value meaning every signed-in member of the organization. */
15
+ export const EVERY_MEMBER = '*';
16
+
17
+ export interface AccessRule {
18
+ /** The Drive folder the rule covers, with everything below it. */
19
+ readonly folder: string;
20
+ /** The folder's name as the rule's author knew it, for review. */
21
+ readonly label: string;
22
+ /**
23
+ * Group addresses, sorted, or `["*"]` for every member. A folder's readers
24
+ * are the groups every rule on its chain names (ADR-039).
25
+ */
26
+ readonly readers: readonly string[];
27
+ }
28
+
29
+ export interface AccessConfiguration {
30
+ /** Groups that read every folder, including folders without a rule. */
31
+ readonly admins: readonly string[];
32
+ readonly rules: readonly AccessRule[];
33
+ }
34
+
35
+ type Fail = (path: string, expectation: string) => never;
36
+
37
+ const GROUP_ADDRESS =
38
+ /^[a-z0-9](?:[a-z0-9._%+-]*[a-z0-9])?@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/u;
39
+
40
+ /** The characters Google uses in file and folder IDs. */
41
+ const FOLDER_ID = /^[A-Za-z0-9_-]{1,128}$/u;
42
+
43
+ function object(
44
+ value: unknown,
45
+ path: string,
46
+ fail: Fail,
47
+ ): Record<string, unknown> {
48
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
49
+ fail(path, 'must be an object');
50
+ }
51
+ return value as Record<string, unknown>;
52
+ }
53
+
54
+ /**
55
+ * A misspelled key would otherwise be ignored and leave a folder governed by a
56
+ * rule its author did not write, so an unknown key is an error.
57
+ */
58
+ function knownKeys(
59
+ source: Record<string, unknown>,
60
+ allowed: readonly string[],
61
+ path: string,
62
+ fail: Fail,
63
+ ): void {
64
+ for (const key of Object.keys(source)) {
65
+ if (!allowed.includes(key)) {
66
+ fail(`${path}.${key}`, 'is not a known setting');
67
+ }
68
+ }
69
+ }
70
+
71
+ function groupAddress(value: unknown, path: string, fail: Fail): string {
72
+ const address = typeof value === 'string' ? value.trim().toLowerCase() : '';
73
+ if (!GROUP_ADDRESS.test(address)) {
74
+ fail(path, 'must be a group address such as "team@example.com"');
75
+ }
76
+ return address;
77
+ }
78
+
79
+ function groupList(
80
+ value: unknown,
81
+ path: string,
82
+ fail: Fail,
83
+ allowEveryMember: boolean,
84
+ ): readonly string[] {
85
+ if (!Array.isArray(value) || value.length === 0) {
86
+ fail(path, 'must be a non-empty array of group addresses');
87
+ }
88
+ if (allowEveryMember && value.includes(EVERY_MEMBER)) {
89
+ if (value.length !== 1) {
90
+ fail(path, `must be ["${EVERY_MEMBER}"] alone, or group addresses`);
91
+ }
92
+ return Object.freeze([EVERY_MEMBER]);
93
+ }
94
+ const groups = value.map((entry, index) =>
95
+ groupAddress(entry, `${path}[${index}]`, fail),
96
+ );
97
+ if (new Set(groups).size !== groups.length) {
98
+ fail(path, 'must not repeat a group');
99
+ }
100
+ return Object.freeze([...groups].sort());
101
+ }
102
+
103
+ export function parseAccessConfiguration(
104
+ value: unknown,
105
+ fail: Fail,
106
+ ): AccessConfiguration {
107
+ const source = object(value, 'access', fail);
108
+ knownKeys(source, ['admins', 'rules'], 'access', fail);
109
+ const admins = groupList(source.admins, 'access.admins', fail, false);
110
+
111
+ if (!Array.isArray(source.rules)) {
112
+ fail('access.rules', 'must be an array');
113
+ }
114
+ const folders = new Set<string>();
115
+ const rules = source.rules.map((raw: unknown, index: number) => {
116
+ const path = `access.rules[${index}]`;
117
+ const rule = object(raw, path, fail);
118
+ knownKeys(rule, ['folder', 'label', 'readers'], path, fail);
119
+ const folder = typeof rule.folder === 'string' ? rule.folder : '';
120
+ if (!FOLDER_ID.test(folder)) {
121
+ fail(`${path}.folder`, 'must be a Drive folder ID');
122
+ }
123
+ if (folders.has(folder)) {
124
+ fail(`${path}.folder`, 'must not have a second rule');
125
+ }
126
+ folders.add(folder);
127
+ const label = typeof rule.label === 'string' ? rule.label.trim() : '';
128
+ if (label.length === 0) {
129
+ fail(`${path}.label`, "must be the folder's name");
130
+ }
131
+ return Object.freeze({
132
+ folder,
133
+ label,
134
+ readers: groupList(rule.readers, `${path}.readers`, fail, true),
135
+ });
136
+ });
137
+
138
+ return Object.freeze({ admins, rules: Object.freeze(rules) });
139
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * What a reviewer should know about the access rules (ADR-039).
3
+ *
4
+ * None of these stops a sync: the content health page and the sync job summary
5
+ * list them. A closed folder is the safe outcome of a missing rule, and a label
6
+ * that drifted is a note for the next pull request, not an outage.
7
+ */
8
+ import {
9
+ EVERY_MEMBER,
10
+ type AccessConfiguration,
11
+ } from './access-configuration.js';
12
+ import { chainReaders } from './access-classes.js';
13
+ import type { CorpusFolder, CorpusStructure } from './corpus-structure.js';
14
+
15
+ interface FolderPlace {
16
+ /** The Drive ID the finding is about. */
17
+ readonly folder: string;
18
+ /** The folder's name as the site shows it. */
19
+ readonly name: string;
20
+ /** Names of the folders above it, from the top; empty at the top. */
21
+ readonly trail: readonly string[];
22
+ }
23
+
24
+ export type AccessFinding =
25
+ | (FolderPlace & {
26
+ /** Documents in it, and in its folders without a rule, closed to all but admins. */
27
+ readonly code: 'folder-without-rule';
28
+ readonly documents: number;
29
+ })
30
+ | (FolderPlace & {
31
+ /** The rule's label no longer matches the folder's name. */
32
+ readonly code: 'rule-label-outdated';
33
+ readonly label: string;
34
+ })
35
+ | {
36
+ /** The rule names a folder the corpus does not have. */
37
+ readonly code: 'rule-folder-missing';
38
+ readonly folder: string;
39
+ readonly label: string;
40
+ }
41
+ | (FolderPlace & {
42
+ /** Groups the rule names that a rule above does not, which admit no one. */
43
+ readonly code: 'rule-group-not-admitted-above';
44
+ readonly label: string;
45
+ readonly groups: readonly string[];
46
+ });
47
+
48
+ const ORDER: Record<AccessFinding['code'], number> = {
49
+ 'folder-without-rule': 0,
50
+ 'rule-group-not-admitted-above': 1,
51
+ 'rule-folder-missing': 2,
52
+ 'rule-label-outdated': 3,
53
+ };
54
+
55
+ function comparable(value: string): string {
56
+ return value.normalize('NFC').trim();
57
+ }
58
+
59
+ /** Names from the top folder down to `folder`'s parent, root excluded. */
60
+ function trailOf(folder: CorpusFolder, corpus: CorpusStructure): string[] {
61
+ const names: string[] = [];
62
+ const seen = new Set<string>([folder.id]);
63
+ let parent = folder.parentId;
64
+ while (parent !== null && !seen.has(parent)) {
65
+ seen.add(parent);
66
+ const above = corpus.folders.get(parent);
67
+ if (!above || above.parentId === null) {
68
+ break;
69
+ }
70
+ names.unshift(above.label);
71
+ parent = above.parentId;
72
+ }
73
+ return names;
74
+ }
75
+
76
+ function placeOf(folder: CorpusFolder, corpus: CorpusStructure): FolderPlace {
77
+ return {
78
+ folder: folder.id,
79
+ name: folder.label,
80
+ trail: folder.parentId === null ? [] : trailOf(folder, corpus),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * The folder a closed document is reported under: the top-level folder that
86
+ * holds it, or the root for a document directly in it. Every folder below a
87
+ * folder without a rule is without a rule too, unless it has its own, so the
88
+ * top is the one place a rule would fix them all.
89
+ */
90
+ function reportedFolder(
91
+ folderId: string | null,
92
+ corpus: CorpusStructure,
93
+ ): string | null {
94
+ let current = folderId ?? corpus.rootFolderId;
95
+ const seen = new Set<string>();
96
+ while (current !== null && !seen.has(current)) {
97
+ seen.add(current);
98
+ const folder = corpus.folders.get(current);
99
+ if (!folder || folder.parentId === null) {
100
+ return current;
101
+ }
102
+ if (folder.parentId === corpus.rootFolderId) {
103
+ return current;
104
+ }
105
+ current = folder.parentId;
106
+ }
107
+ return current;
108
+ }
109
+
110
+ export function accessFindings(
111
+ access: AccessConfiguration,
112
+ corpus: CorpusStructure,
113
+ ): AccessFinding[] {
114
+ const rules = new Map(access.rules.map((rule) => [rule.folder, rule]));
115
+ const findings: AccessFinding[] = [];
116
+
117
+ const closed = new Map<string, number>();
118
+ for (const document of corpus.documents.values()) {
119
+ if (chainReaders(document.parentId, rules, corpus).kind === 'readers') {
120
+ continue;
121
+ }
122
+ const folder = reportedFolder(document.parentId, corpus);
123
+ if (folder !== null) {
124
+ closed.set(folder, (closed.get(folder) ?? 0) + 1);
125
+ }
126
+ }
127
+ for (const [id, documents] of closed) {
128
+ const folder = corpus.folders.get(id);
129
+ findings.push({
130
+ code: 'folder-without-rule',
131
+ documents,
132
+ ...(folder
133
+ ? placeOf(folder, corpus)
134
+ : { folder: id, name: id, trail: [] }),
135
+ });
136
+ }
137
+
138
+ for (const rule of access.rules) {
139
+ const folder = corpus.folders.get(rule.folder);
140
+ if (!folder) {
141
+ findings.push({
142
+ code: 'rule-folder-missing',
143
+ folder: rule.folder,
144
+ label: rule.label,
145
+ });
146
+ continue;
147
+ }
148
+ const label = comparable(rule.label);
149
+ if (
150
+ label !== comparable(folder.name) &&
151
+ label !== comparable(folder.label)
152
+ ) {
153
+ findings.push({
154
+ code: 'rule-label-outdated',
155
+ label: rule.label,
156
+ ...placeOf(folder, corpus),
157
+ });
158
+ }
159
+ if (rule.readers.includes(EVERY_MEMBER) || folder.parentId === null) {
160
+ continue;
161
+ }
162
+ const above = chainReaders(folder.parentId, rules, corpus);
163
+ if (above.kind !== 'readers' || above.readers === EVERY_MEMBER) {
164
+ continue;
165
+ }
166
+ const admitted = new Set(above.readers);
167
+ const groups = rule.readers.filter((group) => !admitted.has(group));
168
+ if (groups.length > 0) {
169
+ findings.push({
170
+ code: 'rule-group-not-admitted-above',
171
+ label: rule.label,
172
+ groups,
173
+ ...placeOf(folder, corpus),
174
+ });
175
+ }
176
+ }
177
+
178
+ return findings.sort(
179
+ (left, right) =>
180
+ ORDER[left.code] - ORDER[right.code] ||
181
+ (left.folder < right.folder ? -1 : left.folder > right.folder ? 1 : 0),
182
+ );
183
+ }