@goodbones/campaigns 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/core/campaign-state.d.ts +38 -0
  3. package/build/dts/core/campaign-state.d.ts.map +1 -0
  4. package/build/dts/core/campaigns.d.ts +196 -0
  5. package/build/dts/core/campaigns.d.ts.map +1 -0
  6. package/build/dts/core/ledger.d.ts +173 -0
  7. package/build/dts/core/ledger.d.ts.map +1 -0
  8. package/build/dts/core/phases.d.ts +26 -0
  9. package/build/dts/core/phases.d.ts.map +1 -0
  10. package/build/dts/core/sectors.d.ts +52 -0
  11. package/build/dts/core/sectors.d.ts.map +1 -0
  12. package/build/dts/domain/config.d.ts +985 -0
  13. package/build/dts/domain/config.d.ts.map +1 -0
  14. package/build/dts/domain/report.d.ts +16 -0
  15. package/build/dts/domain/report.d.ts.map +1 -0
  16. package/build/dts/host/campaigns.d.ts +195 -0
  17. package/build/dts/host/campaigns.d.ts.map +1 -0
  18. package/build/dts/host/diff.d.ts +31 -0
  19. package/build/dts/host/diff.d.ts.map +1 -0
  20. package/build/dts/index.d.ts +17 -0
  21. package/build/dts/index.d.ts.map +1 -0
  22. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  23. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  24. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  25. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  26. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  27. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  28. package/build/dts/load/decode.d.ts +7 -0
  29. package/build/dts/load/decode.d.ts.map +1 -0
  30. package/build/dts/load/extension.d.ts +25 -0
  31. package/build/dts/load/extension.d.ts.map +1 -0
  32. package/build/dts/manifest/lower.d.ts +14 -0
  33. package/build/dts/manifest/lower.d.ts.map +1 -0
  34. package/build/dts/manifest/spec.d.ts +515 -0
  35. package/build/dts/manifest/spec.d.ts.map +1 -0
  36. package/build/dts/ports/campaign-predicate.d.ts +23 -0
  37. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  38. package/build/dts/ports/report-source.d.ts +14 -0
  39. package/build/dts/ports/report-source.d.ts.map +1 -0
  40. package/build/dts/testing.d.ts +2 -0
  41. package/build/dts/testing.d.ts.map +1 -0
  42. package/build/esm/core/campaign-state.js +140 -0
  43. package/build/esm/core/campaign-state.js.map +1 -0
  44. package/build/esm/core/campaigns.js +954 -0
  45. package/build/esm/core/campaigns.js.map +1 -0
  46. package/build/esm/core/ledger.js +519 -0
  47. package/build/esm/core/ledger.js.map +1 -0
  48. package/build/esm/core/phases.js +87 -0
  49. package/build/esm/core/phases.js.map +1 -0
  50. package/build/esm/core/sectors.js +313 -0
  51. package/build/esm/core/sectors.js.map +1 -0
  52. package/build/esm/domain/config.js +245 -0
  53. package/build/esm/domain/config.js.map +1 -0
  54. package/build/esm/domain/report.js +168 -0
  55. package/build/esm/domain/report.js.map +1 -0
  56. package/build/esm/host/campaigns.js +1159 -0
  57. package/build/esm/host/campaigns.js.map +1 -0
  58. package/build/esm/host/diff.js +156 -0
  59. package/build/esm/host/diff.js.map +1 -0
  60. package/build/esm/index.js +27 -0
  61. package/build/esm/index.js.map +1 -0
  62. package/build/esm/infrastructure/campaign-functions.js +67 -0
  63. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  64. package/build/esm/infrastructure/report-source-fake.js +6 -0
  65. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  66. package/build/esm/infrastructure/report-source-live.js +165 -0
  67. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  68. package/build/esm/load/decode.js +60 -0
  69. package/build/esm/load/decode.js.map +1 -0
  70. package/build/esm/load/extension.js +326 -0
  71. package/build/esm/load/extension.js.map +1 -0
  72. package/build/esm/manifest/lower.js +488 -0
  73. package/build/esm/manifest/lower.js.map +1 -0
  74. package/build/esm/manifest/spec.js +296 -0
  75. package/build/esm/manifest/spec.js.map +1 -0
  76. package/build/esm/ports/campaign-predicate.js +2 -0
  77. package/build/esm/ports/campaign-predicate.js.map +1 -0
  78. package/build/esm/ports/report-source.js +7 -0
  79. package/build/esm/ports/report-source.js.map +1 -0
  80. package/build/esm/testing.js +5 -0
  81. package/build/esm/testing.js.map +1 -0
  82. package/package.json +65 -0
  83. package/src/core/campaign-state.ts +227 -0
  84. package/src/core/campaigns.ts +1319 -0
  85. package/src/core/ledger.ts +675 -0
  86. package/src/core/phases.ts +138 -0
  87. package/src/core/sectors.ts +399 -0
  88. package/src/domain/config.ts +314 -0
  89. package/src/domain/report.ts +203 -0
  90. package/src/host/campaigns.ts +1664 -0
  91. package/src/host/diff.ts +203 -0
  92. package/src/index.ts +256 -0
  93. package/src/infrastructure/campaign-functions.ts +99 -0
  94. package/src/infrastructure/report-source-fake.ts +10 -0
  95. package/src/infrastructure/report-source-live.ts +193 -0
  96. package/src/load/decode.ts +81 -0
  97. package/src/load/extension.ts +466 -0
  98. package/src/manifest/lower.ts +615 -0
  99. package/src/manifest/spec.ts +364 -0
  100. package/src/ports/campaign-predicate.ts +29 -0
  101. package/src/ports/report-source.ts +35 -0
  102. package/src/testing.ts +4 -0
