@goodbones/core 0.1.0-beta.1 → 0.1.0-beta.10

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 (164) hide show
  1. package/build/dts/core/campaigns.d.ts +141 -0
  2. package/build/dts/core/campaigns.d.ts.map +1 -0
  3. package/build/dts/core/coverage.d.ts +20 -0
  4. package/build/dts/core/coverage.d.ts.map +1 -1
  5. package/build/dts/core/graph.d.ts +2 -0
  6. package/build/dts/core/graph.d.ts.map +1 -1
  7. package/build/dts/core/imports.d.ts +3 -1
  8. package/build/dts/core/imports.d.ts.map +1 -1
  9. package/build/dts/core/ledger.d.ts +61 -0
  10. package/build/dts/core/ledger.d.ts.map +1 -0
  11. package/build/dts/core/slack.d.ts +22 -0
  12. package/build/dts/core/slack.d.ts.map +1 -0
  13. package/build/dts/core/structure.d.ts +1 -0
  14. package/build/dts/core/structure.d.ts.map +1 -1
  15. package/build/dts/domain/architecture-config.d.ts +324 -1
  16. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  17. package/build/dts/domain/architecture-error.d.ts +8 -0
  18. package/build/dts/domain/architecture-error.d.ts.map +1 -1
  19. package/build/dts/domain/facts.d.ts +1 -0
  20. package/build/dts/domain/facts.d.ts.map +1 -1
  21. package/build/dts/domain/manifest-location.d.ts +9 -0
  22. package/build/dts/domain/manifest-location.d.ts.map +1 -0
  23. package/build/dts/domain/report.d.ts +16 -0
  24. package/build/dts/domain/report.d.ts.map +1 -0
  25. package/build/dts/domain/snapshot.d.ts +224 -0
  26. package/build/dts/domain/snapshot.d.ts.map +1 -0
  27. package/build/dts/domain/violation.d.ts +1 -1
  28. package/build/dts/domain/violation.d.ts.map +1 -1
  29. package/build/dts/index.d.ts +24 -9
  30. package/build/dts/index.d.ts.map +1 -1
  31. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  32. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  33. package/build/dts/infrastructure/manifest-file.d.ts +11 -2
  34. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
  35. package/build/dts/infrastructure/manifest-include.d.ts +16 -0
  36. package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
  37. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  38. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  39. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  40. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  41. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  42. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  43. package/build/dts/infrastructure/walk.d.ts +6 -0
  44. package/build/dts/infrastructure/walk.d.ts.map +1 -1
  45. package/build/dts/load/policy.d.ts +16 -0
  46. package/build/dts/load/policy.d.ts.map +1 -1
  47. package/build/dts/manifest/compile.d.ts +7 -2
  48. package/build/dts/manifest/compile.d.ts.map +1 -1
  49. package/build/dts/manifest/expand.d.ts +27 -0
  50. package/build/dts/manifest/expand.d.ts.map +1 -0
  51. package/build/dts/manifest/infer.d.ts +55 -0
  52. package/build/dts/manifest/infer.d.ts.map +1 -0
  53. package/build/dts/manifest/json-schema.d.ts +10 -0
  54. package/build/dts/manifest/json-schema.d.ts.map +1 -0
  55. package/build/dts/manifest/manifest.d.ts +381 -1
  56. package/build/dts/manifest/manifest.d.ts.map +1 -1
  57. package/build/dts/ports/campaign-predicate.d.ts +24 -0
  58. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  59. package/build/dts/ports/language.d.ts +4 -0
  60. package/build/dts/ports/language.d.ts.map +1 -1
  61. package/build/dts/ports/report-source.d.ts +14 -0
  62. package/build/dts/ports/report-source.d.ts.map +1 -0
  63. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  64. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  65. package/build/dts/testing.d.ts +2 -0
  66. package/build/dts/testing.d.ts.map +1 -1
  67. package/build/esm/core/campaigns.js +778 -0
  68. package/build/esm/core/campaigns.js.map +1 -0
  69. package/build/esm/core/coverage.js +107 -32
  70. package/build/esm/core/coverage.js.map +1 -1
  71. package/build/esm/core/graph.js +45 -0
  72. package/build/esm/core/graph.js.map +1 -1
  73. package/build/esm/core/imports.js +14 -9
  74. package/build/esm/core/imports.js.map +1 -1
  75. package/build/esm/core/ledger.js +171 -0
  76. package/build/esm/core/ledger.js.map +1 -0
  77. package/build/esm/core/slack.js +76 -0
  78. package/build/esm/core/slack.js.map +1 -0
  79. package/build/esm/core/structure.js +5 -2
  80. package/build/esm/core/structure.js.map +1 -1
  81. package/build/esm/domain/architecture-config.js +158 -1
  82. package/build/esm/domain/architecture-config.js.map +1 -1
  83. package/build/esm/domain/architecture-error.js +27 -0
  84. package/build/esm/domain/architecture-error.js.map +1 -1
  85. package/build/esm/domain/manifest-location.js +21 -0
  86. package/build/esm/domain/manifest-location.js.map +1 -0
  87. package/build/esm/domain/report.js +168 -0
  88. package/build/esm/domain/report.js.map +1 -0
  89. package/build/esm/domain/snapshot.js +141 -0
  90. package/build/esm/domain/snapshot.js.map +1 -0
  91. package/build/esm/domain/violation.js.map +1 -1
  92. package/build/esm/index.js +21 -8
  93. package/build/esm/index.js.map +1 -1
  94. package/build/esm/infrastructure/campaign-functions.js +67 -0
  95. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  96. package/build/esm/infrastructure/manifest-file.js +158 -7
  97. package/build/esm/infrastructure/manifest-file.js.map +1 -1
  98. package/build/esm/infrastructure/manifest-include.js +187 -0
  99. package/build/esm/infrastructure/manifest-include.js.map +1 -0
  100. package/build/esm/infrastructure/report-source-fake.js +6 -0
  101. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  102. package/build/esm/infrastructure/report-source-live.js +165 -0
  103. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  104. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  105. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  106. package/build/esm/infrastructure/walk.js +57 -1
  107. package/build/esm/infrastructure/walk.js.map +1 -1
  108. package/build/esm/load/policy.js +123 -3
  109. package/build/esm/load/policy.js.map +1 -1
  110. package/build/esm/manifest/compile.js +228 -26
  111. package/build/esm/manifest/compile.js.map +1 -1
  112. package/build/esm/manifest/expand.js +116 -0
  113. package/build/esm/manifest/expand.js.map +1 -0
  114. package/build/esm/manifest/infer.js +455 -0
  115. package/build/esm/manifest/infer.js.map +1 -0
  116. package/build/esm/manifest/json-schema.js +135 -0
  117. package/build/esm/manifest/json-schema.js.map +1 -0
  118. package/build/esm/manifest/manifest.js +243 -5
  119. package/build/esm/manifest/manifest.js.map +1 -1
  120. package/build/esm/ports/campaign-predicate.js +2 -0
  121. package/build/esm/ports/campaign-predicate.js.map +1 -0
  122. package/build/esm/ports/report-source.js +7 -0
  123. package/build/esm/ports/report-source.js.map +1 -0
  124. package/build/esm/ports/syntax-matcher.js +12 -0
  125. package/build/esm/ports/syntax-matcher.js.map +1 -0
  126. package/build/esm/testing.js +2 -0
  127. package/build/esm/testing.js.map +1 -1
  128. package/package.json +9 -3
  129. package/schema/architecture-node.schema.json +2405 -0
  130. package/schema/architecture.schema.json +2825 -0
  131. package/schema/conformance.schema.json +775 -0
  132. package/src/core/campaigns.ts +1056 -0
  133. package/src/core/coverage.ts +164 -34
  134. package/src/core/graph.ts +48 -0
  135. package/src/core/imports.ts +29 -13
  136. package/src/core/ledger.ts +242 -0
  137. package/src/core/slack.ts +135 -0
  138. package/src/core/structure.ts +10 -5
  139. package/src/domain/architecture-config.ts +205 -1
  140. package/src/domain/architecture-error.ts +30 -0
  141. package/src/domain/facts.ts +9 -4
  142. package/src/domain/manifest-location.ts +41 -0
  143. package/src/domain/report.ts +203 -0
  144. package/src/domain/snapshot.ts +302 -0
  145. package/src/domain/violation.ts +4 -1
  146. package/src/index.ts +172 -3
  147. package/src/infrastructure/campaign-functions.ts +98 -0
  148. package/src/infrastructure/manifest-file.ts +204 -8
  149. package/src/infrastructure/manifest-include.ts +318 -0
  150. package/src/infrastructure/report-source-fake.ts +10 -0
  151. package/src/infrastructure/report-source-live.ts +192 -0
  152. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  153. package/src/infrastructure/walk.ts +70 -1
  154. package/src/load/policy.ts +193 -3
  155. package/src/manifest/compile.ts +290 -28
  156. package/src/manifest/expand.ts +183 -0
  157. package/src/manifest/infer.ts +643 -0
  158. package/src/manifest/json-schema.ts +168 -0
  159. package/src/manifest/manifest.ts +339 -11
  160. package/src/ports/campaign-predicate.ts +30 -0
  161. package/src/ports/language.ts +17 -0
  162. package/src/ports/report-source.ts +35 -0
  163. package/src/ports/syntax-matcher.ts +42 -0
  164. package/src/testing.ts +2 -0
