@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/load/policy.ts
CHANGED
|
@@ -7,13 +7,6 @@ import {
|
|
|
7
7
|
EMPTY_BASELINE,
|
|
8
8
|
makeBaselineFilter,
|
|
9
9
|
} from "../core/baseline.js";
|
|
10
|
-
import {
|
|
11
|
-
type CampaignPredicate,
|
|
12
|
-
campaignsFailingTheirProbe,
|
|
13
|
-
compileCampaignRules,
|
|
14
|
-
type CompiledCampaign,
|
|
15
|
-
leafTermsOf,
|
|
16
|
-
} from "../core/campaigns.js";
|
|
17
10
|
import {
|
|
18
11
|
type CompiledExportRule,
|
|
19
12
|
compileExportRules,
|
|
@@ -29,7 +22,6 @@ import {
|
|
|
29
22
|
compileImportRules,
|
|
30
23
|
rulesFailingTheirProbe,
|
|
31
24
|
} from "../core/imports.js";
|
|
32
|
-
import { decodeLedger, type Ledger } from "../core/ledger.js";
|
|
33
25
|
import {
|
|
34
26
|
type CompiledMemberRule,
|
|
35
27
|
compileMemberRules,
|
|
@@ -54,19 +46,24 @@ import {
|
|
|
54
46
|
import type { SourceFacts } from "../domain/facts.js";
|
|
55
47
|
import type { ManifestLocator } from "../domain/manifest-location.js";
|
|
56
48
|
import { type LoweredRules, lowerManifest } from "../manifest/compile.js";
|
|
57
|
-
import { decodeManifest,
|
|
49
|
+
import { decodeManifest, type Manifest } from "../manifest/manifest.js";
|
|
58
50
|
import type { FactExtractor } from "../ports/fact-extractor.js";
|
|
59
51
|
import type { FileSystem } from "../ports/file-system.js";
|
|
60
52
|
import type { Language } from "../ports/language.js";
|
|
61
53
|
import type { ModuleResolver } from "../ports/module-resolver.js";
|
|
62
|
-
import { NO_REPORTS, type ReportSource } from "../ports/report-source.js";
|
|
63
54
|
import type { SyntaxMatcher } from "../ports/syntax-matcher.js";
|
|
55
|
+
import type { ExtensionRoute, PolicyExtension } from "./extension.js";
|
|
64
56
|
|
|
65
57
|
// A manifest, read by a host, turned into the policy both adapters evaluate.
|
|
66
58
|
// Decoding, lowering, compiling and probing happen here, once, with whatever
|
|
67
59
|
// languages the host hands in — this tier never names one. The host reads the
|
|
68
60
|
// manifest file, constructs its language packs and the live file system, and
|
|
69
61
|
// hands them over; that is the whole of what a host has to know.
|
|
62
|
+
//
|
|
63
|
+
// A family the core does not own arrives the same way: as a `PolicyExtension`
|
|
64
|
+
// in `extensions`, which claims its keys of the manifest, decodes them, and
|
|
65
|
+
// hands back a value carried on `extensions` below. The core never looks
|
|
66
|
+
// inside that value; the package that produced it exports the accessor.
|
|
70
67
|
|
|
71
68
|
export type LoadedPolicy = {
|
|
72
69
|
readonly repoRoot: string;
|
|
@@ -80,18 +77,9 @@ export type LoadedPolicy = {
|
|
|
80
77
|
readonly graph: CompiledGraph;
|
|
81
78
|
readonly adoption: LoweredRules["adoption"];
|
|
82
79
|
readonly structure: CompiledStructure;
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
readonly campaignRules: ReadonlyArray<CompiledCampaign>;
|
|
87
|
-
readonly ledgers: ReadonlyMap<string, Ledger>;
|
|
88
|
-
// Repo-relative; `<ledgerDir>/<id>.json` is a campaign's ledger.
|
|
89
|
-
readonly ledgerDir: string;
|
|
90
|
-
// The predicate functions the host imported for the `fn` terms.
|
|
91
|
-
readonly functions: ReadonlyMap<string, CampaignPredicate>;
|
|
92
|
-
// Answers the `report` terms: the host's live source, or nothing.
|
|
93
|
-
readonly reports: ReportSource;
|
|
94
|
-
// The clock the campaigns are judged by — stalls, timestamps.
|
|
80
|
+
// What each loaded extension loaded, keyed by its id. Opaque here.
|
|
81
|
+
readonly extensions: ReadonlyMap<string, unknown>;
|
|
82
|
+
// The clock the policy is judged by — a stall, a window, a timestamp.
|
|
95
83
|
readonly now: number;
|
|
96
84
|
readonly fileSystem: FileSystem;
|
|
97
85
|
// The language packs this policy is evaluated with. The walker takes its
|
|
@@ -108,6 +96,8 @@ export type LoadedPolicy = {
|
|
|
108
96
|
// Routes each file to the syntax matcher of the language whose scope
|
|
109
97
|
// covers it; `parse` answers `null` for a file whose language has none.
|
|
110
98
|
readonly syntax: SyntaxMatcher;
|
|
99
|
+
// Which scope, and so which language, covers a given file.
|
|
100
|
+
readonly routeFor: (file: string) => ExtensionRoute | undefined;
|
|
111
101
|
readonly ignoreUnresolved: ReadonlyArray<RegExp>;
|
|
112
102
|
// Deprecation notices from reading the manifest. The host prints them once.
|
|
113
103
|
readonly notices: ReadonlyArray<string>;
|
|
@@ -121,17 +111,14 @@ export type LoadPolicyInput = {
|
|
|
121
111
|
// document — before decoding.
|
|
122
112
|
readonly manifest: unknown;
|
|
123
113
|
// From a data file, where a path in the manifest was written, so a decode
|
|
124
|
-
// error names a line. A module manifest has none
|
|
114
|
+
// error names a line. A module manifest has none.
|
|
125
115
|
readonly locate?: ManifestLocator | undefined;
|
|
126
116
|
readonly languages: ReadonlyArray<Language>;
|
|
127
117
|
readonly fileSystem: FileSystem;
|
|
128
|
-
// The
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// answers nothing — a policy with one is refused, since it would be a
|
|
133
|
-
// campaign that can never fire.
|
|
134
|
-
readonly reports?: ReportSource | undefined;
|
|
118
|
+
// The families the core does not own, composed by the host. A manifest key
|
|
119
|
+
// one of these claims is decoded by it; a key none claims is refused as the
|
|
120
|
+
// excess property it is.
|
|
121
|
+
readonly extensions?: ReadonlyArray<PolicyExtension> | undefined;
|
|
135
122
|
// For tests and for a CI that pins the clock; defaults to `Date.now()`.
|
|
136
123
|
readonly now?: number | undefined;
|
|
137
124
|
};
|
|
@@ -179,20 +166,6 @@ const routesOf = (
|
|
|
179
166
|
const routeFor = (routes: ReadonlyArray<Route>, file: string): Route | undefined =>
|
|
180
167
|
routes.find((route) => route.matches.test(file));
|
|
181
168
|
|
|
182
|
-
const referencedFunctions = (detect: CompiledCampaign["detect"]): ReadonlyArray<string> => {
|
|
183
|
-
switch (detect.kind) {
|
|
184
|
-
case "all":
|
|
185
|
-
case "any":
|
|
186
|
-
return detect.terms.flatMap(referencedFunctions);
|
|
187
|
-
case "not":
|
|
188
|
-
return referencedFunctions(detect.term);
|
|
189
|
-
case "fn":
|
|
190
|
-
return [detect.name];
|
|
191
|
-
default:
|
|
192
|
-
return [];
|
|
193
|
-
}
|
|
194
|
-
};
|
|
195
|
-
|
|
196
169
|
// One resolver per scope, built by the scope's language, behind one port that
|
|
197
170
|
// picks the scope by the importing file.
|
|
198
171
|
const makeRouter = (
|
|
@@ -235,56 +208,12 @@ const makeRoutingExtractor = (routes: ReadonlyArray<Route>): FactExtractor => ({
|
|
|
235
208
|
|
|
236
209
|
// One matcher per scope, behind one port that picks the scope by the file. A
|
|
237
210
|
// language without one parses nothing, which a `syntax` term reads as no
|
|
238
|
-
// matches — and which
|
|
211
|
+
// matches — and which an extension's probe check refuses for a family that
|
|
239
212
|
// depends on it.
|
|
240
213
|
const makeRoutingMatcher = (routes: ReadonlyArray<Route>): SyntaxMatcher => ({
|
|
241
214
|
parse: (file, text) => routeFor(routes, file)?.language.syntax?.parse(file, text) ?? null,
|
|
242
215
|
});
|
|
243
216
|
|
|
244
|
-
// The ledgers, read through the port. An absent file is a campaign with no
|
|
245
|
-
// ledger yet; a malformed one is refused, since reading it as empty would
|
|
246
|
-
// report every hit as unrecorded growth.
|
|
247
|
-
const readLedgers = (
|
|
248
|
-
configPath: string,
|
|
249
|
-
fileSystem: FileSystem,
|
|
250
|
-
ledgerDir: string,
|
|
251
|
-
campaigns: ReadonlyArray<CompiledCampaign>,
|
|
252
|
-
): Result.Result<ReadonlyMap<string, Ledger>, ConfigInvalid> => {
|
|
253
|
-
const ledgers = new Map<string, Ledger>();
|
|
254
|
-
for (const campaign of campaigns) {
|
|
255
|
-
const at = `${ledgerDir}/${campaign.id}.json`;
|
|
256
|
-
const text = fileSystem.readText(at);
|
|
257
|
-
if (text === null) continue;
|
|
258
|
-
let raw: unknown;
|
|
259
|
-
try {
|
|
260
|
-
raw = JSON.parse(text);
|
|
261
|
-
} catch (cause) {
|
|
262
|
-
return Result.fail(
|
|
263
|
-
new ConfigInvalid({ configPath, detail: `the ledger ${at} is not JSON: ${String(cause)}` }),
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
const decoded = decodeLedger(raw);
|
|
267
|
-
if (Result.isFailure(decoded)) {
|
|
268
|
-
return Result.fail(
|
|
269
|
-
new ConfigInvalid({
|
|
270
|
-
configPath,
|
|
271
|
-
detail: `the ledger ${at} does not decode:\n${decoded.failure}`,
|
|
272
|
-
}),
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
if (decoded.success.id !== campaign.id) {
|
|
276
|
-
return Result.fail(
|
|
277
|
-
new ConfigInvalid({
|
|
278
|
-
configPath,
|
|
279
|
-
detail: `the ledger ${at} says it belongs to "${decoded.success.id}", not "${campaign.id}".`,
|
|
280
|
-
}),
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
ledgers.set(campaign.id, decoded.success);
|
|
284
|
-
}
|
|
285
|
-
return Result.succeed(ledgers);
|
|
286
|
-
};
|
|
287
|
-
|
|
288
217
|
// The baseline is read through the port, so this tier touches no file itself.
|
|
289
218
|
// An absent or unreadable one carries nothing, which is the safe direction:
|
|
290
219
|
// every violation reports.
|
|
@@ -306,8 +235,12 @@ export const loadPolicy = (
|
|
|
306
235
|
input: LoadPolicyInput,
|
|
307
236
|
): Result.Result<LoadedPolicy, ConfigInvalid | PatternInvalid> => {
|
|
308
237
|
const { configPath, fileSystem, languages, repoRoot } = input;
|
|
238
|
+
const extensions = input.extensions ?? [];
|
|
309
239
|
|
|
310
|
-
const decoded = decodeManifest(configPath, input.manifest, {
|
|
240
|
+
const decoded = decodeManifest(configPath, input.manifest, {
|
|
241
|
+
locate: input.locate,
|
|
242
|
+
extensions,
|
|
243
|
+
});
|
|
311
244
|
if (Result.isFailure(decoded)) return Result.fail(decoded.failure);
|
|
312
245
|
const config = decoded.success.manifest;
|
|
313
246
|
|
|
@@ -381,78 +314,49 @@ export const loadPolicy = (
|
|
|
381
314
|
const structure = compileStructure(rules.structure);
|
|
382
315
|
if (Result.isFailure(structure)) return Result.fail(structure.failure);
|
|
383
316
|
|
|
384
|
-
const campaignRules = compileCampaignRules(rules.campaigns);
|
|
385
|
-
if (Result.isFailure(campaignRules)) return Result.fail(campaignRules.failure);
|
|
386
|
-
|
|
387
|
-
// A `fn` term names a function the host was to import. One it did not is
|
|
388
|
-
// a term that would answer false for every file, and is refused.
|
|
389
|
-
const functions = input.functions ?? new Map<string, CampaignPredicate>();
|
|
390
|
-
const missing = campaignRules.success.flatMap((rule) =>
|
|
391
|
-
referencedFunctions(rule.detect).filter((name) => !functions.has(name)),
|
|
392
|
-
);
|
|
393
|
-
if (missing.length > 0) {
|
|
394
|
-
return Result.fail(
|
|
395
|
-
new ConfigInvalid({
|
|
396
|
-
configPath,
|
|
397
|
-
detail:
|
|
398
|
-
`these campaigns name a predicate function the host did not load: ` +
|
|
399
|
-
`${[...new Set(missing)].join(", ")}. A \`fn\` term is \`module#export\`, resolved ` +
|
|
400
|
-
`relative to the manifest, and the export must be a function.`,
|
|
401
|
-
}),
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// A `report` term is answered by the host's source. Without one every
|
|
406
|
-
// such campaign would report nothing and look complete.
|
|
407
|
-
const reporting = campaignRules.success.filter((rule) =>
|
|
408
|
-
leafTermsOf(rule.detect).includes("report"),
|
|
409
|
-
);
|
|
410
|
-
if (input.reports === undefined && reporting.length > 0) {
|
|
411
|
-
return Result.fail(
|
|
412
|
-
new ConfigInvalid({
|
|
413
|
-
configPath,
|
|
414
|
-
detail:
|
|
415
|
-
`these campaigns hold a \`report\` term and the host provided no report source: ` +
|
|
416
|
-
`${reporting.map((rule) => rule.name).join(", ")}.`,
|
|
417
|
-
}),
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// A `syntax` term needs a matcher for the language of every file in its
|
|
422
|
-
// scope. The probes are the files the campaign is proven on; a probe whose
|
|
423
|
-
// language has none would pass no probe and mean nothing.
|
|
424
|
-
const syntaxless = campaignRules.success.flatMap((rule) => {
|
|
425
|
-
if (!leafTermsOf(rule.detect).includes("syntax")) return [];
|
|
426
|
-
return [...rule.probes.fires, ...rule.probes.ignores].flatMap((probe) => {
|
|
427
|
-
const route = routeFor(routes.success, probe.path);
|
|
428
|
-
return route === undefined || route.language.syntax !== undefined
|
|
429
|
-
? []
|
|
430
|
-
: [`${rule.name} (${probe.path}: ${route.language.id} carries no syntax matcher)`];
|
|
431
|
-
});
|
|
432
|
-
});
|
|
433
|
-
if (syntaxless.length > 0) {
|
|
434
|
-
return Result.fail(
|
|
435
|
-
new ConfigInvalid({
|
|
436
|
-
configPath,
|
|
437
|
-
detail:
|
|
438
|
-
`these campaigns hold a \`syntax\` term in a scope whose language has no syntax ` +
|
|
439
|
-
`matcher: ${[...new Set(syntaxless)].join(", ")}. Compose the language pack with ` +
|
|
440
|
-
`one (\`@goodbones/ast-grep\` for TypeScript), or write the detector without it.`,
|
|
441
|
-
}),
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
317
|
// A probe carrying a source snippet is parsed by the extractor of the
|
|
446
318
|
// language whose scope covers the probe's file — the same extractor the CLI
|
|
447
319
|
// reads that file through. The plugin reads through oxlint's tree instead,
|
|
448
320
|
// and the parity suite is what holds that tree to this one.
|
|
449
321
|
const extractor = makeRoutingExtractor(routes.success);
|
|
322
|
+
const syntax = makeRoutingMatcher(routes.success);
|
|
323
|
+
const routeOf = (file: string): ExtensionRoute | undefined => {
|
|
324
|
+
const found = routeFor(routes.success, file);
|
|
325
|
+
return found === undefined ? undefined : { language: found.language, scope: found.scope };
|
|
326
|
+
};
|
|
327
|
+
const now = input.now ?? Date.now();
|
|
328
|
+
|
|
329
|
+
// Each family the core does not own compiles and probes its own rules, with
|
|
330
|
+
// the same extractor and matcher the core used for its. Its probe failures
|
|
331
|
+
// join the one refusal below, so a vacuous campaign and a vacuous import
|
|
332
|
+
// rule are reported in the same sentence.
|
|
333
|
+
const loadedExtensions = new Map<string, unknown>();
|
|
334
|
+
const extensionVacuous: Array<string> = [];
|
|
335
|
+
for (const extension of extensions) {
|
|
336
|
+
const loaded = extension.load({
|
|
337
|
+
spec: decoded.success.extensions.get(extension.id),
|
|
338
|
+
configPath,
|
|
339
|
+
repoRoot,
|
|
340
|
+
config,
|
|
341
|
+
languages,
|
|
342
|
+
fileSystem,
|
|
343
|
+
extractor,
|
|
344
|
+
syntax,
|
|
345
|
+
routeFor: routeOf,
|
|
346
|
+
now,
|
|
347
|
+
});
|
|
348
|
+
if (Result.isFailure(loaded)) return Result.fail(loaded.failure);
|
|
349
|
+
loadedExtensions.set(extension.id, loaded.success.value);
|
|
350
|
+
for (const name of loaded.success.vacuous ?? []) extensionVacuous.push(name);
|
|
351
|
+
}
|
|
352
|
+
|
|
450
353
|
const parsedBy = (rule: { readonly probe: { readonly from: string } }): string => {
|
|
451
354
|
const route = routeFor(routes.success, rule.probe.from);
|
|
452
355
|
return route === undefined
|
|
453
356
|
? ` — no resolve scope covers its probe ${JSON.stringify(rule.probe.from)}, so no language parsed it`
|
|
454
357
|
: ` — probe parsed by ${route.language.id}, selected by the scope ${JSON.stringify(route.scope.files)}`;
|
|
455
358
|
};
|
|
359
|
+
|
|
456
360
|
const vacuous = [
|
|
457
361
|
...rulesFailingTheirProbe(importRules.success).map((rule) => rule.name),
|
|
458
362
|
...exportRulesFailingTheirProbe(exportRules.success, extractor).map(
|
|
@@ -466,19 +370,7 @@ export const loadPolicy = (
|
|
|
466
370
|
),
|
|
467
371
|
...structureRulesFailingTheirProbe(structure.success),
|
|
468
372
|
...graphRulesFailingTheirProbe(graph.success),
|
|
469
|
-
...
|
|
470
|
-
campaignRules.success,
|
|
471
|
-
extractor,
|
|
472
|
-
(file) => routeFor(routes.success, file)?.language.syntax ?? null,
|
|
473
|
-
functions,
|
|
474
|
-
).map((failed) =>
|
|
475
|
-
failed.outOfScope === true
|
|
476
|
-
? `${failed.name} (its probe ${failed.probe.path} is outside the campaign's own scope)`
|
|
477
|
-
: failed.expected === "fires"
|
|
478
|
-
? `${failed.name} (fires probe ${failed.probe.path} did not fire)`
|
|
479
|
-
: `${failed.name} (ignores probe ${failed.probe.path} fired` +
|
|
480
|
-
(failed.admittedBy === undefined ? ")" : `, admitted by \`${failed.admittedBy}\`)`),
|
|
481
|
-
),
|
|
373
|
+
...extensionVacuous,
|
|
482
374
|
];
|
|
483
375
|
if (vacuous.length > 0) {
|
|
484
376
|
return Result.fail(
|
|
@@ -500,10 +392,6 @@ export const loadPolicy = (
|
|
|
500
392
|
const resolver = makeRouter(configPath, repoRoot, routes.success);
|
|
501
393
|
if (Result.isFailure(resolver)) return Result.fail(resolver.failure);
|
|
502
394
|
|
|
503
|
-
const ledgerDir = config.ledger ?? DEFAULT_LEDGER_DIR;
|
|
504
|
-
const ledgers = readLedgers(configPath, fileSystem, ledgerDir, campaignRules.success);
|
|
505
|
-
if (Result.isFailure(ledgers)) return Result.fail(ledgers.failure);
|
|
506
|
-
|
|
507
395
|
return Result.succeed({
|
|
508
396
|
repoRoot,
|
|
509
397
|
config,
|
|
@@ -514,18 +402,15 @@ export const loadPolicy = (
|
|
|
514
402
|
graph: graph.success,
|
|
515
403
|
adoption: rules.adoption,
|
|
516
404
|
structure: structure.success,
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
ledgerDir,
|
|
520
|
-
functions,
|
|
521
|
-
reports: input.reports ?? NO_REPORTS,
|
|
522
|
-
now: input.now ?? Date.now(),
|
|
405
|
+
extensions: loadedExtensions,
|
|
406
|
+
now,
|
|
523
407
|
fileSystem,
|
|
524
408
|
languages,
|
|
525
409
|
baseline: makeBaselineFilter(readBaseline(fileSystem, config.baseline)),
|
|
526
410
|
resolver: resolver.success,
|
|
527
411
|
extractor,
|
|
528
|
-
syntax
|
|
412
|
+
syntax,
|
|
413
|
+
routeFor: routeOf,
|
|
529
414
|
ignoreUnresolved: (config.resolve.ignoreUnresolved ?? []).map(
|
|
530
415
|
(pattern: string) => new RegExp(pattern),
|
|
531
416
|
),
|
package/src/manifest/compile.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Allowance,
|
|
3
|
-
type CampaignRule,
|
|
4
|
-
type Detector,
|
|
5
3
|
type ExportRule,
|
|
6
4
|
type GraphConfig,
|
|
7
5
|
type ImportRule,
|
|
@@ -17,15 +15,11 @@ import type { ManifestPath } from "../domain/manifest-location.js";
|
|
|
17
15
|
import { fragmentOf, type Substitution } from "./expand.js";
|
|
18
16
|
import { anchored, type CaptureIndex, globToRegexSource, prefixed } from "./glob.js";
|
|
19
17
|
import {
|
|
20
|
-
type CampaignSpec,
|
|
21
|
-
type DetectorSpec,
|
|
22
|
-
durationMs,
|
|
23
18
|
globsOf,
|
|
24
19
|
type ImportsSpec,
|
|
25
20
|
type Manifest,
|
|
26
21
|
type ManifestNode,
|
|
27
22
|
type NamingSpec,
|
|
28
|
-
type SyntaxTermSpec,
|
|
29
23
|
} from "./manifest.js";
|
|
30
24
|
|
|
31
25
|
// The manifest is the authoring surface; these flat rules are the machine's.
|
|
@@ -37,8 +31,6 @@ export type LoweredRules = {
|
|
|
37
31
|
readonly members: ReadonlyArray<MemberRule>;
|
|
38
32
|
readonly surface: ReadonlyArray<SurfaceRule>;
|
|
39
33
|
readonly graph: GraphConfig;
|
|
40
|
-
// Campaigns pass through by their own id, as top-level `exports` rules do.
|
|
41
|
-
readonly campaigns: ReadonlyArray<CampaignRule>;
|
|
42
34
|
// The nodes that said "not tightened yet", by name — the adoption backlog,
|
|
43
35
|
// and what `limits` puts a ceiling on.
|
|
44
36
|
readonly adoption: {
|
|
@@ -80,7 +72,7 @@ const alternativesOf = (key: string): ReadonlyArray<string> =>
|
|
|
80
72
|
.map(stripSlash)
|
|
81
73
|
.filter((one) => one !== "");
|
|
82
74
|
|
|
83
|
-
const expandAliases = (glob: string, aliases: Readonly<Record<string, string>>): string => {
|
|
75
|
+
export const expandAliases = (glob: string, aliases: Readonly<Record<string, string>>): string => {
|
|
84
76
|
for (const [alias, target] of Object.entries(aliases)) {
|
|
85
77
|
if (glob === alias) return target;
|
|
86
78
|
if (glob.startsWith(`${alias}/`)) return target + glob.slice(alias.length);
|
|
@@ -146,7 +138,7 @@ const probeMemberName = (match: string | ReadonlyArray<string> | undefined): str
|
|
|
146
138
|
|
|
147
139
|
// Each convention pairs the shape a name must have with a name that does not
|
|
148
140
|
// have it, so the rule's probe is generated rather than written.
|
|
149
|
-
const CONVENTIONS: Readonly<
|
|
141
|
+
export const CONVENTIONS: Readonly<
|
|
150
142
|
Record<string, { readonly source: string; readonly violating: string; readonly shape: string }>
|
|
151
143
|
> = {
|
|
152
144
|
"kebab-case": {
|
|
@@ -1058,216 +1050,13 @@ export const lowerManifest = (
|
|
|
1058
1050
|
})),
|
|
1059
1051
|
};
|
|
1060
1052
|
|
|
1061
|
-
const campaigns = (manifest.campaigns ?? []).map((campaign) => lowerCampaign(campaign, aliases));
|
|
1062
|
-
|
|
1063
1053
|
return {
|
|
1064
1054
|
imports,
|
|
1065
1055
|
exports,
|
|
1066
1056
|
members,
|
|
1067
1057
|
surface,
|
|
1068
1058
|
graph,
|
|
1069
|
-
campaigns,
|
|
1070
1059
|
adoption: { unrestricted: unrestrictedNodes, partial: partialNodes },
|
|
1071
1060
|
structure: { roots, folders, parity, naming: namingRules },
|
|
1072
1061
|
};
|
|
1073
1062
|
};
|
|
1074
|
-
|
|
1075
|
-
// The keys of a `syntax` term that belong to the engine's rule; `where` is
|
|
1076
|
-
// the one that does not.
|
|
1077
|
-
const SYNTAX_RULE_KEYS = [
|
|
1078
|
-
"pattern",
|
|
1079
|
-
"kind",
|
|
1080
|
-
"regex",
|
|
1081
|
-
"nthChild",
|
|
1082
|
-
"inside",
|
|
1083
|
-
"has",
|
|
1084
|
-
"precedes",
|
|
1085
|
-
"follows",
|
|
1086
|
-
"all",
|
|
1087
|
-
"any",
|
|
1088
|
-
"not",
|
|
1089
|
-
] as const;
|
|
1090
|
-
|
|
1091
|
-
const syntaxRuleOf = (term: SyntaxTermSpec): unknown =>
|
|
1092
|
-
Object.fromEntries(
|
|
1093
|
-
SYNTAX_RULE_KEYS.flatMap((key) => (term[key] === undefined ? [] : [[key, term[key]]])),
|
|
1094
|
-
);
|
|
1095
|
-
|
|
1096
|
-
// The leaf terms a path alone cannot exercise: each reads the file's text,
|
|
1097
|
-
// its syntax, or its facts, so a probe for a detector holding one must carry
|
|
1098
|
-
// a `source`.
|
|
1099
|
-
const TERMS_NEEDING_SOURCE = ["content", "syntax", "exports", "members", "fn"] as const;
|
|
1100
|
-
|
|
1101
|
-
const leafTermsOf = (detector: DetectorSpec): ReadonlyArray<string> => {
|
|
1102
|
-
if ("all" in detector) return detector.all.flatMap(leafTermsOf);
|
|
1103
|
-
if ("any" in detector) return detector.any.flatMap(leafTermsOf);
|
|
1104
|
-
if ("not" in detector) return leafTermsOf(detector.not);
|
|
1105
|
-
return Object.keys(detector);
|
|
1106
|
-
};
|
|
1107
|
-
|
|
1108
|
-
// A campaign's globs resolved: `scope` and a path-shaped `resolves` the way
|
|
1109
|
-
// graph rules resolve theirs, `exports`/`members` names the way `surface` and
|
|
1110
|
-
// `members` rules do, and the rest carried as written. The `fn` string is
|
|
1111
|
-
// kept verbatim for the loader, which holds the function it names.
|
|
1112
|
-
const lowerCampaign = (
|
|
1113
|
-
campaign: CampaignSpec,
|
|
1114
|
-
aliases: Readonly<Record<string, string>>,
|
|
1115
|
-
): CampaignRule => {
|
|
1116
|
-
const asPath = (glob: string): string =>
|
|
1117
|
-
prefixed(
|
|
1118
|
-
globToRegexSource(expandAliases(glob, aliases), {}, { declaring: false, nextGroup: 1 })
|
|
1119
|
-
.source,
|
|
1120
|
-
);
|
|
1121
|
-
const asName = (globs: string | ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
1122
|
-
globsOf(globs).map((one) =>
|
|
1123
|
-
anchored(globToRegexSource(one, {}, { declaring: false, nextGroup: 1 }).source),
|
|
1124
|
-
);
|
|
1125
|
-
const name = `campaign/${campaign.id}`;
|
|
1126
|
-
|
|
1127
|
-
const lower = (detector: DetectorSpec): Detector => {
|
|
1128
|
-
if ("all" in detector) return { all: detector.all.map(lower) };
|
|
1129
|
-
if ("any" in detector) return { any: detector.any.map(lower) };
|
|
1130
|
-
if ("not" in detector) return { not: lower(detector.not) };
|
|
1131
|
-
if ("path" in detector) {
|
|
1132
|
-
const { convention, ...rest } = detector.path;
|
|
1133
|
-
const conventionSource =
|
|
1134
|
-
convention === undefined
|
|
1135
|
-
? undefined
|
|
1136
|
-
: typeof convention === "string"
|
|
1137
|
-
? CONVENTIONS[convention]?.source
|
|
1138
|
-
: convention.regex;
|
|
1139
|
-
if (convention !== undefined && conventionSource === undefined) {
|
|
1140
|
-
throw new Error(`campaign "${campaign.id}" names an unknown convention "${convention}".`);
|
|
1141
|
-
}
|
|
1142
|
-
if (conventionSource !== undefined && rest.subject === undefined) {
|
|
1143
|
-
throw new Error(
|
|
1144
|
-
`campaign "${campaign.id}" states a path convention with no \`subject\`: say which ` +
|
|
1145
|
-
`capture group of \`file\` holds the name the convention is about.`,
|
|
1146
|
-
);
|
|
1147
|
-
}
|
|
1148
|
-
return {
|
|
1149
|
-
path: {
|
|
1150
|
-
file: globsOf(rest.file),
|
|
1151
|
-
...(rest.fileNot === undefined ? {} : { fileNot: globsOf(rest.fileNot) }),
|
|
1152
|
-
...(rest.subject === undefined ? {} : { subject: rest.subject }),
|
|
1153
|
-
...(conventionSource === undefined ? {} : { convention: conventionSource }),
|
|
1154
|
-
},
|
|
1155
|
-
};
|
|
1156
|
-
}
|
|
1157
|
-
if ("imports" in detector) {
|
|
1158
|
-
const { resolves, symbols } = detector.imports;
|
|
1159
|
-
return {
|
|
1160
|
-
imports: {
|
|
1161
|
-
resolves: typeof resolves === "string" ? asPath(resolves) : resolves,
|
|
1162
|
-
...(symbols === undefined ? {} : { symbols: [...symbols] }),
|
|
1163
|
-
},
|
|
1164
|
-
};
|
|
1165
|
-
}
|
|
1166
|
-
if ("exports" in detector) {
|
|
1167
|
-
const term = detector.exports;
|
|
1168
|
-
return {
|
|
1169
|
-
exports: {
|
|
1170
|
-
...(term.name === undefined ? {} : { name: asName(term.name) }),
|
|
1171
|
-
...(term.kinds === undefined ? {} : { kinds: [...term.kinds] }),
|
|
1172
|
-
...(term.declares === undefined ? {} : { declares: [...term.declares] }),
|
|
1173
|
-
...(term.reexport === undefined ? {} : { reexport: term.reexport }),
|
|
1174
|
-
},
|
|
1175
|
-
};
|
|
1176
|
-
}
|
|
1177
|
-
if ("members" in detector) {
|
|
1178
|
-
const term = detector.members;
|
|
1179
|
-
return {
|
|
1180
|
-
members: {
|
|
1181
|
-
subject: term.subject,
|
|
1182
|
-
...(term.name === undefined ? {} : { name: asName(term.name) }),
|
|
1183
|
-
...(term.in === undefined ? {} : { in: asName(term.in) }),
|
|
1184
|
-
...(term.declares === undefined ? {} : { declares: [...term.declares] }),
|
|
1185
|
-
},
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
if ("requires" in detector) return { requires: [...detector.requires] };
|
|
1189
|
-
if ("content" in detector) return { content: { regex: detector.content.regex } };
|
|
1190
|
-
if ("report" in detector) {
|
|
1191
|
-
// Exactly one of `command` and `file`, and a pattern for `regex`: the
|
|
1192
|
-
// manifest schema refused anything else at decode.
|
|
1193
|
-
const term = detector.report;
|
|
1194
|
-
return {
|
|
1195
|
-
report: {
|
|
1196
|
-
...(term.command === undefined ? {} : { command: globsOf(term.command) }),
|
|
1197
|
-
...(term.file === undefined ? {} : { file: globsOf(term.file) }),
|
|
1198
|
-
format: term.format,
|
|
1199
|
-
...(term.pattern === undefined ? {} : { pattern: term.pattern }),
|
|
1200
|
-
...(term.codes === undefined ? {} : { codes: [...term.codes] }),
|
|
1201
|
-
...(term.codesNot === undefined ? {} : { codesNot: [...term.codesNot] }),
|
|
1202
|
-
},
|
|
1203
|
-
};
|
|
1204
|
-
}
|
|
1205
|
-
if ("syntax" in detector) {
|
|
1206
|
-
const { where } = detector.syntax;
|
|
1207
|
-
const narrowed =
|
|
1208
|
-
where === undefined
|
|
1209
|
-
? {}
|
|
1210
|
-
: {
|
|
1211
|
-
where: Object.fromEntries(
|
|
1212
|
-
Object.entries(where).map(([capture, narrowing]) => [
|
|
1213
|
-
capture,
|
|
1214
|
-
{
|
|
1215
|
-
...(narrowing.regex === undefined ? {} : { regex: narrowing.regex }),
|
|
1216
|
-
...(narrowing.binding === undefined
|
|
1217
|
-
? {}
|
|
1218
|
-
: {
|
|
1219
|
-
binding: {
|
|
1220
|
-
resolves:
|
|
1221
|
-
typeof narrowing.binding.resolves === "string"
|
|
1222
|
-
? asPath(narrowing.binding.resolves)
|
|
1223
|
-
: narrowing.binding.resolves,
|
|
1224
|
-
...(narrowing.binding.member === undefined
|
|
1225
|
-
? {}
|
|
1226
|
-
: { member: [...narrowing.binding.member] }),
|
|
1227
|
-
},
|
|
1228
|
-
}),
|
|
1229
|
-
},
|
|
1230
|
-
]),
|
|
1231
|
-
),
|
|
1232
|
-
};
|
|
1233
|
-
return { syntax: { rule: syntaxRuleOf(detector.syntax), ...narrowed } };
|
|
1234
|
-
}
|
|
1235
|
-
return { fn: detector.fn };
|
|
1236
|
-
};
|
|
1237
|
-
|
|
1238
|
-
// A probe without a source proves only the path; a detector that reads the
|
|
1239
|
-
// file needs the file. Refused here, with the term named, rather than at
|
|
1240
|
-
// load as a probe that mysteriously never fires.
|
|
1241
|
-
const leaves = leafTermsOf(campaign.detect);
|
|
1242
|
-
const needsSource = TERMS_NEEDING_SOURCE.filter((term) => leaves.includes(term));
|
|
1243
|
-
const probes = [...campaign.probes.fires, ...(campaign.probes.ignores ?? [])];
|
|
1244
|
-
const sourceless = probes.find((probe) => probe.source === undefined);
|
|
1245
|
-
if (needsSource.length > 0 && sourceless !== undefined) {
|
|
1246
|
-
throw new Error(
|
|
1247
|
-
`campaign "${campaign.id}" has a probe (${sourceless.path}) with no \`source\`, and its ` +
|
|
1248
|
-
`detector holds a ${needsSource.map((term) => `\`${term}\``).join(", ")} term, which ` +
|
|
1249
|
-
`a path alone cannot exercise. Give every probe a source.`,
|
|
1250
|
-
);
|
|
1251
|
-
}
|
|
1252
|
-
if (campaign.probes.fires.length === 0) {
|
|
1253
|
-
throw new Error(
|
|
1254
|
-
`campaign "${campaign.id}" carries no \`probes.fires\`. A campaign proves it can fire ` +
|
|
1255
|
-
`the way every rule does; write at least one source it must report.`,
|
|
1256
|
-
);
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
return {
|
|
1260
|
-
name,
|
|
1261
|
-
id: campaign.id,
|
|
1262
|
-
...(campaign.title === undefined ? {} : { title: campaign.title }),
|
|
1263
|
-
message: campaign.how,
|
|
1264
|
-
why: campaign.why,
|
|
1265
|
-
...(campaign.owner === undefined ? {} : { owner: campaign.owner }),
|
|
1266
|
-
scope: globsOf(campaign.scope).map(asPath),
|
|
1267
|
-
unit: campaign.unit,
|
|
1268
|
-
detect: lower(campaign.detect),
|
|
1269
|
-
probes: { fires: [...campaign.probes.fires], ignores: [...(campaign.probes.ignores ?? [])] },
|
|
1270
|
-
staleAfter: durationMs(campaign.staleAfter),
|
|
1271
|
-
onComplete: campaign.onComplete ?? "keep",
|
|
1272
|
-
};
|
|
1273
|
-
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import type { ManifestPath } from "../domain/manifest-location.js";
|
|
4
|
+
|
|
5
|
+
// A family the core does not own.
|
|
6
|
+
//
|
|
7
|
+
// The five per-file families, the graph and the limits are the core's: it
|
|
8
|
+
// declares their vocabulary, decodes it and evaluates it. A family that is
|
|
9
|
+
// not — `campaigns`, which @goodbones/campaigns owns — states which top-level
|
|
10
|
+
// manifest keys it claims, and decodes them itself. The core splits those
|
|
11
|
+
// keys off the expanded manifest before decoding what is left, so the core's
|
|
12
|
+
// codec never learns a word of the family's vocabulary and a key no
|
|
13
|
+
// extension claims is still the excess property it always was.
|
|
14
|
+
//
|
|
15
|
+
// This is the decode half. The load half — compiling, probing, and whatever
|
|
16
|
+
// state the family reads off disk — is `PolicyExtension` in `load/`, which
|
|
17
|
+
// needs the ports and so cannot live here.
|
|
18
|
+
|
|
19
|
+
export type ManifestExtension<Spec = unknown> = {
|
|
20
|
+
// Names the family in errors, and keys it in `LoadedPolicy.extensions`.
|
|
21
|
+
readonly id: string;
|
|
22
|
+
// The top-level keys of the manifest this family owns.
|
|
23
|
+
readonly manifestKeys: ReadonlyArray<string>;
|
|
24
|
+
// The slice, as the expansion passes left it: the claimed keys that were
|
|
25
|
+
// present, with `defs`/`use` and `include` already resolved.
|
|
26
|
+
//
|
|
27
|
+
// `describe` renders one issue the way the core's own decoder does — the
|
|
28
|
+
// position in the file, the path, and the `use` it came through — so a
|
|
29
|
+
// decode error from a family the core does not own reads exactly like one
|
|
30
|
+
// from a tree node. Failure is a list of rendered lines, because a manifest
|
|
31
|
+
// is edited by hand and the reader fixing one wants the other three.
|
|
32
|
+
//
|
|
33
|
+
// Declared as a method rather than a function property on purpose: the
|
|
34
|
+
// loader holds extensions as `PolicyExtension<unknown, unknown>`, having
|
|
35
|
+
// erased what each one decodes, and method signatures are what let a
|
|
36
|
+
// concretely-typed extension be one of those.
|
|
37
|
+
decode(
|
|
38
|
+
slice: Readonly<Record<string, unknown>>,
|
|
39
|
+
describe: (path: ManifestPath, detail: string) => string,
|
|
40
|
+
): Result.Result<Spec, ReadonlyArray<string>>;
|
|
41
|
+
};
|
package/src/manifest/glob.ts
CHANGED
|
@@ -106,3 +106,8 @@ export const globToRegexSource = (
|
|
|
106
106
|
export const anchored = (source: string): string => `^${source}$`;
|
|
107
107
|
|
|
108
108
|
export const prefixed = (source: string): string => `^${source}`;
|
|
109
|
+
|
|
110
|
+
// A repo-relative glob as the sector discovery compiles a marker's `owns`:
|
|
111
|
+
// anchored at the start, so it names a subtree.
|
|
112
|
+
export const globToRegExp = (glob: string): RegExp =>
|
|
113
|
+
new RegExp(prefixed(globToRegexSource(glob, {}, { declaring: false, nextGroup: 1 }).source));
|