@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,203 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ // What the nudge is scoped to: the files a diff touches, and where in each.
7
+ // Read from git — the working tree against `HEAD` on a developer's machine,
8
+ // or against `--base <ref>` in CI — with `-U0`, so a hunk is exactly the
9
+ // lines that changed and "nearest your change" means something.
10
+
11
+ export type Hunk = {
12
+ // One-based, inclusive, in the new file; a pure deletion is a zero-length
13
+ // hunk at the line after it.
14
+ readonly start: number;
15
+ readonly end: number;
16
+ };
17
+
18
+ export type Diff = {
19
+ // The ref the diff was taken against; `null` for the working tree
20
+ // against HEAD.
21
+ readonly base: string | null;
22
+ // Repo-relative paths, forward slashes, with the hunks in each. A file
23
+ // added whole has one hunk covering it; a deleted file is not here.
24
+ readonly touched: ReadonlyMap<string, ReadonlyArray<Hunk>>;
25
+ readonly deleted: ReadonlyArray<string>;
26
+ readonly added: ReadonlyArray<string>;
27
+ };
28
+
29
+ const git = (repoRoot: string, args: ReadonlyArray<string>): string =>
30
+ execFileSync("git", args, {
31
+ cwd: repoRoot,
32
+ encoding: "utf8",
33
+ stdio: ["ignore", "pipe", "ignore"],
34
+ maxBuffer: 256 * 1024 * 1024,
35
+ });
36
+
37
+ const HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
38
+
39
+ const HEADER = /^diff --git a\/(.+?) b\/(.+)$/;
40
+
41
+ // Parses `git diff -U0`: one entry per file header, one hunk per `@@`. An
42
+ // empty file added or deleted has a header and a mode line and nothing
43
+ // else, so the mode lines are read too.
44
+ export const parseDiff = (
45
+ text: string,
46
+ ): { touched: Map<string, Array<Hunk>>; deleted: Array<string>; added: Array<string> } => {
47
+ const touched = new Map<string, Array<Hunk>>();
48
+ const deleted = new Set<string>();
49
+ const added = new Set<string>();
50
+ let current: string | null = null;
51
+ for (const line of text.split("\n")) {
52
+ const header = HEADER.exec(line);
53
+ if (header !== null) {
54
+ current = header[2] ?? null;
55
+ continue;
56
+ }
57
+ if (current === null) continue;
58
+ if (line.startsWith("deleted file mode")) {
59
+ deleted.add(current);
60
+ touched.delete(current);
61
+ continue;
62
+ }
63
+ if (line.startsWith("new file mode")) {
64
+ added.add(current);
65
+ if (!touched.has(current)) touched.set(current, []);
66
+ continue;
67
+ }
68
+ if (line.startsWith("+++ ")) {
69
+ if (line.slice(4) === "/dev/null") {
70
+ deleted.add(current);
71
+ touched.delete(current);
72
+ } else if (!deleted.has(current) && !touched.has(current)) {
73
+ touched.set(current, []);
74
+ }
75
+ continue;
76
+ }
77
+ const hunk = HUNK.exec(line);
78
+ if (hunk !== null && !deleted.has(current)) {
79
+ const start = Number(hunk[3]);
80
+ const count = hunk[4] === undefined ? 1 : Number(hunk[4]);
81
+ const hunks = touched.get(current) ?? [];
82
+ hunks.push({ start, end: count === 0 ? start : start + count - 1 });
83
+ touched.set(current, hunks);
84
+ }
85
+ }
86
+ // A file added whole with no hunk of its own (empty) covers itself.
87
+ for (const file of added) {
88
+ if ((touched.get(file) ?? []).length === 0) touched.set(file, [{ start: 1, end: 1 }]);
89
+ }
90
+ return { touched, deleted: [...deleted].sort(), added: [...added].sort() };
91
+ };
92
+
93
+ // The diff of the working tree against `base` (or HEAD), untracked files
94
+ // included as added whole.
95
+ export const readDiff = (repoRoot: string, base: string | null): Diff => {
96
+ const against = base ?? "HEAD";
97
+ const parsed = parseDiff(git(repoRoot, ["diff", "-U0", "--no-color", "--no-ext-diff", against]));
98
+ const untracked = git(repoRoot, ["ls-files", "--others", "--exclude-standard"])
99
+ .split("\n")
100
+ .map((one) => one.trim())
101
+ .filter((one) => one !== "");
102
+ for (const file of untracked) {
103
+ if (parsed.touched.has(file)) continue;
104
+ parsed.touched.set(file, [{ start: 1, end: Number.MAX_SAFE_INTEGER }]);
105
+ parsed.added.push(file);
106
+ }
107
+ return {
108
+ base,
109
+ touched: parsed.touched,
110
+ deleted: parsed.deleted,
111
+ added: [...new Set(parsed.added)].sort(),
112
+ };
113
+ };
114
+
115
+ // The distance, in lines, from a range to the nearest hunk of a file: zero
116
+ // when they overlap. `Infinity` for a file with no hunk.
117
+ export const distanceToHunks = (hunks: ReadonlyArray<Hunk>, start: number, end: number): number => {
118
+ let nearest = Number.POSITIVE_INFINITY;
119
+ for (const hunk of hunks) {
120
+ const distance = hunk.end < start ? start - hunk.end : end < hunk.start ? hunk.start - end : 0;
121
+ if (distance < nearest) nearest = distance;
122
+ }
123
+ return nearest;
124
+ };
125
+
126
+ // A file's text as it was at `ref`, or `null` when it did not exist there.
127
+ export const textAt = (repoRoot: string, ref: string, file: string): string | null => {
128
+ try {
129
+ return git(repoRoot, ["show", `${ref}:${file}`]);
130
+ } catch {
131
+ return null;
132
+ }
133
+ };
134
+
135
+ export const commitOf = (repoRoot: string, ref: string): string =>
136
+ git(repoRoot, ["rev-parse", ref]).trim();
137
+
138
+ // The tree at `ref`, materialized in a temporary directory with the
139
+ // repository's `node_modules` linked beside it, so the packs resolve as
140
+ // they would in the checkout. The exact mode of the nudge evaluates it
141
+ // whole; the caller disposes of it.
142
+ export const materializeTree = (
143
+ repoRoot: string,
144
+ ref: string,
145
+ ): { readonly root: string; readonly dispose: () => void } => {
146
+ const root = realpathSync(mkdtempSync(path.join(tmpdir(), "goodbones-base-")));
147
+ execFileSync(
148
+ "sh",
149
+ ["-c", `git archive ${JSON.stringify(ref)} | tar -x -C ${JSON.stringify(root)}`],
150
+ {
151
+ cwd: repoRoot,
152
+ stdio: ["ignore", "ignore", "ignore"],
153
+ },
154
+ );
155
+ const modules = path.join(repoRoot, "node_modules");
156
+ if (existsSync(modules) && !existsSync(path.join(root, "node_modules"))) {
157
+ symlinkSync(modules, path.join(root, "node_modules"), "dir");
158
+ }
159
+ return {
160
+ root,
161
+ dispose: () => {
162
+ rmSync(root, { recursive: true, force: true });
163
+ },
164
+ };
165
+ };
166
+
167
+ // The commits that touched any of these paths, oldest first.
168
+ export type Commit = {
169
+ readonly sha: string;
170
+ readonly at: string;
171
+ readonly subject: string;
172
+ readonly files: ReadonlyArray<string>;
173
+ };
174
+
175
+ export const commitsTouching = (
176
+ repoRoot: string,
177
+ paths: ReadonlyArray<string>,
178
+ since: string | null,
179
+ ): ReadonlyArray<Commit> => {
180
+ const range = since === null ? [] : [`${since}..HEAD`];
181
+ const log = git(repoRoot, [
182
+ "log",
183
+ "--reverse",
184
+ "--format=%x00%H%x09%aI%x09%s",
185
+ "--name-only",
186
+ ...range,
187
+ "--",
188
+ ...paths,
189
+ ]);
190
+ const commits: Array<Commit> = [];
191
+ for (const block of log.split("\u0000")) {
192
+ if (block.trim() === "") continue;
193
+ const [head = "", ...rest] = block.split("\n");
194
+ const [sha = "", at = "", ...subject] = head.split("\t");
195
+ commits.push({
196
+ sha,
197
+ at,
198
+ subject: subject.join("\t"),
199
+ files: rest.map((one) => one.trim()).filter((one) => one !== ""),
200
+ });
201
+ }
202
+ return commits;
203
+ };
package/src/index.ts ADDED
@@ -0,0 +1,256 @@
1
+ // The campaigns family: a refactor tracked as an object in the repository —
2
+ // `objectives`, each a detector with a ledger that only shrinks on its own,
3
+ // over `sectors` the code declares through a `perimeter`, through ordered
4
+ // `phases` toward an end.
5
+ //
6
+ // It is a family @goodbones/core does not own. A host composes it:
7
+ //
8
+ // loadPolicy({ …, extensions: [campaignsExtension({ functions, reports })] })
9
+ //
10
+ // and reads its state back with `campaignsOf(policy)`. The core carries the
11
+ // value and never looks inside it, which is what keeps a policy that only
12
+ // states architecture from paying for any of this.
13
+ export {
14
+ type CampaignEvaluation,
15
+ type CampaignEvaluationInput,
16
+ evaluateCampaign,
17
+ hitsInWindow,
18
+ type ObjectiveHit,
19
+ type SectorState,
20
+ towardNextOf,
21
+ } from "./core/campaign-state.js";
22
+ export {
23
+ type CampaignHit,
24
+ type CampaignInput,
25
+ campaignsFailingTheirProbe,
26
+ campaignsSelecting,
27
+ compileCampaignRule,
28
+ compileCampaignRules,
29
+ type CompiledCampaign,
30
+ type CompiledDetector,
31
+ type CompiledObjective,
32
+ type CompiledPerimeter,
33
+ type CompiledSectorTerm,
34
+ compileObjective,
35
+ candidatesOf as detectorCandidatesOf,
36
+ detectorOf,
37
+ evaluateObjective,
38
+ evaluateObjectives,
39
+ explainDetector,
40
+ explainObjective,
41
+ type FailedProbe,
42
+ leafTermsOf,
43
+ matchKeyOf,
44
+ needsSyntax,
45
+ perFileObjectivesOf,
46
+ probeInputOf,
47
+ reportSpecsOf,
48
+ type TermAnswer,
49
+ } from "./core/campaigns.js";
50
+ export {
51
+ allowedOf,
52
+ type Attestation,
53
+ attestedRecord,
54
+ clearedOf,
55
+ clearedSector,
56
+ closedOf,
57
+ concededSector,
58
+ type Concession,
59
+ type ConcessionRecord,
60
+ decodeLedger,
61
+ decodePlanRecord,
62
+ decodeSectorRecord,
63
+ deltaOf,
64
+ EMPTY_LEDGER,
65
+ EMPTY_SECTOR_RECORD,
66
+ encodeSectorName,
67
+ holdoutsOf,
68
+ initialOf,
69
+ isComplete,
70
+ isLegacyLedger,
71
+ isStalled,
72
+ lastClearedOf,
73
+ type Ledger,
74
+ ledgerArithmeticHolds,
75
+ ledgerPathOf,
76
+ Ledger as LedgerSchema,
77
+ legacyLedgerPathOf,
78
+ type Note,
79
+ NOTE_CAP,
80
+ NOTE_LENGTH,
81
+ notedRecord,
82
+ type PlanDiff,
83
+ planDiffOf,
84
+ planOf,
85
+ planPathOf,
86
+ type PlanRecord,
87
+ positionOf,
88
+ progressOf,
89
+ reachedRecord,
90
+ rebaselinedSector,
91
+ reconcileSector,
92
+ type Reconciliation,
93
+ sectorArithmeticHolds,
94
+ sectorClockOf,
95
+ type SectorLedger,
96
+ type SectorRecord,
97
+ sectorRecordPathOf,
98
+ serializeLedger,
99
+ serializePlanRecord,
100
+ serializeSectorRecord,
101
+ } from "./core/ledger.js";
102
+ export {
103
+ compareResidue,
104
+ derivePhase,
105
+ type Direction,
106
+ donePhaseOf,
107
+ inWindow,
108
+ isDefinedPhase,
109
+ isOpenPhase,
110
+ isShut,
111
+ LEGACY_PHASE,
112
+ objectivesInWindow,
113
+ onTouchOf,
114
+ type Residue as ResidueVector,
115
+ type SectorPosition,
116
+ UNPLACED,
117
+ type Window,
118
+ windowOf,
119
+ worsened,
120
+ } from "./core/phases.js";
121
+ export {
122
+ discoverSectors,
123
+ fixedPrefixOf,
124
+ IMPLICIT_SECTOR,
125
+ LEGACY_SECTOR,
126
+ membershipOf,
127
+ parseSectorMarker,
128
+ type Placement,
129
+ rootOf,
130
+ type Sector,
131
+ SECTOR_HOLDOUT,
132
+ type SectorDiscovery,
133
+ entryOf as sectorEntryOf,
134
+ type SectorIndex,
135
+ type SectorMarker,
136
+ sectorNamed,
137
+ withoutExtension,
138
+ } from "./core/sectors.js";
139
+ export {
140
+ type CampaignProbe,
141
+ type CampaignProbes,
142
+ type CampaignRule,
143
+ type CampaignUnit,
144
+ type Detector,
145
+ type Holdout,
146
+ type ObjectiveRule,
147
+ type OnTouch,
148
+ type PerimeterRule,
149
+ type PhaseConcession,
150
+ type PhaseRule,
151
+ type ProbeDiagnostic,
152
+ type SectorTerm,
153
+ } from "./domain/config.js";
154
+ export {
155
+ type Diagnostic,
156
+ indexByFile,
157
+ parseReport,
158
+ type ParseReportOptions,
159
+ type ReportFormat,
160
+ uniqueDiagnostics,
161
+ } from "./domain/report.js";
162
+ export {
163
+ type Ask,
164
+ attest,
165
+ authorOf,
166
+ type BaseSide,
167
+ baseSideAt,
168
+ baseSideOf,
169
+ campaignFailuresOf,
170
+ type CampaignReport,
171
+ campaignReportsOf,
172
+ clear,
173
+ type ClearOutcome,
174
+ concede,
175
+ type ConcedeOutcome,
176
+ evaluateCampaigns,
177
+ explainCampaignLines,
178
+ historyOf,
179
+ type HistoryRow,
180
+ HOLDOUT_CAP,
181
+ ledgeredFilter,
182
+ note,
183
+ type Nudge,
184
+ type NudgeHoldout,
185
+ nudgeOf,
186
+ type ObjectiveReport,
187
+ type Readers,
188
+ renderCampaignReports,
189
+ renderCampaignRows,
190
+ renderHistory,
191
+ renderNudge,
192
+ type SectorNudge,
193
+ type SectorObjectiveReport,
194
+ type SectorReport,
195
+ snapshotCampaignsOf,
196
+ type Verdict,
197
+ widenedExtensions,
198
+ } from "./host/campaigns.js";
199
+ export {
200
+ type Commit,
201
+ commitOf,
202
+ commitsTouching,
203
+ type Diff,
204
+ distanceToHunks,
205
+ type Hunk,
206
+ materializeTree,
207
+ parseDiff,
208
+ readDiff,
209
+ textAt,
210
+ } from "./host/diff.js";
211
+ export {
212
+ loadCampaignFunctions,
213
+ type LoadedCampaignFunctions,
214
+ } from "./infrastructure/campaign-functions.js";
215
+ export { makeReportSourceLive } from "./infrastructure/report-source-live.js";
216
+ export {
217
+ type CampaignPolicy,
218
+ CAMPAIGNS_EXTENSION_ID,
219
+ campaignsExtension,
220
+ type CampaignsExtensionOptions,
221
+ campaignsOf,
222
+ ledgerKeyOf,
223
+ } from "./load/extension.js";
224
+ export {
225
+ END_STATE_ROOT,
226
+ endStateObjectiveId,
227
+ lowerCampaigns,
228
+ lowerEndState,
229
+ } from "./manifest/lower.js";
230
+ export {
231
+ type CampaignProbeSpec,
232
+ type CampaignProbesSpec,
233
+ CampaignsManifest,
234
+ type CampaignSpec,
235
+ DEFAULT_LEDGER_DIR,
236
+ type DetectorSpec,
237
+ durationMs,
238
+ type EndStateSpec,
239
+ type ObjectiveSpec,
240
+ type PerimeterSpec,
241
+ type PhaseSpec,
242
+ type SectorTermSpec,
243
+ type SyntaxTermSpec,
244
+ } from "./manifest/spec.js";
245
+ export {
246
+ type CampaignPredicate,
247
+ type CampaignPredicateInput,
248
+ type CampaignSubject,
249
+ type Range,
250
+ } from "./ports/campaign-predicate.js";
251
+ export {
252
+ NO_REPORTS,
253
+ type ReportSource,
254
+ reportSourcesOf,
255
+ type ReportSpec,
256
+ } from "./ports/report-source.js";
@@ -0,0 +1,99 @@
1
+ import * as path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ import { ConfigInvalid } from "@goodbones/core";
5
+
6
+ import type { CampaignPredicate } from "../ports/campaign-predicate.js";
7
+
8
+ // A campaign's `fn` term names a predicate function as `module#export`,
9
+ // resolved relative to the root manifest. Importing it touches the module
10
+ // loader, which is why this lives in infrastructure and both hosts call it
11
+ // before `loadPolicy`: the core receives the functions as a map and never
12
+ // imports anything.
13
+ //
14
+ // A `.mjs` manifest may write the function itself in place of the string.
15
+ // It is lifted out here, keyed by a synthesized name, and the manifest handed
16
+ // back with the string in its place — so the schema, which decodes a data
17
+ // file and a module alike, sees one shape. The whole value is walked, not
18
+ // only `campaigns`, so a term written in a `defs` fragment is found too.
19
+
20
+ export type LoadedCampaignFunctions = {
21
+ readonly functions: ReadonlyMap<string, CampaignPredicate>;
22
+ // The manifest with every function value replaced by its synthesized name.
23
+ readonly manifest: unknown;
24
+ };
25
+
26
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
27
+ typeof value === "object" && value !== null && !Array.isArray(value);
28
+
29
+ const REFERENCE = /^(.+)#([A-Za-z_$][\w$]*)$/;
30
+
31
+ // A `fn` term is an object whose one key is `fn`.
32
+ const isFnTerm = (value: Record<string, unknown>): boolean =>
33
+ "fn" in value && Object.keys(value).length === 1;
34
+
35
+ export const loadCampaignFunctions = async (
36
+ manifestPath: string,
37
+ rawManifest: unknown,
38
+ ): Promise<LoadedCampaignFunctions> => {
39
+ const functions = new Map<string, CampaignPredicate>();
40
+ const root = path.dirname(manifestPath);
41
+ const refuse = (detail: string): never => {
42
+ throw new ConfigInvalid({ configPath: manifestPath, detail });
43
+ };
44
+
45
+ const imported = async (reference: string): Promise<void> => {
46
+ if (functions.has(reference)) return;
47
+ const parsed = REFERENCE.exec(reference);
48
+ if (parsed === null) {
49
+ return refuse(
50
+ `the \`fn\` term ${JSON.stringify(reference)} is not \`module#export\`: name the ` +
51
+ `module (relative to the manifest) and the export, joined by \`#\`.`,
52
+ );
53
+ }
54
+ const [, module = "", exportName = ""] = parsed;
55
+ const at = path.resolve(root, module);
56
+ let loaded: unknown;
57
+ try {
58
+ loaded = await import(pathToFileURL(at).href);
59
+ } catch (cause) {
60
+ return refuse(
61
+ `the \`fn\` term ${JSON.stringify(reference)} names a module that does not load: ${String(cause)}`,
62
+ );
63
+ }
64
+ const found = isRecord(loaded) ? loaded[exportName] : undefined;
65
+ if (typeof found !== "function") {
66
+ return refuse(
67
+ `the \`fn\` term ${JSON.stringify(reference)} names an export that is not a function ` +
68
+ `(${found === undefined ? "absent" : typeof found}).`,
69
+ );
70
+ }
71
+ functions.set(reference, found as CampaignPredicate);
72
+ };
73
+
74
+ let synthesized = 0;
75
+ const lift = async (value: unknown): Promise<unknown> => {
76
+ if (Array.isArray(value)) {
77
+ const items: Array<unknown> = [];
78
+ for (const item of value) items.push(await lift(item));
79
+ return items;
80
+ }
81
+ if (!isRecord(value)) return value;
82
+ if (isFnTerm(value)) {
83
+ const fn = value.fn;
84
+ if (typeof fn === "function") {
85
+ synthesized += 1;
86
+ const name = `<manifest>#fn${String(synthesized)}`;
87
+ functions.set(name, fn as CampaignPredicate);
88
+ return { fn: name };
89
+ }
90
+ if (typeof fn === "string") await imported(fn);
91
+ return value;
92
+ }
93
+ const rebuilt: Record<string, unknown> = {};
94
+ for (const [key, entry] of Object.entries(value)) rebuilt[key] = await lift(entry);
95
+ return rebuilt;
96
+ };
97
+
98
+ return { functions, manifest: await lift(rawManifest) };
99
+ };
@@ -0,0 +1,10 @@
1
+ import type { Diagnostic } from "../domain/report.js";
2
+ import type { ReportSource } from "../ports/report-source.js";
3
+
4
+ // Keyed by file: a test states which diagnostics another tool reported on
5
+ // which file, and never runs the tool. Every spec is answered alike.
6
+ export const makeReportSourceFake = (
7
+ staged: Readonly<Record<string, ReadonlyArray<Omit<Diagnostic, "file">>>>,
8
+ ): ReportSource => ({
9
+ diagnosticsOf: (_spec, file) => (staged[file] ?? []).map((one) => ({ ...one, file })),
10
+ });