@jterrazz/typescript 9.3.0 → 10.0.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 (71) hide show
  1. package/README.md +20 -16
  2. package/bin/commands/check.sh +348 -117
  3. package/bin/typescript.sh +55 -0
  4. package/lib/check-architecture.js +89 -0
  5. package/lib/check-baseline.js +144 -0
  6. package/lib/check-docs.js +4 -3
  7. package/lib/check-drift.js +209 -0
  8. package/lib/check-gitignore.js +4 -4
  9. package/lib/check-markdown.js +279 -0
  10. package/lib/check-names.js +125 -0
  11. package/lib/check-publish.js +150 -0
  12. package/lib/check-secrets.js +115 -0
  13. package/lib/check-suppressions.js +355 -0
  14. package/lib/doctor.js +185 -0
  15. package/lib/merge-knip-config.js +57 -25
  16. package/lib/tracked-files.js +165 -0
  17. package/lib/workspace-members.js +5 -6
  18. package/package.json +19 -8
  19. package/presets/oxfmt/index.js +49 -5
  20. package/presets/oxlint/profiles/astro.js +10 -0
  21. package/presets/oxlint/profiles/bun.js +7 -0
  22. package/presets/oxlint/profiles/expo.js +7 -0
  23. package/presets/oxlint/profiles/library.js +16 -0
  24. package/presets/oxlint/profiles/next.js +7 -0
  25. package/presets/oxlint/profiles/node.js +7 -0
  26. package/presets/prettier/astro.json +6 -0
  27. package/presets/tsconfig/expo.json +16 -6
  28. package/presets/tsconfig/library.json +18 -0
  29. package/presets/tsconfig/next.json +12 -2
  30. package/presets/tsconfig/node.json +18 -4
  31. package/rules/README.md +23 -0
  32. package/rules/_contract.js +191 -0
  33. package/rules/_contract.test.ts +81 -0
  34. package/rules/a11y.js +51 -0
  35. package/rules/architecture/hexagonal.js +56 -0
  36. package/rules/architecture/layers.js +75 -0
  37. package/rules/astro.js +49 -0
  38. package/rules/catalog.js +134 -0
  39. package/rules/catalog.test.ts +84 -0
  40. package/rules/compile.js +125 -0
  41. package/rules/core/eslint.js +234 -0
  42. package/rules/core/import.js +107 -0
  43. package/rules/core/jsdoc.js +52 -0
  44. package/rules/core/node.js +36 -0
  45. package/rules/core/oxc.js +54 -0
  46. package/rules/core/promise.js +39 -0
  47. package/rules/core/typescript.js +204 -0
  48. package/rules/core/unicorn.js +200 -0
  49. package/rules/next.js +53 -0
  50. package/rules/profiles.js +89 -0
  51. package/rules/react-native.js +48 -0
  52. package/rules/react.js +148 -0
  53. package/rules/sorted.js +41 -0
  54. package/rules/vitest.js +153 -0
  55. package/src/docs.d.ts +4 -4
  56. package/src/docs.js +75 -57
  57. package/src/docs.test.ts +43 -32
  58. package/src/index.d.ts +13 -9
  59. package/src/index.js +15 -8
  60. package/src/oxfmt.d.ts +15 -2
  61. package/src/oxfmt.test.ts +10 -0
  62. package/src/oxlint.d.ts +57 -10
  63. package/src/oxlint.js +35 -50
  64. package/src/oxlint.test.ts +82 -28
  65. package/presets/oxlint/architectures/hexagonal-rules.js +0 -39
  66. package/presets/oxlint/architectures/hexagonal.js +0 -13
  67. package/presets/oxlint/base.js +0 -145
  68. package/presets/oxlint/expo.js +0 -36
  69. package/presets/oxlint/next.js +0 -43
  70. package/presets/oxlint/node.js +0 -14
  71. package/presets/oxlint/plugins/codestyle.js +0 -231
