@goodbones/browser 0.1.0-beta.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/index.d.ts +12 -0
  3. package/build/dts/index.d.ts.map +1 -0
  4. package/build/dts/main.d.ts +3 -0
  5. package/build/dts/main.d.ts.map +1 -0
  6. package/build/dts/model/atlas.d.ts +157 -0
  7. package/build/dts/model/atlas.d.ts.map +1 -0
  8. package/build/dts/model/campaigns.d.ts +141 -0
  9. package/build/dts/model/campaigns.d.ts.map +1 -0
  10. package/build/dts/server/collect.d.ts +16 -0
  11. package/build/dts/server/collect.d.ts.map +1 -0
  12. package/build/dts/server/compose.d.ts +13 -0
  13. package/build/dts/server/compose.d.ts.map +1 -0
  14. package/build/dts/server/export.d.ts +3 -0
  15. package/build/dts/server/export.d.ts.map +1 -0
  16. package/build/dts/server/handler.d.ts +6 -0
  17. package/build/dts/server/handler.d.ts.map +1 -0
  18. package/build/dts/server/serve.d.ts +15 -0
  19. package/build/dts/server/serve.d.ts.map +1 -0
  20. package/build/dts/server/site.d.ts +2 -0
  21. package/build/dts/server/site.d.ts.map +1 -0
  22. package/build/dts/server/source.d.ts +19 -0
  23. package/build/dts/server/source.d.ts.map +1 -0
  24. package/build/dts/server/vite-plugin.d.ts +8 -0
  25. package/build/dts/server/vite-plugin.d.ts.map +1 -0
  26. package/build/dts/server/watch.d.ts +10 -0
  27. package/build/dts/server/watch.d.ts.map +1 -0
  28. package/build/esm/index.js +14 -0
  29. package/build/esm/index.js.map +1 -0
  30. package/build/esm/main.js +134 -0
  31. package/build/esm/main.js.map +1 -0
  32. package/build/esm/model/atlas.js +431 -0
  33. package/build/esm/model/atlas.js.map +1 -0
  34. package/build/esm/model/campaigns.js +181 -0
  35. package/build/esm/model/campaigns.js.map +1 -0
  36. package/build/esm/server/collect.js +83 -0
  37. package/build/esm/server/collect.js.map +1 -0
  38. package/build/esm/server/compose.js +50 -0
  39. package/build/esm/server/compose.js.map +1 -0
  40. package/build/esm/server/export.js +19 -0
  41. package/build/esm/server/export.js.map +1 -0
  42. package/build/esm/server/handler.js +67 -0
  43. package/build/esm/server/handler.js.map +1 -0
  44. package/build/esm/server/serve.js +80 -0
  45. package/build/esm/server/serve.js.map +1 -0
  46. package/build/esm/server/site.js +6 -0
  47. package/build/esm/server/site.js.map +1 -0
  48. package/build/esm/server/source.js +67 -0
  49. package/build/esm/server/source.js.map +1 -0
  50. package/build/esm/server/vite-plugin.js +21 -0
  51. package/build/esm/server/vite-plugin.js.map +1 -0
  52. package/build/esm/server/watch.js +56 -0
  53. package/build/esm/server/watch.js.map +1 -0
  54. package/build/site/_astro/Browser.yxldcnQ3.js +3 -0
  55. package/build/site/_astro/client.Buyw3Q1S.js +9 -0
  56. package/build/site/_astro/index.DQrWs1cb.css +1 -0
  57. package/build/site/_astro/react.DJY1zw8Z.js +1 -0
  58. package/build/site/favicon.svg +1 -0
  59. package/build/site/index.html +8 -0
  60. package/package.json +78 -0
  61. package/src/app/components/Browser.tsx +153 -0
  62. package/src/app/components/architecture/ArchitectureBrowser.tsx +240 -0
  63. package/src/app/components/architecture/Detail.tsx +655 -0
  64. package/src/app/components/architecture/ManifestPane.tsx +178 -0
  65. package/src/app/components/architecture/Tree.tsx +205 -0
  66. package/src/app/components/architecture/rows.ts +181 -0
  67. package/src/app/components/architecture/selection.ts +6 -0
  68. package/src/app/components/campaigns/CampaignBrowser.tsx +146 -0
  69. package/src/app/components/campaigns/Detail.tsx +653 -0
  70. package/src/app/components/campaigns/Ladder.tsx +326 -0
  71. package/src/app/components/campaigns/pick.ts +7 -0
  72. package/src/app/components/lib/data.ts +132 -0
  73. package/src/app/pages/index.astro +34 -0
  74. package/src/app/public/favicon.svg +1 -0
  75. package/src/app/styles/theme.css +993 -0
  76. package/src/index.ts +37 -0
  77. package/src/main.ts +155 -0
  78. package/src/model/atlas.ts +751 -0
  79. package/src/model/campaigns.ts +376 -0
  80. package/src/server/collect.ts +132 -0
  81. package/src/server/compose.ts +79 -0
  82. package/src/server/export.ts +24 -0
  83. package/src/server/handler.ts +77 -0
  84. package/src/server/serve.ts +100 -0
  85. package/src/server/site.ts +6 -0
  86. package/src/server/source.ts +89 -0
  87. package/src/server/vite-plugin.ts +32 -0
  88. package/src/server/watch.ts +65 -0
