@goodbones/core 0.1.0-beta.6 → 0.1.0-beta.8
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 +140 -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/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 +11 -3
- 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 +732 -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/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 +7 -1
- 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 +57 -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 +1006 -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/facts.ts +9 -4
- 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 +74 -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 +66 -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
package/src/load/policy.ts
CHANGED
|
@@ -7,6 +7,13 @@ 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";
|
|
10
17
|
import {
|
|
11
18
|
type CompiledExportRule,
|
|
12
19
|
compileExportRules,
|
|
@@ -22,6 +29,7 @@ import {
|
|
|
22
29
|
compileImportRules,
|
|
23
30
|
rulesFailingTheirProbe,
|
|
24
31
|
} from "../core/imports.js";
|
|
32
|
+
import { decodeLedger, type Ledger } from "../core/ledger.js";
|
|
25
33
|
import {
|
|
26
34
|
type CompiledMemberRule,
|
|
27
35
|
compileMemberRules,
|
|
@@ -46,11 +54,13 @@ import {
|
|
|
46
54
|
import type { SourceFacts } from "../domain/facts.js";
|
|
47
55
|
import type { ManifestLocator } from "../domain/manifest-location.js";
|
|
48
56
|
import { type LoweredRules, lowerManifest } from "../manifest/compile.js";
|
|
49
|
-
import { decodeManifest, type Manifest } from "../manifest/manifest.js";
|
|
57
|
+
import { decodeManifest, DEFAULT_LEDGER_DIR, type Manifest } from "../manifest/manifest.js";
|
|
50
58
|
import type { FactExtractor } from "../ports/fact-extractor.js";
|
|
51
59
|
import type { FileSystem } from "../ports/file-system.js";
|
|
52
60
|
import type { Language } from "../ports/language.js";
|
|
53
61
|
import type { ModuleResolver } from "../ports/module-resolver.js";
|
|
62
|
+
import { NO_REPORTS, type ReportSource } from "../ports/report-source.js";
|
|
63
|
+
import type { SyntaxMatcher } from "../ports/syntax-matcher.js";
|
|
54
64
|
|
|
55
65
|
// A manifest, read by a host, turned into the policy both adapters evaluate.
|
|
56
66
|
// Decoding, lowering, compiling and probing happen here, once, with whatever
|
|
@@ -70,6 +80,19 @@ export type LoadedPolicy = {
|
|
|
70
80
|
readonly graph: CompiledGraph;
|
|
71
81
|
readonly adoption: LoweredRules["adoption"];
|
|
72
82
|
readonly structure: CompiledStructure;
|
|
83
|
+
// Evaluated by both hosts, one file at a time; the ledgers say which hits
|
|
84
|
+
// are already counted. A campaign with no ledger is one `campaigns init`
|
|
85
|
+
// has not been run for, and `check` says so.
|
|
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.
|
|
95
|
+
readonly now: number;
|
|
73
96
|
readonly fileSystem: FileSystem;
|
|
74
97
|
// The language packs this policy is evaluated with. The walker takes its
|
|
75
98
|
// extensions from them, and lowering the shape of its probes.
|
|
@@ -82,6 +105,9 @@ export type LoadedPolicy = {
|
|
|
82
105
|
// Routes each file to the extractor of the language whose scope covers it.
|
|
83
106
|
// The CLI reads every file through this; the plugin reads oxlint's tree.
|
|
84
107
|
readonly extractor: FactExtractor;
|
|
108
|
+
// Routes each file to the syntax matcher of the language whose scope
|
|
109
|
+
// covers it; `parse` answers `null` for a file whose language has none.
|
|
110
|
+
readonly syntax: SyntaxMatcher;
|
|
85
111
|
readonly ignoreUnresolved: ReadonlyArray<RegExp>;
|
|
86
112
|
// Deprecation notices from reading the manifest. The host prints them once.
|
|
87
113
|
readonly notices: ReadonlyArray<string>;
|
|
@@ -99,6 +125,15 @@ export type LoadPolicyInput = {
|
|
|
99
125
|
readonly locate?: ManifestLocator | undefined;
|
|
100
126
|
readonly languages: ReadonlyArray<Language>;
|
|
101
127
|
readonly fileSystem: FileSystem;
|
|
128
|
+
// The predicate functions the manifest's `fn` terms name, imported by the
|
|
129
|
+
// host before loading — the core touches no module loader.
|
|
130
|
+
readonly functions?: ReadonlyMap<string, CampaignPredicate> | undefined;
|
|
131
|
+
// The host's report source, for the `report` terms. Absent, a term
|
|
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;
|
|
135
|
+
// For tests and for a CI that pins the clock; defaults to `Date.now()`.
|
|
136
|
+
readonly now?: number | undefined;
|
|
102
137
|
};
|
|
103
138
|
|
|
104
139
|
const NOTHING: SourceFacts = {
|
|
@@ -144,6 +179,20 @@ const routesOf = (
|
|
|
144
179
|
const routeFor = (routes: ReadonlyArray<Route>, file: string): Route | undefined =>
|
|
145
180
|
routes.find((route) => route.matches.test(file));
|
|
146
181
|
|
|
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
|
+
|
|
147
196
|
// One resolver per scope, built by the scope's language, behind one port that
|
|
148
197
|
// picks the scope by the importing file.
|
|
149
198
|
const makeRouter = (
|
|
@@ -184,6 +233,58 @@ const makeRoutingExtractor = (routes: ReadonlyArray<Route>): FactExtractor => ({
|
|
|
184
233
|
routeFor(routes, file)?.language.extractor.factsOf(file, text) ?? NOTHING,
|
|
185
234
|
});
|
|
186
235
|
|
|
236
|
+
// One matcher per scope, behind one port that picks the scope by the file. A
|
|
237
|
+
// language without one parses nothing, which a `syntax` term reads as no
|
|
238
|
+
// matches — and which the probe check below refuses for a campaign that
|
|
239
|
+
// depends on it.
|
|
240
|
+
const makeRoutingMatcher = (routes: ReadonlyArray<Route>): SyntaxMatcher => ({
|
|
241
|
+
parse: (file, text) => routeFor(routes, file)?.language.syntax?.parse(file, text) ?? null,
|
|
242
|
+
});
|
|
243
|
+
|
|
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
|
+
|
|
187
288
|
// The baseline is read through the port, so this tier touches no file itself.
|
|
188
289
|
// An absent or unreadable one carries nothing, which is the safe direction:
|
|
189
290
|
// every violation reports.
|
|
@@ -280,6 +381,67 @@ export const loadPolicy = (
|
|
|
280
381
|
const structure = compileStructure(rules.structure);
|
|
281
382
|
if (Result.isFailure(structure)) return Result.fail(structure.failure);
|
|
282
383
|
|
|
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
|
+
|
|
283
445
|
// A probe carrying a source snippet is parsed by the extractor of the
|
|
284
446
|
// language whose scope covers the probe's file — the same extractor the CLI
|
|
285
447
|
// reads that file through. The plugin reads through oxlint's tree instead,
|
|
@@ -304,6 +466,19 @@ export const loadPolicy = (
|
|
|
304
466
|
),
|
|
305
467
|
...structureRulesFailingTheirProbe(structure.success),
|
|
306
468
|
...graphRulesFailingTheirProbe(graph.success),
|
|
469
|
+
...campaignsFailingTheirProbe(
|
|
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
|
+
),
|
|
307
482
|
];
|
|
308
483
|
if (vacuous.length > 0) {
|
|
309
484
|
return Result.fail(
|
|
@@ -325,6 +500,10 @@ export const loadPolicy = (
|
|
|
325
500
|
const resolver = makeRouter(configPath, repoRoot, routes.success);
|
|
326
501
|
if (Result.isFailure(resolver)) return Result.fail(resolver.failure);
|
|
327
502
|
|
|
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
|
+
|
|
328
507
|
return Result.succeed({
|
|
329
508
|
repoRoot,
|
|
330
509
|
config,
|
|
@@ -335,11 +514,18 @@ export const loadPolicy = (
|
|
|
335
514
|
graph: graph.success,
|
|
336
515
|
adoption: rules.adoption,
|
|
337
516
|
structure: structure.success,
|
|
517
|
+
campaignRules: campaignRules.success,
|
|
518
|
+
ledgers: ledgers.success,
|
|
519
|
+
ledgerDir,
|
|
520
|
+
functions,
|
|
521
|
+
reports: input.reports ?? NO_REPORTS,
|
|
522
|
+
now: input.now ?? Date.now(),
|
|
338
523
|
fileSystem,
|
|
339
524
|
languages,
|
|
340
525
|
baseline: makeBaselineFilter(readBaseline(fileSystem, config.baseline)),
|
|
341
526
|
resolver: resolver.success,
|
|
342
527
|
extractor,
|
|
528
|
+
syntax: makeRoutingMatcher(routes.success),
|
|
343
529
|
ignoreUnresolved: (config.resolve.ignoreUnresolved ?? []).map(
|
|
344
530
|
(pattern: string) => new RegExp(pattern),
|
|
345
531
|
),
|
package/src/manifest/compile.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Allowance,
|
|
3
|
+
type CampaignRule,
|
|
4
|
+
type Detector,
|
|
3
5
|
type ExportRule,
|
|
4
6
|
type GraphConfig,
|
|
5
7
|
type ImportRule,
|
|
@@ -15,11 +17,15 @@ import type { ManifestPath } from "../domain/manifest-location.js";
|
|
|
15
17
|
import { fragmentOf, type Substitution } from "./expand.js";
|
|
16
18
|
import { anchored, type CaptureIndex, globToRegexSource, prefixed } from "./glob.js";
|
|
17
19
|
import {
|
|
20
|
+
type CampaignSpec,
|
|
21
|
+
type DetectorSpec,
|
|
22
|
+
durationMs,
|
|
18
23
|
globsOf,
|
|
19
24
|
type ImportsSpec,
|
|
20
25
|
type Manifest,
|
|
21
26
|
type ManifestNode,
|
|
22
27
|
type NamingSpec,
|
|
28
|
+
type SyntaxTermSpec,
|
|
23
29
|
} from "./manifest.js";
|
|
24
30
|
|
|
25
31
|
// The manifest is the authoring surface; these flat rules are the machine's.
|
|
@@ -31,6 +37,8 @@ export type LoweredRules = {
|
|
|
31
37
|
readonly members: ReadonlyArray<MemberRule>;
|
|
32
38
|
readonly surface: ReadonlyArray<SurfaceRule>;
|
|
33
39
|
readonly graph: GraphConfig;
|
|
40
|
+
// Campaigns pass through by their own id, as top-level `exports` rules do.
|
|
41
|
+
readonly campaigns: ReadonlyArray<CampaignRule>;
|
|
34
42
|
// The nodes that said "not tightened yet", by name — the adoption backlog,
|
|
35
43
|
// and what `limits` puts a ceiling on.
|
|
36
44
|
readonly adoption: {
|
|
@@ -1050,13 +1058,226 @@ export const lowerManifest = (
|
|
|
1050
1058
|
})),
|
|
1051
1059
|
};
|
|
1052
1060
|
|
|
1061
|
+
const campaigns = (manifest.campaigns ?? []).map((campaign) => lowerCampaign(campaign, aliases));
|
|
1062
|
+
|
|
1053
1063
|
return {
|
|
1054
1064
|
imports,
|
|
1055
1065
|
exports,
|
|
1056
1066
|
members,
|
|
1057
1067
|
surface,
|
|
1058
1068
|
graph,
|
|
1069
|
+
campaigns,
|
|
1059
1070
|
adoption: { unrestricted: unrestrictedNodes, partial: partialNodes },
|
|
1060
1071
|
structure: { roots, folders, parity, naming: namingRules },
|
|
1061
1072
|
};
|
|
1062
1073
|
};
|
|
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
|
+
const term = detector.report;
|
|
1192
|
+
if ((term.command === undefined) === (term.file === undefined)) {
|
|
1193
|
+
throw new Error(
|
|
1194
|
+
`campaign "${campaign.id}" has a report term that must name exactly one of ` +
|
|
1195
|
+
`\`command\` (a program to run) and \`file\` (a report already written).`,
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
if (term.format === "regex" && term.pattern === undefined) {
|
|
1199
|
+
throw new Error(
|
|
1200
|
+
`campaign "${campaign.id}" has a \`regex\` report term with no \`pattern\`: give ` +
|
|
1201
|
+
`one with named groups \`file\` and \`line\`.`,
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
return {
|
|
1205
|
+
report: {
|
|
1206
|
+
...(term.command === undefined ? {} : { command: term.command }),
|
|
1207
|
+
...(term.file === undefined ? {} : { file: term.file }),
|
|
1208
|
+
format: term.format,
|
|
1209
|
+
...(term.pattern === undefined ? {} : { pattern: term.pattern }),
|
|
1210
|
+
...(term.codes === undefined ? {} : { codes: [...term.codes] }),
|
|
1211
|
+
...(term.codesNot === undefined ? {} : { codesNot: [...term.codesNot] }),
|
|
1212
|
+
},
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
if ("syntax" in detector) {
|
|
1216
|
+
const { where } = detector.syntax;
|
|
1217
|
+
const narrowed =
|
|
1218
|
+
where === undefined
|
|
1219
|
+
? {}
|
|
1220
|
+
: {
|
|
1221
|
+
where: Object.fromEntries(
|
|
1222
|
+
Object.entries(where).map(([capture, narrowing]) => [
|
|
1223
|
+
capture,
|
|
1224
|
+
{
|
|
1225
|
+
...(narrowing.regex === undefined ? {} : { regex: narrowing.regex }),
|
|
1226
|
+
...(narrowing.binding === undefined
|
|
1227
|
+
? {}
|
|
1228
|
+
: {
|
|
1229
|
+
binding: {
|
|
1230
|
+
resolves:
|
|
1231
|
+
typeof narrowing.binding.resolves === "string"
|
|
1232
|
+
? asPath(narrowing.binding.resolves)
|
|
1233
|
+
: narrowing.binding.resolves,
|
|
1234
|
+
...(narrowing.binding.member === undefined
|
|
1235
|
+
? {}
|
|
1236
|
+
: { member: [...narrowing.binding.member] }),
|
|
1237
|
+
},
|
|
1238
|
+
}),
|
|
1239
|
+
},
|
|
1240
|
+
]),
|
|
1241
|
+
),
|
|
1242
|
+
};
|
|
1243
|
+
return { syntax: { rule: syntaxRuleOf(detector.syntax), ...narrowed } };
|
|
1244
|
+
}
|
|
1245
|
+
return { fn: detector.fn };
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
// A probe without a source proves only the path; a detector that reads the
|
|
1249
|
+
// file needs the file. Refused here, with the term named, rather than at
|
|
1250
|
+
// load as a probe that mysteriously never fires.
|
|
1251
|
+
const leaves = leafTermsOf(campaign.detect);
|
|
1252
|
+
const needsSource = TERMS_NEEDING_SOURCE.filter((term) => leaves.includes(term));
|
|
1253
|
+
const probes = [...campaign.probes.fires, ...(campaign.probes.ignores ?? [])];
|
|
1254
|
+
const sourceless = probes.find((probe) => probe.source === undefined);
|
|
1255
|
+
if (needsSource.length > 0 && sourceless !== undefined) {
|
|
1256
|
+
throw new Error(
|
|
1257
|
+
`campaign "${campaign.id}" has a probe (${sourceless.path}) with no \`source\`, and its ` +
|
|
1258
|
+
`detector holds a ${needsSource.map((term) => `\`${term}\``).join(", ")} term, which ` +
|
|
1259
|
+
`a path alone cannot exercise. Give every probe a source.`,
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
if (campaign.probes.fires.length === 0) {
|
|
1263
|
+
throw new Error(
|
|
1264
|
+
`campaign "${campaign.id}" carries no \`probes.fires\`. A campaign proves it can fire ` +
|
|
1265
|
+
`the way every rule does; write at least one source it must report.`,
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
return {
|
|
1270
|
+
name,
|
|
1271
|
+
id: campaign.id,
|
|
1272
|
+
...(campaign.title === undefined ? {} : { title: campaign.title }),
|
|
1273
|
+
message: campaign.how,
|
|
1274
|
+
why: campaign.why,
|
|
1275
|
+
...(campaign.owner === undefined ? {} : { owner: campaign.owner }),
|
|
1276
|
+
scope: globsOf(campaign.scope).map(asPath),
|
|
1277
|
+
unit: campaign.unit,
|
|
1278
|
+
detect: lower(campaign.detect),
|
|
1279
|
+
probes: { fires: [...campaign.probes.fires], ignores: [...(campaign.probes.ignores ?? [])] },
|
|
1280
|
+
staleAfter: durationMs(campaign.staleAfter),
|
|
1281
|
+
onComplete: campaign.onComplete ?? "keep",
|
|
1282
|
+
};
|
|
1283
|
+
};
|
|
@@ -31,9 +31,13 @@ const isObject = (value: JsonValue): value is JsonObject =>
|
|
|
31
31
|
|
|
32
32
|
const isList = (value: JsonValue): value is ReadonlyArray<JsonValue> => Array.isArray(value);
|
|
33
33
|
|
|
34
|
-
// The generator names the recursive
|
|
35
|
-
// stable name is what a `$ref` in an error message or a docs page can
|
|
36
|
-
|
|
34
|
+
// The generator names the recursive schemas after its own internal wrappers.
|
|
35
|
+
// A stable name is what a `$ref` in an error message or a docs page can
|
|
36
|
+
// point at: the tree's node, and a campaign's detector.
|
|
37
|
+
const DEFINITION_NAMES: Readonly<Record<string, string>> = {
|
|
38
|
+
Suspend_: "ManifestNode",
|
|
39
|
+
Union_: "Detector",
|
|
40
|
+
};
|
|
37
41
|
|
|
38
42
|
const USE_REFERENCE = "#/$defs/Use";
|
|
39
43
|
const INCLUDE_REFERENCE = "#/$defs/Include";
|