@brighterly/lib-fe-codestyle 1.0.0 → 1.0.2

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/{invariants → engine}/ast.d.ts +8 -0
  11. package/dist/{invariants → engine}/ast.js +51 -0
  12. package/dist/engine/docs.d.ts +14 -0
  13. package/dist/engine/docs.js +72 -0
  14. package/dist/engine/meta.d.ts +32 -0
  15. package/dist/engine/meta.js +47 -0
  16. package/dist/engine/migrate.d.ts +22 -0
  17. package/dist/engine/migrate.js +175 -0
  18. package/dist/engine/overrides.d.ts +36 -0
  19. package/dist/engine/overrides.js +92 -0
  20. package/dist/engine/registry.d.ts +35 -0
  21. package/dist/engine/registry.js +100 -0
  22. package/dist/engine/report.d.ts +23 -0
  23. package/dist/engine/report.js +87 -0
  24. package/dist/engine/run.d.ts +57 -0
  25. package/dist/engine/run.js +353 -0
  26. package/dist/engine/self-test.d.ts +33 -0
  27. package/dist/engine/self-test.js +141 -0
  28. package/dist/engine/types.d.ts +232 -0
  29. package/dist/{invariants → engine}/types.js +2 -1
  30. package/dist/{invariants → engine}/util.d.ts +31 -39
  31. package/dist/{invariants → engine}/util.js +59 -86
  32. package/dist/index.d.ts +16 -0
  33. package/dist/index.js +14 -0
  34. package/dist/rules/convention.d.ts +20 -0
  35. package/dist/{invariants/rules → rules}/convention.js +103 -16
  36. package/dist/rules/index.d.ts +28 -0
  37. package/dist/{invariants/rules → rules}/index.js +40 -32
  38. package/dist/{invariants/rules → rules}/regression.d.ts +1 -1
  39. package/dist/{invariants/rules → rules}/regression.js +26 -6
  40. package/dist/{invariants/rules → rules}/shared.d.ts +1 -1
  41. package/dist/validate/index.d.ts +1 -1
  42. package/dist/validate/index.js +1 -1
  43. package/package.json +8 -13
  44. package/dist/invariants/index.d.ts +0 -7
  45. package/dist/invariants/index.js +0 -5
  46. package/dist/invariants/rules/convention.d.ts +0 -18
  47. package/dist/invariants/rules/fixtures.d.ts +0 -17
  48. package/dist/invariants/rules/fixtures.js +0 -89
  49. package/dist/invariants/rules/index.d.ts +0 -55
  50. package/dist/invariants/rules/linter.d.ts +0 -10
  51. package/dist/invariants/rules/linter.js +0 -9
  52. package/dist/invariants/run.d.ts +0 -53
  53. package/dist/invariants/run.js +0 -746
  54. package/dist/invariants/self-test.d.ts +0 -46
  55. package/dist/invariants/self-test.js +0 -84
  56. package/dist/invariants/types.d.ts +0 -222
  57. /package/dist/{invariants/rules → rules}/shared.js +0 -0
