@goodbones/core 0.1.0-beta.6 → 0.1.0-beta.8

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 (108) hide show
  1. package/build/dts/core/campaigns.d.ts +140 -0
  2. package/build/dts/core/campaigns.d.ts.map +1 -0
  3. package/build/dts/core/ledger.d.ts +61 -0
  4. package/build/dts/core/ledger.d.ts.map +1 -0
  5. package/build/dts/core/structure.d.ts +1 -0
  6. package/build/dts/core/structure.d.ts.map +1 -1
  7. package/build/dts/domain/architecture-config.d.ts +309 -1
  8. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  9. package/build/dts/domain/facts.d.ts +1 -0
  10. package/build/dts/domain/facts.d.ts.map +1 -1
  11. package/build/dts/domain/report.d.ts +15 -0
  12. package/build/dts/domain/report.d.ts.map +1 -0
  13. package/build/dts/domain/snapshot.d.ts +55 -3
  14. package/build/dts/domain/snapshot.d.ts.map +1 -1
  15. package/build/dts/domain/violation.d.ts +1 -1
  16. package/build/dts/domain/violation.d.ts.map +1 -1
  17. package/build/dts/index.d.ts +11 -3
  18. package/build/dts/index.d.ts.map +1 -1
  19. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  20. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  21. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  22. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  23. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  24. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  25. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  26. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  27. package/build/dts/load/policy.d.ts +14 -0
  28. package/build/dts/load/policy.d.ts.map +1 -1
  29. package/build/dts/manifest/compile.d.ts +2 -1
  30. package/build/dts/manifest/compile.d.ts.map +1 -1
  31. package/build/dts/manifest/json-schema.d.ts.map +1 -1
  32. package/build/dts/manifest/manifest.d.ts +362 -0
  33. package/build/dts/manifest/manifest.d.ts.map +1 -1
  34. package/build/dts/ports/campaign-predicate.d.ts +24 -0
  35. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  36. package/build/dts/ports/language.d.ts +2 -0
  37. package/build/dts/ports/language.d.ts.map +1 -1
  38. package/build/dts/ports/report-source.d.ts +12 -0
  39. package/build/dts/ports/report-source.d.ts.map +1 -0
  40. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  41. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  42. package/build/dts/testing.d.ts +2 -0
  43. package/build/dts/testing.d.ts.map +1 -1
  44. package/build/esm/core/campaigns.js +732 -0
  45. package/build/esm/core/campaigns.js.map +1 -0
  46. package/build/esm/core/ledger.js +171 -0
  47. package/build/esm/core/ledger.js.map +1 -0
  48. package/build/esm/core/structure.js +5 -2
  49. package/build/esm/core/structure.js.map +1 -1
  50. package/build/esm/domain/architecture-config.js +133 -1
  51. package/build/esm/domain/architecture-config.js.map +1 -1
  52. package/build/esm/domain/report.js +151 -0
  53. package/build/esm/domain/report.js.map +1 -0
  54. package/build/esm/domain/snapshot.js +21 -0
  55. package/build/esm/domain/snapshot.js.map +1 -1
  56. package/build/esm/domain/violation.js.map +1 -1
  57. package/build/esm/index.js +7 -1
  58. package/build/esm/index.js.map +1 -1
  59. package/build/esm/infrastructure/campaign-functions.js +67 -0
  60. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  61. package/build/esm/infrastructure/report-source-fake.js +6 -0
  62. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  63. package/build/esm/infrastructure/report-source-live.js +57 -0
  64. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  65. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  66. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  67. package/build/esm/load/policy.js +121 -1
  68. package/build/esm/load/policy.js.map +1 -1
  69. package/build/esm/manifest/compile.js +186 -1
  70. package/build/esm/manifest/compile.js.map +1 -1
  71. package/build/esm/manifest/json-schema.js +7 -3
  72. package/build/esm/manifest/json-schema.js.map +1 -1
  73. package/build/esm/manifest/manifest.js +148 -1
  74. package/build/esm/manifest/manifest.js.map +1 -1
  75. package/build/esm/ports/campaign-predicate.js +2 -0
  76. package/build/esm/ports/campaign-predicate.js.map +1 -0
  77. package/build/esm/ports/report-source.js +2 -0
  78. package/build/esm/ports/report-source.js.map +1 -0
  79. package/build/esm/ports/syntax-matcher.js +12 -0
  80. package/build/esm/ports/syntax-matcher.js.map +1 -0
  81. package/build/esm/testing.js +2 -0
  82. package/build/esm/testing.js.map +1 -1
  83. package/package.json +1 -1
  84. package/schema/architecture-node.schema.json +843 -0
  85. package/schema/architecture.schema.json +1253 -0
  86. package/schema/conformance.schema.json +118 -3
  87. package/src/core/campaigns.ts +1006 -0
  88. package/src/core/ledger.ts +242 -0
  89. package/src/core/structure.ts +10 -5
  90. package/src/domain/architecture-config.ts +178 -1
  91. package/src/domain/facts.ts +9 -4
  92. package/src/domain/report.ts +186 -0
  93. package/src/domain/snapshot.ts +52 -0
  94. package/src/domain/violation.ts +4 -1
  95. package/src/index.ts +74 -0
  96. package/src/infrastructure/campaign-functions.ts +98 -0
  97. package/src/infrastructure/report-source-fake.ts +10 -0
  98. package/src/infrastructure/report-source-live.ts +66 -0
  99. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  100. package/src/load/policy.ts +187 -1
  101. package/src/manifest/compile.ts +221 -0
  102. package/src/manifest/json-schema.ts +7 -3
  103. package/src/manifest/manifest.ts +192 -1
  104. package/src/ports/campaign-predicate.ts +30 -0
  105. package/src/ports/language.ts +6 -0
  106. package/src/ports/report-source.ts +21 -0
  107. package/src/ports/syntax-matcher.ts +42 -0
  108. package/src/testing.ts +2 -0
