@sheplu/editorconfig 0.10.1 → 0.12.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.
- package/README.md +49 -0
- package/index.js +6 -268
- package/package.json +3 -2
- package/src/cascade.js +119 -0
- package/src/check-recursive.js +209 -0
- package/src/check.js +60 -17
- package/src/cli/dispatch.js +99 -0
- package/src/cli/options.js +89 -0
- package/src/cli/write-flow.js +85 -0
- package/src/discover.js +66 -0
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.
|
|
@@ -172,6 +215,12 @@ Tests live under `test/` and are split by scope:
|
|
|
172
215
|
- `test/unit/` — fast, in-process tests of exported functions (no spawning, no I/O beyond a tmp dir).
|
|
173
216
|
- `test/integration/` — full CLI runs. The interactive prompt path is exercised in a real pseudoterminal via [`node-pty`](https://github.com/microsoft/node-pty); the rest go through `child_process.spawnSync` with a closed stdin.
|
|
174
217
|
|
|
218
|
+
`node-pty` ships a native binding that npm normally compiles via a postinstall script. This repo sets `ignore-scripts=true` in `.npmrc`, so after `npm install` you need to build it once:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
npm run rebuild:native
|
|
222
|
+
```
|
|
223
|
+
|
|
175
224
|
Run them with:
|
|
176
225
|
|
|
177
226
|
```bash
|
package/index.js
CHANGED
|
@@ -1,267 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sheplu/editorconfig",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "CLI to generate and validate a consistent .editorconfig across your projects",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
],
|
|
10
10
|
"scripts": {
|
|
11
11
|
"lint": "oxlint",
|
|
12
|
+
"rebuild:native": "npm rebuild node-pty --ignore-scripts=false",
|
|
12
13
|
"test": "node --test",
|
|
13
14
|
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-branches=95 --test-coverage-functions=95",
|
|
14
15
|
"test:unit": "node --test 'test/unit/*.test.js'",
|
|
@@ -41,6 +42,6 @@
|
|
|
41
42
|
"homepage": "https://github.com/sheplu/editorconfig#readme",
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"node-pty": "1.0.0",
|
|
44
|
-
"oxlint": "^1.
|
|
45
|
+
"oxlint": "^1.67.0"
|
|
45
46
|
}
|
|
46
47
|
}
|
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
|
|
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
|
-
|
|
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 =
|
|
90
|
-
|
|
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
|
|
112
|
-
|
|
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
|
|
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 =
|
|
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
|
+
}
|
package/src/discover.js
ADDED
|
@@ -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
|
+
}
|