package/dist/cli.js ADDED
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `codestyle` — a thin argv switch over the engine. No engine logic lives here.
4
+ *
5
+ * Exit codes: 0 clean · 1 a finding at error, a failed proof, a stale doc or a failed step ·
6
+ * 2 the tool itself failed (usage, config).
7
+ */
8
+ import { spawnSync } from 'node:child_process';
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { dirname, resolve } from 'node:path';
11
+ import { ConfigError } from './config/define-config.js';
12
+ import { findConfig, loadConfig } from './config/load-config.js';
13
+ import { renderDocs } from './engine/docs.js';
14
+ import { migrate } from './engine/migrate.js';
15
+ import { buildRegistry } from './engine/registry.js';
16
+ import { check } from './engine/run.js';
17
+ import { runSelfTest } from './engine/self-test.js';
18
+ import { createReader, toRelative, todayUtc } from './engine/util.js';
19
+ import { packageRules } from './rules/index.js';
20
+ import { validate } from './validate/index.js';
21
+ const USAGE = `usage: codestyle <command> [--root=<path>] [flags]
22
+
23
+ commands:
24
+ check run the rules — --staged | --stdin --file=<path>, --json, --only=<a,b>, --rule=<name>,
25
+ --tags=<a,b>, --no-self-test, --no-color
26
+ list print every rule: name · level · owner · tags
27
+ self-test prove this repo's custom rules (--package: the package's own rules)
28
+ docs render every config.docs entry (--check: fail when an output is stale)
29
+ validate run the config.validate steps
30
+ precommit check --staged, then the config.precommit steps
31
+ migrate rewrite 0.5.x ignores and ids, print a codestyle.config.ts skeleton (--write: apply)
32
+ `;
33
+ /** Run a step in the repo root, its output streamed through. */
34
+ const shell = (cmd, cwd) => spawnSync(cmd, { shell: true, stdio: 'inherit', cwd }).status === 0;
35
+ /**
36
+ * A collecting exit: the engine calls it and returns, so one command can chain several. Safe
37
+ * because engine code always `return exit(...)` — nothing runs after an exit call.
38
+ */
39
+ const collect = () => {
40
+ let last = 0;
41
+ return { exit: ((c) => { last = c; }), code: () => last };
42
+ };
43
+ const main = async (argv) => {
44
+ const rootArg = argv.find(a => a.startsWith('--root='));
45
+ const root = rootArg ? resolve(rootArg.slice('--root='.length)) : process.cwd();
46
+ const [command, ...rest] = argv.filter(a => a !== rootArg);
47
+ const usage = (message) => {
48
+ if (message)
49
+ console.error(`codestyle: ${message}`);
50
+ console.error(USAGE);
51
+ return 2;
52
+ };
53
+ if (command === '--help' || command === '-h') {
54
+ console.log(USAGE);
55
+ return 0;
56
+ }
57
+ if (rest.includes('--help')) {
58
+ if (!['check', 'list', 'self-test', 'docs', 'validate', 'precommit', 'migrate'].includes(command ?? ''))
59
+ return usage(`unknown command "${command}"`);
60
+ console.log(USAGE);
61
+ return 0;
62
+ }
63
+ /** Commands other than check take only the flags they name. */
64
+ const onlyFlags = (allowed) => rest.find(a => !allowed.includes(a)) ?? null;
65
+ const load = async () => {
66
+ const config = await loadConfig(root);
67
+ return { config, registry: buildRegistry(config), configFile: toRelative(root, findConfig(root)) };
68
+ };
69
+ switch (command) {
70
+ case 'check': {
71
+ const run = collect();
72
+ await check({ root, argv: rest, exit: run.exit });
73
+ return run.code();
74
+ }
75
+ case 'list': {
76
+ const bad = onlyFlags([]);
77
+ if (bad)
78
+ return usage(`list takes no flag "${bad}"`);
79
+ const { registry } = await load();
80
+ for (const r of registry.all)
81
+ console.log(`${r.rule.name} · ${r.level} · ${r.owner} · ${r.tags.length ? r.tags.join(',') : '-'}`);
82
+ return 0;
83
+ }
84
+ case 'self-test': {
85
+ const bad = onlyFlags(['--package']);
86
+ if (bad)
87
+ return usage(`self-test takes no flag "${bad}"`);
88
+ let rules = packageRules;
89
+ let settings = {};
90
+ if (!rest.includes('--package')) {
91
+ const { config, registry } = await load();
92
+ rules = registry.all.filter(r => r.owner === 'custom').map(r => r.rule);
93
+ settings = config.settings;
94
+ }
95
+ const { ok, failures, checked } = await runSelfTest({ rules, settings, today: todayUtc() });
96
+ if (ok)
97
+ console.log(`self-test: ${checked.length} rule(s) proven`);
98
+ for (const f of failures)
99
+ console.error(`self-test: ${f}`);
100
+ return ok ? 0 : 1;
101
+ }
102
+ case 'docs': {
103
+ const bad = onlyFlags(['--check']);
104
+ if (bad)
105
+ return usage(`docs takes no flag "${bad}"`);
106
+ const { config, registry, configFile } = await load();
107
+ if (config.docs.length === 0)
108
+ throw new ConfigError(`docs: no entries in ${configFile}`);
109
+ const read = createReader(root);
110
+ let stale = 0;
111
+ for (const entry of config.docs) {
112
+ const rendered = renderDocs(entry, registry, read);
113
+ const out = resolve(root, entry.output);
114
+ if (rest.includes('--check')) {
115
+ // A missing output is as stale as an edited one.
116
+ const current = existsSync(out) ? readFileSync(out, 'utf8') : null;
117
+ if (current !== rendered) {
118
+ stale++;
119
+ console.error(`codestyle docs --check: ${entry.output} is stale — run codestyle docs`);
120
+ }
121
+ continue;
122
+ }
123
+ mkdirSync(dirname(out), { recursive: true });
124
+ writeFileSync(out, rendered);
125
+ console.log(`wrote ${entry.output}`);
126
+ }
127
+ return stale ? 1 : 0;
128
+ }
129
+ case 'validate': {
130
+ const bad = onlyFlags([]);
131
+ if (bad)
132
+ return usage(`validate takes no flag "${bad}"`);
133
+ const { config, configFile } = await load();
134
+ if (config.validate.length === 0)
135
+ throw new ConfigError(`validate: declare validate steps in ${configFile}`);
136
+ const run = collect();
137
+ await validate(config.validate, { exit: run.exit, exec: cmd => ({ ok: shell(cmd, root) }) });
138
+ return run.code();
139
+ }
140
+ case 'precommit': {
141
+ const { config } = await load();
142
+ const run = collect();
143
+ await check({ root, argv: ['--staged', ...rest.filter(a => a !== '--staged')], exit: run.exit });
144
+ if (run.code() !== 0)
145
+ return run.code();
146
+ if (config.precommit.length === 0)
147
+ return 0;
148
+ const steps = collect();
149
+ await validate(config.precommit, { exit: steps.exit, exec: cmd => ({ ok: shell(cmd, root) }) });
150
+ return steps.code();
151
+ }
152
+ case 'migrate': {
153
+ const bad = onlyFlags(['--write']);
154
+ if (bad)
155
+ return usage(`migrate takes no flag "${bad}"`);
156
+ const write = rest.includes('--write');
157
+ const { rewritten, configSkeleton, notes } = migrate(root, { write });
158
+ for (const n of notes)
159
+ console.log(n);
160
+ for (const f of rewritten)
161
+ console.log(`${write ? 'rewrote' : 'would rewrite'} ${f}`);
162
+ if (configSkeleton && write)
163
+ console.log('wrote codestyle.config.ts');
164
+ else if (configSkeleton)
165
+ console.log(`\n${configSkeleton}`);
166
+ return 0;
167
+ }
168
+ default:
169
+ return usage(command ? `unknown command "${command}"` : 'no command given');
170
+ }
171
+ };
172
+ // Never `process.exit()` after output: a pipe drains asynchronously (macOS cuts a 64 KiB+ --json at
173
+ // the first buffer). Setting `exitCode` lets the process end on its own once stdout has drained.
174
+ main(process.argv.slice(2)).then(code => { process.exitCode = code; }, (e) => {
175
+ // Any failure of the tool itself is exit 2 — never the 1 that means "an error-level finding".
176
+ // A ConfigError is the author's to fix and gets one line; anything else keeps its stack.
177
+ if (!(e instanceof ConfigError) && e instanceof Error && e.stack)
178
+ console.error(e.stack);
179
+ console.error(`codestyle: ${e instanceof Error ? e.message : String(e)}`);
180
+ process.exitCode = 2;
181
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `defineConfig` — the one call a consumer's `codestyle.config.ts` makes. It validates the
3
+ * shape, fills the defaults and brands the result so the loader can tell a real config from a
4
+ * stray default export. Every failure is a thrown `ConfigError` naming the key path.
5
+ */
6
+ import type { CodestyleConfig, ResolvedConfig } from './schema.js';
7
+ /** A config the tool cannot run with. The CLI turns it into exit 2. */
8
+ export declare class ConfigError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ export declare const defineConfig: (config: CodestyleConfig) => ResolvedConfig;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `defineConfig` — the one call a consumer's `codestyle.config.ts` makes. It validates the
3
+ * shape, fills the defaults and brands the result so the loader can tell a real config from a
4
+ * stray default export. Every failure is a thrown `ConfigError` naming the key path.
5
+ */
6
+ import { configProblem, DEFAULT_FILES, DEFAULT_IGNORES, DEFAULT_RULE_SOURCES } from './schema.js';
7
+ /** A config the tool cannot run with. The CLI turns it into exit 2. */
8
+ export class ConfigError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'ConfigError';
12
+ }
13
+ }
14
+ export const defineConfig = (config) => {
15
+ const problem = configProblem(config);
16
+ if (problem !== null)
17
+ throw new ConfigError(`codestyle config: ${problem}`);
18
+ const resolved = {
19
+ settings: { ...config.settings },
20
+ rules: { ...config.rules },
21
+ custom: [...(config.custom ?? [])],
22
+ overrides: [...(config.overrides ?? [])],
23
+ files: [...(config.files ?? DEFAULT_FILES)],
24
+ ignores: [...(config.ignores ?? DEFAULT_IGNORES)],
25
+ ruleSources: [...(config.ruleSources ?? DEFAULT_RULE_SOURCES)],
26
+ docs: [...(config.docs ?? [])],
27
+ validate: [...(config.validate ?? [])],
28
+ precommit: [...(config.precommit ?? [])],
29
+ };
30
+ // The loader reads this mark to tell a resolved config from a stray default export.
31
+ Object.defineProperty(resolved, Symbol.for('codestyle.config'), { value: true, enumerable: false });
32
+ return resolved;
33
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Find `codestyle.config.*` at the repo root, import it, and return what `defineConfig`
3
+ * produced. The brand `defineConfig` sets is the only proof a default export is a config —
4
+ * a plain object that merely looks like one never had its shape validated.
5
+ */
6
+ import type { ResolvedConfig } from './schema.js';
7
+ /** Looked up in this order; the first that exists wins. */
8
+ export declare const CONFIG_FILES: string[];
9
+ /** Absolute path of the first `CONFIG_FILES` entry under `root` that is a FILE, or null. */
10
+ export declare const findConfig: (root: string) => string | null;
11
+ /**
12
+ * `importer` is injectable so the Node-floor mapping can be tested on a Node that strips
13
+ * types; production always uses the native `import()`.
14
+ */
15
+ export declare const loadConfig: (root: string, importer?: (url: string) => Promise<unknown>) => Promise<ResolvedConfig>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Find `codestyle.config.*` at the repo root, import it, and return what `defineConfig`
3
+ * produced. The brand `defineConfig` sets is the only proof a default export is a config —
4
+ * a plain object that merely looks like one never had its shape validated.
5
+ */
6
+ import { statSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { ConfigError } from './define-config.js';
10
+ /** Looked up in this order; the first that exists wins. */
11
+ export const CONFIG_FILES = ['codestyle.config.ts', 'codestyle.config.mts', 'codestyle.config.js', 'codestyle.config.mjs'];
12
+ /** Absolute path of the first `CONFIG_FILES` entry under `root` that is a FILE, or null. */
13
+ export const findConfig = (root) => {
14
+ for (const name of CONFIG_FILES) {
15
+ const file = resolve(root, name);
16
+ if (statSync(file, { throwIfNoEntry: false })?.isFile())
17
+ return file;
18
+ }
19
+ return null;
20
+ };
21
+ const nativeImport = (url) => import(url);
22
+ const isBranded = (value) => typeof value === 'object' && value !== null && value[Symbol.for('codestyle.config')] === true;
23
+ /**
24
+ * `importer` is injectable so the Node-floor mapping can be tested on a Node that strips
25
+ * types; production always uses the native `import()`.
26
+ */
27
+ export const loadConfig = async (root, importer = nativeImport) => {
28
+ const file = findConfig(root);
29
+ if (file === null)
30
+ throw new ConfigError(`no codestyle.config.* in ${root}`);
31
+ let mod;
32
+ try {
33
+ mod = await importer(pathToFileURL(file).href);
34
+ }
35
+ catch (e) {
36
+ // Only the "this Node cannot load .ts" failure is ours to explain; anything else — a syntax
37
+ // error in the config, a missing import inside it — keeps its real stack.
38
+ const code = e?.code;
39
+ if (code === 'ERR_UNKNOWN_FILE_EXTENSION') {
40
+ throw new ConfigError(`${file} could not be loaded: Node 22.18+ is required to load a TypeScript config (native type stripping) — upgrade Node, or write the config as codestyle.config.mjs`);
41
+ }
42
+ // Type stripping only erases types: an enum, a namespace or a parameter property has runtime meaning.
43
+ if (code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
44
+ throw new ConfigError(`${file} is not erasable-syntax TypeScript (${e.message}) — codestyle.config.ts may not use enums, namespaces or parameter properties`);
45
+ }
46
+ throw e;
47
+ }
48
+ const config = mod?.default;
49
+ if (!isBranded(config))
50
+ throw new ConfigError(`${file} must default-export defineConfig(...)`);
51
+ return config;
52
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The shape of `codestyle.config.ts` — types, defaults and the shape validators.
3
+ *
4
+ * Shape only: whether a rule NAME exists needs the registry (package rules + `custom`), and
5
+ * whether an override may set a level needs the rule's default — both are checked later.
6
+ * The validators return the first problem as text naming its key path, so the caller decides
7
+ * how to fail; they never print.
8
+ */
9
+ import type { AnyRule, Level, RuleName, SharedRuleSettings } from '../engine/types.js';
10
+ /** "This place, this reason": a level for some rules on some files. `reason` is mandatory. */
11
+ export interface OverrideEntry {
12
+ files: string[];
13
+ rules: Record<RuleName, Level>;
14
+ reason: string;
15
+ }
16
+ /** One agent-facing doc rendered by `codestyle docs`: the rules carrying `tags` into `template`, written to `output`. */
17
+ export interface DocsEntry {
18
+ template: string;
19
+ output: string;
20
+ tags?: string[];
21
+ }
22
+ /** One named shell step for `codestyle validate` / the pre-commit chain. */
23
+ export interface Step {
24
+ name: string;
25
+ run: string;
26
+ }
27
+ export interface CodestyleConfig {
28
+ /** App landmarks the package rules read through `RuleContext.settings`. */
29
+ settings?: SharedRuleSettings & Record<string, string>;
30
+ /** Level per rule name, package and custom alike. A rule not named runs at its own default. */
31
+ rules?: Record<RuleName, Level>;
32
+ /** This repo's own rules (each carrying a proof) and check-less external declarations. */
33
+ custom?: AnyRule[];
34
+ overrides?: OverrideEntry[];
35
+ /** Scan roots. Default `DEFAULT_FILES`. */
36
+ files?: string[];
37
+ /** Directory names skipped while walking. Default `DEFAULT_IGNORES`. */
38
+ ignores?: string[];
39
+ /** Paths whose change triggers the self-test in `--staged`, exempt from meta-rule parsing. Default `DEFAULT_RULE_SOURCES`. */
40
+ ruleSources?: string[];
41
+ docs?: DocsEntry[];
42
+ validate?: Step[];
43
+ precommit?: Step[];
44
+ }
45
+ export interface ResolvedConfig extends Required<Omit<CodestyleConfig, 'docs' | 'validate' | 'precommit'>> {
46
+ docs: DocsEntry[];
47
+ validate: Step[];
48
+ precommit: Step[];
49
+ }
50
+ export declare const DEFAULT_FILES: readonly string[];
51
+ export declare const DEFAULT_IGNORES: readonly string[];
52
+ export declare const DEFAULT_RULE_SOURCES: readonly string[];
53
+ /** Every top-level key a config may carry — anything else is a typo that would be a silent no-op. */
54
+ export declare const CONFIG_KEYS: readonly (keyof CodestyleConfig)[];
55
+ type Problem = string | null;
56
+ /** The first shape problem in a config, or null. Keys left undefined take their defaults. */
57
+ export declare const configProblem: (config: unknown) => Problem;
58
+ export {};
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The shape of `codestyle.config.ts` — types, defaults and the shape validators.
3
+ *
4
+ * Shape only: whether a rule NAME exists needs the registry (package rules + `custom`), and
5
+ * whether an override may set a level needs the rule's default — both are checked later.
6
+ * The validators return the first problem as text naming its key path, so the caller decides
7
+ * how to fail; they never print.
8
+ */
9
+ import { malformedUntil } from '../engine/util.js';
10
+ export const DEFAULT_FILES = ['app', 'config', 'tests', 'codestyle'];
11
+ export const DEFAULT_IGNORES = ['node_modules', '.nuxt', '.output', 'dist', 'coverage'];
12
+ export const DEFAULT_RULE_SOURCES = ['codestyle/'];
13
+ const LEVELS = ['off', 'warn', 'error'];
14
+ /** Every top-level key a config may carry — anything else is a typo that would be a silent no-op. */
15
+ export const CONFIG_KEYS = [
16
+ 'settings', 'rules', 'custom', 'overrides', 'files', 'ignores', 'ruleSources', 'docs', 'validate', 'precommit',
17
+ ];
18
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
19
+ const show = (v) => (typeof v === 'string' ? `"${v}"` : String(JSON.stringify(v)));
20
+ const first = (problems) => {
21
+ for (const p of problems)
22
+ if (p)
23
+ return p;
24
+ return null;
25
+ };
26
+ const stringArray = (v, path, { nonEmpty = false } = {}) => {
27
+ if (!Array.isArray(v))
28
+ return `${path} must be an array of strings, got ${show(v)}`;
29
+ if (nonEmpty && v.length === 0)
30
+ return `${path} must name at least one entry`;
31
+ const bad = v.findIndex(x => typeof x !== 'string' || x === '');
32
+ return bad === -1 ? null : `${path}[${bad}] must be a non-empty string, got ${show(v[bad])}`;
33
+ };
34
+ const levelMap = (v, path) => {
35
+ if (!isObject(v))
36
+ return `${path} must be an object of rule name → level, got ${show(v)}`;
37
+ for (const [name, level] of Object.entries(v)) {
38
+ if (typeof level !== 'string' || !LEVELS.includes(level)) {
39
+ return `${path}["${name}"] must be one of off | warn | error, got ${show(level)}`;
40
+ }
41
+ }
42
+ return null;
43
+ };
44
+ const reason = (v, path) => {
45
+ if (typeof v !== 'string' || v.trim() === '')
46
+ return `${path} is required — an exemption without a reason cannot be reviewed`;
47
+ const tok = malformedUntil(v);
48
+ return tok === null ? null : `${path} carries "until ${tok}", which is not a YYYY-MM-DD date`;
49
+ };
50
+ const override = (v, path) => {
51
+ if (!isObject(v))
52
+ return `${path} must be an object { files, rules, reason }, got ${show(v)}`;
53
+ return first([
54
+ stringArray(v.files, `${path}.files`, { nonEmpty: true }),
55
+ levelMap(v.rules, `${path}.rules`),
56
+ reason(v.reason, `${path}.reason`),
57
+ ]);
58
+ };
59
+ const entries = (v, path, entry) => {
60
+ if (!Array.isArray(v))
61
+ return `${path} must be an array, got ${show(v)}`;
62
+ return first(v.map((e, i) => entry(e, `${path}[${i}]`)));
63
+ };
64
+ const docsEntry = (v, path) => {
65
+ if (!isObject(v))
66
+ return `${path} must be an object { template, output, tags? }, got ${show(v)}`;
67
+ if (typeof v.template !== 'string' || v.template === '')
68
+ return `${path}.template must be a non-empty string`;
69
+ if (typeof v.output !== 'string' || v.output === '')
70
+ return `${path}.output must be a non-empty string`;
71
+ return v.tags === undefined ? null : stringArray(v.tags, `${path}.tags`);
72
+ };
73
+ const step = (v, path) => {
74
+ if (!isObject(v))
75
+ return `${path} must be an object { name, run }, got ${show(v)}`;
76
+ if (typeof v.name !== 'string' || v.name === '')
77
+ return `${path}.name must be a non-empty string`;
78
+ return typeof v.run === 'string' && v.run !== '' ? null : `${path}.run must be a non-empty string`;
79
+ };
80
+ const nonEmptyString = (v, path) => (typeof v === 'string' && v !== '' ? null : `${path} must be a non-empty string, got ${show(v)}`);
81
+ /**
82
+ * A custom entry is either an external declaration (`owner` set) or a checkable rule (no
83
+ * `owner`). The two shapes must not blur: a stray `owner` would turn a real rule into a
84
+ * declaration that never runs, and a misspelled `level` would count as neither error nor warn.
85
+ */
86
+ const customEntry = (v, path) => {
87
+ if (!isObject(v))
88
+ return `${path} must be a rule object, got ${show(v)}`;
89
+ if (Object.hasOwn(v, 'owner')) {
90
+ return first([
91
+ nonEmptyString(v.name, `${path}.name`),
92
+ v.owner === 'eslint' || v.owner === 'test' ? null : `${path}.owner must be 'eslint' or 'test' (a rule this package runs has no owner), got ${show(v.owner)}`,
93
+ nonEmptyString(v.ref, `${path}.ref`),
94
+ nonEmptyString(v.title, `${path}.title`),
95
+ nonEmptyString(v.why, `${path}.why`),
96
+ v.tags === undefined ? null : stringArray(v.tags, `${path}.tags`),
97
+ ]);
98
+ }
99
+ return first([
100
+ nonEmptyString(v.name, `${path}.name`),
101
+ typeof v.level === 'string' && LEVELS.includes(v.level) ? null : `${path}.level must be one of off | warn | error, got ${show(v.level)}`,
102
+ stringArray(v.scope, `${path}.scope`, { nonEmpty: true }),
103
+ typeof v.check === 'function' ? null : `${path}.check must be a function`,
104
+ nonEmptyString(v.title, `${path}.title`),
105
+ nonEmptyString(v.why, `${path}.why`),
106
+ nonEmptyString(v.fix, `${path}.fix`),
107
+ v.tags === undefined ? null : stringArray(v.tags, `${path}.tags`),
108
+ ]);
109
+ };
110
+ const settings = (v) => {
111
+ if (!isObject(v))
112
+ return `settings must be an object of key → string, got ${show(v)}`;
113
+ for (const [k, val] of Object.entries(v))
114
+ if (typeof val !== 'string')
115
+ return `settings.${k} must be a string, got ${show(val)}`;
116
+ return null;
117
+ };
118
+ /** The first shape problem in a config, or null. Keys left undefined take their defaults. */
119
+ export const configProblem = (config) => {
120
+ if (!isObject(config))
121
+ return `the config must be an object, got ${show(config)}`;
122
+ const unknown = Object.keys(config).find(k => !CONFIG_KEYS.includes(k));
123
+ if (unknown !== undefined)
124
+ return `unknown top-level key "${unknown}" — known keys: ${CONFIG_KEYS.join(', ')}`;
125
+ const c = config;
126
+ const has = (k) => c[k] !== undefined;
127
+ return first([
128
+ has('settings') ? settings(c.settings) : null,
129
+ has('rules') ? levelMap(c.rules, 'rules') : null,
130
+ has('custom') ? entries(c.custom, 'custom', customEntry) : null,
131
+ has('overrides') ? entries(c.overrides, 'overrides', override) : null,
132
+ has('files') ? stringArray(c.files, 'files') : null,
133
+ has('ignores') ? stringArray(c.ignores, 'ignores') : null,
134
+ has('ruleSources') ? stringArray(c.ruleSources, 'ruleSources') : null,
135
+ has('docs') ? entries(c.docs, 'docs', docsEntry) : null,
136
+ has('validate') ? entries(c.validate, 'validate', step) : null,
137
+ has('precommit') ? entries(c.precommit, 'precommit', step) : null,
138
+ ]);
139
+ };
@@ -22,6 +22,14 @@ export declare const findJsonParseCalls: (file: string, source: string) => Promi
22
22
  line: number;
23
23
  guarded: boolean;
24
24
  }[]>;
25
+ /**
26
+ * The 1-based lines of a file that run inside a component lifetime: the body of a `<script setup>`
27
+ * block, and the body of any function named `use…` (a composable) or `setup` (Options API /
28
+ * `defineComponent`), including everything nested inside them. A store action, a plugin, a plain
29
+ * helper or module-level code is outside: VueUse's automatic cleanup has no scope to attach to there,
30
+ * so a rule that recommends a VueUse composable must stay silent on those lines.
31
+ */
32
+ export declare const componentScopeLines: (file: string, source: string) => Promise<Set<number>>;
25
33
  /**
26
34
  * Every static import that survives compilation: default, named, namespace and bare side-effect
27
35
  * imports plus `export … from` re-exports, in .ts files and in every script block of a .vue file.
@@ -83,6 +83,57 @@ export const findJsonParseCalls = async (file, source) => {
83
83
  }
84
84
  return out;
85
85
  };
86
+ /**
87
+ * The 1-based lines of a file that run inside a component lifetime: the body of a `<script setup>`
88
+ * block, and the body of any function named `use…` (a composable) or `setup` (Options API /
89
+ * `defineComponent`), including everything nested inside them. A store action, a plugin, a plain
90
+ * helper or module-level code is outside: VueUse's automatic cleanup has no scope to attach to there,
91
+ * so a rule that recommends a VueUse composable must stay silent on those lines.
92
+ */
93
+ export const componentScopeLines = async (file, source) => {
94
+ const t = await ts();
95
+ const lines = new Set();
96
+ const add = (from, to) => { for (let l = from; l <= to; l++)
97
+ lines.add(l); };
98
+ if (file.endsWith('.vue')) {
99
+ const re = /<script\b([^>]*)>([\s\S]*?)<\/script>/g;
100
+ let m;
101
+ while ((m = re.exec(source)) !== null) {
102
+ if (!/\bsetup\b/.test(m[1] ?? '') || m[2] === undefined)
103
+ continue;
104
+ const bodyStart = m.index + m[0].length - m[2].length - '</script>'.length;
105
+ const first = source.slice(0, bodyStart).split('\n').length;
106
+ add(first, first + m[2].split('\n').length - 1);
107
+ }
108
+ }
109
+ const SCOPED = /^(?:use[A-Z0-9]|setup$)/;
110
+ const nameOf = (node) => {
111
+ if ((t.isFunctionDeclaration(node) || t.isMethodDeclaration(node) || t.isFunctionExpression(node)) && node.name && t.isIdentifier(node.name))
112
+ return node.name.text;
113
+ const parent = node.parent;
114
+ if (!parent)
115
+ return null;
116
+ if (t.isVariableDeclaration(parent) && t.isIdentifier(parent.name))
117
+ return parent.name.text;
118
+ if (t.isPropertyAssignment(parent) && (t.isIdentifier(parent.name) || t.isStringLiteral(parent.name)))
119
+ return parent.name.text;
120
+ return null;
121
+ };
122
+ for (const script of scriptsOf(file, source)) {
123
+ const sf = t.createSourceFile(file, script.code, t.ScriptTarget.Latest, true);
124
+ const lineOf = (pos) => sf.getLineAndCharacterOfPosition(pos).line + 1 + script.lineOffset;
125
+ const visit = (node) => {
126
+ const isFn = t.isFunctionDeclaration(node) || t.isFunctionExpression(node) || t.isArrowFunction(node) || t.isMethodDeclaration(node);
127
+ if (isFn && SCOPED.test(nameOf(node) ?? '')) {
128
+ add(lineOf(node.getStart(sf)), lineOf(node.getEnd()));
129
+ return;
130
+ }
131
+ t.forEachChild(node, visit);
132
+ };
133
+ visit(sf);
134
+ }
135
+ return lines;
136
+ };
86
137
  /**
87
138
  * Every static import that survives compilation: default, named, namespace and bare side-effect
88
139
  * imports plus `export … from` re-exports, in .ts files and in every script block of a .vue file.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `codestyle docs` rendering: an agent-facing rules file (a consumer's `.claude/rules/e2e.md`)
3
+ * generated from a template plus the registry, so the text an agent reads can never drift from
4
+ * the rules that actually run. Pure — the template arrives through `read`, the output is
5
+ * returned; the CLI writes it (and compares it under `--check`).
6
+ */
7
+ import type { DocsEntry } from '../config/schema.js';
8
+ import type { Registry } from './registry.js';
9
+ /**
10
+ * Render one `config.docs` entry. Rules carrying any of `entry.tags` are selected (every rule
11
+ * when `tags` is absent or empty), in registry order; checkable rules not set `off` go into the rules table,
12
+ * `eslint`/`test`-owned declarations into a second table that is left out when there are none.
13
+ */
14
+ export declare const renderDocs: (entry: DocsEntry, registry: Registry, read: (p: string) => string | null) => string;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `codestyle docs` rendering: an agent-facing rules file (a consumer's `.claude/rules/e2e.md`)
3
+ * generated from a template plus the registry, so the text an agent reads can never drift from
4
+ * the rules that actually run. Pure — the template arrives through `read`, the output is
5
+ * returned; the CLI writes it (and compares it under `--check`).
6
+ */
7
+ import { ConfigError } from '../config/define-config.js';
8
+ import { isExternal } from './types.js';
9
+ const MARKER = '<!-- rules -->';
10
+ /**
11
+ * Split a leading YAML front-matter block (`---` on line 1, closed by the next line that is exactly
12
+ * `---`) off the template. Readers of an agent rules file only recognise front matter on line 1, so it
13
+ * must stay first; without a closing line there is no front matter.
14
+ */
15
+ const splitFrontMatter = (template) => {
16
+ const none = { front: '', rest: template };
17
+ const lines = template.split('\n');
18
+ if (lines[0] !== '---')
19
+ return none;
20
+ const close = lines.indexOf('---', 1);
21
+ if (close === -1)
22
+ return none;
23
+ const front = lines.slice(0, close + 1).join('\n') + '\n';
24
+ return { front, rest: template.slice(front.length) };
25
+ };
26
+ /**
27
+ * Pipes break the table; a bare `<key>` is eaten by any HTML renderer of the file; a newline
28
+ * (multi-line template-literal prose) ends the row — so whitespace runs collapse to one space.
29
+ */
30
+ const cell = (s) => s.replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|').replace(/</g, '&lt;');
31
+ const rulesTable = (entries) => [
32
+ '## Rules',
33
+ '',
34
+ '| Rule | Level | Title | Why | Fix |',
35
+ '|---|---|---|---|---|',
36
+ ...entries.map(({ rule, level }) => {
37
+ const fix = isExternal(rule) ? '' : rule.fix;
38
+ return `| **${rule.name}** | ${level} | ${cell(rule.title)} | ${cell(rule.why)} | ${cell(fix)} |`;
39
+ }),
40
+ ];
41
+ const externalTable = (entries) => [
42
+ '## Owned elsewhere',
43
+ '',
44
+ '| Rule | Owner | Ref | Title | Why |',
45
+ '|---|---|---|---|---|',
46
+ ...entries.map(({ rule, owner }) => `| **${rule.name}** | ${owner} | \`${cell(isExternal(rule) ? rule.ref : '')}\` | ${cell(rule.title)} | ${cell(rule.why)} |`),
47
+ ];
48
+ /**
49
+ * Render one `config.docs` entry. Rules carrying any of `entry.tags` are selected (every rule
50
+ * when `tags` is absent or empty), in registry order; checkable rules not set `off` go into the rules table,
51
+ * `eslint`/`test`-owned declarations into a second table that is left out when there are none.
52
+ */
53
+ export const renderDocs = (entry, registry, read) => {
54
+ const template = read(entry.template);
55
+ if (template === null)
56
+ throw new ConfigError(`docs: template ${entry.template} not found`);
57
+ const markers = template.split(MARKER).length - 1;
58
+ if (markers === 0)
59
+ throw new ConfigError(`docs: ${entry.template} has no ${MARKER} marker`);
60
+ if (markers > 1)
61
+ throw new ConfigError(`docs: ${entry.template} has more than one ${MARKER} marker`);
62
+ const tags = entry.tags ?? [];
63
+ const selected = tags.length ? registry.all.filter(r => r.tags.some(t => tags.includes(t))) : registry.all;
64
+ // An agent rules file lists what must be followed: a rule switched off is not.
65
+ const checkable = selected.filter(r => !isExternal(r.rule) && r.level !== 'off');
66
+ const external = selected.filter(r => isExternal(r.rule));
67
+ const tables = [...rulesTable(checkable), ...(external.length ? ['', ...externalTable(external)] : [])].join('\n');
68
+ const header = `<!-- generated by \`codestyle docs\` from ${entry.template} — edit the template, not this file -->`;
69
+ const { front, rest } = splitFrontMatter(template);
70
+ const body = rest.replace(MARKER, () => tables);
71
+ return `${front}${header}\n${body}`.replace(/\n*$/, '\n');
72
+ };