@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.
- package/README.md +20 -16
- package/bin/commands/check.sh +387 -146
- package/bin/find-tsc.sh +30 -0
- package/bin/typescript.sh +79 -1
- package/lib/check-architecture.js +89 -0
- package/lib/check-baseline.js +144 -0
- package/lib/check-docs.js +4 -3
- package/lib/check-drift.js +209 -0
- package/lib/check-gitignore.js +4 -4
- package/lib/check-markdown.js +279 -0
- package/lib/check-names.js +125 -0
- package/lib/check-publish.js +150 -0
- package/lib/check-secrets.js +115 -0
- package/lib/check-suppressions.js +355 -0
- package/lib/doctor.js +185 -0
- package/lib/entry-points.js +91 -0
- package/lib/merge-knip-config.js +57 -25
- package/lib/tracked-files.js +165 -0
- package/lib/unsafe-fixers.js +25 -0
- package/lib/workspace-members.js +5 -6
- package/package.json +36 -13
- package/presets/oxfmt/index.js +49 -5
- package/presets/oxlint/profiles/astro.js +10 -0
- package/presets/oxlint/profiles/bun.js +7 -0
- package/presets/oxlint/profiles/expo.js +7 -0
- package/presets/oxlint/profiles/library.js +16 -0
- package/presets/oxlint/profiles/next.js +7 -0
- package/presets/oxlint/profiles/node.js +7 -0
- package/presets/oxlint/profiles/react.js +7 -0
- package/presets/prettier/astro.json +6 -0
- package/presets/tsconfig/astro.json +25 -0
- package/presets/tsconfig/expo.json +16 -6
- package/presets/tsconfig/library.json +17 -0
- package/presets/tsconfig/next.json +12 -2
- package/presets/tsconfig/node.json +18 -4
- package/presets/tsconfig/react.json +33 -0
- package/presets/tsdown/build.d.ts +13 -0
- package/presets/tsdown/bundle.d.ts +13 -0
- package/presets/tsdown/bundle.js +10 -1
- package/rules/README.md +23 -0
- package/rules/_contract.js +207 -0
- package/rules/_contract.test.ts +81 -0
- package/rules/a11y.js +51 -0
- package/rules/architecture/hexagonal.js +56 -0
- package/rules/architecture/layers.js +75 -0
- package/rules/astro.js +56 -0
- package/rules/bundler.js +19 -0
- package/rules/catalog.js +166 -0
- package/rules/catalog.test.ts +98 -0
- package/rules/compile.js +125 -0
- package/rules/core/eslint.js +234 -0
- package/rules/core/import.js +117 -0
- package/rules/core/jsdoc.js +52 -0
- package/rules/core/node.js +36 -0
- package/rules/core/oxc.js +54 -0
- package/rules/core/promise.js +39 -0
- package/rules/core/typescript.js +223 -0
- package/rules/core/unicorn.js +210 -0
- package/rules/next.js +53 -0
- package/rules/profiles.js +95 -0
- package/rules/react-native.js +48 -0
- package/rules/react.js +155 -0
- package/rules/sorted.js +41 -0
- package/rules/vitest.js +178 -0
- package/src/docs.d.ts +4 -4
- package/src/docs.js +75 -57
- package/src/docs.test.ts +43 -31
- package/src/index.d.ts +14 -9
- package/src/index.js +17 -8
- package/src/oxfmt.d.ts +15 -2
- package/src/oxfmt.test.ts +10 -0
- package/src/oxlint.d.ts +59 -10
- package/src/oxlint.js +36 -50
- package/src/oxlint.test.ts +82 -28
- package/presets/oxlint/architectures/hexagonal-rules.js +0 -39
- package/presets/oxlint/architectures/hexagonal.js +0 -13
- package/presets/oxlint/base.js +0 -145
- package/presets/oxlint/expo.js +0 -36
- package/presets/oxlint/next.js +0 -43
- package/presets/oxlint/node.js +0 -14
- package/presets/oxlint/plugins/codestyle.js +0 -231
|
@@ -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,56 @@
|
|
|
1
|
+
import { fragment, off, on, scoped } from './_contract.js';
|
|
2
|
+
import { ASSETS } 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
|
+
/*
|
|
32
|
+
* `never`, with two extensions that stay: an asset, which Vite resolves
|
|
33
|
+
* BY its extension, and `.astro` itself — Astro's `resolve.extensions`
|
|
34
|
+
* does not carry it, so a layout imported without it fails the build.
|
|
35
|
+
* An override's options REPLACE the base entry, so the whole list is
|
|
36
|
+
* restated here.
|
|
37
|
+
*/
|
|
38
|
+
'import/extensions': on(['never', { ...ASSETS, astro: 'always' }]),
|
|
39
|
+
'no-restricted-imports': on([
|
|
40
|
+
{
|
|
41
|
+
patterns: [
|
|
42
|
+
{
|
|
43
|
+
group: [
|
|
44
|
+
'next',
|
|
45
|
+
'next/*',
|
|
46
|
+
'next-intl',
|
|
47
|
+
'next-intl/*',
|
|
48
|
+
'@jterrazz/manifest/next',
|
|
49
|
+
],
|
|
50
|
+
message: 'this is an Astro site — the Next runtime is not on it',
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
]),
|
|
55
|
+
},
|
|
56
|
+
});
|
package/rules/bundler.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fragment } from './_contract.js';
|
|
2
|
+
import { EXTENSIONS_NEVER } from './core/import.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* A bundled tree with no framework behind it — Vite, Remotion, a browser
|
|
6
|
+
* extension. oxlint has no plugin to load for one, so this fragment carries a
|
|
7
|
+
* single decision: the bundler resolves the specifier, so an import carries no
|
|
8
|
+
* extension.
|
|
9
|
+
*
|
|
10
|
+
* `rules/next.js`, `rules/astro.js` and `rules/react-native.js` each state the
|
|
11
|
+
* same decision inside their own framework fragment, where it sits beside that
|
|
12
|
+
* framework's plugin. A profile with no framework fragment states it here.
|
|
13
|
+
*/
|
|
14
|
+
export default fragment({
|
|
15
|
+
id: 'bundler',
|
|
16
|
+
rules: {
|
|
17
|
+
'import/extensions': EXTENSIONS_NEVER,
|
|
18
|
+
},
|
|
19
|
+
});
|
package/rules/catalog.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
/** The fence that bounds the list of fixers `fix` refuses to run. */
|
|
35
|
+
export const FIXER_MARKERS = Object.freeze({
|
|
36
|
+
end: '<!-- /GENERATED:fixers -->',
|
|
37
|
+
start: '<!-- GENERATED:fixers -->',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** Every profile there is, so a decision carried by all of them says `all`. */
|
|
41
|
+
const EVERY_PROFILE = Object.keys(PROFILES).length;
|
|
42
|
+
|
|
43
|
+
/** Every decision, in rule order, one entry per distinct decision. */
|
|
44
|
+
export function catalog() {
|
|
45
|
+
/** @type {Map<string, Entry>} */
|
|
46
|
+
const grouped = new Map();
|
|
47
|
+
|
|
48
|
+
for (const [name, definition] of Object.entries(PROFILES)) {
|
|
49
|
+
for (const [key, decision] of resolve(definition).entries()) {
|
|
50
|
+
const identity = `${key} @ ${decision.level} @ ${JSON.stringify(decision.options ?? null)}`;
|
|
51
|
+
const existing = grouped.get(identity);
|
|
52
|
+
|
|
53
|
+
if (existing === undefined) {
|
|
54
|
+
grouped.set(identity, { ...decision, profiles: [name] });
|
|
55
|
+
} else {
|
|
56
|
+
existing.profiles.push(name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [...grouped.values()].toSorted(
|
|
62
|
+
(left, right) =>
|
|
63
|
+
left.rule.localeCompare(right.rule) || left.fragment.localeCompare(right.fragment),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The catalogue as the markdown table the chapter carries between its markers. */
|
|
68
|
+
export function render() {
|
|
69
|
+
const rows = catalog().map((entry) => {
|
|
70
|
+
const state = entry.level === 'off' ? 'off' : 'on';
|
|
71
|
+
const why =
|
|
72
|
+
entry.level === 'off' ? `${entry.reason.kind} — ${entry.reason.by}` : scopeOf(entry);
|
|
73
|
+
return `| \`${entry.rule}\` | ${entry.profiles.join(', ')} | ${state} | ${why} | ${entry.since} |`;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
'| Rule | Profiles | State | Reason | Since |',
|
|
78
|
+
'| --- | --- | --- | --- | --- |',
|
|
79
|
+
...rows,
|
|
80
|
+
].join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The same catalogue, trimmed for an agent: what the rule is, whether it runs,
|
|
85
|
+
* the reason in one clause, and where. No `since` column — an agent is reading
|
|
86
|
+
* to answer "may I write this", not "when was this decided", and the chapter
|
|
87
|
+
* carries that half.
|
|
88
|
+
*/
|
|
89
|
+
export function renderReference() {
|
|
90
|
+
const rows = catalog().map((entry) => {
|
|
91
|
+
const state = entry.level === 'off' ? 'off' : 'on';
|
|
92
|
+
const why = entry.level === 'off' ? reasonClause(entry.reason) : scopeOf(entry);
|
|
93
|
+
const where = entry.profiles.length === EVERY_PROFILE ? 'all' : entry.profiles.join(', ');
|
|
94
|
+
|
|
95
|
+
return `| \`${entry.rule}\` | ${state} | ${why} | ${where} |`;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return ['| Rule | State | Why | Profiles |', '| --- | --- | --- | --- |', ...rows].join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** An `off` in one clause: its kind, and the first thing its reason names. */
|
|
102
|
+
function reasonClause({ by, kind }) {
|
|
103
|
+
return `${kind}: ${by.split(' — ')[0]}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One profile's decisions, its fragments applied left to right, last wins per rule and scope. */
|
|
107
|
+
function resolve(definition) {
|
|
108
|
+
/** @type {Map<string, Entry>} */
|
|
109
|
+
const decisions = new Map();
|
|
110
|
+
|
|
111
|
+
for (const fragment of definition.fragments) {
|
|
112
|
+
absorb(decisions, fragment, fragment.decisions);
|
|
113
|
+
for (const override of fragment.overrides) {
|
|
114
|
+
absorb(decisions, fragment, override.decisions, override.files);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return decisions;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function absorb(decisions, fragment, entries, scoped) {
|
|
122
|
+
const scope = scoped === undefined ? '' : scoped.join(',');
|
|
123
|
+
|
|
124
|
+
for (const [rule, decision] of Object.entries(entries)) {
|
|
125
|
+
decisions.set(`${rule} @ ${scope}`, {
|
|
126
|
+
...decision,
|
|
127
|
+
fragment: fragment.id,
|
|
128
|
+
rule,
|
|
129
|
+
scoped,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What an `on` entry has to say for itself: where it applies, when that is not everywhere. */
|
|
135
|
+
function scopeOf(entry) {
|
|
136
|
+
if (entry.scoped === undefined) {
|
|
137
|
+
return entry.typeAware === true ? 'type-aware' : '—';
|
|
138
|
+
}
|
|
139
|
+
return `scoped to ${entry.scoped.map((glob) => `\`${glob}\``).join(', ')}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Every rule whose own fixer changes meaning, with what the rewrite does.
|
|
144
|
+
* `check` reports them and a human answers them; `fix` runs with each one
|
|
145
|
+
* allowed, so the rewrite is never applied
|
|
146
|
+
* ([Quality checks](../docs/06-quality-checks.md)).
|
|
147
|
+
*/
|
|
148
|
+
export function unsafeFixers() {
|
|
149
|
+
const seen = new Map();
|
|
150
|
+
for (const entry of catalog()) {
|
|
151
|
+
if (entry.fixer === 'unsafe' && !seen.has(entry.rule)) {
|
|
152
|
+
seen.set(entry.rule, entry.fixerReason);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return [...seen.entries()]
|
|
157
|
+
.map(([rule, why]) => ({ rule, why }))
|
|
158
|
+
.toSorted((left, right) => left.rule.localeCompare(right.rule));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The same list as the markdown the chapter carries between its fixer markers. */
|
|
162
|
+
export function renderFixers() {
|
|
163
|
+
return unsafeFixers()
|
|
164
|
+
.map(({ rule, why }) => `- \`${rule}\` — ${why}`)
|
|
165
|
+
.join('\n');
|
|
166
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { expect, test } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
catalog,
|
|
7
|
+
FIXER_MARKERS,
|
|
8
|
+
MARKERS,
|
|
9
|
+
render,
|
|
10
|
+
renderFixers,
|
|
11
|
+
renderReference,
|
|
12
|
+
} from './catalog.js';
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* The catalogue has two readers and one source. `docs/07-lint-presets.md` is
|
|
16
|
+
* the human's, with the reason and the version behind every decision; the
|
|
17
|
+
* skill reference is an agent's, trimmed to what it needs to answer "may I
|
|
18
|
+
* write this". Both carry a table between two markers, both are PROJECTIONS of
|
|
19
|
+
* `rules/`, and this suite fails either one that has drifted. Regenerate with
|
|
20
|
+
* `TEST_UPDATE=1 npm test`, the gesture every other golden here takes.
|
|
21
|
+
*
|
|
22
|
+
* It is the @jterrazz/test pattern — one manifest, every roster derived from
|
|
23
|
+
* it, freshness meta-tested — so a decision is never recorded in two places
|
|
24
|
+
* that can disagree.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const PROJECTIONS = {
|
|
28
|
+
chapter: {
|
|
29
|
+
markers: MARKERS,
|
|
30
|
+
page: resolve(import.meta.dirname, '../docs/07-lint-presets.md'),
|
|
31
|
+
render,
|
|
32
|
+
},
|
|
33
|
+
'fixer list': {
|
|
34
|
+
markers: FIXER_MARKERS,
|
|
35
|
+
page: resolve(import.meta.dirname, '../docs/07-lint-presets.md'),
|
|
36
|
+
render: renderFixers,
|
|
37
|
+
},
|
|
38
|
+
'skill reference': {
|
|
39
|
+
markers: MARKERS,
|
|
40
|
+
page: resolve(import.meta.dirname, '../skills/jterrazz-typescript/references/rules.md'),
|
|
41
|
+
render: renderReference,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A markdown table as its cells, with the alignment padding taken out. */
|
|
46
|
+
function cells(table: string): string[] {
|
|
47
|
+
return table
|
|
48
|
+
.trim()
|
|
49
|
+
.split('\n')
|
|
50
|
+
.map((row) =>
|
|
51
|
+
row
|
|
52
|
+
.split('|')
|
|
53
|
+
.map((cell) => cell.trim().replaceAll(/^-+$/gu, '---'))
|
|
54
|
+
.join(' | '),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('every decision carries the version it was taken in', () => {
|
|
59
|
+
// Given - the whole catalogue
|
|
60
|
+
const entries = catalog();
|
|
61
|
+
expect(entries.length).toBeGreaterThan(500);
|
|
62
|
+
|
|
63
|
+
// Then - each one names a semver, which is what replaces a changelog here
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
expect.soft(entry.since, `${entry.rule} has no since`).toMatch(/^\d+\.\d+\.\d+$/u);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test.each(Object.entries(PROJECTIONS))(
|
|
70
|
+
'the %s carries the catalogue the manifest renders',
|
|
71
|
+
(_name, { markers, page, render: project }) => {
|
|
72
|
+
// Given - the generated section of the projection
|
|
73
|
+
const before = readFileSync(page, 'utf8');
|
|
74
|
+
const start = before.indexOf(markers.start);
|
|
75
|
+
const end = before.indexOf(markers.end);
|
|
76
|
+
expect(start, 'the page has lost its GENERATED marker').toBeGreaterThan(-1);
|
|
77
|
+
expect(end, 'the page has lost its /GENERATED marker').toBeGreaterThan(start);
|
|
78
|
+
|
|
79
|
+
// Then - it is exactly what the manifest renders today
|
|
80
|
+
const table = project();
|
|
81
|
+
if (process.env.TEST_UPDATE === '1') {
|
|
82
|
+
const head = before.slice(0, start + markers.start.length);
|
|
83
|
+
writeFileSync(page, `${head}\n\n${table}\n\n${before.slice(end)}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/*
|
|
87
|
+
* The comparison is column-insensitive on purpose: oxfmt owns markdown
|
|
88
|
+
* and it aligns a table's pipes. What a projection must carry is the
|
|
89
|
+
* DECISIONS.
|
|
90
|
+
*/
|
|
91
|
+
const fresh = readFileSync(page, 'utf8');
|
|
92
|
+
const carried = fresh.slice(
|
|
93
|
+
fresh.indexOf(markers.start) + markers.start.length,
|
|
94
|
+
fresh.indexOf(markers.end),
|
|
95
|
+
);
|
|
96
|
+
expect(cells(carried)).toStrictEqual(cells(table));
|
|
97
|
+
},
|
|
98
|
+
);
|
package/rules/compile.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Fragment -> oxlint config. The compiler is the only place a rule level is
|
|
3
|
+
* spelled, and it never emits `categories`: a category switch arms rules of
|
|
4
|
+
* plugins the config never names, inert until a framework config activates the
|
|
5
|
+
* plugin and then firing unannounced. Every rule of this package is decided by
|
|
6
|
+
* name ([Lint presets](../docs/07-lint-presets.md)).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The linter options every profile ships: type information on, dead directives refused. */
|
|
10
|
+
export const OPTIONS = Object.freeze({
|
|
11
|
+
reportUnusedDisableDirectives: 'error',
|
|
12
|
+
typeAware: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Config keys concatenated across configs, duplicates dropped (===). */
|
|
16
|
+
const CONCAT_DEDUPE = new Set(['extends', 'ignorePatterns', 'jsPlugins', 'plugins']);
|
|
17
|
+
/** Config keys concatenated verbatim — order matters, no dedupe. */
|
|
18
|
+
const CONCAT = new Set(['overrides']);
|
|
19
|
+
/** Config keys shallow-merged as objects — the LAST config wins per key. */
|
|
20
|
+
const SHALLOW_MERGE = new Set(['env', 'globals', 'options', 'rules', 'settings']);
|
|
21
|
+
|
|
22
|
+
/** One fragment as the plain oxlint config object oxlint itself reads. */
|
|
23
|
+
export function compile(fragment) {
|
|
24
|
+
const config = { rules: rulesOf(fragment.decisions) };
|
|
25
|
+
|
|
26
|
+
if (fragment.plugins.length > 0) {
|
|
27
|
+
config.plugins = [...fragment.plugins];
|
|
28
|
+
}
|
|
29
|
+
if (fragment.jsPlugins.length > 0) {
|
|
30
|
+
config.jsPlugins = [...fragment.jsPlugins];
|
|
31
|
+
}
|
|
32
|
+
if (fragment.ignorePatterns.length > 0) {
|
|
33
|
+
config.ignorePatterns = [...fragment.ignorePatterns];
|
|
34
|
+
}
|
|
35
|
+
if (fragment.overrides.length > 0) {
|
|
36
|
+
config.overrides = fragment.overrides.map((override) => ({
|
|
37
|
+
files: [...override.files],
|
|
38
|
+
rules: rulesOf(override.decisions),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
if (fragment.settings) {
|
|
42
|
+
config.settings = fragment.settings;
|
|
43
|
+
}
|
|
44
|
+
if (fragment.options) {
|
|
45
|
+
config.options = fragment.options;
|
|
46
|
+
}
|
|
47
|
+
if (fragment.env) {
|
|
48
|
+
config.env = fragment.env;
|
|
49
|
+
}
|
|
50
|
+
if (fragment.globals) {
|
|
51
|
+
config.globals = fragment.globals;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return config;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Deterministic merge of oxlint config objects, left to right:
|
|
59
|
+
* `jsPlugins` / `plugins` / `ignorePatterns` / `extends` concatenated and
|
|
60
|
+
* deduped, `rules` / `env` / `globals` / `options` / `settings` shallow-merged
|
|
61
|
+
* with last-wins per key, `overrides` concatenated, any other key taken from
|
|
62
|
+
* the last config that sets it.
|
|
63
|
+
*/
|
|
64
|
+
export function merge(...configs) {
|
|
65
|
+
const merged = {};
|
|
66
|
+
for (const config of configs) {
|
|
67
|
+
if (!config || typeof config !== 'object') {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const [key, value] of Object.entries(config)) {
|
|
71
|
+
if (value !== undefined) {
|
|
72
|
+
merged[key] = mergeKey(key, merged[key], value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return merged;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How one key merges: the three tables above, then last-wins. */
|
|
80
|
+
function mergeKey(key, previous, value) {
|
|
81
|
+
if (CONCAT_DEDUPE.has(key)) {
|
|
82
|
+
const combined = [...asArray(previous), ...asArray(value)];
|
|
83
|
+
return combined.filter((entry, index) => combined.indexOf(entry) === index);
|
|
84
|
+
}
|
|
85
|
+
if (CONCAT.has(key)) {
|
|
86
|
+
return [...asArray(previous), ...asArray(value)];
|
|
87
|
+
}
|
|
88
|
+
if (SHALLOW_MERGE.has(key)) {
|
|
89
|
+
return { ...previous, ...value };
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function asArray(value) {
|
|
95
|
+
if (value === undefined) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
return Array.isArray(value) ? value : [value];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A profile: fragments compiled, merged, and given the linter options. */
|
|
102
|
+
export function profile({ fragments, env, globals, ignorePatterns = [] }) {
|
|
103
|
+
return merge(...fragments.map((one) => compile(one)), {
|
|
104
|
+
env,
|
|
105
|
+
globals,
|
|
106
|
+
ignorePatterns,
|
|
107
|
+
options: { ...OPTIONS },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function rulesOf(decisions) {
|
|
112
|
+
const rules = {};
|
|
113
|
+
for (const [rule, decision] of Object.entries(decisions)) {
|
|
114
|
+
if (decision.level === 'off') {
|
|
115
|
+
rules[rule] = 'off';
|
|
116
|
+
} else if (decision.options === undefined) {
|
|
117
|
+
rules[rule] = 'error';
|
|
118
|
+
} else if (Array.isArray(decision.options)) {
|
|
119
|
+
rules[rule] = ['error', ...decision.options];
|
|
120
|
+
} else {
|
|
121
|
+
rules[rule] = ['error', decision.options];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return rules;
|
|
125
|
+
}
|