@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.
Files changed (164) hide show
  1. package/build/dts/core/campaigns.d.ts +141 -0
  2. package/build/dts/core/campaigns.d.ts.map +1 -0
  3. package/build/dts/core/coverage.d.ts +20 -0
  4. package/build/dts/core/coverage.d.ts.map +1 -1
  5. package/build/dts/core/graph.d.ts +2 -0
  6. package/build/dts/core/graph.d.ts.map +1 -1
  7. package/build/dts/core/imports.d.ts +3 -1
  8. package/build/dts/core/imports.d.ts.map +1 -1
  9. package/build/dts/core/ledger.d.ts +61 -0
  10. package/build/dts/core/ledger.d.ts.map +1 -0
  11. package/build/dts/core/slack.d.ts +22 -0
  12. package/build/dts/core/slack.d.ts.map +1 -0
  13. package/build/dts/core/structure.d.ts +1 -0
  14. package/build/dts/core/structure.d.ts.map +1 -1
  15. package/build/dts/domain/architecture-config.d.ts +324 -1
  16. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  17. package/build/dts/domain/architecture-error.d.ts +8 -0
  18. package/build/dts/domain/architecture-error.d.ts.map +1 -1
  19. package/build/dts/domain/facts.d.ts +1 -0
  20. package/build/dts/domain/facts.d.ts.map +1 -1
  21. package/build/dts/domain/manifest-location.d.ts +9 -0
  22. package/build/dts/domain/manifest-location.d.ts.map +1 -0
  23. package/build/dts/domain/report.d.ts +16 -0
  24. package/build/dts/domain/report.d.ts.map +1 -0
  25. package/build/dts/domain/snapshot.d.ts +224 -0
  26. package/build/dts/domain/snapshot.d.ts.map +1 -0
  27. package/build/dts/domain/violation.d.ts +1 -1
  28. package/build/dts/domain/violation.d.ts.map +1 -1
  29. package/build/dts/index.d.ts +24 -9
  30. package/build/dts/index.d.ts.map +1 -1
  31. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  32. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  33. package/build/dts/infrastructure/manifest-file.d.ts +11 -2
  34. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
  35. package/build/dts/infrastructure/manifest-include.d.ts +16 -0
  36. package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
  37. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  38. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  39. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  40. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  41. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  42. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  43. package/build/dts/infrastructure/walk.d.ts +6 -0
  44. package/build/dts/infrastructure/walk.d.ts.map +1 -1
  45. package/build/dts/load/policy.d.ts +16 -0
  46. package/build/dts/load/policy.d.ts.map +1 -1
  47. package/build/dts/manifest/compile.d.ts +7 -2
  48. package/build/dts/manifest/compile.d.ts.map +1 -1
  49. package/build/dts/manifest/expand.d.ts +27 -0
  50. package/build/dts/manifest/expand.d.ts.map +1 -0
  51. package/build/dts/manifest/infer.d.ts +55 -0
  52. package/build/dts/manifest/infer.d.ts.map +1 -0
  53. package/build/dts/manifest/json-schema.d.ts +10 -0
  54. package/build/dts/manifest/json-schema.d.ts.map +1 -0
  55. package/build/dts/manifest/manifest.d.ts +381 -1
  56. package/build/dts/manifest/manifest.d.ts.map +1 -1
  57. package/build/dts/ports/campaign-predicate.d.ts +24 -0
  58. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  59. package/build/dts/ports/language.d.ts +4 -0
  60. package/build/dts/ports/language.d.ts.map +1 -1
  61. package/build/dts/ports/report-source.d.ts +14 -0
  62. package/build/dts/ports/report-source.d.ts.map +1 -0
  63. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  64. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  65. package/build/dts/testing.d.ts +2 -0
  66. package/build/dts/testing.d.ts.map +1 -1
  67. package/build/esm/core/campaigns.js +778 -0
  68. package/build/esm/core/campaigns.js.map +1 -0
  69. package/build/esm/core/coverage.js +107 -32
  70. package/build/esm/core/coverage.js.map +1 -1
  71. package/build/esm/core/graph.js +45 -0
  72. package/build/esm/core/graph.js.map +1 -1
  73. package/build/esm/core/imports.js +14 -9
  74. package/build/esm/core/imports.js.map +1 -1
  75. package/build/esm/core/ledger.js +171 -0
  76. package/build/esm/core/ledger.js.map +1 -0
  77. package/build/esm/core/slack.js +76 -0
  78. package/build/esm/core/slack.js.map +1 -0
  79. package/build/esm/core/structure.js +5 -2
  80. package/build/esm/core/structure.js.map +1 -1
  81. package/build/esm/domain/architecture-config.js +158 -1
  82. package/build/esm/domain/architecture-config.js.map +1 -1
  83. package/build/esm/domain/architecture-error.js +27 -0
  84. package/build/esm/domain/architecture-error.js.map +1 -1
  85. package/build/esm/domain/manifest-location.js +21 -0
  86. package/build/esm/domain/manifest-location.js.map +1 -0
  87. package/build/esm/domain/report.js +168 -0
  88. package/build/esm/domain/report.js.map +1 -0
  89. package/build/esm/domain/snapshot.js +141 -0
  90. package/build/esm/domain/snapshot.js.map +1 -0
  91. package/build/esm/domain/violation.js.map +1 -1
  92. package/build/esm/index.js +21 -8
  93. package/build/esm/index.js.map +1 -1
  94. package/build/esm/infrastructure/campaign-functions.js +67 -0
  95. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  96. package/build/esm/infrastructure/manifest-file.js +158 -7
  97. package/build/esm/infrastructure/manifest-file.js.map +1 -1
  98. package/build/esm/infrastructure/manifest-include.js +187 -0
  99. package/build/esm/infrastructure/manifest-include.js.map +1 -0
  100. package/build/esm/infrastructure/report-source-fake.js +6 -0
  101. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  102. package/build/esm/infrastructure/report-source-live.js +165 -0
  103. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  104. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  105. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  106. package/build/esm/infrastructure/walk.js +57 -1
  107. package/build/esm/infrastructure/walk.js.map +1 -1
  108. package/build/esm/load/policy.js +123 -3
  109. package/build/esm/load/policy.js.map +1 -1
  110. package/build/esm/manifest/compile.js +228 -26
  111. package/build/esm/manifest/compile.js.map +1 -1
  112. package/build/esm/manifest/expand.js +116 -0
  113. package/build/esm/manifest/expand.js.map +1 -0
  114. package/build/esm/manifest/infer.js +455 -0
  115. package/build/esm/manifest/infer.js.map +1 -0
  116. package/build/esm/manifest/json-schema.js +135 -0
  117. package/build/esm/manifest/json-schema.js.map +1 -0
  118. package/build/esm/manifest/manifest.js +243 -5
  119. package/build/esm/manifest/manifest.js.map +1 -1
  120. package/build/esm/ports/campaign-predicate.js +2 -0
  121. package/build/esm/ports/campaign-predicate.js.map +1 -0
  122. package/build/esm/ports/report-source.js +7 -0
  123. package/build/esm/ports/report-source.js.map +1 -0
  124. package/build/esm/ports/syntax-matcher.js +12 -0
  125. package/build/esm/ports/syntax-matcher.js.map +1 -0
  126. package/build/esm/testing.js +2 -0
  127. package/build/esm/testing.js.map +1 -1
  128. package/package.json +9 -3
  129. package/schema/architecture-node.schema.json +2405 -0
  130. package/schema/architecture.schema.json +2825 -0
  131. package/schema/conformance.schema.json +775 -0
  132. package/src/core/campaigns.ts +1056 -0
  133. package/src/core/coverage.ts +164 -34
  134. package/src/core/graph.ts +48 -0
  135. package/src/core/imports.ts +29 -13
  136. package/src/core/ledger.ts +242 -0
  137. package/src/core/slack.ts +135 -0
  138. package/src/core/structure.ts +10 -5
  139. package/src/domain/architecture-config.ts +205 -1
  140. package/src/domain/architecture-error.ts +30 -0
  141. package/src/domain/facts.ts +9 -4
  142. package/src/domain/manifest-location.ts +41 -0
  143. package/src/domain/report.ts +203 -0
  144. package/src/domain/snapshot.ts +302 -0
  145. package/src/domain/violation.ts +4 -1
  146. package/src/index.ts +172 -3
  147. package/src/infrastructure/campaign-functions.ts +98 -0
  148. package/src/infrastructure/manifest-file.ts +204 -8
  149. package/src/infrastructure/manifest-include.ts +318 -0
  150. package/src/infrastructure/report-source-fake.ts +10 -0
  151. package/src/infrastructure/report-source-live.ts +192 -0
  152. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  153. package/src/infrastructure/walk.ts +70 -1
  154. package/src/load/policy.ts +193 -3
  155. package/src/manifest/compile.ts +290 -28
  156. package/src/manifest/expand.ts +183 -0
  157. package/src/manifest/infer.ts +643 -0
  158. package/src/manifest/json-schema.ts +168 -0
  159. package/src/manifest/manifest.ts +339 -11
  160. package/src/ports/campaign-predicate.ts +30 -0
  161. package/src/ports/language.ts +17 -0
  162. package/src/ports/report-source.ts +35 -0
  163. package/src/ports/syntax-matcher.ts +42 -0
  164. package/src/testing.ts +2 -0
