vigiles 33.0.0 → 33.1.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/dist/core/frame.d.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* 🔴 THE ONE CONVERSION POINT. A `RepoPath` is minted here and nowhere else:
|
|
29
29
|
* {@link Frame.repo} from an absolute path, {@link Bundle.path} from a
|
|
30
30
|
* `BundlePath`. A cast to `RepoPath` outside this file is refused by ESLint
|
|
31
|
-
* (`
|
|
31
|
+
* (`local/frame-mint` in `eslint.config.mjs`), so "where did this path get its
|
|
32
32
|
* frame" always has the answer "in frame.ts".
|
|
33
33
|
*
|
|
34
34
|
* WHAT IS UNREPRESENTABLE AND WHAT IS ONLY DETECTABLE. The CLI's output boundary
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "33.
|
|
3
|
+
"version": "33.1.0",
|
|
4
4
|
"description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -67,6 +67,8 @@
|
|
|
67
67
|
"hooks",
|
|
68
68
|
"skills",
|
|
69
69
|
"!skills/**/*.spec.ts",
|
|
70
|
+
"packages/eslint-config/index.mjs",
|
|
71
|
+
"packages/eslint-config/README.md",
|
|
70
72
|
"README.md",
|
|
71
73
|
"LICENSE"
|
|
72
74
|
],
|
|
@@ -75,7 +77,7 @@
|
|
|
75
77
|
"build:core": "tsc",
|
|
76
78
|
"test": "npm run build && vitest run",
|
|
77
79
|
"coverage": "npm run build && vitest run --coverage",
|
|
78
|
-
"lint": "eslint src/",
|
|
80
|
+
"lint": "eslint src/ && node scripts/lint-ceilings.mjs",
|
|
79
81
|
"fmt": "prettier --write .",
|
|
80
82
|
"fmt:check": "prettier --check .",
|
|
81
83
|
"test:unit": "npm run build && vitest run --project unit",
|
|
@@ -112,7 +114,8 @@
|
|
|
112
114
|
"conventional-changelog-conventionalcommits": "9.3.1",
|
|
113
115
|
"eslint": "^10.1.0",
|
|
114
116
|
"eslint-import-resolver-typescript": "^4.4.5",
|
|
115
|
-
"eslint-plugin-boundaries": "^
|
|
117
|
+
"eslint-plugin-boundaries": "^7.2.0",
|
|
118
|
+
"eslint-plugin-functional": "^10.0.1",
|
|
116
119
|
"eslint-plugin-sonarjs": "^4.0.2",
|
|
117
120
|
"globals": "^17.4.0",
|
|
118
121
|
"jest": "^30.4.2",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @vigiles/eslint-config (private)
|
|
2
|
+
|
|
3
|
+
The strict lint rules vigiles and paperlint share: size limits, functional
|
|
4
|
+
rules, no type assertions, and `layers()`, the hexagonal boundary.
|
|
5
|
+
|
|
6
|
+
**This is not part of vigiles' API.** It has no entry in vigiles' `exports`, no
|
|
7
|
+
version of its own, and it can change in any release without notice. It ships
|
|
8
|
+
inside the `vigiles` npm package only so that paperlint can import it by path:
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
// PRIVATE file of vigiles, not a public API. If it moves, this import fails loudly.
|
|
12
|
+
import { layers } from "./node_modules/vigiles/packages/eslint-config/index.mjs";
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Nobody else should depend on it. The plugins it uses (eslint-plugin-boundaries,
|
|
16
|
+
eslint-plugin-functional, eslint-plugin-sonarjs, typescript-eslint) are not
|
|
17
|
+
bundled; the importing repo installs its own.
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The strict lint rules vigiles and paperlint share.
|
|
4
|
+
*
|
|
5
|
+
* `.mjs`, not `.ts`: ESLint loads a config without a build step, and paperlint
|
|
6
|
+
* imports this file by path from `node_modules/vigiles/packages/eslint-config/`.
|
|
7
|
+
* That import is deliberate and private: vigiles' `exports` has no entry for this
|
|
8
|
+
* file, so nobody else should depend on it.
|
|
9
|
+
*
|
|
10
|
+
* The plugins are not bundled. Each repo installs eslint-plugin-boundaries,
|
|
11
|
+
* eslint-plugin-functional and eslint-plugin-sonarjs itself, and the imports
|
|
12
|
+
* below resolve to that copy.
|
|
13
|
+
*
|
|
14
|
+
* What each repo keeps for itself: its folder layout (passed to `layers`), its
|
|
15
|
+
* ceiling table for old functions (passed to `ceilings`) and its
|
|
16
|
+
* eslint-suppressions.json.
|
|
17
|
+
*/
|
|
18
|
+
import boundaries from "eslint-plugin-boundaries";
|
|
19
|
+
import functional from "eslint-plugin-functional";
|
|
20
|
+
import sonarjs from "eslint-plugin-sonarjs";
|
|
21
|
+
|
|
22
|
+
/** @typedef {import("eslint").Linter.Config} Config */
|
|
23
|
+
/** @typedef {import("eslint").Linter.RuleEntry} RuleEntry */
|
|
24
|
+
/** @typedef {{ files: string[], ignores?: string[] }} Scope */
|
|
25
|
+
|
|
26
|
+
/** One collection library: remeda. These would be a second one for the same job. */
|
|
27
|
+
export const ONE_COLLECTION_LIBRARY = ["lodash", "lodash-es", "ramda"].map(
|
|
28
|
+
(name) => ({ name, message: "Use remeda." }),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/** `x as unknown as T` switches the type checker off. For `no-restricted-syntax`. */
|
|
32
|
+
export const AS_UNKNOWN_AS = {
|
|
33
|
+
selector:
|
|
34
|
+
"TSAsExpression > TSAsExpression[typeAnnotation.type='TSUnknownKeyword']",
|
|
35
|
+
message:
|
|
36
|
+
"`as unknown as` switches the type checker off. Convert with a function, or fix the type.",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** A function is one job; at thirty lines a second one starts to hide. */
|
|
40
|
+
const MAX_LINES = { max: 30, skipComments: true, skipBlankLines: true };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Limits on the size of a function. These rules report one finding per function
|
|
44
|
+
* however far over the limit it is, so a suppressed finding would let the
|
|
45
|
+
* function keep growing. Old functions get a ceiling instead (see `ceilings`).
|
|
46
|
+
*/
|
|
47
|
+
export const SIZE_LIMITS = {
|
|
48
|
+
complexity: 10,
|
|
49
|
+
"sonarjs/cognitive-complexity": 10,
|
|
50
|
+
"max-depth": 3,
|
|
51
|
+
"max-params": 4,
|
|
52
|
+
"max-lines-per-function": MAX_LINES.max,
|
|
53
|
+
"max-nested-callbacks": 3,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** @typedef {keyof typeof SIZE_LIMITS} SizeRule */
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} rule
|
|
60
|
+
* @param {number} n
|
|
61
|
+
* @returns {RuleEntry}
|
|
62
|
+
*/
|
|
63
|
+
const sizeRule = (rule, n) =>
|
|
64
|
+
rule === "max-lines-per-function"
|
|
65
|
+
? ["error", { ...MAX_LINES, max: n }]
|
|
66
|
+
: ["error", n];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The strict rules for TypeScript source. Needs the @typescript-eslint plugin registered.
|
|
70
|
+
*
|
|
71
|
+
* @param {string[]} files
|
|
72
|
+
* @returns {Config}
|
|
73
|
+
*/
|
|
74
|
+
export const strictTypeScript = (files) => ({
|
|
75
|
+
files,
|
|
76
|
+
plugins: { sonarjs },
|
|
77
|
+
rules: {
|
|
78
|
+
...Object.fromEntries(
|
|
79
|
+
Object.entries(SIZE_LIMITS).map(([rule, n]) => [rule, sizeRule(rule, n)]),
|
|
80
|
+
),
|
|
81
|
+
"@typescript-eslint/no-explicit-any": "error",
|
|
82
|
+
// `x as T` is a claim the checker takes on trust. Parse or narrow instead.
|
|
83
|
+
"@typescript-eslint/consistent-type-assertions": [
|
|
84
|
+
"error",
|
|
85
|
+
{ assertionStyle: "never" },
|
|
86
|
+
],
|
|
87
|
+
"@typescript-eslint/switch-exhaustiveness-check": [
|
|
88
|
+
"error",
|
|
89
|
+
{ considerDefaultExhaustiveForUnions: true },
|
|
90
|
+
],
|
|
91
|
+
// Not set here: `no-restricted-imports` and `no-restricted-syntax`. Flat config
|
|
92
|
+
// replaces a rule's options per file group, so a repo that sets either for its
|
|
93
|
+
// own reasons would silently drop ours. Merge ONE_COLLECTION_LIBRARY and
|
|
94
|
+
// AS_UNKNOWN_AS into the repo's own blocks instead.
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A test's `describe` callback is a list of cases, not a function to split.
|
|
100
|
+
*
|
|
101
|
+
* @param {string[]} files
|
|
102
|
+
* @returns {Config}
|
|
103
|
+
*/
|
|
104
|
+
export const testSizes = (files) => ({
|
|
105
|
+
files,
|
|
106
|
+
rules: { "max-lines-per-function": sizeRule("max-lines-per-function", 60) },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Per-file ceilings for functions written before the limits: each number is the
|
|
111
|
+
* file's measured maximum, so its functions may shrink and may not grow. Lower a
|
|
112
|
+
* number after a refactor; a file leaves the table once it is under the limits.
|
|
113
|
+
*
|
|
114
|
+
* @param {Record<string, Partial<Record<SizeRule, number>>>} table
|
|
115
|
+
* @returns {Config[]}
|
|
116
|
+
*/
|
|
117
|
+
export const ceilings = (table) =>
|
|
118
|
+
Object.entries(table).map(([file, max]) => ({
|
|
119
|
+
files: [file],
|
|
120
|
+
rules: Object.fromEntries(
|
|
121
|
+
Object.entries(max).map(([rule, n]) => [rule, sizeRule(rule, n)]),
|
|
122
|
+
),
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* No `let`, no mutation, no loops, readonly parameters. Tests may mutate their fixtures.
|
|
127
|
+
*
|
|
128
|
+
* @param {Scope} scope
|
|
129
|
+
* @returns {Config}
|
|
130
|
+
*/
|
|
131
|
+
export const functionalCode = ({ files, ignores }) => ({
|
|
132
|
+
files,
|
|
133
|
+
ignores,
|
|
134
|
+
plugins: { functional },
|
|
135
|
+
rules: {
|
|
136
|
+
"functional/no-let": "error",
|
|
137
|
+
"functional/immutable-data": "error",
|
|
138
|
+
"functional/no-loop-statements": "error",
|
|
139
|
+
"functional/prefer-immutable-types": [
|
|
140
|
+
"error",
|
|
141
|
+
{ enforcement: "ReadonlyShallow", ignoreInferredTypes: true },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/** Node modules that ARE effects. Only an `io` file, a test or the root may import one. */
|
|
147
|
+
export const IO_MODULES = [
|
|
148
|
+
"fs",
|
|
149
|
+
"fs/promises",
|
|
150
|
+
"child_process",
|
|
151
|
+
"os",
|
|
152
|
+
"net",
|
|
153
|
+
"http",
|
|
154
|
+
"https",
|
|
155
|
+
"worker_threads",
|
|
156
|
+
"readline",
|
|
157
|
+
"readline/promises",
|
|
158
|
+
].flatMap((m) => [m, `node:${m}`]);
|
|
159
|
+
|
|
160
|
+
/** Libraries the domain and ports may use: types, pure path arithmetic, hashing, collections. */
|
|
161
|
+
export const PURE_LIBRARIES = [
|
|
162
|
+
"ts-essentials",
|
|
163
|
+
"remeda",
|
|
164
|
+
"path",
|
|
165
|
+
"node:path",
|
|
166
|
+
"crypto",
|
|
167
|
+
"node:crypto",
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
/** What app files may use besides: Node's pure helpers. */
|
|
171
|
+
export const APP_LIBRARIES = [
|
|
172
|
+
...PURE_LIBRARIES,
|
|
173
|
+
"util",
|
|
174
|
+
"node:util",
|
|
175
|
+
"url",
|
|
176
|
+
"node:url",
|
|
177
|
+
"module",
|
|
178
|
+
"node:module",
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
const ORIGINS = ["external", "core"];
|
|
182
|
+
/** @param {string[]} source */
|
|
183
|
+
const libraries = (source) => ({ to: { module: { origin: ORIGINS, source } } });
|
|
184
|
+
/** @param {...string} types */
|
|
185
|
+
const elements = (...types) => ({ element: { types: { anyOf: types } } });
|
|
186
|
+
const APP = { file: { categories: "app" } };
|
|
187
|
+
const ROOT = { file: { categories: "root" } };
|
|
188
|
+
|
|
189
|
+
/** Who may import whom: the domain knows only itself, an adapter never another adapter. */
|
|
190
|
+
/**
|
|
191
|
+
* @param {string[]} domainLibraries
|
|
192
|
+
* @param {string[]} appLibraries
|
|
193
|
+
*/
|
|
194
|
+
const knowledgePolicies = (domainLibraries, appLibraries) => [
|
|
195
|
+
{ from: elements("domain"), allow: { to: elements("domain") } },
|
|
196
|
+
{ from: elements("port"), allow: { to: elements("domain", "port") } },
|
|
197
|
+
{ from: APP, allow: { to: [elements("domain", "port"), APP] } },
|
|
198
|
+
{ from: elements("adapter"), allow: { to: elements("domain", "port") } },
|
|
199
|
+
{ from: ROOT, allow: { to: [{ element: { type: "*" } }, APP, ROOT] } },
|
|
200
|
+
{ from: elements("domain", "port"), allow: libraries(domainLibraries) },
|
|
201
|
+
{ from: APP, allow: libraries(appLibraries) },
|
|
202
|
+
{
|
|
203
|
+
from: [elements("adapter"), ROOT],
|
|
204
|
+
allow: { to: { module: { origin: ORIGINS } } },
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
from: { file: { categories: "test" } },
|
|
208
|
+
allow: {
|
|
209
|
+
to: [
|
|
210
|
+
{ element: { type: "*" } },
|
|
211
|
+
APP,
|
|
212
|
+
ROOT,
|
|
213
|
+
{ module: { origin: ORIGINS } },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Effects only in io files, tests and the root. Written as "nobody, then these
|
|
221
|
+
* three": a file with no category has `categories: null`, and a `noneOf` query
|
|
222
|
+
* never matches null, so the negative form would exempt every plain file.
|
|
223
|
+
*/
|
|
224
|
+
/** @param {string} ioPattern */
|
|
225
|
+
const effectPolicies = (ioPattern) => [
|
|
226
|
+
{
|
|
227
|
+
disallow: { to: { module: { origin: "core", source: IO_MODULES } } },
|
|
228
|
+
message: `{{ dependency.source }} is I/O in a file that is not ${ioPattern}. Take a port instead, and do the I/O in an adapter's io file or the root.`,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
from: { file: { categories: ["io", "test", "root"] } },
|
|
232
|
+
allow: { to: { module: { origin: "core", source: IO_MODULES } } },
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {string} type
|
|
238
|
+
* @param {string | undefined} pattern
|
|
239
|
+
* @param {Record<string, unknown>} [extra]
|
|
240
|
+
*/
|
|
241
|
+
const element = (type, pattern, extra = {}) =>
|
|
242
|
+
pattern === undefined
|
|
243
|
+
? []
|
|
244
|
+
: [{ type, pattern, partialMatch: false, ...extra }];
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Where a repo keeps each layer. Patterns are relative to `root`.
|
|
248
|
+
*
|
|
249
|
+
* @typedef {object} Layout
|
|
250
|
+
* @property {string[]} files the files the rules apply to
|
|
251
|
+
* @property {string} root the repository directory
|
|
252
|
+
* @property {string} domain folder of the domain, e.g. "src/core"
|
|
253
|
+
* @property {string} [ports] folder of the ports, if the repo has one
|
|
254
|
+
* @property {string} adapters adapter folders, one element each, e.g. "src/adapters/*"
|
|
255
|
+
* @property {string} cliRoot the composition root: the only non-test file that may wire anything to anything
|
|
256
|
+
* @property {string} app the flat application files, e.g. "src/*.ts"
|
|
257
|
+
* @property {string} tests test files
|
|
258
|
+
* @property {string} io files allowed to do I/O, e.g. "src/adapters/*\/*.io.ts"
|
|
259
|
+
* @property {string[]} [domainLibraries] what the domain and ports may import (default PURE_LIBRARIES)
|
|
260
|
+
* @property {string[]} [appLibraries] what app files may import (default APP_LIBRARIES)
|
|
261
|
+
* @property {object} [resolver] import/resolver settings (default: the TypeScript resolver)
|
|
262
|
+
* @property {object[]} [extraElements] more boundaries elements, e.g. a folder of plain-JS modules
|
|
263
|
+
* @property {object[]} [extraAllows] more allow-policies, checked before the effects rule
|
|
264
|
+
*/
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The hexagonal layers, as one eslint-plugin-boundaries block. Every linted file
|
|
268
|
+
* must be a declared element or category, so a new file cannot sit outside the
|
|
269
|
+
* rules.
|
|
270
|
+
*
|
|
271
|
+
* `root` must be the repository directory. Without `boundaries/root-path` the
|
|
272
|
+
* plugin matches patterns against `process.cwd()`, and lint started from any
|
|
273
|
+
* other directory classifies nothing and passes.
|
|
274
|
+
*
|
|
275
|
+
* @param {Layout} o
|
|
276
|
+
* @returns {Config}
|
|
277
|
+
*/
|
|
278
|
+
export const layers = (o) => ({
|
|
279
|
+
files: o.files,
|
|
280
|
+
// @ts-expect-error The plugin's types describe an ES module whose `default` is
|
|
281
|
+
// the plugin; Node hands a default import of this CommonJS file the plugin
|
|
282
|
+
// itself (keys meta, rules, configs). Runtime is right, the declaration is not.
|
|
283
|
+
plugins: { boundaries },
|
|
284
|
+
settings: {
|
|
285
|
+
"boundaries/root-path": o.root,
|
|
286
|
+
"import/resolver": o.resolver ?? { typescript: { alwaysTryTypes: true } },
|
|
287
|
+
"boundaries/elements": [
|
|
288
|
+
...element("port", o.ports),
|
|
289
|
+
...element("domain", o.domain),
|
|
290
|
+
...element("adapter", o.adapters, { capture: ["name"] }),
|
|
291
|
+
...(o.extraElements ?? []),
|
|
292
|
+
],
|
|
293
|
+
"boundaries/files": [
|
|
294
|
+
{ category: "root", pattern: o.cliRoot, exclusive: true },
|
|
295
|
+
{ category: "app", pattern: o.app },
|
|
296
|
+
{ category: "test", pattern: o.tests },
|
|
297
|
+
{ category: "io", pattern: o.io },
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
rules: {
|
|
301
|
+
"boundaries/no-unknown-files": "error",
|
|
302
|
+
"boundaries/no-unknown-dependencies": "error",
|
|
303
|
+
"boundaries/dependencies": [
|
|
304
|
+
"error",
|
|
305
|
+
{
|
|
306
|
+
default: "disallow",
|
|
307
|
+
checkAllOrigins: true,
|
|
308
|
+
policies: [
|
|
309
|
+
...knowledgePolicies(
|
|
310
|
+
o.domainLibraries ?? PURE_LIBRARIES,
|
|
311
|
+
o.appLibraries ?? APP_LIBRARIES,
|
|
312
|
+
),
|
|
313
|
+
...(o.extraAllows ?? []),
|
|
314
|
+
...effectPolicies(o.io),
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* `process` and `fetch` are not imports, so `layers` cannot see them. Same scope as its effects rule.
|
|
323
|
+
*
|
|
324
|
+
* @param {Scope} scope
|
|
325
|
+
* @returns {Config}
|
|
326
|
+
*/
|
|
327
|
+
export const ioGlobals = ({ files, ignores }) => ({
|
|
328
|
+
files,
|
|
329
|
+
ignores,
|
|
330
|
+
rules: {
|
|
331
|
+
"no-restricted-globals": [
|
|
332
|
+
"error",
|
|
333
|
+
{
|
|
334
|
+
name: "process",
|
|
335
|
+
message:
|
|
336
|
+
"The environment is an input: the root reads it and passes a value in.",
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
name: "fetch",
|
|
340
|
+
message: "Network access belongs in an adapter's io file.",
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
},
|
|
344
|
+
});
|