ox-dep-lint 0.2.0 → 0.4.0

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.
@@ -1,6 +1,7 @@
1
1
  import { builtinModules } from "node:module";
2
- import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync } from "node:fs";
3
3
  import nodePath from "node:path";
4
+ import * as v from "valibot";
4
5
  import { ResolverFactory } from "oxc-resolver";
5
6
  import { Visitor, parseSync } from "oxc-parser";
6
7
  // src/config.ts
@@ -8,7 +9,10 @@ const ruleNames = [
8
9
  "unknown-workspace-package",
9
10
  "package-without-kind",
10
11
  "package-outside-workspace",
12
+ "directory-without-package",
11
13
  "invalid-package",
14
+ "symbolic-link",
15
+ "unreadable-path",
12
16
  "forbidden-edge",
13
17
  "type-only-edge",
14
18
  "relative-import-leaves-package",
@@ -40,8 +44,22 @@ const defaultFileClasses = [{
40
44
  ],
41
45
  policy: "development"
42
46
  }];
43
- /** Build output and coverage reports under a package, never read unless `output` says otherwise. */
44
- const defaultOutput = ["dist/**", "coverage/**"];
47
+ /**
48
+ * Build output, coverage reports and the caches tools write under a package root (generated, never written by hand),
49
+ * never read unless `output` says otherwise. Shipped code that imports from them is still judged.
50
+ */
51
+ const defaultOutput = [
52
+ "dist/**",
53
+ "coverage/**",
54
+ ".wrangler/**",
55
+ ".astro/**",
56
+ ".svelte-kit/**",
57
+ ".next/**",
58
+ ".turbo/**",
59
+ ".vercel/**",
60
+ ".nuxt/**",
61
+ ".output/**"
62
+ ];
45
63
  const allManifestFields = [
46
64
  "dependencies",
47
65
  "devDependencies",
@@ -50,137 +68,367 @@ const allManifestFields = [
50
68
  ];
51
69
  /** Identity, for type checking a configuration file. */
52
70
  const defineConfig = (config) => config;
53
- const allowedKeys = {
54
- config: [
55
- "workspaceScopes",
56
- "kinds",
57
- "packages",
58
- "fileClasses",
59
- "runtimeModules",
60
- "virtualModules",
61
- "testOnlySpecifiers",
62
- "imports",
63
- "baseline",
64
- "ignore",
65
- "output"
66
- ],
67
- kind: [
68
- "packages",
69
- "mayUse",
70
- "externals",
71
- "runtimeModules"
72
- ],
73
- package: [
74
- "uses",
75
- "externals",
76
- "typeOnlyUses",
77
- "valueSpecifiers",
78
- "testSupportFiles"
79
- ],
80
- fileClass: ["files", "policy"],
81
- runtimeModules: ["prefixes", "names"],
82
- imports: [
83
- "computed",
84
- "selfByName",
85
- "unresolved",
86
- "manifestEdgeFields",
87
- "requireDeclared",
88
- "shippedFields",
89
- "typeOnly"
90
- ],
91
- baseline: [
92
- "file",
93
- "rule",
94
- "target",
95
- "reason"
96
- ]
97
- };
98
- const choices = {
99
- computed: [
71
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
72
+ const plainObject = v.custom(isPlainObject);
73
+ /** `schema` itself, which fails to type check unless it accepts exactly `Output`. */
74
+ const schemaOf = () => (schema) => schema;
75
+ const filePolicySchema = schemaOf()(v.picklist([
76
+ "shipped",
77
+ "development",
78
+ "unchecked"
79
+ ]));
80
+ const manifestFieldSchema = schemaOf()(v.picklist(allManifestFields));
81
+ const ruleNameSchema = schemaOf()(v.picklist(ruleNames));
82
+ /** The values each import policy may take. */
83
+ const policies = {
84
+ computed: schemaOf()(v.picklist([
100
85
  "error-in-shipped",
101
86
  "error",
102
87
  "allow"
103
- ],
104
- selfByName: ["error", "allow"],
105
- unresolved: ["error", "ignore"],
106
- requireDeclared: [true, false],
107
- typeOnly: ["relaxed", "as-values"]
88
+ ])),
89
+ selfByName: schemaOf()(v.picklist(["error", "allow"])),
90
+ unresolved: schemaOf()(v.picklist(["error", "ignore"])),
91
+ requireDeclared: schemaOf()(v.boolean()),
92
+ typeOnly: schemaOf()(v.picklist(["relaxed", "as-values"]))
108
93
  };
94
+ /** 0.2's test for a list of strings. `every` skips the holes of a sparse array, where `v.array` reads `undefined`. */
109
95
  const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
110
- const unknownKeys = (where, value, allowed) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value).filter((key) => !allowed.includes(key)).map((key) => `${where} has the unknown key ${key} (known: ${allowed.join(", ")})`) : [`${where} is not an object`];
111
- /** Problems that make a configuration unusable, as messages. A misspelled key is a problem, never a silent default. */
112
- const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
113
- /** Problems with the configuration's outline (objects where objects belong, lists where lists belong), checked first. */
114
- const shapeProblems = (config) => {
115
- if (!isObject(config)) return ["the configuration must be an object"];
116
- const { kinds, packages, fileClasses, baseline } = config;
96
+ const stringList = v.custom(isStringList);
97
+ /** A list of manifest fields, holes skipped as in 0.2. */
98
+ const manifestFieldList = v.custom((value) => isStringList(value) && value.every((field) => v.is(manifestFieldSchema, field)));
99
+ /**
100
+ * A value in a message, written as 0.2 wrote it: a template literal, which throws for a Symbol. 0.2 threw that way
101
+ * (exit 2 with the TypeError's message), so the check still does.
102
+ */
103
+ const asText = (value) => `${value}`;
104
+ /**
105
+ * Every own entry of an object, each value checked by `value`. Not `v.record`, which skips the keys `constructor`,
106
+ * `prototype` and `__proto__` that 0.2 checked. In an issue's path, the entry is its index and then 1.
107
+ */
108
+ const ownEntries = (value) => v.pipe(plainObject, v.transform((input) => Object.entries(input)), v.array(v.tuple([v.string(), value])));
109
+ /** 0.2's outline test for a list of objects, which skips holes as `every` does. */
110
+ const objectList = v.custom((value) => Array.isArray(value) && value.every(isPlainObject));
111
+ /** The outline (objects where objects belong, lists where lists belong), checked first: nothing else is read until it holds. */
112
+ const outlineSchema = v.pipe(plainObject, v.looseObject({
113
+ kinds: ownEntries(plainObject),
114
+ packages: v.optional(ownEntries(plainObject)),
115
+ imports: v.optional(plainObject),
116
+ runtimeModules: v.optional(plainObject),
117
+ fileClasses: v.optional(objectList),
118
+ baseline: v.optional(objectList)
119
+ }));
120
+ const kindRuleSchema = v.strictObject({
121
+ packages: v.pipe(v.array(v.unknown()), v.minLength(1)),
122
+ mayUse: v.union([v.literal("any"), stringList]),
123
+ externals: v.optional(v.union([v.literal("any"), stringList])),
124
+ runtimeModules: v.optional(v.boolean())
125
+ });
126
+ const packageRuleSchema = v.strictObject({
127
+ uses: v.exactOptional(stringList),
128
+ externals: v.exactOptional(stringList),
129
+ typeOnlyUses: v.exactOptional(stringList),
130
+ valueSpecifiers: v.exactOptional(stringList),
131
+ testSupportFiles: v.exactOptional(stringList)
132
+ });
133
+ const fileClassSchema = v.strictObject({
134
+ files: stringList,
135
+ policy: filePolicySchema
136
+ });
137
+ const runtimeModulesSchema = v.strictObject({
138
+ prefixes: v.exactOptional(stringList),
139
+ names: v.exactOptional(stringList)
140
+ });
141
+ const importPolicySchema = v.strictObject({
142
+ computed: v.optional(policies.computed),
143
+ selfByName: v.optional(policies.selfByName),
144
+ unresolved: v.optional(policies.unresolved),
145
+ manifestEdgeFields: v.optional(manifestFieldList),
146
+ requireDeclared: v.optional(policies.requireDeclared),
147
+ shippedFields: v.optional(manifestFieldList),
148
+ typeOnly: v.optional(policies.typeOnly)
149
+ });
150
+ const baselineEntrySchema = v.strictObject({
151
+ file: v.optional(v.unknown()),
152
+ rule: ruleNameSchema,
153
+ target: v.pipe(v.string(), v.nonEmpty()),
154
+ reason: v.pipe(v.string(), v.check((reason) => reason.trim() !== ""))
155
+ });
156
+ const configSchema = v.strictObject({
157
+ workspaceScopes: stringList,
158
+ kinds: ownEntries(kindRuleSchema),
159
+ packages: v.optional(ownEntries(packageRuleSchema)),
160
+ fileClasses: v.optional(v.array(fileClassSchema)),
161
+ runtimeModules: v.optional(runtimeModulesSchema),
162
+ virtualModules: v.optional(stringList),
163
+ testOnlySpecifiers: v.optional(stringList),
164
+ imports: v.optional(importPolicySchema),
165
+ baseline: v.optional(v.array(baselineEntrySchema)),
166
+ ignore: v.optional(stringList),
167
+ output: v.optional(stringList)
168
+ });
169
+ /** The sections of the messages, in 0.2's order. */
170
+ const section = {
171
+ configurationKeys: 0,
172
+ kinds: 1,
173
+ packages: 2,
174
+ workspaceScopes: 3,
175
+ testOnlySpecifiers: 4,
176
+ virtualModules: 5,
177
+ ignore: 6,
178
+ output: 7,
179
+ runtimeModuleValues: 8,
180
+ importFields: 9,
181
+ fileClasses: 10,
182
+ runtimeModuleKeys: 11,
183
+ importKeys: 12,
184
+ importPolicies: 13,
185
+ baseline: 14
186
+ };
187
+ /** Within a kind: its unknown keys, then these fields, then the kinds it names that do not exist. */
188
+ const kindFieldOrder = [
189
+ "packages",
190
+ "mayUse",
191
+ "runtimeModules",
192
+ "externals"
193
+ ];
194
+ const importFieldOrder = ["manifestEdgeFields", "shippedFields"];
195
+ const importPolicyOrder = [
196
+ "computed",
197
+ "selfByName",
198
+ "unresolved",
199
+ "requireDeclared",
200
+ "typeOnly"
201
+ ];
202
+ const baselineFieldOrder = [
203
+ "file",
204
+ "rule",
205
+ "target",
206
+ "reason"
207
+ ];
208
+ /** The values a policy may take, as its message lists them. */
209
+ const policyValues = (field) => {
210
+ const schema = policies[field];
211
+ return "options" in schema ? schema.options : [true, false];
212
+ };
213
+ /** One message per key of `object` that `entries` does not know, in the object's order. */
214
+ const unknownKeyProblems = (where, object, entries, order) => Object.keys(object).flatMap((key, position) => Object.hasOwn(entries, key) ? [] : [{
215
+ order: [...order, position],
216
+ message: `${where} has the unknown key ${key} (known: ${Object.keys(entries).join(", ")})`
217
+ }]);
218
+ /** For an object whose every value is a list of strings: the unknown keys whose value is not one, in the object's order. */
219
+ const unknownListProblems = (object, entries, order, message) => Object.entries(object).flatMap(([key, value], position) => Object.hasOwn(entries, key) || v.is(stringList, value) ? [] : [{
220
+ order: [...order, position],
221
+ message: message(key)
222
+ }]);
223
+ /** The value an issue's path passes through at `index`, as an object (an entry of `ownEntries` is a pair). */
224
+ const objectAt = (path, index) => {
225
+ const value = path[index]?.value;
226
+ return isPlainObject(value) ? value : {};
227
+ };
228
+ /** The key and value of the `ownEntries` entry at `index` of an issue's path. */
229
+ const entryAt = (path, index) => {
230
+ const pair = path[index]?.value;
231
+ const [name, value] = Array.isArray(pair) ? pair : [];
232
+ return {
233
+ name: String(name),
234
+ value: isPlainObject(value) ? value : {}
235
+ };
236
+ };
237
+ const outlineMessage = (path) => {
238
+ const field = path[0]?.key;
239
+ if (field === void 0) return "the configuration must be an object";
240
+ if (field === "kinds") return path.length === 1 ? "kinds must map each kind to its rule" : `kind ${entryAt(path, 1).name} must be an object`;
241
+ if (field === "packages") return path.length === 1 ? "packages must map package names to their rules" : `package ${entryAt(path, 1).name} must be an object`;
242
+ if (field === "imports" || field === "runtimeModules") return `${field} must be an object`;
243
+ return `${field === "fileClasses" ? "fileClasses" : "baseline"} must be a list of objects`;
244
+ };
245
+ const kindProblems = (path, unknownKey, holder) => {
246
+ const index = Number(path[1]?.key);
247
+ const { name } = entryAt(path, 1);
248
+ if (unknownKey) return unknownKeyProblems(`kind ${name}`, holder, kindRuleSchema.entries, [
249
+ section.kinds,
250
+ index,
251
+ 0
252
+ ]);
253
+ const field = String(path[3]?.key);
254
+ const message = {
255
+ packages: `kind ${name} names no packages`,
256
+ mayUse: `kind ${name}: mayUse must be "any" or a list of kinds`,
257
+ runtimeModules: `kind ${name}: runtimeModules must be true or false`,
258
+ externals: `kind ${name}: externals must be "any" or a list of package names`
259
+ }[field];
260
+ return message === void 0 ? [] : [{
261
+ order: [
262
+ section.kinds,
263
+ index,
264
+ kindFieldOrder.indexOf(field) + 1
265
+ ],
266
+ message
267
+ }];
268
+ };
269
+ /** The kinds a kind may use that are not kinds: a reference across entries, which no schema of one entry sees. */
270
+ const kindReferenceProblems = (kinds) => {
271
+ const known = new Set(Object.keys(kinds));
117
272
  const problems = [];
118
- if (!isObject(kinds)) problems.push("kinds must map each kind to its rule");
119
- else for (const [name, kind] of Object.entries(kinds)) if (!isObject(kind)) problems.push(`kind ${name} must be an object`);
120
- if (packages !== void 0 && !isObject(packages)) problems.push("packages must map package names to their rules");
121
- else for (const [name, rule] of Object.entries(packages ?? {})) if (!isObject(rule)) problems.push(`package ${name} must be an object`);
122
- for (const [key, value] of [["imports", config.imports], ["runtimeModules", config.runtimeModules]]) if (value !== void 0 && !isObject(value)) problems.push(`${key} must be an object`);
123
- for (const [key, list] of [["fileClasses", fileClasses], ["baseline", baseline]]) if (list !== void 0 && (!Array.isArray(list) || !list.every(isObject))) problems.push(`${key} must be a list of objects`);
273
+ for (const [index, [name, kind]] of Object.entries(kinds).entries()) {
274
+ const mayUse = isPlainObject(kind) ? kind.mayUse : void 0;
275
+ if (!Array.isArray(mayUse)) continue;
276
+ for (const [position, used] of [...mayUse].entries()) if (typeof used !== "string" || !known.has(used)) problems.push({
277
+ order: [
278
+ section.kinds,
279
+ index,
280
+ kindFieldOrder.length + 1,
281
+ position
282
+ ],
283
+ message: `kind ${name} may use ${asText(used)}, which is not a kind`
284
+ });
285
+ }
124
286
  return problems;
125
287
  };
126
- const configProblems = (config) => {
127
- const outline = shapeProblems(config);
128
- if (outline.length > 0) return outline;
129
- const problems = unknownKeys("the configuration", config, allowedKeys.config);
130
- const kinds = new Set(Object.keys(config.kinds));
131
- for (const [name, kind] of Object.entries(config.kinds)) {
132
- problems.push(...unknownKeys(`kind ${name}`, kind, allowedKeys.kind));
133
- if (!Array.isArray(kind.packages) || kind.packages.length === 0) problems.push(`kind ${name} names no packages`);
134
- if (kind.mayUse !== "any" && !isStringList(kind.mayUse)) problems.push(`kind ${name}: mayUse must be "any" or a list of kinds`);
135
- if (kind.runtimeModules !== void 0 && typeof kind.runtimeModules !== "boolean") problems.push(`kind ${name}: runtimeModules must be true or false`);
136
- if (kind.externals !== void 0 && kind.externals !== "any" && !isStringList(kind.externals)) problems.push(`kind ${name}: externals must be "any" or a list of package names`);
137
- if (kind.mayUse !== "any") {
138
- for (const used of Array.isArray(kind.mayUse) ? kind.mayUse : []) if (!kinds.has(used)) problems.push(`kind ${name} may use ${used}, which is not a kind`);
139
- }
140
- }
141
- for (const [name, refinements] of Object.entries(config.packages ?? {})) {
142
- problems.push(...unknownKeys(`package ${name}`, refinements, allowedKeys.package));
143
- for (const [key, value] of Object.entries(refinements)) if (!isStringList(value)) problems.push(`package ${name}: ${key} must be a list of strings`);
144
- }
145
- if (!isStringList(config.workspaceScopes)) problems.push("workspaceScopes must be a list of scopes");
146
- for (const key of [
147
- "testOnlySpecifiers",
148
- "virtualModules",
149
- "ignore",
150
- "output"
151
- ]) {
152
- const value = config[key];
153
- if (value !== void 0 && !isStringList(value)) problems.push(`${key} must be a list of strings`);
154
- }
155
- for (const [key, value] of Object.entries(config.runtimeModules ?? {})) if (!isStringList(value)) problems.push(`runtimeModules.${key} must be a list of strings`);
156
- for (const key of ["manifestEdgeFields", "shippedFields"]) {
157
- const value = config.imports?.[key];
158
- if (value !== void 0 && (!isStringList(value) || !value.every((field) => allManifestFields.includes(field)))) problems.push(`imports.${key} must be a list of ${allManifestFields.join(", ")}`);
159
- }
160
- for (const [index, fileClass] of (config.fileClasses ?? []).entries()) {
161
- problems.push(...unknownKeys(`file class ${index}`, fileClass, allowedKeys.fileClass));
162
- if (!isStringList(fileClass.files)) problems.push(`file class ${index}: files must be a list of globs`);
163
- if (![
164
- "shipped",
165
- "development",
166
- "unchecked"
167
- ].includes(fileClass.policy)) problems.push(`file class ${index} has the unknown policy ${String(fileClass.policy)}`);
168
- }
169
- if (config.runtimeModules !== void 0) problems.push(...unknownKeys("runtimeModules", config.runtimeModules, allowedKeys.runtimeModules));
170
- if (config.imports !== void 0) {
171
- problems.push(...unknownKeys("imports", config.imports, allowedKeys.imports));
172
- for (const [key, allowed] of Object.entries(choices)) {
173
- const value = config.imports[key];
174
- if (value !== void 0 && !allowed.includes(value)) problems.push(`imports.${key} is ${JSON.stringify(value)}; it must be one of ${allowed.join(", ")}`);
175
- }
288
+ const packageProblems = (path, unknownKey, holder) => {
289
+ const index = Number(path[1]?.key);
290
+ const { name, value } = entryAt(path, 1);
291
+ const listMessage = (key) => `package ${name}: ${key} must be a list of strings`;
292
+ if (unknownKey) return [...unknownKeyProblems(`package ${name}`, holder, packageRuleSchema.entries, [
293
+ section.packages,
294
+ index,
295
+ 0
296
+ ]), ...unknownListProblems(holder, packageRuleSchema.entries, [
297
+ section.packages,
298
+ index,
299
+ 1
300
+ ], listMessage)];
301
+ const field = String(path[3]?.key);
302
+ return [{
303
+ order: [
304
+ section.packages,
305
+ index,
306
+ 1,
307
+ Object.keys(value).indexOf(field)
308
+ ],
309
+ message: listMessage(field)
310
+ }];
311
+ };
312
+ const runtimeModulesProblems = (path, unknownKey, holder) => {
313
+ const listMessage = (key) => `runtimeModules.${key} must be a list of strings`;
314
+ if (unknownKey) return [...unknownKeyProblems("runtimeModules", holder, runtimeModulesSchema.entries, [section.runtimeModuleKeys]), ...unknownListProblems(holder, runtimeModulesSchema.entries, [section.runtimeModuleValues], listMessage)];
315
+ const field = String(path[1]?.key);
316
+ return [{
317
+ order: [section.runtimeModuleValues, Object.keys(objectAt(path, 0)).indexOf(field)],
318
+ message: listMessage(field)
319
+ }];
320
+ };
321
+ const importProblems = (path, unknownKey, holder) => {
322
+ if (unknownKey) return unknownKeyProblems("imports", holder, importPolicySchema.entries, [section.importKeys]);
323
+ const field = String(path[1]?.key);
324
+ if (importFieldOrder.includes(field)) return [{
325
+ order: [section.importFields, importFieldOrder.indexOf(field)],
326
+ message: `imports.${field} must be a list of ${allManifestFields.join(", ")}`
327
+ }];
328
+ return [{
329
+ order: [section.importPolicies, importPolicyOrder.indexOf(field)],
330
+ message: `imports.${field} is ${JSON.stringify(path[1]?.value)}; it must be one of ${policyValues(field).join(", ")}`
331
+ }];
332
+ };
333
+ const fileClassProblems = (path, unknownKey, holder) => {
334
+ const index = Number(path[1]?.key);
335
+ if (unknownKey) return unknownKeyProblems(`file class ${index}`, holder, fileClassSchema.entries, [
336
+ section.fileClasses,
337
+ index,
338
+ 0
339
+ ]);
340
+ return path[2]?.key === "files" ? [{
341
+ order: [
342
+ section.fileClasses,
343
+ index,
344
+ 1
345
+ ],
346
+ message: `file class ${index}: files must be a list of globs`
347
+ }] : [{
348
+ order: [
349
+ section.fileClasses,
350
+ index,
351
+ 2
352
+ ],
353
+ message: `file class ${index} has the unknown policy ${String(objectAt(path, 1).policy)}`
354
+ }];
355
+ };
356
+ const baselineProblems = (path, unknownKey, holder) => {
357
+ const index = Number(path[1]?.key);
358
+ const entry = objectAt(path, 1);
359
+ const file = asText(entry.file);
360
+ if (unknownKey) return unknownKeyProblems(`the baseline entry for ${file}`, holder, baselineEntrySchema.entries, [
361
+ section.baseline,
362
+ index,
363
+ 0
364
+ ]);
365
+ const field = String(path[2]?.key);
366
+ const message = {
367
+ rule: () => `the baseline entry for ${file} names the unknown rule ${String(entry.rule)}`,
368
+ target: () => `the baseline entry for ${asText(entry.rule)} in ${file} names no target, so it would hide every future finding of that rule there`,
369
+ reason: () => `the baseline entry for ${asText(entry.rule)} in ${file} has no reason`
370
+ }[field]?.();
371
+ return message === void 0 ? [] : [{
372
+ order: [
373
+ section.baseline,
374
+ index,
375
+ baselineFieldOrder.indexOf(field)
376
+ ],
377
+ message
378
+ }];
379
+ };
380
+ /** The messages for one issue of `configSchema`. */
381
+ const problemsOf = (issue) => {
382
+ const path = issue.path ?? [];
383
+ const unknownKey = issue.type === "strict_object" && issue.expected === "never";
384
+ const at = unknownKey ? path.slice(0, -1) : path;
385
+ const holder = unknownKey ? path.at(-1)?.input : void 0;
386
+ const object = isPlainObject(holder) ? holder : {};
387
+ const field = at[0]?.key;
388
+ switch (field) {
389
+ case void 0: return unknownKey ? unknownKeyProblems("the configuration", object, configSchema.entries, [section.configurationKeys]) : [];
390
+ case "kinds": return kindProblems(path, unknownKey, object);
391
+ case "packages": return packageProblems(path, unknownKey, object);
392
+ case "runtimeModules": return runtimeModulesProblems(path, unknownKey, object);
393
+ case "imports": return importProblems(path, unknownKey, object);
394
+ case "fileClasses": return fileClassProblems(path, unknownKey, object);
395
+ case "baseline": return baselineProblems(path, unknownKey, object);
396
+ case "workspaceScopes": return [{
397
+ order: [section.workspaceScopes],
398
+ message: "workspaceScopes must be a list of scopes"
399
+ }];
400
+ case "testOnlySpecifiers":
401
+ case "virtualModules":
402
+ case "ignore":
403
+ case "output": return [{
404
+ order: [section[field]],
405
+ message: `${field} must be a list of strings`
406
+ }];
407
+ default: return [];
176
408
  }
177
- for (const entry of config.baseline ?? []) {
178
- problems.push(...unknownKeys(`the baseline entry for ${entry.file}`, entry, allowedKeys.baseline));
179
- if (!ruleNames.includes(entry.rule)) problems.push(`the baseline entry for ${entry.file} names the unknown rule ${String(entry.rule)}`);
180
- if (typeof entry.target !== "string" || entry.target === "") problems.push(`the baseline entry for ${entry.rule} in ${entry.file} names no target, so it would hide every future finding of that rule there`);
181
- if (typeof entry.reason !== "string" || entry.reason.trim() === "") problems.push(`the baseline entry for ${entry.rule} in ${entry.file} has no reason`);
409
+ };
410
+ const compareOrders = (left, right) => {
411
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
412
+ const difference = (left[index] ?? -1) - (right[index] ?? -1);
413
+ if (difference !== 0) return difference;
182
414
  }
183
- return problems;
415
+ return 0;
416
+ };
417
+ /** Problems that make a configuration unusable, as messages: the outline's alone when it does not hold, else every other. */
418
+ const configProblems = (config) => {
419
+ const outline = v.safeParse(outlineSchema, config);
420
+ if (!outline.success) return [...new Set(outline.issues.map((issue) => outlineMessage(issue.path ?? [])))];
421
+ const sparse = ["fileClasses", "baseline"].filter((key) => [...config[key] ?? []].includes(void 0));
422
+ if (sparse.length > 0) return sparse.map((key) => `${key} must be a list of objects`);
423
+ for (const entry of config.baseline ?? []) asText(entry.file);
424
+ const problems = [...(v.safeParse(configSchema, config).issues ?? []).flatMap(problemsOf), ...kindReferenceProblems(config.kinds)].toSorted((left, right) => compareOrders(left.order, right.order));
425
+ const seen = /* @__PURE__ */ new Set();
426
+ return problems.flatMap((problem) => {
427
+ const key = problem.order.join(" ");
428
+ if (seen.has(key)) return [];
429
+ seen.add(key);
430
+ return [problem.message];
431
+ });
184
432
  };
185
433
  const resolveConfig = (config) => {
186
434
  const problems = configProblems(config);
@@ -270,20 +518,38 @@ const globExpression = (pattern) => {
270
518
  const matchesGlob = (path, pattern) => globExpression(pattern).test(path);
271
519
  /** Whether `path` matches any of `patterns`. */
272
520
  const matchesAny = (path, patterns) => patterns.some((pattern) => matchesGlob(path, pattern));
521
+ // src/result.ts
522
+ const success = (value) => ({
523
+ ok: true,
524
+ value
525
+ });
526
+ const failure = (error) => ({
527
+ ok: false,
528
+ error
529
+ });
530
+ /** Runs `operation`, with what it throws as the failure. */
531
+ const attempt = (operation) => {
532
+ try {
533
+ return success(operation());
534
+ } catch (cause) {
535
+ return failure(cause);
536
+ }
537
+ };
538
+ /** The message of something thrown, which need not be an Error. */
539
+ const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
540
+ /** The error code of something thrown by `node:fs` (`EACCES`), or its message when it has none. */
541
+ const codeOf = (cause) => typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string" ? cause.code : messageOf(cause);
273
542
  // src/workspace.ts
274
543
  /**
275
544
  * The workspace as `package.json` files declare it: the root's `workspaces` globs, and each package's real name (never
276
545
  * guessed from its directory) and dependency fields.
277
546
  */
547
+ /** The manifest at `path`, or why it cannot be read. */
278
548
  const readManifest = (path) => {
279
- let parsed;
280
- try {
281
- parsed = JSON.parse(readFileSync(path, "utf8"));
282
- } catch (cause) {
283
- throw new Error(`${path}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
284
- }
285
- if (typeof parsed !== "object" || parsed === null) throw new Error(`${path} is not a JSON object`);
286
- return parsed;
549
+ const parsed = attempt(() => JSON.parse(readFileSync(path, "utf8")));
550
+ if (!parsed.ok) return failure(`${path}: ${messageOf(parsed.error)}`);
551
+ if (typeof parsed.value !== "object" || parsed.value === null) return failure(`${path} is not a JSON object`);
552
+ return success(parsed.value);
287
553
  };
288
554
  const namesOf = (field) => typeof field === "object" && field !== null && !Array.isArray(field) ? Object.keys(field).toSorted() : [];
289
555
  const fieldsOf = (manifest) => ({
@@ -311,12 +577,9 @@ const workspaceGlobs = (manifest) => {
311
577
  if (Array.isArray(field)) return field.filter((entry) => typeof entry === "string");
312
578
  if (typeof field === "object" && field !== null && "packages" in field && Array.isArray(field.packages)) return field.packages.filter((entry) => typeof entry === "string");
313
579
  };
314
- const workspacesOf = (manifest) => {
315
- try {
316
- return workspaceGlobs(readManifest(manifest)) ?? [];
317
- } catch {
318
- return [];
319
- }
580
+ const workspacesOf = (path) => {
581
+ const manifest = readManifest(path);
582
+ return manifest.ok ? workspaceGlobs(manifest.value) ?? [] : [];
320
583
  };
321
584
  /** Whether `globs` (from the manifest in `candidate`) hold `directory`: one of them matches it or a directory above it. */
322
585
  const holds = (candidate, globs, directory) => {
@@ -345,7 +608,21 @@ const findWorkspaceRoot = (start) => {
345
608
  if (nodePath.dirname(candidate) === candidate) return root ?? nearest;
346
609
  }
347
610
  };
348
- const directoriesMatching = (root, pattern) => {
611
+ /** The first of `path`'s directories (repository-relative), itself included, that is a symbolic link. */
612
+ const linkAlong = (root, path) => {
613
+ const parts = path === "" ? [] : path.split("/");
614
+ return parts.map((_, index) => parts.slice(0, index + 1).join("/")).find((prefix) => {
615
+ const status = attempt(() => lstatSync(nodePath.join(root, prefix)));
616
+ return status.ok && status.value.isSymbolicLink();
617
+ });
618
+ };
619
+ /**
620
+ * The directories under `root` that `pattern` matches, skipping `node_modules` and dot directories, as npm and Bun
621
+ * workspaces do. A symbolic link is never followed, not even one the pattern names literally or passes through (a
622
+ * `packages` that is a link), and a directory that cannot be listed is passed over; with `unlisted`, those and the dot
623
+ * directories the pattern would match are collected there, so the caller can report them.
624
+ */
625
+ const directoriesMatching = (root, pattern, unlisted) => {
349
626
  const clean = (pattern.startsWith("!") ? pattern.slice(1) : pattern).replace(/^\.\//u, "").replace(/\/$/u, "");
350
627
  const fixed = [];
351
628
  for (const segment of clean.split("/")) {
@@ -358,15 +635,34 @@ const directoriesMatching = (root, pattern) => {
358
635
  const walk = (relative, depth) => {
359
636
  if (matchesGlob(relative, clean)) found.push(relative);
360
637
  if (depth >= depthLimit) return;
361
- const absolute = nodePath.join(root, relative);
362
- for (const entry of readdirSync(absolute, { withFileTypes: true })) if (entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith(".")) walk(relative === "" ? entry.name : `${relative}/${entry.name}`, depth + 1);
638
+ const listed = attempt(() => readdirSync(nodePath.join(root, relative), { withFileTypes: true }));
639
+ if (!listed.ok) {
640
+ unlisted?.unreadable.push({
641
+ directory: relative,
642
+ code: codeOf(listed.error)
643
+ });
644
+ return;
645
+ }
646
+ for (const entry of listed.value) {
647
+ const path = relative === "" ? entry.name : `${relative}/${entry.name}`;
648
+ if (entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith(".")) walk(path, depth + 1);
649
+ else if (entry.isDirectory() && entry.name.startsWith(".") && entry.name !== ".git" && matchesGlob(path, clean)) unlisted?.dotDirectories.push(path);
650
+ else if (entry.isSymbolicLink() && matchesGlob(path, clean)) unlisted?.links.push(path);
651
+ }
363
652
  };
653
+ const link = linkAlong(root, base);
654
+ if (link !== void 0) {
655
+ unlisted?.links.push(link);
656
+ return [];
657
+ }
364
658
  if (base === clean) return existsSync(nodePath.join(root, base)) ? [base] : [];
365
659
  if (existsSync(nodePath.join(root, base)) && statSync(nodePath.join(root, base)).isDirectory()) walk(base, base === "" ? 0 : base.split("/").length);
366
660
  return found;
367
661
  };
368
662
  const loadWorkspace = (root) => {
369
- const rootManifest = readManifest(nodePath.join(root, "package.json"));
663
+ const rootRead = readManifest(nodePath.join(root, "package.json"));
664
+ if (!rootRead.ok) throw new Error(rootRead.error);
665
+ const rootManifest = rootRead.value;
370
666
  const globs = workspaceGlobs(rootManifest) ?? [];
371
667
  const directories = /* @__PURE__ */ new Set();
372
668
  for (const pattern of globs.filter((glob) => !glob.startsWith("!"))) for (const directory of directoriesMatching(root, pattern)) if (existsSync(nodePath.join(root, directory, "package.json"))) directories.add(directory);
@@ -375,16 +671,15 @@ const loadWorkspace = (root) => {
375
671
  const problems = [];
376
672
  const byName = /* @__PURE__ */ new Map();
377
673
  for (const directory of [...directories].toSorted()) {
378
- let manifest;
379
- try {
380
- manifest = readManifest(nodePath.join(root, directory, "package.json"));
381
- } catch (error) {
674
+ const read = readManifest(nodePath.join(root, directory, "package.json"));
675
+ if (!read.ok) {
382
676
  problems.push({
383
677
  directory,
384
- message: `package.json cannot be read: ${error instanceof Error ? error.message : String(error)}`
678
+ message: `package.json cannot be read: ${read.error}`
385
679
  });
386
680
  continue;
387
681
  }
682
+ const manifest = read.value;
388
683
  if (typeof manifest.name !== "string" || manifest.name === "") {
389
684
  problems.push({
390
685
  directory,
@@ -414,7 +709,8 @@ const loadWorkspace = (root) => {
414
709
  packages,
415
710
  byName,
416
711
  rootManifest: fieldsOf(rootManifest),
417
- problems
712
+ problems,
713
+ globs
418
714
  };
419
715
  };
420
716
  /** The package whose directory holds the repository-relative `file`, the deepest one when packages nest. */
@@ -875,6 +1171,99 @@ const reads = (engine, file) => {
875
1171
  const owner = packageOfFile(engine.workspace, file);
876
1172
  return owner !== void 0 && !matchesAny(file.slice(owner.directory.length + 1), engine.config.output);
877
1173
  };
1174
+ /** Whether `glob` matches every path under `directory`: it is `**`, or it or a directory above it matches what comes before a trailing `/**`. */
1175
+ const coversDirectory = (glob, directory) => {
1176
+ if (glob === "**") return true;
1177
+ if (!glob.endsWith("/**") || directory === "") return false;
1178
+ const parts = directory.split("/");
1179
+ return parts.some((_, index) => matchesGlob(parts.slice(0, index + 1).join("/"), glob.slice(0, -3)));
1180
+ };
1181
+ /**
1182
+ * Whether `glob` might match a path under `directory`: its leading segments agree with the directory's until a `**`,
1183
+ * and it is longer than the directory. A glob with braces is taken to match, so a doubt counts as a match.
1184
+ */
1185
+ const mayMatchUnder = (glob, directory) => {
1186
+ if (glob.includes("{")) return true;
1187
+ const globParts = glob.split("/");
1188
+ const directoryParts = directory === "" ? [] : directory.split("/");
1189
+ for (const [index, part] of directoryParts.entries()) {
1190
+ const globPart = globParts[index];
1191
+ if (globPart === void 0) return false;
1192
+ if (globPart === "**") return true;
1193
+ if (!matchesGlob(part, globPart)) return false;
1194
+ }
1195
+ return globParts.length > directoryParts.length;
1196
+ };
1197
+ /** Whether an `ignore` glob covers every file under the repository-relative `directory`. */
1198
+ const ignoresDirectory = (engine, directory) => engine.config.ignore.some((glob) => coversDirectory(glob, directory));
1199
+ /**
1200
+ * Whether the check reads no file under the repository-relative `directory`, so a symbolic link or an unreadable
1201
+ * directory there hides nothing: an `ignore` glob covers it, or (relative to the package that owns it) an `output` glob
1202
+ * or an unchecked file class does, with no earlier class that might match a file in it. A glob covers a directory only
1203
+ * through a trailing `/**`; any doubt counts as read, so the answer errs towards a finding.
1204
+ */
1205
+ const skipsDirectory = (engine, directory) => {
1206
+ const { config } = engine;
1207
+ if (ignoresDirectory(engine, directory)) return true;
1208
+ const owner = packageOfFile(engine.workspace, `${directory}/`);
1209
+ if (owner === void 0) return true;
1210
+ const relative = directory.slice(owner.directory.length + 1);
1211
+ if (config.output.some((glob) => coversDirectory(glob, relative))) return true;
1212
+ for (const fileClass of config.fileClasses) {
1213
+ if (fileClass.files.some((glob) => coversDirectory(glob, relative))) return fileClass.policy === "unchecked";
1214
+ if (fileClass.policy !== "unchecked" && fileClass.files.some((glob) => mayMatchUnder(glob, relative))) return false;
1215
+ }
1216
+ return false;
1217
+ };
1218
+ /** Directories the walk never enters, wherever they are: dependencies and git's own data. */
1219
+ const passedOver = (name) => name === "node_modules" || name === ".git";
1220
+ /**
1221
+ * Whether a symbolic link at the repository-relative `path` is a finding: always, whatever it points at, unless it lies
1222
+ * in (or is) a directory the check skips on purpose (`node_modules`, `.git`, or one `skipsDirectory` covers).
1223
+ */
1224
+ const reportsLink = (engine, path) => !path.split("/").some(passedOver) && !skipsDirectory(engine, path);
1225
+ /** A symbolic link the check met and did not follow, at `path` (repository-relative), with the destination it names. */
1226
+ const symbolicLinkFinding = (path, destination) => ({
1227
+ rule: "symbolic-link",
1228
+ file: path,
1229
+ line: 1,
1230
+ column: 0,
1231
+ message: `${path} is a symbolic link to ${destination}, which the check never follows, so what it reaches goes unchecked: replace it with the files themselves, or baseline it`,
1232
+ target: destination
1233
+ });
1234
+ /** A directory the check needed to list, or a source file it needed to read, and could not, with the error code that said why. */
1235
+ const unreadablePathFinding = (path, code, kind = "directory") => ({
1236
+ rule: "unreadable-path",
1237
+ file: path,
1238
+ line: 1,
1239
+ column: 0,
1240
+ message: kind === "directory" ? `${path} cannot be read (${code}), so the files in it cannot be checked: make it readable, or add ${path}/** to ignore` : `${path} cannot be read (${code}), so its imports cannot be checked: make it readable, or add it to ignore`,
1241
+ target: path
1242
+ });
1243
+ /** The destination a symbolic link names, as written. */
1244
+ const linkDestination = (absolute) => {
1245
+ try {
1246
+ return readlinkSync(absolute);
1247
+ } catch {
1248
+ return "an unreadable destination";
1249
+ }
1250
+ };
1251
+ /**
1252
+ * The first symbolic link on the way from `owner`'s directory down to `file` (both repository-relative), the file
1253
+ * itself included. The CLI's walk never follows one, but oxlint does, and lints the files behind it under the link's
1254
+ * path.
1255
+ */
1256
+ const linkOnPath = (engine, owner, file) => {
1257
+ const parts = file.slice(owner.directory.length + 1).split("/");
1258
+ for (const [index] of parts.entries()) {
1259
+ const path = [owner.directory, ...parts.slice(0, index + 1)].join("/");
1260
+ try {
1261
+ if (lstatSync(nodePath.join(engine.workspace.root, path)).isSymbolicLink()) return path;
1262
+ } catch {
1263
+ return;
1264
+ }
1265
+ }
1266
+ };
878
1267
  /** The rules that hold for shipped code and not for development files. */
879
1268
  const shippedOnlyRules = /* @__PURE__ */ new Set([
880
1269
  "runtime-module",
@@ -903,9 +1292,11 @@ const developmentFileBreaks = (engine, file) => {
903
1292
  if (known !== void 0 && [...known.read].every(([path, time]) => modifiedTime(nodePath.join(engine.workspace.root, path)) === time)) return known.breaks;
904
1293
  const read = /* @__PURE__ */ new Map();
905
1294
  const queue = [file];
1295
+ let head = 0;
906
1296
  let breaks;
907
- while (queue.length > 0 && breaks === void 0) {
908
- const next = queue.shift() ?? file;
1297
+ while (head < queue.length && breaks === void 0) {
1298
+ const next = queue[head] ?? file;
1299
+ head += 1;
909
1300
  const absolute = nodePath.join(engine.workspace.root, next);
910
1301
  const time = modifiedTime(absolute);
911
1302
  if (read.has(next) || time === void 0) continue;
@@ -913,7 +1304,12 @@ const developmentFileBreaks = (engine, file) => {
913
1304
  const directory = nodePath.dirname(next);
914
1305
  read.set(directory, modifiedTime(nodePath.join(engine.workspace.root, directory)) ?? -1);
915
1306
  const reached = [];
916
- const finding = findingsForFile(engine, next, readFileSync(absolute, "utf8"), { developmentImports: reached }).find((candidate) => shippedOnlyRules.has(candidate.rule));
1307
+ const text = attempt(() => readFileSync(absolute, "utf8"));
1308
+ if (!text.ok) {
1309
+ breaks = `${next} cannot be read (${codeOf(text.error)}), so it cannot be judged`;
1310
+ break;
1311
+ }
1312
+ const finding = findingsForFile(engine, next, text.value, { developmentImports: reached }).find((candidate) => shippedOnlyRules.has(candidate.rule));
917
1313
  if (finding !== void 0) breaks = `${next === file ? "" : `through ${next}, `}${finding.rule} at line ${finding.line}: ${finding.message}`;
918
1314
  queue.push(...reached);
919
1315
  }
@@ -933,7 +1329,10 @@ const findingsForFile = (engine, file, text, options = {}) => {
933
1329
  const asShipped = developmentImports !== void 0;
934
1330
  const { config, workspace } = engine;
935
1331
  const owner = packageOfFile(workspace, file);
936
- if (owner === void 0 || !sourceFile.test(file) || !reads(engine, file) && developmentImports === void 0) return [];
1332
+ if (owner === void 0 || !sourceFile.test(file)) return [];
1333
+ const link = linkOnPath(engine, owner, file);
1334
+ if (link !== void 0) return reportsLink(engine, link) ? [symbolicLinkFinding(link, linkDestination(nodePath.join(workspace.root, link)))] : [];
1335
+ if (!reads(engine, file) && developmentImports === void 0) return [];
937
1336
  const packageRelative = file.slice(owner.directory.length + 1);
938
1337
  const classified = effectivePolicy(engine, owner, packageRelative);
939
1338
  const policy = asShipped ? "shipped" : classified;
@@ -1064,4 +1463,4 @@ const findingsForFile = (engine, file, text, options = {}) => {
1064
1463
  const baselineCovers = (entry, finding) => entry.file === finding.file && entry.rule === finding.rule && entry.target === finding.target;
1065
1464
  /** `findings` less those the baseline covers. */
1066
1465
  const withoutBaselined = (engine, findings) => findings.filter((finding) => !engine.config.baseline.some((entry) => baselineCovers(entry, finding)));
1067
- export { configProblems as _, findingsForFile as a, resolveConfig as b, withoutBaselined as c, findWorkspaceRoot as d, loadWorkspace as f, matchesGlob as g, matchesAny as h, effectivePolicy as i, importForms as l, packageOfFile as m, createEngine as n, policyOf as o, packageNameOf as p, edgeRefusal as r, reads as s, baselineCovers as t, directoriesMatching as u, defaultFileClasses as v, defineConfig as y };
1466
+ export { defineConfig as A, attempt as C, matchesGlob as D, matchesAny as E, configProblems as O, packageOfFile as S, failure as T, importForms as _, effectivePolicy as a, loadWorkspace as b, linkDestination as c, reads as d, reportsLink as f, withoutBaselined as g, unreadablePathFinding as h, edgeRefusal as i, resolveConfig as j, defaultFileClasses as k, passedOver as l, symbolicLinkFinding as m, coversDirectory as n, findingsForFile as o, skipsDirectory as p, createEngine as r, ignoresDirectory as s, baselineCovers as t, policyOf as u, directoriesMatching as v, codeOf as w, packageNameOf as x, findWorkspaceRoot as y };