@sheplu/editorconfig 0.8.6 → 0.9.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
@@ -4,8 +4,8 @@ A small CLI to manage a **consistent `.editorconfig`** across your projects.
4
4
 
5
5
  - ✅ Generate a sane default `.editorconfig` in seconds
6
6
  - ✅ Check if your existing file matches the target setup
7
- - 🔜 Propose updates (with confirmation) instead of blindly overwriting
8
- - 🔜 Compare your file against the recommended template
7
+ - Confirm before overwriting an existing `.editorconfig` (or pass `--overwrite` to skip the prompt)
8
+ - Override the built-in template with a team-shared file via `--template` (local path or `https://` URL)
9
9
 
10
10
  ## Why?
11
11
 
@@ -49,8 +49,8 @@ npx @sheplu/editorconfig --mode=write
49
49
  This will:
50
50
 
51
51
  - Create a `.editorconfig` file if it doesn’t exist
52
- - (Current behavior) Write the default template
53
- - (Future behavior) Ask before overwriting an existing file
52
+ - Write the default template
53
+ - Ask before overwriting an existing file (or fail in non-interactive contexts unless `--overwrite` is passed)
54
54
 
55
55
  ## Current Features
56
56
 
@@ -62,6 +62,23 @@ npx @sheplu/editorconfig --mode=write
62
62
 
63
63
  Creates a base `.editorconfig` file in the current directory.
64
64
 
65
+ If a `.editorconfig` already exists at the target path, the CLI will:
66
+
67
+ - Prompt for confirmation in an interactive terminal (`y` / `yes` to overwrite, anything else keeps the file).
68
+ - Exit non-zero in non-interactive contexts (CI, redirected stdin) so a script never silently destroys an existing config.
69
+
70
+ Pass `--overwrite` (or `-o`) to bypass the prompt and force a rewrite:
71
+
72
+ ```bash
73
+ npx @sheplu/editorconfig --mode=write --overwrite
74
+ ```
75
+
76
+ You can also point at a custom path:
77
+
78
+ ```bash
79
+ npx @sheplu/editorconfig --mode=write --path=path/to/.editorconfig
80
+ ```
81
+
65
82
  Typical content (example):
66
83
 
67
84
  ```ini
@@ -96,6 +113,36 @@ This command will:
96
113
  - `0` if everything matches
97
114
  - `1` if differences are found
98
115
 
116
+ ### `--template` (custom team template)
117
+
118
+ 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.
119
+
120
+ The flag accepts either a local path or an `https://` URL:
121
+
122
+ ```bash
123
+ # local file
124
+ npx @sheplu/editorconfig --mode=write --template=./team.editorconfig
125
+
126
+ # remote URL
127
+ npx @sheplu/editorconfig --mode=check --template=https://raw.githubusercontent.com/example/team-config/main/.editorconfig
128
+ ```
129
+
130
+ The custom template must follow the same semantics as the built-ins:
131
+
132
+ - Use only known section headers (`[*]`, `[*.md]`, `[*.{js,jsx,...}]`, etc.). Unknown headers (like `[*.proto]`) are rejected.
133
+ - Include `root = true` in the preamble whenever the template redefines `[*]`.
134
+ - Each header may appear at most once.
135
+
136
+ URL fetching is constrained for safety: `https://` only (`http://` is rejected before any network call), redirects must stay on https (max 5 hops), 10-second timeout, 1 MB response cap. Templates are fetched on every invocation — there is no local cache.
137
+
138
+ ### `--help`
139
+
140
+ ```bash
141
+ npx @sheplu/editorconfig --help
142
+ ```
143
+
144
+ Prints the full usage, the available commands, and every supported option.
145
+
99
146
  ## Planned / Upcoming Features
100
147
 
101
148
  ### 1. Interactive update / replace
@@ -115,10 +162,27 @@ npx @sheplu/editorconfig --mode=diff
115
162
  - [ ] Add diff logic and `diff` command
116
163
  - [ ] Add interactive `fix` / `update` command
117
164
  - [ ] Expose presets or configuration options
118
- - [ ] Add tests and CI examples
119
165
 
