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.
@@ -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
- * (`no-restricted-syntax` in `eslint.config.mjs`), so "where did this path get its
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.0.0",
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": "^6.0.2",
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
+ });