@opetope/lint 0.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +335 -0
  4. package/README.ru.md +335 -0
  5. package/dist/ast.d.ts +18 -0
  6. package/dist/ast.js +2 -0
  7. package/dist/ast.js.map +1 -0
  8. package/dist/declarations.d.ts +8 -0
  9. package/dist/declarations.js +2 -0
  10. package/dist/declarations.js.map +1 -0
  11. package/dist/index.d.ts +103 -0
  12. package/dist/index.js +2 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/model-bindings.d.ts +14 -0
  15. package/dist/model-bindings.js +2 -0
  16. package/dist/model-bindings.js.map +1 -0
  17. package/dist/rule.d.ts +9 -0
  18. package/dist/rule.js +2 -0
  19. package/dist/rule.js.map +1 -0
  20. package/dist/rules/define-feature-property-order.d.ts +5 -0
  21. package/dist/rules/define-feature-property-order.js +2 -0
  22. package/dist/rules/define-feature-property-order.js.map +1 -0
  23. package/dist/rules/id-naming.d.ts +8 -0
  24. package/dist/rules/id-naming.js +2 -0
  25. package/dist/rules/id-naming.js.map +1 -0
  26. package/dist/rules/layer-placement.d.ts +8 -0
  27. package/dist/rules/layer-placement.js +2 -0
  28. package/dist/rules/layer-placement.js.map +1 -0
  29. package/dist/rules/no-snapshot-read-in-render.d.ts +4 -0
  30. package/dist/rules/no-snapshot-read-in-render.js +2 -0
  31. package/dist/rules/no-snapshot-read-in-render.js.map +1 -0
  32. package/dist/rules/no-subscribe-outside-models.d.ts +4 -0
  33. package/dist/rules/no-subscribe-outside-models.js +2 -0
  34. package/dist/rules/no-subscribe-outside-models.js.map +1 -0
  35. package/dist/rules/prefer-model-selection.d.ts +7 -0
  36. package/dist/rules/prefer-model-selection.js +2 -0
  37. package/dist/rules/prefer-model-selection.js.map +1 -0
  38. package/dist/rules/require-declared-models.d.ts +5 -0
  39. package/dist/rules/require-declared-models.js +2 -0
  40. package/dist/rules/require-declared-models.js.map +1 -0
  41. package/dist/rules/require-literal-id.d.ts +7 -0
  42. package/dist/rules/require-literal-id.js +2 -0
  43. package/dist/rules/require-literal-id.js.map +1 -0
  44. package/dist/rules/when-predicate.d.ts +6 -0
  45. package/dist/rules/when-predicate.js +2 -0
  46. package/dist/rules/when-predicate.js.map +1 -0
  47. package/dist/static-value.d.ts +15 -0
  48. package/dist/static-value.js +2 -0
  49. package/dist/static-value.js.map +1 -0
  50. package/package.json +54 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # @opetope/lint
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release. See the package README for the supported API and usage.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aleksei Berezin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,335 @@
1
+ # @opetope/lint
2
+
3
+ ESLint rules for the code an Opetope author writes. The plugin holds only what a syntactic check can prove about a
4
+ declaration: shapes that compile and run, yet say something other than what their author meant. What the type
5
+ checker, the runtime and dead-code analysis already answer stays with them (see [`../docs/decisions.md`](https://www.npmjs.com/package/@opetope/runtime), D227).
6
+
7
+ The package depends on no other `@opetope/*` package and reads no types, so a host can lint sources it has not
8
+ built yet.
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ npm install --save-dev @opetope/lint 'eslint@^9' '@typescript-eslint/parser@^8'
14
+ ```
15
+
16
+ Use matching Opetope versions. For release candidates, append `@next` to every `@opetope/*` package in the command.
17
+ The API is ESM-only; Node 20.19+ is required. Development check commands below apply to a contributor checkout.
18
+
19
+ The normative EN/RU guides are shipped in `@opetope/runtime`: after installing it, open
20
+ `node_modules/@opetope/runtime/docs/spec.md` or `spec.ru.md`; recipes are in `cookbook.md` and `cookbook.ru.md`.
21
+ No GitHub access is needed to read those installed guides.
22
+
23
+ ## Usage
24
+
25
+ ```js
26
+ // eslint.config.mjs
27
+ import opetope from '@opetope/lint';
28
+
29
+ export default [
30
+ {
31
+ files: ['src/**/*.{ts,tsx}'],
32
+ ...opetope.configs.recommended,
33
+ },
34
+ ];
35
+ ```
36
+
37
+ The plugin is both the default export and the named `opetopeLint`. The configuration registers it under the
38
+ namespace `opetope`, so a rule is named `opetope/when-predicate`. Parsing requires `@typescript-eslint/parser`;
39
+ both it and `eslint` are peer dependencies.
40
+
41
+ ## Configs
42
+
43
+ ### `recommended`
44
+
45
+ Every rule that holds wherever Opetope is written: `when-predicate`, `define-feature-property-order`,
46
+ `require-literal-id`, `id-naming`, `no-snapshot-read-in-render` and `require-declared-models` as errors. A rule that needs to know where a
47
+ host keeps its files is off here and arrives through `layers`; `prefer-model-selection` is off because the number
48
+ of hooks a component may keep is a taste a project settles for itself.
49
+
50
+ ### `layers`
51
+
52
+ `opetope.configs.layers({ integration, models, ui, tests })` takes the globs of a project's own layers and returns
53
+ the configuration each of them earns. A layer without globs is a layer this project does not have, and it produces
54
+ nothing; a file no glob claims is placed by nobody.
55
+
56
+ ```js
57
+ ...opetope.configs.layers({
58
+ integration: ['src/features/*/integration/**/*.{ts,tsx}'],
59
+ models: ['src/features/*/models/**/*.ts'],
60
+ ui: ['src/features/*/ui/**/*.{ts,tsx}'],
61
+ }),
62
+ ```
63
+
64
+ `integration`, `models` and `ui` each turn on `layer-placement` for their files. `models` also turns on
65
+ `no-subscribe-outside-models` for every source file and steps aside in that layer and in `tests`, which defaults to
66
+ `['**/__tests__/**', '**/*.spec.ts', '**/*.spec.tsx']`. A subscription is covered even in a file no layer claims,
67
+ because forgetting one is how a subscription outlives its reader.
68
+
69
+ ### `internalImports`
70
+
71
+ `opetope.configs.internalImports({ allow, files })` keeps `@opetope/*/internal` out of the code that is neither the
72
+ runtime nor its host integration. `allow` names the files that do that work — the restriction does not reach them,
73
+ so whatever else the project restricts there stays in force — and `files` is what the restriction covers, by
74
+ default every `.ts` and `.tsx`.
75
+
76
+ ```js
77
+ ...opetope.configs.internalImports({ allow: ['src/bootstrap/**/devtools.ts'] }),
78
+ ```
79
+
80
+ `no-restricted-imports` is a core rule with one value per file, and in a flat config the last entry that names it
81
+ wins. A project that already configures that rule takes `opetope.internalImportPattern` into its own entry instead
82
+ of spreading this configuration, which would replace that entry for every file it covers:
83
+
84
+ ```js
85
+ 'no-restricted-imports': ['error', { paths: myPaths, patterns: [...myPatterns, opetope.internalImportPattern] }],
86
+ ```
87
+
88
+ The files that integrate the host then get the same entry without that one pattern, so they keep every other
89
+ restriction the project makes.
90
+
91
+ ## Rules
92
+
93
+ ### `when-predicate`
94
+
95
+ A contribution's `when` answers the visibility fact of this instance, not the source that carries it (D220). The
96
+ rule reads the `when` of a `slot`, `pipe` or `register` contribution, and any `when` whose function destructures the
97
+ evaluation context `{ exports, imports, own, read }`, and reports three shapes:
98
+
99
+ | Written | Reported |
100
+ | ------------------------------------------ | ----------------------------------------------------------------------------- |
101
+ | `when: ({ imports }) => imports.x.allowed` | returns a source; fixed to `({ imports, read }) => read(imports.x.allowed)` |
102
+ | `when: async ({ read }) => read(x)` | a predicate answers synchronously; no fix |
103
+ | `when: () => allowed` | a `Readable<boolean>` is passed as `when` directly, without a wrapper; no fix |
104
+
105
+ The fix wraps the returned member expression in `read(...)` and adds `read` to the destructured context when it is
106
+ absent. A named context is read through itself: `context => context.imports.x.allowed` becomes
107
+ `context => context.read(context.imports.x.allowed)`.
108
+
109
+ **The boundary.** The rule reads shapes, not types. `({ read }) => read(counter)` over a non-boolean source stays a
110
+ type error, and so does a `read` of something that is not a `Readable`. Where a member access of the evaluation
111
+ context is a plain value rather than a source, the predicate cannot change its answer, and the rule reports it as
112
+ one written for a source; hoist that decision out of `when`, or silence the line. `when` outside a contribution —
113
+ the positional `(current, previous)` of `effect`, or the source value of `scope.while` — is left alone.
114
+
115
+ ### `define-feature-property-order`
116
+
117
+ A feature declares its sections in one order: `id`, `imports`, `requires`, `own`, `exports`, `provides`, `when`,
118
+ and the `body` loader that replaces the last three stages (spec §2.1, D186). The order is the reading order of the
119
+ declaration — identity, the edges, the stages, then the lifetime and the loader — so a reader finds a section by
120
+ position, and a key sorter can be configured to hold the same order instead of a second one.
121
+
122
+ The fix reorders the properties. It stands down when a comment sits between two sections, because such a comment
123
+ belongs to neither and reordering would move it away from the line it explains; a comment inside a section travels
124
+ with it. An object with a key that is not a section, or with a spread, is left to the type checker.
125
+
126
+ ### `require-declared-models`
127
+
128
+ A component that reads a per-mount UI model declares that model with `requiresModels`. Owner models remain
129
+ available to the contribution subtree without this declaration (D158, D250).
130
+
131
+ ```tsx
132
+ import { defineFeature } from '@opetope/runtime';
133
+ import { requiresModels, useModel } from '@opetope/react';
134
+
135
+ const Form = () => {
136
+ const actions = useModel(FormActions);
137
+ return null;
138
+ };
139
+ const DeclaredForm = requiresModels([FormActions])(Form);
140
+
141
+ defineFeature({
142
+ id: 'example.form',
143
+ provides: ({ slot }) => ({
144
+ form: slot(FormSlot, ({ model }) => ({
145
+ Component: DeclaredForm,
146
+ models: [model(FormActions, createActions)],
147
+ })),
148
+ }),
149
+ });
150
+ ```
151
+
152
+ The rule checks contributions passed to the `slot` capability of a visible `defineFeature` or
153
+ `defineFeature.body` `provides` callback. A local component receiving UI models must declare its requirements;
154
+ each visible component or hook that reads one of those models declares its own list. Wrapping a parent does not
155
+ declare the requirements of a nested function.
156
+
157
+ Named and namespace imports, import aliases, local aliases, a named `provides` context, and both inline and named
158
+ components are recognized. Bindings are compared within their lexical scopes; an unrelated local `useModel`,
159
+ `requiresModels` or `slot` does not become an Opetope API by sharing its name. Named APIs from relative re-exports
160
+ are recognized by their exported names, without reading the other file.
161
+
162
+ **The boundary.** Analysis stays within one module and follows visible local declarations and factory returns.
163
+ Arbitrary objects with `Component` and `models` are not contribution sites. Imported component implementations,
164
+ dynamic model lists and opaque helpers remain unverified: silence is not proof that their model requirements are
165
+ complete. Model references must be locally visible identifiers or aliases; the rule does not inspect types or walk
166
+ the rendered React tree. It has no autofix, because choosing which model a component should read is an authoring
167
+ choice. Types and runtime authority checks continue to apply.
168
+
169
+ ### `require-literal-id`
170
+
171
+ A declaration names itself with a string that is written, not built where the declaration stands. The rule reads
172
+ every declaration of the public vocabulary that names itself — `defineFeature`, `defineApplication`,
173
+ `defineCondition`, `defineHostContract`, `defineModel`, `definePort`, `defineSlot`, `defineSwitchSlot`,
174
+ `definePipe` and `defineRegistry`. `defineHostContract`, `defineModel` and `definePort` take the id as their first
175
+ argument; the rest take the `id` key of their options.
176
+
177
+ Written means a string literal, a template with no expressions, a name that resolves to an import or to a `const`
178
+ of the same module, and a property read from such a name. Built means anything the call site assembles: a template
179
+ with an expression, a concatenation, a call, or a name that resolves to a parameter.
180
+
181
+ A project that generates a set of declarations from a name says so once, by naming that factory:
182
+
183
+ ```js
184
+ 'opetope/require-literal-id': ['error', { allowInCallees: ['createSlots'] }],
185
+ ```
186
+
187
+ The name is matched against the function the declaration is written in and against the call it is passed to, so
188
+ both shapes of a factory are covered.
189
+
190
+ **The boundary.** The rule follows names inside one module only: an id imported from another file is written
191
+ there, and that is where its own text is checked. The kernel's `defineModule`, `defineCallTarget` and
192
+ `defineCallLane` are not read, because an author never writes them.
193
+
194
+ ### `id-naming`
195
+
196
+ An id starts with a letter and joins segments of letters and digits with one of `.`, `/`, `:` or `-`, within 160
197
+ characters. That is the grammar `declarationId` accepts in `@opetope/core`; an id outside it is a `DeclarationError`
198
+ the moment the declaration runs, and this rule says so before the code runs.
199
+
200
+ A project that reserves a first segment names it, and every declaration of those files must open with it:
201
+
202
+ ```js
203
+ 'opetope/id-naming': ['error', { prefix: 'workspace' }],
204
+ ```
205
+
206
+ **The boundary.** The rule checks the ids whose text it can see in the file — a literal, or a name that resolves to
207
+ one in the same module. An id that arrives from another module is checked where it is written.
208
+
209
+ ### `layer-placement`
210
+
211
+ Each declaration is written in the layer that owns it. The rule reports nothing until a project names its layers
212
+ through `configs.layers`, because the names of directories are not a law of the framework:
213
+
214
+ | Declaration | Layer |
215
+ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
216
+ | `defineFeature` | integration — a feature composes the other two layers |
217
+ | `defineModel` | models, or the UI contracts that declare a mount model |
218
+ | `defineSlot`, `defineSwitchSlot`, `definePipe`, `defineRegistry`, `definePort`, `defineCondition`, `defineHostContract` | integration, or the UI contracts beside the component |
219
+ | `defineApplication` | none of them: the host bootstrap owns it |
220
+
221
+ **This is a convention of the project, not a law of the library.** The framework says a feature's UI imports only
222
+ that feature's own contracts, and that a model knows nothing about the feature; where those files live is the
223
+ project's choice, and this rule holds whatever choice it declared.
224
+
225
+ ### `no-snapshot-read-in-render`
226
+
227
+ `getSnapshot()` called while a component or a hook renders reads the value once and never hears the next one. Read
228
+ it with `useReadable`, or select it together with the commands of the same model:
229
+ `useModel(Declaration, (model, { read }) => ...)` (D205, D214).
230
+
231
+ A render scope is a function named `use…`, or a capitalized function that returns elements. The rule looks at the
232
+ function the call is written in, so a snapshot read in an event handler, an effect or any other nested callback
233
+ stays: those run after render, and the value of that moment is the one they want.
234
+
235
+ **The boundary.** The rule reads the name of the receiver, not its type: every `getSnapshot()` in a render scope is
236
+ reported, whichever object it belongs to. A capitalized function that returns no element is a factory and keeps its
237
+ reads — including a contribution's model factory, which reads the props of its own mount.
238
+
239
+ ### `no-subscribe-outside-models`
240
+
241
+ A subscription written by hand owns a cleanup that nothing around it can see. The rule reports `x.subscribe(...)`
242
+ and stays off until `configs.layers` names the model layer that is allowed to hold one; in a component the reader
243
+ is `useReadable` or a model selection, and in a feature or a model it is `effect`, `event` or `stream`.
244
+
245
+ Handing the function over without calling it is not a subscription written here:
246
+ `useSyncExternalStore(source.subscribe, source.getSnapshot)` passes a reference, and the reader owns what it starts.
247
+
248
+ ### `prefer-model-selection`
249
+
250
+ A component that takes one granted model and reads its fields through a hook each repeats the same wiring. From
251
+ three fields upwards the rule points at one selection instead (D205, D214):
252
+
253
+ ```js
254
+ 'opetope/prefer-model-selection': ['error', { threshold: 3 }],
255
+ ```
256
+
257
+ It counts `useReadable(model.field)` and `useCommand(model.field)` over the variable of a `useModel(Declaration)`
258
+ without a selection, and it counts each granted model separately: two models in one component are two grants, and
259
+ the same variable name in two components is two counts. There is no fix — the shape of the selection is the
260
+ author's to write.
261
+
262
+ ## Coexistence with key sorting
263
+
264
+ A host that sorts object keys alphabetically will disagree with `define-feature-property-order`, because the
265
+ sections of a feature are ordered by meaning and not by name. `recommended` touches no sorting rule: reconciling
266
+ them belongs to the host, and there are two ways.
267
+
268
+ **`perfectionist/sort-objects`** takes a named order for exactly the two callees that declare sections, and keeps
269
+ its plain configuration for every other object:
270
+
271
+ ```js
272
+ 'perfectionist/sort-objects': [
273
+ 'error',
274
+ {
275
+ customGroups: [
276
+ { elementNamePattern: '^id$', groupName: 'feature-id' },
277
+ { elementNamePattern: '^imports$', groupName: 'feature-imports' },
278
+ { elementNamePattern: '^requires$', groupName: 'feature-requires' },
279
+ { elementNamePattern: '^own$', groupName: 'feature-own' },
280
+ { elementNamePattern: '^exports$', groupName: 'feature-exports' },
281
+ { elementNamePattern: '^provides$', groupName: 'feature-provides' },
282
+ { elementNamePattern: '^when$', groupName: 'feature-when' },
283
+ { elementNamePattern: '^body$', groupName: 'feature-body' },
284
+ ],
285
+ groups: [
286
+ 'feature-id',
287
+ 'feature-imports',
288
+ 'feature-requires',
289
+ 'feature-own',
290
+ 'feature-exports',
291
+ 'feature-provides',
292
+ 'feature-when',
293
+ 'feature-body',
294
+ 'unknown',
295
+ ],
296
+ order: 'asc',
297
+ type: 'natural',
298
+ useConfigurationIf: { callingFunctionNamePattern: '^(?:defineFeature|defineFeature\\.body)$' },
299
+ },
300
+ { order: 'asc', type: 'natural' },
301
+ ],
302
+ ```
303
+
304
+ **ESLint core `sort-keys`, and the rule of the same id in Oxlint**, has no per-callee configuration and no
305
+ per-declaration exception: it reports each key that follows a larger one, wherever the object is. Turn it off for
306
+ the files that declare features, or wrap a declaration in `/* eslint-disable sort-keys */` and
307
+ `/* eslint-enable sort-keys */` — a `// eslint-disable-next-line sort-keys` covers one line, so it silences one
308
+ section, not a multi-line declaration. Oxlint reads the same directives under its own prefix,
309
+ `// oxlint-disable-next-line sort-keys`.
310
+
311
+ The fix of this rule moves keys only inside the object of a `defineFeature` call, so it never reorders anything a
312
+ sorting rule owns elsewhere in the file.
313
+
314
+ ## Running the rules under Oxlint
315
+
316
+ Oxlint reads ESLint plugins through `jsPlugins`: it imports the module by path and takes its default export. The
317
+ rules stay on the plain rule API — `meta`, `create(context)` with visitors, `context.report` with a `fix`, and
318
+ `getText`/`getCommentsInside` on the source code — with no typed services and no ESLint-only capability, so both
319
+ linters run them, autofix included:
320
+
321
+ ```json
322
+ {
323
+ "jsPlugins": [{ "name": "opetope", "specifier": "./node_modules/@opetope/lint/dist/index.js" }],
324
+ "rules": { "opetope/when-predicate": "error" }
325
+ }
326
+ ```
327
+
328
+ The alias `name` fixes the namespace, so a rule keeps the same id under either linter. `jsPlugins` is alpha and
329
+ outside semver: `npm run ci:test` runs the built plugin under Oxlint on a valid and an invalid fixture and checks
330
+ the fix it writes, so a break in that bridge fails here instead of in a host.
331
+
332
+ ## Checks
333
+
334
+ From this package: `npm run ci:test`, `npm run ci:type`, `npm run ci:eslint`, `npm run build`. The Oxlint bridge
335
+ test reads `dist`, so `npm run build` comes first.