@jterrazz/typescript 9.3.0 → 10.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 (81) hide show
  1. package/README.md +20 -16
  2. package/bin/commands/check.sh +387 -146
  3. package/bin/find-tsc.sh +30 -0
  4. package/bin/typescript.sh +79 -1
  5. package/lib/check-architecture.js +89 -0
  6. package/lib/check-baseline.js +144 -0
  7. package/lib/check-docs.js +4 -3
  8. package/lib/check-drift.js +209 -0
  9. package/lib/check-gitignore.js +4 -4
  10. package/lib/check-markdown.js +279 -0
  11. package/lib/check-names.js +125 -0
  12. package/lib/check-publish.js +150 -0
  13. package/lib/check-secrets.js +115 -0
  14. package/lib/check-suppressions.js +355 -0
  15. package/lib/doctor.js +185 -0
  16. package/lib/entry-points.js +91 -0
  17. package/lib/merge-knip-config.js +57 -25
  18. package/lib/tracked-files.js +165 -0
  19. package/lib/unsafe-fixers.js +25 -0
  20. package/lib/workspace-members.js +5 -6
  21. package/package.json +36 -13
  22. package/presets/oxfmt/index.js +49 -5
  23. package/presets/oxlint/profiles/astro.js +10 -0
  24. package/presets/oxlint/profiles/bun.js +7 -0
  25. package/presets/oxlint/profiles/expo.js +7 -0
  26. package/presets/oxlint/profiles/library.js +16 -0
  27. package/presets/oxlint/profiles/next.js +7 -0
  28. package/presets/oxlint/profiles/node.js +7 -0
  29. package/presets/oxlint/profiles/react.js +7 -0
  30. package/presets/prettier/astro.json +6 -0
  31. package/presets/tsconfig/astro.json +25 -0
  32. package/presets/tsconfig/expo.json +16 -6
  33. package/presets/tsconfig/library.json +17 -0
  34. package/presets/tsconfig/next.json +12 -2
  35. package/presets/tsconfig/node.json +18 -4
  36. package/presets/tsconfig/react.json +33 -0
  37. package/presets/tsdown/build.d.ts +13 -0
  38. package/presets/tsdown/bundle.d.ts +13 -0
  39. package/presets/tsdown/bundle.js +10 -1
  40. package/rules/README.md +23 -0
  41. package/rules/_contract.js +207 -0
  42. package/rules/_contract.test.ts +81 -0
  43. package/rules/a11y.js +51 -0
  44. package/rules/architecture/hexagonal.js +56 -0
  45. package/rules/architecture/layers.js +75 -0
  46. package/rules/astro.js +56 -0
  47. package/rules/bundler.js +19 -0
  48. package/rules/catalog.js +166 -0
  49. package/rules/catalog.test.ts +98 -0
  50. package/rules/compile.js +125 -0
  51. package/rules/core/eslint.js +234 -0
  52. package/rules/core/import.js +117 -0
  53. package/rules/core/jsdoc.js +52 -0
  54. package/rules/core/node.js +36 -0
  55. package/rules/core/oxc.js +54 -0
  56. package/rules/core/promise.js +39 -0
  57. package/rules/core/typescript.js +223 -0
  58. package/rules/core/unicorn.js +210 -0
  59. package/rules/next.js +53 -0
  60. package/rules/profiles.js +95 -0
  61. package/rules/react-native.js +48 -0
  62. package/rules/react.js +155 -0
  63. package/rules/sorted.js +41 -0
  64. package/rules/vitest.js +178 -0
  65. package/src/docs.d.ts +4 -4
  66. package/src/docs.js +75 -57
  67. package/src/docs.test.ts +43 -31
  68. package/src/index.d.ts +14 -9
  69. package/src/index.js +17 -8
  70. package/src/oxfmt.d.ts +15 -2
  71. package/src/oxfmt.test.ts +10 -0
  72. package/src/oxlint.d.ts +59 -10
  73. package/src/oxlint.js +36 -50
  74. package/src/oxlint.test.ts +82 -28
  75. package/presets/oxlint/architectures/hexagonal-rules.js +0 -39
  76. package/presets/oxlint/architectures/hexagonal.js +0 -13
  77. package/presets/oxlint/base.js +0 -145
  78. package/presets/oxlint/expo.js +0 -36
  79. package/presets/oxlint/next.js +0 -43
  80. package/presets/oxlint/node.js +0 -14
  81. package/presets/oxlint/plugins/codestyle.js +0 -231
