@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.
Files changed (150) hide show
  1. package/build/dts/core/coverage.d.ts +20 -0
  2. package/build/dts/core/coverage.d.ts.map +1 -1
  3. package/build/dts/core/graph.d.ts +2 -0
  4. package/build/dts/core/graph.d.ts.map +1 -1
  5. package/build/dts/core/imports.d.ts +3 -1
  6. package/build/dts/core/imports.d.ts.map +1 -1
  7. package/build/dts/core/slack.d.ts +22 -0
  8. package/build/dts/core/slack.d.ts.map +1 -0
  9. package/build/dts/core/structure.d.ts +1 -0
  10. package/build/dts/core/structure.d.ts.map +1 -1
  11. package/build/dts/domain/architecture-config.d.ts +19 -3
  12. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  13. package/build/dts/domain/architecture-error.d.ts +8 -0
  14. package/build/dts/domain/architecture-error.d.ts.map +1 -1
  15. package/build/dts/domain/facts.d.ts +1 -0
  16. package/build/dts/domain/facts.d.ts.map +1 -1
  17. package/build/dts/domain/manifest-location.d.ts +9 -0
  18. package/build/dts/domain/manifest-location.d.ts.map +1 -0
  19. package/build/dts/domain/snapshot.d.ts +356 -0
  20. package/build/dts/domain/snapshot.d.ts.map +1 -0
  21. package/build/dts/domain/violation.d.ts +1 -1
  22. package/build/dts/domain/violation.d.ts.map +1 -1
  23. package/build/dts/index.d.ts +22 -10
  24. package/build/dts/index.d.ts.map +1 -1
  25. package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -1
  26. package/build/dts/infrastructure/file-system-live.d.ts.map +1 -1
  27. package/build/dts/infrastructure/manifest-file.d.ts +11 -2
  28. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
  29. package/build/dts/infrastructure/manifest-include.d.ts +16 -0
  30. package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
  31. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  32. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  33. package/build/dts/infrastructure/walk.d.ts +12 -1
  34. package/build/dts/infrastructure/walk.d.ts.map +1 -1
  35. package/build/dts/load/extension.d.ts +33 -0
  36. package/build/dts/load/extension.d.ts.map +1 -0
  37. package/build/dts/load/policy.d.ts +10 -0
  38. package/build/dts/load/policy.d.ts.map +1 -1
  39. package/build/dts/manifest/compile.d.ts +11 -1
  40. package/build/dts/manifest/compile.d.ts.map +1 -1
  41. package/build/dts/manifest/expand.d.ts +27 -0
  42. package/build/dts/manifest/expand.d.ts.map +1 -0
  43. package/build/dts/manifest/extension.d.ts +8 -0
  44. package/build/dts/manifest/extension.d.ts.map +1 -0
  45. package/build/dts/manifest/glob.d.ts +1 -0
  46. package/build/dts/manifest/glob.d.ts.map +1 -1
  47. package/build/dts/manifest/infer.d.ts +55 -0
  48. package/build/dts/manifest/infer.d.ts.map +1 -0
  49. package/build/dts/manifest/json-schema.d.ts +14 -0
  50. package/build/dts/manifest/json-schema.d.ts.map +1 -0
  51. package/build/dts/manifest/manifest.d.ts +26 -1
  52. package/build/dts/manifest/manifest.d.ts.map +1 -1
  53. package/build/dts/ports/file-system.d.ts +1 -0
  54. package/build/dts/ports/file-system.d.ts.map +1 -1
  55. package/build/dts/ports/language.d.ts +4 -0
  56. package/build/dts/ports/language.d.ts.map +1 -1
  57. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  58. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  59. package/build/dts/testing.d.ts +1 -0
  60. package/build/dts/testing.d.ts.map +1 -1
  61. package/build/esm/core/coverage.js +107 -32
  62. package/build/esm/core/coverage.js.map +1 -1
  63. package/build/esm/core/graph.js +45 -0
  64. package/build/esm/core/graph.js.map +1 -1
  65. package/build/esm/core/imports.js +14 -9
  66. package/build/esm/core/imports.js.map +1 -1
  67. package/build/esm/core/slack.js +76 -0
  68. package/build/esm/core/slack.js.map +1 -0
  69. package/build/esm/core/structure.js +6 -3
  70. package/build/esm/core/structure.js.map +1 -1
  71. package/build/esm/domain/architecture-config.js +28 -4
  72. package/build/esm/domain/architecture-config.js.map +1 -1
  73. package/build/esm/domain/architecture-error.js +27 -0
  74. package/build/esm/domain/architecture-error.js.map +1 -1
  75. package/build/esm/domain/manifest-location.js +21 -0
  76. package/build/esm/domain/manifest-location.js.map +1 -0
  77. package/build/esm/domain/snapshot.js +181 -0
  78. package/build/esm/domain/snapshot.js.map +1 -0
  79. package/build/esm/domain/violation.js.map +1 -1
  80. package/build/esm/index.js +28 -9
  81. package/build/esm/index.js.map +1 -1
  82. package/build/esm/infrastructure/file-system-fake.js +10 -0
  83. package/build/esm/infrastructure/file-system-fake.js.map +1 -1
  84. package/build/esm/infrastructure/file-system-live.js +9 -1
  85. package/build/esm/infrastructure/file-system-live.js.map +1 -1
  86. package/build/esm/infrastructure/manifest-file.js +158 -7
  87. package/build/esm/infrastructure/manifest-file.js.map +1 -1
  88. package/build/esm/infrastructure/manifest-include.js +187 -0
  89. package/build/esm/infrastructure/manifest-include.js.map +1 -0
  90. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  91. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  92. package/build/esm/infrastructure/walk.js +106 -4
  93. package/build/esm/infrastructure/walk.js.map +1 -1
  94. package/build/esm/load/extension.js +2 -0
  95. package/build/esm/load/extension.js.map +1 -0
  96. package/build/esm/load/policy.js +49 -2
  97. package/build/esm/load/policy.js.map +1 -1
  98. package/build/esm/manifest/compile.js +50 -27
  99. package/build/esm/manifest/compile.js.map +1 -1
  100. package/build/esm/manifest/expand.js +116 -0
  101. package/build/esm/manifest/expand.js.map +1 -0
  102. package/build/esm/manifest/extension.js +2 -0
  103. package/build/esm/manifest/extension.js.map +1 -0
  104. package/build/esm/manifest/glob.js +3 -0
  105. package/build/esm/manifest/glob.js.map +1 -1
  106. package/build/esm/manifest/infer.js +455 -0
  107. package/build/esm/manifest/infer.js.map +1 -0
  108. package/build/esm/manifest/json-schema.js +169 -0
  109. package/build/esm/manifest/json-schema.js.map +1 -0
  110. package/build/esm/manifest/manifest.js +127 -5
  111. package/build/esm/manifest/manifest.js.map +1 -1
  112. package/build/esm/ports/syntax-matcher.js +12 -0
  113. package/build/esm/ports/syntax-matcher.js.map +1 -0
  114. package/build/esm/testing.js +1 -0
  115. package/build/esm/testing.js.map +1 -1
  116. package/package.json +9 -3
  117. package/schema/architecture-node.schema.json +1518 -0
  118. package/schema/architecture.schema.json +4498 -0
  119. package/schema/conformance.schema.json +1019 -0
  120. package/src/core/coverage.ts +164 -34
  121. package/src/core/graph.ts +48 -0
  122. package/src/core/imports.ts +29 -13
  123. package/src/core/slack.ts +135 -0
  124. package/src/core/structure.ts +15 -8
  125. package/src/domain/architecture-config.ts +31 -4
  126. package/src/domain/architecture-error.ts +30 -0
  127. package/src/domain/facts.ts +9 -4
  128. package/src/domain/manifest-location.ts +41 -0
  129. package/src/domain/snapshot.ts +402 -0
  130. package/src/domain/violation.ts +4 -1
  131. package/src/index.ts +149 -7
  132. package/src/infrastructure/file-system-fake.ts +12 -0
  133. package/src/infrastructure/file-system-live.ts +8 -1
  134. package/src/infrastructure/manifest-file.ts +204 -8
  135. package/src/infrastructure/manifest-include.ts +318 -0
  136. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  137. package/src/infrastructure/walk.ts +127 -3
  138. package/src/load/extension.ts +66 -0
  139. package/src/load/policy.ts +77 -2
  140. package/src/manifest/compile.ts +81 -30
  141. package/src/manifest/expand.ts +183 -0
  142. package/src/manifest/extension.ts +41 -0
  143. package/src/manifest/glob.ts +5 -0
  144. package/src/manifest/infer.ts +643 -0
  145. package/src/manifest/json-schema.ts +210 -0
  146. package/src/manifest/manifest.ts +206 -10
  147. package/src/ports/file-system.ts +3 -0
  148. package/src/ports/language.ts +17 -0
  149. package/src/ports/syntax-matcher.ts +42 -0
  150. package/src/testing.ts +1 -0
