@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,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
|
+
};
|