@@ -1,23 +1,33 @@
1
1
  {
2
2
  "display": "Expo",
3
3
  "include": ["${configDir}/**/*.ts", "${configDir}/**/*.tsx"],
4
- "exclude": ["${configDir}/node_modules"],
5
-
4
+ "exclude": [
5
+ "${configDir}/node_modules",
6
+ "${configDir}/**/_fixtures",
7
+ "${configDir}/**/_expected"
8
+ ],
6
9
  "compilerOptions": {
7
10
  "allowJs": true,
8
- "baseUrl": ".",
9
11
  "esModuleInterop": true,
12
+ "exactOptionalPropertyTypes": true,
10
13
  "incremental": true,
11
14
  "jsx": "react-native",
12
15
  "lib": ["DOM", "ESNext"],
13
- "moduleResolution": "node",
16
+ "module": "ESNext",
17
+ "moduleResolution": "bundler",
14
18
  "noEmit": true,
19
+ "noFallthroughCasesInSwitch": true,
20
+ "noImplicitOverride": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noUncheckedSideEffectImports": true,
15
23
  "paths": {
16
- "@/*": ["src/*"]
24
+ "@/*": ["${configDir}/src/*"]
17
25
  },
18
26
  "resolveJsonModule": true,
19
27
  "skipLibCheck": true,
28
+ "strict": true,
20
29
  "target": "ESNext",
21
- "tsBuildInfoFile": "${configDir}/.artifacts/tsc/tsconfig.tsbuildinfo"
30
+ "tsBuildInfoFile": "${configDir}/.artifacts/tsc/tsconfig.tsbuildinfo",
31
+ "verbatimModuleSyntax": true
22
32
  }
23
33
  }
@@ -0,0 +1,17 @@
1
+ {
2
+ "display": "Library (ESM, declaration-emitting)",
3
+ "extends": "./node.json",
4
+ "compilerOptions": {
5
+ "allowJs": false,
6
+ "declaration": true,
7
+ "erasableSyntaxOnly": true
8
+ },
9
+ "include": ["${configDir}/**/*.ts"],
10
+ "exclude": [
11
+ "${configDir}/node_modules",
12
+ "${configDir}/dist",
13
+ "${configDir}/.artifacts",
14
+ "${configDir}/**/_fixtures",
15
+ "${configDir}/**/_expected"
16
+ ]
17
+ }
@@ -18,7 +18,13 @@
18
18
  {
19
19
  "name": "next"
20
20
  }
21
- ]
21
+ ],
22
+ "exactOptionalPropertyTypes": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "noImplicitOverride": true,
25
+ "noUncheckedIndexedAccess": true,
26
+ "noUncheckedSideEffectImports": true,
27
+ "verbatimModuleSyntax": true
22
28
  },
23
29
  "include": [
24
30
  "${configDir}/next-env.d.ts",
@@ -27,5 +33,9 @@
27
33
  "${configDir}/**/*.ts",
28
34
  "${configDir}/**/*.tsx"
29
35
  ],
30
- "exclude": ["${configDir}/node_modules"]
36
+ "exclude": [
37
+ "${configDir}/node_modules",
38
+ "${configDir}/**/_fixtures",
39
+ "${configDir}/**/_expected"
40
+ ]
31
41
  }