120
166
  Additional properties can be found on the [editorconfig wiki](https://github.com/editorconfig/editorconfig/wiki/editorconfig-properties).
121
167
 
168
+ ## Tests
169
+
170
+ Tests live under `test/` and are split by scope:
171
+
172
+ - `test/unit/` — fast, in-process tests of exported functions (no spawning, no I/O beyond a tmp dir).
173
+ - `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
+
175
+ Run them with:
176
+
177
+ ```bash
178
+ npm test # everything
179
+ npm run test:unit # unit only — runs in ~50ms
180
+ npm run test:integration # integration only — spawns the CLI
181
+ npm run lint # oxlint
182
+ ```
183
+
184
+ CI runs lint, audit, and the full suite on every PR across Node 24 / 26.
185
+
122
186
  ## Documentation
123
187
 
124
188
  [Editorconfig documentation](https://github.com/editorconfig/editorconfig/wiki/editorconfig-properties)
package/index.js CHANGED
@@ -1,60 +1,285 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, writeFileSync } from "node:fs";
4
+ import { createInterface } from 'node:readline';
4
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';
5
14
 
6
- const editorconfigContent = `root = true
7
-
8
- [*]
9
- indent_style = tab
10
- indent_size = 4
11
- tab_width = 4
12
- end_of_line = lf
13
- charset = utf-8
14
- spelling_language = en
15
- trim_trailing_whitespace = true
16
- insert_final_newline = true
17
- quote_type = single
18
- spaces_around_operators = true
19
- `;
20
-
21
- export function createEditorConfig(path = '.editorconfig') {
22
- writeFileSync(path, editorconfigContent, 'utf8');
15
+ export { compareEditorConfig };
16
+
17
+ export function createEditorConfig(path = '.editorconfig', languages = [], overrides = EMPTY_OVERRIDES) {
18
+ writeFileSync(path, composeEditorConfig(languages, overrides), 'utf8');
19
+ };
20
+
21
+ export const options = {
22
+ mode: {
23
+ type: 'string',
24
+ short: 'm',
25
+ },
26
+ path: {
27
+ type: 'string',
28
+ short: 'p',
29
+ },
30
+ languages: {
31
+ type: 'string',
32
+ short: 'l',
33
+ },
34
+ help: {
35
+ type: 'boolean',
36
+ short: 'h',
37
+ },
38
+ overwrite: {
39
+ type: 'boolean',
40
+ short: 'o',
41
+ },
42
+ strict: {
43
+ type: 'boolean',
44
+ short: 's',
45
+ },
46
+ template: {
47
+ type: 'string',
48
+ short: 't',
49
+ },
23
50
  };
24
51
 
25
- export function compareEditorConfig(path = '.editorconfig') {
26
- const file = readFileSync(path).toString();
27
- if (editorconfigContent === file) {
28
- console.log('✅ Editorconfig is matching the expected configuration')
52
+ export const NOT_PROVIDED = Symbol('languages-not-provided');
53
+
54
+ export function parseLanguages(raw) {
55
+ if (typeof raw !== 'string') {
56
+ return NOT_PROVIDED;
29
57
  }
30
- else {
31
- console.log('❌ Editorconfig is not matching the expected configuration');
58
+ return raw
59
+ .split(',')
60
+ .map((token) => token.trim().toLowerCase())
61
+ .filter((token) => token.length > 0);
62
+ }
63
+
64
+ function formatAliasList() {
65
+ const grouped = new Map();
66
+ for (const [alias, target] of Object.entries(ALIASES)) {
67
+ const list = grouped.get(target) ?? [];
68
+ list.push(alias);
69
+ grouped.set(target, list);
32
70
  }
71
+ return [...grouped.entries()]
72
+ .map(([target, aliases]) => `${aliases.join(', ')} -> ${target}`)
73
+ .join('; ');
74
+ }
75
+
76
+ export function printHelp() {
77
+ console.log(`Usage: editorconfig --mode=<command> [--path=<path>] [--languages=<list>] [--template=<path|url>]
78
+
79
+ Commands:
80
+ write Create a .editorconfig file with the selected language sections
81
+ check Validate per-section against the canonical templates
82
+
83
+ Options:
84
+ -m, --mode Command to run (write | check)
85
+ -p, --path Path to the .editorconfig file (default: .editorconfig)
86
+ -l, --languages Comma-separated language sections (write: which to emit; check: required set)
87
+ -o, --overwrite Overwrite an existing .editorconfig without confirmation
88
+ -s, --strict Treat unknown section headers as failures (check only)
89
+ -t, --template Path or https URL to a custom .editorconfig-syntax file whose sections override the built-in ones
90
+ -h, --help Show this help message
91
+
92
+ Languages: ${AVAILABLE_LANGUAGES.join(', ')}
93
+ Aliases: ${formatAliasList()}
94
+
95
+ Examples:
96
+ editorconfig --mode=write --languages=js,md
97
+ editorconfig --mode=write --languages= # base only
98
+ editorconfig --mode=write # interactive in TTY, base only otherwise
99
+ editorconfig --mode=check # validate sections present in the file
100
+ editorconfig --mode=check --languages=js,md # require exactly base + js + md
101
+ editorconfig --mode=check --strict # fail on any unknown section header
102
+ editorconfig --mode=write --template=./team.editorconfig
103
+ editorconfig --mode=check --template=./team.editorconfig
104
+ editorconfig --mode=check --template=https://team.example.com/.editorconfig`);
33
105
  };
34
106
 
