ox-dep-lint 0.2.0 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7af0fef: An internal refactor with no change in behaviour for configurations written as plain data (objects, arrays and strings, without getters, Proxies or custom iterators): every finding and every message is the same as in 0.2.0, in the same order, and so is every configuration error. Output differs only where 0.2.0 crashed: a hole in a sparse `fileClasses` or `baseline` list (`[ , ]` in an `.mjs` file) is now reported as `fileClasses must be a list of objects` or `baseline must be a list of objects`, also when a Symbol elsewhere would have crashed first; and a chain of thousands of packages, which exhausted 0.2.0's stack, is now checked. A Symbol written into a message (a baseline entry's file, a kind in `mayUse`) still crashes (exit 2) when no such hole is present, though the TypeError's wording can differ when another value that cannot become a string (an object without a usable `toString`) is also present, because the checks run in another order. A configuration built from getters, Proxies or custom iterators can now be read differently, because validation reads values (including those of unknown keys) that 0.2.0 never read.
8
+
9
+ The configuration is validated by valibot schemas whose issues map to 0.2's messages, cycles between packages come from Tarjan's strongly connected components (iterative, so a long chain of packages cannot exhaust the stack) with the same routes as before, and the walk through development files reads its queue from a head index instead of `shift()`.
10
+
11
+ New runtime dependency: `valibot` 1.5.0 (MIT). The public types are unchanged and written out as before; the emitted declarations do not import valibot.
12
+
3
13
  ## 0.2.0
4
14
 
5
15
  ### Minor Changes
@@ -1,4 +1,4 @@
1
- import { a as findingsForFile, c as withoutBaselined, n as createEngine, r as edgeRefusal, s as reads, t as baselineCovers, u as directoriesMatching } from "./engine-C1qSxYaL.mjs";
1
+ import { a as findingsForFile, c as withoutBaselined, n as createEngine, r as edgeRefusal, s as reads, t as baselineCovers, u as directoriesMatching } from "./engine-Dc5tRuHT.mjs";
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import nodePath from "node:path";
4
4
  // src/graph.ts
@@ -87,37 +87,92 @@ const graphFindings = (engine) => {
87
87
  findings.push(...packageCycles(engine));
88
88
  return findings;
89
89
  };
