@sheplu/editorconfig 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,7 @@ A small CLI to manage a **consistent `.editorconfig`** across your projects.
6
6
  - ✅ Check if your existing file matches the target setup
7
7
  - ✅ Confirm before overwriting an existing `.editorconfig` (or pass `--overwrite` to skip the prompt)
8
8
  - ✅ Override the built-in template with a team-shared file via `--template` (local path or `https://` URL)
9
+ - ✅ Validate every `.editorconfig` in a monorepo at once with `--recursive`
9
10
 
10
11
  ## Why?
11
12
 
@@ -113,6 +114,48 @@ This command will:
113
114
  - `0` if everything matches
114
115
  - `1` if differences are found
115
116
 
117
+ ### `--recursive` (monorepo check)
118
+
119
+ ```bash
120
+ npx @sheplu/editorconfig --mode=check --recursive
121
+ ```
122
+
123
+ Walks the current directory (or the directory passed via `--path`) and validates **every** `.editorconfig` it finds. Useful for monorepos where a top-level file sets defaults and sub-packages add narrower overrides.
124
+
125
+ How files are classified:
126
+
127
+ - The shortest-path `.editorconfig` in each subtree is treated as the **root** and validated with the same rules as `--mode=check` (must declare `root = true`, must have `[*]`, sections must match the canonical template).
128
+ - Deeper `.editorconfig` files are treated as **children**. Children must NOT declare `root = true`, may omit `[*]`, and may legitimately override individual keys.
129
+ - Sibling subtrees with no shared ancestor `.editorconfig` are each validated as their own root.
130
+
131
+ Cross-file checks emitted on top of per-file validation:
132
+
133
+ - `child-root` — a child declared `root = true` (**fail**).
134
+ - `redundant` — a child redeclares a parent key/value verbatim (**warn**).
135
+ - `contradiction` — a child reuses the same header as its root (e.g. both `[*]`) with a different value (**warn**). Different globs like child `[*.js]` overriding root `[*]` are silent — they are legitimate per spec.
136
+
137
+ Skipped during the walk: `node_modules`, `.git`, `dist`, `build`, `coverage`, `.next`, `.cache`, and symlinked directories.
138
+
139
+ Flag interactions in recursive mode:
140
+
141
+ - `--path` becomes the start directory for the walk (default: cwd).
142
+ - `--languages` is enforced on the root file only — children may add or omit language sections freely.
143
+ - `--template` overrides apply to the root section comparison.
144
+ - `--strict` fails on unknown headers in any file.
145
+
146
+ Exit codes: `0` on pass (warnings allowed), `1` if any file fails or any cross-file failure (e.g. child-root) is detected.
147
+
148
+ ```bash
149
+ # Validate everything under cwd
150
+ npx @sheplu/editorconfig --mode=check --recursive
151
+
152
+ # Validate only one subtree
153
+ npx @sheplu/editorconfig --mode=check --recursive --path=./packages/web
154
+
155
+ # CI: enforce js/md sections on the root, anywhere else may add what they need
156
+ npx @sheplu/editorconfig --mode=check --recursive --languages=js,md
157
+ ```
158
+
116
159
  ### `--template` (custom team template)
117
160
 
118
161
  Both `write` and `check` accept a `--template` (or `-t`) flag pointing at a custom `.editorconfig`-syntax file. Sections in that file override the built-in defaults; languages it doesn't redefine still come from the built-ins. This lets a team host a single source of truth and reference it from every repo.
@@ -178,6 +221,7 @@ Run them with:
178
221
  npm test # everything
179
222
  npm run test:unit # unit only — runs in ~50ms
180
223
  npm run test:integration # integration only — spawns the CLI
224
+ npm run test:coverage. # coverage with 95% threshold
181
225
  npm run lint # oxlint