35
- function main() {
36
- const args = process.argv.slice(2);
37
- const options = {
38
- mode: {
39
- type: 'string',
40
- short: 'm',
41
- },
42
- path: {
43
- type: 'string',
44
- short: 'p',
45
- },
46
- };
47
- const { values } = parseArgs({ args, options });
48
- const path = values.path || '.editorconfig'
49
- if (values.mode === 'write') {
50
- createEditorConfig(path);
51
- }
52
- else if (values.mode === 'check') {
53
- compareEditorConfig(path);
107
+ function resolvePromptAnswer(answer) {
108
+ const tokens = answer
109
+ .split(',')
110
+ .map((token) => token.trim().toLowerCase())
111
+ .filter((token) => token.length > 0);
112
+ return tokens.map((token) => {
113
+ const asNumber = Number.parseInt(token, 10);
114
+ if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= AVAILABLE_LANGUAGES.length) {
115
+ return AVAILABLE_LANGUAGES[asNumber - 1];
116
+ }
117
+ return token;
118
+ });
119
+ }
120
+
121
+ function promptLanguages() {
122
+ return new Promise((resolve) => {
123
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
124
+ const numbered = AVAILABLE_LANGUAGES
125
+ .map((name, index) => ` ${index + 1}. ${name}`)
126
+ .join('\n');
127
+ console.log(`Available language sections:\n${numbered}`);
128
+ rl.question('Languages? [comma-separated names or indices, blank=base only] ', (answer) => {
129
+ rl.close();
130
+ resolve(resolvePromptAnswer(answer));
131
+ });
132
+ });
133
+ }
134
+
135
+ function resolveLanguages(parsedLanguages) {
136
+ if (parsedLanguages !== NOT_PROVIDED) {
137
+ return Promise.resolve(parsedLanguages);
138
+ }
139
+ if (process.stdin.isTTY) {
140
+ return promptLanguages();
141
+ }
142
+ return Promise.resolve([]);
143
+ }
144
+
145
+ function confirmOverwrite(path) {
146
+ return new Promise((resolve) => {
147
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
148
+ rl.question(`\`${path}\` already exists. Overwrite? [y/N] `, (answer) => {
149
+ rl.close();
150
+ resolve(/^y(es)?$/iu.test(answer.trim()));
151
+ });
152
+ });
153
+ }
154
+
155
+ async function handleExistingTarget(path, languages, overrides) {
156
+ if (!process.stdin.isTTY) {
157
+ console.error(`\`${path}\` already exists. Use --overwrite to replace it.`);
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ const confirmed = await confirmOverwrite(path);
162
+ if (confirmed) {
163
+ createEditorConfig(path, languages, overrides);
54
164
  }
55
165
  else {
56
- console.error('invalide commande');
166
+ console.log(`Skipped: \`${path}\` was not modified.`);
167
+ }
168
+ }
169
+
170
+ async function runWrite({ path, overwrite, parsedLanguages, overrides }) {
171
+ const languages = await resolveLanguages(parsedLanguages);
172
+ if (!existsSync(path) || overwrite) {
173
+ createEditorConfig(path, languages, overrides);
174
+ return;
175
+ }
176
+ await handleExistingTarget(path, languages, overrides);
177
+ }
178
+
179
+ function dispatchCheck({ path, languages, strict, overrides }) {
180
+ let filter = languages;
181
+ if (filter === NOT_PROVIDED) {
182
+ filter = NO_LANGUAGE_FILTER;
183
+ }
184
+ try {
185
+ runCheck({ path, parsedLanguages: filter, strict, overrides });
186
+ }
187
+ catch (error) {
188
+ console.error(error.message);
189
+ process.exitCode = 1;
57
190
  }
191
+ }
192
+
193
+ async function dispatchWrite({ path, overwrite, languages, overrides }) {
194
+ try {
195
+ await runWrite({ path, overwrite, parsedLanguages: languages, overrides });
196
+ }
197
+ catch (error) {
198
+ console.error(error.message);
199
+ process.exitCode = 1;
200
+ }
201
+ }
202
+
203
+ async function runCommand({ mode, path, overwrite, languages, strict, overrides }) {
204
+ if (mode === 'write') {
205
+ await dispatchWrite({ path, overwrite, languages, overrides });
206
+ return;
207
+ }
208
+ if (mode === 'check') {
209
+ dispatchCheck({ path, languages, strict, overrides });
210
+ return;
211
+ }
212
+ console.error('invalid command');
213
+ process.exitCode = 1;
214
+ }
215
+
216
+ function formatCliError(error) {
217
+ if (error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
218
+ return `${error.message.split('.')[0]}. See --help.`;
219
+ }
220
+ return error.message;
221
+ }
222
+
223
+ function parseCliArgs(args) {
224
+ try {
225
+ return parseArgs({ args, options });
226
+ }
227
+ catch (error) {
228
+ console.error(formatCliError(error));
229
+ process.exitCode = 1;
230
+ return false;
231
+ }
232
+ }
233
+
234
+ const OVERRIDES_FAILED = Symbol('overrides-failed');
235
+
236
+ async function resolveOverrides(templateValue) {
237
+ if (typeof templateValue !== 'string') {
238
+ return EMPTY_OVERRIDES;
239
+ }
240
+ try {
241
+ return await loadCustomTemplate(templateValue);
242
+ }
243
+ catch (error) {
244
+ console.error(error.message);
245
+ process.exitCode = 1;
246
+ return OVERRIDES_FAILED;
247
+ }
248
+ }
249
+
250
+ async function dispatchValues(values) {
251
+ const overrides = await resolveOverrides(values.template);
252
+ if (overrides === OVERRIDES_FAILED) {
253
+ return;
254
+ }
255
+ await runCommand({
256
+ mode: values.mode,
257
+ path: values.path || '.editorconfig',
258
+ overwrite: values.overwrite,
259
+ languages: parseLanguages(values.languages),
260
+ strict: values.strict,
261
+ overrides,
262
+ });
263
+ }
264
+
265
+ async function main() {
266
+ const parsed = parseCliArgs(process.argv.slice(2));
267
+ if (!parsed) {
268
+ return;
269
+ }
270
+ if (parsed.values.help) {
271
+ printHelp();
272
+ return;
273
+ }
274
+ await dispatchValues(parsed.values);
58
275
  };
59
276
 
60
- main();
277
+ if (process.argv[1] === import.meta.filename) {
278
+ try {
279
+ await main();
280
+ }
281
+ catch (error) {
282
+ console.error(error.message);
283
+ process.exitCode = 1;
284
+ }
285
+ }
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "@sheplu/editorconfig",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "src/"
9
+ ],
6
10
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
11
+ "lint": "oxlint",
12
+ "test": "node --test",
13
+ "test:unit": "node --test 'test/unit/*.test.js'",
14
+ "test:integration": "node --test 'test/integration/*.test.js'"
8
15
  },
9
16
  "bin": {
10
17
  "@sheplu/editorconfig": "index.js"
@@ -24,8 +31,15 @@
24
31
  "author": "Jean Burellier",
25
32
  "license": "MIT",
26
33
  "type": "module",
34
+ "engines": {
35
+ "node": ">=24"
36
+ },
27
37
  "bugs": {
28
38
  "url": "https://github.com/sheplu/editorconfig/issues"
29
39
  },
30
- "homepage": "https://github.com/sheplu/editorconfig#readme"
40
+ "homepage": "https://github.com/sheplu/editorconfig#readme",
41
+ "devDependencies": {
42
+ "node-pty": "1.0.0",
43
+ "oxlint": "^1.64.0"
44
+ }
31
45
  }
