@brighterly/lib-fe-codestyle 1.0.0 → 1.0.1

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 (57) hide show
  1. package/README.md +234 -178
  2. package/dist/cli.d.ts +8 -0
  3. package/dist/cli.js +181 -0
  4. package/dist/config/define-config.d.ts +11 -0
  5. package/dist/config/define-config.js +33 -0
  6. package/dist/config/load-config.d.ts +15 -0
  7. package/dist/config/load-config.js +52 -0
  8. package/dist/config/schema.d.ts +58 -0
  9. package/dist/config/schema.js +139 -0
  10. package/dist/engine/docs.d.ts +14 -0
  11. package/dist/engine/docs.js +72 -0
  12. package/dist/engine/meta.d.ts +32 -0
  13. package/dist/engine/meta.js +47 -0
  14. package/dist/engine/migrate.d.ts +22 -0
  15. package/dist/engine/migrate.js +175 -0
  16. package/dist/engine/overrides.d.ts +36 -0
  17. package/dist/engine/overrides.js +92 -0
  18. package/dist/engine/registry.d.ts +35 -0
  19. package/dist/engine/registry.js +100 -0
  20. package/dist/engine/report.d.ts +23 -0
  21. package/dist/engine/report.js +87 -0
  22. package/dist/engine/run.d.ts +57 -0
  23. package/dist/engine/run.js +353 -0
  24. package/dist/engine/self-test.d.ts +33 -0
  25. package/dist/engine/self-test.js +141 -0
  26. package/dist/engine/types.d.ts +232 -0
  27. package/dist/{invariants → engine}/types.js +2 -1
  28. package/dist/{invariants → engine}/util.d.ts +31 -39
  29. package/dist/{invariants → engine}/util.js +59 -86
  30. package/dist/index.d.ts +16 -0
  31. package/dist/index.js +14 -0
  32. package/dist/rules/convention.d.ts +20 -0
  33. package/dist/{invariants/rules → rules}/convention.js +89 -15
  34. package/dist/rules/index.d.ts +28 -0
  35. package/dist/{invariants/rules → rules}/index.js +40 -32
  36. package/dist/{invariants/rules → rules}/regression.d.ts +1 -1
  37. package/dist/{invariants/rules → rules}/regression.js +26 -6
  38. package/dist/{invariants/rules → rules}/shared.d.ts +1 -1
  39. package/dist/validate/index.d.ts +1 -1
  40. package/dist/validate/index.js +1 -1
  41. package/package.json +8 -13
  42. package/dist/invariants/index.d.ts +0 -7
  43. package/dist/invariants/index.js +0 -5
  44. package/dist/invariants/rules/convention.d.ts +0 -18
  45. package/dist/invariants/rules/fixtures.d.ts +0 -17
  46. package/dist/invariants/rules/fixtures.js +0 -89
  47. package/dist/invariants/rules/index.d.ts +0 -55
  48. package/dist/invariants/rules/linter.d.ts +0 -10
  49. package/dist/invariants/rules/linter.js +0 -9
  50. package/dist/invariants/run.d.ts +0 -53
  51. package/dist/invariants/run.js +0 -746
  52. package/dist/invariants/self-test.d.ts +0 -46
  53. package/dist/invariants/self-test.js +0 -84
  54. package/dist/invariants/types.d.ts +0 -222
  55. /package/dist/{invariants → engine}/ast.d.ts +0 -0
  56. /package/dist/{invariants → engine}/ast.js +0 -0
  57. /package/dist/{invariants/rules → rules}/shared.js +0 -0
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `codestyle migrate` — the mechanical half of moving a repo from 0.5.x to 1.0:
3
+ * 1. rewrite `invariant-ignore:` → `codestyle-ignore:` and every renamed package id (`core/R1` →
4
+ * `no-bare-storage`) inside suppression comments and in `invariants/config.mts`;
5
+ * 2. print (or write) a `codestyle.config.ts` skeleton from the old config.
6
+ * Moving `invariants/` to `codestyle/` stays manual — the recipe in the README.
7
+ *
8
+ * The old config cannot be imported (the 0.5.x subpath it imports no longer exists), so it is read
9
+ * as text: each section's object literal is evaluated on its own, once, at migration time — it is
10
+ * the developer's own config. A section that will not evaluate is carried as a TODO, never dropped.
11
+ */
12
+ export interface MigrateResult {
13
+ /** Repo-relative files whose content changed (or would change on a dry run). */
14
+ rewritten: string[];
15
+ /** A complete `codestyle.config.ts`, or `''` when there is no old config. */
16
+ configSkeleton: string;
17
+ /** One line per thing the migration dropped or could not do. */
18
+ notes: string[];
19
+ }
20
+ export declare const migrate: (root: string, io: {
21
+ write: boolean;
22
+ }) => MigrateResult;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `codestyle migrate` — the mechanical half of moving a repo from 0.5.x to 1.0:
3
+ * 1. rewrite `invariant-ignore:` → `codestyle-ignore:` and every renamed package id (`core/R1` →
4
+ * `no-bare-storage`) inside suppression comments and in `invariants/config.mts`;
5
+ * 2. print (or write) a `codestyle.config.ts` skeleton from the old config.
6
+ * Moving `invariants/` to `codestyle/` stays manual — the recipe in the README.
7
+ *
8
+ * The old config cannot be imported (the 0.5.x subpath it imports no longer exists), so it is read
9
+ * as text: each section's object literal is evaluated on its own, once, at migration time — it is
10
+ * the developer's own config. A section that will not evaluate is carried as a TODO, never dropped.
11
+ */
12
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { resolve } from 'node:path';
14
+ import { ConfigError } from '../config/define-config.js';
15
+ import { DEFAULT_IGNORES } from '../config/schema.js';
16
+ import { renamedRules } from '../rules/index.js';
17
+ import { sliceObjectLiteral, walk } from './util.js';
18
+ const OLD_CONFIG = 'invariants/config.mts';
19
+ const OLD_CLI = 'invariants/cli.mts';
20
+ const NEW_CONFIG = 'codestyle.config.ts';
21
+ const REWRITE = /\.(?:ts|mts|js|mjs|vue|scss|md)$/;
22
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
23
+ /** Whole tokens only: `core/R1` must not touch `core/R10`. */
24
+ const RENAMED = Object.entries(renamedRules).map(([from, to]) => [new RegExp(`(?<![\\w/-])${escape(from)}(?![\\w-])`, 'g'), to]);
25
+ const renameIds = (text) => RENAMED.reduce((t, [re, to]) => t.replace(re, to), text);
26
+ const renamedName = (id) => (Object.hasOwn(renamedRules, id) ? renamedRules[id] : id);
27
+ /**
28
+ * Every `invariant-ignore:` marker becomes `codestyle-ignore:` — every one on a line, in comments
29
+ * and in strings alike (a fixture string holding an ignore comment is a proof that wants the 1.0
30
+ * spelling too). Renamed ids are rewritten from a line's first marker onwards, so every marker's
31
+ * ids are covered; in the old config, anywhere. Idempotent: a second pass changes nothing.
32
+ */
33
+ const rewrite = (file, source) => {
34
+ if (file === OLD_CONFIG)
35
+ return renameIds(source.replace(/invariant-ignore:/g, 'codestyle-ignore:'));
36
+ return source.split('\n').map(line => {
37
+ const at = line.search(/(?:invariant|codestyle)-ignore:/);
38
+ if (at === -1)
39
+ return line;
40
+ return line.slice(0, at) + renameIds(line.slice(at).replace(/invariant-ignore:/g, 'codestyle-ignore:'));
41
+ }).join('\n');
42
+ };
43
+ /** `export const <key>…= { … }` → its evaluated value, its raw text when evaluation fails, or null. */
44
+ const section = (source, key) => {
45
+ const m = new RegExp(`export\\s+const\\s+${key}\\b[^=]*=`).exec(source);
46
+ if (!m)
47
+ return null;
48
+ const literal = sliceObjectLiteral(source, m.index + m[0].length);
49
+ try {
50
+ return { ok: true, value: new Function(`return (${literal})`)() };
51
+ }
52
+ catch {
53
+ return { ok: false, raw: literal };
54
+ }
55
+ };
56
+ /** The `[ ... ]` literal starting at the first `[` after `fromIndex`, balanced on brackets. */
57
+ const sliceArrayLiteral = (source, fromIndex) => {
58
+ const start = source.indexOf('[', fromIndex);
59
+ if (start === -1)
60
+ return '';
61
+ let depth = 0;
62
+ for (let i = start; i < source.length; i++) {
63
+ if (source[i] === '[')
64
+ depth++;
65
+ else if (source[i] === ']' && --depth === 0)
66
+ return source.slice(start, i + 1);
67
+ }
68
+ return source.slice(start);
69
+ };
70
+ /** A string array assigned to one of `keys` in the 0.5.x composition root (`scanRoots: [...]`). */
71
+ const arrayIn = (source, keys) => {
72
+ const m = new RegExp(`\\b(?:${keys.join('|')})\\s*[:=]\\s*(?=\\[)`).exec(source);
73
+ if (!m)
74
+ return null;
75
+ const literal = sliceArrayLiteral(source, m.index + m[0].length);
76
+ try {
77
+ const value = new Function(`return (${literal})`)();
78
+ return Array.isArray(value) && value.every(v => typeof v === 'string') ? { ok: true, value } : { ok: false, raw: literal };
79
+ }
80
+ catch {
81
+ return { ok: false, raw: literal };
82
+ }
83
+ };
84
+ /** The old tree moves to `codestyle/`; its paths follow. */
85
+ const moved = (p) => p.replace(/^invariants(?=\/|$)/, 'codestyle');
86
+ const q = (s) => JSON.stringify(s);
87
+ const todo = (key, raw) => [` // TODO: could not evaluate ${key} — port by hand:`, ...raw.split('\n').map(l => ` // ${l}`)];
88
+ const skeletonOf = (source, cli, notes) => {
89
+ const lines = [
90
+ "import { defineConfig } from '@brighterly/lib-fe-codestyle'",
91
+ "// custom rules: import repo from './codestyle/index.ts' and spread it: ...repo",
92
+ '',
93
+ 'export default defineConfig({',
94
+ ];
95
+ // The scan roots and rule sources lived in the composition root, not the config. Dropping them
96
+ // would shrink the tree to the defaults and silence any rule scoped outside it.
97
+ if (cli !== null) {
98
+ for (const [key, names] of [['files', ['scanRoots', 'SCAN_ROOTS']], ['ruleSources', ['ruleSources', 'RULE_SOURCES']]]) {
99
+ const found = arrayIn(cli, [...names]);
100
+ if (found?.ok)
101
+ lines.push(` ${key}: [${found.value.map(p => q(moved(p))).join(', ')}],`);
102
+ else if (found)
103
+ lines.push(...todo(key, found.raw));
104
+ }
105
+ }
106
+ const settings = section(source, 'settings');
107
+ if (settings?.ok) {
108
+ const entries = Object.entries(settings.value);
109
+ if (entries.some(([k]) => k === 'selfTestFile'))
110
+ notes.push('migrate: dropped settings.selfTestFile — fixtures live on the rule objects; files under ruleSources are exempt from ignore parsing');
111
+ const kept = entries.filter(([k]) => k !== 'selfTestFile');
112
+ if (kept.length) {
113
+ lines.push(' settings: {', ...kept.map(([k, v]) => (typeof v === 'string'
114
+ ? ` ${q(k)}: ${q(v)},`
115
+ : ` // TODO: could not read settings[${q(k)}] — port by hand: ${JSON.stringify(v)}`)), ' },');
116
+ }
117
+ }
118
+ else if (settings)
119
+ lines.push(...todo('settings', settings.raw));
120
+ const levels = section(source, 'levels');
121
+ if (levels?.ok) {
122
+ const entries = Object.entries(levels.value);
123
+ const isLevel = (v) => typeof v === 'object' && v !== null && typeof v.level === 'string';
124
+ if (entries.length) {
125
+ lines.push(' rules: {', ...entries.map(([id, o]) => (isLevel(o)
126
+ ? ` ${q(renamedName(id))}: ${q(o.level)},${typeof o.reason === 'string' && o.reason ? ` // ${o.reason.replace(/\n/g, ' ')}` : ''}`
127
+ : ` // TODO: could not read levels[${q(id)}] — port by hand: ${JSON.stringify(o)}`)), ' },');
128
+ }
129
+ }
130
+ else if (levels)
131
+ lines.push(...todo('levels', levels.raw));
132
+ const allowlists = section(source, 'allowlists');
133
+ if (allowlists?.ok) {
134
+ const entries = Object.entries(allowlists.value)
135
+ .flatMap(([id, paths]) => Object.entries(paths).map(([path, reason]) => ` { files: [${q(path)}], rules: { ${q(renamedName(id))}: "off" }, reason: ${q(reason)} },`));
136
+ if (entries.length)
137
+ lines.push(' overrides: [', ...entries, ' ],');
138
+ }
139
+ else if (allowlists)
140
+ lines.push(...todo('allowlists', allowlists.raw));
141
+ if (/export\s+const\s+unmaintained\b/.test(source))
142
+ notes.push('migrate: dropped unmaintained — 1.0 has no exclusion list; prune a tree with `ignores`, or exempt it with an override and its reason');
143
+ if (/export\s+const\s+retired\b/.test(source))
144
+ notes.push('migrate: dropped retired — a dead name is reported by IG1; renamed package ids say where they went');
145
+ lines.push('})', '');
146
+ return lines.join('\n');
147
+ };
148
+ export const migrate = (root, io) => {
149
+ // Never overwrite a config, and refuse before any file is touched.
150
+ if (io.write && existsSync(resolve(root, NEW_CONFIG)))
151
+ throw new ConfigError(`${NEW_CONFIG} already exists — delete it or run without --write`);
152
+ const notes = [];
153
+ const oldPath = resolve(root, OLD_CONFIG);
154
+ const oldSource = existsSync(oldPath) ? readFileSync(oldPath, 'utf8') : null;
155
+ const cliPath = resolve(root, OLD_CLI);
156
+ const cliSource = existsSync(cliPath) ? readFileSync(cliPath, 'utf8') : null;
157
+ const configSkeleton = oldSource === null ? '' : skeletonOf(oldSource, cliSource, notes);
158
+ if (oldSource === null)
159
+ notes.push(`migrate: no ${OLD_CONFIG} — no config skeleton; write ${NEW_CONFIG} by hand`);
160
+ const ignored = new Set(DEFAULT_IGNORES);
161
+ const files = walk(root, '', rel => ignored.has(rel.slice(rel.lastIndexOf('/') + 1))).filter(f => REWRITE.test(f));
162
+ const rewritten = [];
163
+ for (const file of files) {
164
+ const source = readFileSync(resolve(root, file), 'utf8');
165
+ const next = rewrite(file, source);
166
+ if (next === source)
167
+ continue;
168
+ rewritten.push(file);
169
+ if (io.write)
170
+ writeFileSync(resolve(root, file), next);
171
+ }
172
+ if (io.write && configSkeleton)
173
+ writeFileSync(resolve(root, NEW_CONFIG), configSkeleton);
174
+ return { rewritten: rewritten.sort(), configSkeleton, notes };
175
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Per-place overrides: "never the rule is off — always this place, this reason".
3
+ *
4
+ * Compiled once per run from the config against the registry. The runner asks `resolve` for
5
+ * each finding's effective level; a rule receives its own entries through
6
+ * `RuleContext.overrides` (`forRule`). Everything wrong with an entry is a `problem` reported
7
+ * against the config file as IG1, never a silent no-op:
8
+ * a rules key naming no live rule (or an external one, or a renamed id),
9
+ * a glob that matches no file of the scanned tree,
10
+ * an `until` in the past (the entry then stops applying),
11
+ * a missing `until` on a rule that requires one (the entry does not apply to that rule).
12
+ *
13
+ * Pure: the scanned files, the config source and today's date all arrive through `io`.
14
+ */
15
+ import type { ResolvedConfig } from '../config/schema.js';
16
+ import type { Registry } from './registry.js';
17
+ import type { EffectiveOverride, Finding, Level, ReportedFinding, RuleName } from './types.js';
18
+ export interface CompiledOverrides {
19
+ /** The non-expired entries naming `name`, window-adjusted, in config order — what `RuleContext.overrides` receives. */
20
+ forRule: (name: RuleName) => EffectiveOverride[];
21
+ /** `min(level, lowest matching entry)` on `finding.subject ?? finding.file`; never raises. */
22
+ resolve: (finding: Finding, name: RuleName, level: Level) => Level;
23
+ /** Reported against the config file, rule `IG1`, level `error`. */
24
+ problems: ReportedFinding[];
25
+ }
26
+ export interface OverridesIo {
27
+ /** Repo-relative path the problems are reported against. */
28
+ configFile: string;
29
+ /** Its text, so a problem can point at the entry's line. */
30
+ configSource?: string;
31
+ /** The FULL scanned tree, repo-relative — never a --staged/--stdin subset. */
32
+ files: string[];
33
+ /** Today as `YYYY-MM-DD`. */
34
+ today: string;
35
+ }
36
+ export declare const compileOverrides: (config: ResolvedConfig, registry: Registry, io: OverridesIo) => CompiledOverrides;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Per-place overrides: "never the rule is off — always this place, this reason".
3
+ *
4
+ * Compiled once per run from the config against the registry. The runner asks `resolve` for
5
+ * each finding's effective level; a rule receives its own entries through
6
+ * `RuleContext.overrides` (`forRule`). Everything wrong with an entry is a `problem` reported
7
+ * against the config file as IG1, never a silent no-op:
8
+ * a rules key naming no live rule (or an external one, or a renamed id),
9
+ * a glob that matches no file of the scanned tree,
10
+ * an `until` in the past (the entry then stops applying),
11
+ * a missing `until` on a rule that requires one (the entry does not apply to that rule).
12
+ *
13
+ * Pure: the scanned files, the config source and today's date all arrive through `io`.
14
+ */
15
+ import { exemptionExpiry, globToRegExp } from './util.js';
16
+ const RANK = { off: 0, warn: 1, error: 2 };
17
+ const lower = (a, b) => (RANK[a] <= RANK[b] ? a : b);
18
+ const higher = (a, b) => (RANK[a] >= RANK[b] ? a : b);
19
+ /** `iso` minus `days` calendar days, as `YYYY-MM-DD`. */
20
+ const minusDays = (iso, days) => {
21
+ const [y, m, d] = iso.split('-').map(Number);
22
+ return new Date(Date.UTC(y, m - 1, d - days)).toISOString().slice(0, 10);
23
+ };
24
+ export const compileOverrides = (config, registry, io) => {
25
+ const problems = [];
26
+ const lines = io.configSource?.split('\n') ?? [];
27
+ /** First line containing the entry's reason, else the glob, else 1. */
28
+ const lineOf = (...needles) => {
29
+ for (const needle of needles) {
30
+ const i = needle === '' ? -1 : lines.findIndex(l => l.includes(needle));
31
+ if (i !== -1)
32
+ return i + 1;
33
+ }
34
+ return 1;
35
+ };
36
+ const report = (entryIndex, message, glob = '') => {
37
+ const line = lineOf(config.overrides[entryIndex]?.reason ?? '', glob);
38
+ problems.push({ file: io.configFile, line, message, rule: 'IG1', level: 'error' });
39
+ };
40
+ const compiled = [];
41
+ config.overrides.forEach((o, i) => {
42
+ const patterns = o.files.map(g => globToRegExp(g));
43
+ o.files.forEach((glob, j) => {
44
+ if (!io.files.some(f => patterns[j].test(f)))
45
+ report(i, `overrides[${i}].files[${j}] "${glob}" matches no file — the exemption is inert; drop it or fix the glob`, glob);
46
+ });
47
+ const until = exemptionExpiry(o.reason);
48
+ if (until !== null && io.today > until) {
49
+ report(i, `overrides[${i}] expired on ${until} — remove the entry or renew it`);
50
+ return;
51
+ }
52
+ const inWindow = until !== null && io.today >= minusDays(until, 7);
53
+ for (const [name, entryLevel] of Object.entries(o.rules)) {
54
+ const label = `overrides[${i}].rules["${name}"]`;
55
+ if (!registry.live.has(name)) {
56
+ const to = Object.hasOwn(registry.renamed, name) ? registry.renamed[name] : undefined;
57
+ report(i, `${label}: unknown rule${to ? ` — it was renamed to ${to}` : ''}`);
58
+ continue;
59
+ }
60
+ const reg = registry.byName.get(name);
61
+ if (reg && (reg.owner === 'eslint' || reg.owner === 'test')) {
62
+ report(i, `${label}: ${name} is owned by ${reg.owner === 'eslint' ? 'eslint' : 'a test'} — an override cannot change it`);
63
+ continue;
64
+ }
65
+ if (until === null && reg && 'exemptionsRequireUntil' in reg.rule && reg.rule.exemptionsRequireUntil) {
66
+ report(i, `overrides[${i}] names ${name}, which requires an "until YYYY-MM-DD" in its reason`);
67
+ continue;
68
+ }
69
+ // An entry at or above the rule's effective level changes nothing — an inert exemption.
70
+ if (reg && RANK[entryLevel] >= RANK[reg.level]) {
71
+ report(i, `overrides[${i}] never lowers ${name} (${entryLevel} ≥ ${reg.level}) — drop it`);
72
+ continue;
73
+ }
74
+ const effective = inWindow ? higher(entryLevel, 'warn') : entryLevel;
75
+ compiled.push({ name, entry: { files: [...o.files], level: effective, reason: o.reason, until }, patterns });
76
+ }
77
+ });
78
+ return {
79
+ forRule: name => compiled.filter(c => c.name === name).map(c => ({ ...c.entry, files: [...c.entry.files] })),
80
+ resolve: (finding, name, level) => {
81
+ const target = finding.subject ?? finding.file;
82
+ let out = level;
83
+ for (const c of compiled) {
84
+ if (c.name !== name || !c.patterns.some(p => p.test(target)))
85
+ continue;
86
+ out = lower(out, c.entry.level);
87
+ }
88
+ return out;
89
+ },
90
+ problems,
91
+ };
92
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The rule registry — the one place that knows which rules exist, who owns each, and at what
3
+ * effective level. Overrides, the runner and the CLI all read it; nothing else decides whether
4
+ * a name is live.
5
+ *
6
+ * effective level = `config.rules[name]` when present, else the rule's own `level`
7
+ * external rules = live names with no check here: level `off`, never selected
8
+ * meta rules = IG0/IG1/IG2: live (a suppression may name them), never configurable
9
+ */
10
+ import type { ResolvedConfig } from '../config/schema.js';
11
+ import type { AnyRule, Level, Rule, RuleName } from './types.js';
12
+ export interface RegisteredRule {
13
+ rule: AnyRule;
14
+ /** Effective level: the `rules` entry when present, else the rule's own `level`. */
15
+ level: Level;
16
+ owner: 'package' | 'custom' | 'eslint' | 'test';
17
+ tags: string[];
18
+ /** The config's `rules` entry for this rule, when there is one: the base level AND a ceiling over a finding's own level. */
19
+ configured?: Level;
20
+ }
21
+ export interface Registry {
22
+ /** Package first (in its given order), then custom in declaration order. */
23
+ all: RegisteredRule[];
24
+ byName: Map<RuleName, RegisteredRule>;
25
+ /** Checkable (non-external) rules whose effective level is not `off`, in `all` order. */
26
+ select: (opts: {
27
+ tags?: string[];
28
+ only?: RuleName[];
29
+ }) => RegisteredRule[];
30
+ /** Every addressable name, external and IG0/IG1/IG2 included. */
31
+ live: Set<RuleName>;
32
+ /** Old id → new name, for IG1 messages. */
33
+ renamed: Record<string, RuleName>;
34
+ }
35
+ export declare const buildRegistry: (config: ResolvedConfig, pkg?: Rule[]) => Registry;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The rule registry — the one place that knows which rules exist, who owns each, and at what
3
+ * effective level. Overrides, the runner and the CLI all read it; nothing else decides whether
4
+ * a name is live.
5
+ *
6
+ * effective level = `config.rules[name]` when present, else the rule's own `level`
7
+ * external rules = live names with no check here: level `off`, never selected
8
+ * meta rules = IG0/IG1/IG2: live (a suppression may name them), never configurable
9
+ */
10
+ import { ConfigError } from '../config/define-config.js';
11
+ import { packageRules, renamedRules } from '../rules/index.js';
12
+ import { isExternal } from './types.js';
13
+ const META = ['IG0', 'IG1', 'IG2'];
14
+ const META_NAMES = new Set(META);
15
+ /** Own keys only: `constructor` or `toString` must never read as a renamed rule. */
16
+ const renamedTo = (name) => (Object.hasOwn(renamedRules, name) ? renamedRules[name] : undefined);
17
+ const ownerWording = (owner) => (owner === 'eslint' ? 'eslint' : 'a test');
18
+ const unknownName = (label, name) => {
19
+ const to = renamedTo(name);
20
+ return new ConfigError(`${label}: unknown rule${to ? ` — it was renamed to ${to}` : ''}`);
21
+ };
22
+ export const buildRegistry = (config, pkg = packageRules) => {
23
+ const levels = config.rules;
24
+ /** Own keys only: a rule named `toString` must not read `Object.prototype.toString` as its level. */
25
+ const configuredFor = (name) => (Object.hasOwn(levels, name) ? levels[name] : undefined);
26
+ const levelFor = (name, fallback) => configuredFor(name) ?? fallback;
27
+ const byName = new Map();
28
+ const all = [];
29
+ const add = (entry) => {
30
+ all.push(entry);
31
+ byName.set(entry.rule.name, entry);
32
+ };
33
+ for (const rule of pkg) {
34
+ if (byName.has(rule.name))
35
+ throw new ConfigError(`${rule.name} is declared twice in the package rules`);
36
+ add({ rule, level: levelFor(rule.name, rule.level), owner: 'package', tags: [...(rule.tags ?? [])], configured: configuredFor(rule.name) });
37
+ }
38
+ for (const rule of config.custom) {
39
+ const name = rule.name;
40
+ const taken = byName.get(name);
41
+ if (taken) {
42
+ throw new ConfigError(taken.owner === 'package'
43
+ ? `${name} is both a package rule and a custom rule — rename the custom one`
44
+ : `${name} is declared twice in custom — rule names must be unique`);
45
+ }
46
+ const to = renamedTo(name);
47
+ if (to)
48
+ throw new ConfigError(`custom rule "${name}" uses a retired package id (renamed to ${to}) — pick another name`);
49
+ if (META_NAMES.has(name))
50
+ throw new ConfigError(`custom rule "${name}" uses an engine rule name — pick another name`);
51
+ if (isExternal(rule))
52
+ add({ rule, level: 'off', owner: rule.owner, tags: [...(rule.tags ?? [])] });
53
+ else
54
+ add({ rule, level: levelFor(name, rule.level), owner: 'custom', tags: [...(rule.tags ?? [])], configured: configuredFor(name) });
55
+ }
56
+ for (const name of Object.keys(levels)) {
57
+ const label = `rules["${name}"]`;
58
+ if (META_NAMES.has(name))
59
+ throw new ConfigError(`${label}: ${name} is an engine rule and cannot be configured`);
60
+ const entry = byName.get(name);
61
+ if (!entry)
62
+ throw unknownName(label, name);
63
+ if (entry.owner === 'eslint')
64
+ throw new ConfigError(`${label}: ${name} is owned by ${ownerWording(entry.owner)} — set its level in the ESLint config`);
65
+ if (entry.owner === 'test')
66
+ throw new ConfigError(`${label}: ${name} is owned by ${ownerWording(entry.owner)} — it has no level here; the test passes or fails`);
67
+ }
68
+ const checkable = (r) => r.owner === 'package' || r.owner === 'custom';
69
+ const select = ({ tags, only }) => {
70
+ let picked = all.filter(r => checkable(r) && r.level !== 'off');
71
+ // A tag no rule carries selects nothing and would print PASS over an empty gate — a typo, not a filter.
72
+ for (const t of tags ?? []) {
73
+ if (!all.some(r => r.tags.includes(t)))
74
+ throw new ConfigError(`no rule carries tag "${t}" — codestyle list prints the tags`);
75
+ }
76
+ // An empty filter is no filter: the CLI passes [] or undefined for "everything".
77
+ if (only?.length) {
78
+ for (const name of only) {
79
+ const entry = byName.get(name);
80
+ if (!entry)
81
+ throw unknownName(`--only "${name}"`, name);
82
+ if (!checkable(entry))
83
+ throw new ConfigError(`--only "${name}": owned by ${ownerWording(entry.owner)}, so there is nothing to run here`);
84
+ if (entry.level === 'off')
85
+ throw new ConfigError(`${name} is off in the config — raise its level or drop --only`);
86
+ }
87
+ picked = picked.filter(r => only.includes(r.rule.name));
88
+ }
89
+ if (tags?.length)
90
+ picked = picked.filter(r => r.tags.some(t => tags.includes(t)));
91
+ return picked;
92
+ };
93
+ return {
94
+ all,
95
+ byName,
96
+ select,
97
+ live: new Set([...byName.keys(), ...META]),
98
+ renamed: renamedRules,
99
+ };
100
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The human reporter: findings grouped by file, each rule's why/fix once, then the PASS/FAIL
3
+ * line. Layout is deliberate — where, which rule, what happened, what to do — so a long run
4
+ * stays scannable. The JSON shape lives with `check()`; this file only prints for people.
5
+ */
6
+ import type { MetaRuleId } from './meta.js';
7
+ import type { Registry } from './registry.js';
8
+ import type { ReportedFinding } from './types.js';
9
+ export interface ReportOptions {
10
+ /** ANSI colour; the caller turns it off under `--no-color`, NO_COLOR or a non-TTY stdout. */
11
+ color: boolean;
12
+ /** How many files were checked, for the summary line. */
13
+ scanned: number;
14
+ /** The repo's own rules failed their proofs: the run fails even with no error finding. */
15
+ selfTestFailed?: boolean;
16
+ }
17
+ type MetaInfo = Record<MetaRuleId, {
18
+ title: string;
19
+ why: string;
20
+ fix: string;
21
+ }>;
22
+ export declare const printReport: (findings: ReportedFinding[], registry: Registry, meta: MetaInfo, opts: ReportOptions) => void;
23
+ export {};
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The human reporter: findings grouped by file, each rule's why/fix once, then the PASS/FAIL
3
+ * line. Layout is deliberate — where, which rule, what happened, what to do — so a long run
4
+ * stays scannable. The JSON shape lives with `check()`; this file only prints for people.
5
+ */
6
+ const WIDTH = 78;
7
+ /** Wrap prose to WIDTH with a hanging indent, so long `why` text stays readable. */
8
+ const wrap = (text, indent) => {
9
+ const width = WIDTH - indent;
10
+ const out = [];
11
+ let line = '';
12
+ for (const word of text.split(/\s+/)) {
13
+ if (line && `${line} ${word}`.length > width) {
14
+ out.push(line);
15
+ line = word;
16
+ }
17
+ else {
18
+ line = line ? `${line} ${word}` : word;
19
+ }
20
+ }
21
+ if (line)
22
+ out.push(line);
23
+ return out;
24
+ };
25
+ export const printReport = (findings, registry, meta, opts) => {
26
+ const paint = (code) => (text) => (opts.color ? `\u001B[${code}m${text}\u001B[0m` : text);
27
+ const c = { red: paint('31'), redBold: paint('1;31'), yellow: paint('33'), yellowBold: paint('1;33'), green: paint('32'), cyan: paint('36'), bold: paint('1'), dim: paint('2') };
28
+ const infoOf = (name) => {
29
+ if (Object.hasOwn(meta, name))
30
+ return meta[name];
31
+ const rule = registry.byName.get(name)?.rule;
32
+ return rule ? { title: rule.title, why: rule.why, fix: 'fix' in rule ? rule.fix : undefined } : undefined;
33
+ };
34
+ const ordered = [...findings].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
35
+ const byFile = new Map();
36
+ for (const f of ordered)
37
+ byFile.set(f.file, [...(byFile.get(f.file) ?? []), f]);
38
+ console.log();
39
+ for (const [file, list] of byFile) {
40
+ console.log(c.cyan(file));
41
+ for (const f of list) {
42
+ const label = f.level === 'error' ? c.redBold('error') : c.yellowBold('warn ');
43
+ console.log(` ${String(f.line).padStart(4)} ${label} ${c.bold(f.rule)} ${f.message}`);
44
+ }
45
+ console.log();
46
+ }
47
+ // The rationale once per rule, not once per finding.
48
+ const fired = [...new Set(ordered.map(f => f.rule))];
49
+ if (fired.length) {
50
+ console.log(c.dim('─'.repeat(WIDTH)));
51
+ for (const name of fired) {
52
+ const info = infoOf(name);
53
+ console.log(`${c.bold(name)} ${info?.title ?? 'unattributed finding'}`);
54
+ if (info?.why)
55
+ for (const [i, l] of wrap(info.why, 8).entries())
56
+ console.log(` ${i === 0 ? c.dim('why ') : ' '}${c.dim(l)}`);
57
+ if (info?.fix)
58
+ for (const [i, l] of wrap(info.fix, 8).entries())
59
+ console.log(` ${i === 0 ? c.dim('fix ') : ' '}${c.green(l)}`);
60
+ console.log();
61
+ }
62
+ }
63
+ const errors = findings.filter(f => f.level === 'error').length;
64
+ const warnings = findings.filter(f => f.level === 'warn').length;
65
+ const totals = [
66
+ errors ? c.red(`${errors} error${errors === 1 ? '' : 's'}`) : null,
67
+ warnings ? c.yellow(`${warnings} warning${warnings === 1 ? '' : 's'}`) : null,
68
+ ].filter(Boolean).join(c.dim(' · '));
69
+ const scanned = c.dim(` — ${opts.scanned} file(s) checked`);
70
+ console.log(c.dim('─'.repeat(WIDTH)));
71
+ const selfTest = opts.selfTestFailed ? c.red('the self-test failed (see stderr)') : null;
72
+ if (errors === 0 && !selfTest)
73
+ console.log(`${c.green('PASS')} ${totals || 'no findings'}${scanned}`);
74
+ else {
75
+ console.log(`${c.red('FAIL')} ${[selfTest, totals].filter(Boolean).join(c.dim(' · '))}${scanned}`);
76
+ // Never advertise the escape hatch when every error is noIgnore or a meta rule.
77
+ const suppressible = findings.some(f => {
78
+ if (f.level !== 'error')
79
+ return false;
80
+ const rule = registry.byName.get(f.rule)?.rule;
81
+ return rule !== undefined && !('noIgnore' in rule && rule.noIgnore);
82
+ });
83
+ if (suppressible)
84
+ console.log(c.dim(' suppress a reviewed line with // codestyle-ignore: <rule> — <reason>'));
85
+ }
86
+ console.log();
87
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `check()` — the runner on the 1.0 surface: load the config, build the registry, compile the
3
+ * overrides, pick the files and the rules, run them, apply suppressions and overrides to every
4
+ * finding, add the meta findings, self-test the repo's own rules, report.
5
+ *
6
+ * Exit codes:
7
+ * 0 — clean, or warnings only
8
+ * 1 — an error-level finding, or a failed self-test
9
+ * 2 — the tool itself failed (bad flags, no or bad config, a crashing rule)
10
+ */
11
+ import type { RegisteredRule } from './registry.js';
12
+ import type { Level } from './types.js';
13
+ export interface CheckOptions {
14
+ /** Repo root. Passed, never derived — import.meta.url here resolves into node_modules. */
15
+ root: string;
16
+ argv: string[];
17
+ /** `YYYY-MM-DD`; defaults to today (UTC). Overrides and rules read it for `until` dates. */
18
+ today?: string;
19
+ /**
20
+ * Defaults to setting `process.exitCode` — never `process.exit()`, which would cut stdout still
21
+ * draining into a pipe. Engine code always `return exit(...)`. Injectable so tests can collect the code.
22
+ */
23
+ exit?: (code: number) => never;
24
+ /** The `--stdin` content. Defaults to reading fd 0. */
25
+ stdin?: () => string;
26
+ }
27
+ export interface CheckJson {
28
+ schemaVersion: 1;
29
+ ok: boolean;
30
+ scanned: number;
31
+ errors: number;
32
+ warnings: number;
33
+ rules: {
34
+ name: string;
35
+ level: Level;
36
+ title: string;
37
+ tags: string[];
38
+ owner: RegisteredRule['owner'];
39
+ }[];
40
+ findings: {
41
+ rule: string;
42
+ level: Level;
43
+ file: string;
44
+ line: number;
45
+ message: string;
46
+ }[];
47
+ skipped?: {
48
+ file: string;
49
+ reason: string;
50
+ };
51
+ /** Present whenever the self-test ran. */
52
+ selfTest?: {
53
+ ok: boolean;
54
+ failures: string[];
55
+ };
56
+ }
57
+ export declare const check: (options: CheckOptions) => Promise<void>;