@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
@@ -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
+ });
@@ -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
+ });
@@ -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
+ );
@@ -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
+ }