@@ -1,19 +1,33 @@
1
1
  {
2
2
  "display": "Node ESM",
3
- "include": ["${configDir}/**/*.ts"],
4
- "exclude": ["${configDir}/node_modules"],
5
-
3
+ "include": ["${configDir}/**/*.ts", "${configDir}/**/*.js"],
4
+ "exclude": [
5
+ "${configDir}/node_modules",
6
+ "${configDir}/dist",
7
+ "${configDir}/.artifacts",
8
+ "${configDir}/**/_fixtures",
9
+ "${configDir}/**/_expected"
10
+ ],
6
11
  "compilerOptions": {
7
12
  "moduleResolution": "Bundler",
8
13
  "module": "ESNext",
9
14
  "target": "ESNext",
10
15
  "types": ["node"],
11
16
  "strict": true,
17
+ "exactOptionalPropertyTypes": true,
18
+ "noFallthroughCasesInSwitch": true,
19
+ "noImplicitOverride": true,
20
+ "noUncheckedIndexedAccess": true,
21
+ "noUncheckedSideEffectImports": true,
22
+ "verbatimModuleSyntax": true,
12
23
  "incremental": true,
13
24
  "tsBuildInfoFile": "${configDir}/.artifacts/tsc/tsconfig.tsbuildinfo",
14
25
  "experimentalDecorators": true,
15
26
  "esModuleInterop": true,
16
27
  "resolveJsonModule": true,
17
- "skipLibCheck": true
28
+ "skipLibCheck": true,
29
+ "allowJs": true,
30
+ "checkJs": false,
31
+ "noEmit": true
18
32
  }
19
33
  }