@@ -0,0 +1,318 @@
1
+ import * as path from "node:path";
2
+
3
+ import { ConfigInvalid } from "../domain/architecture-error.js";
4
+ import {
5
+ type ManifestLocator,
6
+ type ManifestPath,
7
+ renderManifestPath,
8
+ } from "../domain/manifest-location.js";
9
+
10
+ // A manifest split across files. `{ include: "<path>" }` standing anywhere in
11
+ // a manifest — a node under `tree`, a whole section, one entry of a list — is
12
+ // replaced by the value of the file it names, read by the same parser and
13
+ // resolved relative to the file that wrote the reference. This runs on the raw
14
+ // value before anything else looks at it: the `defs`/`use` expansion sees one
15
+ // document, the decoder sees one document, and the schema never learns that a
16
+ // reference existed.
17
+ //
18
+ // What is deliberately not here: no merging (a value is replaced, full stop),
19
+ // no parameters, no glob of files. An included file is YAML or JSON only — a
20
+ // module would make a data manifest readable by one runtime — and a file may
21
+ // carry a top-level `defs` of its own, which joins the manifest's under one
22
+ // namespace, and a `$schema` for its editor, which is dropped.
23
+
24
+ // One file, as the reader parsed it.
25
+ export type SourceDocument = {
26
+ readonly value: unknown;
27
+ readonly locate: ManifestLocator | undefined;
28
+ };
29
+
30
+ export type IncludeReader = {
31
+ readonly exists: (file: string) => boolean;
32
+ // Throws `ConfigInvalid` naming the file when it does not parse.
33
+ readonly read: (file: string) => SourceDocument;
34
+ };
35
+
36
+ export type IncludedManifest = {
37
+ readonly value: unknown;
38
+ // Answers for every file: a position inside an included file carries that
39
+ // file's path, relative to the root manifest's directory.
40
+ readonly locate: ManifestLocator | undefined;
41
+ // Every file that took part, root first, as absolute paths.
42
+ readonly files: ReadonlyArray<string>;
43
+ };
44
+
45
+ const INCLUDABLE = [".yaml", ".yml", ".json"];
46
+
47
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
48
+ typeof value === "object" && value !== null && !Array.isArray(value);
49
+
50
+ type IncludeReference = Record<string, unknown> & { readonly include: unknown };
51
+
52
+ const isInclude = (value: unknown): value is IncludeReference =>
53
+ isRecord(value) && "include" in value;
54
+
55
+ const isPrefix = (prefix: ManifestPath, whole: ManifestPath): boolean =>
56
+ prefix.length <= whole.length && prefix.every((segment, index) => whole[index] === segment);
57
+
58
+ // Which file a region of the assembled document came from: everything under
59
+ // `at` was written in the file whose locator this is, starting at `origin`
60
+ // there. The root file mounts at the root; each include mounts where it
61
+ // landed; a fragment hoisted out of an included file mounts under `defs`; and
62
+ // a list item that moved — spliced in from another file, or shifted by a
63
+ // splice before it — mounts on its own, since its index is not the one it
64
+ // was written at.
65
+ type Mount = {
66
+ readonly at: ManifestPath;
67
+ readonly origin: ManifestPath;
68
+ readonly label: string | undefined;
69
+ readonly locate: ManifestLocator | undefined;
70
+ };
71
+
72
+ // The file being walked.
73
+ type Source = {
74
+ readonly file: string;
75
+ // How the file is named in a message: its path relative to the root
76
+ // manifest's directory. The root file itself has none, and is named by
77
+ // whoever reports the error.
78
+ readonly label: string | undefined;
79
+ readonly locate: ManifestLocator | undefined;
80
+ // Every file on the way here, for the cycle check.
81
+ readonly stack: ReadonlyArray<string>;
82
+ };
83
+
84
+ type Opened = {
85
+ readonly document: SourceDocument;
86
+ readonly source: Source;
87
+ };
88
+
89
+ const makeLocator = (mounts: ReadonlyArray<Mount>): ManifestLocator | undefined => {
90
+ if (!mounts.some((mount) => mount.locate !== undefined)) return undefined;
91
+ return (manifestPath) => {
92
+ const innermost = mounts
93
+ .filter((mount) => isPrefix(mount.at, manifestPath))
94
+ .sort((a, b) => a.at.length - b.at.length)
95
+ .at(-1);
96
+ if (innermost === undefined) return null;
97
+ const found =
98
+ innermost.locate?.([...innermost.origin, ...manifestPath.slice(innermost.at.length)]) ?? null;
99
+ if (found === null) return null;
100
+ return innermost.label === undefined ? found : { ...found, file: innermost.label };
101
+ };
102
+ };
103
+
104
+ export const expandIncludes = (
105
+ rootPath: string,
106
+ root: SourceDocument,
107
+ reader: IncludeReader,
108
+ ): IncludedManifest => {
109
+ const rootDirectory = path.dirname(rootPath);
110
+ const labelOf = (file: string): string =>
111
+ path.relative(rootDirectory, file).split(path.sep).join("/");
112
+ const nameOf = (source: Source): string => source.label ?? path.basename(rootPath);
113
+
114
+ const files: Array<string> = [rootPath];
115
+ const mounts: Array<Mount> = [{ at: [], origin: [], label: undefined, locate: root.locate }];
116
+ // Fragments hoisted out of included files, and the file each name came from.
117
+ const hoisted: Record<string, unknown> = {};
118
+ const definedIn = new Map<string, string>();
119
+
120
+ const refuse = (source: Source, origin: ManifestPath, detail: string): never => {
121
+ const position = source.locate?.(origin) ?? null;
122
+ const at =
123
+ position === null
124
+ ? ""
125
+ : `${nameOf(source)}:${String(position.line)}:${String(position.column)} `;
126
+ throw new ConfigInvalid({
127
+ configPath: rootPath,
128
+ detail: `the manifest does not include:\n ${at}${renderManifestPath(origin)}: ${detail}`,
129
+ });
130
+ };
131
+
132
+ // Checks a reference and reads the file it names. Nothing is placed yet:
133
+ // where the value lands depends on whether it is a list spliced into a list.
134
+ const open = (reference: IncludeReference, origin: ManifestPath, source: Source): Opened => {
135
+ const { include: specifier, ...rest } = reference;
136
+ if (typeof specifier !== "string") {
137
+ return refuse(source, origin, "`include` names a file, as a string.");
138
+ }
139
+ const shown = `\`include: ${JSON.stringify(specifier)}\``;
140
+ const beside = Object.keys(rest);
141
+ if (beside.length > 0) {
142
+ return refuse(
143
+ source,
144
+ origin,
145
+ `${shown} stands alone: an included file is replaced whole, so there is nothing for ` +
146
+ `${beside.map((key) => `\`${key}\``).join(", ")} to override. ` +
147
+ `To override a fragment, put it under \`defs\` and \`use\` it.`,
148
+ );
149
+ }
150
+ const target = path.resolve(path.dirname(source.file), specifier);
151
+ if (!INCLUDABLE.includes(path.extname(target).toLowerCase())) {
152
+ return refuse(
153
+ source,
154
+ origin,
155
+ `${shown} names a file that is not YAML or JSON. Only a data file can be included: ` +
156
+ `a module would make the manifest readable by one runtime only.`,
157
+ );
158
+ }
159
+ if (source.stack.includes(target)) {
160
+ return refuse(
161
+ source,
162
+ origin,
163
+ `${shown} includes a file that is already being included: ` +
164
+ `${[...source.stack, target].map(labelOf).join(" → ")}.`,
165
+ );
166
+ }
167
+ if (!reader.exists(target)) {
168
+ return refuse(
169
+ source,
170
+ origin,
171
+ `${shown} names a file that does not exist (looked for ${labelOf(target)}, ` +
172
+ `relative to ${nameOf(source)}).`,
173
+ );
174
+ }
175
+ const document = reader.read(target);
176
+ files.push(target);
177
+ return {
178
+ document,
179
+ source: {
180
+ file: target,
181
+ label: labelOf(target),
182
+ locate: document.locate,
183
+ stack: [...source.stack, target],
184
+ },
185
+ };
186
+ };
187
+
188
+ // Places an opened file at `at`, whole, and walks it so no reference is
189
+ // left inside. A top-level `defs` in the file joins the root's, and its
190
+ // `$schema` is for the editor — unless the file was included somewhere
191
+ // under `defs`, where it is a fragment or the map itself, and a key by
192
+ // either name is the author's.
193
+ const place = (opened: Opened, at: ManifestPath): unknown => {
194
+ const { document, source } = opened;
195
+ mounts.push({ at, origin: [], label: source.label, locate: source.locate });
196
+ if (!isRecord(document.value) || at[0] === "defs") {
197
+ return walk(document.value, at, [], source);
198
+ }
199
+ const { $schema: _schema, defs, ...body } = document.value;
200
+ if (defs !== undefined) {
201
+ if (!isRecord(defs)) {
202
+ return refuse(source, ["defs"], "`defs` must be a map of named fragments.");
203
+ }
204
+ for (const [name, fragment] of Object.entries(defs)) {
205
+ const already = definedIn.get(name);
206
+ if (already !== undefined) {
207
+ return refuse(
208
+ source,
209
+ ["defs", name],
210
+ `\`defs.${name}\` is already defined in ${already}. Every file's \`defs\` share ` +
211
+ `one namespace; rename one of them.`,
212
+ );
213
+ }
214
+ definedIn.set(name, nameOf(source));
215
+ mounts.push({
216
+ at: ["defs", name],
217
+ origin: ["defs", name],
218
+ label: source.label,
219
+ locate: source.locate,
220
+ });
221
+ hoisted[name] = walk(fragment, ["defs", name], ["defs", name], source);
222
+ }
223
+ }
224
+ return walk(body, at, [], source);
225
+ };
226
+
227
+ // Appends each item of `list` to `items`. An include standing as an item
228
+ // whose file holds a list is spliced in, so a list can be split across
229
+ // files; an item that does not sit at the index it was written at is
230
+ // mounted on its own.
231
+ const append = (
232
+ items: Array<unknown>,
233
+ list: ReadonlyArray<unknown>,
234
+ at: ManifestPath,
235
+ origin: ManifestPath,
236
+ source: Source,
237
+ spliced: boolean,
238
+ ): void => {
239
+ for (const [index, item] of list.entries()) {
240
+ const itemOrigin = [...origin, index];
241
+ const slot = [...at, items.length];
242
+ if (isInclude(item)) {
243
+ const opened = open(item, itemOrigin, source);
244
+ if (Array.isArray(opened.document.value)) {
245
+ append(items, opened.document.value, at, [], opened.source, true);
246
+ } else {
247
+ items.push(place(opened, slot));
248
+ }
249
+ continue;
250
+ }
251
+ if (spliced || items.length !== index) {
252
+ mounts.push({ at: slot, origin: itemOrigin, label: source.label, locate: source.locate });
253
+ }
254
+ items.push(walk(item, slot, itemOrigin, source));
255
+ }
256
+ };
257
+
258
+ const walk = (
259
+ value: unknown,
260
+ at: ManifestPath,
261
+ origin: ManifestPath,
262
+ source: Source,
263
+ ): unknown => {
264
+ if (isInclude(value)) return place(open(value, origin, source), at);
265
+
266
+ if (Array.isArray(value)) {
267
+ const items: Array<unknown> = [];
268
+ append(items, value, at, origin, source, false);
269
+ return items;
270
+ }
271
+
272
+ if (isRecord(value)) {
273
+ const entries: Record<string, unknown> = {};
274
+ for (const [key, item] of Object.entries(value)) {
275
+ entries[key] = walk(item, [...at, key], [...origin, key], source);
276
+ }
277
+ return entries;
278
+ }
279
+
280
+ return value;
281
+ };
282
+
283
+ const rootSource: Source = {
284
+ file: rootPath,
285
+ label: undefined,
286
+ locate: root.locate,
287
+ stack: [rootPath],
288
+ };
289
+ const value = walk(root.value, [], [], rootSource);
290
+
291
+ // Nothing was included: the value and the locator are the reader's own.
292
+ if (files.length === 1) return { value, locate: root.locate, files };
293
+
294
+ const names = Object.keys(hoisted);
295
+ if (names.length === 0 || !isRecord(value)) {
296
+ return { value, locate: makeLocator(mounts), files };
297
+ }
298
+
299
+ const own = value.defs;
300
+ if (own !== undefined && !isRecord(own)) {
301
+ return refuse(rootSource, ["defs"], "`defs` must be a map of named fragments.");
302
+ }
303
+ for (const name of names) {
304
+ if (own !== undefined && name in own) {
305
+ return refuse(
306
+ rootSource,
307
+ ["defs", name],
308
+ `\`defs.${name}\` is also defined in ${definedIn.get(name) ?? "an included file"}. ` +
309
+ `Every file's \`defs\` share one namespace; rename one of them.`,
310
+ );
311
+ }
312
+ }
313
+ return {
314
+ value: { ...value, defs: { ...(own ?? {}), ...hoisted } },
315
+ locate: makeLocator(mounts),
316
+ files,
317
+ };
318
+ };
@@ -0,0 +1,10 @@
1
+ import type { Diagnostic } from "../domain/report.js";
2
+ import type { ReportSource } from "../ports/report-source.js";
3
+
4
+ // Keyed by file: a test states which diagnostics another tool reported on
5
+ // which file, and never runs the tool. Every spec is answered alike.
6
+ export const makeReportSourceFake = (
7
+ staged: Readonly<Record<string, ReadonlyArray<Omit<Diagnostic, "file">>>>,
8
+ ): ReportSource => ({
9
+ diagnosticsOf: (_spec, file) => (staged[file] ?? []).map((one) => ({ ...one, file })),
10
+ });
@@ -0,0 +1,192 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { availableParallelism } from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ import { ReportUnavailable } from "../domain/architecture-error.js";
7
+ import { type Diagnostic, indexByFile, parseReport, uniqueDiagnostics } from "../domain/report.js";
8
+ import { type ReportSource, reportSourcesOf, type ReportSpec } from "../ports/report-source.js";
9
+
10
+ // The report source on this host: runs a `report` term's commands from the
11
+ // repository root, or reads the files it names, and parses the output once
12
+ // — a report is about the whole repository, a campaign asks per file, and
13
+ // the plugin asks for one file at a time across a whole lint run. Cached
14
+ // per spec for the life of the process: the CLI is one process per `check`,
15
+ // and oxlint's language server is one process per editor session, which
16
+ // sees the report as of when it loaded the plugin. A report the build
17
+ // writes to a file is the predictable form for the editor, and for CI.
18
+ //
19
+ // A term may name several commands — a tool that checks one project at a
20
+ // time, run once per project — and their outputs are one report: parsed
21
+ // each, joined, and a diagnostic two of them print kept once. `read` runs
22
+ // them concurrently, as many at a time as the machine has cores, which is
23
+ // why both hosts call it before any file asks; `diagnosticsOf` with no
24
+ // answer kept runs them one after another, synchronously, and keeps that.
25
+ //
26
+ // A non-zero exit is not a failure — `tsc` exits 2 when there are errors,
27
+ // which is the case the term exists for. A command that cannot be spawned,
28
+ // or a file that is not there, is — and that failure is cached as the report
29
+ // would have been, so the second file to ask gets the same answer without a
30
+ // second spawn. The command is forked from the asking process; under the
31
+ // oxlint plugin that is the linter, which Linux refuses to fork once its AST
32
+ // buffers have merged into one mapping larger than RAM and swap — so the
33
+ // plugin asks at load, and retrying per file would only repeat the refusal.
34
+
35
+ const MAX_BUFFER = 256 * 1024 * 1024;
36
+
37
+ const fileTextOf = (repoRoot: string, file: string): string => {
38
+ const at = path.resolve(repoRoot, file);
39
+ try {
40
+ return readFileSync(at, "utf8");
41
+ } catch (cause) {
42
+ throw new ReportUnavailable({ kind: "file", source: file, detail: String(cause) });
43
+ }
44
+ };
45
+
46
+ const commandTextSync = (repoRoot: string, command: string): string => {
47
+ const run = spawnSync(command, {
48
+ cwd: repoRoot,
49
+ shell: true,
50
+ encoding: "utf8",
51
+ maxBuffer: MAX_BUFFER,
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ });
54
+ if (run.error !== undefined) {
55
+ throw new ReportUnavailable({ kind: "command", source: command, detail: run.error.message });
56
+ }
57
+ return run.stdout;
58
+ };
59
+
60
+ const commandText = (repoRoot: string, command: string): Promise<string> =>
61
+ new Promise((resolve, reject) => {
62
+ const child = spawn(command, {
63
+ cwd: repoRoot,
64
+ shell: true,
65
+ stdio: ["ignore", "pipe", "pipe"],
66
+ });
67
+ const chunks: Array<Buffer> = [];
68
+ let size = 0;
69
+ child.stdout.on("data", (chunk: Buffer) => {
70
+ size += chunk.length;
71
+ if (size > MAX_BUFFER) {
72
+ child.kill();
73
+ reject(
74
+ new ReportUnavailable({
75
+ kind: "command",
76
+ source: command,
77
+ detail: `stdout exceeded ${String(MAX_BUFFER)} bytes`,
78
+ }),
79
+ );
80
+ return;
81
+ }
82
+ chunks.push(chunk);
83
+ });
84
+ child.stderr.resume();
85
+ child.on("error", (cause) => {
86
+ reject(new ReportUnavailable({ kind: "command", source: command, detail: cause.message }));
87
+ });
88
+ child.on("close", () => {
89
+ resolve(Buffer.concat(chunks).toString("utf8"));
90
+ });
91
+ });
92
+
93
+ // `work` over `items`, at most `lanes` at a time, answers in order.
94
+ const inLanes = async <A, B>(
95
+ items: ReadonlyArray<A>,
96
+ lanes: number,
97
+ work: (item: A) => Promise<B>,
98
+ ): Promise<ReadonlyArray<B>> => {
99
+ const answers = new Array<B>(items.length);
100
+ let next = 0;
101
+ const lane = async (): Promise<void> => {
102
+ while (next < items.length) {
103
+ const index = next;
104
+ next += 1;
105
+ answers[index] = await work(items[index] as A);
106
+ }
107
+ };
108
+ await Promise.all(Array.from({ length: Math.min(lanes, items.length) }, lane));
109
+ return answers;
110
+ };
111
+
112
+ // One command named as a string and the same one as a list of one are the
113
+ // same report.
114
+ const keyOf = (spec: ReportSpec): string =>
115
+ JSON.stringify([
116
+ spec.file === undefined ? "command" : "file",
117
+ reportSourcesOf(spec),
118
+ spec.format,
119
+ spec.pattern ?? null,
120
+ ]);
121
+
122
+ type Indexed = ReadonlyMap<string, ReadonlyArray<Diagnostic>>;
123
+
124
+ export const makeReportSourceLive = (repoRoot: string): ReportSource => {
125
+ const cache = new Map<string, Indexed | ReportUnavailable>();
126
+ const reading = new Map<string, Promise<void>>();
127
+
128
+ // The outputs, in the order named, as one report.
129
+ const indexOf = (spec: ReportSpec, texts: ReadonlyArray<string>): Indexed =>
130
+ indexByFile(
131
+ uniqueDiagnostics(
132
+ texts.flatMap((text) =>
133
+ parseReport(spec.format, text, { repoRoot, pattern: spec.pattern }),
134
+ ),
135
+ ),
136
+ );
137
+
138
+ const answerSync = (spec: ReportSpec): Indexed | ReportUnavailable => {
139
+ try {
140
+ const texts = reportSourcesOf(spec).map((source) =>
141
+ spec.file === undefined ? commandTextSync(repoRoot, source) : fileTextOf(repoRoot, source),
142
+ );
143
+ return indexOf(spec, texts);
144
+ } catch (cause) {
145
+ if (cause instanceof ReportUnavailable) return cause;
146
+ throw cause;
147
+ }
148
+ };
149
+
150
+ const answer = async (spec: ReportSpec): Promise<Indexed | ReportUnavailable> => {
151
+ try {
152
+ const sources = reportSourcesOf(spec);
153
+ const texts =
154
+ spec.file === undefined
155
+ ? await inLanes(sources, availableParallelism(), (command) =>
156
+ commandText(repoRoot, command),
157
+ )
158
+ : sources.map((file) => fileTextOf(repoRoot, file));
159
+ return indexOf(spec, texts);
160
+ } catch (cause) {
161
+ if (cause instanceof ReportUnavailable) return cause;
162
+ throw cause;
163
+ }
164
+ };
165
+
166
+ const read = (spec: ReportSpec): Promise<void> => {
167
+ const key = keyOf(spec);
168
+ if (cache.has(key)) return Promise.resolve();
169
+ const pending = reading.get(key);
170
+ if (pending !== undefined) return pending;
171
+ const started = answer(spec).then((found) => {
172
+ cache.set(key, found);
173
+ reading.delete(key);
174
+ });
175
+ reading.set(key, started);
176
+ return started;
177
+ };
178
+
179
+ return {
180
+ read,
181
+ diagnosticsOf: (spec, file) => {
182
+ const key = keyOf(spec);
183
+ let found = cache.get(key);
184
+ if (found === undefined) {
185
+ found = answerSync(spec);
186
+ cache.set(key, found);
187
+ }
188
+ if (found instanceof ReportUnavailable) throw found;
189
+ return found.get(file) ?? [];
190
+ },
191
+ };
192
+ };
@@ -0,0 +1,51 @@
1
+ import type { SyntaxMatch, SyntaxMatcher } from "../ports/syntax-matcher.js";
2
+
3
+ // One match as a test states it: the text, its captures as a plain object,
4
+ // the anchor, and optionally a line — the rest of the range is filled in.
5
+ // `rule` narrows the match to one rule (compared by its JSON), so a test
6
+ // about two `syntax` terms can answer each differently; omit it to answer
7
+ // every rule the same.
8
+ export type StagedMatch = {
9
+ readonly text: string;
10
+ readonly captures?: Readonly<Record<string, string>>;
11
+ readonly anchor?: string | null;
12
+ readonly line?: number;
13
+ readonly rule?: unknown;
14
+ };
15
+
16
+ const ZERO = { line: 0, column: 0 };
17
+
18
+ // Keyed by the source text, as the extractor fake is: a core test states
19
+ // what a snippet's syntax yields and never the parse, which has its own
20
+ // tests in the matcher package. A text not staged parses to no matches; a
21
+ // file whose extension is not among `extensions` does not parse at all,
22
+ // which is the shape of a language with no matcher.
23
+ export const makeSyntaxMatcherFake = (
24
+ staged: Readonly<Record<string, ReadonlyArray<StagedMatch>>>,
25
+ extensions: ReadonlyArray<string> | null = null,
26
+ ): SyntaxMatcher => ({
27
+ parse: (file, text) => {
28
+ if (extensions !== null && !extensions.some((extension) => file.endsWith(extension))) {
29
+ return null;
30
+ }
31
+ const matches = staged[text] ?? [];
32
+ return {
33
+ // A staged match's line is what a position is anchored by.
34
+ anchorAt: (position) => matches.find((one) => one.line === position.line)?.anchor ?? null,
35
+ findAll: (rule) => {
36
+ const asked = JSON.stringify(rule);
37
+ return matches
38
+ .filter((one) => one.rule === undefined || JSON.stringify(one.rule) === asked)
39
+ .map((one): SyntaxMatch => ({
40
+ text: one.text,
41
+ captures: new Map(Object.entries(one.captures ?? {})),
42
+ range: {
43
+ start: one.line === undefined ? ZERO : { line: one.line, column: 0 },
44
+ end: one.line === undefined ? ZERO : { line: one.line, column: one.text.length },
45
+ },
46
+ anchor: one.anchor ?? null,
47
+ }));
48
+ },
49
+ };
50
+ },
51
+ });
@@ -1,4 +1,4 @@
1
- import { readdirSync, statSync } from "node:fs";
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import * as path from "node:path";
3
3
 
