@goodbones/core 0.1.0-beta.7 → 0.1.0-beta.9
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/build/dts/core/campaigns.d.ts +141 -0
- package/build/dts/core/campaigns.d.ts.map +1 -0
- package/build/dts/core/ledger.d.ts +61 -0
- package/build/dts/core/ledger.d.ts.map +1 -0
- package/build/dts/core/structure.d.ts +1 -0
- package/build/dts/core/structure.d.ts.map +1 -1
- package/build/dts/domain/architecture-config.d.ts +309 -1
- package/build/dts/domain/architecture-config.d.ts.map +1 -1
- package/build/dts/domain/architecture-error.d.ts +8 -0
- package/build/dts/domain/architecture-error.d.ts.map +1 -1
- package/build/dts/domain/facts.d.ts +1 -0
- package/build/dts/domain/facts.d.ts.map +1 -1
- package/build/dts/domain/report.d.ts +15 -0
- package/build/dts/domain/report.d.ts.map +1 -0
- package/build/dts/domain/snapshot.d.ts +55 -3
- package/build/dts/domain/snapshot.d.ts.map +1 -1
- package/build/dts/domain/violation.d.ts +1 -1
- package/build/dts/domain/violation.d.ts.map +1 -1
- package/build/dts/index.d.ts +12 -4
- package/build/dts/index.d.ts.map +1 -1
- 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/infrastructure/syntax-matcher-fake.d.ts +10 -0
- package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
- package/build/dts/load/policy.d.ts +14 -0
- package/build/dts/load/policy.d.ts.map +1 -1
- package/build/dts/manifest/compile.d.ts +2 -1
- package/build/dts/manifest/compile.d.ts.map +1 -1
- package/build/dts/manifest/json-schema.d.ts.map +1 -1
- package/build/dts/manifest/manifest.d.ts +362 -0
- package/build/dts/manifest/manifest.d.ts.map +1 -1
- package/build/dts/ports/campaign-predicate.d.ts +24 -0
- package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
- package/build/dts/ports/language.d.ts +2 -0
- package/build/dts/ports/language.d.ts.map +1 -1
- package/build/dts/ports/report-source.d.ts +12 -0
- package/build/dts/ports/report-source.d.ts.map +1 -0
- package/build/dts/ports/syntax-matcher.d.ts +21 -0
- package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
- package/build/dts/testing.d.ts +2 -0
- package/build/dts/testing.d.ts.map +1 -1
- package/build/esm/core/campaigns.js +778 -0
- package/build/esm/core/campaigns.js.map +1 -0
- package/build/esm/core/ledger.js +171 -0
- package/build/esm/core/ledger.js.map +1 -0
- package/build/esm/core/structure.js +5 -2
- package/build/esm/core/structure.js.map +1 -1
- package/build/esm/domain/architecture-config.js +133 -1
- package/build/esm/domain/architecture-config.js.map +1 -1
- package/build/esm/domain/architecture-error.js +27 -0
- package/build/esm/domain/architecture-error.js.map +1 -1
- package/build/esm/domain/report.js +151 -0
- package/build/esm/domain/report.js.map +1 -0
- package/build/esm/domain/snapshot.js +21 -0
- package/build/esm/domain/snapshot.js.map +1 -1
- package/build/esm/domain/violation.js.map +1 -1
- package/build/esm/index.js +8 -2
- package/build/esm/index.js.map +1 -1
- 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 +74 -0
- package/build/esm/infrastructure/report-source-live.js.map +1 -0
- package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
- package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
- package/build/esm/load/policy.js +121 -1
- package/build/esm/load/policy.js.map +1 -1
- package/build/esm/manifest/compile.js +186 -1
- package/build/esm/manifest/compile.js.map +1 -1
- package/build/esm/manifest/json-schema.js +7 -3
- package/build/esm/manifest/json-schema.js.map +1 -1
- package/build/esm/manifest/manifest.js +148 -1
- package/build/esm/manifest/manifest.js.map +1 -1
- 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 +2 -0
- package/build/esm/ports/report-source.js.map +1 -0
- package/build/esm/ports/syntax-matcher.js +12 -0
- package/build/esm/ports/syntax-matcher.js.map +1 -0
- package/build/esm/testing.js +2 -0
- package/build/esm/testing.js.map +1 -1
- package/package.json +1 -1
- package/schema/architecture-node.schema.json +843 -0
- package/schema/architecture.schema.json +1253 -0
- package/schema/conformance.schema.json +118 -3
- package/src/core/campaigns.ts +1056 -0
- package/src/core/ledger.ts +242 -0
- package/src/core/structure.ts +10 -5
- package/src/domain/architecture-config.ts +178 -1
- package/src/domain/architecture-error.ts +30 -0
- package/src/domain/facts.ts +5 -0
- package/src/domain/report.ts +186 -0
- package/src/domain/snapshot.ts +52 -0
- package/src/domain/violation.ts +4 -1
- package/src/index.ts +76 -0
- package/src/infrastructure/campaign-functions.ts +98 -0
- package/src/infrastructure/report-source-fake.ts +10 -0
- package/src/infrastructure/report-source-live.ts +80 -0
- package/src/infrastructure/syntax-matcher-fake.ts +51 -0
- package/src/load/policy.ts +187 -1
- package/src/manifest/compile.ts +221 -0
- package/src/manifest/json-schema.ts +7 -3
- package/src/manifest/manifest.ts +192 -1
- package/src/ports/campaign-predicate.ts +30 -0
- package/src/ports/language.ts +6 -0
- package/src/ports/report-source.ts +21 -0
- package/src/ports/syntax-matcher.ts +42 -0
- package/src/testing.ts +2 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// A campaign's `report` term names a pattern no parser of ours sees: a type
|
|
2
|
+
// error, a lint finding from another tool, anything a program prints one
|
|
3
|
+
// line per occurrence. This is the vocabulary such a report is read into —
|
|
4
|
+
// a diagnostic with a file, a position, a code and a message — and the
|
|
5
|
+
// readers for the formats a report comes in. Pure: text in, diagnostics out.
|
|
6
|
+
// Running the command or reading the file is the report source's business.
|
|
7
|
+
|
|
8
|
+
export type Diagnostic = {
|
|
9
|
+
// Repo-relative, forward slashes.
|
|
10
|
+
readonly file: string;
|
|
11
|
+
// Zero-based, as a syntax match's range is.
|
|
12
|
+
readonly line: number;
|
|
13
|
+
readonly column: number;
|
|
14
|
+
// `TS2551`, `eslint(no-unused-vars)`, `no-debugger` — whatever the tool
|
|
15
|
+
// calls the kind of finding; `""` when the format carries none.
|
|
16
|
+
readonly code: string;
|
|
17
|
+
// The first line of the message.
|
|
18
|
+
readonly message: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ReportFormat = "tsc" | "eslint" | "oxlint" | "regex";
|
|
22
|
+
|
|
23
|
+
export type ParseReportOptions = {
|
|
24
|
+
// Absolute paths in the report are made relative to this.
|
|
25
|
+
readonly repoRoot: string;
|
|
26
|
+
// `regex` only: a pattern with named groups `file` and `line`, and
|
|
27
|
+
// optionally `column`, `code` and `message`; one diagnostic per line that
|
|
28
|
+
// matches, positions one-based as tools print them.
|
|
29
|
+
readonly pattern?: string | undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const relativeTo = (repoRoot: string, file: string): string => {
|
|
33
|
+
const slashed = file.replaceAll("\\", "/");
|
|
34
|
+
const root = repoRoot.replaceAll("\\", "/").replace(/\/$/, "");
|
|
35
|
+
const relative =
|
|
36
|
+
slashed === root
|
|
37
|
+
? ""
|
|
38
|
+
: slashed.startsWith(`${root}/`)
|
|
39
|
+
? slashed.slice(root.length + 1)
|
|
40
|
+
: slashed;
|
|
41
|
+
return relative.replace(/^\.\//, "");
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const oneBased = (value: string | undefined, fallback = 1): number => {
|
|
45
|
+
const parsed = Number(value);
|
|
46
|
+
return Number.isFinite(parsed) && parsed >= 1 ? parsed - 1 : fallback - 1;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const firstLine = (message: string): string => message.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
50
|
+
|
|
51
|
+
// `src/a.ts(12,5): error TS2551: Property 'x' does not exist…`, one per
|
|
52
|
+
// line, with the continuation lines tsc indents beneath a message ignored —
|
|
53
|
+
// the first line is the message.
|
|
54
|
+
const TSC_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
|
|
55
|
+
|
|
56
|
+
const parseTsc = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
|
|
57
|
+
const found: Array<Diagnostic> = [];
|
|
58
|
+
for (const line of text.split(/\r?\n/)) {
|
|
59
|
+
const match = TSC_LINE.exec(line);
|
|
60
|
+
if (match === null) continue;
|
|
61
|
+
const [, file = "", row, column, code = "", message = ""] = match;
|
|
62
|
+
found.push({
|
|
63
|
+
file: relativeTo(options.repoRoot, file),
|
|
64
|
+
line: oneBased(row),
|
|
65
|
+
column: oneBased(column),
|
|
66
|
+
code,
|
|
67
|
+
message: firstLine(message),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
74
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
75
|
+
|
|
76
|
+
const asString = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
77
|
+
const asNumber = (value: unknown): number | undefined =>
|
|
78
|
+
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
79
|
+
|
|
80
|
+
const parseJson = (text: string, format: string): unknown => {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(text);
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
throw new Error(`a ${format} report is JSON, and this one does not parse: ${String(cause)}`);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// `eslint --format json`: one object per file with its messages. A message
|
|
89
|
+
// with no `ruleId` (a parse error) carries the code `""`.
|
|
90
|
+
const parseEslint = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
|
|
91
|
+
const parsed = parseJson(text, "eslint");
|
|
92
|
+
if (!Array.isArray(parsed)) throw new Error("an eslint report is a JSON array of files");
|
|
93
|
+
const found: Array<Diagnostic> = [];
|
|
94
|
+
for (const entry of parsed) {
|
|
95
|
+
if (!isRecord(entry) || !Array.isArray(entry.messages)) continue;
|
|
96
|
+
const file = relativeTo(options.repoRoot, asString(entry.filePath));
|
|
97
|
+
for (const message of entry.messages) {
|
|
98
|
+
if (!isRecord(message)) continue;
|
|
99
|
+
found.push({
|
|
100
|
+
file,
|
|
101
|
+
line: (asNumber(message.line) ?? 1) - 1,
|
|
102
|
+
column: (asNumber(message.column) ?? 1) - 1,
|
|
103
|
+
code: asString(message.ruleId),
|
|
104
|
+
message: firstLine(asString(message.message)),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return found;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// `oxlint --format json`: `{ diagnostics: [{ filename, code, message,
|
|
112
|
+
// labels: [{ span: { line, column } }] }] }`, positions one-based.
|
|
113
|
+
const parseOxlint = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
|
|
114
|
+
const parsed = parseJson(text, "oxlint");
|
|
115
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.diagnostics)) {
|
|
116
|
+
throw new Error("an oxlint report is a JSON object with a `diagnostics` array");
|
|
117
|
+
}
|
|
118
|
+
const found: Array<Diagnostic> = [];
|
|
119
|
+
for (const entry of parsed.diagnostics) {
|
|
120
|
+
if (!isRecord(entry)) continue;
|
|
121
|
+
const labels: ReadonlyArray<unknown> = Array.isArray(entry.labels) ? entry.labels : [];
|
|
122
|
+
const label: unknown = labels[0];
|
|
123
|
+
const span = isRecord(label) && isRecord(label.span) ? label.span : {};
|
|
124
|
+
found.push({
|
|
125
|
+
file: relativeTo(options.repoRoot, asString(entry.filename)),
|
|
126
|
+
line: (asNumber(span.line) ?? 1) - 1,
|
|
127
|
+
column: (asNumber(span.column) ?? 1) - 1,
|
|
128
|
+
code: asString(entry.code),
|
|
129
|
+
message: firstLine(asString(entry.message)),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return found;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const parseRegex = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
|
|
136
|
+
if (options.pattern === undefined) {
|
|
137
|
+
throw new Error("a `regex` report needs a `pattern` with named groups `file` and `line`");
|
|
138
|
+
}
|
|
139
|
+
const pattern = new RegExp(options.pattern);
|
|
140
|
+
const found: Array<Diagnostic> = [];
|
|
141
|
+
for (const line of text.split(/\r?\n/)) {
|
|
142
|
+
const match = pattern.exec(line);
|
|
143
|
+
if (match === null) continue;
|
|
144
|
+
const groups = match.groups ?? {};
|
|
145
|
+
if (groups.file === undefined) continue;
|
|
146
|
+
found.push({
|
|
147
|
+
file: relativeTo(options.repoRoot, groups.file),
|
|
148
|
+
line: oneBased(groups.line),
|
|
149
|
+
column: oneBased(groups.column),
|
|
150
|
+
code: groups.code ?? "",
|
|
151
|
+
message: firstLine(groups.message ?? ""),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return found;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const parseReport = (
|
|
158
|
+
format: ReportFormat,
|
|
159
|
+
text: string,
|
|
160
|
+
options: ParseReportOptions,
|
|
161
|
+
): ReadonlyArray<Diagnostic> => {
|
|
162
|
+
switch (format) {
|
|
163
|
+
case "tsc":
|
|
164
|
+
return parseTsc(text, options);
|
|
165
|
+
case "eslint":
|
|
166
|
+
return parseEslint(text, options);
|
|
167
|
+
case "oxlint":
|
|
168
|
+
return parseOxlint(text, options);
|
|
169
|
+
case "regex":
|
|
170
|
+
return parseRegex(text, options);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// The diagnostics indexed by file, which is how a per-file evaluator asks
|
|
175
|
+
// for them.
|
|
176
|
+
export const indexByFile = (
|
|
177
|
+
diagnostics: Iterable<Diagnostic>,
|
|
178
|
+
): ReadonlyMap<string, ReadonlyArray<Diagnostic>> => {
|
|
179
|
+
const byFile = new Map<string, Array<Diagnostic>>();
|
|
180
|
+
for (const one of diagnostics) {
|
|
181
|
+
const found = byFile.get(one.file);
|
|
182
|
+
if (found === undefined) byFile.set(one.file, [one]);
|
|
183
|
+
else found.push(one);
|
|
184
|
+
}
|
|
185
|
+
return byFile;
|
|
186
|
+
};
|
package/src/domain/snapshot.ts
CHANGED
|
@@ -34,6 +34,7 @@ const VIOLATION_KINDS = [
|
|
|
34
34
|
"member",
|
|
35
35
|
"surface",
|
|
36
36
|
"graph",
|
|
37
|
+
"campaign",
|
|
37
38
|
] as const satisfies ReadonlyArray<ViolationKind>;
|
|
38
39
|
|
|
39
40
|
const Path = describe(Schema.String, "Repo-relative, with forward slashes.");
|
|
@@ -63,6 +64,10 @@ export const SnapshotViolation = Schema.Struct({
|
|
|
63
64
|
),
|
|
64
65
|
message: Schema.String,
|
|
65
66
|
baselined: describe(Schema.Boolean, "Carried by the baseline, so `check` does not fail on it."),
|
|
67
|
+
ledgered: describe(
|
|
68
|
+
Schema.Boolean,
|
|
69
|
+
"For a campaign hit: carried by the campaign's ledger, so `check` does not fail on it.",
|
|
70
|
+
),
|
|
66
71
|
});
|
|
67
72
|
|
|
68
73
|
const AllowanceKind = describe(
|
|
@@ -109,6 +114,48 @@ export const SnapshotVacancy = Schema.Struct({
|
|
|
109
114
|
),
|
|
110
115
|
});
|
|
111
116
|
|
|
117
|
+
// One campaign's burn-down: what its ledger says, and what the clock says
|
|
118
|
+
// about it. Every count is over ledger entries, which are hits by fingerprint.
|
|
119
|
+
export const SnapshotCampaign = Schema.Struct({
|
|
120
|
+
id: describe(Schema.String, "The campaign's id; its ledger is `<ledger>/<id>.json`."),
|
|
121
|
+
title: Schema.optionalKey(describe(Schema.String, "The campaign's title, when it states one.")),
|
|
122
|
+
owner: Schema.optionalKey(
|
|
123
|
+
describe(Schema.String, "Who is running the campaign, as the manifest names them."),
|
|
124
|
+
),
|
|
125
|
+
initial: describe(Schema.Finite, "Entries the day the ledger was written."),
|
|
126
|
+
allowed: describe(
|
|
127
|
+
Schema.Finite,
|
|
128
|
+
"Entries added since by `campaigns allow`, each with a regression record.",
|
|
129
|
+
),
|
|
130
|
+
count: describe(Schema.Finite, "Entries in the ledger now."),
|
|
131
|
+
fixed: describe(
|
|
132
|
+
Schema.Finite,
|
|
133
|
+
"Entries removed by `campaigns prune` since the ledger was written.",
|
|
134
|
+
),
|
|
135
|
+
progress: describe(
|
|
136
|
+
Schema.Finite,
|
|
137
|
+
"`1 - count / (initial + allowed)`: how much of everything ever ledgered has been paid down.",
|
|
138
|
+
),
|
|
139
|
+
lastProgress: describe(
|
|
140
|
+
Schema.String,
|
|
141
|
+
"When an entry last left the ledger, ISO 8601 — the stall clock's reading.",
|
|
142
|
+
),
|
|
143
|
+
regressions: describe(Schema.Finite, "How many times the count was allowed to go up."),
|
|
144
|
+
stalled: describe(
|
|
145
|
+
Schema.Boolean,
|
|
146
|
+
"Entries remain and `lastProgress` is older than the campaign's `staleAfter`.",
|
|
147
|
+
),
|
|
148
|
+
complete: describe(Schema.Boolean, "No entries remain."),
|
|
149
|
+
onComplete: describe(
|
|
150
|
+
Schema.Literals(["keep", "remove"]),
|
|
151
|
+
"What the manifest asks once complete: keep the campaign as a guard, or remove it.",
|
|
152
|
+
),
|
|
153
|
+
ledgered: describe(
|
|
154
|
+
Schema.Boolean,
|
|
155
|
+
"Whether a ledger exists. A campaign with none has been declared and not yet initialised.",
|
|
156
|
+
),
|
|
157
|
+
});
|
|
158
|
+
|
|
112
159
|
export const Snapshot = Schema.Struct({
|
|
113
160
|
version: describe(Schema.Literal(SNAPSHOT_VERSION), "The shape of this document."),
|
|
114
161
|
manifest: describe(
|
|
@@ -187,6 +234,10 @@ export const Snapshot = Schema.Struct({
|
|
|
187
234
|
}),
|
|
188
235
|
'The tiers that said "not tightened yet", by name; `limits` caps how many may.',
|
|
189
236
|
),
|
|
237
|
+
campaigns: describe(
|
|
238
|
+
Schema.Array(SnapshotCampaign),
|
|
239
|
+
"Every campaign the manifest declares, in manifest order, with its burn-down. Campaign hits are not counted in `coverage` or `residue`: a campaign is scoped by construction.",
|
|
240
|
+
),
|
|
190
241
|
});
|
|
191
242
|
|
|
192
243
|
export type Snapshot = typeof Snapshot.Type;
|
|
@@ -194,6 +245,7 @@ export type SnapshotViolation = typeof SnapshotViolation.Type;
|
|
|
194
245
|
export type SnapshotSlack = typeof SnapshotSlack.Type;
|
|
195
246
|
export type SnapshotConcentration = typeof SnapshotConcentration.Type;
|
|
196
247
|
export type SnapshotVacancy = typeof SnapshotVacancy.Type;
|
|
248
|
+
export type SnapshotCampaign = typeof SnapshotCampaign.Type;
|
|
197
249
|
|
|
198
250
|
// Decodes a document some other run wrote — the base of a pull request, a
|
|
199
251
|
// stored one — refusing a key the shape does not declare, so a consumer never
|
package/src/domain/violation.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
// `campaign` is the one kind whose entries live in a ledger rather than the
|
|
2
|
+
// baseline: a hit is debt a campaign is paying down, not a rule being broken.
|
|
3
|
+
export type ViolationKind =
|
|
4
|
+
"import" | "export" | "structure" | "member" | "surface" | "graph" | "campaign";
|
|
2
5
|
|
|
3
6
|
export type Violation = {
|
|
4
7
|
readonly kind: ViolationKind;
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,25 @@ export {
|
|
|
14
14
|
staleEntriesOf,
|
|
15
15
|
unbaselined,
|
|
16
16
|
} from "./core/baseline.js";
|
|
17
|
+
export {
|
|
18
|
+
type CampaignHit,
|
|
19
|
+
type CampaignInput,
|
|
20
|
+
campaignsFailingTheirProbe,
|
|
21
|
+
campaignsSelecting,
|
|
22
|
+
compileCampaignRule,
|
|
23
|
+
compileCampaignRules,
|
|
24
|
+
type CompiledCampaign,
|
|
25
|
+
type CompiledDetector,
|
|
26
|
+
evaluateCampaign,
|
|
27
|
+
evaluateCampaigns,
|
|
28
|
+
explainCampaign,
|
|
29
|
+
type FailedProbe,
|
|
30
|
+
leafTermsOf,
|
|
31
|
+
matchKeyOf,
|
|
32
|
+
probeInputOf,
|
|
33
|
+
reportSpecsOf,
|
|
34
|
+
type TermAnswer,
|
|
35
|
+
} from "./core/campaigns.js";
|
|
17
36
|
export {
|
|
18
37
|
type Coverage,
|
|
19
38
|
type CoverageFamily,
|
|
@@ -64,6 +83,27 @@ export {
|
|
|
64
83
|
rulesSelecting,
|
|
65
84
|
type SelectedRule,
|
|
66
85
|
} from "./core/imports.js";
|
|
86
|
+
export {
|
|
87
|
+
allowed,
|
|
88
|
+
decodeLedger,
|
|
89
|
+
EMPTY_LEDGER,
|
|
90
|
+
entryOf,
|
|
91
|
+
isComplete,
|
|
92
|
+
isStalled,
|
|
93
|
+
type Ledger,
|
|
94
|
+
ledgerArithmeticHolds,
|
|
95
|
+
ledgerOf,
|
|
96
|
+
Ledger as LedgerSchema,
|
|
97
|
+
newEntriesOf,
|
|
98
|
+
progressOf,
|
|
99
|
+
pruned,
|
|
100
|
+
reconcile,
|
|
101
|
+
type Reconciliation,
|
|
102
|
+
type Regression,
|
|
103
|
+
type RegressionRecord,
|
|
104
|
+
serializeLedger,
|
|
105
|
+
staleEntriesOf as staleLedgerEntriesOf,
|
|
106
|
+
} from "./core/ledger.js";
|
|
67
107
|
export {
|
|
68
108
|
type CompiledMemberRule,
|
|
69
109
|
compileMemberRules,
|
|
@@ -96,7 +136,11 @@ export {
|
|
|
96
136
|
export {
|
|
97
137
|
type Allowance,
|
|
98
138
|
type BindingKind,
|
|
139
|
+
type CampaignProbe,
|
|
140
|
+
type CampaignRule,
|
|
141
|
+
type CampaignUnit,
|
|
99
142
|
type DeclarationKind,
|
|
143
|
+
type Detector,
|
|
100
144
|
type ExportFix,
|
|
101
145
|
type ExportRule,
|
|
102
146
|
type GraphConfig,
|
|
@@ -108,6 +152,7 @@ export {
|
|
|
108
152
|
type ImportRule,
|
|
109
153
|
type MemberRule,
|
|
110
154
|
type MemberSubject,
|
|
155
|
+
type ProbeDiagnostic,
|
|
111
156
|
type ResolveConfig,
|
|
112
157
|
type ResolveScope,
|
|
113
158
|
type StructureConfig,
|
|
@@ -117,6 +162,7 @@ export {
|
|
|
117
162
|
ConfigInvalid,
|
|
118
163
|
ImportUnresolved,
|
|
119
164
|
PatternInvalid,
|
|
165
|
+
ReportUnavailable,
|
|
120
166
|
ScopeInvalid,
|
|
121
167
|
} from "./domain/architecture-error.js";
|
|
122
168
|
export {
|
|
@@ -131,11 +177,19 @@ export {
|
|
|
131
177
|
type ManifestPosition,
|
|
132
178
|
renderManifestPath,
|
|
133
179
|
} from "./domain/manifest-location.js";
|
|
180
|
+
export {
|
|
181
|
+
type Diagnostic,
|
|
182
|
+
indexByFile,
|
|
183
|
+
parseReport,
|
|
184
|
+
type ParseReportOptions,
|
|
185
|
+
type ReportFormat,
|
|
186
|
+
} from "./domain/report.js";
|
|
134
187
|
export {
|
|
135
188
|
decodeSnapshot,
|
|
136
189
|
type Snapshot,
|
|
137
190
|
SNAPSHOT_SCHEMA_ID,
|
|
138
191
|
SNAPSHOT_VERSION,
|
|
192
|
+
type SnapshotCampaign,
|
|
139
193
|
type SnapshotConcentration,
|
|
140
194
|
snapshotJsonSchema,
|
|
141
195
|
Snapshot as SnapshotSchema,
|
|
@@ -149,6 +203,10 @@ export {
|
|
|
149
203
|
type Violation,
|
|
150
204
|
type ViolationKind,
|
|
151
205
|
} from "./domain/violation.js";
|
|
206
|
+
export {
|
|
207
|
+
loadCampaignFunctions,
|
|
208
|
+
type LoadedCampaignFunctions,
|
|
209
|
+
} from "./infrastructure/campaign-functions.js";
|
|
152
210
|
export { makeFileSystemLive } from "./infrastructure/file-system-live.js";
|
|
153
211
|
export {
|
|
154
212
|
findManifestFile,
|
|
@@ -163,6 +221,7 @@ export {
|
|
|
163
221
|
type IncludeReader,
|
|
164
222
|
type SourceDocument,
|
|
165
223
|
} from "./infrastructure/manifest-include.js";
|
|
224
|
+
export { makeReportSourceLive } from "./infrastructure/report-source-live.js";
|
|
166
225
|
export {
|
|
167
226
|
listPackageRoots,
|
|
168
227
|
listSourceFiles,
|
|
@@ -204,13 +263,23 @@ export {
|
|
|
204
263
|
manifestNodeJsonSchema,
|
|
205
264
|
} from "./manifest/json-schema.js";
|
|
206
265
|
export {
|
|
266
|
+
type CampaignSpec,
|
|
207
267
|
type DecodedManifest,
|
|
208
268
|
decodeManifest,
|
|
209
269
|
type DecodeManifestOptions,
|
|
270
|
+
DEFAULT_LEDGER_DIR,
|
|
271
|
+
type DetectorSpec,
|
|
272
|
+
durationMs,
|
|
210
273
|
type Manifest,
|
|
211
274
|
type ManifestNode,
|
|
212
275
|
Manifest as ManifestSchema,
|
|
213
276
|
} from "./manifest/manifest.js";
|
|
277
|
+
export {
|
|
278
|
+
type CampaignPredicate,
|
|
279
|
+
type CampaignPredicateInput,
|
|
280
|
+
type CampaignSubject,
|
|
281
|
+
type Range,
|
|
282
|
+
} from "./ports/campaign-predicate.js";
|
|
214
283
|
export { type FactExtractor } from "./ports/fact-extractor.js";
|
|
215
284
|
export { type FileSystem } from "./ports/file-system.js";
|
|
216
285
|
export { type Language } from "./ports/language.js";
|
|
@@ -219,3 +288,10 @@ export {
|
|
|
219
288
|
type ModuleResolver,
|
|
220
289
|
type ResolvedTarget,
|
|
221
290
|
} from "./ports/module-resolver.js";
|
|
291
|
+
export { NO_REPORTS, type ReportSource, type ReportSpec } from "./ports/report-source.js";
|
|
292
|
+
export {
|
|
293
|
+
type Position,
|
|
294
|
+
type SyntaxMatch,
|
|
295
|
+
type SyntaxMatcher,
|
|
296
|
+
type SyntaxTree,
|
|
297
|
+
} from "./ports/syntax-matcher.js";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { ConfigInvalid } from "../domain/architecture-error.js";
|
|
5
|
+
import type { CampaignPredicate } from "../ports/campaign-predicate.js";
|
|
6
|
+
|
|
7
|
+
// A campaign's `fn` term names a predicate function as `module#export`,
|
|
8
|
+
// resolved relative to the root manifest. Importing it touches the module
|
|
9
|
+
// loader, which is why this lives in infrastructure and both hosts call it
|
|
10
|
+
// before `loadPolicy`: the core receives the functions as a map and never
|
|
11
|
+
// imports anything.
|
|
12
|
+
//
|
|
13
|
+
// A `.mjs` manifest may write the function itself in place of the string.
|
|
14
|
+
// It is lifted out here, keyed by a synthesized name, and the manifest handed
|
|
15
|
+
// back with the string in its place — so the schema, which decodes a data
|
|
16
|
+
// file and a module alike, sees one shape. The whole value is walked, not
|
|
17
|
+
// only `campaigns`, so a term written in a `defs` fragment is found too.
|
|
18
|
+
|
|
19
|
+
export type LoadedCampaignFunctions = {
|
|
20
|
+
readonly functions: ReadonlyMap<string, CampaignPredicate>;
|
|
21
|
+
// The manifest with every function value replaced by its synthesized name.
|
|
22
|
+
readonly manifest: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
26
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
|
|
28
|
+
const REFERENCE = /^(.+)#([A-Za-z_$][\w$]*)$/;
|
|
29
|
+
|
|
30
|
+
// A `fn` term is an object whose one key is `fn`.
|
|
31
|
+
const isFnTerm = (value: Record<string, unknown>): boolean =>
|
|
32
|
+
"fn" in value && Object.keys(value).length === 1;
|
|
33
|
+
|
|
34
|
+
export const loadCampaignFunctions = async (
|
|
35
|
+
manifestPath: string,
|
|
36
|
+
rawManifest: unknown,
|
|
37
|
+
): Promise<LoadedCampaignFunctions> => {
|
|
38
|
+
const functions = new Map<string, CampaignPredicate>();
|
|
39
|
+
const root = path.dirname(manifestPath);
|
|
40
|
+
const refuse = (detail: string): never => {
|
|
41
|
+
throw new ConfigInvalid({ configPath: manifestPath, detail });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const imported = async (reference: string): Promise<void> => {
|
|
45
|
+
if (functions.has(reference)) return;
|
|
46
|
+
const parsed = REFERENCE.exec(reference);
|
|
47
|
+
if (parsed === null) {
|
|
48
|
+
return refuse(
|
|
49
|
+
`the \`fn\` term ${JSON.stringify(reference)} is not \`module#export\`: name the ` +
|
|
50
|
+
`module (relative to the manifest) and the export, joined by \`#\`.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const [, module = "", exportName = ""] = parsed;
|
|
54
|
+
const at = path.resolve(root, module);
|
|
55
|
+
let loaded: unknown;
|
|
56
|
+
try {
|
|
57
|
+
loaded = await import(pathToFileURL(at).href);
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
return refuse(
|
|
60
|
+
`the \`fn\` term ${JSON.stringify(reference)} names a module that does not load: ${String(cause)}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const found = isRecord(loaded) ? loaded[exportName] : undefined;
|
|
64
|
+
if (typeof found !== "function") {
|
|
65
|
+
return refuse(
|
|
66
|
+
`the \`fn\` term ${JSON.stringify(reference)} names an export that is not a function ` +
|
|
67
|
+
`(${found === undefined ? "absent" : typeof found}).`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
functions.set(reference, found as CampaignPredicate);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let synthesized = 0;
|
|
74
|
+
const lift = async (value: unknown): Promise<unknown> => {
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
const items: Array<unknown> = [];
|
|
77
|
+
for (const item of value) items.push(await lift(item));
|
|
78
|
+
return items;
|
|
79
|
+
}
|
|
80
|
+
if (!isRecord(value)) return value;
|
|
81
|
+
if (isFnTerm(value)) {
|
|
82
|
+
const fn = value.fn;
|
|
83
|
+
if (typeof fn === "function") {
|
|
84
|
+
synthesized += 1;
|
|
85
|
+
const name = `<manifest>#fn${String(synthesized)}`;
|
|
86
|
+
functions.set(name, fn as CampaignPredicate);
|
|
87
|
+
return { fn: name };
|
|
88
|
+
}
|
|
89
|
+
if (typeof fn === "string") await imported(fn);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
const rebuilt: Record<string, unknown> = {};
|
|
93
|
+
for (const [key, entry] of Object.entries(value)) rebuilt[key] = await lift(entry);
|
|
94
|
+
return rebuilt;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return { functions, manifest: await lift(rawManifest) };
|
|
98
|
+
};
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { ReportUnavailable } from "../domain/architecture-error.js";
|
|
6
|
+
import { type Diagnostic, indexByFile, parseReport } from "../domain/report.js";
|
|
7
|
+
import type { ReportSource, ReportSpec } from "../ports/report-source.js";
|
|
8
|
+
|
|
9
|
+
// The report source on this host: runs a `report` term's command from the
|
|
10
|
+
// repository root, or reads the file it names, and parses the output once —
|
|
11
|
+
// a report is about the whole repository, a campaign asks per file, and the
|
|
12
|
+
// plugin asks for one file at a time across a whole lint run. Cached per
|
|
13
|
+
// spec for the life of the process: the CLI is one process per `check`, and
|
|
14
|
+
// oxlint's language server is one process per editor session, which sees the
|
|
15
|
+
// report as of when it loaded the plugin. A report the build writes to a file
|
|
16
|
+
// is the predictable form for the editor, and for CI.
|
|
17
|
+
//
|
|
18
|
+
// A non-zero exit is not a failure — `tsc` exits 2 when there are errors,
|
|
19
|
+
// which is the case the term exists for. A command that cannot be spawned,
|
|
20
|
+
// or a file that is not there, is — and that failure is cached as the report
|
|
21
|
+
// would have been, so the second file to ask gets the same answer without a
|
|
22
|
+
// second spawn. The command is forked from the asking process; under the
|
|
23
|
+
// oxlint plugin that is the linter, which Linux refuses to fork once its AST
|
|
24
|
+
// buffers have merged into one mapping larger than RAM and swap — so the
|
|
25
|
+
// plugin asks at load, and retrying per file would only repeat the refusal.
|
|
26
|
+
|
|
27
|
+
const MAX_BUFFER = 256 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
const textOf = (repoRoot: string, spec: ReportSpec): string => {
|
|
30
|
+
if (spec.file !== undefined) {
|
|
31
|
+
const at = path.resolve(repoRoot, spec.file);
|
|
32
|
+
try {
|
|
33
|
+
return readFileSync(at, "utf8");
|
|
34
|
+
} catch (cause) {
|
|
35
|
+
throw new ReportUnavailable({ kind: "file", source: spec.file, detail: String(cause) });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const command = spec.command ?? "";
|
|
39
|
+
const run = spawnSync(command, {
|
|
40
|
+
cwd: repoRoot,
|
|
41
|
+
shell: true,
|
|
42
|
+
encoding: "utf8",
|
|
43
|
+
maxBuffer: MAX_BUFFER,
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
});
|
|
46
|
+
if (run.error !== undefined) {
|
|
47
|
+
throw new ReportUnavailable({ kind: "command", source: command, detail: run.error.message });
|
|
48
|
+
}
|
|
49
|
+
return run.stdout;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const keyOf = (spec: ReportSpec): string => JSON.stringify(spec);
|
|
53
|
+
|
|
54
|
+
type Indexed = ReadonlyMap<string, ReadonlyArray<Diagnostic>>;
|
|
55
|
+
|
|
56
|
+
export const makeReportSourceLive = (repoRoot: string): ReportSource => {
|
|
57
|
+
const cache = new Map<string, Indexed | ReportUnavailable>();
|
|
58
|
+
const answerOf = (spec: ReportSpec): Indexed | ReportUnavailable => {
|
|
59
|
+
try {
|
|
60
|
+
return indexByFile(
|
|
61
|
+
parseReport(spec.format, textOf(repoRoot, spec), { repoRoot, pattern: spec.pattern }),
|
|
62
|
+
);
|
|
63
|
+
} catch (cause) {
|
|
64
|
+
if (cause instanceof ReportUnavailable) return cause;
|
|
65
|
+
throw cause;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
diagnosticsOf: (spec, file) => {
|
|
70
|
+
const key = keyOf(spec);
|
|
71
|
+
let answer = cache.get(key);
|
|
72
|
+
if (answer === undefined) {
|
|
73
|
+
answer = answerOf(spec);
|
|
74
|
+
cache.set(key, answer);
|
|
75
|
+
}
|
|
76
|
+
if (answer instanceof ReportUnavailable) throw answer;
|
|
77
|
+
return answer.get(file) ?? [];
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SyntaxMatch, SyntaxMatcher } from "../ports/syntax-matcher.js";
|
|
2
|
+
|
|
3
|
+
// One match as a test states it: the text, its captures as a plain object,
|
|
4
|
+
// the anchor, and optionally a line — the rest of the range is filled in.
|
|
5
|
+
// `rule` narrows the match to one rule (compared by its JSON), so a test
|
|
6
|
+
// about two `syntax` terms can answer each differently; omit it to answer
|
|
7
|
+
// every rule the same.
|
|
8
|
+
export type StagedMatch = {
|
|
9
|
+
readonly text: string;
|
|
10
|
+
readonly captures?: Readonly<Record<string, string>>;
|
|
11
|
+
readonly anchor?: string | null;
|
|
12
|
+
readonly line?: number;
|
|
13
|
+
readonly rule?: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ZERO = { line: 0, column: 0 };
|
|
17
|
+
|
|
18
|
+
// Keyed by the source text, as the extractor fake is: a core test states
|
|
19
|
+
// what a snippet's syntax yields and never the parse, which has its own
|
|
20
|
+
// tests in the matcher package. A text not staged parses to no matches; a
|
|
21
|
+
// file whose extension is not among `extensions` does not parse at all,
|
|
22
|
+
// which is the shape of a language with no matcher.
|
|
23
|
+
export const makeSyntaxMatcherFake = (
|
|
24
|
+
staged: Readonly<Record<string, ReadonlyArray<StagedMatch>>>,
|
|
25
|
+
extensions: ReadonlyArray<string> | null = null,
|
|
26
|
+
): SyntaxMatcher => ({
|
|
27
|
+
parse: (file, text) => {
|
|
28
|
+
if (extensions !== null && !extensions.some((extension) => file.endsWith(extension))) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const matches = staged[text] ?? [];
|
|
32
|
+
return {
|
|
33
|
+
// A staged match's line is what a position is anchored by.
|
|
34
|
+
anchorAt: (position) => matches.find((one) => one.line === position.line)?.anchor ?? null,
|
|
35
|
+
findAll: (rule) => {
|
|
36
|
+
const asked = JSON.stringify(rule);
|
|
37
|
+
return matches
|
|
38
|
+
.filter((one) => one.rule === undefined || JSON.stringify(one.rule) === asked)
|
|
39
|
+
.map((one): SyntaxMatch => ({
|
|
40
|
+
text: one.text,
|
|
41
|
+
captures: new Map(Object.entries(one.captures ?? {})),
|
|
42
|
+
range: {
|
|
43
|
+
start: one.line === undefined ? ZERO : { line: one.line, column: 0 },
|
|
44
|
+
end: one.line === undefined ? ZERO : { line: one.line, column: one.text.length },
|
|
45
|
+
},
|
|
46
|
+
anchor: one.anchor ?? null,
|
|
47
|
+
}));
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
});
|