@@ -0,0 +1,135 @@
1
+ import type { Allowance } from "../domain/architecture-config.js";
2
+ import type { ResolvedTarget } from "../ports/module-resolver.js";
3
+ import { vacantNodesOf } from "./coverage.js";
4
+ import type { CompiledImportRule } from "./imports.js";
5
+ import { firstFromMatch, matchesAny } from "./patterns.js";
6
+
7
+ // Coverage says how many files an allowlist reaches. This is the other
8
+ // question about an allowlist: how much of it is used. A manifest inferred
9
+ // from today's edges reaches every file and constrains nothing, so coverage
10
+ // alone reads as complete while the policy is pure permission; slack is the
11
+ // number that tells the two apart. It is also the signature of an allowlist
12
+ // widened to make a build green — an entry nothing imports through.
13
+
14
+ // An edge the host resolved: the importer, and what the specifier became.
15
+ export type ObservedEdge = {
16
+ readonly importer: string;
17
+ readonly target: ResolvedTarget;
18
+ };
19
+
20
+ // An allowance no observed edge uses. The same shape the lowering recorded,
21
+ // minus the compiled pattern nobody wrote. When the entry arrived through
22
+ // `use`, `node` is the fragment's name — the line to delete is in `defs` —
23
+ // and `of` says how many nodes wrote the reference that carried it.
24
+ export type Slack = Pick<Allowance, "node" | "kind" | "entry"> & {
25
+ readonly fragment?: string;
26
+ readonly of?: number;
27
+ };
28
+
29
+ // A fragment entry used at some of the nodes it was granted to and not the
30
+ // rest. At the fragment level it is not slack — there is no line nobody
31
+ // needs — but it is a per-file permission written as a many-node allowance,
32
+ // which is what an allowlist widened to make one build green looks like.
33
+ export type Concentration = Pick<Allowance, "kind" | "entry"> & {
34
+ readonly fragment: string;
35
+ readonly usedAt: number;
36
+ readonly of: number;
37
+ };
38
+
39
+ export type SlackReport = {
40
+ readonly slack: ReadonlyArray<Slack>;
41
+ readonly concentration: ReadonlyArray<Concentration>;
42
+ };
43
+
44
+ const keyOf = (one: Pick<Allowance, "node" | "kind" | "entry">): string =>
45
+ `${one.node} ${one.kind} ${one.entry}`;
46
+
47
+ // Whether one edge, from a file this rule selects, passes through this entry.
48
+ const uses = (allowance: Allowance, captures: RegExpExecArray, target: ResolvedTarget): boolean => {
49
+ if (allowance.kind === "external") {
50
+ return target.kind === "external" && target.package === allowance.entry;
51
+ }
52
+ return allowance.pattern !== undefined && matchesAny([allowance.pattern], captures, target.path);
53
+ };
54
+
55
+ // One entry as one place wrote it: a node that wrote the line, or a fragment
56
+ // that N nodes pulled in with `use`. Keyed by that place, so a fragment's
57
+ // entry is reported once however many nodes reference it.
58
+ type Declared = {
59
+ readonly node: string;
60
+ readonly kind: Allowance["kind"];
61
+ readonly entry: string;
62
+ readonly fragment: string | undefined;
63
+ // The nodes that declared it, vacant ones excluded — every allowance on a
64
+ // vacant node is unused by construction, and is vacancy, not slack.
65
+ readonly at: Set<string>;
66
+ };
67
+
68
+ const groupKeyOf = (one: Allowance): string =>
69
+ one.fragment === undefined
70
+ ? `node ${one.node} ${one.kind} ${one.entry}`
71
+ : `use ${one.fragment} ${one.kind} ${one.entry}`;
72
+
73
+ // Every allowance the rules carry that no edge uses, in the order the manifest
74
+ // declared them. An entry is inherited by every descendant's rule and written
75
+ // once, so it is keyed by where it was written: an import anywhere under the
76
+ // declaring node is a use. An entry that arrived through `use` is keyed by
77
+ // the fragment, and is slack only when no node it was granted to uses it;
78
+ // used at some and not others, it is reported as concentration instead. A
79
+ // vacant node — one whose allowlist selects no walked file — contributes
80
+ // nothing to either.
81
+ export const slackOf = (
82
+ rules: ReadonlyArray<CompiledImportRule>,
83
+ edges: ReadonlyArray<ObservedEdge>,
84
+ files: ReadonlyArray<string>,
85
+ ): SlackReport => {
86
+ const vacant = vacantNodesOf(rules, files);
87
+
88
+ const declared = new Map<string, Declared>();
89
+ for (const rule of rules) {
90
+ for (const allowance of rule.allowances) {
91
+ if (vacant.has(allowance.node)) continue;
92
+ const key = groupKeyOf(allowance);
93
+ const group = declared.get(key) ?? {
94
+ node: allowance.fragment ?? allowance.node,
95
+ kind: allowance.kind,
96
+ entry: allowance.entry,
97
+ fragment: allowance.fragment,
98
+ at: new Set<string>(),
99
+ };
100
+ group.at.add(allowance.node);
101
+ declared.set(key, group);
102
+ }
103
+ }
104
+
105
+ // Which (node, kind, entry) some edge passes through.
106
+ const used = new Set<string>();
107
+ for (const edge of edges) {
108
+ for (const rule of rules) {
109
+ if (rule.allowances.length === 0) continue;
110
+ const captures = firstFromMatch(rule, edge.importer);
111
+ if (captures === null) continue;
112
+ for (const allowance of rule.allowances) {
113
+ if (uses(allowance, captures, edge.target)) used.add(keyOf(allowance));
114
+ }
115
+ }
116
+ }
117
+
118
+ const slack: Array<Slack> = [];
119
+ const concentration: Array<Concentration> = [];
120
+ for (const group of declared.values()) {
121
+ const { entry, kind } = group;
122
+ const usedAt = [...group.at].filter((node) => used.has(keyOf({ node, kind, entry }))).length;
123
+ if (group.fragment === undefined) {
124
+ if (usedAt === 0) slack.push({ node: group.node, kind, entry });
125
+ continue;
126
+ }
127
+ const of = group.at.size;
128
+ if (usedAt === 0) {
129
+ slack.push({ node: group.node, kind, entry, fragment: group.fragment, of });
130
+ } else if (usedAt < of) {
131
+ concentration.push({ fragment: group.fragment, kind, entry, usedAt, of });
132
+ }
133
+ }
134
+ return { slack, concentration };
135
+ };
@@ -191,17 +191,22 @@ const resolveSibling = (folder: string, relative: string): string => {
191
191
  return segments.join("/");
192
192
  };