package/src/check.js ADDED
@@ -0,0 +1,175 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import {
3
+ BASE_SECTION_HEADER,
4
+ compareSection,
5
+ EMPTY_OVERRIDES,
6
+ expectedBodyForLanguage,
7
+ headerToLanguage,
8
+ languageToHeader,
9
+ parseSections,
10
+ resolveLanguageNames,
11
+ } from './templates/index.js';
12
+
13
+ export const NO_LANGUAGE_FILTER = Symbol('check-no-language-filter');
14
+
15
+ const STATUS_GLYPH = {
16
+ match: '✅',
17
+ mismatch: '❌',
18
+ missing: '❌',
19
+ 'no-root': '❌',
20
+ unknown: '⚠️ ',
21
+ };
22
+
23
+ const STATUS_DETAIL = {
24
+ match: '',
25
+ mismatch: 'section body does not match',
26
+ missing: 'missing',
27
+ 'no-root': "missing 'root = true' before sections",
28
+ unknown: 'unknown header (not validated)',
29
+ };
30
+
31
+ function languageForHeader(header) {
32
+ if (header === BASE_SECTION_HEADER) {
33
+ return 'base';
34
+ }
35
+ return headerToLanguage(header);
36
+ }
37
+
38
+ function checkSection(section, overrides) {
39
+ const language = languageForHeader(section.header);
40
+ if (!language) {
41
+ return { header: section.header, status: 'unknown' };
42
+ }
43
+ const expected = expectedBodyForLanguage(language, overrides);
44
+ const { ok } = compareSection(section.body, expected);
45
+ if (ok) {
46
+ return { header: section.header, status: 'match' };
47
+ }
48
+ return { header: section.header, status: 'mismatch' };
49
+ }
50
+
51
+ function buildExpectedHeaders(parsedLanguages) {
52
+ const resolved = resolveLanguageNames(parsedLanguages);
53
+ return [BASE_SECTION_HEADER, ...resolved.map((name) => languageToHeader(name))];
54
+ }
55
+
56
+ function buildResults({ parsedLanguages, parsed, overrides }) {
57
+ const results = parsed.sections.map((section) => checkSection(section, overrides));
58
+ if (parsedLanguages !== NO_LANGUAGE_FILTER) {
59
+ const present = new Set(parsed.sections.map((section) => section.header));
60
+ const expectedHeaders = buildExpectedHeaders(parsedLanguages);
61
+ for (const header of expectedHeaders) {
62
+ if (!present.has(header)) {
63
+ results.push({ header, status: 'missing' });
64
+ }
65
+ }
66
+ }
67
+ return results;
68
+ }
69
+
70
+ function buildBaseIssues(parsed) {
71
+ const issues = [];
72
+ const hasBase = parsed.sections.some((section) => section.header === BASE_SECTION_HEADER);
73
+ if (!hasBase) {
74
+ issues.push({ header: BASE_SECTION_HEADER, status: 'missing' });
75
+ }
76
+ else if (!parsed.hasRoot) {
77
+ issues.push({ header: BASE_SECTION_HEADER, status: 'no-root' });
78
+ }
79
+ return issues;
80
+ }
81
+
82
+ export function compareEditorConfig(path = '.editorconfig', parsedLanguages = NO_LANGUAGE_FILTER, overrides = EMPTY_OVERRIDES) {
83
+ if (!existsSync(path)) {
84
+ throw new Error(`'${path}' does not exist`);
85
+ }
86
+ const text = readFileSync(path, 'utf8');
87
+ const parsed = parseSections(text);
88
+ const baseIssues = buildBaseIssues(parsed);
89
+ const results = buildResults({ parsedLanguages, parsed, overrides });
90
+ return { baseIssues, results };
91
+ }
92
+
93
+ function formatLine({ header, status }) {
94
+ const glyph = STATUS_GLYPH[status];
95
+ const detail = STATUS_DETAIL[status];
96
+ if (detail) {
97
+ return ` ${glyph} ${header} ${detail}`;
98
+ }
99
+ return ` ${glyph} ${header}`;
100
+ }
101
+
102
+ export function reportIsFailing({ baseIssues, results }, strict) {
103
+ const failing = [...baseIssues, ...results].some((entry) =>
104
+ entry.status === 'mismatch' || entry.status === 'missing' || entry.status === 'no-root',
105
+ );
106
+ const hasUnknown = results.some((entry) => entry.status === 'unknown');
107
+ return failing || (strict && hasUnknown);
108
+ }
109
+
110
+ function printReport(path, report) {
111
+ console.log(`Checking ${path}`);
112
+ console.log('');
113
+ for (const issue of report.baseIssues) {
114
+ console.log(formatLine(issue));
115
+ }
116
+ for (const entry of report.results) {
117
+ console.log(formatLine(entry));
118
+ }
119
+ console.log('');
120
+ }
121
+
122
+ function summarize(report) {
123
+ const lines = [...report.baseIssues, ...report.results];
124
+ const total = lines.length;
125
+ const matched = lines.filter((entry) => entry.status === 'match').length;
126
+ const failed = lines.filter((entry) =>
127
+ entry.status === 'mismatch' || entry.status === 'missing' || entry.status === 'no-root',
128
+ ).length;
129
+ const unknown = lines.filter((entry) => entry.status === 'unknown').length;
130
+ return { total, matched, failed, unknown };
131
+ }
132
+
133
+ function pluralize(count, singular) {
134
+ if (count === 1) {
135
+ return singular;
136
+ }
137
+ return `${singular}s`;
138
+ }
139
+
140
+ function formatFailureSummary({ total, failed, unknown }) {
141
+ const reasons = [];
142
+ if (failed > 0) {
143
+ reasons.push(`${failed} of ${total} ${pluralize(total, 'section')} did not match`);
144
+ }
145
+ if (unknown > 0) {
146
+ reasons.push(`${unknown} unknown ${pluralize(unknown, 'header')}`);
147
+ }
148
+ return `❌ FAIL — ${reasons.join('; ')}`;
149
+ }
150
+
151
+ function formatPassSummary({ matched, unknown }) {
152
+ const matchedLabel = `${matched} ${pluralize(matched, 'section')} matched`;
153
+ if (unknown > 0) {
154
+ return `✅ PASS — ${matchedLabel} (${unknown} unknown ${pluralize(unknown, 'header')} ignored)`;
155
+ }
156
+ return `✅ PASS — ${matchedLabel}`;
157
+ }
158
+
159
+ function formatSummary(report, isFailure) {
160
+ const counts = summarize(report);
161
+ if (isFailure) {
162
+ return formatFailureSummary(counts);
163
+ }
164
+ return formatPassSummary(counts);
165
+ }
166
+
167
+ export function runCheck({ path, parsedLanguages, strict, overrides }) {
168
+ const report = compareEditorConfig(path, parsedLanguages, overrides);
169
+ printReport(path, report);
170
+ const failed = reportIsFailing(report, strict);
171
+ console.log(formatSummary(report, failed));
172
+ if (failed) {
173
+ process.exitCode = 1;
174
+ }
175
+ }
@@ -1,4 +1,4 @@
1
- root = true
1
+ export const base = `root = true
2
2
 
3
3
  [*]
4
4
  indent_style = tab
@@ -11,3 +11,4 @@ trim_trailing_whitespace = true
11
11
  insert_final_newline = true
12
12
  quote_type = single
13
13
  spaces_around_operators = true
14
+ `;
@@ -0,0 +1,4 @@
1
+ export const css = `[*.{css,scss,sass,less}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,123 @@
1
+ import {
2
+ AVAILABLE_LANGUAGES,
3
+ BASE_SECTION_HEADER,
4
+ headerToLanguage,
5
+ languageToHeader,
6
+ parseSections,
7
+ } from './index.js';
8
+ import { readTemplateText } from './template-source.js';
9
+
10
+ const HEADER_LINE = /^\[.*\]$/u;
11
+ const NO_HEADER = '';
12
+
13
+ function knownHeaders() {
14
+ return [BASE_SECTION_HEADER, ...AVAILABLE_LANGUAGES.map((name) => languageToHeader(name))];
15
+ }
16
+
17
+ function languageForHeader(header) {
18
+ if (header === BASE_SECTION_HEADER) {
19
+ return 'base';
20
+ }
21
+ return headerToLanguage(header);
22
+ }
23
+
24
+ function trimTrailingBlankLines(lines) {
25
+ while (lines.length > 0 && lines.at(-1).trim() === '') {
26
+ lines.pop();
27
+ }
28
+ }
29
+
30
+ function flushBlock(state, blocks) {
31
+ if (state.header === NO_HEADER) {
32
+ return;
33
+ }
34
+ trimTrailingBlankLines(state.lines);
35
+ blocks.set(state.header, [state.header, ...state.lines].join('\n'));
36
+ }
37
+
38
+ function processLine(state, blocks, line) {
39
+ const trimmed = line.trim();
40
+ if (HEADER_LINE.test(trimmed)) {
41
+ flushBlock(state, blocks);
42
+ state.header = trimmed;
43
+ state.lines = [];
44
+ return;
45
+ }
46
+ if (state.header !== NO_HEADER) {
47
+ state.lines.push(line);
48
+ }
49
+ }
50
+
51
+ function extractRawSections(text) {
52
+ const normalized = text.replaceAll(/\r\n?/gu, '\n');
53
+ const blocks = new Map();
54
+ const state = { header: NO_HEADER, lines: [] };
55
+ for (const line of normalized.split('\n')) {
56
+ processLine(state, blocks, line);
57
+ }
58
+ flushBlock(state, blocks);
59
+ return blocks;
60
+ }
61
+
62
+ function rejectUnknownHeader(path, header) {
63
+ throw new Error(
64
+ `custom template '${path}' has unknown header '${header}'. Allowed: ${knownHeaders().join(', ')}`,
65
+ );
66
+ }
67
+
68
+ function rejectDuplicate(path, header) {
69
+ throw new Error(
70
+ `custom template '${path}' declares header '${header}' more than once`,
71
+ );
72
+ }
73
+
74
+ function ingestSection({ path, section, accumulator }) {
75
+ const language = languageForHeader(section.header);
76
+ if (!language) {
77
+ rejectUnknownHeader(path, section.header);
78
+ return;
79
+ }
80
+ if (accumulator.seen.has(language)) {
81
+ rejectDuplicate(path, section.header);
82
+ return;
83
+ }
84
+ accumulator.seen.add(language);
85
+ accumulator.bodies.set(language, section.body);
86
+ }
87
+
88
+ function buildBodies(path, sections) {
89
+ const accumulator = { bodies: new Map(), seen: new Set() };
90
+ for (const section of sections) {
91
+ ingestSection({ path, section, accumulator });
92
+ }
93
+ return accumulator.bodies;
94
+ }
95
+
96
+ function rawSectionFor(language, rawBlocks) {
97
+ if (language === 'base') {
98
+ return `root = true\n\n${rawBlocks.get(BASE_SECTION_HEADER)}`;
99
+ }
100
+ return rawBlocks.get(languageToHeader(language));
101
+ }
102
+
103
+ function buildRawSections(bodies, text) {
104
+ const rawBlocks = extractRawSections(text);
105
+ const rawSections = new Map();
106
+ for (const language of bodies.keys()) {
107
+ rawSections.set(language, rawSectionFor(language, rawBlocks));
108
+ }
109
+ return rawSections;
110
+ }
111
+
112
+ export async function loadCustomTemplate(input) {
113
+ const text = await readTemplateText(input);
114
+ const parsed = parseSections(text);
115
+ const bodies = buildBodies(input, parsed.sections);
116
+ if (bodies.has('base') && !parsed.hasRoot) {
117
+ throw new Error(
118
+ `custom template '${input}' redefines [*] but is missing 'root = true' in the preamble`,
119
+ );
120
+ }
121
+ const rawSections = buildRawSections(bodies, text);
122
+ return { bodies, rawSections, hasRoot: parsed.hasRoot };
123
+ }
@@ -0,0 +1,4 @@
1
+ export const dockerfile = `[{Dockerfile,Dockerfile.*,*.dockerfile}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,5 @@
1
+ export const go = `[*.go]
2
+ indent_style = tab
3
+ indent_size = 4
4
+ tab_width = 4
5
+ `;
@@ -0,0 +1,4 @@
1
+ export const html = `[*.{html,htm}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,277 @@
1
+ import { base } from './base.js';
2
+ import { javascript } from './javascript.js';
3
+ import { yaml } from './yaml.js';
4
+ import { markdown } from './markdown.js';
5
+ import { python } from './python.js';
6
+ import { go } from './go.js';
7
+ import { rust } from './rust.js';
8
+ import { terraform } from './terraform.js';
9
+ import { json } from './json.js';
10
+ import { toml } from './toml.js';
11
+ import { shell } from './shell.js';
12
+ import { makefile } from './makefile.js';
13
+ import { dockerfile } from './dockerfile.js';
14
+ import { html } from './html.js';
15
+ import { css } from './css.js';
16
+
17
+ export {
18
+ base,
19
+ javascript,
20
+ yaml,
21
+ markdown,
22
+ python,
23
+ go,
24
+ rust,
25
+ terraform,
26
+ json,
27
+ toml,
28
+ shell,
29
+ makefile,
30
+ dockerfile,
31
+ html,
32
+ css,
33
+ };
34
+
35
+ export const AVAILABLE_LANGUAGES = [
36
+ 'javascript',
37
+ 'yaml',
38
+ 'markdown',
39
+ 'python',
40
+ 'go',
41
+ 'rust',
42
+ 'terraform',
43
+ 'json',
44
+ 'toml',
45
+ 'shell',
46
+ 'makefile',
47
+ 'dockerfile',
48
+ 'html',
49
+ 'css',
50
+ ];
51
+
52
+ export const ALIASES = {
53
+ js: 'javascript',
54
+ jsx: 'javascript',
55
+ ts: 'javascript',
56
+ tsx: 'javascript',
57
+ mjs: 'javascript',
58
+ cjs: 'javascript',
59
+ yml: 'yaml',
60
+ md: 'markdown',
61
+ py: 'python',
62
+ rs: 'rust',
63
+ tf: 'terraform',
64
+ tfvars: 'terraform',
65
+ sh: 'shell',
66
+ bash: 'shell',
67
+ zsh: 'shell',
68
+ mk: 'makefile',
69
+ htm: 'html',
70
+ scss: 'css',
71
+ sass: 'css',
72
+ less: 'css',
73
+ };
74
+
75
+ const LANGUAGE_TEMPLATES = {
76
+ javascript,
77
+ yaml,
78
+ markdown,
79
+ python,
80
+ go,
81
+ rust,
82
+ terraform,
83
+ json,
84
+ toml,
85
+ shell,
86
+ makefile,
87
+ dockerfile,
88
+ html,
89
+ css,
90
+ };
91
+
92
+ function joinSections(sections) {
93
+ return `${sections
94
+ .map((section) => section.replace(/\n+$/u, ''))
95
+ .join('\n\n')}\n`;
96
+ }
97
+
98
+ export const EMPTY_OVERRIDES = Object.freeze({
99
+ bodies: new Map(),
100
+ rawSections: new Map(),
101
+ hasRoot: false,
102
+ });
103
+
104
+ function pickSection(name, builtin, overrides) {
105
+ if (overrides.rawSections.has(name)) {
106
+ return overrides.rawSections.get(name);
107
+ }
108
+ return builtin;
109
+ }
110
+
111
+ export function composeEditorConfig(languageNames = [], overrides = EMPTY_OVERRIDES) {
112
+ const resolved = new Set();
113
+ for (const raw of languageNames) {
114
+ const name = ALIASES[raw] ?? raw;
115
+ if (!Object.hasOwn(LANGUAGE_TEMPLATES, name)) {
116
+ throw new Error(
117
+ `unknown language: '${raw}'. Available: ${AVAILABLE_LANGUAGES.join(', ')}`,
118
+ );
119
+ }
120
+ resolved.add(name);
121
+ }
122
+ const ordered = AVAILABLE_LANGUAGES.filter((name) => resolved.has(name));
123
+ const sections = [
124
+ pickSection('base', base, overrides),
125
+ ...ordered.map((name) => pickSection(name, LANGUAGE_TEMPLATES[name], overrides)),
126
+ ];
127
+ return joinSections(sections);
128
+ }
129
+
130
+ export const editorconfigContent = composeEditorConfig(AVAILABLE_LANGUAGES);
131
+
132
+ const BASE_HEADER = '[*]';
133
+
134
+ function extractHeader(template) {
135
+ return template.split('\n', 1)[0];
136
+ }
137
+
138
+ const HEADER_TO_LANGUAGE = new Map(
139
+ AVAILABLE_LANGUAGES.map((name) => [extractHeader(LANGUAGE_TEMPLATES[name]), name]),
140
+ );
141
+
142
+ export function headerToLanguage(header) {
143
+ return HEADER_TO_LANGUAGE.get(header);
144
+ }
145
+
146
+ function isIgnoredLine(line) {
147
+ if (line === '' || line.startsWith('#') || line.startsWith(';')) {
148
+ return true;
149
+ }
150
+ if (line.startsWith('[') && line.endsWith(']')) {
151
+ return true;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ function applyKeyValue(body, line) {
157
+ const equalsAt = line.indexOf('=');
158
+ if (equalsAt === -1) {
159
+ return;
160
+ }
161
+ const key = line.slice(0, equalsAt).trim().toLowerCase();
162
+ if (key.length === 0) {
163
+ return;
164
+ }
165
+ body.set(key, line.slice(equalsAt + 1).trim());
166
+ }
167
+
168
+ export function parseSection(block) {
169
+ const body = new Map();
170
+ for (const rawLine of block.split('\n')) {
171
+ const line = rawLine.trim();
172
+ if (!isIgnoredLine(line)) {
173
+ applyKeyValue(body, line);
174
+ }
175
+ }
176
+ return body;
177
+ }
178
+
179
+ function finishSection({ header, lines }) {
180
+ return {
181
+ header,
182
+ body: parseSection(lines.join('\n')),
183
+ };
184
+ }
185
+
186
+ function processLine(state, line) {
187
+ const trimmed = line.trim();
188
+ if (/^\[.*\]$/u.test(trimmed)) {
189
+ if (state.current) {
190
+ state.sections.push(finishSection(state.current));
191
+ }
192
+ state.current = { header: trimmed, lines: [] };
193
+ }
194
+ else if (state.current) {
195
+ state.current.lines.push(line);
196
+ }
197
+ else {
198
+ state.preamble.push(line);
199
+ }
200
+ }
201
+
202
+ function splitSections(lines) {
203
+ const state = { sections: [], preamble: [], current: false };
204
+ for (const line of lines) {
205
+ processLine(state, line);
206
+ }
207
+ if (state.current) {
208
+ state.sections.push(finishSection(state.current));
209
+ }
210
+ return { sections: state.sections, preamble: state.preamble };
211
+ }
212
+
213
+ export function parseSections(text) {
214
+ const normalized = text.replaceAll(/\r\n?/gu, '\n');
215
+ const { sections, preamble } = splitSections(normalized.split('\n'));
216
+ const hasRoot = preamble.some((line) => /^\s*root\s*=\s*true\s*$/iu.test(line));
217
+ return { hasRoot, sections };
218
+ }
219
+
220
+ export function compareSection(actualBody, expectedBody) {
221
+ if (actualBody.size !== expectedBody.size) {
222
+ return { ok: false };
223
+ }
224
+ for (const [key, value] of expectedBody) {
225
+ if (!actualBody.has(key) || actualBody.get(key) !== value) {
226
+ return { ok: false };
227
+ }
228
+ }
229
+ return { ok: true };
230
+ }
231
+
232
+ function bodyAfterHeader(template) {
233
+ const headerEnd = template.indexOf('\n');
234
+ return template.slice(headerEnd + 1);
235
+ }
236
+
237
+ function bodyAfterFirstHeader(template) {
238
+ const lines = template.split('\n');
239
+ const headerIndex = lines.findIndex((line) => /^\[.*\]$/u.test(line.trim()));
240
+ if (headerIndex === -1) {
241
+ return template;
242
+ }
243
+ return lines.slice(headerIndex + 1).join('\n');
244
+ }
245
+
246
+ export function expectedBodyForLanguage(language, overrides = EMPTY_OVERRIDES) {
247
+ if (overrides.bodies.has(language)) {
248
+ return overrides.bodies.get(language);
249
+ }
250
+ if (language === 'base') {
251
+ return parseSection(bodyAfterFirstHeader(base));
252
+ }
253
+ return parseSection(bodyAfterHeader(LANGUAGE_TEMPLATES[language]));
254
+ }
255
+
256
+ export function resolveLanguageNames(languageNames) {
257
+ const resolved = new Set();
258
+ for (const raw of languageNames) {
259
+ const name = ALIASES[raw] ?? raw;
260
+ if (!Object.hasOwn(LANGUAGE_TEMPLATES, name)) {
261
+ throw new Error(
262
+ `unknown language: '${raw}'. Available: ${AVAILABLE_LANGUAGES.join(', ')}`,
263
+ );
264
+ }
265
+ resolved.add(name);
266
+ }
267
+ return AVAILABLE_LANGUAGES.filter((name) => resolved.has(name));
268
+ }
269
+
270
+ export function languageToHeader(language) {
271
+ if (language === 'base') {
272
+ return BASE_HEADER;
273
+ }
274
+ return extractHeader(LANGUAGE_TEMPLATES[language]);
275
+ }
276
+
277
+ export const BASE_SECTION_HEADER = BASE_HEADER;
@@ -0,0 +1,6 @@
1
+ export const javascript = `[*.{js,jsx,ts,tsx,mjs,cjs}]
2
+ indent_style = tab
3
+ indent_size = 4
4
+ tab_width = 4
5
+ quote_type = single
6
+ `;
@@ -0,0 +1,4 @@
1
+ export const json = `[*.json]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,5 @@
1
+ export const makefile = `[{Makefile,GNUmakefile,makefile,*.mk}]
2
+ indent_style = tab
3
+ indent_size = 4
4
+ tab_width = 4
5
+ `;
@@ -0,0 +1,5 @@
1
+ export const markdown = `[*.md]
2
+ indent_style = space
3
+ indent_size = 2
4
+ trim_trailing_whitespace = false
5
+ `;
@@ -0,0 +1,5 @@
1
+ export const python = `[*.py]
2
+ indent_style = space
3
+ indent_size = 4
4
+ max_line_length = 88
5
+ `;
@@ -0,0 +1,5 @@
1
+ export const rust = `[*.rs]
2
+ indent_style = space
3
+ indent_size = 4
4
+ max_line_length = 100
5
+ `;
@@ -0,0 +1,4 @@
1
+ export const shell = `[*.{sh,bash,zsh}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,24 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { fetchTemplate } from '../utils/fetch.js';
3
+ import { isHttpScheme, tryParseUrl } from '../utils/url.js';
4
+
5
+ function readLocalFile(path) {
6
+ if (!existsSync(path)) {
7
+ throw new Error(`custom template '${path}' does not exist`);
8
+ }
9
+ return readFileSync(path, 'utf8');
10
+ }
11
+
12
+ export async function readTemplateText(input) {
13
+ if (typeof input !== 'string' || input.trim() === '') {
14
+ throw new Error('--template requires a path or URL to a custom template');
15
+ }
16
+ const url = tryParseUrl(input);
17
+ if (url && isHttpScheme(url)) {
18
+ if (url.protocol !== 'https:') {
19
+ throw new Error(`--template URL must use https (got '${url.protocol.replace(':', '')}')`);
20
+ }
21
+ return await fetchTemplate(url.href);
22
+ }
23
+ return readLocalFile(input);
24
+ }
@@ -0,0 +1,4 @@
1
+ export const terraform = `[*.{tf,tfvars}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,4 @@
1
+ export const toml = `[*.toml]
2
+ indent_style = space
3
+ indent_size = 4
4
+ `;
@@ -0,0 +1,4 @@
1
+ export const yaml = `[*.{yml,yaml}]
2
+ indent_style = space
3
+ indent_size = 2
4
+ `;
@@ -0,0 +1,104 @@
1
+ import { parseRedirectLocation } from './url.js';
2
+
3
+ const TIMEOUT_MS = 10_000;
4
+ const MAX_BYTES = 1_048_576;
5
+ const MAX_REDIRECTS = 5;
6
+
7
+ function ignoreCancelError() {
8
+ return false;
9
+ }
10
+
11
+ export function isRedirect(status) {
12
+ return status >= 300 && status < 400;
13
+ }
14
+
15
+ export function rejectOversized(url) {
16
+ throw new Error(`template body for '${url}' exceeds 1 MB limit`);
17
+ }
18
+
19
+ export function checkContentLength(response, url) {
20
+ const header = response.headers.get('content-length');
21
+ if (header === null) {
22
+ return;
23
+ }
24
+ const bytes = Number.parseInt(header, 10);
25
+ if (Number.isFinite(bytes) && bytes > MAX_BYTES) {
26
+ rejectOversized(url);
27
+ }
28
+ }
29
+
30
+ export function decodeChunks(chunks) {
31
+ return new TextDecoder('utf-8').decode(Buffer.concat(chunks));
32
+ }
33
+
34
+ export function appendChunk({ state, value, reader, url }) {
35
+ state.total += value.byteLength;
36
+ if (state.total > MAX_BYTES) {
37
+ // We're about to throw rejectOversized; swallow any cancel() rejection.
38
+ reader.cancel().catch(ignoreCancelError);
39
+ rejectOversized(url);
40
+ }
41
+ state.chunks.push(value);
42
+ }
43
+
44
+ export async function consumeStream(reader, url) {
45
+ const state = { chunks: [], total: 0 };
46
+ for (;;) {
47
+ // eslint-disable-next-line no-await-in-loop
48
+ const { value, done } = await reader.read();
49
+ if (done) {
50
+ return decodeChunks(state.chunks);
51
+ }
52
+ appendChunk({ state, value, reader, url });
53
+ }
54
+ }
55
+
56
+ export async function readBoundedBody(response, url) {
57
+ checkContentLength(response, url);
58
+ return await consumeStream(response.body.getReader(), url);
59
+ }
60
+
61
+ export function resolveRedirect(response, currentUrl) {
62
+ const location = response.headers.get('location');
63
+ return parseRedirectLocation(location, currentUrl);
64
+ }
65
+
66
+ export async function performFetch(initialUrl, signal) {
67
+ let url = initialUrl;
68
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
69
+ // eslint-disable-next-line no-await-in-loop
70
+ const response = await fetch(url, { signal, redirect: 'manual' });
71
+ if (response.ok) {
72
+ return readBoundedBody(response, url);
73
+ }
74
+ if (!isRedirect(response.status)) {
75
+ throw new Error(`failed to fetch '${url}': HTTP ${response.status}`);
76
+ }
77
+ url = resolveRedirect(response, url);
78
+ }
79
+ throw new Error(`failed to fetch '${initialUrl}': too many redirects (max ${MAX_REDIRECTS})`);
80
+ }
81
+
82
+ export function describeFetchError(url, error) {
83
+ if (error.name === 'AbortError') {
84
+ return new Error(`failed to fetch '${url}': request timed out after ${TIMEOUT_MS / 1000}s`);
85
+ }
86
+ if (/^failed to fetch |^template body /u.test(error.message)) {
87
+ return error;
88
+ }
89
+ return new Error(`failed to fetch '${url}': ${error.message}`);
90
+ }
91
+
92
+ export async function fetchTemplate(url) {
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
95
+ try {
96
+ return await performFetch(url, controller.signal);
97
+ }
98
+ catch (error) {
99
+ throw describeFetchError(url, error);
100
+ }
101
+ finally {
102
+ clearTimeout(timer);
103
+ }
104
+ }
@@ -0,0 +1,23 @@
1
+ export function tryParseUrl(input) {
2
+ try {
3
+ return new URL(input);
4
+ }
5
+ catch {
6
+ return false;
7
+ }
8
+ }
9
+
10
+ export function isHttpScheme(url) {
11
+ return url.protocol === 'http:' || url.protocol === 'https:';
12
+ }
13
+
14
+ export function parseRedirectLocation(location, currentUrl) {
15
+ if (typeof location !== 'string' || location.length === 0) {
16
+ throw new Error(`failed to fetch '${currentUrl}': redirect missing Location header`);
17
+ }
18
+ const next = new URL(location, currentUrl);
19
+ if (next.protocol !== 'https:') {
20
+ throw new Error(`failed to fetch '${currentUrl}': refused redirect to non-https '${next.href}'`);
21
+ }
22
+ return next.href;
23
+ }
@@ -1,42 +0,0 @@
1
- name: Publish package
2
- on:
3
- release:
4
- types: [created]
5
-
6
- jobs:
7
- publish:
8
- runs-on: ubuntu-latest
9
- environment: prod
10
- permissions:
11
- contents: read
12
- id-token: write
13
-
14
- steps:
15
- - name: Checkout
16
- uses: actions/checkout@v5
17
-
18
- - name: Setup Node.js
19
- uses: actions/setup-node@v6
20
- with:
21
- node-version: 24
22
- registry-url: https://registry.npmjs.org
23
-
24
- - name: NPM install
25
- run: |
26
- npm install
27
-
28
- - name: Version
29
- if: github.event_name == 'release' && github.event.action == 'created'
30
- run: |
31
- VERSION=${{ github.event.release.tag_name }}
32
- VERSION=${VERSION:1}
33
- CURRENT_VERSION=$(npm pkg get version | tr -d '"')
34
- if [ "$CURRENT_VERSION" != "$VERSION" ]; then
35
- npm version $VERSION --no-git-tag-version
36
- else
37
- echo "Version already set to $VERSION, skipping npm version command"
38
- fi
39
-
40
- - name: publish
41
- run: |
42
- npm publish --access public