@@ -0,0 +1,210 @@
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
+ // numbered in the order it meets them. A stable name is what a `$ref` in an
36
+ // error message or a docs page can point at: the tree's node, and an
37
+ // objective's detector — told apart by what each declares, since the order
38
+ // moves with the manifest's shape.
39
+ const definitionNameOf = (name: string, definition: JsonValue): string => {
40
+ if (!/^(Suspend|Union)_\d*$/.test(name)) return name;
41
+ const text = JSON.stringify(definition);
42
+ if (text.includes('"children"')) return "ManifestNode";
43
+ if (text.includes('"all"')) return "Detector";
44
+ return name;
45
+ };
46
+
47
+ const DEFINITION_NAMES: Record<string, string> = {};
48
+
49
+ const USE_REFERENCE = "#/$defs/Use";
50
+ const INCLUDE_REFERENCE = "#/$defs/Include";
51
+
52
+ const Use: JsonObject = {
53
+ type: "object",
54
+ description:
55
+ "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.",
56
+ properties: { use: { type: "string" } },
57
+ required: ["use"],
58
+ };
59
+
60
+ const Include: JsonObject = {
61
+ type: "object",
62
+ description:
63
+ "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`.",
64
+ properties: { include: { type: "string" } },
65
+ required: ["include"],
66
+ additionalProperties: false,
67
+ };
68
+
69
+ // Every object schema below the root becomes "this object, or a `use` of a
70
+ // fragment shaped like it, or an `include` of a file holding one", and every
71
+ // list schema "this list, or an `include` of a file holding one". The
72
+ // expansion passes replace a reference wherever it stands, so the schema
73
+ // admits one wherever the value may stand.
74
+ const admitReferences = (value: JsonValue): JsonValue => {
75
+ if (Array.isArray(value)) return value.map(admitReferences);
76
+ if (!isObject(value)) return value;
77
+
78
+ const rebuilt: Record<string, JsonValue> = {};
79
+ for (const [key, entry] of entriesOf(value)) {
80
+ if (key === "$ref" && typeof entry === "string") {
81
+ const name = entry.replace(/^#\/\$defs\//, "");
82
+ rebuilt[key] = `#/$defs/${DEFINITION_NAMES[name] ?? name}`;
83
+ } else {
84
+ rebuilt[key] = admitReferences(entry);
85
+ }
86
+ }
87
+ if (rebuilt.type === "object" && "properties" in rebuilt) {
88
+ return { anyOf: [{ $ref: USE_REFERENCE }, { $ref: INCLUDE_REFERENCE }, rebuilt] };
89
+ }
90
+ if (rebuilt.type === "array") {
91
+ return { anyOf: [{ $ref: INCLUDE_REFERENCE }, rebuilt] };
92
+ }
93
+ return rebuilt;
94
+ };
95
+
96
+ const DEFS_PROPERTY: JsonObject = {
97
+ type: "object",
98
+ description:
99
+ 'Named fragments, referenced elsewhere in the manifest as `{ use: "<name>" }`. A fragment may itself contain `use`. Every file\'s `defs` share one namespace.',
100
+ additionalProperties: true,
101
+ };
102
+
103
+ const SCHEMA_PROPERTY: JsonObject = {
104
+ type: "string",
105
+ description: "For editors. Ignored by the loader.",
106
+ };
107
+
108
+ // A `Record` whose keys are checked — the campaign and objective ids —
109
+ // generates as `patternProperties`, which alone admits any other key beside
110
+ // the matching ones. Closed here, so an editor flags an id in the wrong
111
+ // shape as the loader would.
112
+ const withKeyPatterns = (value: JsonValue): JsonValue => {
113
+ if (Array.isArray(value)) return value.map(withKeyPatterns);
114
+ if (!isObject(value)) return value;
115
+ const rebuilt: Record<string, JsonValue> = {};
116
+ for (const [childKey, entry] of entriesOf(value)) rebuilt[childKey] = withKeyPatterns(entry);
117
+ if (
118
+ rebuilt.type === "object" &&
119
+ "patternProperties" in rebuilt &&
120
+ !("additionalProperties" in rebuilt)
121
+ ) {
122
+ rebuilt.additionalProperties = false;
123
+ }
124
+ return rebuilt;
125
+ };
126
+
127
+ export type ManifestJsonSchemaOptions = {
128
+ // The manifest keys of families the core does not own, as the fields of
129
+ // their own codecs — `CampaignsManifest.fields` from @goodbones/campaigns.
130
+ // Generated together with the core's, so the published schema describes the
131
+ // manifest a host with those families loaded will actually decode, and a
132
+ // host without them publishes a schema without those keys.
133
+ readonly extensions?: ReadonlyArray<Schema.Struct.Fields> | undefined;
134
+ };
135
+
136
+ export const manifestJsonSchema = (options: ManifestJsonSchemaOptions = {}): JsonObject => {
137
+ const whole =
138
+ options.extensions === undefined || options.extensions.length === 0
139
+ ? Manifest
140
+ : Schema.Struct(Object.assign({}, Manifest.fields, ...options.extensions));
141
+ const generated = Schema.toJsonSchemaDocument(whole) as unknown as {
142
+ readonly schema: JsonObject;
143
+ readonly definitions: JsonObject;
144
+ };
145
+ const { properties, ...root } = generated.schema;
146
+ if (properties === undefined || !isObject(properties)) {
147
+ throw new Error("the manifest schema generated with no properties");
148
+ }
149
+
150
+ for (const [name, definition] of entriesOf(generated.definitions)) {
151
+ DEFINITION_NAMES[name] = definitionNameOf(name, definition);
152
+ }
153
+ const definitions: Record<string, JsonValue> = {};
154
+ for (const [name, definition] of entriesOf(generated.definitions)) {
155
+ definitions[DEFINITION_NAMES[name] ?? name] = admitReferences(definition);
156
+ }
157
+
158
+ return {
159
+ $schema: "https://json-schema.org/draft/2020-12/schema",
160
+ $id: MANIFEST_SCHEMA_ID,
161
+ title: "Architecture manifest",
162
+ description:
163
+ "One manifest of a repository's architecture, read by @goodbones/cli and @goodbones/oxlint. See https://dataquail.github.io/goodbones/architecture-rules/manifest/.",
164
+ ...root,
165
+ properties: {
166
+ $schema: SCHEMA_PROPERTY,
167
+ defs: DEFS_PROPERTY,
168
+ ...Object.fromEntries(
169
+ entriesOf(properties).map(([key, value]) => [key, withKeyPatterns(admitReferences(value))]),
170
+ ),
171
+ },
172
+ $defs: { ...definitions, Use, Include },
173
+ };
174
+ };
175
+
176
+ // One node of the tree as a file of its own — what a per-package
177
+ // `architecture.yaml` that the root manifest `include`s is shaped like. The
178
+ // node's object form, with the `$schema` and `defs` keys such a file may carry
179
+ // at the top, over the same definitions as the whole manifest.
180
+ export const manifestNodeJsonSchema = (options: ManifestJsonSchemaOptions = {}): JsonObject => {
181
+ const whole = manifestJsonSchema(options);
182
+ const definitions = whole.$defs;
183
+ if (definitions === undefined || !isObject(definitions)) {
184
+ throw new Error("the manifest schema generated with no definitions");
185
+ }
186
+ const node = definitions.ManifestNode;
187
+ const variants = node !== undefined && isObject(node) ? node.anyOf : undefined;
188
+ const object =
189
+ variants !== undefined && isList(variants)
190
+ ? variants.find((variant) => isObject(variant) && variant.type === "object")
191
+ : undefined;
192
+ if (object === undefined || !isObject(object)) {
193
+ throw new Error("the manifest schema generated with no object form of a node");
194
+ }
195
+ const { $id: _id, $schema: _schema, ...rest } = object;
196
+ return {
197
+ $schema: "https://json-schema.org/draft/2020-12/schema",
198
+ $id: MANIFEST_NODE_SCHEMA_ID,
199
+ title: "Architecture manifest node",
200
+ description:
201
+ "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.",
202
+ ...rest,
203
+ properties: {
204
+ $schema: SCHEMA_PROPERTY,
205
+ defs: DEFS_PROPERTY,
206
+ ...(rest.properties !== undefined && isObject(rest.properties) ? rest.properties : {}),
207
+ },
208
+ $defs: definitions,
209
+ };
210
+ };
@@ -1,8 +1,16 @@
1
1
  import * as Result from "effect/Result";
2
2
  import * as Schema from "effect/Schema";
3
+ import * as SchemaIssue from "effect/SchemaIssue";
3
4
 
4
5
  import { DeclarationKind, ResolveConfig } from "../domain/architecture-config.js";
5
6
  import { ConfigInvalid } from "../domain/architecture-error.js";
7
+ import {
8
+ type ManifestLocator,
9
+ type ManifestPath,
10
+ renderManifestPath,
11
+ } from "../domain/manifest-location.js";
12
+ import { expandManifest, originOf, type Substitution } from "./expand.js";
13
+ import type { ManifestExtension } from "./extension.js";
6
14
 
7
15
  // A manifest is a tree of nodes keyed by path pattern, where everything the
8
16
  // architecture says about a part of the tree is written at that part of the tree.
@@ -98,7 +106,7 @@ const Members = Schema.Struct({
98
106
  // is about; exactly one demand says what is required of them, and none means
99
107
  // `forbid` — a selected site is the violation. Stated on a folder it covers
100
108
  // the subtree, like `members`.
101
- const SurfaceConvention = Schema.Union([
109
+ export const SurfaceConvention = Schema.Union([
102
110
  Schema.Literals(["kebab-case", "camelCase", "PascalCase", "snake_case"]),
103
111
  Schema.Struct({ regex: Schema.String }),
104
112
  ]);
@@ -247,12 +255,48 @@ const CoverageFloors = Schema.Struct({
247
255
  graph: Schema.optionalKey(Schema.Finite),
248
256
  });
249
257
 
258
+ // Ceilings on the conformance measures — what `architecture conformance`
259
+ // counts and `check` does not otherwise gate: the files no family reaches,
260
+ // the nodes no file is under, the allowances nothing imports through, and
261
+ // the fragment entries used at fewer than half the nodes granted them. Each
262
+ // is a count that only goes down: lowered when the number falls, never
263
+ // raised to make a red run green.
264
+ const ConformanceCeilings = Schema.Struct({
265
+ residue: Schema.optionalKey(Schema.Finite),
266
+ vacant: Schema.optionalKey(Schema.Finite),
267
+ slack: Schema.optionalKey(Schema.Finite),
268
+ concentration: Schema.optionalKey(Schema.Finite),
269
+ });
270
+
250
271
  const Limits = Schema.Struct({
251
272
  unrestricted: Schema.optionalKey(Schema.Finite),
252
273
  partial: Schema.optionalKey(Schema.Finite),
253
274
  coverage: Schema.optionalKey(CoverageFloors),
275
+ conformance: Schema.optionalKey(ConformanceCeilings),
276
+ });
277
+
278
+
279
+ const decodeTree = Schema.decodeUnknownResult(Schema.Record(Schema.String, ManifestNodeSchema), {
280
+ errors: "all",
281
+ onExcessProperty: "error",
254
282
  });
255
283
 
284
+ // A tree of nodes, as an `endState` is once rebased: decoded like the
285
+ // manifest's own, every issue listed.
286
+ export const decodeManifestTree = (
287
+ raw: unknown,
288
+ ): Result.Result<Readonly<Record<string, ManifestNode>>, string> => {
289
+ const decoded = decodeTree(raw);
290
+ return Result.isFailure(decoded)
291
+ ? Result.fail(
292
+ flatten(decoded.failure.issue)
293
+ .issues.map((issue) => `${renderManifestPath(pathOf(issue))}: ${issue.message}`)
294
+ .join("\n"),
295
+ )
296
+ : Result.succeed(decoded.success);
297
+ };
298
+
299
+
256
300
  export const Manifest = Schema.Struct({
257
301
  // How an import specifier becomes a file. Every pattern below is matched
258
302
  // against a resolved path, so this is what makes the rest of the file mean
@@ -283,16 +327,30 @@ export type LimitsSpec = typeof Limits.Type;
283
327
  export type NamingSpec = typeof Naming.Type;
284
328
  export type ExportRestriction = typeof ExportRestriction.Type;
285
329
 
330
+
331
+
286
332
  export const globsOf = (globs: string | ReadonlyArray<string>): ReadonlyArray<string> =>
287
333
  typeof globs === "string" ? [globs] : globs;
288
334
 
289
- const decode = Schema.decodeUnknownResult(Manifest);
335
+ // Every issue, not the first: a manifest is edited by hand, and the reader
336
+ // fixing one line wants to know about the other three. A key the schema does
337
+ // not declare is refused rather than dropped — a misspelled `matchNot` that
338
+ // decoded to nothing would be a rule quietly enforcing less than it says.
339
+ const decode = Schema.decodeUnknownResult(Manifest, { errors: "all", onExcessProperty: "error" });
340
+ const flatten = SchemaIssue.makeFormatterStandardSchemaV1();
290
341
 
291
342
  export type DecodedManifest = {
292
343
  readonly manifest: Manifest;
344
+ // What each family the core does not own decoded out of the keys it claims,
345
+ // keyed by the extension's id. The core carries these and never looks
346
+ // inside them.
347
+ readonly extensions: ReadonlyMap<string, unknown>;
293
348
  // Things the manifest said in a form that still loads but is on its way out.
294
349
  // The host prints them; nothing else acts on them.
295
350
  readonly notices: ReadonlyArray<string>;
351
+ // Every `use` the expansion replaced. Lowering reads them to say which
352
+ // fragment an allowance came through; nothing else needs them.
353
+ readonly substitutions: ReadonlyArray<Substitution>;
296
354
  };
297
355
 
298
356
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -392,17 +450,155 @@ const normalizeLegacyMembers = (
392
450
  return { input: { ...input, tree }, notices };
393
451
  };
394
452
 
453
+
454
+ export type DecodeManifestOptions = {
455
+ // Turns a path in the file into a line and column. The YAML reader supplies
456
+ // one; a JavaScript module has no positions to give and passes nothing.
457
+ readonly locate?: ManifestLocator | undefined;
458
+ // Families the core does not own. Each claims top-level keys, which are
459
+ // split off before the core decodes what is left and handed to that
460
+ // family's own codec.
461
+ readonly extensions?: ReadonlyArray<ManifestExtension> | undefined;
462
+ };
463
+
464
+ const fileLabelOf = (configPath: string): string => configPath.split(/[\\/]/).at(-1) ?? configPath;
465
+
466
+ // A position carries its own file when the value was written in a file the
467
+ // manifest `include`d; otherwise it is in the manifest file itself.
468
+ const positionOf = (
469
+ file: string,
470
+ locate: ManifestLocator | undefined,
471
+ path: ManifestPath,
472
+ ): string | null => {
473
+ const found = locate?.(path) ?? null;
474
+ return found === null
475
+ ? null
476
+ : `${found.file ?? file}:${String(found.line)}:${String(found.column)}`;
477
+ };
478
+
479
+ // One line per issue: where in the file, which path, what was wrong — and,
480
+ // when the value came in through a `use`, the reference that pulled it in,
481
+ // since the fragment's own line may sit far from where the reader is looking.
482
+ const describeIssue = (
483
+ configPath: string,
484
+ locate: ManifestLocator | undefined,
485
+ substitutions: ReadonlyArray<Substitution>,
486
+ path: ManifestPath,
487
+ detail: string,
488
+ ): string => {
489
+ const file = fileLabelOf(configPath);
490
+ const origin = originOf(substitutions, path);
491
+ const at = positionOf(file, locate, origin.path);
492
+ const via = origin.via.map(({ at: ref, name }) => {
493
+ const position = positionOf(file, locate, ref);
494
+ return `via \`use: ${JSON.stringify(name)}\`${position === null ? "" : ` at ${position}`}`;
495
+ });
496
+ return (
497
+ ` ${at === null ? "" : `${at} `}${renderManifestPath(origin.path)}: ${detail}` +
498
+ (via.length === 0 ? "" : ` (${via.join(", ")})`)
499
+ );
500
+ };
501
+
502
+ // The standard-schema formatter flattens the issue tree to `{ path, message }`
503
+ // pairs; a path segment may arrive wrapped as `{ key }`.
504
+ const pathOf = (issue: {
505
+ readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }> | undefined;
506
+ }): ManifestPath =>
507
+ (issue.path ?? []).map((segment) => (typeof segment === "object" ? segment.key : segment));
508
+
395
509
  export const decodeManifest = (
396
510
  configPath: string,
397
511
  input: unknown,
512
+ options: DecodeManifestOptions = {},
398
513
  ): Result.Result<DecodedManifest, ConfigInvalid> => {
399
- const resolve = normalizeLegacyResolve(input);
514
+ const expanded = expandManifest(input);
515
+ if (Result.isFailure(expanded)) {
516
+ return Result.fail(
517
+ new ConfigInvalid({
518
+ configPath,
519
+ detail:
520
+ "the manifest does not expand:\n" +
521
+ describeIssue(
522
+ configPath,
523
+ options.locate,
524
+ [],
525
+ expanded.failure.path,
526
+ expanded.failure.detail,
527
+ ),
528
+ }),
529
+ );
530
+ }
531
+ const { substitutions, value } = expanded.success;
532
+
533
+ const extensions = options.extensions ?? [];
534
+ const describe = (path: ManifestPath, detail: string): string =>
535
+ describeIssue(configPath, options.locate, substitutions, path, detail);
536
+
537
+ // The keys a family the core does not own claims are split off here. What
538
+ // is left is decoded by the core's codec with excess properties refused, so
539
+ // a key nobody claims is still the misspelling it always was.
540
+ const claimed = new Map<string, string>();
541
+ for (const extension of extensions) {
542
+ for (const key of extension.manifestKeys) {
543
+ const already = claimed.get(key);
544
+ if (already !== undefined) {
545
+ return Result.fail(
546
+ new ConfigInvalid({
547
+ configPath,
548
+ detail:
549
+ `two loaded families both claim the manifest key \`${key}\` ` +
550
+ `("${already}" and "${extension.id}"). Load one of them.`,
551
+ }),
552
+ );
553
+ }
554
+ claimed.set(key, extension.id);
555
+ }
556
+ }
557
+ const core = isRecord(value)
558
+ ? Object.fromEntries(Object.entries(value).filter(([key]) => !claimed.has(key)))
559
+ : value;
560
+
561
+ const resolve = normalizeLegacyResolve(core);
400
562
  const members = normalizeLegacyMembers(resolve.input);
401
- return Result.map(
402
- Result.mapError(
403
- decode(members.input),
404
- (issue) => new ConfigInvalid({ configPath, detail: String(issue) }),
405
- ),
406
- (manifest) => ({ manifest, notices: [...resolve.notices, ...members.notices] }),
407
- );
563
+ const decoded = decode(members.input);
564
+ if (Result.isFailure(decoded)) {
565
+ const lines = flatten(decoded.failure.issue).issues.map((issue) =>
566
+ describeIssue(configPath, options.locate, substitutions, pathOf(issue), issue.message),
567
+ );
568
+ return Result.fail(
569
+ new ConfigInvalid({
570
+ configPath,
571
+ detail: `the manifest does not decode:\n${lines.join("\n")}`,
572
+ }),
573
+ );
574
+ }
575
+
576
+ // Each family decodes its own slice, and renders its issues the way the
577
+ // core renders its own. One family's failure is reported on its own: the
578
+ // core's decode already succeeded, so there is nothing to collect it with.
579
+ const decodedExtensions = new Map<string, unknown>();
580
+ for (const extension of extensions) {
581
+ const slice = isRecord(value)
582
+ ? Object.fromEntries(
583
+ extension.manifestKeys.flatMap((key) => (key in value ? [[key, value[key]]] : [])),
584
+ )
585
+ : {};
586
+ const result = extension.decode(slice, describe);
587
+ if (Result.isFailure(result)) {
588
+ return Result.fail(
589
+ new ConfigInvalid({
590
+ configPath,
591
+ detail: `the manifest does not decode:\n${result.failure.join("\n")}`,
592
+ }),
593
+ );
594
+ }
595
+ decodedExtensions.set(extension.id, result.success);
596
+ }
597
+
598
+ return Result.succeed({
599
+ manifest: decoded.success,
600
+ extensions: decodedExtensions,
601
+ notices: [...resolve.notices, ...members.notices],
602
+ substitutions,
603
+ });
408
604
  };
@@ -5,4 +5,7 @@ export type FileSystem = {
5
5
  readonly exists: (repoRelativePath: string) => boolean;
6
6
  // The file's text, or `null` when it is absent or unreadable.
7
7
  readonly readText: (repoRelativePath: string) => string | null;
8
+ // The names in a folder — the per-sector records under a campaign's
9
+ // ledger directory are found this way. Empty when the folder is absent.
10
+ readonly list: (repoRelativeDir: string) => ReadonlyArray<string>;
8
11
  };
@@ -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,42 @@
1
+ // A campaign's `syntax` term asks a question the facts cannot answer: does
2
+ // this file contain an expression of this shape? A syntax matcher parses one
3
+ // file and finds every node matching a rule, returning the matched text, what
4
+ // each metavariable captured, where it sits, and the named declaration it
5
+ // sits in. The rule object is the engine's own — ast-grep's, for the one
6
+ // matcher that exists — and is carried opaquely; the matcher validates it.
7
+ //
8
+ // The matcher knows nothing about imports. Narrowing a capture by what its
9
+ // identifier is bound to is done in the core, against the file's facts and
10
+ // the resolver, so a second engine has only this port to implement.
11
+
12
+ export type Position = {
13
+ // Zero-based line and column.
14
+ readonly line: number;
15
+ readonly column: number;
16
+ };
17
+
18
+ export type SyntaxMatch = {
19
+ readonly text: string;
20
+ // Each metavariable of the rule to the text it captured.
21
+ readonly captures: ReadonlyMap<string, string>;
22
+ readonly range: { readonly start: Position; readonly end: Position };
23
+ // The name of the nearest enclosing named declaration — the match itself
24
+ // when it is one — or `null` for a match at the top level of the file.
25
+ readonly anchor: string | null;
26
+ };
27
+
28
+ export type SyntaxTree = {
29
+ // Every node the rule matches, in source order. A rule the engine cannot
30
+ // read throws, with the engine's own sentence.
31
+ readonly findAll: (rule: unknown) => ReadonlyArray<SyntaxMatch>;
32
+ // The name of the innermost named declaration enclosing a position, or
33
+ // `null` at the top level — how a diagnostic another tool reported at a
34
+ // line is anchored on a declaration rather than on the line.
35
+ readonly anchorAt: (position: Position) => string | null;
36
+ };
37
+
38
+ export type SyntaxMatcher = {
39
+ // Parses one file's text. `null` when the matcher has no grammar for the
40
+ // file's extension, which a campaign treats as a file with no matches.
41
+ readonly parse: (file: string, text: string) => SyntaxTree | null;
42
+ };
package/src/testing.ts CHANGED
@@ -4,3 +4,4 @@
4
4
  export { makeFactExtractorFake } from "./infrastructure/fact-extractor-fake.js";
5
5
  export { makeFileSystemFake } from "./infrastructure/file-system-fake.js";
6
6
  export { makeModuleResolverFake } from "./infrastructure/module-resolver-fake.js";
7
+ export { makeSyntaxMatcherFake, type StagedMatch } from "./infrastructure/syntax-matcher-fake.js";