@@ -0,0 +1,751 @@
1
+ import {
2
+ anchored,
3
+ type CompiledImportRule,
4
+ cyclesIn,
5
+ evaluateGraph,
6
+ evaluateMemberSite,
7
+ evaluateResolvedEdge,
8
+ evaluateSelectedBindings,
9
+ evaluateStructure,
10
+ evaluateSurface,
11
+ expandAliases,
12
+ exportRulesSelecting,
13
+ fingerprintOf,
14
+ firstFromMatch,
15
+ formatMessage,
16
+ globToRegexSource,
17
+ type Graph,
18
+ hasGraphRules,
19
+ type LoadedPolicy,
20
+ type ManifestLocator,
21
+ type ManifestNode,
22
+ type ManifestPath,
23
+ matchesAny,
24
+ memberRulesSelecting,
25
+ type ObservedEdge,
26
+ prefixed,
27
+ requiredSiblingsOf,
28
+ rulesSelecting,
29
+ slackOf,
30
+ type SourceFacts,
31
+ surfaceRulesSelecting,
32
+ type Violation,
33
+ type ViolationKind,
34
+ } from "@goodbones/core";
35
+ import * as Result from "effect/Result";
36
+
37
+ // The Architecture Browser's data: the repository as the manifest sees it.
38
+ // Every walked file with the node that governs it, every folder, every edge
39
+ // the resolver could follow with the verdict the policy gave it and the
40
+ // allowance that admitted it, every violation, and the manifest itself — its
41
+ // files' text, and each node's position in them — so a file in the tree and a
42
+ // line of `architecture.yaml` point at each other.
43
+ //
44
+ // Built from a loaded policy and the facts the host read; this module reads
45
+ // no file. The host hands in `factsOf` and the manifest's text.
46
+
47
+ export type AtlasPosition = {
48
+ // Repo-relative path of the manifest file the position is in.
49
+ readonly file: string;
50
+ // 1-based, as editors count.
51
+ readonly line: number;
52
+ readonly column: number;
53
+ };
54
+
55
+ export type AtlasAllowance = {
56
+ readonly kind: "allow" | "external";
57
+ // As authored, after `use` expansion.
58
+ readonly entry: string;
59
+ // After alias expansion; the same as `entry` for an external.
60
+ readonly expanded: string;
61
+ // The `defs` fragment it arrived through, when the node wrote `use`.
62
+ readonly fragment: string | null;
63
+ // Whether any observed edge passes through it. An entry nothing uses is
64
+ // slack: the signature of an allowlist widened to make a build green.
65
+ readonly used: boolean;
66
+ };
67
+
68
+ export type AtlasNode = {
69
+ // The slug rule names carry (`core/src/core`): the join key between a node
70
+ // and every compiled rule lowered from it.
71
+ readonly id: string;
72
+ // The key as authored (`core/`), and the path of keys down to it.
73
+ readonly key: string;
74
+ readonly path: string;
75
+ readonly parent: string | null;
76
+ readonly depth: number;
77
+ readonly folder: boolean;
78
+ readonly message: string | null;
79
+ readonly layout: "open" | "enumerated";
80
+ readonly partial: boolean;
81
+ readonly position: AtlasPosition | null;
82
+ readonly imports: {
83
+ readonly message: string | null;
84
+ readonly reset: boolean;
85
+ readonly unrestricted: boolean;
86
+ readonly allowances: ReadonlyArray<AtlasAllowance>;
87
+ readonly deny: ReadonlyArray<{
88
+ readonly match: ReadonlyArray<string>;
89
+ readonly message: string;
90
+ }>;
91
+ } | null;
92
+ readonly importedBy: {
93
+ readonly message: string;
94
+ readonly allow: ReadonlyArray<string>;
95
+ } | null;
96
+ readonly members: ReadonlyArray<{ readonly subject: string; readonly message: string }>;
97
+ readonly surface: ReadonlyArray<{ readonly message: string }>;
98
+ readonly requires: ReadonlyArray<string>;
99
+ readonly children: ReadonlyArray<string>;
100
+ // The compiled rules lowered from this node, by name.
101
+ readonly rules: ReadonlyArray<string>;
102
+ // Walked files this node is the deepest governor of.
103
+ readonly files: number;
104
+ };
105
+
106
+ export type AtlasExternal = {
107
+ readonly package: string;
108
+ readonly specifiers: ReadonlyArray<string>;
109
+ readonly status: EdgeStatus;
110
+ readonly admittedBy: { readonly node: string; readonly entry: string } | null;
111
+ };
112
+
113
+ export type AtlasFile = {
114
+ readonly path: string;
115
+ readonly name: string;
116
+ readonly folder: string;
117
+ // Under a walked root and of a language the policy loads. A file reached
118
+ // from the walk but outside it (a shared config at the repository root) is
119
+ // here for its edge to land on, and governed by nothing.
120
+ readonly walked: boolean;
121
+ // The deepest governing node, and every governing node root first.
122
+ readonly node: string | null;
123
+ readonly chain: ReadonlyArray<string>;
124
+ // Names of the import rules selecting the file — the allowlist in force,
125
+ // and the prohibitions and `importedBy` rules that speak to it.
126
+ readonly importRules: ReadonlyArray<string>;
127
+ readonly requires: ReadonlyArray<{
128
+ readonly sibling: string;
129
+ readonly present: boolean;
130
+ readonly rule: string;
131
+ }>;
132
+ readonly externals: ReadonlyArray<AtlasExternal>;
133
+ readonly builtins: ReadonlyArray<string>;
134
+ readonly violations: number;
135
+ };
136
+
137
+ export type AtlasFolder = {
138
+ readonly path: string;
139
+ readonly name: string;
140
+ readonly parent: string | null;
141
+ readonly depth: number;
142
+ // The deepest node governing files in this folder.
143
+ readonly node: string | null;
144
+ // Nodes whose own path is exactly this folder.
145
+ readonly declares: ReadonlyArray<string>;
146
+ };
147
+
148
+ export type EdgeStatus = "allowed" | "refused" | "ungoverned";
149
+
150
+ export type AtlasEdge = {
151
+ readonly from: string;
152
+ readonly to: string;
153
+ readonly specifiers: ReadonlyArray<string>;
154
+ readonly status: EdgeStatus;
155
+ readonly admittedBy: {
156
+ readonly node: string;
157
+ readonly entry: string;
158
+ readonly fragment: string | null;
159
+ } | null;
160
+ readonly refusedBy: ReadonlyArray<{ readonly rule: string; readonly message: string }>;
161
+ };
162
+
163
+ export type AtlasViolation = {
164
+ readonly fingerprint: string;
165
+ readonly kind: ViolationKind;
166
+ readonly rule: string;
167
+ readonly file: string;
168
+ readonly subject: string | null;
169
+ readonly message: string;
170
+ readonly baselined: boolean;
171
+ };
172
+
173
+ export type AtlasRule = {
174
+ readonly family: "exports" | "cycles" | "orphans" | "reach";
175
+ readonly name: string;
176
+ readonly message: string;
177
+ readonly position: AtlasPosition | null;
178
+ };
179
+
180
+ export type Atlas = {
181
+ readonly version: 1;
182
+ readonly name: string;
183
+ readonly generatedAt: string;
184
+ readonly roots: ReadonlyArray<string>;
185
+ readonly manifest: {
186
+ readonly path: string;
187
+ readonly files: ReadonlyArray<{ readonly path: string; readonly text: string }>;
188
+ readonly aliases: Readonly<Record<string, string>>;
189
+ readonly nodes: ReadonlyArray<AtlasNode>;
190
+ readonly rules: ReadonlyArray<AtlasRule>;
191
+ };
192
+ readonly files: ReadonlyArray<AtlasFile>;
193
+ readonly folders: ReadonlyArray<AtlasFolder>;
194
+ readonly edges: ReadonlyArray<AtlasEdge>;
195
+ readonly violations: ReadonlyArray<AtlasViolation>;
196
+ readonly unresolved: ReadonlyArray<{
197
+ readonly file: string;
198
+ readonly specifier: string;
199
+ readonly detail: string;
200
+ }>;
201
+ readonly cycles: number;
202
+ };
203
+
204
+ export type AtlasInput = {
205
+ readonly policy: LoadedPolicy;
206
+ readonly name: string;
207
+ readonly roots: ReadonlyArray<string>;
208
+ // The walked files of the policy's languages, repo-relative.
209
+ readonly files: ReadonlyArray<string>;
210
+ readonly factsOf: (file: string) => SourceFacts;
211
+ // Where a manifest value was written, and the files it was written in.
212
+ readonly locate: ManifestLocator | undefined;
213
+ readonly manifestPath: string;
214
+ readonly manifestFiles: ReadonlyArray<{ readonly path: string; readonly text: string }>;
215
+ readonly now: number;
216
+ };
217
+
218
+ const FOLDER_KEY = /\/$/;
219
+ const ALTERNATIVE = /\s*\|\s*/;
220
+ const WILDCARD = /[*{]/;
221
+
222
+ const stripSlash = (key: string): string => key.replace(FOLDER_KEY, "");
223
+
224
+ const alternativesOf = (key: string): ReadonlyArray<string> =>
225
+ key
226
+ .split(ALTERNATIVE)
227
+ .map(stripSlash)
228
+ .filter((one) => one !== "");
229
+
230
+ const escapeRegExp = (literal: string): string => literal.replace(/[.+^$()|[\]\\*?{}]/g, "\\$&");
231
+
232
+ const dirnameOf = (file: string): string => {
233
+ const at = file.lastIndexOf("/");
234
+ return at === -1 ? "" : file.slice(0, at);
235
+ };
236
+
237
+ const basenameOf = (file: string): string => file.slice(file.lastIndexOf("/") + 1);
238
+
239
+ // One manifest node, compiled the way lowering compiles it: the same regex
240
+ // source for its path, so the files this atlas says a node governs are the
241
+ // files its rules select.
242
+ type WalkedNode = {
243
+ readonly node: AtlasNode;
244
+ readonly matches: (file: string) => boolean;
245
+ readonly isExactly: (folder: string) => boolean;
246
+ readonly wildcards: number;
247
+ };
248
+
249
+ type Frame = {
250
+ readonly pathSource: string;
251
+ readonly captures: Readonly<Record<string, number>>;
252
+ readonly nextGroup: number;
253
+ };
254
+
255
+ const positionAt = (
256
+ locate: ManifestLocator | undefined,
257
+ manifestPath: string,
258
+ path: ManifestPath,
259
+ ): AtlasPosition | null => {
260
+ const found = locate?.(path);
261
+ if (found === null || found === undefined) return null;
262
+ const directory = dirnameOf(manifestPath);
263
+ const file =
264
+ found.file === undefined
265
+ ? manifestPath
266
+ : directory === ""
267
+ ? found.file
268
+ : `${directory}/${found.file}`;
269
+ return { file, line: found.line, column: found.column };
270
+ };
271
+
272
+ const walkTree = (
273
+ policy: LoadedPolicy,
274
+ locate: ManifestLocator | undefined,
275
+ manifestPath: string,
276
+ unusedKeys: ReadonlySet<string>,
277
+ ): ReadonlyArray<WalkedNode> => {
278
+ const aliases = policy.config.aliases ?? {};
279
+ const walked: Array<WalkedNode> = [];
280
+ const allowlistOf = (id: string): CompiledImportRule | undefined =>
281
+ policy.importRules.find((rule) => rule.name === `${id}/imports`);
282
+ const ruleNames = [
283
+ ...policy.importRules.map((rule) => rule.name),
284
+ ...policy.memberRules.map((rule) => rule.name),
285
+ ...policy.surfaceRules.map((rule) => rule.name),
286
+ ...policy.structure.roots.map((rule) => rule.name),
287
+ ...policy.structure.folders.map((rule) => rule.name),
288
+ ...policy.structure.parity.map((rule) => rule.name),
289
+ ...policy.structure.naming.map((rule) => rule.name),
290
+ ];
291
+
292
+ const walk = (
293
+ key: string,
294
+ node: ManifestNode,
295
+ parent: Frame,
296
+ id: string,
297
+ parentId: string | null,
298
+ path: string,
299
+ depth: number,
300
+ siblings: ReadonlyArray<string>,
301
+ nodePath: ManifestPath,
302
+ ): void => {
303
+ const literalSiblings = siblings
304
+ .filter((sibling) => sibling !== key)
305
+ .flatMap(alternativesOf)
306
+ .filter((sibling) => !WILDCARD.test(sibling));
307
+ const alternatives = alternativesOf(key).map((one) => expandAliases(one, aliases));
308
+ const [first = ""] = alternatives;
309
+ const compiledFirst = globToRegexSource(first, parent.captures, {
310
+ declaring: true,
311
+ nextGroup: parent.nextGroup,
312
+ });
313
+ const source =
314
+ alternatives.length === 1
315
+ ? compiledFirst.source
316
+ : `(?:${alternatives
317
+ .map(
318
+ (one) =>
319
+ globToRegexSource(one, parent.captures, {
320
+ declaring: false,
321
+ nextGroup: parent.nextGroup,
322
+ }).source,
323
+ )
324
+ .join("|")})`;
325
+ const guarded =
326
+ literalSiblings.length > 0 && WILDCARD.test(first)
327
+ ? `(?!(?:${literalSiblings.map(escapeRegExp).join("|")})(?:/|$))${source}`
328
+ : source;
329
+ const pathSource = parent.pathSource === "" ? guarded : `${parent.pathSource}/${guarded}`;
330
+ const nextGroup =
331
+ parent.nextGroup +
332
+ (Object.keys(compiledFirst.captures).length - Object.keys(parent.captures).length);
333
+ const frame: Frame = { pathSource, captures: compiledFirst.captures, nextGroup };
334
+ const isFolder = FOLDER_KEY.test(key) || node.children !== undefined;
335
+ const self = new RegExp(anchored(pathSource));
336
+ const under = new RegExp(prefixed(`${pathSource}/`));
337
+
338
+ const children = Object.entries(node.children ?? {});
339
+ const childIds = children.map(([childKey]) => `${id}/${alternativesOf(childKey)[0] ?? ""}`);
340
+
341
+ const allowlist = allowlistOf(id);
342
+ const own = (allowlist?.allowances ?? []).filter((one) => one.node === id);
343
+ const imports =
344
+ node.imports === undefined
345
+ ? null
346
+ : {
347
+ message: node.imports.message ?? null,
348
+ reset: node.imports.reset === true,
349
+ unrestricted: node.imports.unrestricted === true,
350
+ allowances: own.map((one) => ({
351
+ kind: one.kind,
352
+ entry: one.entry,
353
+ expanded: one.kind === "allow" ? expandAliases(one.entry, aliases) : one.entry,
354
+ fragment: one.fragment ?? null,
355
+ used: !unusedKeys.has(
356
+ `${one.fragment ?? one.node}\u0000${one.kind}\u0000${one.entry}`,
357
+ ),
358
+ })),
359
+ deny: (node.imports.deny ?? []).map((one) => ({
360
+ match: typeof one.match === "string" ? [one.match] : [...one.match],
361
+ message: one.message,
362
+ })),
363
+ };
364
+
365
+ walked.push({
366
+ node: {
367
+ id,
368
+ key,
369
+ path,
370
+ parent: parentId,
371
+ depth,
372
+ folder: isFolder,
373
+ message: node.message ?? null,
374
+ layout: node.layout === "open" ? "open" : "enumerated",
375
+ partial: node.partial === true,
376
+ position: positionAt(locate, manifestPath, nodePath),
377
+ imports,
378
+ importedBy:
379
+ node.importedBy === undefined
380
+ ? null
381
+ : {
382
+ message: node.importedBy.message,
383
+ allow:
384
+ typeof node.importedBy.allow === "string"
385
+ ? [node.importedBy.allow]
386
+ : [...node.importedBy.allow],
387
+ },
388
+ members: (node.members ?? []).map((one) => ({
389
+ subject: one.subject,
390
+ message: one.message,
391
+ })),
392
+ surface: (node.surface ?? []).map((one) => ({ message: one.message })),
393
+ requires: [...(node.requires ?? [])],
394
+ children: childIds,
395
+ rules: ruleNames.filter(
396
+ (name) => name.startsWith(`${id}/`) && !name.slice(id.length + 1).includes("/"),
397
+ ),
398
+ files: 0,
399
+ },
400
+ matches: isFolder ? (file) => under.test(file) : (file) => self.test(file),
401
+ isExactly: (folder) => isFolder && self.test(folder),
402
+ wildcards: (first.match(/[*{]/g) ?? []).length,
403
+ });
404
+
405
+ for (const [childKey, child] of children) {
406
+ walk(
407
+ childKey,
408
+ child,
409
+ frame,
410
+ `${id}/${alternativesOf(childKey)[0] ?? ""}`,
411
+ id,
412
+ `${path}${childKey}`,
413
+ depth + 1,
414
+ children.map(([one]) => one),
415
+ [...nodePath, "children", childKey],
416
+ );
417
+ }
418
+ };
419
+
420
+ const roots = Object.entries(policy.config.tree);
421
+ for (const [key, node] of roots) {
422
+ walk(
423
+ key,
424
+ node,
425
+ { pathSource: "", captures: {}, nextGroup: 1 },
426
+ stripSlash(key)
427
+ .replace(/[^a-zA-Z0-9]+/g, "-")
428
+ .replace(/^-|-$/g, ""),
429
+ null,
430
+ key,
431
+ 0,
432
+ roots.map(([one]) => one),
433
+ ["tree", key],
434
+ );
435
+ }
436
+ return walked;
437
+ };
438
+
439
+ // The deepest node whose path covers the file, root first. Ties at a depth
440
+ // go to the key with fewer wildcards: `src/` over `**/`.
441
+ const chainOf = (nodes: ReadonlyArray<WalkedNode>, file: string): ReadonlyArray<AtlasNode> => {
442
+ const matching = nodes.filter((one) => one.matches(file));
443
+ const byDepth = new Map<number, WalkedNode>();
444
+ for (const one of matching) {
445
+ const held = byDepth.get(one.node.depth);
446
+ if (held === undefined || one.wildcards < held.wildcards) byDepth.set(one.node.depth, one);
447
+ }
448
+ const chain = [...byDepth.values()].sort((a, b) => a.node.depth - b.node.depth);
449
+ // A node only governs through its ancestors: drop anything whose parent is
450
+ // not the previous link.
451
+ const linked: Array<AtlasNode> = [];
452
+ for (const one of chain) {
453
+ const previous = linked[linked.length - 1];
454
+ if (previous === undefined ? one.node.parent === null : one.node.parent === previous.id) {
455
+ linked.push(one.node);
456
+ }
457
+ }
458
+ return linked;
459
+ };
460
+
461
+ const admittedBy = (
462
+ allowlist: CompiledImportRule | undefined,
463
+ captures: RegExpExecArray | null,
464
+ target: { readonly kind: string; readonly path: string; readonly package?: string },
465
+ ): { node: string; entry: string; fragment: string | null } | null => {
466
+ if (allowlist === undefined || captures === null) return null;
467
+ for (const allowance of allowlist.allowances) {
468
+ if (allowance.kind === "external") {
469
+ if (target.kind === "external" && target.package === allowance.entry) {
470
+ return {
471
+ node: allowance.node,
472
+ entry: allowance.entry,
473
+ fragment: allowance.fragment ?? null,
474
+ };
475
+ }
476
+ continue;
477
+ }
478
+ if (allowance.pattern !== undefined && matchesAny([allowance.pattern], captures, target.path)) {
479
+ return { node: allowance.node, entry: allowance.entry, fragment: allowance.fragment ?? null };
480
+ }
481
+ }
482
+ return null;
483
+ };
484
+
485
+ export const buildAtlas = (input: AtlasInput): Atlas => {
486
+ const { factsOf, policy } = input;
487
+ const known = new Set(input.files);
488
+ const violations: Array<Violation> = [];
489
+ const unresolved: Array<Atlas["unresolved"][number]> = [];
490
+ const observed: Array<ObservedEdge> = [];
491
+ const edgeMap = new Map<string, AtlasEdge>();
492
+ const externalsOf = new Map<string, Array<AtlasExternal>>();
493
+ const builtinsOf = new Map<string, Array<string>>();
494
+ const importRulesOf = new Map<string, ReadonlyArray<string>>();
495
+ const requiresOf = new Map<string, AtlasFile["requires"]>();
496
+ const outside = new Set<string>();
497
+ const graphEdges = new Map<string, ReadonlyArray<string>>();
498
+
499
+ for (const file of input.files) {
500
+ const facts = factsOf(file);
501
+ const selectedImports = rulesSelecting(policy.importRules, file);
502
+ const selectedExports = exportRulesSelecting(policy.exportRules, file);
503
+ const selectedMembers = memberRulesSelecting(policy.memberRules, file);
504
+ const selectedSurface = surfaceRulesSelecting(policy.surfaceRules, file);
505
+ const allowlist = selectedImports
506
+ .map(([rule]) => rule)
507
+ .find((rule) => rule.to.length === 0 && rule.toNot.length > 0);
508
+ const captures = allowlist === undefined ? null : firstFromMatch(allowlist, file);
509
+ importRulesOf.set(
510
+ file,
511
+ selectedImports.map(([rule]) => rule.name),
512
+ );
513
+
514
+ for (const violation of evaluateStructure(policy.structure, policy.fileSystem, file)) {
515
+ violations.push(violation);
516
+ }
517
+ for (const violation of evaluateSurface(selectedSurface, file, facts.exportSites)) {
518
+ violations.push(violation);
519
+ }
520
+ for (const site of facts.memberSites) {
521
+ for (const violation of evaluateMemberSite(selectedMembers, site)) violations.push(violation);
522
+ }
523
+ requiresOf.set(
524
+ file,
525
+ policy.structure.parity
526
+ .filter(
527
+ (rule) =>
528
+ rule.file.some((pattern) => pattern.test(file)) &&
529
+ !rule.fileNot.some((pattern) => pattern.test(file)),
530
+ )
531
+ .flatMap((rule) =>
532
+ requiredSiblingsOf(rule, file).map((sibling) => ({
533
+ sibling,
534
+ present: policy.fileSystem.exists(sibling),
535
+ rule: rule.name,
536
+ })),
537
+ ),
538
+ );
539
+
540
+ const targets = new Set<string>();
541
+ for (const specifier of facts.specifiers) {
542
+ const resolved = policy.resolver.resolve(file, specifier);
543
+ if (Result.isFailure(resolved)) {
544
+ if (
545
+ policy.config.resolve.unresolved !== "off" &&
546
+ !policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))
547
+ ) {
548
+ unresolved.push({ file, specifier, detail: resolved.failure.detail });
549
+ }
550
+ continue;
551
+ }
552
+ const target = resolved.success;
553
+ const refused =
554
+ selectedImports.length === 0 ? [] : evaluateResolvedEdge(selectedImports, file, target);
555
+ for (const violation of refused) violations.push(violation);
556
+ if (selectedImports.length > 0) observed.push({ importer: file, target });
557
+ const status: EdgeStatus =
558
+ refused.length > 0 ? "refused" : allowlist === undefined ? "ungoverned" : "allowed";
559
+
560
+ const bound = facts.bindings.get(specifier) ?? [];
561
+ const exported = evaluateSelectedBindings(selectedExports, policy.resolver, {
562
+ importer: file,
563
+ specifier,
564
+ bindings: bound,
565
+ });
566
+ if (!Result.isFailure(exported)) {
567
+ for (const { violation } of exported.success) violations.push(violation);
568
+ }
569
+
570
+ if (target.kind === "builtin") {
571
+ const held = builtinsOf.get(file) ?? [];
572
+ if (!held.includes(target.path)) held.push(target.path);
573
+ builtinsOf.set(file, held);
574
+ continue;
575
+ }
576
+ if (target.kind === "external") {
577
+ const name = target.package ?? target.path;
578
+ const held = externalsOf.get(file) ?? [];
579
+ const existing = held.find((one) => one.package === name);
580
+ if (existing === undefined) {
581
+ const by = admittedBy(allowlist, captures, target);
582
+ held.push({
583
+ package: name,
584
+ specifiers: [specifier],
585
+ status,
586
+ admittedBy: by === null ? null : { node: by.node, entry: by.entry },
587
+ });
588
+ } else {
589
+ held.splice(held.indexOf(existing), 1, {
590
+ ...existing,
591
+ specifiers: [...existing.specifiers, specifier],
592
+ status: existing.status === "refused" ? "refused" : status,
593
+ });
594
+ }
595
+ externalsOf.set(file, held);
596
+ continue;
597
+ }
598
+ if (!known.has(target.path)) outside.add(target.path);
599
+ else targets.add(target.path);
600
+ const key = `${file}\u0000${target.path}`;
601
+ const held = edgeMap.get(key);
602
+ const refusedBy = refused.map((violation) => ({
603
+ rule: violation.ruleName,
604
+ message: formatMessage(violation),
605
+ }));
606
+ if (held === undefined) {
607
+ edgeMap.set(key, {
608
+ from: file,
609
+ to: target.path,
610
+ specifiers: [specifier],
611
+ status,
612
+ admittedBy: admittedBy(allowlist, captures, target),
613
+ refusedBy,
614
+ });
615
+ } else {
616
+ edgeMap.set(key, {
617
+ ...held,
618
+ specifiers: [...held.specifiers, specifier],
619
+ status: held.status === "refused" || status === "refused" ? "refused" : held.status,
620
+ refusedBy: [
621
+ ...held.refusedBy,
622
+ ...refusedBy.filter((one) => !held.refusedBy.some((two) => two.rule === one.rule)),
623
+ ],
624
+ });
625
+ }
626
+ }
627
+ graphEdges.set(file, [...targets]);
628
+ }
629
+
630
+ const graph: Graph = { files: input.files, edges: graphEdges };
631
+ if (hasGraphRules(policy.graph)) {
632
+ for (const violation of evaluateGraph(policy.graph, graph)) violations.push(violation);
633
+ }
634
+ const cycles = cyclesIn(graph).length;
635
+
636
+ const slack = slackOf(policy.importRules, observed, input.files);
637
+ const unusedKeys = new Set(
638
+ slack.slack.map((one) => `${one.fragment ?? one.node}\u0000${one.kind}\u0000${one.entry}`),
639
+ );
640
+ const nodes = walkTree(policy, input.locate, input.manifestPath, unusedKeys);
641
+ const governed = new Map<string, number>();
642
+
643
+ const violationsByFile = new Map<string, number>();
644
+ for (const violation of violations) {
645
+ violationsByFile.set(violation.file, (violationsByFile.get(violation.file) ?? 0) + 1);
646
+ }
647
+
648
+ const fileOf = (path: string, walked: boolean): AtlasFile => {
649
+ const chain = walked ? chainOf(nodes, path) : [];
650
+ const node = chain[chain.length - 1]?.id ?? null;
651
+ if (node !== null) governed.set(node, (governed.get(node) ?? 0) + 1);
652
+ return {
653
+ path,
654
+ name: basenameOf(path),
655
+ folder: dirnameOf(path),
656
+ walked,
657
+ node,
658
+ chain: chain.map((one) => one.id),
659
+ importRules: importRulesOf.get(path) ?? [],
660
+ requires: requiresOf.get(path) ?? [],
661
+ externals: externalsOf.get(path) ?? [],
662
+ builtins: builtinsOf.get(path) ?? [],
663
+ violations: violationsByFile.get(path) ?? 0,
664
+ };
665
+ };
666
+ const files = [
667
+ ...input.files.map((path) => fileOf(path, true)),
668
+ ...[...outside].sort().map((path) => fileOf(path, false)),
669
+ ].sort((a, b) => a.path.localeCompare(b.path));
670
+
671
+ const folderPaths = new Set<string>();
672
+ for (const file of files) {
673
+ let folder = file.folder;
674
+ while (folder !== "" && !folderPaths.has(folder)) {
675
+ folderPaths.add(folder);
676
+ folder = dirnameOf(folder);
677
+ }
678
+ }
679
+ const folders: ReadonlyArray<AtlasFolder> = [...folderPaths].sort().map((path) => {
680
+ const chain = chainOf(nodes, `${path}/zzprobe`);
681
+ return {
682
+ path,
683
+ name: basenameOf(path),
684
+ parent: dirnameOf(path) === "" ? null : dirnameOf(path),
685
+ depth: path.split("/").length - 1,
686
+ node: chain[chain.length - 1]?.id ?? null,
687
+ declares: nodes.filter((one) => one.isExactly(path)).map((one) => one.node.id),
688
+ };
689
+ });
690
+
691
+ const position = (path: ManifestPath) => positionAt(input.locate, input.manifestPath, path);
692
+ const rules: Array<AtlasRule> = [
693
+ ...(policy.config.exports ?? []).map((rule, index) => ({
694
+ family: "exports" as const,
695
+ name: rule.name,
696
+ message: rule.message,
697
+ position: position(["exports", index]),
698
+ })),
699
+ ...(policy.config.graph?.cycles ?? []).map((rule, index) => ({
700
+ family: "cycles" as const,
701
+ name: rule.name,
702
+ message: rule.message,
703
+ position: position(["graph", "cycles", index]),
704
+ })),
705
+ ...(policy.config.graph?.orphans ?? []).map((rule, index) => ({
706
+ family: "orphans" as const,
707
+ name: rule.name,
708
+ message: rule.message,
709
+ position: position(["graph", "orphans", index]),
710
+ })),
711
+ ...(policy.config.graph?.reach ?? []).map((rule, index) => ({
712
+ family: "reach" as const,
713
+ name: rule.name,
714
+ message: rule.message,
715
+ position: position(["graph", "reach", index]),
716
+ })),
717
+ ];
718
+
719
+ return {
720
+ version: 1,
721
+ name: input.name,
722
+ generatedAt: new Date(input.now).toISOString(),
723
+ roots: input.roots,
724
+ manifest: {
725
+ path: input.manifestPath,
726
+ files: input.manifestFiles,
727
+ aliases: policy.config.aliases ?? {},
728
+ nodes: nodes.map((one) => ({ ...one.node, files: governed.get(one.node.id) ?? 0 })),
729
+ rules,
730
+ },
731
+ files,
732
+ folders,
733
+ edges: [...edgeMap.values()].sort((a, b) => {
734
+ const byFrom = a.from.localeCompare(b.from);
735
+ return byFrom !== 0 ? byFrom : a.to.localeCompare(b.to);
736
+ }),
737
+ violations: violations
738
+ .map((violation) => ({
739
+ fingerprint: fingerprintOf(violation),
740
+ kind: violation.kind,
741
+ rule: violation.ruleName,
742
+ file: violation.file,
743
+ subject: violation.subject,
744
+ message: formatMessage(violation),
745
+ baselined: policy.baseline.isBaselined(violation),
746
+ }))
747
+ .sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)),
748
+ unresolved,
749
+ cycles,
750
+ };
751
+ };