@@ -0,0 +1,242 @@
1
+ import * as Result from "effect/Result";
2
+ import * as Schema from "effect/Schema";
3
+
4
+ import type { CampaignUnit } from "../domain/architecture-config.js";
5
+ import type { Violation } from "../domain/violation.js";
6
+
7
+ // A campaign's ledger: every place the pattern still occurs, and the record of
8
+ // every time the count was allowed to go up. It is the baseline's cousin and
9
+ // not the baseline — same fingerprint, different file, different semantics.
10
+ //
11
+ // - One file per campaign, so two pull requests migrating different
12
+ // components never conflict, and finishing a campaign is deleting its file.
13
+ // - Growth is only ever recorded, never silent. `prune` only removes;
14
+ // `allow` is the one way an entry is added, and it always appends a
15
+ // regression with a reason, a timestamp and an author.
16
+ // - The arithmetic is checked: `entries.length === initial + Σ delta − fixed`.
17
+ // A hand-added entry with no regression record fails the build, with no
18
+ // git history in the loop.
19
+ // - A fixed entry is stale, and `check` fails on it as it fails on a stale
20
+ // baseline entry, so progress lands as a visible diff.
21
+
22
+ const Regression = Schema.Struct({
23
+ at: Schema.String,
24
+ by: Schema.String,
25
+ delta: Schema.Finite,
26
+ reason: Schema.String,
27
+ entries: Schema.Array(Schema.String),
28
+ });
29
+
30
+ export const Ledger = Schema.Struct({
31
+ version: Schema.Literal(1),
32
+ id: Schema.String,
33
+ created: Schema.String,
34
+ // The count on the day the ledger was written.
35
+ initial: Schema.Finite,
36
+ // Entries removed by `prune` since.
37
+ fixed: Schema.Finite,
38
+ // When an entry last left the ledger — what the stall clock reads.
39
+ lastProgress: Schema.String,
40
+ regressions: Schema.Array(Regression),
41
+ // `file` or `file#subject`, deduplicated and sorted.
42
+ entries: Schema.Array(Schema.String),
43
+ });
44
+
45
+ export type Ledger = typeof Ledger.Type;
46
+ export type Regression = typeof Regression.Type;
47
+
48
+ const decode = Schema.decodeUnknownResult(Ledger, { errors: "all", onExcessProperty: "error" });
49
+
50
+ export const EMPTY_LEDGER = (id: string, now: number): Ledger => {
51
+ const at = new Date(now).toISOString();
52
+ return {
53
+ version: 1,
54
+ id,
55
+ created: at,
56
+ initial: 0,
57
+ fixed: 0,
58
+ lastProgress: at,
59
+ regressions: [],
60
+ entries: [],
61
+ };
62
+ };
63
+
64
+ // A malformed ledger is refused, unlike a malformed baseline, which reads as
65
+ // empty: an empty baseline reports every violation, the safe direction, while
66
+ // an empty ledger would report every hit as unrecorded growth and demand a
67
+ // regression record for debt that was already counted.
68
+ export const decodeLedger = (raw: unknown): Result.Result<Ledger, string> => {
69
+ const decoded = decode(raw);
70
+ return Result.isFailure(decoded)
71
+ ? Result.fail(String(decoded.failure.issue))
72
+ : Result.succeed(decoded.success);
73
+ };
74
+
75
+ export const serializeLedger = (ledger: Ledger): string => `${JSON.stringify(ledger, null, 2)}\n`;
76
+
77
+ // A hit's entry: the file, and its subject when the campaign's unit has one.
78
+ export const entryOf = (violation: Violation): string =>
79
+ violation.subject === null ? violation.file : `${violation.file}#${violation.subject}`;
80
+
81
+ const allowedTotal = (ledger: Ledger): number =>
82
+ ledger.initial + ledger.regressions.reduce((sum, one) => sum + one.delta, 0);
83
+
84
+ export const ledgerArithmeticHolds = (ledger: Ledger): boolean =>
85
+ ledger.entries.length === allowedTotal(ledger) - ledger.fixed;
86
+
87
+ // A `match` entry is `file#anchor#hash`; an edit inside the anchored
88
+ // declaration changes the hash and nothing else, and is the same entry.
89
+ const anchorOf = (entry: string): string => entry.slice(0, entry.lastIndexOf("#"));
90
+
91
+ export type Reconciliation = {
92
+ // Hits the ledger already carries, exactly or by anchor.
93
+ readonly ledgered: ReadonlyArray<Violation>;
94
+ // Hits the ledger does not carry: unrecorded growth.
95
+ readonly unrecorded: ReadonlyArray<Violation>;
96
+ // Entries no hit produces: fixed, and waiting to be pruned.
97
+ readonly stale: ReadonlyArray<string>;
98
+ // Entries whose hash moved under a still-present anchor, with what they
99
+ // read now. `prune` rewrites them; they count as neither fixed nor new.
100
+ readonly drifted: ReadonlyArray<{ readonly from: string; readonly to: string }>;
101
+ };
102
+
103
+ export const reconcile = (
104
+ ledger: Ledger,
105
+ hits: Iterable<Violation>,
106
+ unit: CampaignUnit,
107
+ ): Reconciliation => {
108
+ const entries = new Set(ledger.entries);
109
+ const ledgered: Array<Violation> = [];
110
+ const unmatched: Array<Violation> = [];
111
+ const current = new Set<string>();
112
+ for (const hit of hits) {
113
+ const entry = entryOf(hit);
114
+ current.add(entry);
115
+ if (entries.has(entry)) ledgered.push(hit);
116
+ else unmatched.push(hit);
117
+ }
118
+ let stale = ledger.entries.filter((entry) => !current.has(entry));
119
+ const drifted: Array<{ from: string; to: string }> = [];
120
+ const unrecorded: Array<Violation> = [];
121
+
122
+ if (unit === "match") {
123
+ // Pair each stale entry with an unmatched hit under the same anchor, in
124
+ // order, so a declaration with two edited matches keeps two entries.
125
+ const byAnchor = new Map<string, Array<string>>();
126
+ for (const entry of stale) {
127
+ const anchor = anchorOf(entry);
128
+ byAnchor.set(anchor, [...(byAnchor.get(anchor) ?? []), entry]);
129
+ }
130
+ const paired = new Set<string>();
131
+ for (const hit of unmatched) {
132
+ const entry = entryOf(hit);
133
+ const candidates = byAnchor.get(anchorOf(entry));
134
+ const from = candidates?.shift();
135
+ if (from === undefined) {
136
+ unrecorded.push(hit);
137
+ continue;
138
+ }
139
+ paired.add(from);
140
+ drifted.push({ from, to: entry });
141
+ ledgered.push(hit);
142
+ }
143
+ stale = stale.filter((entry) => !paired.has(entry));
144
+ } else {
145
+ for (const hit of unmatched) unrecorded.push(hit);
146
+ }
147
+
148
+ return { ledgered, unrecorded, stale, drifted };
149
+ };
150
+
151
+ export const staleEntriesOf = (
152
+ ledger: Ledger,
153
+ hits: Iterable<Violation>,
154
+ unit: CampaignUnit,
155
+ ): ReadonlyArray<string> => reconcile(ledger, hits, unit).stale;
156
+
157
+ export const newEntriesOf = (
158
+ ledger: Ledger,
159
+ hits: Iterable<Violation>,
160
+ unit: CampaignUnit,
161
+ ): ReadonlyArray<string> => reconcile(ledger, hits, unit).unrecorded.map(entryOf);
162
+
163
+ const sorted = (entries: Iterable<string>): ReadonlyArray<string> => [...new Set(entries)].sort();
164
+
165
+ // A first ledger: every hit is an entry, and the count is the `initial`.
166
+ export const ledgerOf = (id: string, hits: Iterable<Violation>, now: number): Ledger => {
167
+ const entries = sorted([...hits].map(entryOf));
168
+ return { ...EMPTY_LEDGER(id, now), initial: entries.length, entries };
169
+ };
170
+
171
+ // `prune`: the stale entries leave, `fixed` rises by as many, and
172
+ // `lastProgress` moves — only when something left. A drifted entry is
173
+ // rewritten in place and counts as neither.
174
+ export const pruned = (
175
+ ledger: Ledger,
176
+ hits: Iterable<Violation>,
177
+ unit: CampaignUnit,
178
+ now: number,
179
+ ): Ledger => {
180
+ const { drifted, stale } = reconcile(ledger, hits, unit);
181
+ const rewritten = new Map(drifted.map((one) => [one.from, one.to]));
182
+ const gone = new Set(stale);
183
+ const entries = sorted(
184
+ ledger.entries
185
+ .filter((entry) => !gone.has(entry))
186
+ .map((entry) => rewritten.get(entry) ?? entry),
187
+ );
188
+ return {
189
+ ...ledger,
190
+ fixed: ledger.fixed + stale.length,
191
+ lastProgress: stale.length > 0 ? new Date(now).toISOString() : ledger.lastProgress,
192
+ entries,
193
+ };
194
+ };
195
+
196
+ export type RegressionRecord = {
197
+ readonly at: number;
198
+ readonly by: string;
199
+ readonly reason: string;
200
+ };
201
+
202
+ // `allow`: the entries join the ledger, and a regression records that they
203
+ // did. The delta is what was actually added — an entry already present is
204
+ // not growth.
205
+ export const allowed = (
206
+ ledger: Ledger,
207
+ entries: Iterable<string>,
208
+ record: RegressionRecord,
209
+ ): Ledger => {
210
+ const present = new Set(ledger.entries);
211
+ const added = sorted([...entries].filter((entry) => !present.has(entry)));
212
+ if (added.length === 0) return ledger;
213
+ return {
214
+ ...ledger,
215
+ regressions: [
216
+ ...ledger.regressions,
217
+ {
218
+ at: new Date(record.at).toISOString(),
219
+ by: record.by,
220
+ delta: added.length,
221
+ reason: record.reason,
222
+ entries: added,
223
+ },
224
+ ],
225
+ entries: sorted([...ledger.entries, ...added]),
226
+ };
227
+ };
228
+
229
+ // `1 − count / (initial + Σ delta)`: how much of everything the campaign was
230
+ // ever asked to pay down has been paid.
231
+ export const progressOf = (ledger: Ledger): number => {
232
+ const total = allowedTotal(ledger);
233
+ return total === 0 ? 1 : 1 - ledger.entries.length / total;
234
+ };
235
+
236
+ export const isComplete = (ledger: Ledger): boolean => ledger.entries.length === 0;
237
+
238
+ export const isStalled = (
239
+ rule: { readonly staleAfter: number },
240
+ ledger: Ledger,
241
+ now: number,
242
+ ): boolean => !isComplete(ledger) && now - Date.parse(ledger.lastProgress) > rule.staleAfter;
@@ -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 }),
@@ -295,6 +295,177 @@ export const GraphConfig = Schema.Struct({
295
295
  reach: Schema.optionalKey(Schema.Array(GraphReachRule)),
296
296
  });
