@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,138 @@
1
+ import type { OnTouch, PhaseRule } from "../domain/config.js";
2
+ import type { CompiledCampaign, CompiledObjective } from "./campaigns.js";
3
+
4
+ // A phase is a named, ordered group of objectives: defined when it names
5
+ // one (or is attested, or carries an end state), open when it has only an
6
+ // intent — and then it is last. A sector's phase is derived, never
7
+ // declared: the first phase, in order, with residue for that sector. What
8
+ // derivation cannot say is where a sector has been, and windows need that,
9
+ // so the per-sector ledger records the furthest phase ever reached.
10
+
11
+ // The index a sector past every phase stands at.
12
+ export const donePhaseOf = (rule: CompiledCampaign): number => rule.phases.length;
13
+
14
+ // The phase the legacy stands at: the first, so the first phase's
15
+ // objectives count for what no sector has claimed, and it is not a sink.
16
+ export const LEGACY_PHASE = 0;
17
+
18
+ export const isOpenPhase = (phase: PhaseRule): boolean =>
19
+ phase.objectives.length === 0 && !phase.attested;
20
+
21
+ export const isDefinedPhase = (phase: PhaseRule): boolean => !isOpenPhase(phase);
22
+
23
+ // What the sector's own record says about its position: the furthest phase
24
+ // derived for it so far (`-1` before any `clear`), and the phases attested.
25
+ export type SectorPosition = {
26
+ readonly reached: number;
27
+ readonly attested: ReadonlySet<string>;
28
+ };
29
+
30
+ export const UNPLACED: SectorPosition = { reached: -1, attested: new Set() };
31
+
32
+ export type Window = {
33
+ // The index of the phase naming the objective; `-1` for one no phase
34
+ // names, which is in window everywhere, the legacy included.
35
+ readonly from: number;
36
+ // Exclusive; `Infinity` to the end.
37
+ readonly until: number;
38
+ };
39
+
40
+ export const windowOf = (rule: CompiledCampaign, objective: CompiledObjective): Window => {
41
+ const from = rule.phases.findIndex((phase) => phase.objectives.includes(objective.id));
42
+ const until =
43
+ objective.until === null
44
+ ? Number.POSITIVE_INFINITY
45
+ : rule.phases.findIndex((phase) => phase.id === objective.until);
46
+ return { from, until: until === -1 ? Number.POSITIVE_INFINITY : until };
47
+ };
48
+
49
+ // A window a sector has passed never reopens for it: when a later plan edit
50
+ // sends the sector back down the ladder, the transitional shapes it has
51
+ // already been through are not demanded again.
52
+ export const isShut = (
53
+ rule: CompiledCampaign,
54
+ objective: CompiledObjective,
55
+ position: SectorPosition,
56
+ ): boolean => {
57
+ const { until } = windowOf(rule, objective);
58
+ return Number.isFinite(until) && until <= position.reached;
59
+ };
60
+
61
+ export const inWindow = (
62
+ rule: CompiledCampaign,
63
+ objective: CompiledObjective,
64
+ phase: number,
65
+ position: SectorPosition,
66
+ ): boolean => {
67
+ if (isShut(rule, objective, position)) return false;
68
+ const { from, until } = windowOf(rule, objective);
69
+ return phase >= from && phase < until;
70
+ };
71
+
72
+ export const objectivesInWindow = (
73
+ rule: CompiledCampaign,
74
+ phase: number,
75
+ position: SectorPosition,
76
+ ): ReadonlyArray<CompiledObjective> =>
77
+ rule.objectives.filter((objective) => inWindow(rule, objective, phase, position));
78
+
79
+ // The first phase, in order, with residue for the sector: an objective it
80
+ // names with a holdout in the sector (and a window not shut), an attested
81
+ // phase not yet attested for it, or the open phase, which always has
82
+ // residue. A sector past the last phase is done.
83
+ export const derivePhase = (
84
+ rule: CompiledCampaign,
85
+ counts: (objectiveId: string) => number,
86
+ position: SectorPosition,
87
+ ): number => {
88
+ for (const [index, phase] of rule.phases.entries()) {
89
+ if (isOpenPhase(phase)) return index;
90
+ if (phase.attested && !position.attested.has(phase.id)) return index;
91
+ const residue = phase.objectives.some((objectiveId) => {
92
+ const objective = rule.objectives.find((one) => one.id === objectiveId);
93
+ return (
94
+ objective !== undefined && !isShut(rule, objective, position) && counts(objectiveId) > 0
95
+ );
96
+ });
97
+ if (residue) return index;
98
+ }
99
+ return donePhaseOf(rule);
100
+ };
101
+
102
+ // What a touched sector owes: the phase's own word, else the campaign's,
103
+ // else `advise` for an open phase and `ratchet` for a defined one.
104
+ export const onTouchOf = (rule: CompiledCampaign, phase: number): OnTouch => {
105
+ const at = rule.phases[phase];
106
+ return (
107
+ at?.onTouch ?? rule.onTouch ?? (at !== undefined && isOpenPhase(at) ? "advise" : "ratchet")
108
+ );
109
+ };
110
+
111
+ // The residue vector: one dimension per objective in window, never summed.
112
+ export type Residue = Readonly<Record<string, number>>;
113
+
114
+ export type Direction = "forward" | "back" | "mixed" | "neutral";
115
+
116
+ // Forward means no dimension is worse and at least one is better; back the
117
+ // reverse; anything else is mixed and is reported as such, never resolved
118
+ // by a sum.
119
+ export const compareResidue = (before: Residue, after: Residue): Direction => {
120
+ let better = false;
121
+ let worse = false;
122
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
123
+ const from = before[key] ?? 0;
124
+ const to = after[key] ?? 0;
125
+ if (to < from) better = true;
126
+ if (to > from) worse = true;
127
+ }
128
+ if (better && worse) return "mixed";
129
+ if (better) return "forward";
130
+ if (worse) return "back";
131
+ return "neutral";
132
+ };
133
+
134
+ // The dimensions that went back, by name.
135
+ export const worsened = (before: Residue, after: Residue): ReadonlyArray<string> =>
136
+ Object.keys(after)
137
+ .filter((key) => (after[key] ?? 0) > (before[key] ?? 0))
138
+ .sort();
@@ -0,0 +1,399 @@
1
+ import type { Violation } from "@goodbones/core";
2
+
3
+ import type { CompiledCampaign, CompiledPerimeter } from "./campaigns.js";
4
+
5
+ // A sector is the thing that moves through a campaign's phases as one,
6
+ // whatever its size — a bounded context, a component folder, a single file.
7
+ // It is never listed in the manifest: the code births it, through the
8
+ // campaign's perimeter, and this module is where that reading happens.
9
+ // Given the files in a campaign's scope (and, for a `match` perimeter, what
10
+ // the perimeter's detector finds in each), it says which sector every file
11
+ // is in, what the sector's roots are, and which files no sector claims —
12
+ // the legacy, which stands at the first phase and is not a sink.
13
+ //
14
+ // Holdouts are keyed relative to the sector's root, so a lift from one host
15
+ // to another moves nothing in the ledger. The root is the marker's folder,
16
+ // the matched glob, the file's own folder, or the repository for the
17
+ // implicit sector and the legacy.
18
+
19
+ // The one sector a campaign with no perimeter has: the whole scope.
20
+ export const IMPLICIT_SECTOR = "scope";
21
+
22
+ // What no sector has claimed.
23
+ export const LEGACY_SECTOR = "legacy";
24
+
25
+ export type Sector = {
26
+ readonly name: string;
27
+ // The folders the sector's files sit under, repo-relative without a
28
+ // trailing slash; `""` is the repository. The first is the primary root.
29
+ readonly roots: ReadonlyArray<string>;
30
+ readonly files: ReadonlyArray<string>;
31
+ // For a marker sector, the marker file; for a match sector, the
32
+ // declaration the perimeter matched.
33
+ readonly marker: string | null;
34
+ readonly declaration: { readonly file: string; readonly anchor: string } | null;
35
+ };
36
+
37
+ export type SectorIndex = {
38
+ readonly sectors: ReadonlyMap<string, Sector>;
39
+ // The legacy: files in the scope no sector claims.
40
+ readonly legacy: ReadonlyArray<string>;
41
+ // The sector a file is in — a sector's name, `LEGACY_SECTOR`, or `null`
42
+ // when the file is outside the campaign (outside the scope, or unclaimed
43
+ // and outside `legacy`).
44
+ readonly sectorOf: (file: string) => string | null;
45
+ // The sector a hit is in. For a `match` perimeter a file may hold several
46
+ // sectors, one per matched declaration; a hit is in the one whose anchor
47
+ // it carries, else in the file's only sector, else in the legacy.
48
+ readonly sectorOfHit: (file: string, subject: string | null) => string | null;
49
+ // Files two sectors claim — nested perimeters — which fail `check`.
50
+ readonly drift: ReadonlyArray<{ readonly file: string; readonly sectors: ReadonlyArray<string> }>;
51
+ };
52
+
53
+ // What a marker file says about its sector, when it exports a `sector`
54
+ // object: `export const sector = { name: "billing", owns: ["src/billing/**"] }`.
55
+ export type SectorMarker = {
56
+ readonly name?: string | undefined;
57
+ readonly owns?: ReadonlyArray<string> | undefined;
58
+ };
59
+
60
+ const MARKER_EXPORT = /export\s+const\s+sector(?:\s*:[^=]*)?\s*=\s*\{/;
61
+
62
+ // Reads the exported `sector` object out of a marker's text, tolerating the
63
+ // object literal as a person writes it — unquoted keys, single quotes, a
64
+ // trailing comma, `as const`. Nothing is executed. `null` when the file
65
+ // exports no such object; a throw when it does and the object cannot be read.
66
+ export const parseSectorMarker = (text: string): SectorMarker | null => {
67
+ const at = MARKER_EXPORT.exec(text);
68
+ if (at === null) return null;
69
+ const start = at.index + at[0].length - 1;
70
+ let depth = 0;
71
+ let end = -1;
72
+ for (let i = start; i < text.length; i += 1) {
73
+ const char = text[i];
74
+ if (char === "{") depth += 1;
75
+ else if (char === "}") {
76
+ depth -= 1;
77
+ if (depth === 0) {
78
+ end = i;
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ if (end === -1) throw new Error("the `sector` object's braces do not balance");
84
+ const literal = text
85
+ .slice(start, end + 1)
86
+ .replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_match, inner: string) => JSON.stringify(inner))
87
+ .replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, '$1"$2":')
88
+ .replace(/,\s*([}\]])/g, "$1");
89
+ let parsed: unknown;
90
+ try {
91
+ parsed = JSON.parse(literal);
92
+ } catch (cause) {
93
+ throw new Error(`the \`sector\` object is not a plain literal: ${String(cause)}`);
94
+ }
95
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
96
+ throw new Error("the `sector` export is not an object");
97
+ }
98
+ const record = parsed as Record<string, unknown>;
99
+ const name = record.name;
100
+ const owns = record.owns;
101
+ if (name !== undefined && typeof name !== "string") {
102
+ throw new Error("the `sector` object's `name` is not a string");
103
+ }
104
+ if (
105
+ owns !== undefined &&
106
+ (!Array.isArray(owns) || owns.some((one: unknown) => typeof one !== "string"))
107
+ ) {
108
+ throw new Error("the `sector` object's `owns` is not a list of globs");
109
+ }
110
+ return {
111
+ ...(name === undefined ? {} : { name }),
112
+ ...(owns === undefined ? {} : { owns: owns as ReadonlyArray<string> }),
113
+ };
114
+ };
115
+
116
+ const folderOf = (file: string): string => {
117
+ const at = file.lastIndexOf("/");
118
+ return at === -1 ? "" : file.slice(0, at);
119
+ };
120
+
121
+ const basenameOf = (file: string): string => file.slice(file.lastIndexOf("/") + 1);
122
+
123
+ // The path without its extension — a `file` sector's identity, so that
124
+ // `a.js → a.ts` is one sector before and after.
125
+ export const withoutExtension = (file: string): string => {
126
+ const base = basenameOf(file);
127
+ const dot = base.lastIndexOf(".");
128
+ return dot <= 0 ? file : file.slice(0, file.length - (base.length - dot));
129
+ };
130
+
131
+ // The fixed prefix of a glob — everything before its first wildcard, cut
132
+ // at a folder boundary — which is the root the glob's files sit under.
133
+ export const fixedPrefixOf = (glob: string): string => {
134
+ const wildcard = glob.search(/[*?[{]/);
135
+ const fixed = wildcard === -1 ? glob : glob.slice(0, wildcard);
136
+ const cut = fixed.lastIndexOf("/");
137
+ return cut === -1 ? "" : fixed.slice(0, cut);
138
+ };
139
+
140
+ const under = (root: string, file: string): boolean =>
141
+ root === "" || file === root || file.startsWith(`${root}/`);
142
+
143
+ export type SectorDiscovery = {
144
+ // The files in the campaign's scope, repo-relative.
145
+ readonly files: ReadonlyArray<string>;
146
+ readonly readText: (file: string) => string | null;
147
+ // Compiles a repo-relative glob a marker's `owns` lists, as the manifest
148
+ // compiles its own; the core owns no glob syntax.
149
+ readonly globToRegExp: (glob: string) => RegExp;
150
+ // For a `match` perimeter: the anchors the perimeter's detector finds in
151
+ // one file — one sector each.
152
+ readonly perimeterMatches?: ((file: string) => ReadonlyArray<string>) | undefined;
153
+ // For an `nx` perimeter: the workspace's projects, as the host read them.
154
+ readonly projects?: ReadonlyArray<{ readonly name: string; readonly root: string }> | undefined;
155
+ };
156
+
157
+ type Claim = { readonly sector: string; readonly root: string };
158
+
159
+ const indexOf = (
160
+ rule: CompiledCampaign,
161
+ files: ReadonlyArray<string>,
162
+ sectors: ReadonlyMap<string, Sector>,
163
+ claims: ReadonlyMap<string, ReadonlyArray<Claim>>,
164
+ ): SectorIndex => {
165
+ const seen = new Set(files);
166
+ const legacyOf = (file: string): string | null =>
167
+ rule.legacy === null || rule.legacy.some((pattern) => pattern.test(file))
168
+ ? LEGACY_SECTOR
169
+ : null;
170
+ // A file the campaign never saw — outside its scope, or not a source
171
+ // file at all — is in no sector.
172
+ const sectorOf = (file: string): string | null => {
173
+ if (!seen.has(file)) return null;
174
+ const claimed = claims.get(file);
175
+ if (claimed !== undefined && claimed.length > 0) return claimed[0]?.sector ?? null;
176
+ return legacyOf(file);
177
+ };
178
+ const legacy = files.filter((file) => sectorOf(file) === LEGACY_SECTOR);
179
+ const drift = [...claims.entries()]
180
+ .filter(([, claimed]) => new Set(claimed.map((one) => one.sector)).size > 1)
181
+ .map(([file, claimed]) => ({
182
+ file,
183
+ sectors: [...new Set(claimed.map((one) => one.sector))].sort(),
184
+ }));
185
+ const sectorOfHit = (file: string, subject: string | null): string | null => {
186
+ if (rule.perimeter?.kind !== "match") return sectorOf(file);
187
+ const claimed = claims.get(file) ?? [];
188
+ if (subject !== null) {
189
+ const anchor = subject.split("#")[0] ?? subject;
190
+ const own = claimed.find((one) => sectors.get(one.sector)?.declaration?.anchor === anchor);
191
+ if (own !== undefined) return own.sector;
192
+ }
193
+ if (claimed.length === 1) return claimed[0]?.sector ?? null;
194
+ return legacyOf(file);
195
+ };
196
+ return { sectors, legacy, sectorOf, sectorOfHit, drift };
197
+ };
198
+
199
+ // The sectors of one campaign, read off its files through its perimeter.
200
+ export const discoverSectors = (rule: CompiledCampaign, input: SectorDiscovery): SectorIndex => {
201
+ const files = [...input.files].sort();
202
+ const sectors = new Map<string, Sector>();
203
+ const claims = new Map<string, Array<Claim>>();
204
+ const claim = (file: string, sector: string, root: string): void => {
205
+ claims.set(file, [...(claims.get(file) ?? []), { sector, root }]);
206
+ };
207
+ const add = (sector: Sector): void => {
208
+ const known = sectors.get(sector.name);
209
+ // A name declared by two markers is one sector with both roots.
210
+ sectors.set(
211
+ sector.name,
212
+ known === undefined
213
+ ? sector
214
+ : {
215
+ ...known,
216
+ roots: [...new Set([...known.roots, ...sector.roots])],
217
+ files: [...new Set([...known.files, ...sector.files])].sort(),
218
+ },
219
+ );
220
+ for (const file of sector.files) claim(file, sector.name, rootOf(sector, file));
221
+ };
222
+
223
+ const perimeter: CompiledPerimeter | null = rule.perimeter;
224
+ if (perimeter === null) {
225
+ add({ name: IMPLICIT_SECTOR, roots: [""], files, marker: null, declaration: null });
226
+ return indexOf(rule, files, sectors, claims);
227
+ }
228
+
229
+ switch (perimeter.kind) {
230
+ case "file": {
231
+ for (const file of files) {
232
+ add({
233
+ name: withoutExtension(file),
234
+ roots: [folderOf(file)],
235
+ files: [file],
236
+ marker: null,
237
+ declaration: null,
238
+ });
239
+ }
240
+ break;
241
+ }
242
+ case "glob": {
243
+ const byName = new Map<string, Array<string>>();
244
+ for (const file of files) {
245
+ for (const pattern of perimeter.glob) {
246
+ const found = pattern.exec(file);
247
+ if (found === null) continue;
248
+ const name = found[0].replace(/\/$/, "");
249
+ byName.set(name, [...(byName.get(name) ?? []), file]);
250
+ break;
251
+ }
252
+ }
253
+ for (const [name, own] of byName) {
254
+ add({ name, roots: [name], files: own, marker: null, declaration: null });
255
+ }
256
+ break;
257
+ }
258
+ case "marker": {
259
+ const markers = files.filter((file) => perimeter.marker.some((one) => one.test(file)));
260
+ for (const marker of markers) {
261
+ const text = input.readText(marker) ?? "";
262
+ let read: SectorMarker | null;
263
+ try {
264
+ read = parseSectorMarker(text);
265
+ } catch (cause) {
266
+ throw new Error(`the sector marker ${marker} does not read: ${String(cause)}`);
267
+ }
268
+ const folder = folderOf(marker);
269
+ const name = read?.name ?? basenameOf(folder === "" ? "." : folder);
270
+ const owns = read?.owns ?? [];
271
+ const patterns = owns.map((glob) => input.globToRegExp(glob));
272
+ const roots = [folder, ...owns.map(fixedPrefixOf)].filter(
273
+ (root, at, all) => all.indexOf(root) === at,
274
+ );
275
+ const own = files.filter(
276
+ (file) =>
277
+ (owns.length === 0 && under(folder, file)) ||
278
+ patterns.some((pattern) => pattern.test(file)) ||
279
+ file === marker,
280
+ );
281
+ add({ name, roots, files: own, marker, declaration: null });
282
+ }
283
+ break;
284
+ }
285
+ case "match": {
286
+ const matches = input.perimeterMatches ?? (() => []);
287
+ for (const file of files) {
288
+ for (const anchor of matches(file)) {
289
+ add({
290
+ name: `${file}#${anchor}`,
291
+ roots: [folderOf(file)],
292
+ files: [file],
293
+ marker: null,
294
+ declaration: { file, anchor },
295
+ });
296
+ }
297
+ }
298
+ break;
299
+ }
300
+ case "nx": {
301
+ for (const project of input.projects ?? []) {
302
+ const own = files.filter((file) => under(project.root, file));
303
+ if (own.length === 0) continue;
304
+ add({
305
+ name: project.name,
306
+ roots: [project.root],
307
+ files: own,
308
+ marker: null,
309
+ declaration: null,
310
+ });
311
+ }
312
+ break;
313
+ }
314
+ }
315
+ return indexOf(rule, files, sectors, claims);
316
+ };
317
+
318
+ // The root a file of the sector sits under: the first of the sector's
319
+ // roots that prefixes it, so an entry written relative to it survives the
320
+ // sector moving as one.
321
+ export const rootOf = (sector: Sector, file: string): string =>
322
+ sector.roots.find((root) => under(root, file)) ?? "";
323
+
324
+ // A hit's ledger entry, relative to the sector's root: `file#subject`, with
325
+ // the file written from the root. A sector's own holdout (`holdout: sector`)
326
+ // is `~`, the sector itself.
327
+ export const SECTOR_HOLDOUT = "~";
328
+
329
+ export const entryOf = (violation: Violation, root: string): string => {
330
+ const stripped = root === "" ? violation.file : violation.file.slice(root.length + 1);
331
+ const relative = stripped === "" ? violation.file : stripped;
332
+ return violation.subject === null ? relative : `${relative}#${violation.subject}`;
333
+ };
334
+
335
+ // The sector a name belongs to in an index, the legacy included as a
336
+ // synthetic sector rooted at the repository.
337
+ export const sectorNamed = (index: SectorIndex, name: string): Sector | null =>
338
+ name === LEGACY_SECTOR
339
+ ? { name, roots: [""], files: index.legacy, marker: null, declaration: null }
340
+ : (index.sectors.get(name) ?? null);
341
+
342
+ // Where one file's hits fall, for a host that sees one file at a time. A
343
+ // `glob` or `file` perimeter answers from the path; `match` from the
344
+ // anchors the perimeter's detector found in the file; `marker` and `nx`
345
+ // from an index the host built at load, since those need the other files.
346
+ // A file outside the scope, or unclaimed and outside `legacy`, is in no
347
+ // sector, and the answer is `null`.
348
+ export type Placement = { readonly sector: string; readonly root: string };
349
+
350
+ export const membershipOf = (
351
+ rule: CompiledCampaign,
352
+ file: string,
353
+ index: SectorIndex | null,
354
+ anchors: ReadonlyArray<string>,
355
+ ): ((subject: string | null) => Placement | null) => {
356
+ const inScope = rule.scope.some((pattern) => pattern.test(file));
357
+ if (!inScope) return () => null;
358
+ const legacy = (): Placement | null =>
359
+ rule.legacy === null || rule.legacy.some((pattern) => pattern.test(file))
360
+ ? { sector: LEGACY_SECTOR, root: "" }
361
+ : null;
362
+ const perimeter = rule.perimeter;
363
+ if (perimeter === null) return () => ({ sector: IMPLICIT_SECTOR, root: "" });
364
+ switch (perimeter.kind) {
365
+ case "file": {
366
+ const placement = { sector: withoutExtension(file), root: folderOf(file) };
367
+ return () => placement;
368
+ }
369
+ case "glob": {
370
+ for (const pattern of perimeter.glob) {
371
+ const found = pattern.exec(file);
372
+ if (found === null) continue;
373
+ const name = found[0].replace(/\/$/, "");
374
+ return () => ({ sector: name, root: name });
375
+ }
376
+ return legacy;
377
+ }
378
+ case "match": {
379
+ const own = anchors.map((anchor) => ({ sector: `${file}#${anchor}`, root: folderOf(file) }));
380
+ return (subject) => {
381
+ if (subject !== null) {
382
+ const anchor = subject.split("#")[0] ?? subject;
383
+ const found = own.find((one) => one.sector === `${file}#${anchor}`);
384
+ if (found !== undefined) return found;
385
+ }
386
+ return own.length === 1 ? (own[0] ?? null) : legacy();
387
+ };
388
+ }
389
+ case "marker":
390
+ case "nx": {
391
+ if (index === null) return legacy;
392
+ const name = index.sectorOf(file);
393
+ if (name === null) return () => null;
394
+ const sector = sectorNamed(index, name);
395
+ const placement = { sector: name, root: sector === null ? "" : rootOf(sector, file) };
396
+ return () => placement;
397
+ }
398
+ }
399
+ };