@goodbones/core 0.1.0-beta.1 → 0.1.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/dts/core/campaigns.d.ts +141 -0
- package/build/dts/core/campaigns.d.ts.map +1 -0
- package/build/dts/core/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/ledger.d.ts +61 -0
- package/build/dts/core/ledger.d.ts.map +1 -0
- 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 +324 -1
- package/build/dts/domain/architecture-config.d.ts.map +1 -1
- package/build/dts/domain/architecture-error.d.ts +8 -0
- package/build/dts/domain/architecture-error.d.ts.map +1 -1
- package/build/dts/domain/facts.d.ts +1 -0
- package/build/dts/domain/facts.d.ts.map +1 -1
- package/build/dts/domain/manifest-location.d.ts +9 -0
- package/build/dts/domain/manifest-location.d.ts.map +1 -0
- package/build/dts/domain/report.d.ts +16 -0
- package/build/dts/domain/report.d.ts.map +1 -0
- package/build/dts/domain/snapshot.d.ts +224 -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 +24 -9
- 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/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/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/infrastructure/walk.d.ts +6 -0
- package/build/dts/infrastructure/walk.d.ts.map +1 -1
- package/build/dts/load/policy.d.ts +16 -0
- 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/expand.d.ts +27 -0
- package/build/dts/manifest/expand.d.ts.map +1 -0
- 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 +10 -0
- package/build/dts/manifest/json-schema.d.ts.map +1 -0
- package/build/dts/manifest/manifest.d.ts +381 -1
- 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 +4 -0
- package/build/dts/ports/language.d.ts.map +1 -1
- package/build/dts/ports/report-source.d.ts +14 -0
- package/build/dts/ports/report-source.d.ts.map +1 -0
- package/build/dts/ports/syntax-matcher.d.ts +21 -0
- package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
- package/build/dts/testing.d.ts +2 -0
- package/build/dts/testing.d.ts.map +1 -1
- package/build/esm/core/campaigns.js +778 -0
- package/build/esm/core/campaigns.js.map +1 -0
- package/build/esm/core/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/ledger.js +171 -0
- package/build/esm/core/ledger.js.map +1 -0
- package/build/esm/core/slack.js +76 -0
- package/build/esm/core/slack.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 +158 -1
- package/build/esm/domain/architecture-config.js.map +1 -1
- package/build/esm/domain/architecture-error.js +27 -0
- package/build/esm/domain/architecture-error.js.map +1 -1
- package/build/esm/domain/manifest-location.js +21 -0
- package/build/esm/domain/manifest-location.js.map +1 -0
- package/build/esm/domain/report.js +168 -0
- package/build/esm/domain/report.js.map +1 -0
- package/build/esm/domain/snapshot.js +141 -0
- package/build/esm/domain/snapshot.js.map +1 -0
- package/build/esm/domain/violation.js.map +1 -1
- package/build/esm/index.js +21 -8
- 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/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/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 +165 -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/infrastructure/walk.js +57 -1
- package/build/esm/infrastructure/walk.js.map +1 -1
- package/build/esm/load/policy.js +123 -3
- package/build/esm/load/policy.js.map +1 -1
- package/build/esm/manifest/compile.js +228 -26
- 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/infer.js +455 -0
- package/build/esm/manifest/infer.js.map +1 -0
- package/build/esm/manifest/json-schema.js +135 -0
- package/build/esm/manifest/json-schema.js.map +1 -0
- package/build/esm/manifest/manifest.js +243 -5
- 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 +7 -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 +9 -3
- package/schema/architecture-node.schema.json +2405 -0
- package/schema/architecture.schema.json +2825 -0
- package/schema/conformance.schema.json +775 -0
- package/src/core/campaigns.ts +1056 -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/ledger.ts +242 -0
- package/src/core/slack.ts +135 -0
- package/src/core/structure.ts +10 -5
- package/src/domain/architecture-config.ts +205 -1
- 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/report.ts +203 -0
- package/src/domain/snapshot.ts +302 -0
- package/src/domain/violation.ts +4 -1
- package/src/index.ts +172 -3
- package/src/infrastructure/campaign-functions.ts +98 -0
- package/src/infrastructure/manifest-file.ts +204 -8
- package/src/infrastructure/manifest-include.ts +318 -0
- package/src/infrastructure/report-source-fake.ts +10 -0
- package/src/infrastructure/report-source-live.ts +192 -0
- package/src/infrastructure/syntax-matcher-fake.ts +51 -0
- package/src/infrastructure/walk.ts +70 -1
- package/src/load/policy.ts +193 -3
- package/src/manifest/compile.ts +290 -28
- package/src/manifest/expand.ts +183 -0
- package/src/manifest/infer.ts +643 -0
- package/src/manifest/json-schema.ts +168 -0
- package/src/manifest/manifest.ts +339 -11
- package/src/ports/campaign-predicate.ts +30 -0
- package/src/ports/language.ts +17 -0
- package/src/ports/report-source.ts +35 -0
- package/src/ports/syntax-matcher.ts +42 -0
- package/src/testing.ts +2 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
|
|
3
|
+
import { Manifest } from "./manifest.js";
|
|
4
|
+
|
|
5
|
+
// The manifest's shape as a JSON Schema, generated from the same codec that
|
|
6
|
+
// decodes it, so the two cannot disagree. A YAML file names it in a header
|
|
7
|
+
// comment and a JSON file in a `$schema` key; either way the editor completes
|
|
8
|
+
// keys and flags a misspelled one before the loader ever runs.
|
|
9
|
+
//
|
|
10
|
+
// Three things the codec does not know are added here: the `defs` map, the
|
|
11
|
+
// `{ use }` reference form that may stand in for any object, and the
|
|
12
|
+
// `{ include }` form that may stand in for any object or list — all belong to
|
|
13
|
+
// the passes that run before decoding.
|
|
14
|
+
|
|
15
|
+
export const MANIFEST_SCHEMA_ID =
|
|
16
|
+
"https://dataquail.github.io/goodbones/schema/architecture.schema.json";
|
|
17
|
+
|
|
18
|
+
// The schema an included file names: one node of the tree, with the two keys
|
|
19
|
+
// a file of its own may carry at the top.
|
|
20
|
+
export const MANIFEST_NODE_SCHEMA_ID =
|
|
21
|
+
"https://dataquail.github.io/goodbones/schema/architecture-node.schema.json";
|
|
22
|
+
|
|
23
|
+
type JsonValue = string | number | boolean | null | JsonObject | ReadonlyArray<JsonValue>;
|
|
24
|
+
type JsonObject = { readonly [key: string]: JsonValue };
|
|
25
|
+
|
|
26
|
+
const entriesOf = (value: JsonObject): ReadonlyArray<readonly [string, JsonValue]> =>
|
|
27
|
+
Object.entries(value);
|
|
28
|
+
|
|
29
|
+
const isObject = (value: JsonValue): value is JsonObject =>
|
|
30
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
|
|
32
|
+
const isList = (value: JsonValue): value is ReadonlyArray<JsonValue> => Array.isArray(value);
|
|
33
|
+
|
|
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
|
+
};
|
|
41
|
+
|
|
42
|
+
const USE_REFERENCE = "#/$defs/Use";
|
|
43
|
+
const INCLUDE_REFERENCE = "#/$defs/Include";
|
|
44
|
+
|
|
45
|
+
const Use: JsonObject = {
|
|
46
|
+
type: "object",
|
|
47
|
+
description:
|
|
48
|
+
"A reference to a fragment under the top-level `defs`. Replaced by a copy of the fragment before the manifest is decoded; any other key written beside `use` overrides the fragment's key of the same name.",
|
|
49
|
+
properties: { use: { type: "string" } },
|
|
50
|
+
required: ["use"],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const Include: JsonObject = {
|
|
54
|
+
type: "object",
|
|
55
|
+
description:
|
|
56
|
+
"A reference to another YAML or JSON file, relative to this one. Replaced by that file's whole value before the manifest is decoded; a list item naming a file that holds a list is spliced in. Nothing may be written beside `include`.",
|
|
57
|
+
properties: { include: { type: "string" } },
|
|
58
|
+
required: ["include"],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Every object schema below the root becomes "this object, or a `use` of a
|
|
63
|
+
// fragment shaped like it, or an `include` of a file holding one", and every
|
|
64
|
+
// list schema "this list, or an `include` of a file holding one". The
|
|
65
|
+
// expansion passes replace a reference wherever it stands, so the schema
|
|
66
|
+
// admits one wherever the value may stand.
|
|
67
|
+
const admitReferences = (value: JsonValue): JsonValue => {
|
|
68
|
+
if (Array.isArray(value)) return value.map(admitReferences);
|
|
69
|
+
if (!isObject(value)) return value;
|
|
70
|
+
|
|
71
|
+
const rebuilt: Record<string, JsonValue> = {};
|
|
72
|
+
for (const [key, entry] of entriesOf(value)) {
|
|
73
|
+
if (key === "$ref" && typeof entry === "string") {
|
|
74
|
+
const name = entry.replace(/^#\/\$defs\//, "");
|
|
75
|
+
rebuilt[key] = `#/$defs/${DEFINITION_NAMES[name] ?? name}`;
|
|
76
|
+
} else {
|
|
77
|
+
rebuilt[key] = admitReferences(entry);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (rebuilt.type === "object" && "properties" in rebuilt) {
|
|
81
|
+
return { anyOf: [{ $ref: USE_REFERENCE }, { $ref: INCLUDE_REFERENCE }, rebuilt] };
|
|
82
|
+
}
|
|
83
|
+
if (rebuilt.type === "array") {
|
|
84
|
+
return { anyOf: [{ $ref: INCLUDE_REFERENCE }, rebuilt] };
|
|
85
|
+
}
|
|
86
|
+
return rebuilt;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const DEFS_PROPERTY: JsonObject = {
|
|
90
|
+
type: "object",
|
|
91
|
+
description:
|
|
92
|
+
'Named fragments, referenced elsewhere in the manifest as `{ use: "<name>" }`. A fragment may itself contain `use`. Every file\'s `defs` share one namespace.',
|
|
93
|
+
additionalProperties: true,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const SCHEMA_PROPERTY: JsonObject = {
|
|
97
|
+
type: "string",
|
|
98
|
+
description: "For editors. Ignored by the loader.",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const manifestJsonSchema = (): JsonObject => {
|
|
102
|
+
const generated = Schema.toJsonSchemaDocument(Manifest) as unknown as {
|
|
103
|
+
readonly schema: JsonObject;
|
|
104
|
+
readonly definitions: JsonObject;
|
|
105
|
+
};
|
|
106
|
+
const { properties, ...root } = generated.schema;
|
|
107
|
+
if (properties === undefined || !isObject(properties)) {
|
|
108
|
+
throw new Error("the manifest schema generated with no properties");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const definitions: Record<string, JsonValue> = {};
|
|
112
|
+
for (const [name, definition] of entriesOf(generated.definitions)) {
|
|
113
|
+
definitions[DEFINITION_NAMES[name] ?? name] = admitReferences(definition);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
118
|
+
$id: MANIFEST_SCHEMA_ID,
|
|
119
|
+
title: "Architecture manifest",
|
|
120
|
+
description:
|
|
121
|
+
"One manifest of a repository's architecture, read by @goodbones/cli and @goodbones/oxlint. See https://dataquail.github.io/goodbones/architecture-rules/manifest/.",
|
|
122
|
+
...root,
|
|
123
|
+
properties: {
|
|
124
|
+
$schema: SCHEMA_PROPERTY,
|
|
125
|
+
defs: DEFS_PROPERTY,
|
|
126
|
+
...Object.fromEntries(
|
|
127
|
+
entriesOf(properties).map(([key, value]) => [key, admitReferences(value)]),
|
|
128
|
+
),
|
|
129
|
+
},
|
|
130
|
+
$defs: { ...definitions, Use, Include },
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// One node of the tree as a file of its own — what a per-package
|
|
135
|
+
// `architecture.yaml` that the root manifest `include`s is shaped like. The
|
|
136
|
+
// node's object form, with the `$schema` and `defs` keys such a file may carry
|
|
137
|
+
// at the top, over the same definitions as the whole manifest.
|
|
138
|
+
export const manifestNodeJsonSchema = (): JsonObject => {
|
|
139
|
+
const whole = manifestJsonSchema();
|
|
140
|
+
const definitions = whole.$defs;
|
|
141
|
+
if (definitions === undefined || !isObject(definitions)) {
|
|
142
|
+
throw new Error("the manifest schema generated with no definitions");
|
|
143
|
+
}
|
|
144
|
+
const node = definitions.ManifestNode;
|
|
145
|
+
const variants = node !== undefined && isObject(node) ? node.anyOf : undefined;
|
|
146
|
+
const object =
|
|
147
|
+
variants !== undefined && isList(variants)
|
|
148
|
+
? variants.find((variant) => isObject(variant) && variant.type === "object")
|
|
149
|
+
: undefined;
|
|
150
|
+
if (object === undefined || !isObject(object)) {
|
|
151
|
+
throw new Error("the manifest schema generated with no object form of a node");
|
|
152
|
+
}
|
|
153
|
+
const { $id: _id, $schema: _schema, ...rest } = object;
|
|
154
|
+
return {
|
|
155
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
156
|
+
$id: MANIFEST_NODE_SCHEMA_ID,
|
|
157
|
+
title: "Architecture manifest node",
|
|
158
|
+
description:
|
|
159
|
+
"One node of an architecture manifest's tree, as a file the manifest includes. See https://dataquail.github.io/goodbones/architecture-rules/manifest/#splitting-the-manifest-include.",
|
|
160
|
+
...rest,
|
|
161
|
+
properties: {
|
|
162
|
+
$schema: SCHEMA_PROPERTY,
|
|
163
|
+
defs: DEFS_PROPERTY,
|
|
164
|
+
...(rest.properties !== undefined && isObject(rest.properties) ? rest.properties : {}),
|
|
165
|
+
},
|
|
166
|
+
$defs: definitions,
|
|
167
|
+
};
|
|
168
|
+
};
|
package/src/manifest/manifest.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import * as Result from "effect/Result";
|
|
2
2
|
import * as Schema from "effect/Schema";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import * as SchemaIssue from "effect/SchemaIssue";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CampaignUnit,
|
|
7
|
+
DeclarationKind,
|
|
8
|
+
ImportProbeTarget,
|
|
9
|
+
ProbeDiagnostic,
|
|
10
|
+
ReportFormat,
|
|
11
|
+
ResolveConfig,
|
|
12
|
+
} from "../domain/architecture-config.js";
|
|
5
13
|
import { ConfigInvalid } from "../domain/architecture-error.js";
|
|
14
|
+
import {
|
|
15
|
+
type ManifestLocator,
|
|
16
|
+
type ManifestPath,
|
|
17
|
+
renderManifestPath,
|
|
18
|
+
} from "../domain/manifest-location.js";
|
|
19
|
+
import { expandManifest, originOf, type Substitution } from "./expand.js";
|
|
6
20
|
|
|
7
21
|
// A manifest is a tree of nodes keyed by path pattern, where everything the
|
|
8
22
|
// architecture says about a part of the tree is written at that part of the tree.
|
|
@@ -247,10 +261,220 @@ const CoverageFloors = Schema.Struct({
|
|
|
247
261
|
graph: Schema.optionalKey(Schema.Finite),
|
|
248
262
|
});
|
|
249
263
|
|
|
264
|
+
// Ceilings on the conformance measures — what `architecture conformance`
|
|
265
|
+
// counts and `check` does not otherwise gate: the files no family reaches,
|
|
266
|
+
// the nodes no file is under, the allowances nothing imports through, and
|
|
267
|
+
// the fragment entries used at fewer than half the nodes granted them. Each
|
|
268
|
+
// is a count that only goes down: lowered when the number falls, never
|
|
269
|
+
// raised to make a red run green.
|
|
270
|
+
const ConformanceCeilings = Schema.Struct({
|
|
271
|
+
residue: Schema.optionalKey(Schema.Finite),
|
|
272
|
+
vacant: Schema.optionalKey(Schema.Finite),
|
|
273
|
+
slack: Schema.optionalKey(Schema.Finite),
|
|
274
|
+
concentration: Schema.optionalKey(Schema.Finite),
|
|
275
|
+
});
|
|
276
|
+
|
|
250
277
|
const Limits = Schema.Struct({
|
|
251
278
|
unrestricted: Schema.optionalKey(Schema.Finite),
|
|
252
279
|
partial: Schema.optionalKey(Schema.Finite),
|
|
253
280
|
coverage: Schema.optionalKey(CoverageFloors),
|
|
281
|
+
conformance: Schema.optionalKey(ConformanceCeilings),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// A campaign: a migration the repository is running, tracked as an object —
|
|
285
|
+
// a detector, a rationale, a guide, an owner, a definition of done, and a
|
|
286
|
+
// ledger of every place the pattern still occurs. Where a rule says what may
|
|
287
|
+
// never happen, a campaign names what the code is moving away from.
|
|
288
|
+
//
|
|
289
|
+
// The detector is a predicate algebra: `all`, `any` and `not` over leaf
|
|
290
|
+
// terms, the same three words ast-grep uses. Leaf terms reuse the other
|
|
291
|
+
// families' vocabularies where one exists.
|
|
292
|
+
|
|
293
|
+
// A regular expression over the whole repo-relative path, matched as the
|
|
294
|
+
// `structure` naming rules are: with `subject` and `convention`, the capture
|
|
295
|
+
// group named holds the name being judged, and the term holds when the name
|
|
296
|
+
// has the convention's shape.
|
|
297
|
+
const PathTerm = Schema.Struct({
|
|
298
|
+
file: Globs,
|
|
299
|
+
fileNot: Schema.optionalKey(Globs),
|
|
300
|
+
subject: Schema.optionalKey(Schema.Finite),
|
|
301
|
+
convention: Schema.optionalKey(SurfaceConvention),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Holds when some import of the file resolves to the target — a path glob,
|
|
305
|
+
// `{ external: <package> }` or `{ builtin: <module> }` — and, with `symbols`,
|
|
306
|
+
// pulls one of those names across it.
|
|
307
|
+
const ImportsTerm = Schema.Struct({
|
|
308
|
+
resolves: ImportProbeTarget,
|
|
309
|
+
symbols: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Holds for an export site the selectors admit — the `surface` selectors.
|
|
313
|
+
const ExportsTerm = Schema.Struct({
|
|
314
|
+
name: Schema.optionalKey(Globs),
|
|
315
|
+
kinds: Schema.optionalKey(Schema.Array(Schema.Literals(["named", "default", "namespace"]))),
|
|
316
|
+
declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
|
|
317
|
+
reexport: Schema.optionalKey(Schema.Boolean),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Holds for a member site the selectors admit — the `members` selectors.
|
|
321
|
+
const MembersTerm = Schema.Struct({
|
|
322
|
+
subject: Schema.Literals(["members", "calls"]),
|
|
323
|
+
name: Schema.optionalKey(Globs),
|
|
324
|
+
in: Schema.optionalKey(Globs),
|
|
325
|
+
declares: Schema.optionalKey(Schema.Array(DeclarationKind)),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// A regular expression over the file's text, multiline.
|
|
329
|
+
const ContentTerm = Schema.Struct({ regex: Schema.String });
|
|
330
|
+
|
|
331
|
+
// How a metavariable is narrowed: by the text it captured, or by what the
|
|
332
|
+
// identifier at its root is bound to — the module it resolves to and the
|
|
333
|
+
// name it was imported as (`member`), which is how `class $N extends $BASE`
|
|
334
|
+
// says "a React component" rather than "any class with a base".
|
|
335
|
+
const CaptureNarrowing = Schema.Struct({
|
|
336
|
+
regex: Schema.optionalKey(Schema.String),
|
|
337
|
+
binding: Schema.optionalKey(
|
|
338
|
+
Schema.Struct({
|
|
339
|
+
resolves: ImportProbeTarget,
|
|
340
|
+
member: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
341
|
+
}),
|
|
342
|
+
),
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// An ast-grep rule object — `pattern`, `kind`, `regex`, `has`, `inside`,
|
|
346
|
+
// `precedes`, `follows`, `nthChild`, `all`, `any`, `not` — with `where`
|
|
347
|
+
// beside it. The rule is the engine's to validate; the manifest only knows
|
|
348
|
+
// which keys are a rule's and which is the narrowing. `kind` names are the
|
|
349
|
+
// engine's node kinds, which today are tree-sitter's.
|
|
350
|
+
const SyntaxTerm = Schema.Struct({
|
|
351
|
+
pattern: Schema.optionalKey(Schema.Unknown),
|
|
352
|
+
kind: Schema.optionalKey(Schema.Unknown),
|
|
353
|
+
regex: Schema.optionalKey(Schema.Unknown),
|
|
354
|
+
nthChild: Schema.optionalKey(Schema.Unknown),
|
|
355
|
+
inside: Schema.optionalKey(Schema.Unknown),
|
|
356
|
+
has: Schema.optionalKey(Schema.Unknown),
|
|
357
|
+
precedes: Schema.optionalKey(Schema.Unknown),
|
|
358
|
+
follows: Schema.optionalKey(Schema.Unknown),
|
|
359
|
+
all: Schema.optionalKey(Schema.Unknown),
|
|
360
|
+
any: Schema.optionalKey(Schema.Unknown),
|
|
361
|
+
not: Schema.optionalKey(Schema.Unknown),
|
|
362
|
+
where: Schema.optionalKey(Schema.Record(Schema.String, CaptureNarrowing)),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// One program to run or file to read, or several: a tool that takes one
|
|
366
|
+
// project at a time is run once per project, and the outputs are read as
|
|
367
|
+
// one report — a diagnostic two of them print is one diagnostic.
|
|
368
|
+
const ReportSources = Schema.Union([
|
|
369
|
+
Schema.String,
|
|
370
|
+
Schema.Array(Schema.String).check(Schema.isMinLength(1)),
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
// A finding of another program, read from the output of `command` (run
|
|
374
|
+
// from the repository root, once per `check`) or from `file` (written by
|
|
375
|
+
// an earlier step) in one of the known formats — `tsc`, `eslint --format
|
|
376
|
+
// json`, `oxlint --format json` — or by a `regex` with named groups. Holds
|
|
377
|
+
// for each diagnostic on the file whose code the term speaks to.
|
|
378
|
+
const ReportTerm = Schema.Struct({
|
|
379
|
+
command: Schema.optionalKey(ReportSources),
|
|
380
|
+
file: Schema.optionalKey(ReportSources),
|
|
381
|
+
format: ReportFormat,
|
|
382
|
+
pattern: Schema.optionalKey(Schema.String),
|
|
383
|
+
codes: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
384
|
+
codesNot: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
385
|
+
}).check(
|
|
386
|
+
// Refused at decode, where the issue names a line, rather than in the
|
|
387
|
+
// lowering: a term that names both sources or neither, and a `regex`
|
|
388
|
+
// term with nothing to match lines against.
|
|
389
|
+
Schema.makeFilter((term) => {
|
|
390
|
+
const issues: Array<Schema.FilterIssue> = [];
|
|
391
|
+
if ((term.command === undefined) === (term.file === undefined)) {
|
|
392
|
+
issues.push(
|
|
393
|
+
"a report term names exactly one of `command` (a program to run) and `file` (a report already written)",
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (term.format === "regex" && term.pattern === undefined) {
|
|
397
|
+
issues.push({
|
|
398
|
+
path: ["pattern"],
|
|
399
|
+
issue: "a `regex` report term needs a `pattern` with named groups `file` and `line`",
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return issues;
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
export type DetectorSpec =
|
|
407
|
+
| { readonly all: ReadonlyArray<DetectorSpec> }
|
|
408
|
+
| { readonly any: ReadonlyArray<DetectorSpec> }
|
|
409
|
+
| { readonly not: DetectorSpec }
|
|
410
|
+
| { readonly path: typeof PathTerm.Type }
|
|
411
|
+
| { readonly imports: typeof ImportsTerm.Type }
|
|
412
|
+
| { readonly exports: typeof ExportsTerm.Type }
|
|
413
|
+
| { readonly members: typeof MembersTerm.Type }
|
|
414
|
+
// The `structure` parity strings: holds when every named sibling exists.
|
|
415
|
+
| { readonly requires: ReadonlyArray<string> }
|
|
416
|
+
| { readonly content: typeof ContentTerm.Type }
|
|
417
|
+
| { readonly syntax: typeof SyntaxTerm.Type }
|
|
418
|
+
| { readonly report: typeof ReportTerm.Type }
|
|
419
|
+
// `module#export`: a predicate function the host imports before loading.
|
|
420
|
+
| { readonly fn: string };
|
|
421
|
+
|
|
422
|
+
// Each object carries exactly one term key, so a misspelled one is a decode
|
|
423
|
+
// error that names the line rather than a term quietly dropped.
|
|
424
|
+
const DetectorRef = Schema.suspend((): Schema.Codec<DetectorSpec> => DetectorSpec);
|
|
425
|
+
|
|
426
|
+
const DetectorSpec = Schema.Union([
|
|
427
|
+
Schema.Struct({ all: Schema.Array(DetectorRef) }),
|
|
428
|
+
Schema.Struct({ any: Schema.Array(DetectorRef) }),
|
|
429
|
+
Schema.Struct({ not: DetectorRef }),
|
|
430
|
+
Schema.Struct({ path: PathTerm }),
|
|
431
|
+
Schema.Struct({ imports: ImportsTerm }),
|
|
432
|
+
Schema.Struct({ exports: ExportsTerm }),
|
|
433
|
+
Schema.Struct({ members: MembersTerm }),
|
|
434
|
+
Schema.Struct({ requires: Schema.Array(Schema.String) }),
|
|
435
|
+
Schema.Struct({ content: ContentTerm }),
|
|
436
|
+
Schema.Struct({ syntax: SyntaxTerm }),
|
|
437
|
+
Schema.Struct({ report: ReportTerm }),
|
|
438
|
+
Schema.Struct({ fn: Schema.String }),
|
|
439
|
+
]);
|
|
440
|
+
|
|
441
|
+
// A source the campaign is proven against at load. `path` alone proves a
|
|
442
|
+
// path-shaped detector; `source` is parsed, `edges` answers the `imports`
|
|
443
|
+
// term and a binding narrowing in place of the live resolver, `files`
|
|
444
|
+
// answers `requires` in place of the file system, and `report` answers a
|
|
445
|
+
// `report` term in place of running anything — one-based positions, as a
|
|
446
|
+
// tool prints them.
|
|
447
|
+
const CampaignProbe = Schema.Struct({
|
|
448
|
+
path: Schema.String,
|
|
449
|
+
source: Schema.optionalKey(Schema.String),
|
|
450
|
+
edges: Schema.optionalKey(Schema.Record(Schema.String, ImportProbeTarget)),
|
|
451
|
+
files: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
452
|
+
report: Schema.optionalKey(Schema.Array(ProbeDiagnostic)),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// `30d`, `12h`: how long a campaign may go without progress before the
|
|
456
|
+
// conformance report calls it stalled.
|
|
457
|
+
const Duration = Schema.String.check(Schema.isPattern(/^\d+[dh]$/));
|
|
458
|
+
|
|
459
|
+
const Campaign = Schema.Struct({
|
|
460
|
+
// The ledger file's name, and the rule name's tail: `campaign/<id>`.
|
|
461
|
+
id: Schema.String.check(Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)),
|
|
462
|
+
title: Schema.optionalKey(Schema.String),
|
|
463
|
+
why: Schema.String,
|
|
464
|
+
// What a reader at a hit does about it — the message every hit carries.
|
|
465
|
+
how: Schema.String,
|
|
466
|
+
owner: Schema.optionalKey(Schema.String),
|
|
467
|
+
// Which files the campaign selects. Alias-aware globs, as the graph rules
|
|
468
|
+
// take; a campaign's reach is its scope, so it joins no coverage row.
|
|
469
|
+
scope: Globs,
|
|
470
|
+
unit: CampaignUnit,
|
|
471
|
+
detect: DetectorSpec,
|
|
472
|
+
probes: Schema.Struct({
|
|
473
|
+
fires: Schema.Array(CampaignProbe),
|
|
474
|
+
ignores: Schema.optionalKey(Schema.Array(CampaignProbe)),
|
|
475
|
+
}),
|
|
476
|
+
staleAfter: Duration,
|
|
477
|
+
onComplete: Schema.optionalKey(Schema.Literals(["keep", "remove"])),
|
|
254
478
|
});
|
|
255
479
|
|
|
256
480
|
export const Manifest = Schema.Struct({
|
|
@@ -267,6 +491,10 @@ export const Manifest = Schema.Struct({
|
|
|
267
491
|
exports: Schema.optionalKey(Schema.Array(ExportRestriction)),
|
|
268
492
|
graph: Schema.optionalKey(Graph),
|
|
269
493
|
limits: Schema.optionalKey(Limits),
|
|
494
|
+
campaigns: Schema.optionalKey(Schema.Array(Campaign)),
|
|
495
|
+
// Where each campaign's ledger is written: `<ledger>/<id>.json`, relative
|
|
496
|
+
// to the manifest. Defaults to `.architecture-campaigns`.
|
|
497
|
+
ledger: Schema.optionalKey(Schema.String),
|
|
270
498
|
// Shorthands expanded in every glob, so a pattern reads the way the repo's own
|
|
271
499
|
// imports do rather than repeating `packages/server/src` on every line.
|
|
272
500
|
aliases: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
@@ -282,17 +510,36 @@ export type GraphSpec = typeof Graph.Type;
|
|
|
282
510
|
export type LimitsSpec = typeof Limits.Type;
|
|
283
511
|
export type NamingSpec = typeof Naming.Type;
|
|
284
512
|
export type ExportRestriction = typeof ExportRestriction.Type;
|
|
513
|
+
export type CampaignSpec = typeof Campaign.Type;
|
|
514
|
+
export type CampaignProbeSpec = typeof CampaignProbe.Type;
|
|
515
|
+
export type SyntaxTermSpec = typeof SyntaxTerm.Type;
|
|
516
|
+
|
|
517
|
+
export const DEFAULT_LEDGER_DIR = ".architecture-campaigns";
|
|
518
|
+
|
|
519
|
+
// `30d` → milliseconds. The schema has already refused any other shape.
|
|
520
|
+
export const durationMs = (duration: string): number => {
|
|
521
|
+
const amount = Number(duration.slice(0, -1));
|
|
522
|
+
return amount * (duration.endsWith("h") ? 3_600_000 : 86_400_000);
|
|
523
|
+
};
|
|
285
524
|
|
|
286
525
|
export const globsOf = (globs: string | ReadonlyArray<string>): ReadonlyArray<string> =>
|
|
287
526
|
typeof globs === "string" ? [globs] : globs;
|
|
288
527
|
|
|
289
|
-
|
|
528
|
+
// Every issue, not the first: a manifest is edited by hand, and the reader
|
|
529
|
+
// fixing one line wants to know about the other three. A key the schema does
|
|
530
|
+
// not declare is refused rather than dropped — a misspelled `matchNot` that
|
|
531
|
+
// decoded to nothing would be a rule quietly enforcing less than it says.
|
|
532
|
+
const decode = Schema.decodeUnknownResult(Manifest, { errors: "all", onExcessProperty: "error" });
|
|
533
|
+
const flatten = SchemaIssue.makeFormatterStandardSchemaV1();
|
|
290
534
|
|
|
291
535
|
export type DecodedManifest = {
|
|
292
536
|
readonly manifest: Manifest;
|
|
293
537
|
// Things the manifest said in a form that still loads but is on its way out.
|
|
294
538
|
// The host prints them; nothing else acts on them.
|
|
295
539
|
readonly notices: ReadonlyArray<string>;
|
|
540
|
+
// Every `use` the expansion replaced. Lowering reads them to say which
|
|
541
|
+
// fragment an allowance came through; nothing else needs them.
|
|
542
|
+
readonly substitutions: ReadonlyArray<Substitution>;
|
|
296
543
|
};
|
|
297
544
|
|
|
298
545
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -392,17 +639,98 @@ const normalizeLegacyMembers = (
|
|
|
392
639
|
return { input: { ...input, tree }, notices };
|
|
393
640
|
};
|
|
394
641
|
|
|
642
|
+
export type DecodeManifestOptions = {
|
|
643
|
+
// Turns a path in the file into a line and column. The YAML reader supplies
|
|
644
|
+
// one; a JavaScript module has no positions to give and passes nothing.
|
|
645
|
+
readonly locate?: ManifestLocator | undefined;
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const fileLabelOf = (configPath: string): string => configPath.split(/[\\/]/).at(-1) ?? configPath;
|
|
649
|
+
|
|
650
|
+
// A position carries its own file when the value was written in a file the
|
|
651
|
+
// manifest `include`d; otherwise it is in the manifest file itself.
|
|
652
|
+
const positionOf = (
|
|
653
|
+
file: string,
|
|
654
|
+
locate: ManifestLocator | undefined,
|
|
655
|
+
path: ManifestPath,
|
|
656
|
+
): string | null => {
|
|
657
|
+
const found = locate?.(path) ?? null;
|
|
658
|
+
return found === null
|
|
659
|
+
? null
|
|
660
|
+
: `${found.file ?? file}:${String(found.line)}:${String(found.column)}`;
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
// One line per issue: where in the file, which path, what was wrong — and,
|
|
664
|
+
// when the value came in through a `use`, the reference that pulled it in,
|
|
665
|
+
// since the fragment's own line may sit far from where the reader is looking.
|
|
666
|
+
const describeIssue = (
|
|
667
|
+
configPath: string,
|
|
668
|
+
locate: ManifestLocator | undefined,
|
|
669
|
+
substitutions: ReadonlyArray<Substitution>,
|
|
670
|
+
path: ManifestPath,
|
|
671
|
+
detail: string,
|
|
672
|
+
): string => {
|
|
673
|
+
const file = fileLabelOf(configPath);
|
|
674
|
+
const origin = originOf(substitutions, path);
|
|
675
|
+
const at = positionOf(file, locate, origin.path);
|
|
676
|
+
const via = origin.via.map(({ at: ref, name }) => {
|
|
677
|
+
const position = positionOf(file, locate, ref);
|
|
678
|
+
return `via \`use: ${JSON.stringify(name)}\`${position === null ? "" : ` at ${position}`}`;
|
|
679
|
+
});
|
|
680
|
+
return (
|
|
681
|
+
` ${at === null ? "" : `${at} `}${renderManifestPath(origin.path)}: ${detail}` +
|
|
682
|
+
(via.length === 0 ? "" : ` (${via.join(", ")})`)
|
|
683
|
+
);
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// The standard-schema formatter flattens the issue tree to `{ path, message }`
|
|
687
|
+
// pairs; a path segment may arrive wrapped as `{ key }`.
|
|
688
|
+
const pathOf = (issue: {
|
|
689
|
+
readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }> | undefined;
|
|
690
|
+
}): ManifestPath =>
|
|
691
|
+
(issue.path ?? []).map((segment) => (typeof segment === "object" ? segment.key : segment));
|
|
692
|
+
|
|
395
693
|
export const decodeManifest = (
|
|
396
694
|
configPath: string,
|
|
397
695
|
input: unknown,
|
|
696
|
+
options: DecodeManifestOptions = {},
|
|
398
697
|
): Result.Result<DecodedManifest, ConfigInvalid> => {
|
|
399
|
-
const
|
|
698
|
+
const expanded = expandManifest(input);
|
|
699
|
+
if (Result.isFailure(expanded)) {
|
|
700
|
+
return Result.fail(
|
|
701
|
+
new ConfigInvalid({
|
|
702
|
+
configPath,
|
|
703
|
+
detail:
|
|
704
|
+
"the manifest does not expand:\n" +
|
|
705
|
+
describeIssue(
|
|
706
|
+
configPath,
|
|
707
|
+
options.locate,
|
|
708
|
+
[],
|
|
709
|
+
expanded.failure.path,
|
|
710
|
+
expanded.failure.detail,
|
|
711
|
+
),
|
|
712
|
+
}),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
const { substitutions, value } = expanded.success;
|
|
716
|
+
|
|
717
|
+
const resolve = normalizeLegacyResolve(value);
|
|
400
718
|
const members = normalizeLegacyMembers(resolve.input);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
(
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
|
|
719
|
+
const decoded = decode(members.input);
|
|
720
|
+
if (Result.isFailure(decoded)) {
|
|
721
|
+
const lines = flatten(decoded.failure.issue).issues.map((issue) =>
|
|
722
|
+
describeIssue(configPath, options.locate, substitutions, pathOf(issue), issue.message),
|
|
723
|
+
);
|
|
724
|
+
return Result.fail(
|
|
725
|
+
new ConfigInvalid({
|
|
726
|
+
configPath,
|
|
727
|
+
detail: `the manifest does not decode:\n${lines.join("\n")}`,
|
|
728
|
+
}),
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
return Result.succeed({
|
|
732
|
+
manifest: decoded.success,
|
|
733
|
+
notices: [...resolve.notices, ...members.notices],
|
|
734
|
+
substitutions,
|
|
735
|
+
});
|
|
408
736
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SourceFacts } from "../domain/facts.js";
|
|
2
|
+
import type { SyntaxTree } from "./syntax-matcher.js";
|
|
3
|
+
|
|
4
|
+
// The floor of a campaign's detector: a predicate function the repository
|
|
5
|
+
// writes, named from the manifest as `module#export` and handed to the
|
|
6
|
+
// policy by the host. It is given what the evaluator has about one file and
|
|
7
|
+
// answers either a verdict about the file or the subjects it found in it —
|
|
8
|
+
// a declaration name, or any key a `match` campaign should ledger — each
|
|
9
|
+
// optionally with where it sits. Exported as a public type so a referenced
|
|
10
|
+
// module typechecks on its own.
|
|
11
|
+
|
|
12
|
+
export type Range = {
|
|
13
|
+
// Zero-based.
|
|
14
|
+
readonly start: { readonly line: number; readonly column: number };
|
|
15
|
+
readonly end: { readonly line: number; readonly column: number };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CampaignPredicateInput = {
|
|
19
|
+
readonly file: string;
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly facts: SourceFacts;
|
|
22
|
+
// The file parsed by the scope's syntax matcher, or `null` without one.
|
|
23
|
+
readonly syntax: SyntaxTree | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type CampaignSubject = { readonly subject: string; readonly range?: Range };
|
|
27
|
+
|
|
28
|
+
export type CampaignPredicate = (
|
|
29
|
+
input: CampaignPredicateInput,
|
|
30
|
+
) => boolean | ReadonlyArray<CampaignSubject>;
|
package/src/ports/language.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ExportFix, ResolveScope } from "../domain/architecture-config.js";
|
|
|
4
4
|
import type { ScopeInvalid } from "../domain/architecture-error.js";
|
|
5
5
|
import type { FactExtractor } from "./fact-extractor.js";
|
|
6
6
|
import type { ModuleResolver } from "./module-resolver.js";
|
|
7
|
+
import type { SyntaxMatcher } from "./syntax-matcher.js";
|
|
7
8
|
|
|
8
9
|
// Everything the policy needs from one programming language, behind one port.
|
|
9
10
|
//
|
|
@@ -21,6 +22,17 @@ export type Language = {
|
|
|
21
22
|
// Files carrying one of those extensions that are not source — for
|
|
22
23
|
// TypeScript, a declaration file states types and no linter visits one.
|
|
23
24
|
readonly ignoredFiles: ReadonlyArray<RegExp>;
|
|
25
|
+
// The files whose presence makes a folder a package of this language —
|
|
26
|
+
// `package.json`, `go.mod`, `Cargo.toml`. `infer` counts its depth from a
|
|
27
|
+
// package rather than from the repository root, so a monorepo of twenty
|
|
28
|
+
// packages is described one package at a time. Empty if the ecosystem has no
|
|
29
|
+
// such marker.
|
|
30
|
+
readonly packageMarkers: ReadonlyArray<string>;
|
|
31
|
+
// The folder names a package of this language keeps its source under, in
|
|
32
|
+
// order of preference — `src` for TypeScript, nothing for Go, where source
|
|
33
|
+
// sits at the package root. The first one that exists is where `infer`
|
|
34
|
+
// starts counting.
|
|
35
|
+
readonly sourceRoots: ReadonlyArray<string>;
|
|
24
36
|
// Reads the facts out of one source text. The CLI reads every file through
|
|
25
37
|
// it, and a probe carrying a `source` snippet is parsed by it at load.
|
|
26
38
|
readonly extractor: FactExtractor;
|
|
@@ -28,6 +40,11 @@ export type Language = {
|
|
|
28
40
|
// carry out. A rewrite is written in one module syntax; a rule naming one no
|
|
29
41
|
// loaded language implements is refused at load.
|
|
30
42
|
readonly fixes: ReadonlyArray<ExportFix>;
|
|
43
|
+
// Finds expressions of a given shape in one file, for a campaign's `syntax`
|
|
44
|
+
// term. Optional: a pack without one still answers every other family, and
|
|
45
|
+
// a campaign that needs one in its scope is refused at load with a
|
|
46
|
+
// sentence naming the language.
|
|
47
|
+
readonly syntax?: SyntaxMatcher;
|
|
31
48
|
// A resolver for the files one scope covers. Built once per scope per run;
|
|
32
49
|
// resolution is the expensive half of linting an architecture. The scope's
|
|
33
50
|
// `options` are this language's to read, and anything it does not
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Diagnostic, ReportFormat } from "../domain/report.js";
|
|
2
|
+
|
|
3
|
+
// What a `report` term names: a program to run from the repository root, or
|
|
4
|
+
// a file some earlier step wrote — or several of either, read as one report
|
|
5
|
+
// — and the format the output is read in.
|
|
6
|
+
export type ReportSpec = {
|
|
7
|
+
readonly command?: string | ReadonlyArray<string> | undefined;
|
|
8
|
+
readonly file?: string | ReadonlyArray<string> | undefined;
|
|
9
|
+
readonly format: ReportFormat;
|
|
10
|
+
// `regex` only.
|
|
11
|
+
readonly pattern?: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// The commands or files a spec names, as a list.
|
|
15
|
+
export const reportSourcesOf = (spec: ReportSpec): ReadonlyArray<string> => {
|
|
16
|
+
const named = spec.command ?? spec.file ?? [];
|
|
17
|
+
return typeof named === "string" ? [named] : named;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Answers a `report` term for one file. The live source runs the commands
|
|
21
|
+
// once per process (a report is about the whole repository, and a campaign
|
|
22
|
+
// asks per file) and reads a file once; a fake answers from a table. The
|
|
23
|
+
// core never spawns anything.
|
|
24
|
+
//
|
|
25
|
+
// `read` is the host's chance to ask ahead: it reads the report — several
|
|
26
|
+
// commands at once — and keeps the answer, or the failure, for
|
|
27
|
+
// `diagnosticsOf` to hand back. Both hosts call it before any file is
|
|
28
|
+
// judged; a source that does not offer it is asked synchronously, one
|
|
29
|
+
// command after another, the first time a file asks.
|
|
30
|
+
export type ReportSource = {
|
|
31
|
+
readonly diagnosticsOf: (spec: ReportSpec, file: string) => ReadonlyArray<Diagnostic>;
|
|
32
|
+
readonly read?: ((spec: ReportSpec) => Promise<void>) | undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const NO_REPORTS: ReportSource = { diagnosticsOf: () => [] };
|