@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,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
@@ -0,0 +1,4 @@
1
+ // The fake for this family's one port, for tests that state what another
2
+ // tool reported and never run it. The fakes for the core's ports are under
3
+ // `@goodbones/core/testing`.
4
+ export { makeReportSourceFake } from "./infrastructure/report-source-fake.js";