4
4
  import type { Language } from "../ports/language.js";
@@ -52,3 +52,72 @@ export const listSourceFiles = (
52
52
  }
53
53
  return found.sort();
54
54
  };
55
+
56
+ // What `infer` needs to know about a language beyond the walker's two: what
57
+ // marks a package of it, and where a package keeps its source.
58
+ export type PackagedLanguage = Pick<Language, "packageMarkers" | "sourceRoots">;
59
+
60
+ export type PackageRoot = {
61
+ // Repo-relative folder with forward slashes; `""` is the repository itself.
62
+ readonly root: string;
63
+ // The package's source folder, repo-relative, when one of the language's
64
+ // `sourceRoots` exists under it; otherwise `null`, and source sits at the root.
65
+ readonly source: string | null;
66
+ };
67
+
68
+ // The packages under (and above) `roots`: every folder holding one of the
69
+ // languages' marker files, the walk roots' own ancestors included, so a walk
70
+ // rooted at `src/` still finds the `package.json` beside it. Sorted by path.
71
+ export const listPackageRoots = (
72
+ repoRoot: string,
73
+ roots: ReadonlyArray<string>,
74
+ languages: ReadonlyArray<PackagedLanguage>,
75
+ ): ReadonlyArray<PackageRoot> => {
76
+ const markers = languages.flatMap((language) => language.packageMarkers);
77
+ const sourceRoots = languages.flatMap((language) => language.sourceRoots);
78
+ if (markers.length === 0) return [];
79
+
80
+ const isDirectory = (absolute: string): boolean => {
81
+ try {
82
+ return statSync(absolute).isDirectory();
83
+ } catch {
84
+ return false;
85
+ }
86
+ };
87
+ const relative = (absolute: string): string =>
88
+ path.relative(repoRoot, absolute).replaceAll(path.sep, "/");
89
+
90
+ const found = new Map<string, PackageRoot>();
91
+ const consider = (absolute: string): void => {
92
+ const key = relative(absolute);
93
+ if (found.has(key)) return;
94
+ if (!markers.some((marker) => existsSync(path.join(absolute, marker)))) return;
95
+ const source = sourceRoots.find((name) => isDirectory(path.join(absolute, name)));
96
+ found.set(key, {
97
+ root: key,
98
+ source: source === undefined ? null : relative(path.join(absolute, source)),
99
+ });
100
+ };
101
+ const walk = (absolute: string): void => {
102
+ consider(absolute);
103
+ for (const entry of readdirSync(absolute)) {
104
+ if (SKIPPED.has(entry)) continue;
105
+ const child = path.join(absolute, entry);
106
+ if (isDirectory(child)) walk(child);
107
+ }
108
+ };
109
+
110
+ const top = path.resolve(repoRoot);
111
+ for (const root of roots) {
112
+ let absolute = path.resolve(repoRoot, root);
113
+ if (!isDirectory(absolute)) absolute = path.dirname(absolute);
114
+ walk(absolute);
115
+ // The ancestors, up to the repository: a package is as often above the
116
+ // walk root as inside it.
117
+ for (let above = absolute; above.startsWith(top); above = path.dirname(above)) {
118
+ consider(above);
119
+ if (above === top) break;
120
+ }
121
+ }
122
+ return [...found.values()].sort((a, b) => a.root.localeCompare(b.root));
123
+ };