193
193
 
194
- export const requiredSiblingsOf = (
195
- rule: CompiledStructureParity,
194
+ // The paths a list of `requires` templates names beside a file. Shared with
195
+ // the campaigns evaluator, whose `requires` term asks the same question.
196
+ export const siblingsOf = (
197
+ templates: ReadonlyArray<string>,
196
198
  file: string,
197
199
  ): ReadonlyArray<string> => {
198
200
  const base = baseOf(basenameOf(file));
199
201
  const folder = dirnameOf(file);
200
- return rule.requires.map((template) =>
201
- resolveSibling(folder, template.replaceAll("{base}", base)),
202
- );
202
+ return templates.map((template) => resolveSibling(folder, template.replaceAll("{base}", base)));
203
203
  };
204
204
 
205
+ export const requiredSiblingsOf = (
206
+ rule: CompiledStructureParity,
207
+ file: string,
208
+ ): ReadonlyArray<string> => siblingsOf(rule.requires, file);
209
+
205
210
  type NamingMatch = {
206
211
  readonly subject: string;
207
212
  readonly expected: string | null;
@@ -16,7 +16,7 @@ const PatternList = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
16
16
  // importer path. `to` is the target in one of three forms, and the form is its
17
17
  // dependency kind: a repo-relative resolved path is `local`, `{ external }`
18
18
  // names a third-party package, `{ builtin }` names a runtime module.
19
- const ImportProbeTarget = Schema.Union([
19
+ export const ImportProbeTarget = Schema.Union([
20
20
  Schema.String,
21
21
  Schema.Struct({ external: Schema.String }),
22
22
  Schema.Struct({ builtin: Schema.String }),
@@ -27,6 +27,28 @@ const ImportProbe = Schema.Struct({
27
27
  to: ImportProbeTarget,
28
28
  });
29
29
 
30
+ // One entry of an `imports` allowlist, as the author wrote it and where. An
31
+ // allowlist rule compiles its entries into `toNot` patterns and `externals`
32
+ // names, which is all evaluation needs; this is the other direction — from a
33
+ // pattern back to the line in the manifest that put it there — so a report
34
+ // can say which line no import uses. Slack is the signature of an allowlist
35
+ // widened to make a build green, and it is only visible with the entry kept.
36
+ export const Allowance = Schema.Struct({
37
+ // The manifest node that declared the entry, as rule names name nodes.
38
+ node: Schema.String,
39
+ // `allow` is a path glob; `external` is a package name.
40
+ kind: Schema.Literals(["allow", "external"]),
41
+ // The entry as written, after alias expansion.
42
+ entry: Schema.String,
43
+ // For an `allow`: the compiled target pattern, as `toNot` carries it.
44
+ pattern: Schema.optionalKey(Schema.String),
45
+ // The `defs` fragment the entry arrived through, when the node wrote
46
+ // `use: <name>` rather than the entry itself. Slack is attributed to the
47
+ // fragment then: the node's authors wrote one word, and the line to delete
48
+ // is in `defs`.
49
+ fragment: Schema.optionalKey(Schema.String),
50
+ });
51
+
30
52
  export const ImportRule = Schema.Struct({
31
53
  name: Schema.String,
32
54
  message: Schema.String,
@@ -35,6 +57,9 @@ export const ImportRule = Schema.Struct({
35
57
  fromNot: Schema.optionalKey(PatternList),
36
58
  to: Schema.optionalKey(PatternList),
37
59
  toNot: Schema.optionalKey(PatternList),
60
+ // Where each `toNot` pattern and `externals` name came from, for an
61
+ // allowlist lowered from a manifest. A hand-written rule carries none.
62
+ allowances: Schema.optionalKey(Schema.Array(Allowance)),
38
63
  // Third-party packages this rule permits, by package name. An external
39
64
  // target is judged by its package, never by where the language's resolver
40
65
  // happened to find it on disk — `to`/`toNot` patterns are for the
@@ -270,6 +295,178 @@ export const GraphConfig = Schema.Struct({
270
295
  reach: Schema.optionalKey(Schema.Array(GraphReachRule)),
271
296
  });
272
297
 
298
+ // A campaign: a migration tracked as an object in the repository. Where a
299
+ // rule says what may never happen, a campaign names a pattern the code is
300
+ // moving away from, ledgers every place it still occurs, and refuses to let
301
+ // the count go up unrecorded. Lowered from the manifest's `campaigns` list
302
+ // with its globs resolved; the detector below is what the evaluator compiles.
303
+
304
+ // What a hit is: a whole file, a named declaration in it, or one matched
305
+ // expression. The unit decides what a term from another level means (a
306
+ // file-level term in a declaration campaign is a filter; a declaration-level
307
+ // term in a file campaign is existential) and what the fingerprint anchors on.
308
+ export const CampaignUnit = Schema.Literals(["file", "declaration", "match"]);
309
+
310
+ // The detector's leaf terms. Every pattern is a regular-expression source,
311
+ // as everywhere else in this config; the manifest writes globs and lowering
312
+ // translates them. Each term answers at one level — `path`, `imports`,
313
+ // `requires`, `content` and a boolean `fn` about the file; `exports` and
314
+ // `members` about a declaration; `syntax` and a listing `fn` about a match.
315
+ const PathTerm = Schema.Struct({
316
+ file: PatternList,
317
+ fileNot: Schema.optionalKey(PatternList),
318
+ // With `convention`: which capture group of `file` holds the name being
319
+ // judged, and the shape it must have for the term to hold.
320
+ subject: Schema.optionalKey(Schema.Finite),
321
+ convention: Schema.optionalKey(Schema.String),
322
+ });
323
+ export type PathTerm = (typeof PathTerm)["Type"];
324
+
325
+ const ImportsTerm = Schema.Struct({
326
+ // Where an edge of the file must resolve to: a path pattern, a package
327
+ // name, or a builtin. Holds when at least one edge does.
328
+ resolves: ImportProbeTarget,
329
+ // Names that must be pulled across that edge; omit for any binding.
330
+ symbols: Schema.optionalKey(Schema.Array(Schema.String)),
331
+ });
332
+ export type ImportsTerm = (typeof ImportsTerm)["Type"];
333
+
334
+ const ExportsTerm = Schema.Struct({
335
+ name: Schema.optionalKey(PatternList),
336
+ kinds: Schema.optionalKey(Schema.Array(BindingKind)),
337
+ declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
338
+ reexport: Schema.optionalKey(Schema.Boolean),
339
+ });
340
+ export type ExportsTerm = (typeof ExportsTerm)["Type"];
341
+
342
+ const MembersTerm = Schema.Struct({
343
+ subject: MemberSubject,
344
+ name: Schema.optionalKey(PatternList),
345
+ in: Schema.optionalKey(PatternList),
346
+ declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
347
+ });
348
+ export type MembersTerm = (typeof MembersTerm)["Type"];
349
+
350
+ const ContentTerm = Schema.Struct({ regex: Schema.String });
351
+ type ContentTerm = (typeof ContentTerm)["Type"];
352
+
353
+ // How a metavariable of a syntax rule is narrowed: by the text it captured,
354
+ // or by what the identifier at its root is bound to — the module it was
355
+ // imported from and the name it was imported as.
356
+ const BindingNarrowing = Schema.Struct({
357
+ resolves: ImportProbeTarget,
358
+ member: Schema.optionalKey(Schema.Array(Schema.String)),
359
+ });
360
+
361
+ const CaptureNarrowing = Schema.Struct({
362
+ regex: Schema.optionalKey(Schema.String),
363
+ binding: Schema.optionalKey(BindingNarrowing),
364
+ });
365
+
366
+ const SyntaxTerm = Schema.Struct({
367
+ // The engine's rule object, carried opaquely: the matcher validates it.
368
+ rule: Schema.Unknown,
369
+ where: Schema.optionalKey(Schema.Record(Schema.String, CaptureNarrowing)),
370
+ });
371
+ export type SyntaxTerm = (typeof SyntaxTerm)["Type"];
372
+
373
+ // A pattern no parser of ours sees, reported by another program: a type
374
+ // error, another linter's finding. Exactly one of `command` (run from the
375
+ // repository root, once per process) and `file` (written by an earlier
376
+ // step), each a list — the manifest's one string lowered to a list of one —
377
+ // read as one report. Each diagnostic on a file is a match anchored on the
378
+ // declaration at its position, keyed by its code and a hash of its message.
379
+ export const ReportFormat = Schema.Literals(["tsc", "eslint", "oxlint", "regex"]);
380
+
381
+ const ReportTerm = Schema.Struct({
382
+ command: Schema.optionalKey(Schema.Array(Schema.String)),
383
+ file: Schema.optionalKey(Schema.Array(Schema.String)),
384
+ format: ReportFormat,
385
+ // `regex` only: named groups `file`, `line`, and optionally `column`,
386
+ // `code`, `message`.
387
+ pattern: Schema.optionalKey(Schema.String),
388
+ // The codes the term speaks to; omit for every one.
389
+ codes: Schema.optionalKey(Schema.Array(Schema.String)),
390
+ codesNot: Schema.optionalKey(Schema.Array(Schema.String)),
391
+ });
392
+ export type ReportTerm = (typeof ReportTerm)["Type"];
393
+
394
+ export type Detector =
395
+ | { readonly all: ReadonlyArray<Detector> }
396
+ | { readonly any: ReadonlyArray<Detector> }
397
+ | { readonly not: Detector }
398
+ | { readonly path: PathTerm }
399
+ | { readonly imports: ImportsTerm }
400
+ | { readonly exports: ExportsTerm }
401
+ | { readonly members: MembersTerm }
402
+ | { readonly requires: ReadonlyArray<string> }
403
+ | { readonly content: ContentTerm }
404
+ | { readonly syntax: SyntaxTerm }
405
+ | { readonly report: ReportTerm }
406
+ // `module#export`, resolved by the host before the policy loads.
407
+ | { readonly fn: string };
408
+
409
+ const DetectorRef = Schema.suspend((): Schema.Codec<Detector> => Detector);
410
+
411
+ export const Detector = Schema.Union([
412
+ Schema.Struct({ all: Schema.Array(DetectorRef) }),
413
+ Schema.Struct({ any: Schema.Array(DetectorRef) }),
414
+ Schema.Struct({ not: DetectorRef }),
415
+ Schema.Struct({ path: PathTerm }),
416
+ Schema.Struct({ imports: ImportsTerm }),
417
+ Schema.Struct({ exports: ExportsTerm }),
418
+ Schema.Struct({ members: MembersTerm }),
419
+ Schema.Struct({ requires: Schema.Array(Schema.String) }),
420
+ Schema.Struct({ content: ContentTerm }),
421
+ Schema.Struct({ syntax: SyntaxTerm }),
422
+ Schema.Struct({ report: ReportTerm }),
423
+ Schema.Struct({ fn: Schema.String }),
424
+ ]);
425
+
426
+ // One diagnostic a probe stands in for, positions one-based as a tool
427
+ // prints them.
428
+ export const ProbeDiagnostic = Schema.Struct({
429
+ line: Schema.Finite,
430
+ column: Schema.optionalKey(Schema.Finite),
431
+ code: Schema.optionalKey(Schema.String),
432
+ message: Schema.optionalKey(Schema.String),
433
+ });
434
+
435
+ // A source the campaign is proven against: the path it would have, its text
436
+ // when a term needs one, the target of each of its edges (in place of the live
437
+ // resolver), the files beside it (in place of the file system) and the
438
+ // diagnostics reported on it (in place of the report source).
439
+ export const CampaignProbe = Schema.Struct({
440
+ path: Schema.String,
441
+ source: Schema.optionalKey(Schema.String),
442
+ edges: Schema.optionalKey(Schema.Record(Schema.String, ImportProbeTarget)),
443
+ files: Schema.optionalKey(Schema.Array(Schema.String)),
444
+ report: Schema.optionalKey(Schema.Array(ProbeDiagnostic)),
445
+ });
446
+
447
+ export const CampaignRule = Schema.Struct({
448
+ // `campaign/<id>`, the rule name a violation carries.
449
+ name: Schema.String,
450
+ id: Schema.String,
451
+ title: Schema.optionalKey(Schema.String),
452
+ // The `how`: what a reader at a hit does about it.
453
+ message: Schema.String,
454
+ why: Schema.String,
455
+ owner: Schema.optionalKey(Schema.String),
456
+ scope: PatternList,
457
+ unit: CampaignUnit,
458
+ detect: Detector,
459
+ probes: Schema.Struct({
460
+ fires: Schema.Array(CampaignProbe),
461
+ ignores: Schema.Array(CampaignProbe),
462
+ }),
463
+ // Milliseconds without progress after which the campaign is stalled.
464
+ staleAfter: Schema.Finite,
465
+ // What `check` demands once the count is zero: keep the campaign as a
466
+ // guard against recurrence, or remove it from the manifest.
467
+ onComplete: Schema.Literals(["keep", "remove"]),
468
+ });
469
+
273
470
  const PathProbe = Schema.Struct({ path: Schema.String });
274
471
 
275
472
  // The file taxonomy, as three questions rather than one nested tree.
@@ -336,6 +533,7 @@ const StructureConfig = Schema.Struct({
336
533
  naming: Schema.optionalKey(Schema.Array(StructureNaming)),
337
534
  });
338
535
 
536
+ export type Allowance = (typeof Allowance)["Type"];
339
537
  export type ImportRule = (typeof ImportRule)["Type"];
340
538
  export type ResolveConfig = (typeof ResolveConfig)["Type"];
341
539
  export type ResolveScope = (typeof ResolveScope)["Type"];
@@ -361,6 +559,12 @@ export type StructureRoot = (typeof StructureRoot)["Type"];
361
559
  export type StructureFolder = (typeof StructureFolder)["Type"];
362
560
  export type StructureParity = (typeof StructureParity)["Type"];
363
561
  export type StructureNaming = (typeof StructureNaming)["Type"];
562
+ export type CampaignUnit = (typeof CampaignUnit)["Type"];
563
+ export type CampaignProbe = (typeof CampaignProbe)["Type"];
564
+ export type ProbeDiagnostic = (typeof ProbeDiagnostic)["Type"];
565
+ export type ReportFormat = (typeof ReportFormat)["Type"];
566
+ export type CampaignRule = (typeof CampaignRule)["Type"];
567
+ export type CaptureNarrowing = (typeof CaptureNarrowing)["Type"];
364
568
 
365
569
  // The file pattern an open folder's layout rule carries: it admits any name,
366
570
  // so it claims the folder without policing it. Coverage counts it apart.
@@ -57,3 +57,33 @@ export class ImportUnresolved extends Schema.TaggedErrorClass<ImportUnresolved>(
57
57
  return `${this.fromFile} imports "${this.specifier}", which does not resolve: ${this.detail}`;
58
58
  }
59
59
  }
60
+
61
+ // A `report` term whose source cannot be read: a command that could not be
62
+ // spawned, or a file that is not there. The live source raises it once per
63
+ // spec and keeps it, as it would have kept the report, so a lint over eight
64
+ // hundred files does not attempt the spawn eight hundred times; the plugin
65
+ // catches it and reports it once. The message says what to do, because a
66
+ // command is forked from whichever process asks — the CLI, or oxlint with
67
+ // the plugin loaded, which is the linter itself — and Linux's default
68
+ // overcommit heuristic refuses to fork a process holding one mapping larger
69
+ // than RAM and swap, which the linter's AST buffers become mid-lint.
70
+ export class ReportUnavailable extends Schema.TaggedErrorClass<ReportUnavailable>(
71
+ "ReportUnavailable",
72
+ )("ReportUnavailable", {
73
+ // "command" or "file", and the value as authored.
74
+ kind: Schema.Literals(["command", "file"]),
75
+ source: Schema.String,
76
+ detail: Schema.String,
77
+ }) {
78
+ override get message(): string {
79
+ return this.kind === "command"
80
+ ? `the report command \`${this.source}\` could not be run: ${this.detail}. The command ` +
81
+ `is forked from the process that asks for the report — \`architecture check\`, or ` +
82
+ `oxlint with the plugin loaded, which is the linter itself, and Linux can refuse to ` +
83
+ `fork the linter once it holds more memory than the machine could back. A report an ` +
84
+ `earlier step writes, named by \`file:\` instead of \`command:\`, is the form for CI ` +
85
+ `and the editor.`
86
+ : `the report file ${this.source} cannot be read: ${this.detail}. A \`report\` term's ` +
87
+ `\`file\` is written by an earlier step; run that first, or name a \`command\`.`;
88
+ }
89
+ }
@@ -1,15 +1,20 @@
1
1
  import type { BindingKind, DeclarationKind, MemberSubject } from "./architecture-config.js";
2
2
 
3
- // What a policy can know about one source file, read once. Both adapters
4
- // produce this vocabulary — the plugin from oxlint's syntax tree, the CLI from
5
- // TypeScript's — which is what keeps them answerable to the same core rather
6
- // than to each other. A rule is evaluated against these and nothing else.
3
+ // What a policy can know about one source file, read once. A language pack
4
+ // produces this vocabulary out of its syntax tree, and both hosts read it —
5
+ // which is what keeps them answerable to the same core rather than to each
6
+ // other. A rule is evaluated against these and nothing else.
7
7
 
8
8
  // One name pulled across one import edge: `import { makeCommandBus } from "…"`
9
9
  // is a single binding, and so is the `Effect` in `import { Effect } from "effect"`.
10
10
  export type Binding = {
11
11
  readonly symbol: string;
12
12
  readonly kind: BindingKind;
13
+ // The name the binding is known by in the importing file — `C` for
14
+ // `import { Component as C }`, `React` for a default import — which is how
15
+ // an identifier in the file's own syntax is traced back to the module it
16
+ // came from. Absent for a form that binds no name.
17
+ readonly local?: string;
13
18
  };
14
19
 
15
20
  // One declared or called name. For `members`, the declaration it is written in
@@ -0,0 +1,41 @@
1
+ // Where in the manifest file a value was written. A decode error that names a
2
+ // path is something the reader has to find; one that names a line is something
3
+ // an editor can jump to. The host that read the file knows its lines; the
4
+ // decoder knows the path — the locator is how the second asks the first.
5
+
6
+ // A path into the manifest as the decoder sees it: object keys and array
7
+ // indices, root first.
8
+ export type ManifestPath = ReadonlyArray<PropertyKey>;
9
+
10
+ export type ManifestPosition = {
11
+ // 1-based, as editors count.
12
+ readonly line: number;
13
+ readonly column: number;
14
+ // The file the position is in, when it is not the manifest itself: a
15
+ // manifest assembled from several files through `include` answers with the
16
+ // included file's path, relative to the manifest's own directory. Absent
17
+ // for a position in the manifest file.
18
+ readonly file?: string | undefined;
19
+ };
20
+
21
+ // Answers with the position of the value at `path`, or the nearest ancestor
22
+ // that exists when the path names something the file does not contain (a
23
+ // missing key), or `null` when the source has no positions to give.
24
+ export type ManifestLocator = (path: ManifestPath) => ManifestPosition | null;
25
+
26
+ const isIdentifier = (key: string): boolean => /^[A-Za-z_$][\w$]*$/.test(key);
27
+
28
+ // `tree["~/core/"].members[0].subject` — dotted where a key reads as a name,
29
+ // bracketed where it does not, so a node key that is a path pattern stays
30
+ // legible.
31
+ export const renderManifestPath = (path: ManifestPath): string =>
32
+ path.length === 0
33
+ ? "(root)"
34
+ : path
35
+ .map((segment, index) => {
36
+ if (typeof segment === "number") return `[${String(segment)}]`;
37
+ const key = String(segment);
38
+ if (isIdentifier(key)) return index === 0 ? key : `.${key}`;
39
+ return `[${JSON.stringify(key)}]`;
40
+ })
41
+ .join("");