ox-dep-lint 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 89ab407: New policy `imports.typeOnly`. With `"as-values"`, shipped code's type-only imports (every form, `/// <reference types>` included) are judged by `runtime-module`, `forbidden-external`, `undeclared-import` and `shipped-imports-development-file`. So `import type { Socket } from "bun"` fails in a kind with `runtimeModules: false`, and a vendor's types fail where the kind's `externals` do not allow it. A shipped `@types/x` still declares `x` for a type-only import. The default, `"relaxed"`, keeps 0.1's behaviour: types do not load, so those rules skip them.
8
+
9
+ A runtime's own types package (`node`, `@types/node`, `bun-types`, `@types/bun`, `@cloudflare/workers-types`, or a subpath of one) used type-only now counts as that runtime, in shipped and development files alike. So it is never reported as an outside package or as undeclared; until now an undeclared one gave `undeclared-import`. Under `"as-values"`, `runtime-module` judges it where the kind allows no runtime modules; under `"relaxed"` it passes, like other type-only imports.
10
+
11
+ ## 0.1.1
12
+
13
+ - 0.1.0 was published without its built `dist` files, so neither the CLI nor the plugin could load. A `prepack` script now builds before every pack and publish, so a package cannot go out without them. Use 0.1.1.
14
+ - The `bin` path is written as npm stores it (`dist/cli.mjs`), so publishing no longer warns that it was corrected.
15
+ - First release published from GitHub Actions through npm trusted publishing, with provenance.
16
+
17
+ ## 0.1.0
18
+
19
+ - First release: the oxlint plugin `dep-lint/imports` and the `ox-dep-lint` CLI, sharing one engine. See the README for the rules, the configuration and what is not covered yet.
package/README.md CHANGED
@@ -79,6 +79,9 @@ A kind may use its own kind only when `mayUse` lists it. `imports` holds the pol
79
79
  | `manifestEdgeFields` | all four fields | any subset |
80
80
  | `requireDeclared` | `true` | `false` |
81
81
  | `shippedFields` | `dependencies`, `peerDependencies`, `optionalDependencies` | any subset |
82
+ | `typeOnly` | `relaxed` | `as-values` |
83
+
84
+ `typeOnly: "as-values"` judges shipped code's type-only imports (`import type`, `export type`, type-level `import()`, `declare module`, `import type X = require()`, `/// <reference types>`) by `runtime-module`, `forbidden-external`, `undeclared-import` and `shipped-imports-development-file`, so `import type { Socket } from "bun"` fails in a kind without runtime modules. Two differences from value imports remain. A shipped field that declares `@types/x` declares `x` for a type-only import, and such an `@types/x` is an outside package too, so `externals` lists both. A runtime's own types package (`node` or `@types/node`, `bun`, `bun-types` or `@types/bun`, `@cloudflare/workers-types`) counts as that runtime, so it is judged by `runtime-module` and never needs declaring. That includes a published package whose emitted declarations reference those types: declare `@types/node` or `bun-types` for its consumers yourself; this check does not ask for it. By default types do not load, so those rules skip type-only imports. Kind edges and `typeOnlyUses` always see the real form.
82
85
 
83
86
  Misspelled keys, unknown policies and baseline entries without a `target` are configuration errors, never silent defaults.
84
87
 