@@ -0,0 +1,33 @@
1
+ {
2
+ "display": "React (bundled, no framework)",
3
+ "include": ["${configDir}/**/*.ts", "${configDir}/**/*.tsx"],
4
+ "exclude": [
5
+ "${configDir}/node_modules",
6
+ "${configDir}/dist",
7
+ "${configDir}/.artifacts",
8
+ "${configDir}/**/_fixtures",
9
+ "${configDir}/**/_expected"
10
+ ],
11
+ "compilerOptions": {
12
+ "allowJs": true,
13
+ "esModuleInterop": true,
14
+ "exactOptionalPropertyTypes": true,
15
+ "incremental": true,
16
+ "isolatedModules": true,
17
+ "jsx": "react-jsx",
18
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
19
+ "module": "ESNext",
20
+ "moduleResolution": "bundler",
21
+ "noEmit": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noImplicitOverride": true,
24
+ "noUncheckedIndexedAccess": true,
25
+ "noUncheckedSideEffectImports": true,
26
+ "resolveJsonModule": true,
27
+ "skipLibCheck": true,
28
+ "strict": true,
29
+ "target": "ESNext",
30
+ "tsBuildInfoFile": "${configDir}/.artifacts/tsc/tsconfig.tsbuildinfo",
31
+ "verbatimModuleSyntax": true
32
+ }
33
+ }
@@ -0,0 +1,13 @@
1
+ /*
2
+ * The shape of a bundler config is tsdown's own fact, so this declaration does
3
+ * not restate it: `UserConfig` is re-exported from the tool, for a consumer
4
+ * that annotates its own config ([Developing](../../docs/02-developing.md)).
5
+ */
6
+
7
+ import type { UserConfig } from 'tsdown';
8
+
9
+ /** An application build: one ESM output with declarations and source maps. */
10
+ declare const build: UserConfig;
11
+
12
+ export { type UserConfig } from 'tsdown';
13
+ export default build;
@@ -0,0 +1,13 @@
1
+ /*
2
+ * The shape of a bundler config is tsdown's own fact, so this declaration does
3
+ * not restate it: `UserConfig` is re-exported from the tool, for a consumer
4
+ * that annotates its own config ([Developing](../../docs/02-developing.md)).
5
+ */
6
+
7
+ import type { UserConfig } from 'tsdown';
8
+
9
+ /** A library bundle: ESM and CJS outputs with declarations and source maps. */
10
+ declare const bundle: UserConfig;
11
+
12
+ export { type UserConfig } from 'tsdown';
13
+ export default bundle;
@@ -3,7 +3,16 @@ import { defineConfig } from 'tsdown';
3
3
  export default defineConfig({
4
4
  entry: ['src/index.ts'],
5
5
  format: ['esm', 'cjs'],
6
- dts: true,
6
+ /*
7
+ * `isolatedDeclarations` lives here, not in the `library` tsconfig preset:
8
+ * what it buys is a declaration emitted without a type-checker, which is a
9
+ * property of the PUBLISHED artefact and of nothing else. In the tsconfig
10
+ * it also reached every spec file, where it refused the destructured
11
+ * export a specification hands back ([Developing](../../docs/02-developing.md)).
12
+ * `bundle` is the library command, so the guarantee sits exactly where the
13
+ * tsconfig preset used to put it — and nowhere wider.
14
+ */
15
+ dts: { compilerOptions: { isolatedDeclarations: true } },
7
16
  sourcemap: true,
8
17
  clean: true,
9
18
  hash: false,
@@ -0,0 +1,23 @@
1
+ # `rules/` — the manifest
2
+
3
+ One nature lives here: a DECISION about a lint rule. Never a config object, never a path, never a tool invocation — those are `presets/`'s and `bin/`'s. What the decisions mean, and the four laws they answer to, is [Lint presets](../docs/07-lint-presets.md); this page only says where each thing is.
4
+
5
+ | File | Holds |
6
+ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | `_contract.js` | `fragment()`, `on()`, `typeAware()`, `off()`, `unsafeFix()`, `scoped()` — and the load-time refusal of a level that is not `error`/`off`, of an `off` with no reason and of an unsafe fixer with no reason |
8
+ | `compile.js` | fragment → plain oxlint config, and the deterministic merge behind `compose()`. It never emits `categories` |
9
+ | `profiles.js` | which fragments each of the seven profiles carries, and what none of them lints |
10
+ | `catalog.js` | every decision as one list, and the markdown the chapter carries between its `GENERATED` markers |
11
+ | `core/` | one file per plugin of the rulebook every profile holds: `eslint`, `typescript`, `unicorn`, `oxc`, `import`, `promise`, `node`, `jsdoc` |
12
+ | `react.js` · `a11y.js` · `next.js` · `react-native.js` · `astro.js` · `bundler.js` | what a framework profile adds to that rulebook, and what a bundled tree with no framework still owes |
13
+ | `vitest.js` | the test-file rules, as an `overrides` block — they read a test and say nothing about anything else |
14
+ | `sorted.js` | perfectionist: only what oxfmt does not sort |
15
+ | `architecture/` | `layers.js` turns a declared layer map into `no-restricted-imports` overrides; `hexagonal.js` is the map this package ships |
16
+
17
+ A fragment is loaded, not read: importing one runs its contract, so a decision that breaks an invariant fails at import time rather than at review time.
18
+
19
+ ## What proves it
20
+
21
+ - `_contract.test.ts` — the invariants, and the compile of a fragment.
22
+ - `catalog.test.ts` — the chapter is the manifest's projection, and nothing else.
23
+ - `specs/cli/preset/` — the resolved rule set, the plugin coverage, the fixpoint against the formatter, the behaviour of each decision, and the exclusive pairs.
@@ -0,0 +1,207 @@
1
+ /*
2
+ * The rulebook's contract: a FRAGMENT is a set of decisions, one per rule of
3
+ * one plugin, and nothing else. `compile.js` turns a fragment into the oxlint
4
+ * config object a profile ships.
5
+ *
6
+ * import { fragment, off, on } from '../_contract.js';
7
+ *
8
+ * export default fragment({
9
+ * id: 'core/promise',
10
+ * plugins: ['promise'],
11
+ * since: '10.0.0',
12
+ * rules: {
13
+ * 'promise/no-nesting': on(),
14
+ * 'promise/always-return': off({ kind: 'exclusive', by: 'promise/prefer-await-to-then' }),
15
+ * },
16
+ * });
17
+ *
18
+ * Two invariants the whole package rests on: a decision is `error` or `off`,
19
+ * never `warn`; and an `off` carries exactly one recorded reason.
20
+ */
21
+
22
+ /**
23
+ * @typedef {object} Reason Why a rule is off.
24
+ * @property {string} kind One of `REASON_KINDS`.
25
+ * @property {string} by The rule, page or measurement that carries it.
26
+ *
27
+ * @typedef {object} Decision What the rulebook says about one rule.
28
+ * @property {string} rule The oxlint rule id.
29
+ * @property {'error' | 'off'} level There is no third level.
30
+ * @property {unknown} [options] The rule's options, at their decided value.
31
+ * @property {Reason} [reason] Present on every `off`, absent on every `on`.
32
+ * @property {boolean} [typeAware] Whether the rule needs type information.
33
+ * @property {'unsafe'} [fixer] Present when the rule's own fixer changes meaning.
34
+ * @property {string} [fixerReason] What that rewrite changes — measured, one clause.
35
+ * @property {string} since The version the decision was taken in.
36
+ *
37
+ * @typedef {object} Scoped An `overrides` block, stated in decisions.
38
+ * @property {readonly string[]} files The globs it applies to.
39
+ * @property {Readonly<Record<string, Decision>>} decisions What it says there.
40
+ */
41
+
42
+ /** The version this rulebook was born in — the default `since` of every decision. */
43
+ export const SINCE = '10.0.0';
44
+
45
+ /** The five reasons an `off` may carry. Anything else is refused at load time. */
46
+ export const REASON_KINDS = Object.freeze([
47
+ /** Fights oxfmt — proved by the fixpoint suite. */
48
+ 'formatter',
49
+ /** Mutually exclusive with a rule that is on — the rule is named. */
50
+ 'exclusive',
51
+ /** Refuses a convention the estate holds — the page is named. */
52
+ 'convention',
53
+ /** Measured false-positive rate on real estate code — the measurement is cited. */
54
+ 'evidence',
55
+ /** Covered by TypeScript itself, or by a stronger rule that is on — it is named. */
56
+ 'covered',
57
+ ]);
58
+
59
+ /**
60
+ * A rule that is on. Every rule of the rulebook is on at `error`; the optional
61
+ * argument is the rule's options, at their strictest sensible value.
62
+ */
63
+ export function on(options) {
64
+ return Object.freeze({ level: 'error', options });
65
+ }
66
+
67
+ /**
68
+ * Every named rule, on at `error` with its default options — the bulk of a
69
+ * fragment, where the rule id IS the whole decision.
70
+ */
71
+ export function allOn(rules) {
72
+ return Object.fromEntries(rules.map((rule) => [rule, on()]));
73
+ }
74
+
75
+ /**
76
+ * A rule that is off, and why. `reason` is `{ kind, by }` where `kind` is one
77
+ * of `REASON_KINDS` and `by` names the rule, page or measurement that carries
78
+ * the decision. "Too strict" is not a reason.
79
+ */
80
+ export function off(reason, since) {
81
+ assertReason(reason);
82
+ return Object.freeze({ level: 'off', reason: Object.freeze({ ...reason }), since });
83
+ }
84
+
85
+ /**
86
+ * A decision whose FIXER changes meaning. The rule stays on — `check` reports
87
+ * it and a human answers it — but `fix` never applies the rewrite: the eight
88
+ * marked here have each been measured turning working code into code that
89
+ * does not compile, or into code that claims something else
90
+ * ([Lint presets](../docs/07-lint-presets.md)).
91
+ */
92
+ export function unsafeFix(decision, why) {
93
+ if (typeof why !== 'string' || why.length === 0) {
94
+ throw new TypeError('An unsafe fixer must say what its rewrite changes.');
95
+ }
96
+ return Object.freeze({ ...decision, fixer: 'unsafe', fixerReason: why });
97
+ }
98
+
99
+ /**
100
+ * A rule that is on and needs type information — `oxlint --type-aware`, which
101
+ * every profile of this package turns on. The mark is what the catalogue reads.
102
+ */
103
+ export function typeAware(options) {
104
+ return Object.freeze({ level: 'error', options, typeAware: true });
105
+ }
106
+
107
+ /**
108
+ * Declare a fragment: one plugin's decisions, or one framework's. `rules` maps
109
+ * an oxlint rule id to an `on()` / `typeAware()` / `off()` decision, and every
110
+ * decision inherits the fragment's `since` unless it carries its own.
111
+ */
112
+ export function fragment(definition) {
113
+ const {
114
+ id,
115
+ plugins = [],
116
+ jsPlugins = [],
117
+ rules = {},
118
+ overrides = [],
119
+ settings,
120
+ options,
121
+ ignorePatterns = [],
122
+ env,
123
+ globals,
124
+ since = SINCE,
125
+ } = definition;
126
+
127
+ if (typeof id !== 'string' || id.length === 0) {
128
+ throw new TypeError('A fragment needs an id.');
129
+ }
130
+
131
+ /** @type {Record<string, Decision>} */
132
+ const decisions = {};
133
+ for (const [rule, decision] of Object.entries(rules)) {
134
+ decisions[rule] = normalise(id, rule, decision, since);
135
+ }
136
+
137
+ return Object.freeze({
138
+ decisions: Object.freeze(decisions),
139
+ env,
140
+ globals,
141
+ id,
142
+ ignorePatterns: Object.freeze([...ignorePatterns]),
143
+ jsPlugins: Object.freeze([...jsPlugins]),
144
+ options,
145
+ overrides: Object.freeze([...overrides]),
146
+ plugins: Object.freeze([...plugins]),
147
+ settings,
148
+ since,
149
+ });
150
+ }
151
+
152
+ /** Every decision of every fragment, keyed by rule id, fragments merged left to right. */
153
+ export function decisionsOf(...fragments) {
154
+ const merged = {};
155
+ for (const one of fragments) {
156
+ for (const [rule, decision] of Object.entries(one.decisions)) {
157
+ merged[rule] = { ...decision, fragment: one.id };
158
+ }
159
+ for (const override of one.overrides) {
160
+ for (const [rule, decision] of Object.entries(override.decisions ?? {})) {
161
+ merged[rule] = { ...decision, fragment: one.id, scoped: override.files };
162
+ }
163
+ }
164
+ }
165
+ return merged;
166
+ }
167
+
168
+ /**
169
+ * An `overrides` block, stated in decisions like a fragment's own body. Its
170
+ * rule options REPLACE the base entry — they never merge — so a block states
171
+ * the complete option list for every rule it names.
172
+ */
173
+ export function scoped({ files, rules, since = SINCE }) {
174
+ /** @type {Record<string, Decision>} */
175
+ const decisions = {};
176
+ for (const [rule, decision] of Object.entries(rules)) {
177
+ decisions[rule] = normalise(files.join(','), rule, decision, since);
178
+ }
179
+ return Object.freeze({ decisions: Object.freeze(decisions), files: Object.freeze([...files]) });
180
+ }
181
+
182
+ function assertReason(reason) {
183
+ if (!reason || typeof reason !== 'object') {
184
+ throw new TypeError('An off decision needs a reason.');
185
+ }
186
+ if (!REASON_KINDS.includes(reason.kind)) {
187
+ throw new TypeError(`Unknown reason kind "${reason.kind}".`);
188
+ }
189
+ if (typeof reason.by !== 'string' || reason.by.length === 0) {
190
+ throw new TypeError(`A "${reason.kind}" reason must name what carries it.`);
191
+ }
192
+ }
193
+
194
+ function normalise(owner, rule, decision, since) {
195
+ if (!decision || typeof decision !== 'object' || typeof decision.level !== 'string') {
196
+ throw new TypeError(
197
+ `${owner}: "${rule}" is not a decision — use on(), typeAware() or off().`,
198
+ );
199
+ }
200
+ if (decision.level !== 'error' && decision.level !== 'off') {
201
+ throw new TypeError(`${owner}: "${rule}" is "${decision.level}" — a rule is error or off.`);
202
+ }
203
+ if (decision.level === 'off') {
204
+ assertReason(decision.reason);
205
+ }
206
+ return Object.freeze({ ...decision, rule, since: decision.since ?? since });
207
+ }
@@ -0,0 +1,81 @@
1
+ import { expect, test } from 'vitest';
2
+
3
+ import { fragment, off, on, REASON_KINDS, typeAware } from './_contract.js';
4
+ import { compile } from './compile.js';
5
+
6
+ /*
7
+ * The two invariants every fragment of the rulebook rests on: a decision is
8
+ * `error` or `off` and never `warn`, and an `off` carries exactly one of the
9
+ * five recorded reasons. Both are enforced at LOAD time, so a fragment that
10
+ * breaks one cannot be imported at all.
11
+ */
12
+
13
+ const REASON = { by: 'oxfmt sortImports', kind: 'covered' } as const;
14
+
15
+ test('refuses an off with no reason', () => {
16
+ // Given - a decision turned off on nobody's authority
17
+ // Then - the contract refuses it before any config is built
18
+ expect(() => off()).toThrow(/needs a reason/u);
19
+ });
20
+
21
+ test('refuses a reason whose kind is not one of the five', () => {
22
+ // Given - a reason invented on the spot
23
+ // Then - the contract names the five and refuses the sixth
24
+ expect(() => off({ by: 'taste', kind: 'too-strict' })).toThrow(/Unknown reason kind/u);
25
+ expect(REASON_KINDS).toHaveLength(5);
26
+ });
27
+
28
+ test('refuses a reason that names nothing', () => {
29
+ // Given - a kind with no rule, page or measurement behind it
30
+ // Then - "covered" has to say by what
31
+ expect(() => off({ by: '', kind: 'covered' })).toThrow(/must name what carries it/u);
32
+ });
33
+
34
+ /** A fragment reaching for the warning tier this rulebook does not have. */
35
+ function buildWarningFragment() {
36
+ return fragment({ id: 'probe', rules: { curly: { level: 'warn' } } });
37
+ }
38
+
39
+ test('refuses a level that is neither error nor off', () => {
40
+ // Given - a fragment reaching for a warning tier
41
+ // Then - there is no warn in this rulebook
42
+ expect(buildWarningFragment).toThrow(/a rule is error or off/u);
43
+ });
44
+
45
+ test('gives every decision the fragment version, unless it carries its own', () => {
46
+ // Given - a fragment born in one version with one decision taken later
47
+ const probe = fragment({
48
+ id: 'probe',
49
+ rules: { curly: on(), 'no-var': off(REASON, '10.2.0') },
50
+ since: '10.0.0',
51
+ });
52
+
53
+ // Then - each decision states when it was taken
54
+ expect(probe.decisions.curly?.since).toBe('10.0.0');
55
+ expect(probe.decisions['no-var']?.since).toBe('10.2.0');
56
+ });
57
+
58
+ test('compiles a fragment to a plain oxlint config, and never to a category', () => {
59
+ // Given - a fragment with an option, a type-aware rule and an off
60
+ const probe = fragment({
61
+ id: 'probe',
62
+ plugins: ['typescript'],
63
+ rules: {
64
+ curly: on(),
65
+ 'max-depth': on([4]),
66
+ 'no-var': off(REASON),
67
+ 'typescript/await-thenable': typeAware(),
68
+ },
69
+ });
70
+
71
+ // Then - the config states every level by name
72
+ expect(compile(probe)).toStrictEqual({
73
+ plugins: ['typescript'],
74
+ rules: {
75
+ curly: 'error',
76
+ 'max-depth': ['error', 4],
77
+ 'no-var': 'off',
78
+ 'typescript/await-thenable': 'error',
79
+ },
80
+ });
81
+ });
package/rules/a11y.js ADDED
@@ -0,0 +1,51 @@
1
+ import { allOn, fragment } from './_contract.js';
2
+
3
+ /*
4
+ * The `jsx-a11y` plugin, all 36 rules on. There is no off here and no reason
5
+ * to record: an accessibility rule describes what a person using the product
6
+ * can reach, and the estate has no profile that may decide otherwise.
7
+ */
8
+ export default fragment({
9
+ id: 'a11y',
10
+ plugins: ['jsx-a11y'],
11
+ rules: allOn(
12
+ [
13
+ 'alt-text',
14
+ 'anchor-ambiguous-text',
15
+ 'anchor-has-content',
16
+ 'anchor-is-valid',
17
+ 'aria-activedescendant-has-tabindex',
18
+ 'aria-props',
19
+ 'aria-proptypes',
20
+ 'aria-role',
21
+ 'aria-unsupported-elements',
22
+ 'autocomplete-valid',
23
+ 'click-events-have-key-events',
24
+ 'control-has-associated-label',
25
+ 'heading-has-content',
26
+ 'html-has-lang',
27
+ 'iframe-has-title',
28
+ 'img-redundant-alt',
29
+ 'interactive-supports-focus',
30
+ 'label-has-associated-control',
31
+ 'lang',
32
+ 'media-has-caption',
33
+ 'mouse-events-have-key-events',
34
+ 'no-access-key',
35
+ 'no-aria-hidden-on-focusable',
36
+ 'no-autofocus',
37
+ 'no-distracting-elements',
38
+ 'no-interactive-element-to-noninteractive-role',
39
+ 'no-noninteractive-element-interactions',
40
+ 'no-noninteractive-element-to-interactive-role',
41
+ 'no-noninteractive-tabindex',
42
+ 'no-redundant-roles',
43
+ 'no-static-element-interactions',
44
+ 'prefer-tag-over-role',
45
+ 'role-has-required-aria-props',
46
+ 'role-supports-aria-props',
47
+ 'scope',
48
+ 'tabindex-no-positive',
49
+ ].map((rule) => `jsx-a11y/${rule}`),
50
+ ),
51
+ });
@@ -0,0 +1,56 @@
1
+ import { layers } from './layers.js';
2
+
3
+ /*
4
+ * The hexagonal map: the six boundaries this package has enforced since v6,
5
+ * restated as a layer map. The dependency arrow points inward — the domain
6
+ * knows nothing, the application knows the domain, everything else knows the
7
+ * application.
8
+ */
9
+ export const HEXAGONAL_MAP = Object.freeze([
10
+ {
11
+ deny: [
12
+ '**/application/**',
13
+ '**/infrastructure/**',
14
+ '**/presentation/**',
15
+ '**/di/**',
16
+ '**/config/**',
17
+ '**/generated/**',
18
+ ],
19
+ files: ['**/domain/**'],
20
+ message: 'the domain layer is pure — it imports no other layer',
21
+ name: 'domain',
22
+ },
23
+ {
24
+ deny: ['**/infrastructure/**', '**/presentation/**', '**/di/**'],
25
+ files: ['**/application/use-cases/**'],
26
+ message: 'a use case depends on the domain and on ports, never on an implementation',
27
+ name: 'application/use-cases',
28
+ },
29
+ {
30
+ deny: ['**/infrastructure/**', '**/presentation/**', '**/di/**'],
31
+ files: ['**/application/ports/**'],
32
+ message: 'a port is an interface — it cannot depend on what implements it',
33
+ name: 'application/ports',
34
+ },
35
+ {
36
+ deny: ['**/infrastructure/outbound/**'],
37
+ files: ['**/infrastructure/inbound/**'],
38
+ message: 'an inbound adapter reaches an outbound one through injection, not by import',
39
+ name: 'infrastructure/inbound',
40
+ },
41
+ {
42
+ deny: ['**/navigation/**'],
43
+ files: ['**/presentation/ui/atoms/**', '**/presentation/ui/molecules/**'],
44
+ message: 'an atom and a molecule are pure — navigation is a feature concern',
45
+ name: 'presentation/ui',
46
+ },
47
+ {
48
+ allow: ['**/presentation/features/common/**'],
49
+ deny: ['**/presentation/features/**'],
50
+ files: ['**/presentation/features/**'],
51
+ message: 'a feature is independent — shared code lives in features/common',
52
+ name: 'presentation/features',
53
+ },
54
+ ]);
55
+
56
+ export default layers({ id: 'architecture/hexagonal', map: HEXAGONAL_MAP });