90
- /** Cycles between workspace packages through their runtime dependency fields, each reported once. */
90
+ /**
91
+ * Tarjan's strongly connected components, iterative so that a long chain of packages cannot exhaust the stack. The
92
+ * search starts from `roots` in order and follows each node's `successors` in order, so components and routes are
93
+ * deterministic.
94
+ */
95
+ const stronglyConnectedComponents = (roots, successors) => {
96
+ const index = /* @__PURE__ */ new Map();
97
+ const lowLink = /* @__PURE__ */ new Map();
98
+ const stack = [];
99
+ const onStack = /* @__PURE__ */ new Set();
100
+ const frames = [];
101
+ const pathPosition = /* @__PURE__ */ new Map();
102
+ const components = [];
103
+ const routes = [];
104
+ const enter = (name) => {
105
+ const order = index.size;
106
+ index.set(name, order);
107
+ lowLink.set(name, order);
108
+ stack.push(name);
109
+ onStack.add(name);
110
+ pathPosition.set(name, frames.length);
111
+ frames.push({
112
+ name,
113
+ next: 0
114
+ });
115
+ };
116
+ for (const root of roots) {
117
+ if (index.has(root)) continue;
118
+ enter(root);
119
+ for (let frame = frames.at(-1); frame !== void 0; frame = frames.at(-1)) {
120
+ const target = (successors.get(frame.name) ?? [])[frame.next];
121
+ if (target !== void 0) {
122
+ frame.next += 1;
123
+ if (!index.has(target)) enter(target);
124
+ else if (onStack.has(target)) {
125
+ lowLink.set(frame.name, Math.min(lowLink.get(frame.name) ?? 0, index.get(target) ?? 0));
126
+ const position = pathPosition.get(target);
127
+ if (position !== void 0) routes.push([...frames.slice(position).map((onPath) => onPath.name), target]);
128
+ }
129
+ continue;
130
+ }
131
+ frames.pop();
132
+ pathPosition.delete(frame.name);
133
+ const link = lowLink.get(frame.name) ?? 0;
134
+ if (link === index.get(frame.name)) {
135
+ const component = [];
136
+ for (let member = stack.pop(); member !== void 0; member = member === frame.name ? void 0 : stack.pop()) {
137
+ onStack.delete(member);
138
+ component.push(member);
139
+ }
140
+ components.push(component);
141
+ }
142
+ const parent = frames.at(-1);
143
+ if (parent !== void 0) lowLink.set(parent.name, Math.min(lowLink.get(parent.name) ?? 0, link));
144
+ }
145
+ }
146
+ return {
147
+ components,
148
+ routes
149
+ };
150
+ };
151
+ /**
152
+ * Cycles between workspace packages through their runtime dependency fields. Every route the search closes is a
153
+ * finding, once per set of packages, starting at the package the search reached first; so a component with several
154
+ * cycles can have several findings.
155
+ */
91
156
  const packageCycles = (engine) => {
92
157
  const { workspace } = engine;
93
158
  const edges = new Map(workspace.packages.map((entry) => [entry.name, [...new Set(runtimeFields.flatMap((field) => entry.manifest[field]))].filter((name) => workspace.byName.has(name) && name !== entry.name)]));
94
- const state = /* @__PURE__ */ new Map();
95
159
  const findings = [];
96
160
  const seen = /* @__PURE__ */ new Set();
97
- const visit = (name, path) => {
98
- if (state.get(name) === "done") return;
99
- if (state.get(name) === "visiting") {
100
- const cycle = [...path.slice(path.indexOf(name)), name];
101
- const key = cycle.slice(0, -1).toSorted().join(" ");
102
- if (!seen.has(key)) {
103
- seen.add(key);
104
- const first = workspace.byName.get(name);
105
- findings.push({
106
- rule: "package-cycle",
107
- file: `${first?.directory ?? name}/package.json`,
108
- line: 1,
109
- column: 0,
110
- message: `dependency cycle: ${cycle.join(" -> ")}`,
111
- target: key
112
- });
113
- }
114
- return;
161
+ for (const cycle of stronglyConnectedComponents([...edges.keys()].toSorted(), edges).routes) {
162
+ const key = cycle.slice(0, -1).toSorted().join(" ");
163
+ if (!seen.has(key)) {
164
+ seen.add(key);
165
+ const first = cycle[0] ?? "";
166
+ findings.push({
167
+ rule: "package-cycle",
168
+ file: `${workspace.byName.get(first)?.directory ?? first}/package.json`,
169
+ line: 1,
170
+ column: 0,
171
+ message: `dependency cycle: ${cycle.join(" -> ")}`,
172
+ target: key
173
+ });
115
174
  }
116
- state.set(name, "visiting");
117
- for (const next of edges.get(name) ?? []) visit(next, [...path, name]);
118
- state.set(name, "done");
119
- };
120
- for (const name of [...edges.keys()].toSorted()) visit(name, []);
175
+ }
121
176
  return findings;
122
177
  };
123
178
  /** Baseline entries that no finding in `findings` needs; each is a finding of its own, so the baseline only shrinks. */
package/dist/cli.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { n as Config } from "./config-ClNl5rro.mjs";
1
+ import { n as Config } from "./config-DO_LRXM8.mjs";
2
2
  export declare const loadConfig: (path: string) => Promise<Config>;
package/dist/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { d as findWorkspaceRoot } from "./engine-C1qSxYaL.mjs";
3
- import { n as formatFinding, t as checkWorkspace } from "./check-BIxs2Gr0.mjs";
2
+ import { d as findWorkspaceRoot } from "./engine-Dc5tRuHT.mjs";
3
+ import { n as formatFinding, t as checkWorkspace } from "./check-F4cLmfYv.mjs";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import nodePath from "node:path";
6
6
  import process from "node:process";
@@ -113,6 +113,7 @@ interface ResolvedConfig {
113
113
  declare const defaultFileClasses: readonly FileClass[];
114
114
  /** Identity, for type checking a configuration file. */
115
115
  declare const defineConfig: (config: Config) => Config;
116
+ /** Problems that make a configuration unusable, as messages: the outline's alone when it does not hold, else every other. */
116
117
  declare const configProblems: (config: Config) => string[];
117
118
  declare const resolveConfig: (config: Config) => ResolvedConfig;
118
119
  export { ImportPolicy as a, PackageRule as c, configProblems as d, defaultFileClasses as f, FilePolicy as i, ResolvedConfig as l, resolveConfig as m, Config as n, KindRule as o, defineConfig as p, FileClass as r, ManifestField as s, BaselineEntry as t, RuleName as u };
@@ -1,6 +1,7 @@
1
1
  import { builtinModules } from "node:module";
2
2
  import { existsSync, readFileSync, readdirSync, 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
@@ -50,137 +51,367 @@ const allManifestFields = [
50
51
  ];
51
52
  /** Identity, for type checking a configuration file. */
52
53
  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: [
54
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
55
+ const plainObject = v.custom(isPlainObject);
56
+ /** `schema` itself, which fails to type check unless it accepts exactly `Output`. */
57
+ const schemaOf = () => (schema) => schema;
58
+ const filePolicySchema = schemaOf()(v.picklist([
59
+ "shipped",
60
+ "development",
61
+ "unchecked"
62
+ ]));
63
+ const manifestFieldSchema = schemaOf()(v.picklist(allManifestFields));
64
+ const ruleNameSchema = schemaOf()(v.picklist(ruleNames));
65
+ /** The values each import policy may take. */
66
+ const policies = {
67
+ computed: schemaOf()(v.picklist([
100
68
  "error-in-shipped",
101
69
  "error",
102
70
  "allow"
103
- ],
104
- selfByName: ["error", "allow"],
105
- unresolved: ["error", "ignore"],
106
- requireDeclared: [true, false],
107
- typeOnly: ["relaxed", "as-values"]
71
+ ])),
72
+ selfByName: schemaOf()(v.picklist(["error", "allow"])),
73
+ unresolved: schemaOf()(v.picklist(["error", "ignore"])),
74
+ requireDeclared: schemaOf()(v.boolean()),
75
+ typeOnly: schemaOf()(v.picklist(["relaxed", "as-values"]))
108
76
  };
77
+ /** 0.2's test for a list of strings. `every` skips the holes of a sparse array, where `v.array` reads `undefined`. */
109
78
  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;
79
+ const stringList = v.custom(isStringList);
80
+ /** A list of manifest fields, holes skipped as in 0.2. */
81
+ const manifestFieldList = v.custom((value) => isStringList(value) && value.every((field) => v.is(manifestFieldSchema, field)));
82
+ /**
83
+ * A value in a message, written as 0.2 wrote it: a template literal, which throws for a Symbol. 0.2 threw that way
84
+ * (exit 2 with the TypeError's message), so the check still does.
85
+ */
86
+ const asText = (value) => `${value}`;
87
+ /**
88
+ * Every own entry of an object, each value checked by `value`. Not `v.record`, which skips the keys `constructor`,
89
+ * `prototype` and `__proto__` that 0.2 checked. In an issue's path, the entry is its index and then 1.
90
+ */
91
+ const ownEntries = (value) => v.pipe(plainObject, v.transform((input) => Object.entries(input)), v.array(v.tuple([v.string(), value])));
92
+ /** 0.2's outline test for a list of objects, which skips holes as `every` does. */
93
+ const objectList = v.custom((value) => Array.isArray(value) && value.every(isPlainObject));
94
+ /** The outline (objects where objects belong, lists where lists belong), checked first: nothing else is read until it holds. */
95
+ const outlineSchema = v.pipe(plainObject, v.looseObject({
96
+ kinds: ownEntries(plainObject),
97
+ packages: v.optional(ownEntries(plainObject)),
98
+ imports: v.optional(plainObject),
99
+ runtimeModules: v.optional(plainObject),
100
+ fileClasses: v.optional(objectList),
101
+ baseline: v.optional(objectList)
102
+ }));
103
+ const kindRuleSchema = v.strictObject({
104
+ packages: v.pipe(v.array(v.unknown()), v.minLength(1)),
105
+ mayUse: v.union([v.literal("any"), stringList]),
106
+ externals: v.optional(v.union([v.literal("any"), stringList])),
107
+ runtimeModules: v.optional(v.boolean())
108
+ });
109
+ const packageRuleSchema = v.strictObject({
110
+ uses: v.exactOptional(stringList),
111
+ externals: v.exactOptional(stringList),
112
+ typeOnlyUses: v.exactOptional(stringList),
113
+ valueSpecifiers: v.exactOptional(stringList),
114
+ testSupportFiles: v.exactOptional(stringList)
115
+ });
116
+ const fileClassSchema = v.strictObject({
117
+ files: stringList,
118
+ policy: filePolicySchema
119
+ });
120
+ const runtimeModulesSchema = v.strictObject({
121
+ prefixes: v.exactOptional(stringList),
122
+ names: v.exactOptional(stringList)
123
+ });
124
+ const importPolicySchema = v.strictObject({
125
+ computed: v.optional(policies.computed),
126
+ selfByName: v.optional(policies.selfByName),
127
+ unresolved: v.optional(policies.unresolved),
128
+ manifestEdgeFields: v.optional(manifestFieldList),
129
+ requireDeclared: v.optional(policies.requireDeclared),
130
+ shippedFields: v.optional(manifestFieldList),
131
+ typeOnly: v.optional(policies.typeOnly)
132
+ });
133
+ const baselineEntrySchema = v.strictObject({
134
+ file: v.optional(v.unknown()),
135
+ rule: ruleNameSchema,
136
+ target: v.pipe(v.string(), v.nonEmpty()),
137
+ reason: v.pipe(v.string(), v.check((reason) => reason.trim() !== ""))
138
+ });
139
+ const configSchema = v.strictObject({
140
+ workspaceScopes: stringList,
141
+ kinds: ownEntries(kindRuleSchema),
142
+ packages: v.optional(ownEntries(packageRuleSchema)),
143
+ fileClasses: v.optional(v.array(fileClassSchema)),
144
+ runtimeModules: v.optional(runtimeModulesSchema),
145
+ virtualModules: v.optional(stringList),
146
+ testOnlySpecifiers: v.optional(stringList),
147
+ imports: v.optional(importPolicySchema),
148
+ baseline: v.optional(v.array(baselineEntrySchema)),
149
+ ignore: v.optional(stringList),
150
+ output: v.optional(stringList)
151
+ });
152
+ /** The sections of the messages, in 0.2's order. */
153
+ const section = {
154
+ configurationKeys: 0,
155
+ kinds: 1,
156
+ packages: 2,
157
+ workspaceScopes: 3,
158
+ testOnlySpecifiers: 4,
159
+ virtualModules: 5,
160
+ ignore: 6,
161
+ output: 7,
162
+ runtimeModuleValues: 8,
163
+ importFields: 9,
164
+ fileClasses: 10,
165
+ runtimeModuleKeys: 11,
166
+ importKeys: 12,
167
+ importPolicies: 13,
168
+ baseline: 14
169
+ };
170
+ /** Within a kind: its unknown keys, then these fields, then the kinds it names that do not exist. */
171
+ const kindFieldOrder = [
172
+ "packages",
173
+ "mayUse",
174
+ "runtimeModules",
175
+ "externals"
176
+ ];
177
+ const importFieldOrder = ["manifestEdgeFields", "shippedFields"];
178
+ const importPolicyOrder = [
179
+ "computed",
180
+ "selfByName",
181
+ "unresolved",
182
+ "requireDeclared",
183
+ "typeOnly"
184
+ ];
185
+ const baselineFieldOrder = [
186
+ "file",
187
+ "rule",
188
+ "target",
189
+ "reason"
190
+ ];
191
+ /** The values a policy may take, as its message lists them. */
192
+ const policyValues = (field) => {
193
+ const schema = policies[field];
194
+ return "options" in schema ? schema.options : [true, false];
195
+ };
196
+ /** One message per key of `object` that `entries` does not know, in the object's order. */
197
+ const unknownKeyProblems = (where, object, entries, order) => Object.keys(object).flatMap((key, position) => Object.hasOwn(entries, key) ? [] : [{
198
+ order: [...order, position],
199
+ message: `${where} has the unknown key ${key} (known: ${Object.keys(entries).join(", ")})`
200
+ }]);
201
+ /** For an object whose every value is a list of strings: the unknown keys whose value is not one, in the object's order. */
202
+ const unknownListProblems = (object, entries, order, message) => Object.entries(object).flatMap(([key, value], position) => Object.hasOwn(entries, key) || v.is(stringList, value) ? [] : [{
203
+ order: [...order, position],
204
+ message: message(key)
205
+ }]);
206
+ /** The value an issue's path passes through at `index`, as an object (an entry of `ownEntries` is a pair). */
207
+ const objectAt = (path, index) => {
208
+ const value = path[index]?.value;
209
+ return isPlainObject(value) ? value : {};
210
+ };
211
+ /** The key and value of the `ownEntries` entry at `index` of an issue's path. */
212
+ const entryAt = (path, index) => {
213
+ const pair = path[index]?.value;
214
+ const [name, value] = Array.isArray(pair) ? pair : [];
215
+ return {
216
+ name: String(name),
217
+ value: isPlainObject(value) ? value : {}
218
+ };
219
+ };
220
+ const outlineMessage = (path) => {
221
+ const field = path[0]?.key;
222
+ if (field === void 0) return "the configuration must be an object";
223
+ if (field === "kinds") return path.length === 1 ? "kinds must map each kind to its rule" : `kind ${entryAt(path, 1).name} must be an object`;
224
+ if (field === "packages") return path.length === 1 ? "packages must map package names to their rules" : `package ${entryAt(path, 1).name} must be an object`;
225
+ if (field === "imports" || field === "runtimeModules") return `${field} must be an object`;
226
+ return `${field === "fileClasses" ? "fileClasses" : "baseline"} must be a list of objects`;
227
+ };
228
+ const kindProblems = (path, unknownKey, holder) => {
229
+ const index = Number(path[1]?.key);
230
+ const { name } = entryAt(path, 1);
231
+ if (unknownKey) return unknownKeyProblems(`kind ${name}`, holder, kindRuleSchema.entries, [
232
+ section.kinds,
233
+ index,
234
+ 0
235
+ ]);
236
+ const field = String(path[3]?.key);
237
+ const message = {
238
+ packages: `kind ${name} names no packages`,
239
+ mayUse: `kind ${name}: mayUse must be "any" or a list of kinds`,
240
+ runtimeModules: `kind ${name}: runtimeModules must be true or false`,
241
+ externals: `kind ${name}: externals must be "any" or a list of package names`
242
+ }[field];
243
+ return message === void 0 ? [] : [{
244
+ order: [
245
+ section.kinds,
246
+ index,
247
+ kindFieldOrder.indexOf(field) + 1
248
+ ],
249
+ message
250
+ }];
251
+ };
252
+ /** The kinds a kind may use that are not kinds: a reference across entries, which no schema of one entry sees. */
253
+ const kindReferenceProblems = (kinds) => {
254
+ const known = new Set(Object.keys(kinds));
117
255
  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`);
256
+ for (const [index, [name, kind]] of Object.entries(kinds).entries()) {
257
+ const mayUse = isPlainObject(kind) ? kind.mayUse : void 0;
258
+ if (!Array.isArray(mayUse)) continue;
259
+ for (const [position, used] of [...mayUse].entries()) if (typeof used !== "string" || !known.has(used)) problems.push({
260
+ order: [
261
+ section.kinds,
262
+ index,
263
+ kindFieldOrder.length + 1,
264
+ position
265
+ ],
266
+ message: `kind ${name} may use ${asText(used)}, which is not a kind`
267
+ });
268
+ }
124
269
  return problems;
125
270
  };
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
- }
271
+ const packageProblems = (path, unknownKey, holder) => {
272
+ const index = Number(path[1]?.key);
273
+ const { name, value } = entryAt(path, 1);
274
+ const listMessage = (key) => `package ${name}: ${key} must be a list of strings`;
275
+ if (unknownKey) return [...unknownKeyProblems(`package ${name}`, holder, packageRuleSchema.entries, [
276
+ section.packages,
277
+ index,
278
+ 0
279
+ ]), ...unknownListProblems(holder, packageRuleSchema.entries, [
280
+ section.packages,
281
+ index,
282
+ 1
283
+ ], listMessage)];
284
+ const field = String(path[3]?.key);
285
+ return [{
286
+ order: [
287
+ section.packages,
288
+ index,
289
+ 1,
290
+ Object.keys(value).indexOf(field)
291
+ ],
292
+ message: listMessage(field)
293
+ }];
294
+ };
295
+ const runtimeModulesProblems = (path, unknownKey, holder) => {
296
+ const listMessage = (key) => `runtimeModules.${key} must be a list of strings`;
297
+ if (unknownKey) return [...unknownKeyProblems("runtimeModules", holder, runtimeModulesSchema.entries, [section.runtimeModuleKeys]), ...unknownListProblems(holder, runtimeModulesSchema.entries, [section.runtimeModuleValues], listMessage)];
298
+ const field = String(path[1]?.key);
299
+ return [{
300
+ order: [section.runtimeModuleValues, Object.keys(objectAt(path, 0)).indexOf(field)],
301
+ message: listMessage(field)
302
+ }];
303
+ };
304
+ const importProblems = (path, unknownKey, holder) => {
305
+ if (unknownKey) return unknownKeyProblems("imports", holder, importPolicySchema.entries, [section.importKeys]);
306
+ const field = String(path[1]?.key);
307
+ if (importFieldOrder.includes(field)) return [{
308
+ order: [section.importFields, importFieldOrder.indexOf(field)],
309
+ message: `imports.${field} must be a list of ${allManifestFields.join(", ")}`
310
+ }];
311
+ return [{
312
+ order: [section.importPolicies, importPolicyOrder.indexOf(field)],
313
+ message: `imports.${field} is ${JSON.stringify(path[1]?.value)}; it must be one of ${policyValues(field).join(", ")}`
314
+ }];
315
+ };
316
+ const fileClassProblems = (path, unknownKey, holder) => {
317
+ const index = Number(path[1]?.key);
318
+ if (unknownKey) return unknownKeyProblems(`file class ${index}`, holder, fileClassSchema.entries, [
319
+ section.fileClasses,
320
+ index,
321
+ 0
322
+ ]);
323
+ return path[2]?.key === "files" ? [{
324
+ order: [
325
+ section.fileClasses,
326
+ index,
327
+ 1
328
+ ],
329
+ message: `file class ${index}: files must be a list of globs`
330
+ }] : [{
331
+ order: [
332
+ section.fileClasses,
333
+ index,
334
+ 2
335
+ ],
336
+ message: `file class ${index} has the unknown policy ${String(objectAt(path, 1).policy)}`
337
+ }];
338
+ };
339
+ const baselineProblems = (path, unknownKey, holder) => {
340
+ const index = Number(path[1]?.key);
341
+ const entry = objectAt(path, 1);
342
+ const file = asText(entry.file);
343
+ if (unknownKey) return unknownKeyProblems(`the baseline entry for ${file}`, holder, baselineEntrySchema.entries, [
344
+ section.baseline,
345
+ index,
346
+ 0
347
+ ]);
348
+ const field = String(path[2]?.key);
349
+ const message = {
350
+ rule: () => `the baseline entry for ${file} names the unknown rule ${String(entry.rule)}`,
351
+ target: () => `the baseline entry for ${asText(entry.rule)} in ${file} names no target, so it would hide every future finding of that rule there`,
352
+ reason: () => `the baseline entry for ${asText(entry.rule)} in ${file} has no reason`
353
+ }[field]?.();
354
+ return message === void 0 ? [] : [{
355
+ order: [
356
+ section.baseline,
357
+ index,
358
+ baselineFieldOrder.indexOf(field)
359
+ ],
360
+ message
361
+ }];
362
+ };
363
+ /** The messages for one issue of `configSchema`. */
364
+ const problemsOf = (issue) => {
365
+ const path = issue.path ?? [];
366
+ const unknownKey = issue.type === "strict_object" && issue.expected === "never";
367
+ const at = unknownKey ? path.slice(0, -1) : path;
368
+ const holder = unknownKey ? path.at(-1)?.input : void 0;
369
+ const object = isPlainObject(holder) ? holder : {};
370
+ const field = at[0]?.key;
371
+ switch (field) {
372
+ case void 0: return unknownKey ? unknownKeyProblems("the configuration", object, configSchema.entries, [section.configurationKeys]) : [];
373
+ case "kinds": return kindProblems(path, unknownKey, object);
374
+ case "packages": return packageProblems(path, unknownKey, object);
375
+ case "runtimeModules": return runtimeModulesProblems(path, unknownKey, object);
376
+ case "imports": return importProblems(path, unknownKey, object);
377
+ case "fileClasses": return fileClassProblems(path, unknownKey, object);
378
+ case "baseline": return baselineProblems(path, unknownKey, object);
379
+ case "workspaceScopes": return [{
380
+ order: [section.workspaceScopes],
381
+ message: "workspaceScopes must be a list of scopes"
382
+ }];
383
+ case "testOnlySpecifiers":
384
+ case "virtualModules":
385
+ case "ignore":
386
+ case "output": return [{
387
+ order: [section[field]],
388
+ message: `${field} must be a list of strings`
389
+ }];
390
+ default: return [];
176
391
  }
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`);
392
+ };
393
+ const compareOrders = (left, right) => {
394
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
395
+ const difference = (left[index] ?? -1) - (right[index] ?? -1);
396
+ if (difference !== 0) return difference;
182
397
  }
183
- return problems;
398
+ return 0;
399
+ };
400
+ /** Problems that make a configuration unusable, as messages: the outline's alone when it does not hold, else every other. */
401
+ const configProblems = (config) => {
402
+ const outline = v.safeParse(outlineSchema, config);
403
+ if (!outline.success) return [...new Set(outline.issues.map((issue) => outlineMessage(issue.path ?? [])))];
404
+ const sparse = ["fileClasses", "baseline"].filter((key) => [...config[key] ?? []].includes(void 0));
405
+ if (sparse.length > 0) return sparse.map((key) => `${key} must be a list of objects`);
406
+ for (const entry of config.baseline ?? []) asText(entry.file);
407
+ const problems = [...(v.safeParse(configSchema, config).issues ?? []).flatMap(problemsOf), ...kindReferenceProblems(config.kinds)].toSorted((left, right) => compareOrders(left.order, right.order));
408
+ const seen = /* @__PURE__ */ new Set();
409
+ return problems.flatMap((problem) => {
410
+ const key = problem.order.join(" ");
411
+ if (seen.has(key)) return [];
412
+ seen.add(key);
413
+ return [problem.message];
414
+ });
184
415
  };
185
416
  const resolveConfig = (config) => {
186
417
  const problems = configProblems(config);
@@ -270,20 +501,36 @@ const globExpression = (pattern) => {
270
501
  const matchesGlob = (path, pattern) => globExpression(pattern).test(path);
271
502
  /** Whether `path` matches any of `patterns`. */
272
503
  const matchesAny = (path, patterns) => patterns.some((pattern) => matchesGlob(path, pattern));
504
+ // src/result.ts
505
+ const success = (value) => ({
506
+ ok: true,
507
+ value
508
+ });
509
+ const failure = (error) => ({
510
+ ok: false,
511
+ error
512
+ });
513
+ /** Runs `operation`, with what it throws as the failure. */
514
+ const attempt = (operation) => {
515
+ try {
516
+ return success(operation());
517
+ } catch (cause) {
518
+ return failure(cause);
519
+ }
520
+ };
521
+ /** The message of something thrown, which need not be an Error. */
522
+ const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
273
523
  // src/workspace.ts
274
524
  /**
275
525
  * The workspace as `package.json` files declare it: the root's `workspaces` globs, and each package's real name (never
276
526
  * guessed from its directory) and dependency fields.
277
527
  */
528
+ /** The manifest at `path`, or why it cannot be read. */
278
529
  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;
530
+ const parsed = attempt(() => JSON.parse(readFileSync(path, "utf8")));
531
+ if (!parsed.ok) return failure(`${path}: ${messageOf(parsed.error)}`);
532
+ if (typeof parsed.value !== "object" || parsed.value === null) return failure(`${path} is not a JSON object`);
533
+ return success(parsed.value);
287
534
  };
288
535
  const namesOf = (field) => typeof field === "object" && field !== null && !Array.isArray(field) ? Object.keys(field).toSorted() : [];
289
536
  const fieldsOf = (manifest) => ({
@@ -311,12 +558,9 @@ const workspaceGlobs = (manifest) => {
311
558
  if (Array.isArray(field)) return field.filter((entry) => typeof entry === "string");
312
559
  if (typeof field === "object" && field !== null && "packages" in field && Array.isArray(field.packages)) return field.packages.filter((entry) => typeof entry === "string");
313
560
  };
314
- const workspacesOf = (manifest) => {
315
- try {
316
- return workspaceGlobs(readManifest(manifest)) ?? [];
317
- } catch {
318
- return [];
319
- }
561
+ const workspacesOf = (path) => {
562
+ const manifest = readManifest(path);
563
+ return manifest.ok ? workspaceGlobs(manifest.value) ?? [] : [];
320
564
  };
321
565
  /** Whether `globs` (from the manifest in `candidate`) hold `directory`: one of them matches it or a directory above it. */
322
566
  const holds = (candidate, globs, directory) => {
@@ -366,7 +610,9 @@ const directoriesMatching = (root, pattern) => {
366
610
  return found;
367
611
  };
368
612
  const loadWorkspace = (root) => {
369
- const rootManifest = readManifest(nodePath.join(root, "package.json"));
613
+ const rootRead = readManifest(nodePath.join(root, "package.json"));
614
+ if (!rootRead.ok) throw new Error(rootRead.error);
615
+ const rootManifest = rootRead.value;
370
616
  const globs = workspaceGlobs(rootManifest) ?? [];
371
617
  const directories = /* @__PURE__ */ new Set();
372
618
  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 +621,15 @@ const loadWorkspace = (root) => {
375
621
  const problems = [];
376
622
  const byName = /* @__PURE__ */ new Map();
377
623
  for (const directory of [...directories].toSorted()) {
378
- let manifest;
379
- try {
380
- manifest = readManifest(nodePath.join(root, directory, "package.json"));
381
- } catch (error) {
624
+ const read = readManifest(nodePath.join(root, directory, "package.json"));
625
+ if (!read.ok) {
382
626
  problems.push({
383
627
  directory,
384
- message: `package.json cannot be read: ${error instanceof Error ? error.message : String(error)}`
628
+ message: `package.json cannot be read: ${read.error}`
385
629
  });
386
630
  continue;
387
631
  }
632
+ const manifest = read.value;
388
633
  if (typeof manifest.name !== "string" || manifest.name === "") {
389
634
  problems.push({
390
635
  directory,
@@ -903,9 +1148,11 @@ const developmentFileBreaks = (engine, file) => {
903
1148
  if (known !== void 0 && [...known.read].every(([path, time]) => modifiedTime(nodePath.join(engine.workspace.root, path)) === time)) return known.breaks;
904
1149
  const read = /* @__PURE__ */ new Map();
905
1150
  const queue = [file];
1151
+ let head = 0;
906
1152
  let breaks;
907
- while (queue.length > 0 && breaks === void 0) {
908
- const next = queue.shift() ?? file;
1153
+ while (head < queue.length && breaks === void 0) {
1154
+ const next = queue[head] ?? file;
1155
+ head += 1;
909
1156
  const absolute = nodePath.join(engine.workspace.root, next);
910
1157
  const time = modifiedTime(absolute);
911
1158
  if (read.has(next) || time === void 0) continue;
@@ -1064,4 +1311,4 @@ const findingsForFile = (engine, file, text, options = {}) => {
1064
1311
  const baselineCovers = (entry, finding) => entry.file === finding.file && entry.rule === finding.rule && entry.target === finding.target;
1065
1312
  /** `findings` less those the baseline covers. */
1066
1313
  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 };
1314
+ export { resolveConfig as S, matchesAny as _, findingsForFile as a, defaultFileClasses as b, withoutBaselined as c, findWorkspaceRoot as d, loadWorkspace as f, failure as g, attempt 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, matchesGlob as v, defineConfig as x, configProblems as y };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ImportPolicy, c as PackageRule, d as configProblems, f as defaultFileClasses, i as FilePolicy, l as ResolvedConfig, m as resolveConfig, n as Config, o as KindRule, p as defineConfig, r as FileClass, s as ManifestField, t as BaselineEntry, u as RuleName } from "./config-ClNl5rro.mjs";
1
+ import { a as ImportPolicy, c as PackageRule, d as configProblems, f as defaultFileClasses, i as FilePolicy, l as ResolvedConfig, m as resolveConfig, n as Config, o as KindRule, p as defineConfig, r as FileClass, s as ManifestField, t as BaselineEntry, u as RuleName } from "./config-DO_LRXM8.mjs";
2
2
  interface WorkspacePackage {
3
3
  readonly name: string;
4
4
  /** Repository-relative directory, `/`-separated (`libraries/parcel`). */
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { _ as configProblems, a as findingsForFile, b as resolveConfig, c as withoutBaselined, d as findWorkspaceRoot, f as loadWorkspace, g as matchesGlob, h as matchesAny, i as effectivePolicy, l as importForms, m as packageOfFile, n as createEngine, o as policyOf, p as packageNameOf, s as reads, v as defaultFileClasses, y as defineConfig } from "./engine-C1qSxYaL.mjs";
2
- import { a as staleBaseline, i as graphFindings, n as formatFinding, r as listSourceFiles, t as checkWorkspace } from "./check-BIxs2Gr0.mjs";
1
+ import { S as resolveConfig, _ as matchesAny, a as findingsForFile, b as defaultFileClasses, c as withoutBaselined, d as findWorkspaceRoot, f as loadWorkspace, i as effectivePolicy, l as importForms, m as packageOfFile, n as createEngine, o as policyOf, p as packageNameOf, s as reads, v as matchesGlob, x as defineConfig, y as configProblems } from "./engine-Dc5tRuHT.mjs";
2
+ import { a as staleBaseline, i as graphFindings, n as formatFinding, r as listSourceFiles, t as checkWorkspace } from "./check-F4cLmfYv.mjs";
3
3
  export { checkWorkspace, configProblems, createEngine, defaultFileClasses, defineConfig, effectivePolicy, findWorkspaceRoot, findingsForFile, formatFinding, graphFindings, importForms, listSourceFiles, loadWorkspace, matchesAny, matchesGlob, packageNameOf, packageOfFile, policyOf, reads, resolveConfig, staleBaseline, withoutBaselined };
package/dist/plugin.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as findingsForFile, c as withoutBaselined, d as findWorkspaceRoot, n as createEngine } from "./engine-C1qSxYaL.mjs";
1
+ import { a as findingsForFile, c as withoutBaselined, d as findWorkspaceRoot, g as failure, h as attempt, n as createEngine } from "./engine-Dc5tRuHT.mjs";
2
2
  import { statSync } from "node:fs";
3
3
  import nodePath from "node:path";
4
4
  // src/plugin.ts
@@ -23,7 +23,7 @@ const modified = (path) => {
23
23
  }
24
24
  };
25
25
  const manifestsOf = (root, engine) => {
26
- const directories = engine instanceof Error ? [] : engine.workspace.packages.map((entry) => nodePath.join(root, entry.directory));
26
+ const directories = engine.ok ? engine.value.workspace.packages.map((entry) => nodePath.join(root, entry.directory)) : [];
27
27
  const paths = [
28
28
  nodePath.join(root, "package.json"),
29
29
  ...directories.map((directory) => nodePath.join(directory, "package.json")),
@@ -32,11 +32,8 @@ const manifestsOf = (root, engine) => {
32
32
  return new Map(paths.map((path) => [path, modified(path)]));
33
33
  };
34
34
  const build = (root, config) => {
35
- try {
36
- return createEngine(root, config);
37
- } catch (error) {
38
- return error instanceof Error ? error : new Error(String(error));
39
- }
35
+ const built = attempt(() => createEngine(root, config));
36
+ return built.ok ? built : failure(built.error instanceof Error ? built.error : new Error(String(built.error)));
40
37
  };
41
38
  /** The engine for `root` and `config`, rebuilt when a package.json it read has changed. */
42
39
  const engineFor = (root, config) => {
@@ -90,14 +87,15 @@ const plugin = {
90
87
  });
91
88
  return;
92
89
  }
93
- const engine = engineFor(root, config);
94
- if (engine instanceof Error) {
90
+ const built = engineFor(root, config);
91
+ if (!built.ok) {
95
92
  context.report({
96
- message: engine.message,
93
+ message: built.error.message,
97
94
  loc: { start }
98
95
  });
99
96
  return;
100
97
  }
98
+ const engine = built.value;
101
99
  const file = nodePath.relative(root, context.filename).split(nodePath.sep).join("/");
102
100
  for (const finding of withoutBaselined(engine, findingsForFile(engine, file, context.sourceCode.text))) {
103
101
  const message = `${finding.rule}: ${finding.message}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ox-dep-lint",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Module dependency graph rules for oxlint: package kinds, outside packages, runtime and test-only modules, undeclared, escaping and computed imports",
5
5
  "license": "MIT",
6
6
  "author": "Philip B. Krogh",
@@ -38,7 +38,8 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "oxc-parser": "0.153.0",
41
- "oxc-resolver": "11.24.2"
41
+ "oxc-resolver": "11.24.2",
42
+ "valibot": "1.5.0"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@changesets/cli": "3.0.3",