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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e75db50: Sources under a workspace glob no longer escape the check unreported: each one is read, or the reason it cannot be read is a finding. Plain files at a glob's level (`packages/x.ts`, outside every package directory) and entries that are neither files, directories nor links (FIFOs, sockets) are still not reported. This can add findings to a repository that passed before, so upgrade with the CLI once and baseline or fix what it reports.
8
+
9
+ - The walk now reads dot directories inside a package (`src/.internal/x.ts`), as oxlint already did; it skips only `node_modules` and `.git`.
10
+ - `output` now defaults to `dist/**`, `coverage/**` and the caches tools write at a package root: `.wrangler/**`, `.astro/**`, `.svelte-kit/**`, `.next/**`, `.turbo/**`, `.vercel/**`, `.nuxt/**`, `.output/**`. Setting `output` replaces the defaults; it does not add to them.
11
+ - `symbolic-link`: every symbolic link under a package directory, whatever it points at and whatever its name (`.d.ts`, `.md`, `.json`, extensionless); a link a `workspaces` or kind glob matches or names literally; and a link a glob passes through (`packages` itself a link). Links are never followed: 0.2 read a link to a file as if it were the file, skipped a link to a directory silently, and followed a linked `packages`. Only links inside directories the check skips on purpose are not reported: `node_modules`, `.git`, and directories that `ignore`, `output` or an unchecked file class cover through a trailing `/**`. A baseline entry names the link as `file` and its destination, as written, as `target`; baselined or not, what a link points at is never read. A Nix `result` link inside a package is a finding; `ignore: ["**/result/**"]` clears it. The oxlint plugin reports it on each file oxlint reaches through a link and reads nothing else in that file.
12
+ - `unreadable-path`: a directory the walk, a `workspaces` glob or a kind's glob cannot list, or a source file the check cannot read (`EACCES`), unless the check skips it on purpose. 0.2 stopped with exit code 2 and the error instead.
13
+ - `directory-without-package`: a directory a root `workspaces` glob matches that has no `package.json` and lies neither inside nor above a member, so nothing checks its files; and a dot directory such a glob would match (`packages/.template`), with or without a `package.json`, since npm and Bun workspaces never match dot directories. Only the topmost of nested ones is reported, and a negated glob (`!tools/legacy` or `!tools/legacy/**`) leaves it out. No new configuration key: add `dir/**` to `ignore`, or baseline it, to keep one.
14
+
15
+ A workspaces entry or kind glob that names or passes through a symbolic link no longer finds packages there: the link is reported instead of followed. `listSourceFiles` no longer lists a file reached through a link; the new `walkSources` returns the files with the walk's findings. `Workspace` has a new field, `globs`, the root's `workspaces` globs.
16
+
17
+ ## 0.3.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 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.
22
+
23
+ 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()`.
24
+
25
+ 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.
26
+
3
27
  ## 0.2.0
4
28
 
5
29
  ### Minor Changes
package/README.md CHANGED
@@ -11,27 +11,30 @@ ox-dep-lint replaces the hand-written module graph checks of three repositories
11
11
 
12
12
  ## What it checks
13
13
 
14
- | Rule | Finding | Front end |
15
- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
16
- | `forbidden-edge` | A load of a workspace package that its kind (or its `uses`) does not allow. The same check runs on edges declared in `package.json`: all four dependency fields by default, devDependencies included. | both |
17
- | `type-only-edge` | A value import of a package listed in `typeOnlyUses`. Only `import type` and `export type` declarations are type-only; an inline `import { type X }` still loads the module under `verbatimModuleSyntax`. | both |
18
- | `unknown-workspace-package` | A name under a workspace scope that no package has, in an import or a `package.json`. | both |
19
- | `package-path-escape` | A package specifier with `.` or `..` after the name (`@acme/a/../b/src/x.ts`). | both |
20
- | `relative-import-leaves-package` | A relative path, alias, root-absolute path or worker URL that resolves into another package, into `node_modules` or out of the repository. Import a package by its name instead. | both |
21
- | `shipped-imports-development-file` | Shipped code importing a development file (of its own package, or one another package exports), or a file under `output` or `ignore`, that would not pass as shipped code itself: a test helper using `fast-check`, a script using `node:fs`. A development file that would pass, such as a shared helper under `scripts/`, may be imported. | both |
22
- | `forbidden-external` | An outside package that the kind's `externals` does not allow, in shipped code or in `package.json`'s shipped fields. An outside package is named by the package it installs (the `name` in its installed `package.json`, or an `npm:` alias's target), so an alias cannot pass one package off as another; `externals` name real packages. | both, `package.json` in the CLI |
23
- | `runtime-module` | `node:*`, a bare Node built-in (`fs`), `bun`, `bun:*` or `cloudflare:*`, also when a package import (`#crypto`) maps to one, in the shipped code of a kind with `runtimeModules: false`. | both |
24
- | `test-only-in-shipped` | A `testOnlySpecifiers` match (`fast-check`, `vite-plus/test`) in shipped code, with any `?query` stripped. | both |
25
- | `undeclared-import` | A package the file's own `package.json` does not declare. Shipped code needs `dependencies`, `peerDependencies` or `optionalDependencies` (`imports.shippedFields`); type-only imports and development files may also use devDependencies and the root's. This covers what knip misses: files no entry reaches, and shipped code leaning on a root devDependency. | both |
26
- | `computed-import` | A load whose module the check cannot read: `import(expression)`, `require(expression)`, `import.meta.glob`, a kept `createRequire` function, or a literal written with an escape sequence (which is also checked by the module it spells). Shipped code only, by default. | both |
27
- | `unresolved-import` | A relative path or alias (`#x`, `@/x`, `~/x`) that names no file. | both |
28
- | `self-import-by-name` | A package importing itself by its own name (off by default). | both |
29
- | `package-without-kind` | A workspace package that no kind's globs hold. | both |
30
- | `package-outside-workspace` | A directory a kind names, with a `package.json`, that the root `workspaces` leave out, so nothing in it would be checked. | CLI |
31
- | `invalid-package` | A workspace member whose `package.json` cannot be read, has no name, or has another member's name. | CLI |
32
- | `package-cycle` | A cycle between packages through their runtime dependency fields. | CLI |
33
- | `stale-baseline` | A baseline entry that no finding needs any more, so the baseline can only shrink. | CLI |
34
- | `parse-error` | A file that does not parse, so its imports cannot be checked. | both |
14
+ | Rule | Finding | Front end |
15
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
16
+ | `forbidden-edge` | A load of a workspace package that its kind (or its `uses`) does not allow. The same check runs on edges declared in `package.json`: all four dependency fields by default, devDependencies included. | both |
17
+ | `type-only-edge` | A value import of a package listed in `typeOnlyUses`. Only `import type` and `export type` declarations are type-only; an inline `import { type X }` still loads the module under `verbatimModuleSyntax`. | both |
18
+ | `unknown-workspace-package` | A name under a workspace scope that no package has, in an import or a `package.json`. | both |
19
+ | `package-path-escape` | A package specifier with `.` or `..` after the name (`@acme/a/../b/src/x.ts`). | both |
20
+ | `relative-import-leaves-package` | A relative path, alias, root-absolute path or worker URL that resolves into another package, into `node_modules` or out of the repository. Import a package by its name instead. | both |
21
+ | `shipped-imports-development-file` | Shipped code importing a development file (of its own package, or one another package exports), or a file under `output` or `ignore`, that would not pass as shipped code itself: a test helper using `fast-check`, a script using `node:fs`. A development file that would pass, such as a shared helper under `scripts/`, may be imported. | both |
22
+ | `forbidden-external` | An outside package that the kind's `externals` does not allow, in shipped code or in `package.json`'s shipped fields. An outside package is named by the package it installs (the `name` in its installed `package.json`, or an `npm:` alias's target), so an alias cannot pass one package off as another; `externals` name real packages. | both, `package.json` in the CLI |
23
+ | `runtime-module` | `node:*`, a bare Node built-in (`fs`), `bun`, `bun:*` or `cloudflare:*`, also when a package import (`#crypto`) maps to one, in the shipped code of a kind with `runtimeModules: false`. | both |
24
+ | `test-only-in-shipped` | A `testOnlySpecifiers` match (`fast-check`, `vite-plus/test`) in shipped code, with any `?query` stripped. | both |
25
+ | `undeclared-import` | A package the file's own `package.json` does not declare. Shipped code needs `dependencies`, `peerDependencies` or `optionalDependencies` (`imports.shippedFields`); type-only imports and development files may also use devDependencies and the root's. This covers what knip misses: files no entry reaches, and shipped code leaning on a root devDependency. | both |
26
+ | `computed-import` | A load whose module the check cannot read: `import(expression)`, `require(expression)`, `import.meta.glob`, a kept `createRequire` function, or a literal written with an escape sequence (which is also checked by the module it spells). Shipped code only, by default. | both |
27
+ | `unresolved-import` | A relative path or alias (`#x`, `@/x`, `~/x`) that names no file. | both |
28
+ | `self-import-by-name` | A package importing itself by its own name (off by default). | both |
29
+ | `package-without-kind` | A workspace package that no kind's globs hold. | both |
30
+ | `package-outside-workspace` | A directory a kind names, with a `package.json`, that the root `workspaces` leave out, so nothing in it would be checked. | CLI |
31
+ | `invalid-package` | A workspace member whose `package.json` cannot be read, has no name, or has another member's name. | CLI |
32
+ | `directory-without-package` | A directory a root `workspaces` glob matches that has no `package.json` and lies neither inside nor above a member, so no package owns its files and nothing checks them (tsukuri's "directory in no tier"). Also a dot directory such a glob would match (`packages/.template`), with or without a `package.json`: npm and Bun workspaces never match dot directories, so it is no package. Only the topmost of nested ones is reported; a negated glob (`!tools/legacy` or `!tools/legacy/**`) leaves the directory out. Silence one with `ignore` (`tools/vendored/**`) or the baseline. | CLI |
33
+ | `symbolic-link` | Every symbolic link under a package directory, whatever it points at (a file, a directory, nothing, a cycle) and whatever its name (`.d.ts`, `.md`, `.json`, extensionless); a link a `workspaces` or kind glob matches or names (`packages/x -> ../elsewhere`); and a link a glob passes through (`packages` itself a link). The check never follows one. It skips only links inside directories it skips on purpose: `node_modules`, `.git`, and directories `ignore`, `output` or an unchecked file class cover through a trailing `/**`. | both (the plugin on each file oxlint reaches through a link) |
34
+ | `unreadable-path` | A directory the walk, a `workspaces` glob or a kind glob cannot list, or a source file the check cannot read (`EACCES`), unless the check skips it on purpose. It used to stop the check with exit code 2. | CLI |
35
+ | `package-cycle` | A cycle between packages through their runtime dependency fields. | CLI |
36
+ | `stale-baseline` | A baseline entry that no finding needs any more, so the baseline can only shrink. | CLI |
37
+ | `parse-error` | A file that does not parse, so its imports cannot be checked. | both |
35
38
 
36
39
  A load is any of: `import` and `export ... from` (static, `defer` and `source` phases), `import()`, a type-level `import("...")`, `import x = require("...")`, a string-named `declare module "..."`, `/// <reference types="..." />`, `require`, `require.resolve`, `module.require`, `import.meta.require`, `import.meta.resolve`, `process.getBuiltinModule`, `createRequire(...)("...")`, and a `new URL("...", import.meta.url)` passed to `new Worker`, `new SharedWorker` or `navigator.serviceWorker.register` (bundlers load it as a module, extension or not; any other `new URL` locates a file to read or an asset to serve, and is not a load). `require.resolve` and `import.meta.resolve` with a literal are edges; with an expression they load nothing.
37
40
 
@@ -43,7 +46,7 @@ The first matching file class decides how a file is checked. Globs in a class ar
43
46
  - **development:** kind edges, relative imports and unresolved imports are still checked. The file may use devDependencies (its own and the root's), test-only specifiers, runtime modules, any outside package and computed `import()`.
44
47
  - **unchecked:** the file is not read.
45
48
 
46
- By default, `test/**`, `tests/**`, `e2e/**`, `scripts/**`, package-root `*.config.*`, `**/__tests__/**`, `**/fixtures/**` and `**/*.{test,spec,bench}.*` are development files. A package's `dist/**` and `coverage/**` are not read; `output` changes that list (`[]` reads everything). A package's `testSupportFiles` are development files too (a conformance suite's entry, for example).
49
+ By default, `test/**`, `tests/**`, `e2e/**`, `scripts/**`, package-root `*.config.*`, `**/__tests__/**`, `**/fixtures/**` and `**/*.{test,spec,bench}.*` are development files. A package's build output and the caches tools write at its root are not read: `output` defaults to `dist/**`, `coverage/**`, `.wrangler/**`, `.astro/**`, `.svelte-kit/**`, `.next/**`, `.turbo/**`, `.vercel/**`, `.nuxt/**` and `.output/**`. Setting `output` replaces these defaults rather than adding to them, so list the ones you still want (`[]` reads everything). Shipped code that imports a file under `output` still has that file judged. A package's `testSupportFiles` are development files too (a conformance suite's entry, for example).
47
50
 
48
51
  ## Configuration
49
52
 
@@ -85,6 +88,10 @@ A kind may use its own kind only when `mayUse` lists it. `imports` holds the pol
85
88
 
86
89
  Misspelled keys, unknown policies and baseline entries without a `target` are configuration errors, never silent defaults.
87
90
 
91
+ The root `workspaces` globs also say where packages live: a directory they match without a `package.json` is a `directory-without-package` finding, so no new key is needed. To keep such a directory (vendored sources that are not a package), add `dir/**` to `ignore` or baseline it with `file` and `target` both set to the directory.
92
+
93
+ A `symbolic-link` baseline entry names the link as `file` and its destination, as the link writes it, as `target`, so pointing the link elsewhere makes the entry stale. Baselining a link does not make the check read through it: what a link points at is never read, baselined or not. A Nix `result` link (from `nix build`) left inside a package is a finding too; build outside packages, or add `**/result/**` to `ignore`.
94
+
88
95
  ## Installing
89
96
 
90
97
  ```sh
@@ -95,7 +102,7 @@ Releases are published from a `v*` tag by `.github/workflows/release.yml`, with
95
102
 
96
103
  ## Running it
97
104
 
98
- The CLI walks every workspace package's directory (skipping `node_modules`, dot directories and `output`), so generated and gitignored sources and nested repositories are checked too, and runs the whole-graph checks:
105
+ The CLI walks every workspace package's directory, dot directories included (`src/.internal/`), skipping only `node_modules` and `.git` and reading nothing under `output`, so generated and gitignored sources and nested repositories are checked too, and runs the whole-graph checks. Tool caches such as `.wrangler` and `.astro` are in the default `output`; for another cache, set `output` to the defaults plus its glob, since `output` replaces the defaults. The CLI never follows a symbolic link: every link is a `symbolic-link` finding, and a directory it cannot list or a file it cannot read is an `unreadable-path` finding. What it still does not report: plain files directly at a workspace glob's level (`packages/x.ts`), and FIFOs and sockets.
99
106
 
100
107
  ```sh
101
108
  ox-dep-lint # finds ox-dep-lint.config.{ts,mts,js,mjs,json} at the workspace root
@@ -120,7 +127,7 @@ export default defineConfig({
120
127
 
121
128
  The plugin and the CLI call one function per file, and a test runs both under real oxlint and requires the same findings at the same places. In a long-lived process (an editor's language server) the plugin rebuilds its view of the workspace when a `package.json` changes, and looks again for a file it could not resolve.
122
129
 
123
- Where they can still differ: oxlint chooses which files it lints (its own ignores), and the CLI reads the files of workspace packages. oxlint shows line-1 columns of a file that starts with a byte order mark three further on than the CLI does.
130
+ Where they can still differ: oxlint chooses which files it lints (its own ignores), and the CLI reads the files of workspace packages. oxlint follows symbolic links, so the plugin reports `symbolic-link` at line 1 of each file it is given through a link (and reads nothing else in it), where the CLI reports the link once; a link to a file oxlint does not lint (`README.md`) is reported by the CLI alone. Both read dot directories. oxlint shows line-1 columns of a file that starts with a byte order mark three further on than the CLI does.
124
131
 
125
132
  ## Not yet
126
133
 
@@ -0,0 +1,341 @@
1
+ import { C as attempt, D as matchesGlob, c as linkDestination, d as reads, f as reportsLink, g as withoutBaselined, h as unreadablePathFinding, i as edgeRefusal, l as passedOver, m as symbolicLinkFinding, n as coversDirectory, o as findingsForFile, p as skipsDirectory, r as createEngine, s as ignoresDirectory, t as baselineCovers, v as directoriesMatching, w as codeOf } from "./engine-DmECSDnu.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, directories the workspaces match that no package owns, and baseline entries no
8
+ * finding needs any more. Only the CLI runs these, because an oxlint plugin sees one file at a time and never sees
9
+ * `package.json`.
10
+ */
11
+ const runtimeFields = [
12
+ "dependencies",
13
+ "optionalDependencies",
14
+ "peerDependencies"
15
+ ];
16
+ const graphFindings = (engine) => {
17
+ const { config, workspace } = engine;
18
+ const findings = [];
19
+ const at = {
20
+ line: 1,
21
+ column: 0
22
+ };
23
+ for (const problem of workspace.problems) findings.push({
24
+ rule: "invalid-package",
25
+ file: `${problem.directory}/package.json`,
26
+ ...at,
27
+ message: problem.message,
28
+ target: problem.directory
29
+ });
30
+ const members = /* @__PURE__ */ new Set([...workspace.packages.map((entry) => entry.directory), ...workspace.problems.map((problem) => problem.directory)]);
31
+ const unlistedByKinds = {
32
+ links: [],
33
+ unreadable: [],
34
+ dotDirectories: []
35
+ };
36
+ for (const kind of Object.values(config.kinds)) for (const pattern of kind.packages) for (const directory of directoriesMatching(workspace.root, pattern, unlistedByKinds)) if (!members.has(directory) && existsSync(nodePath.join(workspace.root, directory, "package.json"))) {
37
+ members.add(directory);
38
+ findings.push({
39
+ rule: "package-outside-workspace",
40
+ file: `${directory}/package.json`,
41
+ ...at,
42
+ message: `${directory} is a package a kind names, but the root package.json's workspaces leave it out, so nothing in it is checked`,
43
+ target: directory
44
+ });
45
+ }
46
+ findings.push(...directoriesWithoutPackage(engine, members, unlistedByKinds));
47
+ for (const entry of workspace.packages) {
48
+ const manifest = `${entry.directory}/package.json`;
49
+ if (engine.kindOf(entry) === void 0) {
50
+ findings.push({
51
+ rule: "package-without-kind",
52
+ file: manifest,
53
+ ...at,
54
+ message: `${entry.name} matches no kind: add its directory to one`,
55
+ target: entry.name
56
+ });
57
+ continue;
58
+ }
59
+ const kindRule = config.kinds[engine.kindOf(entry) ?? ""];
60
+ const extra = config.packages[entry.name]?.externals ?? [];
61
+ for (const field of config.imports.shippedFields) for (const name of entry.manifest[field]) {
62
+ const outside = !workspace.byName.has(name) && !config.workspaceScopes.some((scope) => name.startsWith(scope));
63
+ const installed = entry.aliases.get(name) ?? name;
64
+ if (outside && kindRule !== void 0 && kindRule.externals !== "any" && !kindRule.externals.includes(installed) && !extra.includes(installed)) findings.push({
65
+ rule: "forbidden-external",
66
+ file: manifest,
67
+ ...at,
68
+ message: `${field} names ${installed === name ? name : `${name} (${installed})`}, which its kind does not allow`,
69
+ target: installed
70
+ });
71
+ }
72
+ for (const field of config.imports.manifestEdgeFields) for (const name of entry.manifest[field]) {
73
+ const to = workspace.byName.get(name);
74
+ if (to === void 0) {
75
+ if (config.workspaceScopes.some((scope) => name.startsWith(scope))) findings.push({
76
+ rule: "unknown-workspace-package",
77
+ file: manifest,
78
+ ...at,
79
+ message: `${field} names ${name}, which is not a workspace package`,
80
+ target: name
81
+ });
82
+ continue;
83
+ }
84
+ const refusal = edgeRefusal(engine, entry, to, true);
85
+ if (refusal !== void 0 && to.name !== entry.name) findings.push({
86
+ rule: refusal.rule,
87
+ file: manifest,
88
+ ...at,
89
+ message: `${field}: ${refusal.why}`,
90
+ target: name
91
+ });
92
+ }
93
+ }
94
+ findings.push(...packageCycles(engine));
95
+ return findings;
96
+ };
97
+ const isDirectory = (absolute) => {
98
+ try {
99
+ return statSync(absolute).isDirectory();
100
+ } catch {
101
+ return false;
102
+ }
103
+ };
104
+ /** Whether `absolute`, links followed, is something other than a directory; false when nothing is there. */
105
+ const isFile = (absolute) => {
106
+ try {
107
+ return !statSync(absolute).isDirectory();
108
+ } catch {
109
+ return false;
110
+ }
111
+ };
112
+ /** The error code of a directory that cannot be listed, or undefined when it can. */
113
+ const unreadable = (absolute) => {
114
+ try {
115
+ readdirSync(absolute);
116
+ return;
117
+ } catch (cause) {
118
+ return codeOf(cause);
119
+ }
120
+ };
121
+ /**
122
+ * What the root's `workspaces` globs (and a kind's globs) match that is no package: a directory without a
123
+ * `package.json`, whose files no package owns and so nothing checks (only the topmost of nested ones); a dot directory,
124
+ * which no workspaces glob matches; a directory the globs cannot list; and a symbolic link to a directory (or to
125
+ * nothing), which is never followed. Inside a member the walk reports these, so they are left to it. A directory above a member (under `packages/**`), one a negated glob or `ignore` leaves out, and
126
+ * anything under `node_modules` or `.git` are not reported.
127
+ */
128
+ const directoriesWithoutPackage = (engine, members, unlistedByKinds) => {
129
+ const { workspace } = engine;
130
+ const negated = workspace.globs.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1).replace(/^\.\//u, "").replace(/\/$/u, ""));
131
+ const inside = (path) => [...members].some((member) => path === member || path.startsWith(`${member}/`));
132
+ const above = (path) => [...members].some((member) => member.startsWith(`${path}/`));
133
+ const leftOut = (path) => path.split("/").some(passedOver) || negated.some((pattern) => matchesGlob(path, pattern) || coversDirectory(pattern, path)) || ignoresDirectory(engine, path);
134
+ const candidates = /* @__PURE__ */ new Map();
135
+ const unlisted = {
136
+ links: [...unlistedByKinds.links],
137
+ unreadable: [...unlistedByKinds.unreadable],
138
+ dotDirectories: []
139
+ };
140
+ for (const glob of workspace.globs.filter((pattern) => !pattern.startsWith("!"))) for (const directory of directoriesMatching(workspace.root, glob, unlisted)) if (!candidates.has(directory)) candidates.set(directory, glob);
141
+ const withoutPackage = (directory, glob) => {
142
+ if (above(directory) || !isDirectory(nodePath.join(workspace.root, directory))) return;
143
+ const code = unreadable(nodePath.join(workspace.root, directory));
144
+ return code === void 0 ? {
145
+ rule: "directory-without-package",
146
+ file: directory,
147
+ line: 1,
148
+ column: 0,
149
+ message: `${directory} matches the workspaces glob ${glob} but has no package.json, so no package owns what it holds and nothing in it is checked: make it a package, remove it, or add ${directory}/** to ignore`,
150
+ target: directory
151
+ } : unreadablePathFinding(directory, code);
152
+ };
153
+ const dotDirectory = (directory) => ({
154
+ rule: "directory-without-package",
155
+ file: directory,
156
+ line: 1,
157
+ column: 0,
158
+ message: `${directory} is a dot directory, which workspaces globs never match, so it is no package${existsSync(nodePath.join(workspace.root, directory, "package.json")) ? " though it has a package.json" : ""} and nothing in it is checked: rename it, list it in workspaces by its full path, remove it, or add ${directory}/** to ignore`,
159
+ target: directory
160
+ });
161
+ const pending = [
162
+ ...unlisted.unreadable.map(({ directory, code }) => ({
163
+ path: directory,
164
+ finding: () => unreadablePathFinding(directory, code)
165
+ })),
166
+ ...[...candidates].map(([directory, glob]) => ({
167
+ path: directory,
168
+ finding: () => withoutPackage(directory, glob)
169
+ })),
170
+ ...unlisted.dotDirectories.map((directory) => ({
171
+ path: directory,
172
+ finding: () => dotDirectory(directory)
173
+ })),
174
+ ...unlisted.links.map((link) => ({
175
+ path: link,
176
+ finding: () => isFile(nodePath.join(workspace.root, link)) ? void 0 : symbolicLinkFinding(link, linkDestination(nodePath.join(workspace.root, link)))
177
+ }))
178
+ ].toSorted((left, right) => left.path.split("/").length - right.path.split("/").length || left.path.localeCompare(right.path));
179
+ const findings = /* @__PURE__ */ new Map();
180
+ for (const { path, finding } of pending) {
181
+ const reportedAbove = [...findings.keys()].some((reported) => path.startsWith(`${reported}/`));
182
+ if (findings.has(path) || inside(path) || leftOut(path) || reportedAbove) continue;
183
+ const made = finding();
184
+ if (made !== void 0) findings.set(path, made);
185
+ }
186
+ return [...findings.values()].toSorted((left, right) => left.file.localeCompare(right.file));
187
+ };
188
+ /**
189
+ * Tarjan's strongly connected components, iterative so that a long chain of packages cannot exhaust the stack. The
190
+ * search starts from `roots` in order and follows each node's `successors` in order, so components and routes are
191
+ * deterministic.
192
+ */
193
+ const stronglyConnectedComponents = (roots, successors) => {
194
+ const index = /* @__PURE__ */ new Map();
195
+ const lowLink = /* @__PURE__ */ new Map();
196
+ const stack = [];
197
+ const onStack = /* @__PURE__ */ new Set();
198
+ const frames = [];
199
+ const pathPosition = /* @__PURE__ */ new Map();
200
+ const components = [];
201
+ const routes = [];
202
+ const enter = (name) => {
203
+ const order = index.size;
204
+ index.set(name, order);
205
+ lowLink.set(name, order);
206
+ stack.push(name);
207
+ onStack.add(name);
208
+ pathPosition.set(name, frames.length);
209
+ frames.push({
210
+ name,
211
+ next: 0
212
+ });
213
+ };
214
+ for (const root of roots) {
215
+ if (index.has(root)) continue;
216
+ enter(root);
217
+ for (let frame = frames.at(-1); frame !== void 0; frame = frames.at(-1)) {
218
+ const target = (successors.get(frame.name) ?? [])[frame.next];
219
+ if (target !== void 0) {
220
+ frame.next += 1;
221
+ if (!index.has(target)) enter(target);
222
+ else if (onStack.has(target)) {
223
+ lowLink.set(frame.name, Math.min(lowLink.get(frame.name) ?? 0, index.get(target) ?? 0));
224
+ const position = pathPosition.get(target);
225
+ if (position !== void 0) routes.push([...frames.slice(position).map((onPath) => onPath.name), target]);
226
+ }
227
+ continue;
228
+ }
229
+ frames.pop();
230
+ pathPosition.delete(frame.name);
231
+ const link = lowLink.get(frame.name) ?? 0;
232
+ if (link === index.get(frame.name)) {
233
+ const component = [];
234
+ for (let member = stack.pop(); member !== void 0; member = member === frame.name ? void 0 : stack.pop()) {
235
+ onStack.delete(member);
236
+ component.push(member);
237
+ }
238
+ components.push(component);
239
+ }
240
+ const parent = frames.at(-1);
241
+ if (parent !== void 0) lowLink.set(parent.name, Math.min(lowLink.get(parent.name) ?? 0, link));
242
+ }
243
+ }
244
+ return {
245
+ components,
246
+ routes
247
+ };
248
+ };
249
+ /**
250
+ * Cycles between workspace packages through their runtime dependency fields. Every route the search closes is a
251
+ * finding, once per set of packages, starting at the package the search reached first; so a component with several
252
+ * cycles can have several findings.
253
+ */
254
+ const packageCycles = (engine) => {
255
+ const { workspace } = engine;
256
+ 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)]));
257
+ const findings = [];
258
+ const seen = /* @__PURE__ */ new Set();
259
+ for (const cycle of stronglyConnectedComponents([...edges.keys()].toSorted(), edges).routes) {
260
+ const key = cycle.slice(0, -1).toSorted().join(" ");
261
+ if (!seen.has(key)) {
262
+ seen.add(key);
263
+ const first = cycle[0] ?? "";
264
+ findings.push({
265
+ rule: "package-cycle",
266
+ file: `${workspace.byName.get(first)?.directory ?? first}/package.json`,
267
+ line: 1,
268
+ column: 0,
269
+ message: `dependency cycle: ${cycle.join(" -> ")}`,
270
+ target: key
271
+ });
272
+ }
273
+ }
274
+ return findings;
275
+ };
276
+ /** Baseline entries that no finding in `findings` needs; each is a finding of its own, so the baseline only shrinks. */
277
+ const staleBaseline = (engine, findings) => engine.config.baseline.filter((entry) => !findings.some((finding) => baselineCovers(entry, finding))).map((entry) => ({
278
+ rule: "stale-baseline",
279
+ file: entry.file,
280
+ line: 1,
281
+ column: 0,
282
+ message: `the baseline entry for ${entry.rule}${entry.target === void 0 ? "" : ` (${entry.target})`} is no longer needed: remove it`,
283
+ target: entry.rule
284
+ }));
285
+ // src/check.ts
286
+ /** The whole check: every source file of the workspace through the engine, plus the whole-graph checks and the baseline. */
287
+ /**
288
+ * Every source file the engine reads, found by walking each package's directory (skipping `node_modules` and `.git`;
289
+ * other dot directories hold sources too). A walk rather than `git ls-files`: generated and gitignored sources still
290
+ * ship, and a nested repository or submodule inside a package is still part of it. A symbolic link is never followed:
291
+ * every one is a finding, whatever it points at, unless it lies in a directory the check skips on purpose; and so is a
292
+ * directory the walk cannot list.
293
+ */
294
+ const walkSources = (engine) => {
295
+ const files = /* @__PURE__ */ new Set();
296
+ const findings = /* @__PURE__ */ new Map();
297
+ const { root } = engine.workspace;
298
+ const walk = (relative) => {
299
+ let entries;
300
+ try {
301
+ entries = readdirSync(nodePath.join(root, relative), { withFileTypes: true });
302
+ } catch (cause) {
303
+ if (!skipsDirectory(engine, relative)) findings.set(relative, unreadablePathFinding(relative, codeOf(cause)));
304
+ return;
305
+ }
306
+ for (const entry of entries) {
307
+ const path = `${relative}/${entry.name}`;
308
+ if (entry.isDirectory()) {
309
+ if (!passedOver(entry.name)) walk(path);
310
+ } else if (entry.isSymbolicLink()) {
311
+ if (reportsLink(engine, path)) findings.set(path, symbolicLinkFinding(path, linkDestination(nodePath.join(root, path))));
312
+ } else if (entry.isFile() && reads(engine, path)) files.add(path);
313
+ }
314
+ };
315
+ for (const entry of engine.workspace.packages) walk(entry.directory);
316
+ return {
317
+ files: [...files].toSorted(),
318
+ findings: [...findings.values()].toSorted((left, right) => left.file.localeCompare(right.file))
319
+ };
320
+ };
321
+ /** The source files `walkSources` finds. */
322
+ const listSourceFiles = (engine) => [...walkSources(engine).files];
323
+ const checkWorkspace = (root, config) => {
324
+ const engine = createEngine(root, config);
325
+ const { files, findings: walked } = walkSources(engine);
326
+ const perFile = files.flatMap((file) => {
327
+ const text = attempt(() => readFileSync(nodePath.join(root, file), "utf8"));
328
+ return text.ok ? findingsForFile(engine, file, text.value) : [unreadablePathFinding(file, codeOf(text.error), "file")];
329
+ }).filter((finding) => finding.rule !== "package-without-kind");
330
+ const all = [
331
+ ...walked,
332
+ ...perFile,
333
+ ...graphFindings(engine)
334
+ ];
335
+ return {
336
+ files: files.length,
337
+ findings: [...withoutBaselined(engine, all), ...staleBaseline(engine, all)]
338
+ };
339
+ };
340
+ const formatFinding = (finding) => `${finding.file}:${finding.line}:${finding.column + 1} ${finding.rule}: ${finding.message}`;
341
+ export { graphFindings as a, walkSources as i, formatFinding as n, staleBaseline as o, listSourceFiles as r, checkWorkspace as t };
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-CmWm5RyK.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 { y as findWorkspaceRoot } from "./engine-DmECSDnu.mjs";
3
+ import { n as formatFinding, t as checkWorkspace } from "./check-BbeuFBNh.mjs";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import nodePath from "node:path";
6
6
  import process from "node:process";
@@ -33,7 +33,7 @@ interface FileClass {
33
33
  readonly files: readonly string[];
34
34
  readonly policy: FilePolicy;
35
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"];
36
+ declare const ruleNames: readonly ["unknown-workspace-package", "package-without-kind", "package-outside-workspace", "directory-without-package", "invalid-package", "symbolic-link", "unreadable-path", "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
37
  type RuleName = (typeof ruleNames)[number];
38
38
  /** A finding the repository tolerates for now. The check fails when no finding needs it any more, so the list shrinks. */
39
39
  interface BaselineEntry {
@@ -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 };