@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,364 @@
|
|
|
1
|
+
import { DeclarationKind, ImportProbeTarget, SurfaceConvention } from "@goodbones/core";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
|
|
4
|
+
import { Holdout, ProbeDiagnostic, ReportFormat } from "../domain/config.js";
|
|
5
|
+
|
|
6
|
+
// The authoring surface of the campaigns family: the `campaigns` map and the
|
|
7
|
+
// `ledger` path, as a reader writes them. @goodbones/core decodes everything
|
|
8
|
+
// else in the manifest and hands these two keys here, so the core codec never
|
|
9
|
+
// learns a word of this vocabulary. `lower.ts` turns what this decodes into
|
|
10
|
+
// the rules the evaluators compile.
|
|
11
|
+
//
|
|
12
|
+
// `Globs` is repeated here rather than imported: it is two lines, and a codec
|
|
13
|
+
// primitive shared across a package boundary would make every glob in the
|
|
14
|
+
// manifest a thing two packages have to agree about.
|
|
15
|
+
|
|
16
|
+
const Globs = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
|
|
17
|
+
|
|
18
|
+
// A campaign: a migration the repository is running, tracked as an object —
|
|
19
|
+
// a detector, a rationale, a guide, an owner, a definition of done, and a
|
|
20
|
+
// ledger of every place the pattern still occurs. Where a rule says what may
|
|
21
|
+
// never happen, a campaign names what the code is moving away from.
|
|
22
|
+
//
|
|
23
|
+
// The detector is a predicate algebra: `all`, `any` and `not` over leaf
|
|
24
|
+
// terms, the same three words ast-grep uses. Leaf terms reuse the other
|
|
25
|
+
// families' vocabularies where one exists.
|
|
26
|
+
|
|
27
|
+
// A regular expression over the whole repo-relative path, matched as the
|
|
28
|
+
// `structure` naming rules are: with `subject` and `convention`, the capture
|
|
29
|
+
// group named holds the name being judged, and the term holds when the name
|
|
30
|
+
// has the convention's shape.
|
|
31
|
+
const PathTerm = Schema.Struct({
|
|
32
|
+
file: Globs,
|
|
33
|
+
fileNot: Schema.optionalKey(Globs),
|
|
34
|
+
subject: Schema.optionalKey(Schema.Finite),
|
|
35
|
+
convention: Schema.optionalKey(SurfaceConvention),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Holds when some import of the file resolves to the target — a path glob,
|
|
39
|
+
// `{ external: <package> }` or `{ builtin: <module> }` — and, with `symbols`,
|
|
40
|
+
// pulls one of those names across it.
|
|
41
|
+
const ImportsTerm = Schema.Struct({
|
|
42
|
+
resolves: ImportProbeTarget,
|
|
43
|
+
symbols: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Holds for an export site the selectors admit — the `surface` selectors.
|
|
47
|
+
const ExportsTerm = Schema.Struct({
|
|
48
|
+
name: Schema.optionalKey(Globs),
|
|
49
|
+
kinds: Schema.optionalKey(Schema.Array(Schema.Literals(["named", "default", "namespace"]))),
|
|
50
|
+
declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
|
|
51
|
+
reexport: Schema.optionalKey(Schema.Boolean),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Holds for a member site the selectors admit — the `members` selectors.
|
|
55
|
+
const MembersTerm = Schema.Struct({
|
|
56
|
+
subject: Schema.Literals(["members", "calls"]),
|
|
57
|
+
name: Schema.optionalKey(Globs),
|
|
58
|
+
in: Schema.optionalKey(Globs),
|
|
59
|
+
declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// A regular expression over the file's text, multiline.
|
|
63
|
+
const ContentTerm = Schema.Struct({ regex: Schema.String });
|
|
64
|
+
|
|
65
|
+
// How a metavariable is narrowed: by the text it captured, or by what the
|
|
66
|
+
// identifier at its root is bound to — the module it resolves to and the
|
|
67
|
+
// name it was imported as (`member`), which is how `class $N extends $BASE`
|
|
68
|
+
// says "a React component" rather than "any class with a base".
|
|
69
|
+
const CaptureNarrowing = Schema.Struct({
|
|
70
|
+
regex: Schema.optionalKey(Schema.String),
|
|
71
|
+
binding: Schema.optionalKey(
|
|
72
|
+
Schema.Struct({
|
|
73
|
+
resolves: ImportProbeTarget,
|
|
74
|
+
member: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// An ast-grep rule object — `pattern`, `kind`, `regex`, `has`, `inside`,
|
|
80
|
+
// `precedes`, `follows`, `nthChild`, `all`, `any`, `not` — with `where`
|
|
81
|
+
// beside it. The rule is the engine's to validate; the manifest only knows
|
|
82
|
+
// which keys are a rule's and which is the narrowing. `kind` names are the
|
|
83
|
+
// engine's node kinds, which today are tree-sitter's.
|
|
84
|
+
const SyntaxTerm = Schema.Struct({
|
|
85
|
+
pattern: Schema.optionalKey(Schema.Unknown),
|
|
86
|
+
kind: Schema.optionalKey(Schema.Unknown),
|
|
87
|
+
regex: Schema.optionalKey(Schema.Unknown),
|
|
88
|
+
nthChild: Schema.optionalKey(Schema.Unknown),
|
|
89
|
+
inside: Schema.optionalKey(Schema.Unknown),
|
|
90
|
+
has: Schema.optionalKey(Schema.Unknown),
|
|
91
|
+
precedes: Schema.optionalKey(Schema.Unknown),
|
|
92
|
+
follows: Schema.optionalKey(Schema.Unknown),
|
|
93
|
+
all: Schema.optionalKey(Schema.Unknown),
|
|
94
|
+
any: Schema.optionalKey(Schema.Unknown),
|
|
95
|
+
not: Schema.optionalKey(Schema.Unknown),
|
|
96
|
+
where: Schema.optionalKey(Schema.Record(Schema.String, CaptureNarrowing)),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// One program to run or file to read, or several: a tool that takes one
|
|
100
|
+
// project at a time is run once per project, and the outputs are read as
|
|
101
|
+
// one report — a diagnostic two of them print is one diagnostic.
|
|
102
|
+
const ReportSources = Schema.Union([
|
|
103
|
+
Schema.String,
|
|
104
|
+
Schema.Array(Schema.String).check(Schema.isMinLength(1)),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
// A finding of another program, read from the output of `command` (run
|
|
108
|
+
// from the repository root, once per `check`) or from `file` (written by
|
|
109
|
+
// an earlier step) in one of the known formats — `tsc`, `eslint --format
|
|
110
|
+
// json`, `oxlint --format json` — or by a `regex` with named groups. Holds
|
|
111
|
+
// for each diagnostic on the file whose code the term speaks to.
|
|
112
|
+
const ReportTerm = Schema.Struct({
|
|
113
|
+
command: Schema.optionalKey(ReportSources),
|
|
114
|
+
file: Schema.optionalKey(ReportSources),
|
|
115
|
+
format: ReportFormat,
|
|
116
|
+
pattern: Schema.optionalKey(Schema.String),
|
|
117
|
+
codes: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
118
|
+
codesNot: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
119
|
+
}).check(
|
|
120
|
+
// Refused at decode, where the issue names a line, rather than in the
|
|
121
|
+
// lowering: a term that names both sources or neither, and a `regex`
|
|
122
|
+
// term with nothing to match lines against.
|
|
123
|
+
Schema.makeFilter((term) => {
|
|
124
|
+
const issues: Array<Schema.FilterIssue> = [];
|
|
125
|
+
if ((term.command === undefined) === (term.file === undefined)) {
|
|
126
|
+
issues.push(
|
|
127
|
+
"a report term names exactly one of `command` (a program to run) and `file` (a report already written)",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (term.format === "regex" && term.pattern === undefined) {
|
|
131
|
+
issues.push({
|
|
132
|
+
path: ["pattern"],
|
|
133
|
+
issue: "a `regex` report term needs a `pattern` with named groups `file` and `line`",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return issues;
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
export type DetectorSpec =
|
|
141
|
+
| { readonly all: ReadonlyArray<DetectorSpec> }
|
|
142
|
+
| { readonly any: ReadonlyArray<DetectorSpec> }
|
|
143
|
+
| { readonly not: DetectorSpec }
|
|
144
|
+
| { readonly path: typeof PathTerm.Type }
|
|
145
|
+
| { readonly imports: typeof ImportsTerm.Type }
|
|
146
|
+
| { readonly exports: typeof ExportsTerm.Type }
|
|
147
|
+
| { readonly members: typeof MembersTerm.Type }
|
|
148
|
+
// The `structure` parity strings: holds when every named sibling exists.
|
|
149
|
+
| { readonly requires: ReadonlyArray<string> }
|
|
150
|
+
| { readonly content: typeof ContentTerm.Type }
|
|
151
|
+
| { readonly syntax: typeof SyntaxTerm.Type }
|
|
152
|
+
| { readonly report: typeof ReportTerm.Type }
|
|
153
|
+
// `module#export`: a predicate function the host imports before loading.
|
|
154
|
+
| { readonly fn: string };
|
|
155
|
+
|
|
156
|
+
// Each object carries exactly one term key, so a misspelled one is a decode
|
|
157
|
+
// error that names the line rather than a term quietly dropped.
|
|
158
|
+
const DetectorRef = Schema.suspend((): Schema.Codec<DetectorSpec> => DetectorSpec);
|
|
159
|
+
|
|
160
|
+
const DetectorSpec = Schema.Union([
|
|
161
|
+
Schema.Struct({ all: Schema.Array(DetectorRef) }),
|
|
162
|
+
Schema.Struct({ any: Schema.Array(DetectorRef) }),
|
|
163
|
+
Schema.Struct({ not: DetectorRef }),
|
|
164
|
+
Schema.Struct({ path: PathTerm }),
|
|
165
|
+
Schema.Struct({ imports: ImportsTerm }),
|
|
166
|
+
Schema.Struct({ exports: ExportsTerm }),
|
|
167
|
+
Schema.Struct({ members: MembersTerm }),
|
|
168
|
+
Schema.Struct({ requires: Schema.Array(Schema.String) }),
|
|
169
|
+
Schema.Struct({ content: ContentTerm }),
|
|
170
|
+
Schema.Struct({ syntax: SyntaxTerm }),
|
|
171
|
+
Schema.Struct({ report: ReportTerm }),
|
|
172
|
+
Schema.Struct({ fn: Schema.String }),
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
// A source an objective is proven against at load. `path` alone proves a
|
|
176
|
+
// path-shaped detector; `source` is parsed, `edges` answers the `imports`
|
|
177
|
+
// term and a binding narrowing in place of the live resolver, `files`
|
|
178
|
+
// answers `requires` in place of the file system, and `report` answers a
|
|
179
|
+
// `report` term in place of running anything — one-based positions, as a
|
|
180
|
+
// tool prints them.
|
|
181
|
+
const CampaignProbe = Schema.Struct({
|
|
182
|
+
path: Schema.String,
|
|
183
|
+
source: Schema.optionalKey(Schema.String),
|
|
184
|
+
edges: Schema.optionalKey(Schema.Record(Schema.String, ImportProbeTarget)),
|
|
185
|
+
files: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
186
|
+
report: Schema.optionalKey(Schema.Array(ProbeDiagnostic)),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const CampaignProbes = Schema.Struct({
|
|
190
|
+
fires: Schema.Array(CampaignProbe),
|
|
191
|
+
ignores: Schema.optionalKey(Schema.Array(CampaignProbe)),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// `30d`, `12h`: how long a campaign may go without progress before the
|
|
195
|
+
// conformance report calls it stalled.
|
|
196
|
+
const Duration = Schema.String.check(Schema.isPattern(/^\d+[dh]$/));
|
|
197
|
+
|
|
198
|
+
const KebabId = Schema.String.check(Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/));
|
|
199
|
+
|
|
200
|
+
// A term quantified over a sector's files: `has` a detector at least one
|
|
201
|
+
// file (or declaration, or match) in the sector satisfies — a presence;
|
|
202
|
+
// `oneRoot` every file under the root the perimeter was found at; `oneHost`
|
|
203
|
+
// every file under the host named.
|
|
204
|
+
const SectorTermSpec = Schema.Union([
|
|
205
|
+
Schema.Struct({ has: DetectorRef }),
|
|
206
|
+
Schema.Struct({ oneRoot: Schema.Literal(true) }),
|
|
207
|
+
Schema.Struct({ oneHost: Globs }),
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
// An objective: a detector with a ledger that only shrinks on its own. The
|
|
211
|
+
// `holdout` says what one ledger entry is; `match` is a per-file detector
|
|
212
|
+
// and `sector` a term over the sector's files, exactly one of them.
|
|
213
|
+
// `until` names the phase at which it stops counting.
|
|
214
|
+
const Objective = Schema.Struct({
|
|
215
|
+
// What a reader at a holdout does about it — the message every hit
|
|
216
|
+
// carries; falls back to the campaign's.
|
|
217
|
+
how: Schema.optionalKey(Schema.String),
|
|
218
|
+
why: Schema.optionalKey(Schema.String),
|
|
219
|
+
holdout: Holdout,
|
|
220
|
+
match: Schema.optionalKey(DetectorRef),
|
|
221
|
+
sector: Schema.optionalKey(SectorTermSpec),
|
|
222
|
+
until: Schema.optionalKey(KebabId),
|
|
223
|
+
probes: Schema.optionalKey(CampaignProbes),
|
|
224
|
+
}).check(
|
|
225
|
+
Schema.makeFilter((objective) => {
|
|
226
|
+
const issues: Array<Schema.FilterIssue> = [];
|
|
227
|
+
if ((objective.match === undefined) === (objective.sector === undefined)) {
|
|
228
|
+
issues.push(
|
|
229
|
+
"an objective names exactly one of `match` (a detector over each file) and `sector` (a term over the sector's files)",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (objective.sector !== undefined && objective.holdout !== "sector") {
|
|
233
|
+
issues.push({
|
|
234
|
+
path: ["holdout"],
|
|
235
|
+
issue: "a `sector` objective's holdout is the sector: write `holdout: sector`",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (objective.match !== undefined && objective.holdout === "sector") {
|
|
239
|
+
issues.push({
|
|
240
|
+
path: ["holdout"],
|
|
241
|
+
issue: "a `match` objective's holdout is `file`, `declaration` or `match`",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (objective.match !== undefined && (objective.probes?.fires.length ?? 0) === 0) {
|
|
245
|
+
issues.push({
|
|
246
|
+
path: ["probes"],
|
|
247
|
+
issue:
|
|
248
|
+
"a `match` objective carries `probes.fires`: at least one source it must report, as every rule proves it can fire",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return issues;
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// How a sector is recognized. `file`: one per file, keyed by the path without
|
|
256
|
+
// its extension. `nx`: the workspace's projects. `{ glob }`: one per match.
|
|
257
|
+
// `{ marker }`: a file that names the sector — an exported `sector` object
|
|
258
|
+
// with `name` and the globs it `owns`; without one, the folder. `{ match }`:
|
|
259
|
+
// one per detector match, keyed by its anchor, and proven by probes at least
|
|
260
|
+
// one of which is a sector in its end shape.
|
|
261
|
+
const PerimeterSpec = Schema.Union([
|
|
262
|
+
Schema.Literals(["file", "nx"]),
|
|
263
|
+
Schema.Struct({ glob: Globs }),
|
|
264
|
+
Schema.Struct({
|
|
265
|
+
marker: Globs,
|
|
266
|
+
probes: Schema.optionalKey(
|
|
267
|
+
Schema.Struct({ fires: Schema.optionalKey(Globs), ignores: Schema.optionalKey(Globs) }),
|
|
268
|
+
),
|
|
269
|
+
}),
|
|
270
|
+
Schema.Struct({
|
|
271
|
+
match: DetectorRef,
|
|
272
|
+
holdout: Schema.optionalKey(Schema.Literals(["declaration", "match"])),
|
|
273
|
+
probes: CampaignProbes,
|
|
274
|
+
}),
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
const OnTouchSpec = Schema.Literals(["advise", "ratchet", "paydown"]);
|
|
278
|
+
|
|
279
|
+
// A receipt for a change to a defined phase: a reason, dated.
|
|
280
|
+
const PhaseConcessionSpec = Schema.Struct({
|
|
281
|
+
reason: Schema.String,
|
|
282
|
+
at: Schema.String,
|
|
283
|
+
by: Schema.optionalKey(Schema.String),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// A sector-relative node tree: one root, `~/`, standing for the sector,
|
|
287
|
+
// shaped like the manifest's own tree with one allow entry of its own —
|
|
288
|
+
// `{ sector, via }`, another sector through its port. Carried as written
|
|
289
|
+
// and decoded as a tree once the lowering has rebased it for a sector, so
|
|
290
|
+
// the node codec itself never learns the extra entry.
|
|
291
|
+
const EndStateSpec = Schema.Record(Schema.String, Schema.Unknown);
|
|
292
|
+
// A phase: a named, ordered group of objectives. Defined when it names one
|
|
293
|
+
// (or is `attested`, or carries an `endState`); open when it has only an
|
|
294
|
+
// intent, and then it is last.
|
|
295
|
+
const Phase = Schema.Struct({
|
|
296
|
+
id: KebabId,
|
|
297
|
+
intent: Schema.optionalKey(Schema.String),
|
|
298
|
+
objectives: Schema.optionalKey(Schema.Array(KebabId)),
|
|
299
|
+
attested: Schema.optionalKey(Schema.Boolean),
|
|
300
|
+
onTouch: Schema.optionalKey(OnTouchSpec),
|
|
301
|
+
endState: Schema.optionalKey(EndStateSpec),
|
|
302
|
+
concessions: Schema.optionalKey(Schema.Array(PhaseConcessionSpec)),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// The scope, as globs or with the extensions the campaign widens the walk
|
|
306
|
+
// to beyond the packs' own.
|
|
307
|
+
const ScopeSpec = Schema.Union([
|
|
308
|
+
Globs,
|
|
309
|
+
Schema.Struct({ path: Globs, extensions: Schema.optionalKey(Schema.Array(Schema.String)) }),
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
// A campaign: one multi-step refactor with an end. Everything above
|
|
313
|
+
// `objectives` is optional, so a one-objective campaign is the minimal form.
|
|
314
|
+
const Campaign = Schema.Struct({
|
|
315
|
+
title: Schema.optionalKey(Schema.String),
|
|
316
|
+
why: Schema.optionalKey(Schema.String),
|
|
317
|
+
// The message a holdout carries when its objective states no `how`.
|
|
318
|
+
how: Schema.optionalKey(Schema.String),
|
|
319
|
+
owner: Schema.optionalKey(Schema.String),
|
|
320
|
+
// Which files the campaign sees. Alias-aware globs, as the graph rules
|
|
321
|
+
// take; a campaign's reach is its scope, so it joins no coverage row.
|
|
322
|
+
// Defaults to every file.
|
|
323
|
+
scope: Schema.optionalKey(ScopeSpec),
|
|
324
|
+
// What the unclaimed remainder of the scope counts as; absent, all of it.
|
|
325
|
+
legacy: Schema.optionalKey(Globs),
|
|
326
|
+
perimeter: Schema.optionalKey(PerimeterSpec),
|
|
327
|
+
onTouch: Schema.optionalKey(OnTouchSpec),
|
|
328
|
+
phases: Schema.optionalKey(Schema.Array(Phase)),
|
|
329
|
+
// Sugar for the last phase's end state.
|
|
330
|
+
endState: Schema.optionalKey(EndStateSpec),
|
|
331
|
+
objectives: Schema.Record(KebabId, Objective),
|
|
332
|
+
staleAfter: Schema.optionalKey(Duration),
|
|
333
|
+
onComplete: Schema.optionalKey(Schema.Literals(["keep", "remove"])),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// The slice of the manifest this family owns: the `campaigns` map, and the
|
|
337
|
+
// directory its ledgers are written under. @goodbones/core splits these two
|
|
338
|
+
// keys off the expanded manifest and hands them here; everything the core
|
|
339
|
+
// decodes, it decodes without them.
|
|
340
|
+
export const CampaignsManifest = Schema.Struct({
|
|
341
|
+
campaigns: Schema.optionalKey(Schema.Record(KebabId, Campaign)),
|
|
342
|
+
// Where the ledgers are written: `<ledger>/<campaign>/<objective>.json`,
|
|
343
|
+
// relative to the manifest. Defaults to `.architecture-campaigns`.
|
|
344
|
+
ledger: Schema.optionalKey(Schema.String),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
export type CampaignsManifest = typeof CampaignsManifest.Type;
|
|
348
|
+
export type CampaignSpec = typeof Campaign.Type;
|
|
349
|
+
export type ObjectiveSpec = typeof Objective.Type;
|
|
350
|
+
export type PhaseSpec = typeof Phase.Type;
|
|
351
|
+
export type PerimeterSpec = typeof PerimeterSpec.Type;
|
|
352
|
+
export type SectorTermSpec = typeof SectorTermSpec.Type;
|
|
353
|
+
export type EndStateSpec = typeof EndStateSpec.Type;
|
|
354
|
+
export type CampaignProbeSpec = typeof CampaignProbe.Type;
|
|
355
|
+
export type CampaignProbesSpec = typeof CampaignProbes.Type;
|
|
356
|
+
export type SyntaxTermSpec = typeof SyntaxTerm.Type;
|
|
357
|
+
|
|
358
|
+
export const DEFAULT_LEDGER_DIR = ".architecture-campaigns";
|
|
359
|
+
|
|
360
|
+
// `30d` -> milliseconds. The schema has already refused any other shape.
|
|
361
|
+
export const durationMs = (duration: string): number => {
|
|
362
|
+
const amount = Number(duration.slice(0, -1));
|
|
363
|
+
return amount * (duration.endsWith("h") ? 3_600_000 : 86_400_000);
|
|
364
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SourceFacts, SyntaxTree } from "@goodbones/core";
|
|
2
|
+
|
|
3
|
+
// The floor of a campaign's detector: a predicate function the repository
|
|
4
|
+
// writes, named from the manifest as `module#export` and handed to the
|
|
5
|
+
// policy by the host. It is given what the evaluator has about one file and
|
|
6
|
+
// answers either a verdict about the file or the subjects it found in it —
|
|
7
|
+
// a declaration name, or any key a `match` campaign should ledger — each
|
|
8
|
+
// optionally with where it sits. Exported as a public type so a referenced
|
|
9
|
+
// module typechecks on its own.
|
|
10
|
+
|
|
11
|
+
export type Range = {
|
|
12
|
+
// Zero-based.
|
|
13
|
+
readonly start: { readonly line: number; readonly column: number };
|
|
14
|
+
readonly end: { readonly line: number; readonly column: number };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type CampaignPredicateInput = {
|
|
18
|
+
readonly file: string;
|
|
19
|
+
readonly text: string;
|
|
20
|
+
readonly facts: SourceFacts;
|
|
21
|
+
// The file parsed by the scope's syntax matcher, or `null` without one.
|
|
22
|
+
readonly syntax: SyntaxTree | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CampaignSubject = { readonly subject: string; readonly range?: Range };
|
|
26
|
+
|
|
27
|
+
export type CampaignPredicate = (
|
|
28
|
+
input: CampaignPredicateInput,
|
|
29
|
+
) => boolean | ReadonlyArray<CampaignSubject>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Diagnostic, ReportFormat } from "../domain/report.js";
|
|
2
|
+
|
|
3
|
+
// What a `report` term names: a program to run from the repository root, or
|
|
4
|
+
// a file some earlier step wrote — or several of either, read as one report
|
|
5
|
+
// — and the format the output is read in.
|
|
6
|
+
export type ReportSpec = {
|
|
7
|
+
readonly command?: string | ReadonlyArray<string> | undefined;
|
|
8
|
+
readonly file?: string | ReadonlyArray<string> | undefined;
|
|
9
|
+
readonly format: ReportFormat;
|
|
10
|
+
// `regex` only.
|
|
11
|
+
readonly pattern?: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// The commands or files a spec names, as a list.
|
|
15
|
+
export const reportSourcesOf = (spec: ReportSpec): ReadonlyArray<string> => {
|
|
16
|
+
const named = spec.command ?? spec.file ?? [];
|
|
17
|
+
return typeof named === "string" ? [named] : named;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Answers a `report` term for one file. The live source runs the commands
|
|
21
|
+
// once per process (a report is about the whole repository, and a campaign
|
|
22
|
+
// asks per file) and reads a file once; a fake answers from a table. The
|
|
23
|
+
// core never spawns anything.
|
|
24
|
+
//
|
|
25
|
+
// `read` is the host's chance to ask ahead: it reads the report — several
|
|
26
|
+
// commands at once — and keeps the answer, or the failure, for
|
|
27
|
+
// `diagnosticsOf` to hand back. Both hosts call it before any file is
|
|
28
|
+
// judged; a source that does not offer it is asked synchronously, one
|
|
29
|
+
// command after another, the first time a file asks.
|
|
30
|
+
export type ReportSource = {
|
|
31
|
+
readonly diagnosticsOf: (spec: ReportSpec, file: string) => ReadonlyArray<Diagnostic>;
|
|
32
|
+
readonly read?: ((spec: ReportSpec) => Promise<void>) | undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const NO_REPORTS: ReportSource = { diagnosticsOf: () => [] };
|
package/src/testing.ts
ADDED