182
226
  ```
183
227
 
package/index.js CHANGED
@@ -1,267 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, writeFileSync } from "node:fs";
4
- import { createInterface } from 'node:readline';
5
- import { parseArgs } from 'node:util';
6
- import {
7
- ALIASES,
8
- AVAILABLE_LANGUAGES,
9
- composeEditorConfig,
10
- EMPTY_OVERRIDES,
11
- } from './src/templates/index.js';
12
- import { loadCustomTemplate } from './src/templates/custom-template.js';
13
- import { compareEditorConfig, NO_LANGUAGE_FILTER, runCheck } from './src/check.js';
14
- import { logger } from './src/utils/logger.js';
3
+ import { compareEditorConfig } from './src/check.js';
4
+ import { parseCliArgs, printHelp } from './src/cli/options.js';
5
+ import { dispatchValues } from './src/cli/dispatch.js';
6
+ import { createEditorConfig } from './src/cli/write-flow.js';
15
7
 
16
- export { compareEditorConfig };
17
-
18
- export function createEditorConfig(path = '.editorconfig', languages = [], overrides = EMPTY_OVERRIDES) {
19
- writeFileSync(path, composeEditorConfig(languages, overrides), 'utf8');
20
- };
21
-
22
- export const options = {
23
- mode: {
24
- type: 'string',
25
- short: 'm',
26
- },
27
- path: {
28
- type: 'string',
29
- short: 'p',
30
- },
31
- languages: {
32
- type: 'string',
33
- short: 'l',
34
- },
35
- help: {
36
- type: 'boolean',
37
- short: 'h',
38
- },
39
- overwrite: {
40
- type: 'boolean',
41
- short: 'o',
42
- },
43
- strict: {
44
- type: 'boolean',
45
- short: 's',
46
- },
47
- template: {
48
- type: 'string',
49
- short: 't',
50
- },
51
- };
52
-
53
- export const NOT_PROVIDED = Symbol('languages-not-provided');
54
-
55
- export function parseLanguages(raw) {
56
- if (typeof raw !== 'string') {
57
- return NOT_PROVIDED;
58
- }
59
- return raw
60
- .split(',')
61
- .map((token) => token.trim().toLowerCase())
62
- .filter((token) => token.length > 0);
63
- }
64
-
65
- function formatAliasList() {
66
- const grouped = new Map();
67
- for (const [alias, target] of Object.entries(ALIASES)) {
68
- const list = grouped.get(target) ?? [];
69
- list.push(alias);
70
- grouped.set(target, list);
71
- }
72
- return [...grouped.entries()]
73
- .map(([target, aliases]) => `${aliases.join(', ')} -> ${target}`)
74
- .join('; ');
75
- }
76
-
77
- export function printHelp() {
78
- logger.log(`Usage: editorconfig --mode=<command> [--path=<path>] [--languages=<list>] [--template=<path|url>]
79
-
80
- Commands:
81
- write Create a .editorconfig file with the selected language sections
82
- check Validate per-section against the canonical templates
83
-
84
- Options:
85
- -m, --mode Command to run (write | check)
86
- -p, --path Path to the .editorconfig file (default: .editorconfig)
87
- -l, --languages Comma-separated language sections (write: which to emit; check: required set)
88
- -o, --overwrite Overwrite an existing .editorconfig without confirmation
89
- -s, --strict Treat unknown section headers as failures (check only)
90
- -t, --template Path or https URL to a custom .editorconfig-syntax file whose sections override the built-in ones
91
- -h, --help Show this help message
92
-
93
- Languages: ${AVAILABLE_LANGUAGES.join(', ')}
94
- Aliases: ${formatAliasList()}
95
-
96
- Examples:
97
- editorconfig --mode=write --languages=js,md
98
- editorconfig --mode=write --languages= # base only
99
- editorconfig --mode=write # interactive in TTY, base only otherwise
100
- editorconfig --mode=check # validate sections present in the file
101
- editorconfig --mode=check --languages=js,md # require exactly base + js + md
102
- editorconfig --mode=check --strict # fail on any unknown section header
103
- editorconfig --mode=write --template=./team.editorconfig
104
- editorconfig --mode=check --template=./team.editorconfig
105
- editorconfig --mode=check --template=https://team.example.com/.editorconfig`);
106
- };
107
-
108
- function resolvePromptAnswer(answer) {
109
- const tokens = answer
110
- .split(',')
111
- .map((token) => token.trim().toLowerCase())
112
- .filter((token) => token.length > 0);
113
- return tokens.map((token) => {
114
- const asNumber = Number.parseInt(token, 10);
115
- if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= AVAILABLE_LANGUAGES.length) {
116
- return AVAILABLE_LANGUAGES[asNumber - 1];
117
- }
118
- return token;
119
- });
120
- }
121
-
122
- function promptLanguages() {
123
- return new Promise((resolve) => {
124
- const rl = createInterface({ input: process.stdin, output: process.stdout });
125
- const numbered = AVAILABLE_LANGUAGES
126
- .map((name, index) => ` ${index + 1}. ${name}`)
127
- .join('\n');
128
- logger.log(`Available language sections:\n${numbered}`);
129
- rl.question('Languages? [comma-separated names or indices, blank=base only] ', (answer) => {
130
- rl.close();
131
- resolve(resolvePromptAnswer(answer));
132
- });
133
- });
134
- }
135
-
136
- function resolveLanguages(parsedLanguages) {
137
- if (parsedLanguages !== NOT_PROVIDED) {
138
- return Promise.resolve(parsedLanguages);
139
- }
140
- if (process.stdin.isTTY) {
141
- return promptLanguages();
142
- }
143
- return Promise.resolve([]);
144
- }
145
-
146
- function confirmOverwrite(path) {
147
- return new Promise((resolve) => {
148
- const rl = createInterface({ input: process.stdin, output: process.stdout });
149
- rl.question(`\`${path}\` already exists. Overwrite? [y/N] `, (answer) => {
150
- rl.close();
151
- resolve(/^y(es)?$/iu.test(answer.trim()));
152
- });
153
- });
154
- }
155
-
156
- async function handleExistingTarget(path, languages, overrides) {
157
- if (!process.stdin.isTTY) {
158
- logger.error(`\`${path}\` already exists. Use --overwrite to replace it.`);
159
- process.exitCode = 1;
160
- return;
161
- }
162
- const confirmed = await confirmOverwrite(path);
163
- if (confirmed) {
164
- createEditorConfig(path, languages, overrides);
165
- }
166
- else {
167
- logger.log(`Skipped: \`${path}\` was not modified.`);
168
- }
169
- }
170
-
171
- async function runWrite({ path, overwrite, parsedLanguages, overrides }) {
172
- const languages = await resolveLanguages(parsedLanguages);
173
- if (!existsSync(path) || overwrite) {
174
- createEditorConfig(path, languages, overrides);
175
- return;
176
- }
177
- await handleExistingTarget(path, languages, overrides);
178
- }
179
-
180
- function dispatchCheck({ path, languages, strict, overrides }) {
181
- let filter = languages;
182
- if (filter === NOT_PROVIDED) {
183
- filter = NO_LANGUAGE_FILTER;
184
- }
185
- try {
186
- runCheck({ path, parsedLanguages: filter, strict, overrides });
187
- }
188
- catch (error) {
189
- logger.error(error.message);
190
- process.exitCode = 1;
191
- }
192
- }
193
-
194
- async function dispatchWrite({ path, overwrite, languages, overrides }) {
195
- try {
196
- await runWrite({ path, overwrite, parsedLanguages: languages, overrides });
197
- }
198
- catch (error) {
199
- logger.error(error.message);
200
- process.exitCode = 1;
201
- }
202
- }
203
-
204
- async function runCommand({ mode, path, overwrite, languages, strict, overrides }) {
205
- if (mode === 'write') {
206
- await dispatchWrite({ path, overwrite, languages, overrides });
207
- return;
208
- }
209
- if (mode === 'check') {
210
- dispatchCheck({ path, languages, strict, overrides });
211
- return;
212
- }
213
- logger.error('invalid command');
214
- process.exitCode = 1;
215
- }
216
-
217
- function formatCliError(error) {
218
- if (error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
219
- return `${error.message.split('.')[0]}. See --help.`;
220
- }
221
- return error.message;
222
- }
223
-
224
- function parseCliArgs(args) {
225
- try {
226
- return parseArgs({ args, options });
227
- }
228
- catch (error) {
229
- logger.error(formatCliError(error));
230
- process.exitCode = 1;
231
- return false;
232
- }
233
- }
234
-
235
- const OVERRIDES_FAILED = Symbol('overrides-failed');
236
-
237
- async function resolveOverrides(templateValue) {
238
- if (typeof templateValue !== 'string') {
239
- return EMPTY_OVERRIDES;
240
- }
241
- try {
242
- return await loadCustomTemplate(templateValue);
243
- }
244
- catch (error) {
245
- logger.error(error.message);
246
- process.exitCode = 1;
247
- return OVERRIDES_FAILED;
248
- }
249
- }
250
-
251
- async function dispatchValues(values) {
252
- const overrides = await resolveOverrides(values.template);
253
- if (overrides === OVERRIDES_FAILED) {
254
- return;
255
- }
256
- await runCommand({
257
- mode: values.mode,
258
- path: values.path || '.editorconfig',
259
- overwrite: values.overwrite,
260
- languages: parseLanguages(values.languages),
261
- strict: values.strict,
262
- overrides,
263
- });
264
- }
8
+ export { compareEditorConfig, createEditorConfig };
265
9
 
