@goodbones/core 0.1.0-beta.10 → 0.1.0-beta.12
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/structure.d.ts.map +1 -1
- package/build/dts/domain/architecture-config.d.ts +3 -310
- package/build/dts/domain/architecture-config.d.ts.map +1 -1
- package/build/dts/domain/snapshot.d.ts +150 -18
- package/build/dts/domain/snapshot.d.ts.map +1 -1
- package/build/dts/index.d.ts +11 -14
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -1
- package/build/dts/infrastructure/file-system-live.d.ts.map +1 -1
- package/build/dts/infrastructure/walk.d.ts +6 -1
- package/build/dts/infrastructure/walk.d.ts.map +1 -1
- package/build/dts/load/extension.d.ts +33 -0
- package/build/dts/load/extension.d.ts.map +1 -0
- package/build/dts/load/policy.d.ts +4 -10
- package/build/dts/load/policy.d.ts.map +1 -1
- package/build/dts/manifest/compile.d.ts +7 -2
- package/build/dts/manifest/compile.d.ts.map +1 -1
- package/build/dts/manifest/extension.d.ts +8 -0
- package/build/dts/manifest/extension.d.ts.map +1 -0
- package/build/dts/manifest/glob.d.ts +1 -0
- package/build/dts/manifest/glob.d.ts.map +1 -1
- package/build/dts/manifest/json-schema.d.ts +6 -2
- package/build/dts/manifest/json-schema.d.ts.map +1 -1
- package/build/dts/manifest/manifest.d.ts +7 -362
- package/build/dts/manifest/manifest.d.ts.map +1 -1
- package/build/dts/ports/file-system.d.ts +1 -0
- package/build/dts/ports/file-system.d.ts.map +1 -1
- package/build/dts/testing.d.ts +0 -1
- package/build/dts/testing.d.ts.map +1 -1
- package/build/esm/core/structure.js +1 -1
- package/build/esm/core/structure.js.map +1 -1
- package/build/esm/domain/architecture-config.js +3 -136
- package/build/esm/domain/architecture-config.js.map +1 -1
- package/build/esm/domain/snapshot.js +55 -15
- package/build/esm/domain/snapshot.js.map +1 -1
- package/build/esm/index.js +16 -10
- package/build/esm/index.js.map +1 -1
- package/build/esm/infrastructure/file-system-fake.js +10 -0
- package/build/esm/infrastructure/file-system-fake.js.map +1 -1
- package/build/esm/infrastructure/file-system-live.js +9 -1
- package/build/esm/infrastructure/file-system-live.js.map +1 -1
- package/build/esm/infrastructure/walk.js +50 -4
- package/build/esm/infrastructure/walk.js.map +1 -1
- package/build/esm/load/extension.js +2 -0
- package/build/esm/load/extension.js.map +1 -0
- package/build/esm/load/policy.js +43 -116
- package/build/esm/load/policy.js.map +1 -1
- package/build/esm/manifest/compile.js +3 -182
- package/build/esm/manifest/compile.js.map +1 -1
- package/build/esm/manifest/extension.js +2 -0
- package/build/esm/manifest/extension.js.map +1 -0
- package/build/esm/manifest/glob.js +3 -0
- package/build/esm/manifest/glob.js.map +1 -1
- package/build/esm/manifest/json-schema.js +45 -11
- package/build/esm/manifest/json-schema.js.map +1 -1
- package/build/esm/manifest/manifest.js +56 -172
- package/build/esm/manifest/manifest.js.map +1 -1
- package/build/esm/testing.js +0 -1
- package/build/esm/testing.js.map +1 -1
- package/package.json +1 -1
- package/schema/architecture-node.schema.json +0 -887
- package/schema/architecture.schema.json +3264 -1591
- package/schema/conformance.schema.json +291 -47
- package/src/core/structure.ts +5 -3
- package/src/domain/architecture-config.ts +3 -180
- package/src/domain/snapshot.ts +125 -25
- package/src/index.ts +51 -78
- package/src/infrastructure/file-system-fake.ts +12 -0
- package/src/infrastructure/file-system-live.ts +8 -1
- package/src/infrastructure/walk.ts +58 -3
- package/src/load/extension.ts +66 -0
- package/src/load/policy.ts +60 -175
- package/src/manifest/compile.ts +2 -213
- package/src/manifest/extension.ts +41 -0
- package/src/manifest/glob.ts +5 -0
- package/src/manifest/json-schema.ts +53 -11
- package/src/manifest/manifest.ts +82 -214
- package/src/ports/file-system.ts +3 -0
- package/src/testing.ts +0 -1
- package/build/dts/core/campaigns.d.ts +0 -141
- package/build/dts/core/campaigns.d.ts.map +0 -1
- package/build/dts/core/ledger.d.ts +0 -61
- package/build/dts/core/ledger.d.ts.map +0 -1
- package/build/dts/domain/report.d.ts +0 -16
- package/build/dts/domain/report.d.ts.map +0 -1
- package/build/dts/infrastructure/campaign-functions.d.ts +0 -7
- package/build/dts/infrastructure/campaign-functions.d.ts.map +0 -1
- package/build/dts/infrastructure/report-source-fake.d.ts +0 -4
- package/build/dts/infrastructure/report-source-fake.d.ts.map +0 -1
- package/build/dts/infrastructure/report-source-live.d.ts +0 -3
- package/build/dts/infrastructure/report-source-live.d.ts.map +0 -1
- package/build/dts/ports/campaign-predicate.d.ts +0 -24
- package/build/dts/ports/campaign-predicate.d.ts.map +0 -1
- package/build/dts/ports/report-source.d.ts +0 -14
- package/build/dts/ports/report-source.d.ts.map +0 -1
- package/build/esm/core/campaigns.js +0 -778
- package/build/esm/core/campaigns.js.map +0 -1
- package/build/esm/core/ledger.js +0 -171
- package/build/esm/core/ledger.js.map +0 -1
- package/build/esm/domain/report.js +0 -168
- package/build/esm/domain/report.js.map +0 -1
- package/build/esm/infrastructure/campaign-functions.js +0 -67
- package/build/esm/infrastructure/campaign-functions.js.map +0 -1
- package/build/esm/infrastructure/report-source-fake.js +0 -6
- package/build/esm/infrastructure/report-source-fake.js.map +0 -1
- package/build/esm/infrastructure/report-source-live.js +0 -165
- package/build/esm/infrastructure/report-source-live.js.map +0 -1
- package/build/esm/ports/campaign-predicate.js +0 -2
- package/build/esm/ports/campaign-predicate.js.map +0 -1
- package/build/esm/ports/report-source.js +0 -7
- package/build/esm/ports/report-source.js.map +0 -1
- package/src/core/campaigns.ts +0 -1056
- package/src/core/ledger.ts +0 -242
- package/src/domain/report.ts +0 -203
- package/src/infrastructure/campaign-functions.ts +0 -98
- package/src/infrastructure/report-source-fake.ts +0 -10
- package/src/infrastructure/report-source-live.ts +0 -192
- package/src/ports/campaign-predicate.ts +0 -30
- package/src/ports/report-source.ts +0 -35
package/src/core/ledger.ts
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import * as Result from "effect/Result";
|
|
2
|
-
import * as Schema from "effect/Schema";
|
|
3
|
-
|
|
4
|
-
import type { CampaignUnit } from "../domain/architecture-config.js";
|
|
5
|
-
import type { Violation } from "../domain/violation.js";
|
|
6
|
-
|
|
7
|
-
// A campaign's ledger: every place the pattern still occurs, and the record of
|
|
8
|
-
// every time the count was allowed to go up. It is the baseline's cousin and
|
|
9
|
-
// not the baseline — same fingerprint, different file, different semantics.
|
|
10
|
-
//
|
|
11
|
-
// - One file per campaign, so two pull requests migrating different
|
|
12
|
-
// components never conflict, and finishing a campaign is deleting its file.
|
|
13
|
-
// - Growth is only ever recorded, never silent. `prune` only removes;
|
|
14
|
-
// `allow` is the one way an entry is added, and it always appends a
|
|
15
|
-
// regression with a reason, a timestamp and an author.
|
|
16
|
-
// - The arithmetic is checked: `entries.length === initial + Σ delta − fixed`.
|
|
17
|
-
// A hand-added entry with no regression record fails the build, with no
|
|
18
|
-
// git history in the loop.
|
|
19
|
-
// - A fixed entry is stale, and `check` fails on it as it fails on a stale
|
|
20
|
-
// baseline entry, so progress lands as a visible diff.
|
|
21
|
-
|
|
22
|
-
const Regression = Schema.Struct({
|
|
23
|
-
at: Schema.String,
|
|
24
|
-
by: Schema.String,
|
|
25
|
-
delta: Schema.Finite,
|
|
26
|
-
reason: Schema.String,
|
|
27
|
-
entries: Schema.Array(Schema.String),
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
export const Ledger = Schema.Struct({
|
|
31
|
-
version: Schema.Literal(1),
|
|
32
|
-
id: Schema.String,
|
|
33
|
-
created: Schema.String,
|
|
34
|
-
// The count on the day the ledger was written.
|
|
35
|
-
initial: Schema.Finite,
|
|
36
|
-
// Entries removed by `prune` since.
|
|
37
|
-
fixed: Schema.Finite,
|
|
38
|
-
// When an entry last left the ledger — what the stall clock reads.
|
|
39
|
-
lastProgress: Schema.String,
|
|
40
|
-
regressions: Schema.Array(Regression),
|
|
41
|
-
// `file` or `file#subject`, deduplicated and sorted.
|
|
42
|
-
entries: Schema.Array(Schema.String),
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
export type Ledger = typeof Ledger.Type;
|
|
46
|
-
export type Regression = typeof Regression.Type;
|
|
47
|
-
|
|
48
|
-
const decode = Schema.decodeUnknownResult(Ledger, { errors: "all", onExcessProperty: "error" });
|
|
49
|
-
|
|
50
|
-
export const EMPTY_LEDGER = (id: string, now: number): Ledger => {
|
|
51
|
-
const at = new Date(now).toISOString();
|
|
52
|
-
return {
|
|
53
|
-
version: 1,
|
|
54
|
-
id,
|
|
55
|
-
created: at,
|
|
56
|
-
initial: 0,
|
|
57
|
-
fixed: 0,
|
|
58
|
-
lastProgress: at,
|
|
59
|
-
regressions: [],
|
|
60
|
-
entries: [],
|
|
61
|
-
};
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
// A malformed ledger is refused, unlike a malformed baseline, which reads as
|
|
65
|
-
// empty: an empty baseline reports every violation, the safe direction, while
|
|
66
|
-
// an empty ledger would report every hit as unrecorded growth and demand a
|
|
67
|
-
// regression record for debt that was already counted.
|
|
68
|
-
export const decodeLedger = (raw: unknown): Result.Result<Ledger, string> => {
|
|
69
|
-
const decoded = decode(raw);
|
|
70
|
-
return Result.isFailure(decoded)
|
|
71
|
-
? Result.fail(String(decoded.failure.issue))
|
|
72
|
-
: Result.succeed(decoded.success);
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
export const serializeLedger = (ledger: Ledger): string => `${JSON.stringify(ledger, null, 2)}\n`;
|
|
76
|
-
|
|
77
|
-
// A hit's entry: the file, and its subject when the campaign's unit has one.
|
|
78
|
-
export const entryOf = (violation: Violation): string =>
|
|
79
|
-
violation.subject === null ? violation.file : `${violation.file}#${violation.subject}`;
|
|
80
|
-
|
|
81
|
-
const allowedTotal = (ledger: Ledger): number =>
|
|
82
|
-
ledger.initial + ledger.regressions.reduce((sum, one) => sum + one.delta, 0);
|
|
83
|
-
|
|
84
|
-
export const ledgerArithmeticHolds = (ledger: Ledger): boolean =>
|
|
85
|
-
ledger.entries.length === allowedTotal(ledger) - ledger.fixed;
|
|
86
|
-
|
|
87
|
-
// A `match` entry is `file#anchor#hash`; an edit inside the anchored
|
|
88
|
-
// declaration changes the hash and nothing else, and is the same entry.
|
|
89
|
-
const anchorOf = (entry: string): string => entry.slice(0, entry.lastIndexOf("#"));
|
|
90
|
-
|
|
91
|
-
export type Reconciliation = {
|
|
92
|
-
// Hits the ledger already carries, exactly or by anchor.
|
|
93
|
-
readonly ledgered: ReadonlyArray<Violation>;
|
|
94
|
-
// Hits the ledger does not carry: unrecorded growth.
|
|
95
|
-
readonly unrecorded: ReadonlyArray<Violation>;
|
|
96
|
-
// Entries no hit produces: fixed, and waiting to be pruned.
|
|
97
|
-
readonly stale: ReadonlyArray<string>;
|
|
98
|
-
// Entries whose hash moved under a still-present anchor, with what they
|
|
99
|
-
// read now. `prune` rewrites them; they count as neither fixed nor new.
|
|
100
|
-
readonly drifted: ReadonlyArray<{ readonly from: string; readonly to: string }>;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
export const reconcile = (
|
|
104
|
-
ledger: Ledger,
|
|
105
|
-
hits: Iterable<Violation>,
|
|
106
|
-
unit: CampaignUnit,
|
|
107
|
-
): Reconciliation => {
|
|
108
|
-
const entries = new Set(ledger.entries);
|
|
109
|
-
const ledgered: Array<Violation> = [];
|
|
110
|
-
const unmatched: Array<Violation> = [];
|
|
111
|
-
const current = new Set<string>();
|
|
112
|
-
for (const hit of hits) {
|
|
113
|
-
const entry = entryOf(hit);
|
|
114
|
-
current.add(entry);
|
|
115
|
-
if (entries.has(entry)) ledgered.push(hit);
|
|
116
|
-
else unmatched.push(hit);
|
|
117
|
-
}
|
|
118
|
-
let stale = ledger.entries.filter((entry) => !current.has(entry));
|
|
119
|
-
const drifted: Array<{ from: string; to: string }> = [];
|
|
120
|
-
const unrecorded: Array<Violation> = [];
|
|
121
|
-
|
|
122
|
-
if (unit === "match") {
|
|
123
|
-
// Pair each stale entry with an unmatched hit under the same anchor, in
|
|
124
|
-
// order, so a declaration with two edited matches keeps two entries.
|
|
125
|
-
const byAnchor = new Map<string, Array<string>>();
|
|
126
|
-
for (const entry of stale) {
|
|
127
|
-
const anchor = anchorOf(entry);
|
|
128
|
-
byAnchor.set(anchor, [...(byAnchor.get(anchor) ?? []), entry]);
|
|
129
|
-
}
|
|
130
|
-
const paired = new Set<string>();
|
|
131
|
-
for (const hit of unmatched) {
|
|
132
|
-
const entry = entryOf(hit);
|
|
133
|
-
const candidates = byAnchor.get(anchorOf(entry));
|
|
134
|
-
const from = candidates?.shift();
|
|
135
|
-
if (from === undefined) {
|
|
136
|
-
unrecorded.push(hit);
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
paired.add(from);
|
|
140
|
-
drifted.push({ from, to: entry });
|
|
141
|
-
ledgered.push(hit);
|
|
142
|
-
}
|
|
143
|
-
stale = stale.filter((entry) => !paired.has(entry));
|
|
144
|
-
} else {
|
|
145
|
-
for (const hit of unmatched) unrecorded.push(hit);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return { ledgered, unrecorded, stale, drifted };
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
export const staleEntriesOf = (
|
|
152
|
-
ledger: Ledger,
|
|
153
|
-
hits: Iterable<Violation>,
|
|
154
|
-
unit: CampaignUnit,
|
|
155
|
-
): ReadonlyArray<string> => reconcile(ledger, hits, unit).stale;
|
|
156
|
-
|
|
157
|
-
export const newEntriesOf = (
|
|
158
|
-
ledger: Ledger,
|
|
159
|
-
hits: Iterable<Violation>,
|
|
160
|
-
unit: CampaignUnit,
|
|
161
|
-
): ReadonlyArray<string> => reconcile(ledger, hits, unit).unrecorded.map(entryOf);
|
|
162
|
-
|
|
163
|
-
const sorted = (entries: Iterable<string>): ReadonlyArray<string> => [...new Set(entries)].sort();
|
|
164
|
-
|
|
165
|
-
// A first ledger: every hit is an entry, and the count is the `initial`.
|
|
166
|
-
export const ledgerOf = (id: string, hits: Iterable<Violation>, now: number): Ledger => {
|
|
167
|
-
const entries = sorted([...hits].map(entryOf));
|
|
168
|
-
return { ...EMPTY_LEDGER(id, now), initial: entries.length, entries };
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
// `prune`: the stale entries leave, `fixed` rises by as many, and
|
|
172
|
-
// `lastProgress` moves — only when something left. A drifted entry is
|
|
173
|
-
// rewritten in place and counts as neither.
|
|
174
|
-
export const pruned = (
|
|
175
|
-
ledger: Ledger,
|
|
176
|
-
hits: Iterable<Violation>,
|
|
177
|
-
unit: CampaignUnit,
|
|
178
|
-
now: number,
|
|
179
|
-
): Ledger => {
|
|
180
|
-
const { drifted, stale } = reconcile(ledger, hits, unit);
|
|
181
|
-
const rewritten = new Map(drifted.map((one) => [one.from, one.to]));
|
|
182
|
-
const gone = new Set(stale);
|
|
183
|
-
const entries = sorted(
|
|
184
|
-
ledger.entries
|
|
185
|
-
.filter((entry) => !gone.has(entry))
|
|
186
|
-
.map((entry) => rewritten.get(entry) ?? entry),
|
|
187
|
-
);
|
|
188
|
-
return {
|
|
189
|
-
...ledger,
|
|
190
|
-
fixed: ledger.fixed + stale.length,
|
|
191
|
-
lastProgress: stale.length > 0 ? new Date(now).toISOString() : ledger.lastProgress,
|
|
192
|
-
entries,
|
|
193
|
-
};
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
export type RegressionRecord = {
|
|
197
|
-
readonly at: number;
|
|
198
|
-
readonly by: string;
|
|
199
|
-
readonly reason: string;
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
// `allow`: the entries join the ledger, and a regression records that they
|
|
203
|
-
// did. The delta is what was actually added — an entry already present is
|
|
204
|
-
// not growth.
|
|
205
|
-
export const allowed = (
|
|
206
|
-
ledger: Ledger,
|
|
207
|
-
entries: Iterable<string>,
|
|
208
|
-
record: RegressionRecord,
|
|
209
|
-
): Ledger => {
|
|
210
|
-
const present = new Set(ledger.entries);
|
|
211
|
-
const added = sorted([...entries].filter((entry) => !present.has(entry)));
|
|
212
|
-
if (added.length === 0) return ledger;
|
|
213
|
-
return {
|
|
214
|
-
...ledger,
|
|
215
|
-
regressions: [
|
|
216
|
-
...ledger.regressions,
|
|
217
|
-
{
|
|
218
|
-
at: new Date(record.at).toISOString(),
|
|
219
|
-
by: record.by,
|
|
220
|
-
delta: added.length,
|
|
221
|
-
reason: record.reason,
|
|
222
|
-
entries: added,
|
|
223
|
-
},
|
|
224
|
-
],
|
|
225
|
-
entries: sorted([...ledger.entries, ...added]),
|
|
226
|
-
};
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
// `1 − count / (initial + Σ delta)`: how much of everything the campaign was
|
|
230
|
-
// ever asked to pay down has been paid.
|
|
231
|
-
export const progressOf = (ledger: Ledger): number => {
|
|
232
|
-
const total = allowedTotal(ledger);
|
|
233
|
-
return total === 0 ? 1 : 1 - ledger.entries.length / total;
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
export const isComplete = (ledger: Ledger): boolean => ledger.entries.length === 0;
|
|
237
|
-
|
|
238
|
-
export const isStalled = (
|
|
239
|
-
rule: { readonly staleAfter: number },
|
|
240
|
-
ledger: Ledger,
|
|
241
|
-
now: number,
|
|
242
|
-
): boolean => !isComplete(ledger) && now - Date.parse(ledger.lastProgress) > rule.staleAfter;
|
package/src/domain/report.ts
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
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
|
-
// One report from several outputs. A tool that takes one project at a time
|
|
175
|
-
// is run once per project, and a project's program includes the files of
|
|
176
|
-
// the projects it references, so one diagnostic is printed under several
|
|
177
|
-
// runs; it is one diagnostic. Kept once, at its first appearance, when
|
|
178
|
-
// identical in file, position, code and message.
|
|
179
|
-
export const uniqueDiagnostics = (diagnostics: Iterable<Diagnostic>): ReadonlyArray<Diagnostic> => {
|
|
180
|
-
const seen = new Set<string>();
|
|
181
|
-
const kept: Array<Diagnostic> = [];
|
|
182
|
-
for (const one of diagnostics) {
|
|
183
|
-
const key = JSON.stringify([one.file, one.line, one.column, one.code, one.message]);
|
|
184
|
-
if (seen.has(key)) continue;
|
|
185
|
-
seen.add(key);
|
|
186
|
-
kept.push(one);
|
|
187
|
-
}
|
|
188
|
-
return kept;
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
// The diagnostics indexed by file, which is how a per-file evaluator asks
|
|
192
|
-
// for them.
|
|
193
|
-
export const indexByFile = (
|
|
194
|
-
diagnostics: Iterable<Diagnostic>,
|
|
195
|
-
): ReadonlyMap<string, ReadonlyArray<Diagnostic>> => {
|
|
196
|
-
const byFile = new Map<string, Array<Diagnostic>>();
|
|
197
|
-
for (const one of diagnostics) {
|
|
198
|
-
const found = byFile.get(one.file);
|
|
199
|
-
if (found === undefined) byFile.set(one.file, [one]);
|
|
200
|
-
else found.push(one);
|
|
201
|
-
}
|
|
202
|
-
return byFile;
|
|
203
|
-
};
|
|
@@ -1,98 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,10 +0,0 @@
|
|
|
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
|
-
});
|