@goodbones/core 0.1.0-beta.1 → 0.1.0-beta.11
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/coverage.d.ts +20 -0
- package/build/dts/core/coverage.d.ts.map +1 -1
- package/build/dts/core/graph.d.ts +2 -0
- package/build/dts/core/graph.d.ts.map +1 -1
- package/build/dts/core/imports.d.ts +3 -1
- package/build/dts/core/imports.d.ts.map +1 -1
- package/build/dts/core/slack.d.ts +22 -0
- package/build/dts/core/slack.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 +19 -3
- package/build/dts/domain/architecture-config.d.ts.map +1 -1
- package/build/dts/domain/architecture-error.d.ts +8 -0
- package/build/dts/domain/architecture-error.d.ts.map +1 -1
- package/build/dts/domain/facts.d.ts +1 -0
- package/build/dts/domain/facts.d.ts.map +1 -1
- package/build/dts/domain/manifest-location.d.ts +9 -0
- package/build/dts/domain/manifest-location.d.ts.map +1 -0
- package/build/dts/domain/snapshot.d.ts +356 -0
- package/build/dts/domain/snapshot.d.ts.map +1 -0
- 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 +22 -10
- 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/manifest-file.d.ts +11 -2
- package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
- package/build/dts/infrastructure/manifest-include.d.ts +16 -0
- package/build/dts/infrastructure/manifest-include.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/infrastructure/walk.d.ts +12 -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 +10 -0
- package/build/dts/load/policy.d.ts.map +1 -1
- package/build/dts/manifest/compile.d.ts +11 -1
- package/build/dts/manifest/compile.d.ts.map +1 -1
- package/build/dts/manifest/expand.d.ts +27 -0
- package/build/dts/manifest/expand.d.ts.map +1 -0
- 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/infer.d.ts +55 -0
- package/build/dts/manifest/infer.d.ts.map +1 -0
- package/build/dts/manifest/json-schema.d.ts +14 -0
- package/build/dts/manifest/json-schema.d.ts.map +1 -0
- package/build/dts/manifest/manifest.d.ts +26 -1
- 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/ports/language.d.ts +4 -0
- package/build/dts/ports/language.d.ts.map +1 -1
- 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 +1 -0
- package/build/dts/testing.d.ts.map +1 -1
- package/build/esm/core/coverage.js +107 -32
- package/build/esm/core/coverage.js.map +1 -1
- package/build/esm/core/graph.js +45 -0
- package/build/esm/core/graph.js.map +1 -1
- package/build/esm/core/imports.js +14 -9
- package/build/esm/core/imports.js.map +1 -1
- package/build/esm/core/slack.js +76 -0
- package/build/esm/core/slack.js.map +1 -0
- package/build/esm/core/structure.js +6 -3
- package/build/esm/core/structure.js.map +1 -1
- package/build/esm/domain/architecture-config.js +28 -4
- package/build/esm/domain/architecture-config.js.map +1 -1
- package/build/esm/domain/architecture-error.js +27 -0
- package/build/esm/domain/architecture-error.js.map +1 -1
- package/build/esm/domain/manifest-location.js +21 -0
- package/build/esm/domain/manifest-location.js.map +1 -0
- package/build/esm/domain/snapshot.js +181 -0
- package/build/esm/domain/snapshot.js.map +1 -0
- package/build/esm/domain/violation.js.map +1 -1
- package/build/esm/index.js +28 -9
- 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/manifest-file.js +158 -7
- package/build/esm/infrastructure/manifest-file.js.map +1 -1
- package/build/esm/infrastructure/manifest-include.js +187 -0
- package/build/esm/infrastructure/manifest-include.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/infrastructure/walk.js +106 -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 +49 -2
- package/build/esm/load/policy.js.map +1 -1
- package/build/esm/manifest/compile.js +50 -27
- package/build/esm/manifest/compile.js.map +1 -1
- package/build/esm/manifest/expand.js +116 -0
- package/build/esm/manifest/expand.js.map +1 -0
- 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/infer.js +455 -0
- package/build/esm/manifest/infer.js.map +1 -0
- package/build/esm/manifest/json-schema.js +169 -0
- package/build/esm/manifest/json-schema.js.map +1 -0
- package/build/esm/manifest/manifest.js +127 -5
- package/build/esm/manifest/manifest.js.map +1 -1
- package/build/esm/ports/syntax-matcher.js +12 -0
- package/build/esm/ports/syntax-matcher.js.map +1 -0
- package/build/esm/testing.js +1 -0
- package/build/esm/testing.js.map +1 -1
- package/package.json +9 -3
- package/schema/architecture-node.schema.json +1518 -0
- package/schema/architecture.schema.json +4498 -0
- package/schema/conformance.schema.json +1019 -0
- package/src/core/coverage.ts +164 -34
- package/src/core/graph.ts +48 -0
- package/src/core/imports.ts +29 -13
- package/src/core/slack.ts +135 -0
- package/src/core/structure.ts +15 -8
- package/src/domain/architecture-config.ts +31 -4
- package/src/domain/architecture-error.ts +30 -0
- package/src/domain/facts.ts +9 -4
- package/src/domain/manifest-location.ts +41 -0
- package/src/domain/snapshot.ts +402 -0
- package/src/domain/violation.ts +4 -1
- package/src/index.ts +149 -7
- package/src/infrastructure/file-system-fake.ts +12 -0
- package/src/infrastructure/file-system-live.ts +8 -1
- package/src/infrastructure/manifest-file.ts +204 -8
- package/src/infrastructure/manifest-include.ts +318 -0
- package/src/infrastructure/syntax-matcher-fake.ts +51 -0
- package/src/infrastructure/walk.ts +127 -3
- package/src/load/extension.ts +66 -0
- package/src/load/policy.ts +77 -2
- package/src/manifest/compile.ts +81 -30
- package/src/manifest/expand.ts +183 -0
- package/src/manifest/extension.ts +41 -0
- package/src/manifest/glob.ts +5 -0
- package/src/manifest/infer.ts +643 -0
- package/src/manifest/json-schema.ts +210 -0
- package/src/manifest/manifest.ts +206 -10
- package/src/ports/file-system.ts +3 -0
- package/src/ports/language.ts +17 -0
- package/src/ports/syntax-matcher.ts +42 -0
- package/src/testing.ts +1 -0
package/src/load/policy.ts
CHANGED
|
@@ -44,18 +44,26 @@ import {
|
|
|
44
44
|
type PatternInvalid,
|
|
45
45
|
} from "../domain/architecture-error.js";
|
|
46
46
|
import type { SourceFacts } from "../domain/facts.js";
|
|
47
|
+
import type { ManifestLocator } from "../domain/manifest-location.js";
|
|
47
48
|
import { type LoweredRules, lowerManifest } from "../manifest/compile.js";
|
|
48
49
|
import { decodeManifest, type Manifest } from "../manifest/manifest.js";
|
|
49
50
|
import type { FactExtractor } from "../ports/fact-extractor.js";
|
|
50
51
|
import type { FileSystem } from "../ports/file-system.js";
|
|
51
52
|
import type { Language } from "../ports/language.js";
|
|
52
53
|
import type { ModuleResolver } from "../ports/module-resolver.js";
|
|
54
|
+
import type { SyntaxMatcher } from "../ports/syntax-matcher.js";
|
|
55
|
+
import type { ExtensionRoute, PolicyExtension } from "./extension.js";
|
|
53
56
|
|
|
54
57
|
// A manifest, read by a host, turned into the policy both adapters evaluate.
|
|
55
58
|
// Decoding, lowering, compiling and probing happen here, once, with whatever
|
|
56
59
|
// languages the host hands in — this tier never names one. The host reads the
|
|
57
60
|
// manifest file, constructs its language packs and the live file system, and
|
|
58
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.
|
|
59
67
|
|
|
60
68
|
export type LoadedPolicy = {
|
|
61
69
|
readonly repoRoot: string;
|
|
@@ -69,6 +77,10 @@ export type LoadedPolicy = {
|
|
|
69
77
|
readonly graph: CompiledGraph;
|
|
70
78
|
readonly adoption: LoweredRules["adoption"];
|
|
71
79
|
readonly structure: CompiledStructure;
|
|
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.
|
|
83
|
+
readonly now: number;
|
|
72
84
|
readonly fileSystem: FileSystem;
|
|
73
85
|
// The language packs this policy is evaluated with. The walker takes its
|
|
74
86
|
// extensions from them, and lowering the shape of its probes.
|
|
@@ -81,6 +93,11 @@ export type LoadedPolicy = {
|
|
|
81
93
|
// Routes each file to the extractor of the language whose scope covers it.
|
|
82
94
|
// The CLI reads every file through this; the plugin reads oxlint's tree.
|
|
83
95
|
readonly extractor: FactExtractor;
|
|
96
|
+
// Routes each file to the syntax matcher of the language whose scope
|
|
97
|
+
// covers it; `parse` answers `null` for a file whose language has none.
|
|
98
|
+
readonly syntax: SyntaxMatcher;
|
|
99
|
+
// Which scope, and so which language, covers a given file.
|
|
100
|
+
readonly routeFor: (file: string) => ExtensionRoute | undefined;
|
|
84
101
|
readonly ignoreUnresolved: ReadonlyArray<RegExp>;
|
|
85
102
|
// Deprecation notices from reading the manifest. The host prints them once.
|
|
86
103
|
readonly notices: ReadonlyArray<string>;
|
|
@@ -93,8 +110,17 @@ export type LoadPolicyInput = {
|
|
|
93
110
|
// The manifest as the host read it — a module's default export, a parsed
|
|
94
111
|
// document — before decoding.
|
|
95
112
|
readonly manifest: unknown;
|
|
113
|
+
// From a data file, where a path in the manifest was written, so a decode
|
|
114
|
+
// error names a line. A module manifest has none.
|
|
115
|
+
readonly locate?: ManifestLocator | undefined;
|
|
96
116
|
readonly languages: ReadonlyArray<Language>;
|
|
97
117
|
readonly fileSystem: FileSystem;
|
|
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;
|
|
122
|
+
// For tests and for a CI that pins the clock; defaults to `Date.now()`.
|
|
123
|
+
readonly now?: number | undefined;
|
|
98
124
|
};
|
|
99
125
|
|
|
100
126
|
const NOTHING: SourceFacts = {
|
|
@@ -180,6 +206,14 @@ const makeRoutingExtractor = (routes: ReadonlyArray<Route>): FactExtractor => ({
|
|
|
180
206
|
routeFor(routes, file)?.language.extractor.factsOf(file, text) ?? NOTHING,
|
|
181
207
|
});
|
|
182
208
|
|
|
209
|
+
// One matcher per scope, behind one port that picks the scope by the file. A
|
|
210
|
+
// language without one parses nothing, which a `syntax` term reads as no
|
|
211
|
+
// matches — and which an extension's probe check refuses for a family that
|
|
212
|
+
// depends on it.
|
|
213
|
+
const makeRoutingMatcher = (routes: ReadonlyArray<Route>): SyntaxMatcher => ({
|
|
214
|
+
parse: (file, text) => routeFor(routes, file)?.language.syntax?.parse(file, text) ?? null,
|
|
215
|
+
});
|
|
216
|
+
|
|
183
217
|
// The baseline is read through the port, so this tier touches no file itself.
|
|
184
218
|
// An absent or unreadable one carries nothing, which is the safe direction:
|
|
185
219
|
// every violation reports.
|
|
@@ -201,8 +235,12 @@ export const loadPolicy = (
|
|
|
201
235
|
input: LoadPolicyInput,
|
|
202
236
|
): Result.Result<LoadedPolicy, ConfigInvalid | PatternInvalid> => {
|
|
203
237
|
const { configPath, fileSystem, languages, repoRoot } = input;
|
|
238
|
+
const extensions = input.extensions ?? [];
|
|
204
239
|
|
|
205
|
-
const decoded = decodeManifest(configPath, input.manifest
|
|
240
|
+
const decoded = decodeManifest(configPath, input.manifest, {
|
|
241
|
+
locate: input.locate,
|
|
242
|
+
extensions,
|
|
243
|
+
});
|
|
206
244
|
if (Result.isFailure(decoded)) return Result.fail(decoded.failure);
|
|
207
245
|
const config = decoded.success.manifest;
|
|
208
246
|
|
|
@@ -212,7 +250,7 @@ export const loadPolicy = (
|
|
|
212
250
|
// The manifest is the authoring surface; these flat rules are the machine's.
|
|
213
251
|
// The languages tell lowering what a source file in each scope is called, so
|
|
214
252
|
// a synthetic probe is a file of the scope's language.
|
|
215
|
-
const rules = lowerManifest(config, languages);
|
|
253
|
+
const rules = lowerManifest(config, languages, { substitutions: decoded.success.substitutions });
|
|
216
254
|
|
|
217
255
|
// The ceilings. A tier that says "not tightened yet" is a sentence someone
|
|
218
256
|
// wrote; a ceiling on how many may say so is what keeps the backlog from
|
|
@@ -281,12 +319,44 @@ export const loadPolicy = (
|
|
|
281
319
|
// reads that file through. The plugin reads through oxlint's tree instead,
|
|
282
320
|
// and the parity suite is what holds that tree to this one.
|
|
283
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
|
+
|
|
284
353
|
const parsedBy = (rule: { readonly probe: { readonly from: string } }): string => {
|
|
285
354
|
const route = routeFor(routes.success, rule.probe.from);
|
|
286
355
|
return route === undefined
|
|
287
356
|
? ` — no resolve scope covers its probe ${JSON.stringify(rule.probe.from)}, so no language parsed it`
|
|
288
357
|
: ` — probe parsed by ${route.language.id}, selected by the scope ${JSON.stringify(route.scope.files)}`;
|
|
289
358
|
};
|
|
359
|
+
|
|
290
360
|
const vacuous = [
|
|
291
361
|
...rulesFailingTheirProbe(importRules.success).map((rule) => rule.name),
|
|
292
362
|
...exportRulesFailingTheirProbe(exportRules.success, extractor).map(
|
|
@@ -300,6 +370,7 @@ export const loadPolicy = (
|
|
|
300
370
|
),
|
|
301
371
|
...structureRulesFailingTheirProbe(structure.success),
|
|
302
372
|
...graphRulesFailingTheirProbe(graph.success),
|
|
373
|
+
...extensionVacuous,
|
|
303
374
|
];
|
|
304
375
|
if (vacuous.length > 0) {
|
|
305
376
|
return Result.fail(
|
|
@@ -331,11 +402,15 @@ export const loadPolicy = (
|
|
|
331
402
|
graph: graph.success,
|
|
332
403
|
adoption: rules.adoption,
|
|
333
404
|
structure: structure.success,
|
|
405
|
+
extensions: loadedExtensions,
|
|
406
|
+
now,
|
|
334
407
|
fileSystem,
|
|
335
408
|
languages,
|
|
336
409
|
baseline: makeBaselineFilter(readBaseline(fileSystem, config.baseline)),
|
|
337
410
|
resolver: resolver.success,
|
|
338
411
|
extractor,
|
|
412
|
+
syntax,
|
|
413
|
+
routeFor: routeOf,
|
|
339
414
|
ignoreUnresolved: (config.resolve.ignoreUnresolved ?? []).map(
|
|
340
415
|
(pattern: string) => new RegExp(pattern),
|
|
341
416
|
),
|
package/src/manifest/compile.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type Allowance,
|
|
2
3
|
type ExportRule,
|
|
3
4
|
type GraphConfig,
|
|
4
5
|
type ImportRule,
|
|
@@ -10,6 +11,8 @@ import {
|
|
|
10
11
|
type StructureRoot,
|
|
11
12
|
type SurfaceRule,
|
|
12
13
|
} from "../domain/architecture-config.js";
|
|
14
|
+
import type { ManifestPath } from "../domain/manifest-location.js";
|
|
15
|
+
import { fragmentOf, type Substitution } from "./expand.js";
|
|
13
16
|
import { anchored, type CaptureIndex, globToRegexSource, prefixed } from "./glob.js";
|
|
14
17
|
import {
|
|
15
18
|
globsOf,
|
|
@@ -69,7 +72,7 @@ const alternativesOf = (key: string): ReadonlyArray<string> =>
|
|
|
69
72
|
.map(stripSlash)
|
|
70
73
|
.filter((one) => one !== "");
|
|
71
74
|
|
|
72
|
-
const expandAliases = (glob: string, aliases: Readonly<Record<string, string>>): string => {
|
|
75
|
+
export const expandAliases = (glob: string, aliases: Readonly<Record<string, string>>): string => {
|
|
73
76
|
for (const [alias, target] of Object.entries(aliases)) {
|
|
74
77
|
if (glob === alias) return target;
|
|
75
78
|
if (glob.startsWith(`${alias}/`)) return target + glob.slice(alias.length);
|
|
@@ -84,12 +87,11 @@ type Frame = {
|
|
|
84
87
|
readonly pathGlob: string;
|
|
85
88
|
readonly captures: CaptureIndex;
|
|
86
89
|
readonly nextGroup: number;
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
readonly externals: ReadonlyArray<string>;
|
|
90
|
+
// The allowlist in force, accumulated down the tree; `reset` is the only
|
|
91
|
+
// thing that clears it. Each entry remembers the node that wrote it. A path
|
|
92
|
+
// glob is compiled to a target pattern; a package is judged by its name and
|
|
93
|
+
// never by where the language's resolver found it, so it carries none.
|
|
94
|
+
readonly allowances: ReadonlyArray<Allowance>;
|
|
93
95
|
readonly importsMessage: string;
|
|
94
96
|
// Inherited like the allowlist: a tier states its naming convention once.
|
|
95
97
|
readonly naming: NamingSpec | undefined;
|
|
@@ -136,7 +138,7 @@ const probeMemberName = (match: string | ReadonlyArray<string> | undefined): str
|
|
|
136
138
|
|
|
137
139
|
// Each convention pairs the shape a name must have with a name that does not
|
|
138
140
|
// have it, so the rule's probe is generated rather than written.
|
|
139
|
-
const CONVENTIONS: Readonly<
|
|
141
|
+
export const CONVENTIONS: Readonly<
|
|
140
142
|
Record<string, { readonly source: string; readonly violating: string; readonly shape: string }>
|
|
141
143
|
> = {
|
|
142
144
|
"kebab-case": {
|
|
@@ -225,22 +227,24 @@ type Denial = {
|
|
|
225
227
|
readonly probe: string;
|
|
226
228
|
};
|
|
227
229
|
|
|
230
|
+
// Which `defs` fragment one key of a node's `imports` was written in, when it
|
|
231
|
+
// was written in one. Resolved per key, since `imports: { use: x, allow: […] }`
|
|
232
|
+
// takes `allow` from the reference site and `external` from the fragment.
|
|
233
|
+
type ImportsProvenance = (key: "allow" | "external") => string | undefined;
|
|
234
|
+
|
|
228
235
|
const mergeImports = (
|
|
229
236
|
frame: Frame,
|
|
230
237
|
spec: ImportsSpec | undefined,
|
|
231
238
|
aliases: Readonly<Record<string, string>>,
|
|
232
239
|
captures: CaptureIndex,
|
|
233
240
|
nextGroup: number,
|
|
234
|
-
|
|
241
|
+
node: string,
|
|
242
|
+
provenance: ImportsProvenance,
|
|
243
|
+
): Pick<Frame, "allowances" | "importsMessage"> & {
|
|
235
244
|
readonly deny: ReadonlyArray<Denial>;
|
|
236
245
|
} => {
|
|
237
246
|
if (spec === undefined) {
|
|
238
|
-
return {
|
|
239
|
-
allow: frame.allow,
|
|
240
|
-
externals: frame.externals,
|
|
241
|
-
deny: [],
|
|
242
|
-
importsMessage: frame.importsMessage,
|
|
243
|
-
};
|
|
247
|
+
return { allowances: frame.allowances, deny: [], importsMessage: frame.importsMessage };
|
|
244
248
|
}
|
|
245
249
|
|
|
246
250
|
const compileAllow = (glob: string): string =>
|
|
@@ -249,8 +253,25 @@ const mergeImports = (
|
|
|
249
253
|
.source,
|
|
250
254
|
);
|
|
251
255
|
|
|
252
|
-
const
|
|
253
|
-
|
|
256
|
+
const via = (key: "allow" | "external"): Pick<Allowance, "fragment"> => {
|
|
257
|
+
const fragment = provenance(key);
|
|
258
|
+
return fragment === undefined ? {} : { fragment };
|
|
259
|
+
};
|
|
260
|
+
const own: ReadonlyArray<Allowance> = [
|
|
261
|
+
...globsOf(spec.allow ?? []).map((glob) => ({
|
|
262
|
+
node,
|
|
263
|
+
kind: "allow" as const,
|
|
264
|
+
entry: expandAliases(glob, aliases),
|
|
265
|
+
pattern: compileAllow(glob),
|
|
266
|
+
...via("allow"),
|
|
267
|
+
})),
|
|
268
|
+
...(spec.external ?? []).map((name) => ({
|
|
269
|
+
node,
|
|
270
|
+
kind: "external" as const,
|
|
271
|
+
entry: name,
|
|
272
|
+
...via("external"),
|
|
273
|
+
})),
|
|
274
|
+
];
|
|
254
275
|
const deny = (spec.deny ?? []).flatMap((entry) =>
|
|
255
276
|
globsOf(entry.match).map((glob) => ({
|
|
256
277
|
match: compileAllow(glob),
|
|
@@ -266,8 +287,7 @@ const mergeImports = (
|
|
|
266
287
|
// a mistake here would be dangerous in.
|
|
267
288
|
const dropping = spec.reset === true || spec.unrestricted === true;
|
|
268
289
|
return {
|
|
269
|
-
|
|
270
|
-
externals: dropping ? external : [...frame.externals, ...external],
|
|
290
|
+
allowances: dropping ? own : [...frame.allowances, ...own],
|
|
271
291
|
// Only what this node declares. A prohibition is emitted once, over its whole
|
|
272
292
|
// subtree, so descendants neither re-emit it nor can escape it — which is
|
|
273
293
|
// what makes `reset` structurally unable to make a subtree quieter.
|
|
@@ -276,11 +296,20 @@ const mergeImports = (
|
|
|
276
296
|
};
|
|
277
297
|
};
|
|
278
298
|
|
|
299
|
+
export type LowerOptions = {
|
|
300
|
+
// The `use` references the expansion replaced, so an allowance can say
|
|
301
|
+
// which fragment it came through. A manifest lowered without them is one
|
|
302
|
+
// whose every entry reads as authored where it sits.
|
|
303
|
+
readonly substitutions?: ReadonlyArray<Substitution>;
|
|
304
|
+
};
|
|
305
|
+
|
|
279
306
|
export const lowerManifest = (
|
|
280
307
|
manifest: Manifest,
|
|
281
308
|
languages: ReadonlyArray<ProbeLanguage> = [],
|
|
309
|
+
options: LowerOptions = {},
|
|
282
310
|
): LoweredRules => {
|
|
283
311
|
const aliases = manifest.aliases ?? {};
|
|
312
|
+
const substitutions = options.substitutions ?? [];
|
|
284
313
|
|
|
285
314
|
// The extension a synthetic probe file carries: the first extension of the
|
|
286
315
|
// language whose scope covers the probe's folder. A probe is matched by its
|
|
@@ -343,6 +372,9 @@ export const lowerManifest = (
|
|
|
343
372
|
parent: Frame,
|
|
344
373
|
name: string,
|
|
345
374
|
siblings: ReadonlyArray<string>,
|
|
375
|
+
// Where this node sits in the expanded document, so its `imports` keys
|
|
376
|
+
// can be traced back through any `use` that carried them.
|
|
377
|
+
nodePath: ManifestPath,
|
|
346
378
|
): void => {
|
|
347
379
|
const literalSiblings = siblings
|
|
348
380
|
.filter((sibling) => sibling !== key)
|
|
@@ -392,15 +424,22 @@ export const lowerManifest = (
|
|
|
392
424
|
parent.nextGroup +
|
|
393
425
|
(Object.keys(compiled.captures).length - Object.keys(parent.captures).length);
|
|
394
426
|
|
|
395
|
-
const merged = mergeImports(
|
|
427
|
+
const merged = mergeImports(
|
|
428
|
+
parent,
|
|
429
|
+
node.imports,
|
|
430
|
+
aliases,
|
|
431
|
+
compiled.captures,
|
|
432
|
+
nextGroup,
|
|
433
|
+
name,
|
|
434
|
+
(field) => fragmentOf(substitutions, [...nodePath, "imports", field]),
|
|
435
|
+
);
|
|
396
436
|
const ownDenials = merged.deny;
|
|
397
437
|
const frame: Frame = {
|
|
398
438
|
pathSource,
|
|
399
439
|
pathGlob: joinedGlob,
|
|
400
440
|
captures: compiled.captures,
|
|
401
441
|
nextGroup,
|
|
402
|
-
|
|
403
|
-
externals: merged.externals,
|
|
442
|
+
allowances: merged.allowances,
|
|
404
443
|
importsMessage: merged.importsMessage,
|
|
405
444
|
naming: node.name ?? parent.naming,
|
|
406
445
|
};
|
|
@@ -590,7 +629,11 @@ export const lowerManifest = (
|
|
|
590
629
|
}
|
|
591
630
|
const siblingKeys = childKeys.map(([childKey]) => childKey);
|
|
592
631
|
for (const [childKey, child] of childKeys) {
|
|
593
|
-
walk(childKey, child, frame, `${name}/${alternativesOf(childKey)[0] ?? ""}`, siblingKeys
|
|
632
|
+
walk(childKey, child, frame, `${name}/${alternativesOf(childKey)[0] ?? ""}`, siblingKeys, [
|
|
633
|
+
...nodePath,
|
|
634
|
+
"children",
|
|
635
|
+
childKey,
|
|
636
|
+
]);
|
|
594
637
|
}
|
|
595
638
|
}
|
|
596
639
|
|
|
@@ -608,9 +651,14 @@ export const lowerManifest = (
|
|
|
608
651
|
? probePathOf(joinedGlob, "")
|
|
609
652
|
: probePathOf(joinedGlob, "").replace(/\/[^/]*$/, "");
|
|
610
653
|
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
654
|
+
const allow = frame.allowances.flatMap((one) =>
|
|
655
|
+
one.pattern === undefined ? [] : [one.pattern],
|
|
656
|
+
);
|
|
657
|
+
const externals = frame.allowances
|
|
658
|
+
.filter((one) => one.kind === "external")
|
|
659
|
+
.map((one) => one.entry);
|
|
660
|
+
const admitsEverything = allow.some((pattern) => pattern === "^.*" || pattern === "^");
|
|
661
|
+
const hasAllowlist = frame.allowances.length > 0 && !admitsEverything;
|
|
614
662
|
|
|
615
663
|
if (emitsOwnImports && node.imports?.unrestricted !== true && !hasAllowlist) {
|
|
616
664
|
throw new Error(
|
|
@@ -630,8 +678,9 @@ export const lowerManifest = (
|
|
|
630
678
|
probe: { from: scopeProbe, to: probeOutside("nowhere", ownFolder) },
|
|
631
679
|
from: scope,
|
|
632
680
|
...exemptions,
|
|
633
|
-
toNot:
|
|
634
|
-
...(
|
|
681
|
+
toNot: allow,
|
|
682
|
+
...(externals.length > 0 ? { externals } : {}),
|
|
683
|
+
allowances: frame.allowances,
|
|
635
684
|
});
|
|
636
685
|
}
|
|
637
686
|
}
|
|
@@ -841,8 +890,7 @@ export const lowerManifest = (
|
|
|
841
890
|
pathGlob: "",
|
|
842
891
|
captures: {},
|
|
843
892
|
nextGroup: 1,
|
|
844
|
-
|
|
845
|
-
externals: [],
|
|
893
|
+
allowances: [],
|
|
846
894
|
importsMessage: "This import is not on this folder's allowlist.",
|
|
847
895
|
naming: undefined,
|
|
848
896
|
};
|
|
@@ -856,6 +904,8 @@ export const lowerManifest = (
|
|
|
856
904
|
aliases,
|
|
857
905
|
{},
|
|
858
906
|
1,
|
|
907
|
+
"repo",
|
|
908
|
+
() => undefined,
|
|
859
909
|
).deny.entries()) {
|
|
860
910
|
imports.push({
|
|
861
911
|
name: `repo/deny-${String(index)}`,
|
|
@@ -898,6 +948,7 @@ export const lowerManifest = (
|
|
|
898
948
|
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
899
949
|
.replace(/^-|-$/g, ""),
|
|
900
950
|
Object.keys(manifest.tree),
|
|
951
|
+
["tree", key],
|
|
901
952
|
);
|
|
902
953
|
}
|
|
903
954
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import * as Result from "effect/Result";
|
|
2
|
+
|
|
3
|
+
import type { ManifestPath } from "../domain/manifest-location.js";
|
|
4
|
+
|
|
5
|
+
// Reuse inside a manifest, in the manifest's own schema rather than the
|
|
6
|
+
// format's. A top-level `defs` map names fragments; `{ use: "<name>" }`
|
|
7
|
+
// anywhere in the rest of the document is replaced by a deep copy of the
|
|
8
|
+
// fragment. This runs on the raw value before decoding, so it works the same
|
|
9
|
+
// in YAML, in JSON, and in a JavaScript module that chose to write it — and
|
|
10
|
+
// the schema that decodes the result never has to know a reference existed.
|
|
11
|
+
//
|
|
12
|
+
// There is deliberately nothing else here: no interpolation, no includes, no
|
|
13
|
+
// deep merge. A fragment that needs partial override is two fragments; a
|
|
14
|
+
// manifest that needs more than a data format offers needs a generator, and
|
|
15
|
+
// a generator can emit YAML.
|
|
16
|
+
|
|
17
|
+
export type ExpandIssue = {
|
|
18
|
+
readonly path: ManifestPath;
|
|
19
|
+
readonly detail: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// One `use` that was expanded. `at` is where the fragment landed in the
|
|
23
|
+
// expanded document; `ref` is where the reference was written in the original
|
|
24
|
+
// one; `overrides` are the keys written beside `use`, which came from the
|
|
25
|
+
// reference site rather than from the fragment.
|
|
26
|
+
export type Substitution = {
|
|
27
|
+
readonly at: ManifestPath;
|
|
28
|
+
readonly ref: ManifestPath;
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly overrides: ReadonlySet<string>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ExpandedManifest = {
|
|
34
|
+
readonly value: unknown;
|
|
35
|
+
readonly substitutions: ReadonlyArray<Substitution>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Where a path in the expanded document was written in the original one. When
|
|
39
|
+
// the path crosses a `use`, `via` lists each reference it passed through,
|
|
40
|
+
// outermost first, so an error inside a fragment can name the line that pulled
|
|
41
|
+
// the fragment in as well as the fragment itself.
|
|
42
|
+
export type Origin = {
|
|
43
|
+
readonly path: ManifestPath;
|
|
44
|
+
readonly via: ReadonlyArray<{ readonly at: ManifestPath; readonly name: string }>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
48
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
|
|
50
|
+
const isReference = (value: unknown): value is Record<string, unknown> & { readonly use: string } =>
|
|
51
|
+
isRecord(value) && typeof value.use === "string";
|
|
52
|
+
|
|
53
|
+
export const expandManifest = (input: unknown): Result.Result<ExpandedManifest, ExpandIssue> => {
|
|
54
|
+
if (!isRecord(input)) return Result.succeed({ value: input, substitutions: [] });
|
|
55
|
+
|
|
56
|
+
// Two keys the file may carry that the schema does not: the fragments, and
|
|
57
|
+
// the `$schema` a JSON author writes for editor validation.
|
|
58
|
+
const { $schema: _schema, defs, ...rest } = input;
|
|
59
|
+
if (defs !== undefined && !isRecord(defs)) {
|
|
60
|
+
return Result.fail({
|
|
61
|
+
path: ["defs"],
|
|
62
|
+
detail: "`defs` must be a map of named fragments.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const fragments: Record<string, unknown> = defs ?? {};
|
|
66
|
+
const defined = Object.keys(fragments);
|
|
67
|
+
const substitutions: Array<Substitution> = [];
|
|
68
|
+
|
|
69
|
+
// `at` is the path in the document being built; `origin` the path in the
|
|
70
|
+
// document as written; `stack` the fragments currently being expanded, for
|
|
71
|
+
// the cycle check.
|
|
72
|
+
const walk = (
|
|
73
|
+
value: unknown,
|
|
74
|
+
at: ManifestPath,
|
|
75
|
+
origin: ManifestPath,
|
|
76
|
+
stack: ReadonlyArray<string>,
|
|
77
|
+
): Result.Result<unknown, ExpandIssue> => {
|
|
78
|
+
if (isReference(value)) {
|
|
79
|
+
const { use: name, ...overrides } = value;
|
|
80
|
+
if (!(name in fragments)) {
|
|
81
|
+
return Result.fail({
|
|
82
|
+
path: origin,
|
|
83
|
+
detail:
|
|
84
|
+
`\`use: ${JSON.stringify(name)}\` names no entry in \`defs\`` +
|
|
85
|
+
(defined.length === 0
|
|
86
|
+
? " — the manifest defines none."
|
|
87
|
+
: ` (defined: ${defined.join(", ")}).`),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (stack.includes(name)) {
|
|
91
|
+
return Result.fail({
|
|
92
|
+
path: origin,
|
|
93
|
+
detail: `\`defs\` contains a cycle: ${[...stack, name].join(" → ")}.`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
substitutions.push({ at, ref: origin, name, overrides: new Set(Object.keys(overrides)) });
|
|
97
|
+
|
|
98
|
+
const fragment = walk(fragments[name], at, ["defs", name], [...stack, name]);
|
|
99
|
+
if (Result.isFailure(fragment)) return fragment;
|
|
100
|
+
if (Object.keys(overrides).length === 0) return fragment;
|
|
101
|
+
|
|
102
|
+
if (!isRecord(fragment.success)) {
|
|
103
|
+
return Result.fail({
|
|
104
|
+
path: origin,
|
|
105
|
+
detail:
|
|
106
|
+
`\`use: ${JSON.stringify(name)}\` is written with overrides ` +
|
|
107
|
+
`(${Object.keys(overrides).join(", ")}), but \`defs.${name}\` is not an object, ` +
|
|
108
|
+
`so there is nothing to override.`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const merged: Record<string, unknown> = { ...fragment.success };
|
|
112
|
+
for (const [key, override] of Object.entries(overrides)) {
|
|
113
|
+
const expanded = walk(override, [...at, key], [...origin, key], stack);
|
|
114
|
+
if (Result.isFailure(expanded)) return expanded;
|
|
115
|
+
merged[key] = expanded.success;
|
|
116
|
+
}
|
|
117
|
+
return Result.succeed(merged);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
const items: Array<unknown> = [];
|
|
122
|
+
for (const [index, item] of value.entries()) {
|
|
123
|
+
const expanded = walk(item, [...at, index], [...origin, index], stack);
|
|
124
|
+
if (Result.isFailure(expanded)) return expanded;
|
|
125
|
+
items.push(expanded.success);
|
|
126
|
+
}
|
|
127
|
+
return Result.succeed(items);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (isRecord(value)) {
|
|
131
|
+
const entries: Record<string, unknown> = {};
|
|
132
|
+
for (const [key, item] of Object.entries(value)) {
|
|
133
|
+
const expanded = walk(item, [...at, key], [...origin, key], stack);
|
|
134
|
+
if (Result.isFailure(expanded)) return expanded;
|
|
135
|
+
entries[key] = expanded.success;
|
|
136
|
+
}
|
|
137
|
+
return Result.succeed(entries);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return Result.succeed(value);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const expanded = walk(rest, [], [], []);
|
|
144
|
+
if (Result.isFailure(expanded)) return Result.fail(expanded.failure);
|
|
145
|
+
return Result.succeed({ value: expanded.success, substitutions });
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const isPrefix = (prefix: ManifestPath, path: ManifestPath): boolean =>
|
|
149
|
+
prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment);
|
|
150
|
+
|
|
151
|
+
// Maps a path in the expanded document back to where it was written.
|
|
152
|
+
export const originOf = (
|
|
153
|
+
substitutions: ReadonlyArray<Substitution>,
|
|
154
|
+
path: ManifestPath,
|
|
155
|
+
): Origin => {
|
|
156
|
+
const crossed = substitutions
|
|
157
|
+
.filter((one) => isPrefix(one.at, path))
|
|
158
|
+
.sort((a, b) => a.at.length - b.at.length);
|
|
159
|
+
const innermost = crossed.at(-1);
|
|
160
|
+
if (innermost === undefined) return { path, via: [] };
|
|
161
|
+
|
|
162
|
+
const outer = crossed.slice(0, -1).map((one) => ({ at: one.ref, name: one.name }));
|
|
163
|
+
const rest = path.slice(innermost.at.length);
|
|
164
|
+
const first = rest[0];
|
|
165
|
+
|
|
166
|
+
// A key written beside `use` belongs to the reference site, not the fragment.
|
|
167
|
+
if (typeof first === "string" && innermost.overrides.has(first)) {
|
|
168
|
+
return { path: [...innermost.ref, ...rest], via: outer };
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
path: ["defs", innermost.name, ...rest],
|
|
172
|
+
via: [...outer, { at: innermost.ref, name: innermost.name }],
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// The fragment a path in the expanded document was written in, when it was:
|
|
177
|
+
// the innermost `use` the path crossed, or nothing when the value at that path
|
|
178
|
+
// was authored where it sits — including a key written beside `use`, which
|
|
179
|
+
// belongs to the reference site.
|
|
180
|
+
export const fragmentOf = (
|
|
181
|
+
substitutions: ReadonlyArray<Substitution>,
|
|
182
|
+
path: ManifestPath,
|
|
183
|
+
): string | undefined => originOf(substitutions, path).via.at(-1)?.name;
|
|
@@ -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));
|