@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.
- package/LICENSE +21 -0
- package/build/dts/core/campaign-state.d.ts +38 -0
- package/build/dts/core/campaign-state.d.ts.map +1 -0
- package/build/dts/core/campaigns.d.ts +196 -0
- package/build/dts/core/campaigns.d.ts.map +1 -0
- package/build/dts/core/ledger.d.ts +173 -0
- package/build/dts/core/ledger.d.ts.map +1 -0
- package/build/dts/core/phases.d.ts +26 -0
- package/build/dts/core/phases.d.ts.map +1 -0
- package/build/dts/core/sectors.d.ts +52 -0
- package/build/dts/core/sectors.d.ts.map +1 -0
- package/build/dts/domain/config.d.ts +985 -0
- package/build/dts/domain/config.d.ts.map +1 -0
- package/build/dts/domain/report.d.ts +16 -0
- package/build/dts/domain/report.d.ts.map +1 -0
- package/build/dts/host/campaigns.d.ts +195 -0
- package/build/dts/host/campaigns.d.ts.map +1 -0
- package/build/dts/host/diff.d.ts +31 -0
- package/build/dts/host/diff.d.ts.map +1 -0
- package/build/dts/index.d.ts +17 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
- package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
- package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
- package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
- package/build/dts/infrastructure/report-source-live.d.ts +3 -0
- package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
- package/build/dts/load/decode.d.ts +7 -0
- package/build/dts/load/decode.d.ts.map +1 -0
- package/build/dts/load/extension.d.ts +25 -0
- package/build/dts/load/extension.d.ts.map +1 -0
- package/build/dts/manifest/lower.d.ts +14 -0
- package/build/dts/manifest/lower.d.ts.map +1 -0
- package/build/dts/manifest/spec.d.ts +515 -0
- package/build/dts/manifest/spec.d.ts.map +1 -0
- package/build/dts/ports/campaign-predicate.d.ts +23 -0
- package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
- package/build/dts/ports/report-source.d.ts +14 -0
- package/build/dts/ports/report-source.d.ts.map +1 -0
- package/build/dts/testing.d.ts +2 -0
- package/build/dts/testing.d.ts.map +1 -0
- package/build/esm/core/campaign-state.js +140 -0
- package/build/esm/core/campaign-state.js.map +1 -0
- package/build/esm/core/campaigns.js +954 -0
- package/build/esm/core/campaigns.js.map +1 -0
- package/build/esm/core/ledger.js +519 -0
- package/build/esm/core/ledger.js.map +1 -0
- package/build/esm/core/phases.js +87 -0
- package/build/esm/core/phases.js.map +1 -0
- package/build/esm/core/sectors.js +313 -0
- package/build/esm/core/sectors.js.map +1 -0
- package/build/esm/domain/config.js +245 -0
- package/build/esm/domain/config.js.map +1 -0
- package/build/esm/domain/report.js +168 -0
- package/build/esm/domain/report.js.map +1 -0
- package/build/esm/host/campaigns.js +1159 -0
- package/build/esm/host/campaigns.js.map +1 -0
- package/build/esm/host/diff.js +156 -0
- package/build/esm/host/diff.js.map +1 -0
- package/build/esm/index.js +27 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/infrastructure/campaign-functions.js +67 -0
- package/build/esm/infrastructure/campaign-functions.js.map +1 -0
- package/build/esm/infrastructure/report-source-fake.js +6 -0
- package/build/esm/infrastructure/report-source-fake.js.map +1 -0
- package/build/esm/infrastructure/report-source-live.js +165 -0
- package/build/esm/infrastructure/report-source-live.js.map +1 -0
- package/build/esm/load/decode.js +60 -0
- package/build/esm/load/decode.js.map +1 -0
- package/build/esm/load/extension.js +326 -0
- package/build/esm/load/extension.js.map +1 -0
- package/build/esm/manifest/lower.js +488 -0
- package/build/esm/manifest/lower.js.map +1 -0
- package/build/esm/manifest/spec.js +296 -0
- package/build/esm/manifest/spec.js.map +1 -0
- package/build/esm/ports/campaign-predicate.js +2 -0
- package/build/esm/ports/campaign-predicate.js.map +1 -0
- package/build/esm/ports/report-source.js +7 -0
- package/build/esm/ports/report-source.js.map +1 -0
- package/build/esm/testing.js +5 -0
- package/build/esm/testing.js.map +1 -0
- package/package.json +65 -0
- package/src/core/campaign-state.ts +227 -0
- package/src/core/campaigns.ts +1319 -0
- package/src/core/ledger.ts +675 -0
- package/src/core/phases.ts +138 -0
- package/src/core/sectors.ts +399 -0
- package/src/domain/config.ts +314 -0
- package/src/domain/report.ts +203 -0
- package/src/host/campaigns.ts +1664 -0
- package/src/host/diff.ts +203 -0
- package/src/index.ts +256 -0
- package/src/infrastructure/campaign-functions.ts +99 -0
- package/src/infrastructure/report-source-fake.ts +10 -0
- package/src/infrastructure/report-source-live.ts +193 -0
- package/src/load/decode.ts +81 -0
- package/src/load/extension.ts +466 -0
- package/src/manifest/lower.ts +615 -0
- package/src/manifest/spec.ts +364 -0
- package/src/ports/campaign-predicate.ts +29 -0
- package/src/ports/report-source.ts +35 -0
- 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
|
+
};
|