@collective-intelligence/lint 0.0.0-stage → 0.1.21
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/README.md +171 -2
- package/dist/index.d.ts +10 -0
- package/dist/index.js +354 -0
- package/dist/rule.d.ts +7 -0
- package/dist/rules/index.d.ts +7 -0
- package/dist/rules/package-entrypoint/package-entrypoint.d.ts +2 -0
- package/dist/rules/subpath-entrypoint/subpath-entrypoint.d.ts +2 -0
- package/dist/rules/subpath-imports/subpath-imports.d.ts +2 -0
- package/dist/rules/subpath-index/subpath-index.d.ts +2 -0
- package/dist/workspace/layout.d.ts +11 -0
- package/dist/workspace/package-exports.d.ts +19 -0
- package/dist/workspace/workspace.d.ts +3 -0
- package/package.json +48 -4
package/README.md
CHANGED
|
@@ -1,3 +1,172 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @collective-intelligence/lint
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Collective Intelligence lint rules, as one Oxlint plugin. Every CI lint
|
|
4
|
+
rule lives in this package. The plugin is named `ci-solution`, so each rule is
|
|
5
|
+
enabled as `ci-solution/<rule>`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add -d oxlint @collective-intelligence/lint
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The plugin needs `oxlint` 1.82.0 or later.
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
### All rules
|
|
18
|
+
|
|
19
|
+
Extend the `recommended` config in `oxlint.config.ts`. It loads the plugin and
|
|
20
|
+
enables every rule as an error:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { recommended } from "@collective-intelligence/lint"
|
|
24
|
+
import { defineConfig } from "oxlint"
|
|
25
|
+
|
|
26
|
+
export default defineConfig({
|
|
27
|
+
extends: [recommended]
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
To change one rule, set it after the extend:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
export default defineConfig({
|
|
35
|
+
extends: [recommended],
|
|
36
|
+
rules: { "ci-solution/subpath-index": "warn" }
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Selected rules
|
|
41
|
+
|
|
42
|
+
Load the plugin and enable only the rules you want:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { defineConfig } from "oxlint"
|
|
46
|
+
|
|
47
|
+
export default defineConfig({
|
|
48
|
+
jsPlugins: ["@collective-intelligence/lint"],
|
|
49
|
+
rules: {
|
|
50
|
+
"ci-solution/package-entrypoint": "error",
|
|
51
|
+
"ci-solution/subpath-imports": "error"
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A JSON config works the same way:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"jsPlugins": ["@collective-intelligence/lint"],
|
|
61
|
+
"rules": { "ci-solution/package-entrypoint": "error" }
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The `RuleName` type lists every rule name.
|
|
66
|
+
|
|
67
|
+
## Rules
|
|
68
|
+
|
|
69
|
+
| Rule | Enforces | Group |
|
|
70
|
+
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------------ |
|
|
71
|
+
| `ci-solution/package-entrypoint` | An import of another package targets an entrypoint declared in its `package.json` exports. | Package boundaries |
|
|
72
|
+
| `ci-solution/subpath-imports` | Imports between subpath folders follow the allowed direction. | Solution layout |
|
|
73
|
+
| `ci-solution/subpath-entrypoint` | An import from another subpath folder targets the folder or its index file. | Solution layout |
|
|
74
|
+
| `ci-solution/subpath-index` | Every subpath folder has an `index.ts` or `index.tsx`. | Solution layout |
|
|
75
|
+
|
|
76
|
+
### Package boundaries
|
|
77
|
+
|
|
78
|
+
A workspace holds packages that import each other. These rules keep each
|
|
79
|
+
package behind its declared public entrypoints.
|
|
80
|
+
|
|
81
|
+
#### `package-entrypoint`
|
|
82
|
+
|
|
83
|
+
Import another package through its root (`@scope/pkg`) or a subpath listed in
|
|
84
|
+
its `package.json` `exports` (`@scope/pkg/views`). `*` patterns and `null`
|
|
85
|
+
exclusions follow Node resolution. The rule finds the package through
|
|
86
|
+
`node_modules` from the importing file, so workspace packages must be linked by
|
|
87
|
+
the package manager.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { invoiceTotal } from "@acme/billing" // valid
|
|
91
|
+
import { invoiceTable } from "@acme/billing/views" // valid when "./views" is exported
|
|
92
|
+
import { rate } from "@acme/billing/src/domain/rates" // reported
|
|
93
|
+
import { rate } from "../../billing/src/domain/rates" // reported: relative path into another package
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A package without an `exports` field accepts every subpath except `src/`.
|
|
97
|
+
Packages that cannot be found are not checked.
|
|
98
|
+
|
|
99
|
+
### Solution layout
|
|
100
|
+
|
|
101
|
+
A solution package can split its sources into subpath folders:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
src/
|
|
105
|
+
ci/ Collective Intelligence entity and capability declarations
|
|
106
|
+
domain/ domain model, free of runtime concerns
|
|
107
|
+
api/ API contracts
|
|
108
|
+
server/ server handlers and runtime wiring
|
|
109
|
+
headless/ UI state without rendering
|
|
110
|
+
views/ rendered UI
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
These rules keep those folders apart.
|
|
114
|
+
|
|
115
|
+
#### `subpath-imports`
|
|
116
|
+
|
|
117
|
+
| Folder | Must not import |
|
|
118
|
+
| ----------- | --------------------------------------- |
|
|
119
|
+
| `domain/` | `server/`, `views/`, `headless/`, `ci/` |
|
|
120
|
+
| `ci/` | `views/`, `headless/` |
|
|
121
|
+
| `headless/` | `server/` |
|
|
122
|
+
| `views/` | `server/` |
|
|
123
|
+
| `api/` | no limit |
|
|
124
|
+
| `server/` | no limit |
|
|
125
|
+
|
|
126
|
+
The rule checks relative imports and imports through the package's own name
|
|
127
|
+
(`@acme/billing/server` from `src/views/`).
|
|
128
|
+
|
|
129
|
+
#### `subpath-entrypoint`
|
|
130
|
+
|
|
131
|
+
An import from another subpath folder targets the folder or its index file.
|
|
132
|
+
Imports inside one subpath folder are free.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// src/views/table.ts
|
|
136
|
+
import { invoiceTotal } from "../domain" // valid
|
|
137
|
+
import { invoiceTotal } from "../domain/index.ts" // valid
|
|
138
|
+
import { rate } from "../domain/rates" // reported
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
#### `subpath-index`
|
|
142
|
+
|
|
143
|
+
Every subpath folder under `src/` has an `index.ts` or `index.tsx` entrypoint.
|
|
144
|
+
The rule reports each file in a folder that has neither.
|
|
145
|
+
|
|
146
|
+
## Adding a rule
|
|
147
|
+
|
|
148
|
+
Each rule is a plain Oxlint rule in its own folder, `src/rules/<rule>/`:
|
|
149
|
+
|
|
150
|
+
1. Write `src/rules/<rule>/<rule>.ts`. Export a `LintRule` (`src/rule.ts`)
|
|
151
|
+
with:
|
|
152
|
+
- `group`: the rule group in the table above (add a group to `RuleGroup`
|
|
153
|
+
when no group fits);
|
|
154
|
+
- `rule`: the rule from `defineRule` of `@oxlint/plugins`, with a
|
|
155
|
+
`messages` entry in `meta` for each finding. `moduleSpecifiers`
|
|
156
|
+
(`src/rule.ts`) builds a visitor that checks every module specifier.
|
|
157
|
+
|
|
158
|
+
A rule reads the workspace through `src/workspace/`: `packageOf` and
|
|
159
|
+
`installedPackage` find package manifests, `declaresEntrypoint` checks
|
|
160
|
+
`exports`, and `layoutFile` finds the subpath of a file.
|
|
161
|
+
|
|
162
|
+
2. Add one line to the registry, `src/rules/index.ts`. The plugin, the
|
|
163
|
+
`recommended` config and the `RuleName` type follow from it.
|
|
164
|
+
3. Write `src/rules/<rule>/<rule>.test.ts` against the fixture workspace in
|
|
165
|
+
`test/fixtures/workspace/`, and add fixture files for the new cases. Add the
|
|
166
|
+
new diagnostics to the expectations of `src/index.test.ts`.
|
|
167
|
+
4. Add the rule to the table above, and to `CHANGELOG.md`.
|
|
168
|
+
|
|
169
|
+
Oxlint calls rule visitors synchronously, so a rule reads files with the
|
|
170
|
+
synchronous `node:fs` API: it cannot wait for a promise or a timer. The
|
|
171
|
+
workspace lookups cache each folder they visit, so each package.json is read
|
|
172
|
+
once per plugin load.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Plugin } from "@oxlint/plugins";
|
|
2
|
+
import { rules } from "./rules/index.ts";
|
|
3
|
+
declare const plugin: Plugin;
|
|
4
|
+
export type RuleName = `ci-solution/${keyof typeof rules}`;
|
|
5
|
+
export interface RecommendedConfig {
|
|
6
|
+
jsPlugins: string[];
|
|
7
|
+
rules: Record<RuleName, "error">;
|
|
8
|
+
}
|
|
9
|
+
export declare const recommended: RecommendedConfig;
|
|
10
|
+
export default plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { definePlugin } from "@oxlint/plugins";
|
|
3
|
+
|
|
4
|
+
// src/rules/package-entrypoint/package-entrypoint.ts
|
|
5
|
+
import { defineRule } from "@oxlint/plugins";
|
|
6
|
+
import path2 from "node:path";
|
|
7
|
+
|
|
8
|
+
// src/rule.ts
|
|
9
|
+
var isStringLiteral = (node) => node?.type === "Literal" && String(node.value) === node.value;
|
|
10
|
+
var moduleSpecifiers = (check) => {
|
|
11
|
+
const visit = (node) => {
|
|
12
|
+
if (isStringLiteral(node)) {
|
|
13
|
+
check(node);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
CallExpression: (node) => {
|
|
18
|
+
if (node.callee.type === "Identifier" && node.callee.name === "require" && node.arguments.length === 1) {
|
|
19
|
+
visit(node.arguments[0] ?? null);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
ExportAllDeclaration: (node) => {
|
|
23
|
+
visit(node.source);
|
|
24
|
+
},
|
|
25
|
+
ExportNamedDeclaration: (node) => {
|
|
26
|
+
visit(node.source);
|
|
27
|
+
},
|
|
28
|
+
ImportDeclaration: (node) => {
|
|
29
|
+
visit(node.source);
|
|
30
|
+
},
|
|
31
|
+
ImportExpression: (node) => {
|
|
32
|
+
visit(node.source);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// src/workspace/package-exports.ts
|
|
38
|
+
var parseExports = (exports) => {
|
|
39
|
+
const subpaths = exports instanceof Object && !Array.isArray(exports) ? Object.entries(exports) : [];
|
|
40
|
+
return subpaths.some(([key]) => key.startsWith(".")) ? new Map(subpaths.map(([key, target]) => [key, target !== null])) : new Map([[".", exports !== null]]);
|
|
41
|
+
};
|
|
42
|
+
var packageInfo = (root, fields) => ({
|
|
43
|
+
exports: fields.exports === undefined ? null : parseExports(fields.exports),
|
|
44
|
+
name: fields.name ?? null,
|
|
45
|
+
root
|
|
46
|
+
});
|
|
47
|
+
var parsePackageSpecifier = (specifier) => {
|
|
48
|
+
if (/^(?:[./#]|[a-z][a-z\d+.-]*:)/iu.test(specifier)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const parts = specifier.split("/");
|
|
52
|
+
const nameLength = specifier.startsWith("@") ? 2 : 1;
|
|
53
|
+
return {
|
|
54
|
+
name: parts.slice(0, nameLength).join("/"),
|
|
55
|
+
subpath: parts.slice(nameLength).join("/")
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
var matchPattern = (exports, key) => {
|
|
59
|
+
let best = null;
|
|
60
|
+
for (const [pattern, exported] of exports) {
|
|
61
|
+
const star = pattern.indexOf("*");
|
|
62
|
+
const prefix = pattern.slice(0, star);
|
|
63
|
+
const suffix = pattern.slice(star + 1);
|
|
64
|
+
if (star !== -1 && key.length >= pattern.length && key.startsWith(prefix) && key.endsWith(suffix) && (best === null || prefix.length > best.prefix)) {
|
|
65
|
+
best = { exported, prefix: prefix.length };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return best?.exported === true;
|
|
69
|
+
};
|
|
70
|
+
var declaresEntrypoint = (target, subpath) => {
|
|
71
|
+
if (target.exports === null) {
|
|
72
|
+
return subpath !== "src" && !subpath.startsWith("src/");
|
|
73
|
+
}
|
|
74
|
+
const key = subpath === "" ? "." : `./${subpath}`;
|
|
75
|
+
return target.exports.get(key) ?? matchPattern(target.exports, key);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// src/workspace/workspace.ts
|
|
79
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
80
|
+
import path from "node:path";
|
|
81
|
+
var readPackage = (root) => {
|
|
82
|
+
const file = path.join(root, "package.json");
|
|
83
|
+
if (!existsSync(file)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const fields = JSON.parse(readFileSync(file, "utf-8"));
|
|
87
|
+
return packageInfo(root, fields);
|
|
88
|
+
};
|
|
89
|
+
var owners = new Map;
|
|
90
|
+
var installed = new Map;
|
|
91
|
+
var packageOf = (target) => {
|
|
92
|
+
const cached = owners.get(target);
|
|
93
|
+
if (cached !== undefined) {
|
|
94
|
+
return cached;
|
|
95
|
+
}
|
|
96
|
+
const parent = path.dirname(target);
|
|
97
|
+
const owner = readPackage(target) ?? (parent === target ? null : packageOf(parent));
|
|
98
|
+
owners.set(target, owner);
|
|
99
|
+
return owner;
|
|
100
|
+
};
|
|
101
|
+
var installedPackage = (directory, name) => {
|
|
102
|
+
const key = `${directory}\x00${name}`;
|
|
103
|
+
const cached = installed.get(key);
|
|
104
|
+
if (cached !== undefined) {
|
|
105
|
+
return cached;
|
|
106
|
+
}
|
|
107
|
+
const parent = path.dirname(directory);
|
|
108
|
+
const found = readPackage(path.join(directory, "node_modules", name)) ?? (parent === directory ? null : installedPackage(parent, name));
|
|
109
|
+
installed.set(key, found);
|
|
110
|
+
return found;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// src/rules/package-entrypoint/package-entrypoint.ts
|
|
114
|
+
var packageName = (target) => target.name ?? path2.basename(target.root);
|
|
115
|
+
var packageEntrypoint = {
|
|
116
|
+
group: "package-boundaries",
|
|
117
|
+
rule: defineRule({
|
|
118
|
+
create(context) {
|
|
119
|
+
const directory = path2.dirname(context.filename);
|
|
120
|
+
const owner = packageOf(directory);
|
|
121
|
+
if (owner === null) {
|
|
122
|
+
return {};
|
|
123
|
+
}
|
|
124
|
+
return moduleSpecifiers((node) => {
|
|
125
|
+
const specifier = node.value;
|
|
126
|
+
if (specifier.startsWith(".")) {
|
|
127
|
+
const target = packageOf(path2.resolve(directory, specifier));
|
|
128
|
+
if (target !== null && target.root !== owner.root) {
|
|
129
|
+
context.report({
|
|
130
|
+
data: { name: packageName(target), specifier },
|
|
131
|
+
messageId: "relative",
|
|
132
|
+
node
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const parsed = parsePackageSpecifier(specifier);
|
|
138
|
+
if (parsed === null) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const target = parsed.name === owner.name ? owner : installedPackage(directory, parsed.name);
|
|
142
|
+
if (target !== null && !declaresEntrypoint(target, parsed.subpath)) {
|
|
143
|
+
context.report({
|
|
144
|
+
data: { name: parsed.name, specifier },
|
|
145
|
+
messageId: "undeclared",
|
|
146
|
+
node
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
meta: {
|
|
152
|
+
docs: {
|
|
153
|
+
description: "Require imports of another package to target an entrypoint declared in its package.json exports."
|
|
154
|
+
},
|
|
155
|
+
messages: {
|
|
156
|
+
relative: "`{{specifier}}` reaches into package `{{name}}` through a relative path. Import it by its package name and a declared entrypoint.",
|
|
157
|
+
undeclared: "`{{specifier}}` is not a declared entrypoint of `{{name}}`. Import the package root or a subpath listed in its package.json exports."
|
|
158
|
+
},
|
|
159
|
+
type: "problem"
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// src/rules/subpath-entrypoint/subpath-entrypoint.ts
|
|
165
|
+
import { defineRule as defineRule2 } from "@oxlint/plugins";
|
|
166
|
+
import path4 from "node:path";
|
|
167
|
+
|
|
168
|
+
// src/workspace/layout.ts
|
|
169
|
+
import path3 from "node:path";
|
|
170
|
+
var SUBPATHS = [
|
|
171
|
+
"ci",
|
|
172
|
+
"domain",
|
|
173
|
+
"api",
|
|
174
|
+
"server",
|
|
175
|
+
"headless",
|
|
176
|
+
"views"
|
|
177
|
+
];
|
|
178
|
+
var isSubpath = (folder) => SUBPATHS.some((subpath) => subpath === folder);
|
|
179
|
+
var subpathOfSegments = (segments) => {
|
|
180
|
+
const [folder] = segments ?? [];
|
|
181
|
+
return isSubpath(folder) ? folder : null;
|
|
182
|
+
};
|
|
183
|
+
var sourceSegments = (root, target) => {
|
|
184
|
+
const relative = path3.relative(path3.join(root, "src"), target);
|
|
185
|
+
return relative === "" || relative.startsWith("..") || path3.isAbsolute(relative) ? null : relative.split(path3.sep);
|
|
186
|
+
};
|
|
187
|
+
var isSubpathEntrypoint = (segments) => {
|
|
188
|
+
const [, file] = segments;
|
|
189
|
+
return segments.length === 1 || segments.length === 2 && path3.parse(file ?? "").name === "index";
|
|
190
|
+
};
|
|
191
|
+
var layoutFile = (filename) => {
|
|
192
|
+
const owner = packageOf(path3.dirname(filename));
|
|
193
|
+
const segments = owner && sourceSegments(owner.root, filename);
|
|
194
|
+
const subpath = segments && segments.length > 1 ? subpathOfSegments(segments) : null;
|
|
195
|
+
return owner && subpath ? { owner, subpath } : null;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// src/rules/subpath-entrypoint/subpath-entrypoint.ts
|
|
199
|
+
var subpathEntrypoint = {
|
|
200
|
+
group: "solution-layout",
|
|
201
|
+
rule: defineRule2({
|
|
202
|
+
create(context) {
|
|
203
|
+
const directory = path4.dirname(context.filename);
|
|
204
|
+
const owner = packageOf(directory);
|
|
205
|
+
const source = owner === null ? null : sourceSegments(owner.root, context.filename);
|
|
206
|
+
if (owner === null || source === null) {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
const sourceSubpath = source.length > 1 ? subpathOfSegments(source) : null;
|
|
210
|
+
return moduleSpecifiers((node) => {
|
|
211
|
+
if (!node.value.startsWith(".")) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const target = sourceSegments(owner.root, path4.resolve(directory, node.value));
|
|
215
|
+
const targetSubpath = subpathOfSegments(target);
|
|
216
|
+
if (target !== null && targetSubpath !== null && targetSubpath !== sourceSubpath && !isSubpathEntrypoint(target)) {
|
|
217
|
+
context.report({
|
|
218
|
+
data: { specifier: node.value, target: targetSubpath },
|
|
219
|
+
messageId: "deep",
|
|
220
|
+
node
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
meta: {
|
|
226
|
+
docs: {
|
|
227
|
+
description: "Require imports from another subpath to target the subpath folder or its index file."
|
|
228
|
+
},
|
|
229
|
+
messages: {
|
|
230
|
+
deep: "`{{specifier}}` reaches past the `{{target}}/` entrypoint. Import the subpath folder, which resolves to its index.ts or index.tsx."
|
|
231
|
+
},
|
|
232
|
+
type: "problem"
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// src/rules/subpath-imports/subpath-imports.ts
|
|
238
|
+
import { defineRule as defineRule3 } from "@oxlint/plugins";
|
|
239
|
+
import path5 from "node:path";
|
|
240
|
+
var FORBIDDEN_IMPORTS = {
|
|
241
|
+
api: [],
|
|
242
|
+
ci: ["views", "headless"],
|
|
243
|
+
domain: ["server", "views", "headless", "ci"],
|
|
244
|
+
headless: ["server"],
|
|
245
|
+
server: [],
|
|
246
|
+
views: ["server"]
|
|
247
|
+
};
|
|
248
|
+
var subpathImports = {
|
|
249
|
+
group: "solution-layout",
|
|
250
|
+
rule: defineRule3({
|
|
251
|
+
create(context) {
|
|
252
|
+
const file = layoutFile(context.filename);
|
|
253
|
+
if (file === null) {
|
|
254
|
+
return {};
|
|
255
|
+
}
|
|
256
|
+
const { owner, subpath } = file;
|
|
257
|
+
const directory = path5.dirname(context.filename);
|
|
258
|
+
const forbidden = FORBIDDEN_IMPORTS[subpath];
|
|
259
|
+
const targetSubpath = (specifier) => {
|
|
260
|
+
if (specifier.startsWith(".")) {
|
|
261
|
+
return subpathOfSegments(sourceSegments(owner.root, path5.resolve(directory, specifier)));
|
|
262
|
+
}
|
|
263
|
+
const parsed = parsePackageSpecifier(specifier);
|
|
264
|
+
return parsed !== null && parsed.name === owner.name ? subpathOfSegments(parsed.subpath.split("/")) : null;
|
|
265
|
+
};
|
|
266
|
+
return moduleSpecifiers((node) => {
|
|
267
|
+
const target = targetSubpath(node.value);
|
|
268
|
+
if (target !== null && forbidden.includes(target)) {
|
|
269
|
+
context.report({
|
|
270
|
+
data: {
|
|
271
|
+
forbidden: forbidden.map((name) => `\`${name}/\``).join(", "),
|
|
272
|
+
source: subpath,
|
|
273
|
+
specifier: node.value,
|
|
274
|
+
target
|
|
275
|
+
},
|
|
276
|
+
messageId: "forbidden",
|
|
277
|
+
node
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
meta: {
|
|
283
|
+
docs: {
|
|
284
|
+
description: "Disallow imports between subpaths that break the solution package layout direction."
|
|
285
|
+
},
|
|
286
|
+
messages: {
|
|
287
|
+
forbidden: "`{{specifier}}` imports `{{target}}/` from `{{source}}/`. `{{source}}/` must not import {{forbidden}}."
|
|
288
|
+
},
|
|
289
|
+
type: "problem"
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// src/rules/subpath-index/subpath-index.ts
|
|
295
|
+
import { defineRule as defineRule4 } from "@oxlint/plugins";
|
|
296
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
297
|
+
import path6 from "node:path";
|
|
298
|
+
var INDEX_FILES = ["index.ts", "index.tsx"];
|
|
299
|
+
var subpathIndex = {
|
|
300
|
+
group: "solution-layout",
|
|
301
|
+
rule: defineRule4({
|
|
302
|
+
create(context) {
|
|
303
|
+
const file = layoutFile(context.filename);
|
|
304
|
+
if (file === null) {
|
|
305
|
+
return {};
|
|
306
|
+
}
|
|
307
|
+
const folder = path6.join(file.owner.root, "src", file.subpath);
|
|
308
|
+
if (INDEX_FILES.some((name) => existsSync2(path6.join(folder, name)))) {
|
|
309
|
+
return {};
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
Program: (node) => {
|
|
313
|
+
context.report({
|
|
314
|
+
data: { subpath: file.subpath },
|
|
315
|
+
messageId: "missing",
|
|
316
|
+
node
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
meta: {
|
|
322
|
+
docs: {
|
|
323
|
+
description: "Require every subpath folder to have an index.ts or index.tsx entrypoint."
|
|
324
|
+
},
|
|
325
|
+
messages: {
|
|
326
|
+
missing: "Subpath folder `src/{{subpath}}/` has no index.ts or index.tsx entrypoint."
|
|
327
|
+
},
|
|
328
|
+
type: "problem"
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// src/rules/index.ts
|
|
334
|
+
var rules = {
|
|
335
|
+
"package-entrypoint": packageEntrypoint,
|
|
336
|
+
"subpath-entrypoint": subpathEntrypoint,
|
|
337
|
+
"subpath-imports": subpathImports,
|
|
338
|
+
"subpath-index": subpathIndex
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// src/index.ts
|
|
342
|
+
var plugin = definePlugin({
|
|
343
|
+
meta: { name: "ci-solution" },
|
|
344
|
+
rules: Object.fromEntries(Object.entries(rules).map(([name, { rule }]) => [name, rule]))
|
|
345
|
+
});
|
|
346
|
+
var recommended = {
|
|
347
|
+
jsPlugins: ["@collective-intelligence/lint"],
|
|
348
|
+
rules: Object.fromEntries(Object.keys(rules).map((name) => [`ci-solution/${name}`, "error"]))
|
|
349
|
+
};
|
|
350
|
+
var src_default = plugin;
|
|
351
|
+
export {
|
|
352
|
+
src_default as default,
|
|
353
|
+
recommended
|
|
354
|
+
};
|
package/dist/rule.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ESTree, Rule, Visitor } from "@oxlint/plugins";
|
|
2
|
+
export type RuleGroup = "package-boundaries" | "solution-layout";
|
|
3
|
+
export interface LintRule {
|
|
4
|
+
readonly group: RuleGroup;
|
|
5
|
+
readonly rule: Rule;
|
|
6
|
+
}
|
|
7
|
+
export declare const moduleSpecifiers: (check: (literal: ESTree.StringLiteral) => void) => Visitor;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PackageInfo } from "./package-exports.ts";
|
|
2
|
+
export declare const SUBPATHS: readonly ["ci", "domain", "api", "server", "headless", "views"];
|
|
3
|
+
export type Subpath = (typeof SUBPATHS)[number];
|
|
4
|
+
export declare const subpathOfSegments: (segments: readonly string[] | null) => Subpath | null;
|
|
5
|
+
export declare const sourceSegments: (root: string, target: string) => readonly string[] | null;
|
|
6
|
+
export declare const isSubpathEntrypoint: (segments: readonly string[]) => boolean;
|
|
7
|
+
export interface LayoutFile {
|
|
8
|
+
readonly owner: PackageInfo;
|
|
9
|
+
readonly subpath: Subpath;
|
|
10
|
+
}
|
|
11
|
+
export declare const layoutFile: (filename: string) => LayoutFile | null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ExportsField = string | null | readonly ExportsField[] | {
|
|
2
|
+
readonly [key: string]: ExportsField;
|
|
3
|
+
};
|
|
4
|
+
export interface Manifest {
|
|
5
|
+
readonly exports?: ExportsField;
|
|
6
|
+
readonly name?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface PackageInfo {
|
|
9
|
+
readonly exports: ReadonlyMap<string, boolean> | null;
|
|
10
|
+
readonly name: string | null;
|
|
11
|
+
readonly root: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const packageInfo: (root: string, fields: Manifest) => PackageInfo;
|
|
14
|
+
export interface PackageSpecifier {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly subpath: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const parsePackageSpecifier: (specifier: string) => PackageSpecifier | null;
|
|
19
|
+
export declare const declaresEntrypoint: (target: PackageInfo, subpath: string) => boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@collective-intelligence/lint",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.21",
|
|
4
|
+
"description": "Oxlint plugin with the Collective Intelligence lint rules: package boundaries and solution package layout.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"ci-source": "./src/index.ts",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "bun tools/build.ts",
|
|
23
|
+
"test": "bun test src && bun run test:pack",
|
|
24
|
+
"test:pack": "bun run build && bun tools/test-pack.ts",
|
|
25
|
+
"test:unit": "bun test src",
|
|
26
|
+
"format": "oxfmt --write .",
|
|
27
|
+
"format:check": "oxfmt --check .",
|
|
28
|
+
"format:check:staged": "oxfmt --check",
|
|
29
|
+
"lint:staged": "oxlint --no-error-on-unmatched-pattern",
|
|
30
|
+
"prepack": "bun run build",
|
|
31
|
+
"type-check": "bun check",
|
|
32
|
+
"lint": "oxlint src test tools"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@oxlint/plugins": "^1.85.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"oxlint": ">=1.82.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"bun-types": "^1.4.2",
|
|
45
|
+
"oxfmt": "0.66.0",
|
|
46
|
+
"oxlint": "1.82.0",
|
|
47
|
+
"typescript": "7.0.2",
|
|
48
|
+
"ultracite": "7.10.7"
|
|
49
|
+
}
|
|
50
|
+
}
|