297
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 diagnostic on a file is a match anchored on the declaration
377
+ // at its position, keyed by its code and a hash of its message.
378
+ export const ReportFormat = Schema.Literals(["tsc", "eslint", "oxlint", "regex"]);
379
+
380
+ const ReportTerm = Schema.Struct({
381
+ command: Schema.optionalKey(Schema.String),
382
+ file: Schema.optionalKey(Schema.String),
383
+ format: ReportFormat,
384
+ // `regex` only: named groups `file`, `line`, and optionally `column`,
385
+ // `code`, `message`.
386
+ pattern: Schema.optionalKey(Schema.String),
387
+ // The codes the term speaks to; omit for every one.
388
+ codes: Schema.optionalKey(Schema.Array(Schema.String)),
389
+ codesNot: Schema.optionalKey(Schema.Array(Schema.String)),
390
+ });
391
+ export type ReportTerm = (typeof ReportTerm)["Type"];
392
+
393
+ export type Detector =
394
+ | { readonly all: ReadonlyArray<Detector> }
395
+ | { readonly any: ReadonlyArray<Detector> }
396
+ | { readonly not: Detector }
397
+ | { readonly path: PathTerm }
398
+ | { readonly imports: ImportsTerm }
399
+ | { readonly exports: ExportsTerm }
400
+ | { readonly members: MembersTerm }
401
+ | { readonly requires: ReadonlyArray<string> }
402
+ | { readonly content: ContentTerm }
403
+ | { readonly syntax: SyntaxTerm }
404
+ | { readonly report: ReportTerm }
405
+ // `module#export`, resolved by the host before the policy loads.
406
+ | { readonly fn: string };
407
+
408
+ const DetectorRef = Schema.suspend((): Schema.Codec<Detector> => Detector);
409
+
410
+ export const Detector = Schema.Union([
411
+ Schema.Struct({ all: Schema.Array(DetectorRef) }),
412
+ Schema.Struct({ any: Schema.Array(DetectorRef) }),
413
+ Schema.Struct({ not: DetectorRef }),
414
+ Schema.Struct({ path: PathTerm }),
415
+ Schema.Struct({ imports: ImportsTerm }),
416
+ Schema.Struct({ exports: ExportsTerm }),
417
+ Schema.Struct({ members: MembersTerm }),
418
+ Schema.Struct({ requires: Schema.Array(Schema.String) }),
419
+ Schema.Struct({ content: ContentTerm }),
420
+ Schema.Struct({ syntax: SyntaxTerm }),
421
+ Schema.Struct({ report: ReportTerm }),
422
+ Schema.Struct({ fn: Schema.String }),
423
+ ]);
424
+
425
+ // One diagnostic a probe stands in for, positions one-based as a tool
426
+ // prints them.
427
+ export const ProbeDiagnostic = Schema.Struct({
428
+ line: Schema.Finite,
429
+ column: Schema.optionalKey(Schema.Finite),
430
+ code: Schema.optionalKey(Schema.String),
431
+ message: Schema.optionalKey(Schema.String),
432
+ });
433
+
434
+ // A source the campaign is proven against: the path it would have, its text
435
+ // when a term needs one, the target of each of its edges (in place of the live
436
+ // resolver), the files beside it (in place of the file system) and the
437
+ // diagnostics reported on it (in place of the report source).
438
+ export const CampaignProbe = Schema.Struct({
439
+ path: Schema.String,
440
+ source: Schema.optionalKey(Schema.String),
441
+ edges: Schema.optionalKey(Schema.Record(Schema.String, ImportProbeTarget)),
442
+ files: Schema.optionalKey(Schema.Array(Schema.String)),
443
+ report: Schema.optionalKey(Schema.Array(ProbeDiagnostic)),
444
+ });
445
+
446
+ export const CampaignRule = Schema.Struct({
447
+ // `campaign/<id>`, the rule name a violation carries.
448
+ name: Schema.String,
449
+ id: Schema.String,
450
+ title: Schema.optionalKey(Schema.String),
451
+ // The `how`: what a reader at a hit does about it.
452
+ message: Schema.String,
453
+ why: Schema.String,
454
+ owner: Schema.optionalKey(Schema.String),
455
+ scope: PatternList,
456
+ unit: CampaignUnit,
457
+ detect: Detector,
458
+ probes: Schema.Struct({
459
+ fires: Schema.Array(CampaignProbe),
460
+ ignores: Schema.Array(CampaignProbe),
461
+ }),
462
+ // Milliseconds without progress after which the campaign is stalled.
463
+ staleAfter: Schema.Finite,
464
+ // What `check` demands once the count is zero: keep the campaign as a
465
+ // guard against recurrence, or remove it from the manifest.
466
+ onComplete: Schema.Literals(["keep", "remove"]),
467
+ });
468
+
298
469
  const PathProbe = Schema.Struct({ path: Schema.String });