@@ -0,0 +1,191 @@
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 {string} since The version the decision was taken in.
34
+ *
35
+ * @typedef {object} Scoped An `overrides` block, stated in decisions.
36
+ * @property {readonly string[]} files The globs it applies to.
37
+ * @property {Readonly<Record<string, Decision>>} decisions What it says there.
38
+ */
39
+
40
+ /** The version this rulebook was born in — the default `since` of every decision. */
41
+ export const SINCE = '10.0.0';
42
+
43
+ /** The five reasons an `off` may carry. Anything else is refused at load time. */
44
+ export const REASON_KINDS = Object.freeze([
45
+ /** Fights oxfmt — proved by the fixpoint suite. */
46
+ 'formatter',
47
+ /** Mutually exclusive with a rule that is on — the rule is named. */
48
+ 'exclusive',
49
+ /** Refuses a convention the estate holds — the page is named. */
50
+ 'convention',
51
+ /** Measured false-positive rate on real estate code — the measurement is cited. */
52
+ 'evidence',
53
+ /** Covered by TypeScript itself, or by a stronger rule that is on — it is named. */
54
+ 'covered',
55
+ ]);
56
+
57
+ /**
58
+ * A rule that is on. Every rule of the rulebook is on at `error`; the optional
59
+ * argument is the rule's options, at their strictest sensible value.
60
+ */
61
+ export function on(options) {
62
+ return Object.freeze({ level: 'error', options });
63
+ }
64
+
65
+ /**
66
+ * Every named rule, on at `error` with its default options — the bulk of a
67
+ * fragment, where the rule id IS the whole decision.
68
+ */
69
+ export function allOn(rules) {
70
+ return Object.fromEntries(rules.map((rule) => [rule, on()]));
71
+ }
72
+
73
+ /**
74
+ * A rule that is off, and why. `reason` is `{ kind, by }` where `kind` is one
75
+ * of `REASON_KINDS` and `by` names the rule, page or measurement that carries
76
+ * the decision. "Too strict" is not a reason.
77
+ */
78
+ export function off(reason, since) {
79
+ assertReason(reason);
80
+ return Object.freeze({ level: 'off', reason: Object.freeze({ ...reason }), since });
81
+ }
82
+
83
+ /**
84
+ * A rule that is on and needs type information — `oxlint --type-aware`, which
85
+ * every profile of this package turns on. The mark is what the catalogue reads.
86
+ */
87
+ export function typeAware(options) {
88
+ return Object.freeze({ level: 'error', options, typeAware: true });
89
+ }
90
+
91
+ /**
92
+ * Declare a fragment: one plugin's decisions, or one framework's. `rules` maps
93
+ * an oxlint rule id to an `on()` / `typeAware()` / `off()` decision, and every
94
+ * decision inherits the fragment's `since` unless it carries its own.
95
+ */
96
+ export function fragment(definition) {
97
+ const {
98
+ id,
99
+ plugins = [],
100
+ jsPlugins = [],
101
+ rules = {},
102
+ overrides = [],
103
+ settings,
104
+ options,
105
+ ignorePatterns = [],
106
+ env,
107
+ globals,
108
+ since = SINCE,
109
+ } = definition;
110
+
111
+ if (typeof id !== 'string' || id.length === 0) {
112
+ throw new TypeError('A fragment needs an id.');
113
+ }
114
+
115
+ /** @type {Record<string, Decision>} */
116
+ const decisions = {};
117
+ for (const [rule, decision] of Object.entries(rules)) {
118
+ decisions[rule] = normalise(id, rule, decision, since);
119
+ }
120
+
121
+ return Object.freeze({
122
+ decisions: Object.freeze(decisions),
123
+ env,
124
+ globals,
125
+ id,
126
+ ignorePatterns: Object.freeze([...ignorePatterns]),
127
+ jsPlugins: Object.freeze([...jsPlugins]),
128
+ options,
129
+ overrides: Object.freeze([...overrides]),
130
+ plugins: Object.freeze([...plugins]),
131
+ settings,
132
+ since,
133
+ });
134
+ }
135
+
136
+ /** Every decision of every fragment, keyed by rule id, fragments merged left to right. */
137
+ export function decisionsOf(...fragments) {
138
+ const merged = {};
139
+ for (const one of fragments) {
140
+ for (const [rule, decision] of Object.entries(one.decisions)) {
141
+ merged[rule] = { ...decision, fragment: one.id };
142
+ }
143
+ for (const override of one.overrides) {
144
+ for (const [rule, decision] of Object.entries(override.decisions ?? {})) {
145
+ merged[rule] = { ...decision, fragment: one.id, scoped: override.files };
146
+ }
147
+ }
148
+ }
149
+ return merged;
150
+ }
151
+
152
+ /**
153
+ * An `overrides` block, stated in decisions like a fragment's own body. Its
154
+ * rule options REPLACE the base entry — they never merge — so a block states
155
+ * the complete option list for every rule it names.
156
+ */
157
+ export function scoped({ files, rules, since = SINCE }) {
158
+ /** @type {Record<string, Decision>} */
159
+ const decisions = {};
160
+ for (const [rule, decision] of Object.entries(rules)) {
161
+ decisions[rule] = normalise(files.join(','), rule, decision, since);
162
+ }
163
+ return Object.freeze({ decisions: Object.freeze(decisions), files: Object.freeze([...files]) });
164
+ }
165
+
166
+ function assertReason(reason) {
167
+ if (!reason || typeof reason !== 'object') {
168
+ throw new TypeError('An off decision needs a reason.');
169
+ }
170
+ if (!REASON_KINDS.includes(reason.kind)) {
171
+ throw new TypeError(`Unknown reason kind "${reason.kind}".`);
172
+ }
173
+ if (typeof reason.by !== 'string' || reason.by.length === 0) {
174
+ throw new TypeError(`A "${reason.kind}" reason must name what carries it.`);
175
+ }
176
+ }
177
+
178
+ function normalise(owner, rule, decision, since) {
179
+ if (!decision || typeof decision !== 'object' || typeof decision.level !== 'string') {
180
+ throw new TypeError(
181
+ `${owner}: "${rule}" is not a decision — use on(), typeAware() or off().`,
182
+ );
183
+ }
184
+ if (decision.level !== 'error' && decision.level !== 'off') {
185
+ throw new TypeError(`${owner}: "${rule}" is "${decision.level}" — a rule is error or off.`);
186
+ }
187
+ if (decision.level === 'off') {
188
+ assertReason(decision.reason);
189
+ }
190
+ return Object.freeze({ ...decision, rule, since: decision.since ?? since });
191
+ }
@@ -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 });
@@ -0,0 +1,75 @@
1
+ import { fragment, on, scoped } from '../_contract.js';
2
+
3
+ /*
4
+ * A layer map, compiled to `no-restricted-imports` overrides — one per layer,
5
+ * each carrying that layer's COMPLETE pattern list.
6
+ *
7
+ * Two oxlint semantics shape every line of this file, both verified against
8
+ * 1.83 and both permanent:
9
+ *
10
+ * - An override's rule OPTIONS REPLACE the base entry; they never merge
11
+ * (oxc#17527). So a layer's override states every pattern that layer is
12
+ * bound by, and nothing about layering is left in the base config.
13
+ * - The `regex` matcher is Rust regex, which has no lookahead. An exception
14
+ * like "features may not import features, except common" is two `group`
15
+ * globs — the deny glob, then the same glob negated with `!`.
16
+ *
17
+ * And one limit worth stating: `no-restricted-imports` matches the SPECIFIER
18
+ * STRING, never a resolved path. `../beta/thing.js` does not carry the layer
19
+ * name, so it passes. A layer map is a textual boundary, and a repository that
20
+ * needs a graph boundary declares a dependency-cruiser map beside it.
21
+ */
22
+
23
+ /**
24
+ * Build a fragment from a layer map. Each layer is
25
+ * `{ name, files, deny, allow?, message }`: `files` are the globs that BELONG
26
+ * to the layer, `deny` the specifier globs it may not import, `allow` the
27
+ * exceptions carved out of them.
28
+ */
29
+ export function layers({ id = 'layers', map }) {
30
+ assertDisjoint(id, map);
31
+
32
+ return fragment({
33
+ id,
34
+ overrides: map.map((layer) =>
35
+ scoped({
36
+ files: [...layer.files],
37
+ rules: {
38
+ 'no-restricted-imports': on([
39
+ {
40
+ patterns: [
41
+ {
42
+ group: [
43
+ ...layer.deny,
44
+ ...(layer.allow ?? []).map((glob) => `!${glob}`),
45
+ ],
46
+ message: layer.message,
47
+ },
48
+ ],
49
+ },
50
+ ]),
51
+ },
52
+ }),
53
+ ),
54
+ rules: {},
55
+ });
56
+ }
57
+
58
+ /*
59
+ * One file belongs to one layer. Two layers sharing a `files` glob would each
60
+ * write an override for it, and the last one would silently erase the first —
61
+ * the replace semantics above. Refuse the map instead of shipping the hole.
62
+ */
63
+ function assertDisjoint(id, map) {
64
+ const seen = new Map();
65
+ for (const layer of map) {
66
+ for (const glob of layer.files) {
67
+ if (seen.has(glob)) {
68
+ throw new TypeError(
69
+ `${id}: "${glob}" belongs to both "${seen.get(glob)}" and "${layer.name}" — an override replaces, so one map entry would erase the other.`,
70
+ );
71
+ }
72
+ seen.set(glob, layer.name);
73
+ }
74
+ }
75
+ }
package/rules/astro.js ADDED
@@ -0,0 +1,49 @@
1
+ import { fragment, off, on, scoped } from './_contract.js';
2
+ import { EXTENSIONS_NEVER } from './core/import.js';
3
+
4
+ /*
5
+ * Astro. oxlint parses an `.astro` file's script body but not its frontmatter
6
+ * as a module, so the three decisions below are scoped to `*.astro` alone;
7
+ * everything else in an Astro tree is ordinary TypeScript under the core
8
+ * rulebook.
9
+ *
10
+ * The restricted imports are the boundary five checkouts hand-copied before
11
+ * this fragment existed: an Astro site is not a Next site, and reaching for
12
+ * Next's router or its intl package is the mistake that keeps being made.
13
+ */
14
+ export default fragment({
15
+ id: 'astro',
16
+ ignorePatterns: ['.astro/**', 'assets/**', 'public/**'],
17
+ overrides: [
18
+ scoped({
19
+ files: ['**/*.astro'],
20
+ rules: {
21
+ 'import/no-commonjs': on(),
22
+ 'no-restricted-globals': on(['__dirname', '__filename']),
23
+ 'unicorn/prefer-module': off({
24
+ by: 'an .astro frontmatter block is not a module body, so the rule reads the file wrong',
25
+ kind: 'convention',
26
+ }),
27
+ },
28
+ }),
29
+ ],
30
+ rules: {
31
+ 'import/extensions': EXTENSIONS_NEVER,
32
+ 'no-restricted-imports': on([
33
+ {
34
+ patterns: [
35
+ {
36
+ group: [
37
+ 'next',
38
+ 'next/*',
39
+ 'next-intl',
40
+ 'next-intl/*',
41
+ '@jterrazz/manifest/next',
42
+ ],
43
+ message: 'this is an Astro site — the Next runtime is not on it',
44
+ },
45
+ ],
46
+ },
47
+ ]),
48
+ },
49
+ });
@@ -0,0 +1,134 @@
1
+ import { PROFILES } from './profiles.js';
2
+
3
+ /**
4
+ * @typedef {object} Entry One decision, with the profiles that carry it.
5
+ * @property {string} rule The oxlint rule id.
6
+ * @property {'error' | 'off'} level What the rulebook says about it.
7
+ * @property {unknown} [options] The rule's options, at their decided value.
8
+ * @property {{ by: string, kind: string }} [reason] Why it is off.
9
+ * @property {boolean} [typeAware] Whether it needs type information.
10
+ * @property {readonly string[]} [scoped] The globs it applies to, when not all of them.
11
+ * @property {string} fragment The fragment that took the decision.
12
+ * @property {string} since The version the decision was taken in.
13
+ * @property {string[]} profiles The profiles that carry it.
14
+ */
15
+
16
+ /*
17
+ * The catalogue: every decision of every profile, with the profiles that carry
18
+ * it. It is the one place the rulebook is readable as a list, and the GENERATED
19
+ * section of [Lint presets](../docs/07-lint-presets.md) is its projection —
20
+ * `rules/catalog.test.ts` fails a chapter that has drifted from it.
21
+ *
22
+ * Composition is last-wins per rule, so the catalogue resolves each profile
23
+ * first and groups after: `import/extensions` is `always` under node and
24
+ * `never` under the bundler profiles, and a reader is owed both lines, each
25
+ * naming only the profiles it is true for.
26
+ */
27
+
28
+ /** The fence that bounds the generated section of the chapter. */
29
+ export const MARKERS = Object.freeze({
30
+ end: '<!-- /GENERATED -->',
31
+ start: '<!-- GENERATED -->',
32
+ });
33
+
34
+ /** Every profile there is, so a decision carried by all of them says `all`. */
35
+ const EVERY_PROFILE = Object.keys(PROFILES).length;
36
+
37
+ /** Every decision, in rule order, one entry per distinct decision. */
38
+ export function catalog() {
39
+ /** @type {Map<string, Entry>} */
40
+ const grouped = new Map();
41
+
42
+ for (const [name, definition] of Object.entries(PROFILES)) {
43
+ for (const [key, decision] of resolve(definition).entries()) {
44
+ const identity = `${key} @ ${decision.level} @ ${JSON.stringify(decision.options ?? null)}`;
45
+ const existing = grouped.get(identity);
46
+
47
+ if (existing === undefined) {
48
+ grouped.set(identity, { ...decision, profiles: [name] });
49
+ } else {
50
+ existing.profiles.push(name);
51
+ }
52
+ }
53
+ }
54
+
55
+ return [...grouped.values()].toSorted(
56
+ (left, right) =>
57
+ left.rule.localeCompare(right.rule) || left.fragment.localeCompare(right.fragment),
58
+ );
59
+ }
60
+
61
+ /** The catalogue as the markdown table the chapter carries between its markers. */
62
+ export function render() {
63
+ const rows = catalog().map((entry) => {
64
+ const state = entry.level === 'off' ? 'off' : 'on';
65
+ const why =
66
+ entry.level === 'off' ? `${entry.reason.kind} — ${entry.reason.by}` : scopeOf(entry);
67
+ return `| \`${entry.rule}\` | ${entry.profiles.join(', ')} | ${state} | ${why} | ${entry.since} |`;
68
+ });
69
+
70
+ return [
71
+ '| Rule | Profiles | State | Reason | Since |',
72
+ '| --- | --- | --- | --- | --- |',
73
+ ...rows,
74
+ ].join('\n');
75
+ }
76
+
77
+ /**
78
+ * The same catalogue, trimmed for an agent: what the rule is, whether it runs,
79
+ * the reason in one clause, and where. No `since` column — an agent is reading
80
+ * to answer "may I write this", not "when was this decided", and the chapter
81
+ * carries that half.
82
+ */
83
+ export function renderReference() {
84
+ const rows = catalog().map((entry) => {
85
+ const state = entry.level === 'off' ? 'off' : 'on';
86
+ const why = entry.level === 'off' ? reasonClause(entry.reason) : scopeOf(entry);
87
+ const where = entry.profiles.length === EVERY_PROFILE ? 'all' : entry.profiles.join(', ');
88
+
89
+ return `| \`${entry.rule}\` | ${state} | ${why} | ${where} |`;
90
+ });
91
+
92
+ return ['| Rule | State | Why | Profiles |', '| --- | --- | --- | --- |', ...rows].join('\n');
93
+ }
94
+
95
+ /** An `off` in one clause: its kind, and the first thing its reason names. */
96
+ function reasonClause({ by, kind }) {
97
+ return `${kind}: ${by.split(' — ')[0]}`;
98
+ }
99
+
100
+ /** One profile's decisions, its fragments applied left to right, last wins per rule and scope. */
101
+ function resolve(definition) {
102
+ /** @type {Map<string, Entry>} */
103
+ const decisions = new Map();
104
+
105
+ for (const fragment of definition.fragments) {
106
+ absorb(decisions, fragment, fragment.decisions);
107
+ for (const override of fragment.overrides) {
108
+ absorb(decisions, fragment, override.decisions, override.files);
109
+ }
110
+ }
111
+
112
+ return decisions;
113
+ }
114
+
115
+ function absorb(decisions, fragment, entries, scoped) {
116
+ const scope = scoped === undefined ? '' : scoped.join(',');
117
+
118
+ for (const [rule, decision] of Object.entries(entries)) {
119
+ decisions.set(`${rule} @ ${scope}`, {
120
+ ...decision,
121
+ fragment: fragment.id,
122
+ rule,
123
+ scoped,
124
+ });
125
+ }
126
+ }
127
+
128
+ /** What an `on` entry has to say for itself: where it applies, when that is not everywhere. */
129
+ function scopeOf(entry) {
130
+ if (entry.scoped === undefined) {
131
+ return entry.typeAware === true ? 'type-aware' : '—';
132
+ }
133
+ return `scoped to ${entry.scoped.map((glob) => `\`${glob}\``).join(', ')}`;
134
+ }