@@ -127,6 +130,12 @@ Where they can still differ: oxlint chooses which files it lints (its own ignore
127
130
  - `/// <reference path="..." />` and JSDoc `@import` types are not read.
128
131
  - A `require` passed around as a value (`const load = require; load("x")`) is not followed. Telling the global loader from a local of the same name needs scope analysis; until then the check reads `require` only where it is called.
129
132
 
133
+ ## Contributing and releases
134
+
135
+ - Commits and pull request titles follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix:`, `feat:`, `chore:`); a `commit-msg` hook and CI check them with commitlint.
136
+ - Every pull request adds a changeset: `bunx changeset` and pick patch, minor or major, or `bunx changeset --empty` when nothing ships. Before 1.0, a breaking change is a minor bump.
137
+ - Versions follow [semantic versioning](https://semver.org/) and are never edited by hand. While changesets wait on main, the `release` workflow keeps a pull request `chore(release): vX.Y.Z` up to date with the bumped `package.json` and the new `CHANGELOG.md` entry. Merging it publishes that version to npm through trusted publishing (with provenance), tags it and makes a GitHub release. Empty changesets alone open no release; they are consumed with the next one.
138
+
130
139
  ## Licence
131
140
 
132
141
  MIT. The specifier reader and resolver are adapted from phibkro/vektorprogrammet-reimagined, and the rules from that repository and phibkro/file-transfer, all by the same author. The escape-sequence rule closes a bypass found in reebeat/reebeat's tier check. No code from reebeat was copied.
@@ -0,0 +1,168 @@
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";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import nodePath from "node:path";
4
+ // src/graph.ts
5
+ /**
6
+ * Checks that need the whole workspace rather than one file: edges declared only in `package.json`, cycles between
7
+ * packages, packages no kind holds, and baseline entries no finding needs any more. Only the CLI runs these, because an
8
+ * oxlint plugin sees one file at a time and never sees `package.json`.
9
+ */
10
+ const runtimeFields = [
11
+ "dependencies",
12
+ "optionalDependencies",
13
+ "peerDependencies"
14
+ ];
15
+ const graphFindings = (engine) => {
16
+ const { config, workspace } = engine;
17
+ const findings = [];
18
+ const at = {
19
+ line: 1,
20
+ column: 0
21
+ };
22
+ for (const problem of workspace.problems) findings.push({
23
+ rule: "invalid-package",
24
+ file: `${problem.directory}/package.json`,
25
+ ...at,
26
+ message: problem.message,
27
+ target: problem.directory
28
+ });
29
+ const members = /* @__PURE__ */ new Set([...workspace.packages.map((entry) => entry.directory), ...workspace.problems.map((problem) => problem.directory)]);
30
+ for (const kind of Object.values(config.kinds)) for (const pattern of kind.packages) for (const directory of directoriesMatching(workspace.root, pattern)) if (!members.has(directory) && existsSync(nodePath.join(workspace.root, directory, "package.json"))) {
31
+ members.add(directory);
32
+ findings.push({
33
+ rule: "package-outside-workspace",
34
+ file: `${directory}/package.json`,
35
+ ...at,
36
+ message: `${directory} is a package a kind names, but the root package.json's workspaces leave it out, so nothing in it is checked`,
37
+ target: directory
38
+ });
39
+ }
40
+ for (const entry of workspace.packages) {
41
+ const manifest = `${entry.directory}/package.json`;
42
+ if (engine.kindOf(entry) === void 0) {
43
+ findings.push({
44
+ rule: "package-without-kind",
45
+ file: manifest,
46
+ ...at,
47
+ message: `${entry.name} matches no kind: add its directory to one`,
48
+ target: entry.name
49
+ });
50
+ continue;
51
+ }
52
+ const kindRule = config.kinds[engine.kindOf(entry) ?? ""];
53
+ const extra = config.packages[entry.name]?.externals ?? [];
54
+ for (const field of config.imports.shippedFields) for (const name of entry.manifest[field]) {
55
+ const outside = !workspace.byName.has(name) && !config.workspaceScopes.some((scope) => name.startsWith(scope));
56
+ const installed = entry.aliases.get(name) ?? name;
57
+ if (outside && kindRule !== void 0 && kindRule.externals !== "any" && !kindRule.externals.includes(installed) && !extra.includes(installed)) findings.push({
58
+ rule: "forbidden-external",
59
+ file: manifest,
60
+ ...at,
61
+ message: `${field} names ${installed === name ? name : `${name} (${installed})`}, which its kind does not allow`,
62
+ target: installed
63
+ });
64
+ }
65
+ for (const field of config.imports.manifestEdgeFields) for (const name of entry.manifest[field]) {
66
+ const to = workspace.byName.get(name);
67
+ if (to === void 0) {
68
+ if (config.workspaceScopes.some((scope) => name.startsWith(scope))) findings.push({
69
+ rule: "unknown-workspace-package",
70
+ file: manifest,
71
+ ...at,
72
+ message: `${field} names ${name}, which is not a workspace package`,
73
+ target: name
74
+ });
75
+ continue;
76
+ }
77
+ const refusal = edgeRefusal(engine, entry, to, true);
78
+ if (refusal !== void 0 && to.name !== entry.name) findings.push({
79
+ rule: refusal.rule,
80
+ file: manifest,
81
+ ...at,
82
+ message: `${field}: ${refusal.why}`,
83
+ target: name
84
+ });
85
+ }
86
+ }
87
+ findings.push(...packageCycles(engine));
88
+ return findings;
89
+ };
90
+ /** Cycles between workspace packages through their runtime dependency fields, each reported once. */
91
+ const packageCycles = (engine) => {
92
+ const { workspace } = engine;
93
+ 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
+ const findings = [];
96
+ 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;
115
+ }
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, []);
121
+ return findings;
122
+ };
123
+ /** Baseline entries that no finding in `findings` needs; each is a finding of its own, so the baseline only shrinks. */
124
+ const staleBaseline = (engine, findings) => engine.config.baseline.filter((entry) => !findings.some((finding) => baselineCovers(entry, finding))).map((entry) => ({
125
+ rule: "stale-baseline",
126
+ file: entry.file,
127
+ line: 1,
128
+ column: 0,
129
+ message: `the baseline entry for ${entry.rule}${entry.target === void 0 ? "" : ` (${entry.target})`} is no longer needed: remove it`,
130
+ target: entry.rule
131
+ }));
132
+ // src/check.ts
133
+ /** The whole check: every source file of the workspace through the engine, plus the whole-graph checks and the baseline. */
134
+ const isFile = (path) => {
135
+ try {
136
+ return statSync(path).isFile();
137
+ } catch {
138
+ return false;
139
+ }
140
+ };
141
+ /**
142
+ * Every source file the engine reads, found by walking each package's directory (skipping `node_modules` and dot
143
+ * directories). A walk rather than `git ls-files`: generated and gitignored sources still ship, and a nested repository
144
+ * or submodule inside a package is still part of it.
145
+ */
146
+ const listSourceFiles = (engine) => {
147
+ const files = /* @__PURE__ */ new Set();
148
+ const walk = (relative) => {
149
+ for (const entry of readdirSync(nodePath.join(engine.workspace.root, relative), { withFileTypes: true })) {
150
+ const path = `${relative}/${entry.name}`;
151
+ if (entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith(".")) walk(path);
152
+ else if ((entry.isFile() || entry.isSymbolicLink() && isFile(nodePath.join(engine.workspace.root, path))) && reads(engine, path)) files.add(path);
153
+ }
154
+ };
155
+ for (const entry of engine.workspace.packages) walk(entry.directory);
156
+ return [...files].toSorted();
157
+ };
158
+ const checkWorkspace = (root, config) => {
159
+ const engine = createEngine(root, config);
160
+ const files = listSourceFiles(engine);
161
+ const all = [...files.flatMap((file) => findingsForFile(engine, file, readFileSync(nodePath.join(root, file), "utf8"))).filter((finding) => finding.rule !== "package-without-kind"), ...graphFindings(engine)];
162
+ return {
163
+ files: files.length,
164
+ findings: [...withoutBaselined(engine, all), ...staleBaseline(engine, all)]
165
+ };
166
+ };
167
+ const formatFinding = (finding) => `${finding.file}:${finding.line}:${finding.column + 1} ${finding.rule}: ${finding.message}`;
168
+ export { staleBaseline as a, graphFindings as i, formatFinding as n, listSourceFiles as r, checkWorkspace as t };
package/dist/cli.d.mts ADDED
@@ -0,0 +1,2 @@
1
+ import { n as Config } from "./config-ClNl5rro.mjs";
2
+ export declare const loadConfig: (path: string) => Promise<Config>;
package/dist/cli.mjs ADDED
@@ -0,0 +1,57 @@
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";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import nodePath from "node:path";
6
+ import process from "node:process";
7
+ import { pathToFileURL } from "node:url";
8
+ // src/cli.ts
9
+ /**
10
+ * `ox-dep-lint [--root <dir>] [--config <file>]`: checks the workspace and exits 1 on any finding. The configuration is
11
+ * `ox-dep-lint.config.{ts,mts,js,mjs,json}` at the workspace root unless `--config` names one.
12
+ */
13
+ const configNames = [
14
+ "ox-dep-lint.config.ts",
15
+ "ox-dep-lint.config.mts",
16
+ "ox-dep-lint.config.js",
17
+ "ox-dep-lint.config.mjs",
18
+ "ox-dep-lint.config.json"
19
+ ];
20
+ const argument = (name) => {
21
+ const index = process.argv.indexOf(name);
22
+ return index === -1 ? void 0 : process.argv[index + 1];
23
+ };
24
+ const loadConfig = async (path) => {
25
+ if (path.endsWith(".json")) return JSON.parse(readFileSync(path, "utf8"));
26
+ const loaded = await import(pathToFileURL(path).href);
27
+ if (typeof loaded !== "object" || loaded === null || !("default" in loaded)) throw new Error(`${path} has no default export`);
28
+ return loaded.default;
29
+ };
30
+ const main = async () => {
31
+ const root = argument("--root") ?? findWorkspaceRoot(process.cwd());
32
+ if (root === void 0) {
33
+ process.stderr.write("ox-dep-lint: no package.json with workspaces at or above this directory; pass --root\n");
34
+ process.exitCode = 2;
35
+ return;
36
+ }
37
+ const named = argument("--config");
38
+ const configPath = named === void 0 ? configNames.map((name) => nodePath.join(root, name)).find((path) => existsSync(path)) : nodePath.resolve(named);
39
+ if (configPath === void 0) {
40
+ process.stderr.write(`ox-dep-lint: no configuration (${configNames.join(", ")}) in ${root}\n`);
41
+ process.exitCode = 2;
42
+ return;
43
+ }
44
+ const started = performance.now();
45
+ const result = checkWorkspace(root, await loadConfig(configPath));
46
+ for (const finding of result.findings) process.stdout.write(`${formatFinding(finding)}\n`);
47
+ const seconds = ((performance.now() - started) / 1e3).toFixed(2);
48
+ process.stdout.write(`ox-dep-lint: ${result.files} files, ${result.findings.length} findings (${seconds} s)\n`);
49
+ process.exitCode = result.findings.length === 0 ? 0 : 1;
50
+ };
51
+ try {
52
+ await main();
53
+ } catch (error) {
54
+ process.stderr.write(`ox-dep-lint: ${error instanceof Error ? error.message : String(error)}\n`);
55
+ process.exitCode = 2;
56
+ }
57
+ export { loadConfig };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * How a file is checked. `shipped`: every rule. `development` (tests, scripts, fixtures): kind edges, relative imports
3
+ * and unresolved imports are still checked, but devDependencies, test-only specifiers, runtime modules, outside
4
+ * packages and computed `import()` are allowed. `unchecked`: not read at all.
5
+ */
6
+ type FilePolicy = "shipped" | "development" | "unchecked";
7
+ /** One kind of package (a layer of the workspace): which packages it holds and what they may import. */
8
+ interface KindRule {
9
+ /** Globs over package directories, relative to the repository root (`libraries/*`, `packages/core`). */
10
+ readonly packages: readonly string[];
11
+ /** Kinds whose packages this kind may import, its own included only when listed; `"any"` for tools. */
12
+ readonly mayUse: readonly string[] | "any";
13
+ /** Outside packages shipped code may import and `package.json` may name in `dependencies`, or `"any"`. Default `"any"`. */
14
+ readonly externals?: readonly string[] | "any";
15
+ /** Whether shipped code may import runtime modules (`node:fs`, `fs`, `bun`, `cloudflare:workers`). Default true. */
16
+ readonly runtimeModules?: boolean;
17
+ }
18
+ /** Refinements for one package, keyed by its name. */
19
+ interface PackageRule {
20
+ /** The workspace packages it may import, a subset of what its kind allows. */
21
+ readonly uses?: readonly string[];
22
+ /** Outside packages beyond its kind's. */
23
+ readonly externals?: readonly string[];
24
+ /** Workspace packages it may import only with `import type`, `export type` or a type-level `import()`. */
25
+ readonly typeOnlyUses?: readonly string[];
26
+ /** Globs over specifiers of a `typeOnlyUses` package that may still be imported as values (`@acme/core/steps`). */
27
+ readonly valueSpecifiers?: readonly string[];
28
+ /** Package-relative globs of files checked as development files though they ship (a conformance suite's entry). */
29
+ readonly testSupportFiles?: readonly string[];
30
+ }
31
+ /** Files of one policy, by package-relative globs. The first class that matches a file wins; others are shipped. */
32
+ interface FileClass {
33
+ readonly files: readonly string[];
34
+ readonly policy: FilePolicy;
35
+ }
36
+ declare const ruleNames: readonly ["unknown-workspace-package", "package-without-kind", "package-outside-workspace", "invalid-package", "forbidden-edge", "type-only-edge", "relative-import-leaves-package", "package-path-escape", "shipped-imports-development-file", "self-import-by-name", "forbidden-external", "runtime-module", "test-only-in-shipped", "undeclared-import", "computed-import", "unresolved-import", "package-cycle", "stale-baseline", "parse-error"];
37
+ type RuleName = (typeof ruleNames)[number];
38
+ /** A finding the repository tolerates for now. The check fails when no finding needs it any more, so the list shrinks. */
39
+ interface BaselineEntry {
40
+ /** Repository-relative file (or `package.json` path for a manifest finding). */
41
+ readonly file: string;
42
+ readonly rule: RuleName;
43
+ /** The specifier or package the finding names. */
44
+ readonly target: string;
45
+ readonly reason: string;
46
+ }
47
+ type ManifestField = "dependencies" | "devDependencies" | "optionalDependencies" | "peerDependencies";
48
+ interface ImportPolicy {
49
+ /** `import(expression)`, an escaped literal, `createRequire`: an error in shipped code (default), everywhere, or never. */
50
+ readonly computed?: "error-in-shipped" | "error" | "allow";
51
+ /** A package importing itself by its own name. Default `"allow"`. */
52
+ readonly selfByName?: "error" | "allow";
53
+ /** A relative path or alias that names no file. Default `"error"`. */
54
+ readonly unresolved?: "error" | "ignore";
55
+ /** Which `package.json` fields count as edges between workspace packages. Default all four. */
56
+ readonly manifestEdgeFields?: readonly ManifestField[];
57
+ /**
58
+ * Every package a file imports must be declared in its own `package.json`. Shipped code needs `dependencies`,
59
+ * `peerDependencies` or `optionalDependencies` (`shippedFields`); a type-only import and a development file may also
60
+ * use devDependencies and the root's. Default true. knip's `unlisted` misses files no entry reaches and root
61
+ * devDependencies used by shipped code.
62
+ */
63
+ readonly requireDeclared?: boolean;
64
+ /** The fields that satisfy a shipped import. Default `dependencies`, `peerDependencies`, `optionalDependencies`. */
65
+ readonly shippedFields?: readonly ManifestField[];
66
+ /**
67
+ * How shipped code's type-only imports (`import type`, `export type`, a type-level `import()`, `declare module`, a
68
+ * types reference) are judged by `runtime-module`, `forbidden-external`, `undeclared-import` and
69
+ * `shipped-imports-development-file`. `"relaxed"` (default): types do not load, so those rules skip them and
70
+ * devDependencies declare them. `"as-values"`: judged like value imports, for kinds whose public types must not name
71
+ * a runtime or vendor either. Kind edges and `typeOnlyUses` always see the real form.
72
+ */
73
+ readonly typeOnly?: "relaxed" | "as-values";
74
+ }
75
+ interface Config {
76
+ /** Scopes whose specifiers must name a workspace package (`@file-transfer/`). */
77
+ readonly workspaceScopes: readonly string[];
78
+ readonly kinds: Readonly<Record<string, KindRule>>;
79
+ readonly packages?: Readonly<Record<string, PackageRule>>;
80
+ /** Default: tests, scripts and fixtures are development files. */
81
+ readonly fileClasses?: readonly FileClass[];
82
+ /** Default: `node:`, `bun:`, `cloudflare:` prefixes and every bare Node built-in plus `bun`. */
83
+ readonly runtimeModules?: {
84
+ readonly prefixes?: readonly string[];
85
+ readonly names?: readonly string[];
86
+ };
87
+ /** Prefixes of a bundler's virtual modules, which have nothing on disk to check. Default `virtual:`, `astro:`. */
88
+ readonly virtualModules?: readonly string[];
89
+ /** Globs over specifiers only development files may import: `fast-check` is that specifier alone, `fast-check/**` its subpaths. */
90
+ readonly testOnlySpecifiers?: readonly string[];
91
+ readonly imports?: ImportPolicy;
92
+ readonly baseline?: readonly BaselineEntry[];
93
+ /** Repository-relative globs of files never read. */
94
+ readonly ignore?: readonly string[];
95
+ /** Package-relative globs of build output, never read. Default `dist/**` and `coverage/**`; `[]` reads everything. */
96
+ readonly output?: readonly string[];
97
+ }
98
+ /** The configuration with every default filled in. */
99
+ interface ResolvedConfig {
100
+ readonly workspaceScopes: readonly string[];
101
+ readonly kinds: Readonly<Record<string, Required<KindRule>>>;
102
+ readonly packages: Readonly<Record<string, PackageRule>>;
103
+ readonly fileClasses: readonly FileClass[];
104
+ readonly runtimePrefixes: readonly string[];
105
+ readonly runtimeNames: ReadonlySet<string>;
106
+ readonly virtualPrefixes: readonly string[];
107
+ readonly testOnlySpecifiers: readonly string[];
108
+ readonly imports: Required<ImportPolicy>;
109
+ readonly baseline: readonly BaselineEntry[];
110
+ readonly ignore: readonly string[];
111
+ readonly output: readonly string[];
112
+ }
113
+ declare const defaultFileClasses: readonly FileClass[];
114
+ /** Identity, for type checking a configuration file. */
115
+ declare const defineConfig: (config: Config) => Config;
116
+ declare const configProblems: (config: Config) => string[];
117
+ declare const resolveConfig: (config: Config) => ResolvedConfig;
118
+ 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 };