266
10
  async function main() {
267
11
  const parsed = parseCliArgs(process.argv.slice(2));
@@ -276,11 +20,5 @@ async function main() {
276
20
  };
277
21
 
278
22
  if (process.argv[1] === import.meta.filename) {
279
- try {
280
- await main();
281
- }
282
- catch (error) {
283
- logger.error(error.message);
284
- process.exitCode = 1;
285
- }
23
+ await main();
286
24
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sheplu/editorconfig",
3
- "version": "0.10.0",
4
- "description": "",
3
+ "version": "0.11.0",
4
+ "description": "CLI to generate and validate a consistent .editorconfig across your projects",
5
5
  "main": "index.js",
6
6
  "files": [
7
7
  "index.js",
@@ -10,6 +10,7 @@
10
10
  "scripts": {
11
11
  "lint": "oxlint",
12
12
  "test": "node --test",
13
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-branches=95 --test-coverage-functions=95",
13
14
  "test:unit": "node --test 'test/unit/*.test.js'",
14
15
  "test:integration": "node --test 'test/integration/*.test.js'"
15
16
  },
@@ -40,6 +41,6 @@
40
41
  "homepage": "https://github.com/sheplu/editorconfig#readme",
41
42
  "devDependencies": {
42
43
  "node-pty": "1.0.0",
43
- "oxlint": "^1.65.0"
44
+ "oxlint": "^1.67.0"
44
45
  }
45
46
  }
package/src/cascade.js ADDED
@@ -0,0 +1,119 @@
1
+ import { dirname, sep } from 'node:path';
2
+
3
+ function depthOf(path) {
4
+ return path.split(sep).length;
5
+ }
6
+
7
+ function isAncestorDir(ancestor, descendant) {
8
+ return descendant.startsWith(`${ancestor}${sep}`);
9
+ }
10
+
11
+ function comparePaths(left, right) {
12
+ const dl = depthOf(left);
13
+ const dr = depthOf(right);
14
+ if (dl !== dr) {
15
+ return dl - dr;
16
+ }
17
+ return left.localeCompare(right);
18
+ }
19
+
20
+ function findClosestAncestor(trees, dir) {
21
+ return trees.find((tree) => isAncestorDir(dirname(tree.root), dir)) ?? false;
22
+ }
23
+
24
+ export function classify(paths) {
25
+ const sorted = paths.toSorted(comparePaths);
26
+ const trees = [];
27
+ for (const path of sorted) {
28
+ const ancestor = findClosestAncestor(trees, dirname(path));
29
+ if (ancestor) {
30
+ ancestor.children.push(path);
31
+ }
32
+ else {
33
+ trees.push({ root: path, children: [] });
34
+ }
35
+ }
36
+ return trees;
37
+ }
38
+
39
+ function buildSectionMap(parsed) {
40
+ const map = new Map();
41
+ for (const section of parsed.sections) {
42
+ if (!map.has(section.header)) {
43
+ map.set(section.header, new Map());
44
+ }
45
+ const target = map.get(section.header);
46
+ for (const [key, value] of section.body) {
47
+ target.set(key, value);
48
+ }
49
+ }
50
+ return map;
51
+ }
52
+
53
+ function diffKey({ childPath, header, key, childValue, rootValue }) {
54
+ if (rootValue === childValue) {
55
+ return {
56
+ kind: 'redundant',
57
+ severity: 'warn',
58
+ file: childPath,
59
+ header,
60
+ key,
61
+ value: childValue,
62
+ };
63
+ }
64
+ return {
65
+ kind: 'contradiction',
66
+ severity: 'warn',
67
+ file: childPath,
68
+ header,
69
+ key,
70
+ rootValue,
71
+ childValue,
72
+ };
73
+ }
74
+
75
+ function compareChildKeys({ childPath, header, childBody, rootSections }) {
76
+ const rootSameHeader = rootSections.get(header);
77
+ if (!rootSameHeader) {
78
+ return [];
79
+ }
80
+ const issues = [];
81
+ for (const [key, value] of childBody) {
82
+ if (rootSameHeader.has(key)) {
83
+ issues.push(diffKey({
84
+ childPath,
85
+ header,
86
+ key,
87
+ childValue: value,
88
+ rootValue: rootSameHeader.get(key),
89
+ }));
90
+ }
91
+ }
92
+ return issues;
93
+ }
94
+
95
+ function compareKeyIssues(left, right) {
96
+ if (left.header !== right.header) {
97
+ return left.header.localeCompare(right.header);
98
+ }
99
+ return left.key.localeCompare(right.key);
100
+ }
101
+
102
+ function collectKeyIssues({ rootParsed, childParsed, childPath }) {
103
+ const rootSections = buildSectionMap(rootParsed);
104
+ const childSections = buildSectionMap(childParsed);
105
+ const issues = [];
106
+ for (const [header, childBody] of childSections) {
107
+ issues.push(...compareChildKeys({ childPath, header, childBody, rootSections }));
108
+ }
109
+ return issues.toSorted(compareKeyIssues);
110
+ }
111
+
112
+ export function crossFileIssues({ rootParsed, childParsed, childPath }) {
113
+ const issues = [];
114
+ if (childParsed.hasRoot) {
115
+ issues.push({ kind: 'child-root', severity: 'fail', file: childPath });
116
+ }
117
+ issues.push(...collectKeyIssues({ rootParsed, childParsed, childPath }));
118
+ return issues;
119
+ }
@@ -0,0 +1,209 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { dirname, relative, resolve } from 'node:path';
3
+ import { discoverEditorConfigs } from './discover.js';
4
+ import { classify, crossFileIssues } from './cascade.js';
5
+ import {
6
+ compareEditorConfigForRole,
7
+ NO_LANGUAGE_FILTER,
8
+ printReport,
9
+ reportIsFailing,
10
+ summarizeReport,
11
+ } from './check.js';
12
+ import { logger } from './utils/logger.js';
13
+
14
+ function resolveStartDir(rawPath) {
15
+ const abs = resolve(rawPath);
16
+ if (!existsSync(abs)) {
17
+ throw new Error(`'${rawPath}' does not exist`);
18
+ }
19
+ const stats = statSync(abs);
20
+ if (stats.isDirectory()) {
21
+ return abs;
22
+ }
23
+ const dir = dirname(abs);
24
+ logger.warn(`--recursive expects a directory; using '${dir}' instead of '${rawPath}'.`);
25
+ return dir;
26
+ }
27
+
28
+ function displayPath(absPath, startDir) {
29
+ return relative(startDir, absPath);
30
+ }
31
+
32
+ function formatChildRoot(where) {
33
+ return ` ❌ ${where} declares 'root = true' (forbidden in child)`;
34
+ }
35
+
36
+ function formatRedundant(issue, where) {
37
+ return ` ⚠️ ${where} ${issue.header} ${issue.key} = ${issue.value} (redundant — same as root)`;
38
+ }
39
+
40
+ function formatContradiction(issue, where) {
41
+ return ` ⚠️ ${where} ${issue.header} ${issue.key} = ${issue.childValue} (contradicts root ${issue.header} ${issue.key} = ${issue.rootValue})`;
42
+ }
43
+
44
+ function formatCrossFileIssue(issue, startDir) {
45
+ const where = displayPath(issue.file, startDir);
46
+ if (issue.kind === 'child-root') {
47
+ return formatChildRoot(where);
48
+ }
49
+ if (issue.kind === 'redundant') {
50
+ return formatRedundant(issue, where);
51
+ }
52
+ return formatContradiction(issue, where);
53
+ }
54
+
55
+ function buildEntry({ path, role, parsedLanguages, overrides }) {
56
+ return {
57
+ path,
58
+ role,
59
+ report: compareEditorConfigForRole({ path, parsedLanguages, overrides, role }),
60
+ };
61
+ }
62
+
63
+ function buildFileEntries(tree, parsedLanguages, overrides) {
64
+ const entries = [buildEntry({ path: tree.root, role: 'root', parsedLanguages, overrides })];
65
+ for (const child of tree.children) {
66
+ entries.push(buildEntry({
67
+ path: child,
68
+ role: 'child',
69
+ parsedLanguages: NO_LANGUAGE_FILTER,
70
+ overrides,
71
+ }));
72
+ }
73
+ return entries;
74
+ }
75
+
76
+ function collectCrossIssues(rootEntry, childEntries) {
77
+ const issues = [];
78
+ for (const child of childEntries) {
79
+ issues.push(...crossFileIssues({
80
+ rootParsed: rootEntry.report.parsed,
81
+ childParsed: child.report.parsed,
82
+ childPath: child.path,
83
+ }));
84
+ }
85
+ return issues;
86
+ }
87
+
88
+ function printFileBlock(entry, startDir) {
89
+ const label = `[${entry.role}]`;
90
+ printReport(displayPath(entry.path, startDir), entry.report, { label });
91
+ }
92
+
93
+ function printCrossFileBlock(issues, startDir) {
94
+ if (issues.length === 0) {
95
+ return;
96
+ }
97
+ logger.log('Cross-file warnings');
98
+ logger.log('');
99
+ for (const issue of issues) {
100
+ logger.log(formatCrossFileIssue(issue, startDir));
101
+ }
102
+ logger.log('');
103
+ }
104
+
105
+ function aggregateCounts(entries, strict) {
106
+ const totals = { matched: 0, failed: 0, unknown: 0, filesFailed: 0 };
107
+ for (const entry of entries) {
108
+ const counts = summarizeReport(entry.report);
109
+ totals.matched += counts.matched;
110
+ totals.failed += counts.failed;
111
+ totals.unknown += counts.unknown;
112
+ if (reportIsFailing(entry.report, strict)) {
113
+ totals.filesFailed += 1;
114
+ }
115
+ }
116
+ return totals;
117
+ }
118
+
119
+ function pluralize(count, singular) {
120
+ if (count === 1) {
121
+ return singular;
122
+ }
123
+ return `${singular}s`;
124
+ }
125
+
126
+ function buildSummaryParts({ fileCount, counts, warnings, crossFailures, strict }) {
127
+ const parts = [`${fileCount} ${pluralize(fileCount, 'file')} checked`];
128
+ if (counts.filesFailed > 0) {
129
+ parts.push(`${counts.filesFailed} failed`);
130
+ }
131
+ if (crossFailures > 0) {
132
+ parts.push(`${crossFailures} cross-file ${pluralize(crossFailures, 'failure')}`);
133
+ }
134
+ if (warnings > 0) {
135
+ parts.push(`${warnings} ${pluralize(warnings, 'warning')}`);
136
+ }
137
+ if (counts.unknown > 0 && !strict) {
138
+ parts.push(`${counts.unknown} unknown ${pluralize(counts.unknown, 'header')} ignored`);
139
+ }
140
+ return parts;
141
+ }
142
+
143
+ function summaryHead(passed) {
144
+ if (passed) {
145
+ return '✅ PASS';
146
+ }
147
+ return '❌ FAIL';
148
+ }
149
+
150
+ function formatGlobalSummary({ entries, crossIssues, strict }) {
151
+ const counts = aggregateCounts(entries, strict);
152
+ const warnings = crossIssues.filter((issue) => issue.severity === 'warn').length;
153
+ const crossFailures = crossIssues.filter((issue) => issue.severity === 'fail').length;
154
+ const passed = counts.filesFailed === 0 && crossFailures === 0;
155
+ const parts = buildSummaryParts({
156
+ fileCount: entries.length,
157
+ counts,
158
+ warnings,
159
+ crossFailures,
160
+ strict,
161
+ });
162
+ return { line: `${summaryHead(passed)} — ${parts.join('; ')}`, failed: !passed };
163
+ }
164
+
165
+ function processTree({ tree, parsedLanguages, overrides, startDir }) {
166
+ const entries = buildFileEntries(tree, parsedLanguages, overrides);
167
+ const [rootEntry, ...childEntries] = entries;
168
+ const crossIssues = collectCrossIssues(rootEntry, childEntries);
169
+ for (const entry of entries) {
170
+ printFileBlock(entry, startDir);
171
+ }
172
+ return { entries, crossIssues };
173
+ }
174
+
175
+ function gatherAll({ trees, parsedLanguages, overrides, startDir }) {
176
+ const allEntries = [];
177
+ const allCrossIssues = [];
178
+ for (const tree of trees) {
179
+ const { entries, crossIssues } = processTree({ tree, parsedLanguages, overrides, startDir });
180
+ allEntries.push(...entries);
181
+ allCrossIssues.push(...crossIssues);
182
+ }
183
+ return { allEntries, allCrossIssues };
184
+ }
185
+
186
+ function reportAndExit({ allEntries, allCrossIssues, strict, startDir }) {
187
+ printCrossFileBlock(allCrossIssues, startDir);
188
+ const summary = formatGlobalSummary({ entries: allEntries, crossIssues: allCrossIssues, strict });
189
+ logger.log(summary.line);
190
+ if (summary.failed) {
191
+ process.exitCode = 1;
192
+ }
193
+ }
194
+
195
+ export function runCheckRecursive({ startDir: rawStart, parsedLanguages, strict, overrides }) {
196
+ const startDir = resolveStartDir(rawStart);
197
+ const paths = discoverEditorConfigs(startDir);
198
+ if (paths.length === 0) {
199
+ logger.log(`No .editorconfig files found under ${startDir}`);
200
+ return;
201
+ }
202
+ const { allEntries, allCrossIssues } = gatherAll({
203
+ trees: classify(paths),
204
+ parsedLanguages,
205
+ overrides,
206
+ startDir,
207
+ });
208
+ reportAndExit({ allEntries, allCrossIssues, strict, startDir });
209
+ }
package/src/check.js CHANGED
@@ -18,6 +18,7 @@ const STATUS_GLYPH = {
18
18
  mismatch: '❌',
19
19
  missing: '❌',
20
20
  'no-root': '❌',
21
+ 'child-root-forbidden': '❌',
21
22
  unknown: '⚠️ ',
22
23
  };
23
24
 
@@ -26,9 +27,12 @@ const STATUS_DETAIL = {
26
27
  mismatch: 'section body does not match',
27
28
  missing: 'missing',
28
29
  'no-root': "missing 'root = true' before sections",
30
+ 'child-root-forbidden': "child file must not declare 'root = true'",
29
31
  unknown: 'unknown header (not validated)',
30
32
  };
31
33
 
34
+ const FAILING_STATUSES = new Set(['mismatch', 'missing', 'no-root', 'child-root-forbidden']);
35
+
32
36
  function languageForHeader(header) {
33
37
  if (header === BASE_SECTION_HEADER) {
34
38
  return 'base';
@@ -36,11 +40,14 @@ function languageForHeader(header) {
36
40
  return headerToLanguage(header);
37
41
  }
38
42
 
39
- function checkSection(section, overrides) {
43
+ function checkSection(section, overrides, role) {
40
44
  const language = languageForHeader(section.header);
41
45
  if (!language) {
42
46
  return { header: section.header, status: 'unknown' };
43
47
  }
48
+ if (role === 'child') {
49
+ return { header: section.header, status: 'match' };
50
+ }
44
51
  const expected = expectedBodyForLanguage(language, overrides);
45
52
  const { ok } = compareSection(section.body, expected);
46
53
  if (ok) {
@@ -54,8 +61,8 @@ function buildExpectedHeaders(parsedLanguages) {
54
61
  return [BASE_SECTION_HEADER, ...resolved.map((name) => languageToHeader(name))];
55
62
  }
56
63
 
57
- function buildResults({ parsedLanguages, parsed, overrides }) {
58
- const results = parsed.sections.map((section) => checkSection(section, overrides));
64
+ function buildResults({ parsedLanguages, parsed, overrides, role }) {
65
+ const results = parsed.sections.map((section) => checkSection(section, overrides, role));
59
66
  if (parsedLanguages !== NO_LANGUAGE_FILTER) {
60
67
  const present = new Set(parsed.sections.map((section) => section.header));
61
68
  const expectedHeaders = buildExpectedHeaders(parsedLanguages);
@@ -68,7 +75,7 @@ function buildResults({ parsedLanguages, parsed, overrides }) {
68
75
  return results;
69
76
  }
70
77
 
71
- function buildBaseIssues(parsed) {
78
+ function buildRootBaseIssues(parsed) {
72
79
  const issues = [];
73
80
  const hasBase = parsed.sections.some((section) => section.header === BASE_SECTION_HEADER);
74
81
  if (!hasBase) {
@@ -80,14 +87,47 @@ function buildBaseIssues(parsed) {
80
87
  return issues;
81
88
  }
82
89
 
83
- export function compareEditorConfig(path = '.editorconfig', parsedLanguages = NO_LANGUAGE_FILTER, overrides = EMPTY_OVERRIDES) {
90
+ function buildChildBaseIssues(parsed) {
91
+ if (parsed.hasRoot) {
92
+ return [{ header: BASE_SECTION_HEADER, status: 'child-root-forbidden' }];
93
+ }
94
+ return [];
95
+ }
96
+
97
+ function buildBaseIssuesForRole(parsed, role) {
98
+ if (role === 'child') {
99
+ return buildChildBaseIssues(parsed);
100
+ }
101
+ return buildRootBaseIssues(parsed);
102
+ }
103
+
104
+ export function compareEditorConfigForRole({
105
+ path,
106
+ parsedLanguages = NO_LANGUAGE_FILTER,
107
+ overrides = EMPTY_OVERRIDES,
108
+ role = 'root',
109
+ }) {
84
110
  if (!existsSync(path)) {
85
111
  throw new Error(`'${path}' does not exist`);
86
112
  }
87
113
  const text = readFileSync(path, 'utf8');
88
114
  const parsed = parseSections(text);
89
- const baseIssues = buildBaseIssues(parsed);
90
- const results = buildResults({ parsedLanguages, parsed, overrides });
115
+ const baseIssues = buildBaseIssuesForRole(parsed, role);
116
+ let effectiveLanguages = parsedLanguages;
117
+ if (role === 'child') {
118
+ effectiveLanguages = NO_LANGUAGE_FILTER;
119
+ }
120
+ const results = buildResults({ parsedLanguages: effectiveLanguages, parsed, overrides, role });
121
+ return { baseIssues, results, parsed };
122
+ }
123
+
124
+ export function compareEditorConfig(path = '.editorconfig', parsedLanguages = NO_LANGUAGE_FILTER, overrides = EMPTY_OVERRIDES) {
125
+ const { baseIssues, results } = compareEditorConfigForRole({
126
+ path,
127
+ parsedLanguages,
128
+ overrides,
129
+ role: 'root',
130
+ });
91
131
  return { baseIssues, results };
92
132
  }
93
133
 
@@ -101,15 +141,20 @@ function formatLine({ header, status }) {
101
141
  }
102
142
 
103
143
  export function reportIsFailing({ baseIssues, results }, strict) {
104
- const failing = [...baseIssues, ...results].some((entry) =>
105
- entry.status === 'mismatch' || entry.status === 'missing' || entry.status === 'no-root',
106
- );
144
+ const failing = [...baseIssues, ...results].some((entry) => FAILING_STATUSES.has(entry.status));
107
145
  const hasUnknown = results.some((entry) => entry.status === 'unknown');
108
146
  return failing || (strict && hasUnknown);
109
147
  }
110
148
 
111
- function printReport(path, report) {
112
- logger.log(`Checking ${path}`);
149
+ function buildHeading(displayPath, label) {
150
+ if (label) {
151
+ return `Checking ${displayPath} ${label}`;
152
+ }
153
+ return `Checking ${displayPath}`;
154
+ }
155
+
156
+ export function printReport(displayPath, report, { label } = {}) {
157
+ logger.log(buildHeading(displayPath, label));
113
158
  logger.log('');
114
159
  for (const issue of report.baseIssues) {
115
160
  logger.log(formatLine(issue));
@@ -120,13 +165,11 @@ function printReport(path, report) {
120
165
  logger.log('');
121
166
  }
122
167
 
123
- function summarize(report) {
168
+ export function summarizeReport(report) {
124
169
  const lines = [...report.baseIssues, ...report.results];
125
170
  const total = lines.length;
126
171
  const matched = lines.filter((entry) => entry.status === 'match').length;
127
- const failed = lines.filter((entry) =>
128
- entry.status === 'mismatch' || entry.status === 'missing' || entry.status === 'no-root',
129
- ).length;
172
+ const failed = lines.filter((entry) => FAILING_STATUSES.has(entry.status)).length;
130
173
  const unknown = lines.filter((entry) => entry.status === 'unknown').length;
131
174
  return { total, matched, failed, unknown };
132
175
  }
@@ -158,7 +201,7 @@ function formatPassSummary({ matched, unknown }) {
158
201
  }
159
202
 
160
203
  function formatSummary(report, isFailure) {
161
- const counts = summarize(report);
204
+ const counts = summarizeReport(report);
162
205
  if (isFailure) {
163
206
  return formatFailureSummary(counts);
164
207
  }
@@ -0,0 +1,99 @@
1
+ import { EMPTY_OVERRIDES } from '../templates/index.js';
2
+ import { loadCustomTemplate } from '../templates/custom-template.js';
3
+ import { NO_LANGUAGE_FILTER, runCheck } from '../check.js';
4
+ import { runCheckRecursive } from '../check-recursive.js';
5
+ import { logger } from '../utils/logger.js';
6
+ import { NOT_PROVIDED, parseLanguages } from './options.js';
7
+ import { runWrite } from './write-flow.js';
8
+
9
+ const OVERRIDES_FAILED = Symbol('overrides-failed');
10
+
11
+ async function resolveOverrides(templateValue) {
12
+ if (typeof templateValue !== 'string') {
13
+ return EMPTY_OVERRIDES;
14
+ }
15
+ try {
16
+ return await loadCustomTemplate(templateValue);
17
+ }
18
+ catch (error) {
19
+ logger.error(error.message);
20
+ process.exitCode = 1;
21
+ return OVERRIDES_FAILED;
22
+ }
23
+ }
24
+
25
+ function resolveCheckPath(values) {
26
+ if (typeof values.path === 'string' && values.path.length > 0) {
27
+ return values.path;
28
+ }
29
+ if (values.recursive) {
30
+ return process.cwd();
31
+ }
32
+ return '.editorconfig';
33
+ }
34
+
35
+ function dispatchCheck({ path, languages, strict, overrides, recursive }) {
36
+ let filter = languages;
37
+ if (filter === NOT_PROVIDED) {
38
+ filter = NO_LANGUAGE_FILTER;
39
+ }
40
+ try {
41
+ if (recursive) {
42
+ runCheckRecursive({ startDir: path, parsedLanguages: filter, strict, overrides });
43
+ return;
44
+ }
45
+ runCheck({ path, parsedLanguages: filter, strict, overrides });
46
+ }
47
+ catch (error) {
48
+ logger.error(error.message);
49
+ process.exitCode = 1;
50
+ }
51
+ }
52
+
53
+ async function dispatchWrite({ path, overwrite, languages, overrides }) {
54
+ try {
55
+ await runWrite({ path, overwrite, parsedLanguages: languages, overrides });
56
+ }
57
+ catch (error) {
58
+ logger.error(error.message);
59
+ process.exitCode = 1;
60
+ }
61
+ }
62
+
63
+ async function handleWrite({ path, overwrite, languages, overrides, recursive }) {
64
+ if (recursive) {
65
+ logger.error('--recursive (-r) is only supported with --mode=check');
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+ await dispatchWrite({ path, overwrite, languages, overrides });
70
+ }
71
+
72
+ async function runCommand({ mode, path, overwrite, languages, strict, overrides, recursive }) {
73
+ if (mode === 'write') {
74
+ await handleWrite({ path, overwrite, languages, overrides, recursive });
75
+ return;
76
+ }
77
+ if (mode === 'check') {
78
+ dispatchCheck({ path, languages, strict, overrides, recursive });
79
+ return;
80
+ }
81
+ logger.error('invalid command');
82
+ process.exitCode = 1;
83
+ }
84
+
85
+ export async function dispatchValues(values) {
86
+ const overrides = await resolveOverrides(values.template);
87
+ if (overrides === OVERRIDES_FAILED) {
88
+ return;
89
+ }
90
+ await runCommand({
91
+ mode: values.mode,
92
+ path: resolveCheckPath(values),
93
+ overwrite: values.overwrite,
94
+ languages: parseLanguages(values.languages),
95
+ strict: values.strict,
96
+ overrides,
97
+ recursive: Boolean(values.recursive),
98
+ });
99
+ }
@@ -0,0 +1,89 @@
1
+ import { parseArgs } from 'node:util';
2
+ import {
3
+ ALIASES,
4
+ AVAILABLE_LANGUAGES,
5
+ } from '../templates/index.js';
6
+ import { logger } from '../utils/logger.js';
7
+
8
+ export const options = {
9
+ mode: { type: 'string', short: 'm' },
10
+ path: { type: 'string', short: 'p' },
11
+ languages: { type: 'string', short: 'l' },
12
+ help: { type: 'boolean', short: 'h' },
13
+ overwrite: { type: 'boolean', short: 'o' },
14
+ strict: { type: 'boolean', short: 's' },
15
+ template: { type: 'string', short: 't' },
16
+ recursive: { type: 'boolean', short: 'r' },
17
+ };
18
+
19
+ export const NOT_PROVIDED = Symbol('languages-not-provided');
20
+
21
+ export function parseLanguages(raw) {
22
+ if (typeof raw !== 'string') {
23
+ return NOT_PROVIDED;
24
+ }
25
+ return raw
26
+ .split(',')
27
+ .map((token) => token.trim().toLowerCase())
28
+ .filter((token) => token.length > 0);
29
+ }
30
+
31
+ function formatAliasList() {
32
+ const grouped = new Map();
33
+ for (const [alias, target] of Object.entries(ALIASES)) {
34
+ const list = grouped.get(target) ?? [];
35
+ list.push(alias);
36
+ grouped.set(target, list);
37
+ }
38
+ return [...grouped.entries()]
39
+ .map(([target, aliases]) => `${aliases.join(', ')} -> ${target}`)
40
+ .join('; ');
41
+ }
42
+
43
+ export function printHelp() {
44
+ logger.log(`Usage: editorconfig --mode=<command> [--path=<path>] [--languages=<list>] [--template=<path|url>] [--recursive]
45
+
46
+ Commands:
47
+ write Create a .editorconfig file with the selected language sections
48
+ check Validate per-section against the canonical templates
49
+
50
+ Options:
51
+ -m, --mode Command to run (write | check)
52
+ -p, --path Path to the .editorconfig file, or start directory when used with --recursive (default: .editorconfig / cwd)
53
+ -l, --languages Comma-separated language sections (write: which to emit; check: required set; recursive: enforced on root only)
54
+ -o, --overwrite Overwrite an existing .editorconfig without confirmation
55
+ -s, --strict Treat unknown section headers as failures (check only)
56
+ -t, --template Path or https URL to a custom .editorconfig-syntax file whose sections override the built-in ones
57
+ -r, --recursive Check only. Walk the start directory and validate every .editorconfig (root + children) with cascade checks
58
+ -h, --help Show this help message
59
+
60
+ Languages: ${AVAILABLE_LANGUAGES.join(', ')}
61
+ Aliases: ${formatAliasList()}
62
+
63
+ Examples:
64
+ editorconfig --mode=write --languages=js,md
65
+ editorconfig --mode=write # interactive in TTY, base only otherwise
66
+ editorconfig --mode=check --languages=js,md # require exactly base + js + md
67
+ editorconfig --mode=check --strict # fail on any unknown section header
68
+ editorconfig --mode=check --recursive # validate every .editorconfig under cwd (monorepo)
69
+ editorconfig --mode=check --template=./team.editorconfig
70
+ editorconfig --mode=check --template=https://team.example.com/.editorconfig`);
71
+ };
72
+
73
+ function formatCliError(error) {
74
+ if (error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
75
+ return `${error.message.split('.')[0]}. See --help.`;
76
+ }
77
+ return error.message;
78
+ }
79
+
80
+ export function parseCliArgs(args) {
81
+ try {
82
+ return parseArgs({ args, options });
83
+ }
84
+ catch (error) {
85
+ logger.error(formatCliError(error));
86
+ process.exitCode = 1;
87
+ return false;
88
+ }
89
+ }
@@ -0,0 +1,85 @@
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import {
4
+ AVAILABLE_LANGUAGES,
5
+ composeEditorConfig,
6
+ EMPTY_OVERRIDES,
7
+ } from '../templates/index.js';
8
+ import { logger } from '../utils/logger.js';
9
+ import { NOT_PROVIDED } from './options.js';
10
+
11
+ export function createEditorConfig(path = '.editorconfig', languages = [], overrides = EMPTY_OVERRIDES) {
12
+ writeFileSync(path, composeEditorConfig(languages, overrides), 'utf8');
13
+ };
14
+
15
+ function resolvePromptAnswer(answer) {
16
+ const tokens = answer
17
+ .split(',')
18
+ .map((token) => token.trim().toLowerCase())
19
+ .filter((token) => token.length > 0);
20
+ return tokens.map((token) => {
21
+ const asNumber = Number.parseInt(token, 10);
22
+ if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= AVAILABLE_LANGUAGES.length) {
23
+ return AVAILABLE_LANGUAGES[asNumber - 1];
24
+ }
25
+ return token;
26
+ });
27
+ }
28
+
29
+ function promptLanguages() {
30
+ return new Promise((resolve) => {
31
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
32
+ const numbered = AVAILABLE_LANGUAGES
33
+ .map((name, index) => ` ${index + 1}. ${name}`)
34
+ .join('\n');
35
+ logger.log(`Available language sections:\n${numbered}`);
36
+ rl.question('Languages? [comma-separated names or indices, blank=base only] ', (answer) => {
37
+ rl.close();
38
+ resolve(resolvePromptAnswer(answer));
39
+ });
40
+ });
41
+ }
42
+
43
+ function resolveLanguages(parsedLanguages) {
44
+ if (parsedLanguages !== NOT_PROVIDED) {
45
+ return Promise.resolve(parsedLanguages);
46
+ }
47
+ if (process.stdin.isTTY) {
48
+ return promptLanguages();
49
+ }
50
+ return Promise.resolve([]);
51
+ }
52
+
53
+ function confirmOverwrite(path) {
54
+ return new Promise((resolve) => {
55
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
56
+ rl.question(`\`${path}\` already exists. Overwrite? [y/N] `, (answer) => {
57
+ rl.close();
58
+ resolve(/^y(es)?$/iu.test(answer.trim()));
59
+ });
60
+ });
61
+ }
62
+
63
+ async function handleExistingTarget(path, languages, overrides) {
64
+ if (!process.stdin.isTTY) {
65
+ logger.error(`\`${path}\` already exists. Use --overwrite to replace it.`);
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+ const confirmed = await confirmOverwrite(path);
70
+ if (confirmed) {
71
+ createEditorConfig(path, languages, overrides);
72
+ }
73
+ else {
74
+ logger.log(`Skipped: \`${path}\` was not modified.`);
75
+ }
76
+ }
77
+
78
+ export async function runWrite({ path, overwrite, parsedLanguages, overrides }) {
79
+ const languages = await resolveLanguages(parsedLanguages);
80
+ if (!existsSync(path) || overwrite) {
81
+ createEditorConfig(path, languages, overrides);
82
+ return;
83
+ }
84
+ await handleExistingTarget(path, languages, overrides);
85
+ }
@@ -0,0 +1,66 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { logger } from './utils/logger.js';
4
+
5
+ export const IGNORED_DIRS = new Set([
6
+ 'node_modules',
7
+ '.git',
8
+ 'dist',
9
+ 'build',
10
+ 'coverage',
11
+ '.next',
12
+ '.cache',
13
+ ]);
14
+
15
+ const EDITORCONFIG_NAME = '.editorconfig';
16
+
17
+ function readDir(dir) {
18
+ try {
19
+ return readdirSync(dir, { withFileTypes: true });
20
+ }
21
+ catch (error) {
22
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
23
+ logger.warn(`Skipping unreadable directory: ${dir}`);
24
+ return [];
25
+ }
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ function classifyEntry(entry) {
31
+ if (entry.isSymbolicLink()) {
32
+ return 'skip';
33
+ }
34
+ if (entry.isDirectory()) {
35
+ if (IGNORED_DIRS.has(entry.name)) {
36
+ return 'skip';
37
+ }
38
+ return 'descend';
39
+ }
40
+ if (entry.isFile() && entry.name === EDITORCONFIG_NAME) {
41
+ return 'collect';
42
+ }
43
+ return 'skip';
44
+ }
45
+
46
+ function processDir(dir, stack, found) {
47
+ for (const entry of readDir(dir)) {
48
+ const full = join(dir, entry.name);
49
+ const action = classifyEntry(entry);
50
+ if (action === 'descend') {
51
+ stack.push(full);
52
+ }
53
+ else if (action === 'collect') {
54
+ found.push(full);
55
+ }
56
+ }
57
+ }
58
+
59
+ export function discoverEditorConfigs(startDir) {
60
+ const found = [];
61
+ const stack = [startDir];
62
+ while (stack.length > 0) {
63
+ processDir(stack.pop(), stack, found);
64
+ }
65
+ return found.toSorted();
66
+ }