@@ -0,0 +1,314 @@
1
+ import {
2
+ BindingKind,
3
+ DeclarationKind,
4
+ ImportProbeTarget,
5
+ MemberSubject,
6
+ PatternList,
7
+ } from "@goodbones/core";
8
+ import * as Schema from "effect/Schema";
9
+
10
+ // The lowered vocabulary of the campaigns family — what the manifest's
11
+ // `campaigns` map becomes once its globs are resolved, and what the
12
+ // evaluators in `core/` compile. It reaches into @goodbones/core only for
13
+ // the pattern primitives the other families already speak: an import's
14
+ // probe target, a binding kind, a declaration kind, a member subject.
15
+
16
+ // A campaign: one multi-step refactor, tracked as an object in the
17
+ // repository. Where a rule says what may never happen, a campaign names what
18
+ // the code is moving away from — as `objectives`, each a detector with a
19
+ // ledger that only shrinks on its own — over `sectors` the code declares
20
+ // through a `perimeter`, through an ordered sequence of `phases` toward an
21
+ // end. Lowered from the manifest's `campaigns` map with its globs resolved;
22
+ // the detector below is what the evaluator compiles.
23
+
24
+ // What one holdout is: a whole file, a named declaration in it, one matched
25
+ // expression, or the sector itself (for a presence objective — the sector is
26
+ // the holdout while nothing in it matches). The first three are the unit a
27
+ // per-file detector answers at; the unit decides what a term from another
28
+ // level means (a file-level term in a declaration objective is a filter; a
29
+ // declaration-level term in a file objective is existential) and what the
30
+ // fingerprint anchors on.
31
+ export const CampaignUnit = Schema.Literals(["file", "declaration", "match"]);
32
+ export const Holdout = Schema.Literals(["file", "declaration", "match", "sector"]);
33
+
34
+ // The detector's leaf terms. Every pattern is a regular-expression source,
35
+ // as everywhere else in this config; the manifest writes globs and lowering
36
+ // translates them. Each term answers at one level — `path`, `imports`,
37
+ // `requires`, `content` and a boolean `fn` about the file; `exports` and
38
+ // `members` about a declaration; `syntax` and a listing `fn` about a match.
39
+ const PathTerm = Schema.Struct({
40
+ file: PatternList,
41
+ fileNot: Schema.optionalKey(PatternList),
42
+ // With `convention`: which capture group of `file` holds the name being
43
+ // judged, and the shape it must have for the term to hold.
44
+ subject: Schema.optionalKey(Schema.Finite),
45
+ convention: Schema.optionalKey(Schema.String),
46
+ });
47
+ export type PathTerm = (typeof PathTerm)["Type"];
48
+
49
+ const ImportsTerm = Schema.Struct({
50
+ // Where an edge of the file must resolve to: a path pattern, a package
51
+ // name, or a builtin. Holds when at least one edge does.
52
+ resolves: ImportProbeTarget,
53
+ // Names that must be pulled across that edge; omit for any binding.
54
+ symbols: Schema.optionalKey(Schema.Array(Schema.String)),
55
+ });
56
+ export type ImportsTerm = (typeof ImportsTerm)["Type"];
57
+
58
+ const ExportsTerm = Schema.Struct({
59
+ name: Schema.optionalKey(PatternList),
60
+ kinds: Schema.optionalKey(Schema.Array(BindingKind)),
61
+ declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
62
+ reexport: Schema.optionalKey(Schema.Boolean),
63
+ });
64
+ export type ExportsTerm = (typeof ExportsTerm)["Type"];
65
+
66
+ const MembersTerm = Schema.Struct({
67
+ subject: MemberSubject,
68
+ name: Schema.optionalKey(PatternList),
69
+ in: Schema.optionalKey(PatternList),
70
+ declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
71
+ });
72
+ export type MembersTerm = (typeof MembersTerm)["Type"];
73
+
74
+ const ContentTerm = Schema.Struct({ regex: Schema.String });
75
+ type ContentTerm = (typeof ContentTerm)["Type"];
76
+
77
+ // How a metavariable of a syntax rule is narrowed: by the text it captured,
78
+ // or by what the identifier at its root is bound to — the module it was
79
+ // imported from and the name it was imported as.
80
+ const BindingNarrowing = Schema.Struct({
81
+ resolves: ImportProbeTarget,
82
+ member: Schema.optionalKey(Schema.Array(Schema.String)),
83
+ });
84
+
85
+ const CaptureNarrowing = Schema.Struct({
86
+ regex: Schema.optionalKey(Schema.String),
87
+ binding: Schema.optionalKey(BindingNarrowing),
88
+ });
89
+
90
+ const SyntaxTerm = Schema.Struct({
91
+ // The engine's rule object, carried opaquely: the matcher validates it.
92
+ rule: Schema.Unknown,
93
+ where: Schema.optionalKey(Schema.Record(Schema.String, CaptureNarrowing)),
94
+ });
95
+ export type SyntaxTerm = (typeof SyntaxTerm)["Type"];
96
+
97
+ // A pattern no parser of ours sees, reported by another program: a type
98
+ // error, another linter's finding. Exactly one of `command` (run from the
99
+ // repository root, once per process) and `file` (written by an earlier
100
+ // step), each a list — the manifest's one string lowered to a list of one —
101
+ // read as one report. Each diagnostic on a file is a match anchored on the
102
+ // declaration at its position, keyed by its code and a hash of its message.
103
+ export const ReportFormat = Schema.Literals(["tsc", "eslint", "oxlint", "regex"]);
104
+
105
+ const ReportTerm = Schema.Struct({
106
+ command: Schema.optionalKey(Schema.Array(Schema.String)),
107
+ file: Schema.optionalKey(Schema.Array(Schema.String)),
108
+ format: ReportFormat,
109
+ // `regex` only: named groups `file`, `line`, and optionally `column`,
110
+ // `code`, `message`.
111
+ pattern: Schema.optionalKey(Schema.String),
112
+ // The codes the term speaks to; omit for every one.
113
+ codes: Schema.optionalKey(Schema.Array(Schema.String)),
114
+ codesNot: Schema.optionalKey(Schema.Array(Schema.String)),
115
+ });
116
+ export type ReportTerm = (typeof ReportTerm)["Type"];
117
+
118
+ export type Detector =
119
+ | { readonly all: ReadonlyArray<Detector> }
120
+ | { readonly any: ReadonlyArray<Detector> }
121
+ | { readonly not: Detector }
122
+ | { readonly path: PathTerm }
123
+ | { readonly imports: ImportsTerm }
124
+ | { readonly exports: ExportsTerm }
125
+ | { readonly members: MembersTerm }
126
+ | { readonly requires: ReadonlyArray<string> }
127
+ | { readonly content: ContentTerm }
128
+ | { readonly syntax: SyntaxTerm }
129
+ | { readonly report: ReportTerm }
130
+ // `module#export`, resolved by the host before the policy loads.
131
+ | { readonly fn: string };
132
+
133
+ const DetectorRef = Schema.suspend((): Schema.Codec<Detector> => Detector);
134
+
135
+ export const Detector = Schema.Union([
136
+ Schema.Struct({ all: Schema.Array(DetectorRef) }),
137
+ Schema.Struct({ any: Schema.Array(DetectorRef) }),
138
+ Schema.Struct({ not: DetectorRef }),
139
+ Schema.Struct({ path: PathTerm }),
140
+ Schema.Struct({ imports: ImportsTerm }),
141
+ Schema.Struct({ exports: ExportsTerm }),
142
+ Schema.Struct({ members: MembersTerm }),
143
+ Schema.Struct({ requires: Schema.Array(Schema.String) }),
144
+ Schema.Struct({ content: ContentTerm }),
145
+ Schema.Struct({ syntax: SyntaxTerm }),
146
+ Schema.Struct({ report: ReportTerm }),
147
+ Schema.Struct({ fn: Schema.String }),
148
+ ]);
149
+
150
+ // A term quantified over the sector's files rather than answered by one.
151
+ // `has` takes an ordinary detector and holds when at least one file (or
152
+ // declaration, or match) in the sector satisfies it — a presence, whose
153
+ // holdout is the sector until something matches. `oneRoot` holds when every
154
+ // file of the sector sits under the root its perimeter was found at, which
155
+ // is what an `endState` needs; `oneHost` when every file matches the host.
156
+ // Each needs the sector's file list and nothing else, so the CLI reports
157
+ // them and the plugin, which sees one file, reads only their effect on the
158
+ // sector's phase through the ledger.
159
+ export const SectorTerm = Schema.Union([
160
+ Schema.Struct({ has: Detector }),
161
+ Schema.Struct({ oneRoot: Schema.Literal(true) }),
162
+ Schema.Struct({ oneHost: PatternList }),
163
+ ]);
164
+ export type SectorTerm = (typeof SectorTerm)["Type"];
165
+
166
+ // One diagnostic a probe stands in for, positions one-based as a tool
167
+ // prints them.
168
+ export const ProbeDiagnostic = Schema.Struct({
169
+ line: Schema.Finite,
170
+ column: Schema.optionalKey(Schema.Finite),
171
+ code: Schema.optionalKey(Schema.String),
172
+ message: Schema.optionalKey(Schema.String),
173
+ });
174
+
175
+ // A source an objective is proven against: the path it would have, its text
176
+ // when a term needs one, the target of each of its edges (in place of the live
177
+ // resolver), the files beside it (in place of the file system) and the
178
+ // diagnostics reported on it (in place of the report source).
179
+ export const CampaignProbe = Schema.Struct({
180
+ path: Schema.String,
181
+ source: Schema.optionalKey(Schema.String),
182
+ edges: Schema.optionalKey(Schema.Record(Schema.String, ImportProbeTarget)),
183
+ files: Schema.optionalKey(Schema.Array(Schema.String)),
184
+ report: Schema.optionalKey(Schema.Array(ProbeDiagnostic)),
185
+ });
186
+
187
+ export const CampaignProbes = Schema.Struct({
188
+ fires: Schema.Array(CampaignProbe),
189
+ ignores: Schema.Array(CampaignProbe),
190
+ });
191
+
192
+ // An objective: a detector, a granularity, probes, and a ledger that only
193
+ // shrinks on its own. Owned by one campaign; named by at most one phase (an
194
+ // objective no phase names is in window in every phase). Exactly one of
195
+ // `match` (a per-file detector, evaluated by both hosts) and `sector` (a
196
+ // term over the sector's files, evaluated by the CLI) — or, for the
197
+ // objectives an `endState` expands into, `endState` naming the family.
198
+ export const ObjectiveRule = Schema.Struct({
199
+ // `campaign/<campaign>/<objective>`, the rule name a violation carries.
200
+ name: Schema.String,
201
+ id: Schema.String,
202
+ campaign: Schema.String,
203
+ // The `how`: what a reader at a holdout does about it.
204
+ message: Schema.String,
205
+ why: Schema.optionalKey(Schema.String),
206
+ holdout: Holdout,
207
+ match: Schema.optionalKey(Detector),
208
+ sector: Schema.optionalKey(SectorTerm),
209
+ endState: Schema.optionalKey(
210
+ Schema.Struct({
211
+ phase: Schema.String,
212
+ family: Schema.Literals(["imports", "exports", "members", "surface", "structure"]),
213
+ }),
214
+ ),
215
+ // The phase at which the objective stops counting, exclusive; absent, it
216
+ // counts to the end.
217
+ until: Schema.optionalKey(Schema.String),
218
+ probes: CampaignProbes,
219
+ });
220
+
221
+ // How a sector is recognized. The name is the sector's identity. A `marker`
222
+ // is a file that names the sector (and may list the globs it owns); `glob`
223
+ // is one sector per match; `match` one sector per detector match, keyed by
224
+ // its anchor; `file` one sector per file, keyed by the path without its
225
+ // extension; `nx` reads the workspace's projects. A campaign with none has
226
+ // one implicit sector, the scope.
227
+ export const PerimeterRule = Schema.Union([
228
+ Schema.Struct({ kind: Schema.Literal("file") }),
229
+ Schema.Struct({ kind: Schema.Literal("nx") }),
230
+ Schema.Struct({ kind: Schema.Literal("glob"), glob: PatternList }),
231
+ Schema.Struct({
232
+ kind: Schema.Literal("marker"),
233
+ marker: PatternList,
234
+ probes: Schema.optionalKey(Schema.Struct({ fires: PatternList, ignores: PatternList })),
235
+ }),
236
+ Schema.Struct({
237
+ kind: Schema.Literal("match"),
238
+ match: Detector,
239
+ unit: Schema.Literals(["declaration", "match"]),
240
+ probes: CampaignProbes,
241
+ }),
242
+ ]);
243
+ export type PerimeterRule = (typeof PerimeterRule)["Type"];
244
+
245
+ // What a touched sector owes beyond the nudge. A strict ladder: the ledger
246
+ // records growth with a reason under `advise`; `ratchet` refuses it in a
247
+ // touched sector; `paydown` adds that a diff editing a holdout-bearing
248
+ // declaration must leave the sector with fewer holdouts.
249
+ export const OnTouch = Schema.Literals(["advise", "ratchet", "paydown"]);
250
+ export type OnTouch = (typeof OnTouch)["Type"];
251
+
252
+ // A dated receipt for a change to a defined phase, which authorizes the next
253
+ // `clear` to re-baseline the sectors in that phase's window.
254
+ export const PhaseConcession = Schema.Struct({
255
+ reason: Schema.String,
256
+ at: Schema.String,
257
+ by: Schema.optionalKey(Schema.String),
258
+ });
259
+
260
+ // A phase: a named, ordered group of objectives. Defined when it names one
261
+ // (or is attested, or carries an end state); open when it has only an
262
+ // intent, and then it is last. `hash` is the phase's definition — its
263
+ // position, its objectives and their detectors — so a change to a defined
264
+ // phase is visible against the plan the ledger recorded.
265
+ export const PhaseRule = Schema.Struct({
266
+ id: Schema.String,
267
+ intent: Schema.optionalKey(Schema.String),
268
+ objectives: Schema.Array(Schema.String),
269
+ attested: Schema.Boolean,
270
+ onTouch: Schema.optionalKey(OnTouch),
271
+ // The sector-relative node tree, carried as written; expanded per sector
272
+ // by the host that evaluates it.
273
+ endState: Schema.optionalKey(Schema.Unknown),
274
+ concessions: Schema.Array(PhaseConcession),
275
+ hash: Schema.String,
276
+ });
277
+ export type PhaseRule = (typeof PhaseRule)["Type"];
278
+
279
+ export const CampaignRule = Schema.Struct({
280
+ // `campaign/<id>`.
281
+ name: Schema.String,
282
+ id: Schema.String,
283
+ title: Schema.optionalKey(Schema.String),
284
+ why: Schema.optionalKey(Schema.String),
285
+ owner: Schema.optionalKey(Schema.String),
286
+ scope: PatternList,
287
+ // Extensions the campaign widens the walk to, beyond the packs' — a
288
+ // JavaScript half a TypeScript pack does not visit.
289
+ extensions: Schema.Array(Schema.String),
290
+ // What the remainder of the scope counts as; absent, everything no sector
291
+ // claims is legacy.
292
+ legacy: Schema.optionalKey(PatternList),
293
+ perimeter: Schema.optionalKey(PerimeterRule),
294
+ onTouch: Schema.optionalKey(OnTouch),
295
+ phases: Schema.Array(PhaseRule),
296
+ objectives: Schema.Array(ObjectiveRule),
297
+ // Milliseconds without progress after which the campaign is stalled;
298
+ // absent, it never is.
299
+ staleAfter: Schema.optionalKey(Schema.Finite),
300
+ // What `check` demands once every count is zero: keep the campaign as a
301
+ // guard against recurrence, or remove it from the manifest.
302
+ onComplete: Schema.Literals(["keep", "remove"]),
303
+ });
304
+
305
+ export type CampaignUnit = (typeof CampaignUnit)["Type"];
306
+ export type Holdout = (typeof Holdout)["Type"];
307
+ export type CampaignProbe = (typeof CampaignProbe)["Type"];
308
+ export type CampaignProbes = (typeof CampaignProbes)["Type"];
309
+ export type ProbeDiagnostic = (typeof ProbeDiagnostic)["Type"];
310
+ export type ReportFormat = (typeof ReportFormat)["Type"];
311
+ export type ObjectiveRule = (typeof ObjectiveRule)["Type"];
312
+ export type PhaseConcession = (typeof PhaseConcession)["Type"];
313
+ export type CampaignRule = (typeof CampaignRule)["Type"];
314
+ export type CaptureNarrowing = (typeof CaptureNarrowing)["Type"];
@@ -0,0 +1,203 @@
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
+ // One report from several outputs. A tool that takes one project at a time
175
+ // is run once per project, and a project's program includes the files of
176
+ // the projects it references, so one diagnostic is printed under several
177
+ // runs; it is one diagnostic. Kept once, at its first appearance, when
178
+ // identical in file, position, code and message.
179
+ export const uniqueDiagnostics = (diagnostics: Iterable<Diagnostic>): ReadonlyArray<Diagnostic> => {
180
+ const seen = new Set<string>();
181
+ const kept: Array<Diagnostic> = [];
182
+ for (const one of diagnostics) {
183
+ const key = JSON.stringify([one.file, one.line, one.column, one.code, one.message]);
184
+ if (seen.has(key)) continue;
185
+ seen.add(key);
186
+ kept.push(one);
187
+ }
188
+ return kept;
189
+ };
190
+
191
+ // The diagnostics indexed by file, which is how a per-file evaluator asks
192
+ // for them.
193
+ export const indexByFile = (
194
+ diagnostics: Iterable<Diagnostic>,
195
+ ): ReadonlyMap<string, ReadonlyArray<Diagnostic>> => {
196
+ const byFile = new Map<string, Array<Diagnostic>>();
197
+ for (const one of diagnostics) {
198
+ const found = byFile.get(one.file);
199
+ if (found === undefined) byFile.set(one.file, [one]);
200
+ else found.push(one);
201
+ }
202
+ return byFile;
203
+ };