299
470
 
300
471
  // The file taxonomy, as three questions rather than one nested tree.
@@ -387,6 +558,12 @@ export type StructureRoot = (typeof StructureRoot)["Type"];
387
558
  export type StructureFolder = (typeof StructureFolder)["Type"];
388
559
  export type StructureParity = (typeof StructureParity)["Type"];
389
560
  export type StructureNaming = (typeof StructureNaming)["Type"];
561
+ export type CampaignUnit = (typeof CampaignUnit)["Type"];
562
+ export type CampaignProbe = (typeof CampaignProbe)["Type"];
563
+ export type ProbeDiagnostic = (typeof ProbeDiagnostic)["Type"];
564
+ export type ReportFormat = (typeof ReportFormat)["Type"];
565
+ export type CampaignRule = (typeof CampaignRule)["Type"];
566
+ export type CaptureNarrowing = (typeof CaptureNarrowing)["Type"];
390
567
 
391
568
  // The file pattern an open folder's layout rule carries: it admits any name,
392
569
  // so it claims the folder without policing it. Coverage counts it apart.
@@ -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,186 @@
1
+ // A campaign's `report` term names a pattern no parser of ours sees: a type
2
+ // error, a lint finding from another tool, anything a program prints one
3
+ // line per occurrence. This is the vocabulary such a report is read into —
4
+ // a diagnostic with a file, a position, a code and a message — and the
5
+ // readers for the formats a report comes in. Pure: text in, diagnostics out.
6
+ // Running the command or reading the file is the report source's business.
7
+
8
+ export type Diagnostic = {
9
+ // Repo-relative, forward slashes.
10
+ readonly file: string;
11
+ // Zero-based, as a syntax match's range is.
12
+ readonly line: number;
13
+ readonly column: number;
14
+ // `TS2551`, `eslint(no-unused-vars)`, `no-debugger` — whatever the tool
15
+ // calls the kind of finding; `""` when the format carries none.
16
+ readonly code: string;
17
+ // The first line of the message.
18
+ readonly message: string;
19
+ };
20
+
21
+ export type ReportFormat = "tsc" | "eslint" | "oxlint" | "regex";
22
+
23
+ export type ParseReportOptions = {
24
+ // Absolute paths in the report are made relative to this.
25
+ readonly repoRoot: string;
26
+ // `regex` only: a pattern with named groups `file` and `line`, and
27
+ // optionally `column`, `code` and `message`; one diagnostic per line that
28
+ // matches, positions one-based as tools print them.
29
+ readonly pattern?: string | undefined;
30
+ };
31
+
32
+ const relativeTo = (repoRoot: string, file: string): string => {
33
+ const slashed = file.replaceAll("\\", "/");
34
+ const root = repoRoot.replaceAll("\\", "/").replace(/\/$/, "");
35
+ const relative =
36
+ slashed === root
37
+ ? ""
38
+ : slashed.startsWith(`${root}/`)
39
+ ? slashed.slice(root.length + 1)
40
+ : slashed;
41
+ return relative.replace(/^\.\//, "");
42
+ };
43
+
44
+ const oneBased = (value: string | undefined, fallback = 1): number => {
45
+ const parsed = Number(value);
46
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed - 1 : fallback - 1;
47
+ };
48
+
49
+ const firstLine = (message: string): string => message.split(/\r?\n/, 1)[0]?.trim() ?? "";
50
+
51
+ // `src/a.ts(12,5): error TS2551: Property 'x' does not exist…`, one per
52
+ // line, with the continuation lines tsc indents beneath a message ignored —
53
+ // the first line is the message.
54
+ const TSC_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
55
+
56
+ const parseTsc = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
57
+ const found: Array<Diagnostic> = [];
58
+ for (const line of text.split(/\r?\n/)) {
59
+ const match = TSC_LINE.exec(line);
60
+ if (match === null) continue;
61
+ const [, file = "", row, column, code = "", message = ""] = match;
62
+ found.push({
63
+ file: relativeTo(options.repoRoot, file),
64
+ line: oneBased(row),
65
+ column: oneBased(column),
66
+ code,
67
+ message: firstLine(message),
68
+ });
69
+ }
70
+ return found;
71
+ };
72
+
73
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
74
+ typeof value === "object" && value !== null && !Array.isArray(value);
75
+
76
+ const asString = (value: unknown): string => (typeof value === "string" ? value : "");
77
+ const asNumber = (value: unknown): number | undefined =>
78
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
79
+
80
+ const parseJson = (text: string, format: string): unknown => {
81
+ try {
82
+ return JSON.parse(text);
83
+ } catch (cause) {
84
+ throw new Error(`a ${format} report is JSON, and this one does not parse: ${String(cause)}`);
85
+ }
86
+ };
87
+
88
+ // `eslint --format json`: one object per file with its messages. A message
89
+ // with no `ruleId` (a parse error) carries the code `""`.
90
+ const parseEslint = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
91
+ const parsed = parseJson(text, "eslint");
92
+ if (!Array.isArray(parsed)) throw new Error("an eslint report is a JSON array of files");
93
+ const found: Array<Diagnostic> = [];
94
+ for (const entry of parsed) {
95
+ if (!isRecord(entry) || !Array.isArray(entry.messages)) continue;
96
+ const file = relativeTo(options.repoRoot, asString(entry.filePath));
97
+ for (const message of entry.messages) {
98
+ if (!isRecord(message)) continue;
99
+ found.push({
100
+ file,
101
+ line: (asNumber(message.line) ?? 1) - 1,
102
+ column: (asNumber(message.column) ?? 1) - 1,
103
+ code: asString(message.ruleId),
104
+ message: firstLine(asString(message.message)),
105
+ });
106
+ }
107
+ }
108
+ return found;
109
+ };
110
+
111
+ // `oxlint --format json`: `{ diagnostics: [{ filename, code, message,
112
+ // labels: [{ span: { line, column } }] }] }`, positions one-based.
113
+ const parseOxlint = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
114
+ const parsed = parseJson(text, "oxlint");
115
+ if (!isRecord(parsed) || !Array.isArray(parsed.diagnostics)) {
116
+ throw new Error("an oxlint report is a JSON object with a `diagnostics` array");
117
+ }
118
+ const found: Array<Diagnostic> = [];
119
+ for (const entry of parsed.diagnostics) {
120
+ if (!isRecord(entry)) continue;
121
+ const labels: ReadonlyArray<unknown> = Array.isArray(entry.labels) ? entry.labels : [];
122
+ const label: unknown = labels[0];
123
+ const span = isRecord(label) && isRecord(label.span) ? label.span : {};
124
+ found.push({
125
+ file: relativeTo(options.repoRoot, asString(entry.filename)),
126
+ line: (asNumber(span.line) ?? 1) - 1,
127
+ column: (asNumber(span.column) ?? 1) - 1,
128
+ code: asString(entry.code),
129
+ message: firstLine(asString(entry.message)),
130
+ });
131
+ }
132
+ return found;
133
+ };
134
+
135
+ const parseRegex = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
136
+ if (options.pattern === undefined) {
137
+ throw new Error("a `regex` report needs a `pattern` with named groups `file` and `line`");
138
+ }
139
+ const pattern = new RegExp(options.pattern);
140
+ const found: Array<Diagnostic> = [];
141
+ for (const line of text.split(/\r?\n/)) {
142
+ const match = pattern.exec(line);
143
+ if (match === null) continue;
144
+ const groups = match.groups ?? {};
145
+ if (groups.file === undefined) continue;
146
+ found.push({
147
+ file: relativeTo(options.repoRoot, groups.file),
148
+ line: oneBased(groups.line),
149
+ column: oneBased(groups.column),
150
+ code: groups.code ?? "",
151
+ message: firstLine(groups.message ?? ""),
152
+ });
153
+ }
154
+ return found;
155
+ };
156
+
157
+ export const parseReport = (
158
+ format: ReportFormat,
159
+ text: string,
160
+ options: ParseReportOptions,
161
+ ): ReadonlyArray<Diagnostic> => {
162
+ switch (format) {
163
+ case "tsc":
164
+ return parseTsc(text, options);
165
+ case "eslint":
166
+ return parseEslint(text, options);
167
+ case "oxlint":
168
+ return parseOxlint(text, options);
169
+ case "regex":
170
+ return parseRegex(text, options);
171
+ }
172
+ };
173
+
174
+ // The diagnostics indexed by file, which is how a per-file evaluator asks
175
+ // for them.
176
+ export const indexByFile = (
177
+ diagnostics: Iterable<Diagnostic>,
178
+ ): ReadonlyMap<string, ReadonlyArray<Diagnostic>> => {
179
+ const byFile = new Map<string, Array<Diagnostic>>();
180
+ for (const one of diagnostics) {
181
+ const found = byFile.get(one.file);
182
+ if (found === undefined) byFile.set(one.file, [one]);
183
+ else found.push(one);
184
+ }
185
+ return byFile;
186
+ };