appease 0.0.1 → 0.0.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emilio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # appease
2
+
3
+
4
+ Make peace in this app.
5
+
6
+
7
+ ![designing](https://img.shields.io/badge/stability-designing-red.svg)
8
+ [![npm-version](https://img.shields.io/npm/v/appease.svg)](https://npmjs.org/package/appease)
9
+ [![downloads](https://img.shields.io/npm/dm/appease.svg)](https://npmjs.org/package/appease)
10
+ [![build](https://github.com/emilioplatzer/appease/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/emilioplatzer/appease/actions/workflows/build-and-test.yml)
11
+ [![security](https://socket.dev/api/badge/npm/package/appease)](https://socket.dev/npm/package/appease)
12
+ [![qa-control](https://github.com/emilioplatzer/appease/actions/workflows/qa-control.yml/badge.svg)](https://github.com/emilioplatzer/appease/actions/workflows/qa-control.yml)
13
+
14
+
15
+ language: ![English](https://raw.githubusercontent.com/codenautas/multilang/master/img/lang-en.png)
16
+ also available in:
17
+ [![Spanish](https://raw.githubusercontent.com/codenautas/multilang/master/img/lang-es.png)](LEEME.md)
18
+
19
+ A tool to bring order, **once**, to the low-level format of a repository's files (line
20
+ endings, BOM, trailing spaces, final newline, indentation), and to leave the convention
21
+ configured so that keeping it afterwards is free and diffs stay clean and humanly reviewable.
22
+
23
+ The underlying idea: honoring "the exact format" on every commit is an effort (especially on
24
+ Windows, where line endings get mixed on their own). If instead you normalize once and pin
25
+ the convention with `.gitattributes` + `.editorconfig` + `.vscode/settings.json`, the cost
26
+ disappears.
27
+
28
+ The name is a play on words between **normalize** and **appease** (to bring to peace).
29
+
30
+
31
+ ---
32
+
33
+ ## What it normalizes, and where each decision lives
34
+
35
+ Each format "axis" is controlled from **a single configuration file**, depending on which
36
+ tool is capable of handling it. To answer "what happens to this file on a given axis?" you
37
+ never have to look at two files.
38
+
39
+ | Axis | Who handles it | Config file |
40
+ |---|---|---|
41
+ | Line ending (EOL) | Git | `.gitattributes` |
42
+ | BOM / charset | editor + appease | `.editorconfig` |
43
+ | Trailing spaces | editor + appease | `.editorconfig` |
44
+ | Final newline | editor + appease | `.editorconfig` |
45
+ | Indentation (convention) | editor | `.editorconfig` |
46
+ | Show whitespace on selection | VSC | `.vscode/settings.json` |
47
+
48
+ ### Line ending (EOL) — handled by Git
49
+
50
+ It lives in `.gitattributes`, which is **per-repo and overrides Git's global configuration**,
51
+ so there's no need to touch anything in global Git (nor break other repos that rely on its
52
+ behavior).
53
+
54
+
55
+ ```gitattributes
56
+ # Default: on commit, Git normalizes to LF in the repo (EOL disappears from diffs);
57
+ # on checkout, it delivers the OS-native EOL (CRLF on Windows, LF on Linux).
58
+ * text=auto
59
+
60
+ # One-off exceptions, one by one:
61
+ path/to/file.crlf text eol=crlf # always CRLF, everywhere
62
+ path/to/file.lf text eol=lf # always LF, everywhere
63
+ path/to/file.raw -text # byte for byte: leave everything as-is (commit and checkout)
64
+ ```
65
+
66
+ Requirement (assumed on purpose): this default yields **native** EOL because each machine
67
+ resolves it via its `core.eol`, whose default value (when unset) is already `native`. appease
68
+ **does not touch Git's configuration** —neither global nor local— so native EOL remains an
69
+ environment requirement: it works as long as `core.eol` is not pinned to `lf`/`crlf`. You can
70
+ verify it, without changing anything, with `git config --get core.eol`.
71
+
72
+ When adopting or changing `.gitattributes`, already-committed files are not rewritten on
73
+ their own; a one-time pass is needed: `git add --renormalize .`.
74
+
75
+ ### BOM / charset — handled by `.editorconfig`
76
+
77
+ Git does **not** know how to add or remove the BOM; that's why this axis lives in
78
+ `.editorconfig` (which VSC honors live) and appease applies it.
79
+
80
+ - Default: **UTF-8 without BOM** (the sane choice for JS/TS/JSON/web).
81
+ - `charset = utf-8-bom` → with a fixed BOM (PowerShell 5.1 with non-ASCII, CSV for Excel, etc.).
82
+ - `charset = unset` → leave the BOM as-is, don't touch it.
83
+
84
+ ### Trailing spaces and final newline — `.editorconfig`
85
+
86
+ - `trim_trailing_whitespace = true` → trim (default). `= false` → leave them as-is
87
+ (typical in Markdown, where two trailing spaces are an intentional line break).
88
+ - `insert_final_newline = true` → guarantee exactly one (default). `= false` → leave
89
+ as-is.
90
+
91
+ ### Indentation — a convention for the editor, **not** a rewrite
92
+
93
+ For now indentation is treated as a **convention**, not as a mass rewrite (converting
94
+ tab↔spaces is structural, it depends on the language —Makefile *requires* tabs, Go uses tabs—
95
+ and it's exactly the most invasive change).
96
+
97
+ - `.editorconfig`: `indent_style = space` (to stop adding more tabs), with registered
98
+ exceptions (`[Makefile] indent_style = tab`, Go, etc.).
99
+ - `.vscode/settings.json`: `editor.renderWhitespace = "selection"`, so that whitespace is
100
+ shown **only when selecting** text (tabs appear as little arrows on demand; with no
101
+ selection nothing shows). It's pinned in the repo to guarantee that behavior for the whole
102
+ team, without depending on each person's VSC default.
103
+
104
+
105
+ ```json
106
+ {
107
+ "editor.renderWhitespace": "selection"
108
+ }
109
+ ```
110
+
111
+ The actual conversion of tabs stays **out** of this workflow for now (see `--tabs-*`).
112
+
113
+ ### Example `.editorconfig`
114
+
115
+
116
+ ```editorconfig
117
+ root = true
118
+
119
+ # Default for everything
120
+ [*]
121
+ charset = utf-8 # no BOM
122
+ trim_trailing_whitespace = true
123
+ insert_final_newline = true
124
+ indent_style = space
125
+
126
+ # Markdown: the two trailing spaces are intentional
127
+ [*.md]
128
+ trim_trailing_whitespace = false
129
+
130
+ # Examples of one-off exceptions
131
+ [test/fixtures/excel/**]
132
+ charset = utf-8-bom
133
+
134
+ [test/fixtures/raw/**]
135
+ charset = unset
136
+ trim_trailing_whitespace = false
137
+ insert_final_newline = false
138
+
139
+ [Makefile]
140
+ indent_style = tab
141
+ ```
142
+
143
+ ---
144
+
145
+ ## Modes
146
+
147
+ Every command prints at the end **which files it created or modified**.
148
+
149
+ Each command takes the directory to process as an optional positional argument
150
+ (`appease <command> [dir]`; defaults to the current directory).
151
+
152
+ | Command | Reads | Writes | Destructive |
153
+ |---|---|---|---|
154
+ | `audit` | existing configs (or the defaults it would propose) + the files | nothing, only **reports** what is out of spec | no |
155
+ | `add-config-defaults` | — | the configs with **pure defaults** (without looking at reality) | no (only creates config) |
156
+ | `adapt-configs` | configs + audit | creates or **adapts** the configs to reflect what was found | does not touch source code |
157
+ | `fix-format` | configs | **modifies the files** (BOM, trailing, newline; EOL via Git) honoring the configs | yes (Git reverts) |
158
+
159
+ ### `audit`: report format
160
+
161
+ `audit` prints canonical JSON with **two** lists. The key point is that conforming files
162
+ **appear in neither**: only the ones that need attention and the ones that couldn't be
163
+ evaluated are listed. A clean repo yields both lists empty:
164
+
165
+
166
+ ```json
167
+ {
168
+ "findings": [],
169
+ "notAnalyzed": []
170
+ }
171
+ ```
172
+
173
+ - **`findings`**: **analyzed files that deviate** from their resolved config. Each entry
174
+ carries `path`, `deviations` (axes that differ from what the config asks for) and
175
+ `unresolved` (axes governed by an unrecognized config value: not evaluated, reported as-is).
176
+ - **`notAnalyzed`**: files that were **not analyzed**, with their `reason`
177
+ (`binary-extension`, `binary-content`, `gitattributes-notext`, `non-utf8`).
178
+
179
+ This format is **provisional**: today the output is the direct serialization of the
180
+ `AuditResult` type, meant to be easy to parse and test. It may grow if the value justifies it.
181
+
182
+ ### `adapt-configs`: records every deviation as an exception
183
+
184
+ `adapt-configs` records **every** deviation it finds as an explicit exception, across all
185
+ axes equally (without classifying or guessing intent). This gives a safety invariant: **right
186
+ after `adapt-configs`, a `fix-format` changes nothing**, because the config describes
187
+ reality 100%. Only when you **prune** (delete) exceptions does `fix-format` touch *that and
188
+ only that*.
189
+
190
+ So, "everything is wrong and I want to fix it all at once" is solved by deleting the block of
191
+ exceptions: everything falls back to the default → `fix-format` rewrites whatever is needed.
192
+
193
+ Behavior details:
194
+
195
+ - A deviation can be **multi-axis** in a single file (CRLF + trailing + no final newline +
196
+ BOM). Its entry covers several properties; deleting it reverts that entire file to the
197
+ default. To keep a single property (rare) you edit the line instead of deleting it.
198
+ - Each exception lands in the **file that owns the axis**: EOL → `.gitattributes`, the rest →
199
+ `.editorconfig`. "Select all and delete" is per config file (two places).
200
+
201
+ ### `--tabs-*` (out of scope for now)
202
+
203
+ Indentation conversion (tab→spaces or vice versa) is left as separate switches, to be defined
204
+ later, since it's structural, risky and language-dependent. In the meantime, tabs are fixed
205
+ by hand.
206
+
207
+
208
+ ---
209
+
210
+ ## Suggested workflow
211
+
212
+ 0. *(optional)* `add-config-defaults` → **commit**. Versions the "north star" (the pure
213
+ norm), so that in step 1 deviations stand out in the diff against that norm.
214
+ 1. `adapt-configs` → the `git diff` of the configs shows **every added exception = every
215
+ deviation**. That diff is the real report.
216
+ 2. Review those exceptions: keep the ones that were on purpose, **delete** by hand the ones
217
+ that were junk (if almost everything is wrong, delete the whole block).
218
+ 3. `fix-format` → normalizes everything no longer protected by an exception.
219
+
220
+ Since Git reverts anything, the destructive steps are safe to try.
221
+
222
+
223
+ ---
224
+
225
+ ## Architecture
226
+
227
+ Designed to **stand on its own** and to be integrable later as a dependency of another tool.
228
+
229
+ Text transformation is separated from orchestration (Git, file system, args), so the core is
230
+ pure and testable.
231
+
232
+ ### Pure function (with its tests)
233
+
234
+ The heart is a side-effect-free function: it receives a file's content (already decoded) and
235
+ the options resolved for that file, and returns the normalized content plus a report of what
236
+ changed. It touches neither disk, nor Git, nor arguments.
237
+
238
+
239
+ ```
240
+ normalizeText(content: string, options: Options): { content: string; report: Report }
241
+ ```
242
+
243
+ - `Options`: BOM (remove | add | keep), trailing (trim | keep), final newline (ensure | keep).
244
+ (EOL is Git's responsibility, not this function's.)
245
+ - `Report`: what was modified (so as not to fail silently).
246
+
247
+ The audit also has its pure core: given a chunk of text, it detects its current state (EOL
248
+ crlf/lf/mixed, BOM yes/no, trailing yes/no, final newline, indentation tabs/spaces/mixed).
249
+
250
+ Test cases: CRLF↔LF, mixed EOL, trailing, missing/excess final newline, BOM present/absent,
251
+ empty file, and **idempotence** (running twice changes nothing).
252
+
253
+ Strongly typed, `strict`, no `any`. Errors are handled, not ignored.
254
+
255
+
256
+ ### `cli.ts`
257
+
258
+ Maps the commands (`audit`, `add-config-defaults`, `adapt-configs`, `fix-format`) to
259
+ TS calls and orchestrates the effects:
260
+
261
+ 1. Discovers files (`git ls-files`), skips binaries and the ones marked `-text`.
262
+ 2. Reads `.gitattributes` and `.editorconfig` to resolve the per-file options.
263
+ 3. Depending on the command: only reports, generates/adapts configs, or reads each file, calls
264
+ the pure function and rewrites if it changed.
265
+ 4. Prints the summary of created/modified files.
266
+
267
+
268
+ ---
269
+
270
+ ## To be defined at implementation time
271
+
272
+ - Default value for `indent_size` (probably detected per project/language).
273
+ - Exact format of the `audit` report (provisional today, documented above).
274
+ - Binary detection and handling of files in encodings other than UTF-8.
275
+ - Concrete `--tabs-*` switches.
276
+
package/dist/cli.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import type { RunOptions } from "./core/types.js";
3
+ /**
4
+ * Parse argv into resolved RunOptions. cleye is the single source of truth: each RunMode is a
5
+ * subcommand (`appease <command> [dir]`), so the modes are mutually exclusive by construction and
6
+ * each gets its own `--help`. cleye prints usage and exits on unknown flags and on `--help`.
7
+ */
8
+ export declare function parseArgs(argv?: string[]): RunOptions;
9
+ export declare function main(argv: string[]): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // CLI entry point. Maps switches to the public API and orchestrates the effects.
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { cli, command } from "cleye";
6
+ import { runAppease } from "./index.js";
7
+ /** `--dry-run`, shared by the commands that write to disk (not by `audit`, which never writes). */
8
+ const dryRunFlag = {
9
+ dryRun: { type: Boolean, description: "Simulate writes; report what would change but touch nothing" },
10
+ };
11
+ /**
12
+ * Parse argv into resolved RunOptions. cleye is the single source of truth: each RunMode is a
13
+ * subcommand (`appease <command> [dir]`), so the modes are mutually exclusive by construction and
14
+ * each gets its own `--help`. cleye prints usage and exits on unknown flags and on `--help`.
15
+ */
16
+ export function parseArgs(argv) {
17
+ const parsed = cli({
18
+ name: "appease",
19
+ help: { description: "Normalize a repo's text files to its .editorconfig / .gitattributes. One command is required." },
20
+ commands: [
21
+ command({ name: "audit", parameters: ["[dir]"], strictFlags: true, help: { description: "Report deviations only (no writes)" } }),
22
+ command({ name: "add-config-defaults", parameters: ["[dir]"], strictFlags: true, flags: dryRunFlag, help: { description: "Write pure-default configs (only the ones that do not exist yet)" } }),
23
+ command({ name: "adapt-configs", parameters: ["[dir]"], strictFlags: true, flags: dryRunFlag, help: { description: "Write/adapt configs to reflect the repo's current reality" } }),
24
+ command({ name: "fix-format", parameters: ["[dir]"], strictFlags: true, flags: dryRunFlag, help: { description: "Normalize files (BOM / EOL / trailing whitespace / final newline)" } }),
25
+ ],
26
+ }, undefined, argv);
27
+ if (parsed.command === undefined) {
28
+ parsed.showHelp();
29
+ process.stderr.write("\nSpecify a command: audit | add-config-defaults | adapt-configs | fix-format\n");
30
+ process.exit(1);
31
+ }
32
+ const dir = parsed._.dir;
33
+ return {
34
+ mode: parsed.command,
35
+ cwd: dir !== undefined ? resolve(dir) : process.cwd(),
36
+ dryRun: parsed.command === "audit" ? false : parsed.flags.dryRun ?? false,
37
+ };
38
+ }
39
+ /** Drop empty `unresolved` arrays from the audit JSON (an empty list carries no information). */
40
+ function omitEmptyUnresolved(key, value) {
41
+ return key === "unresolved" && Array.isArray(value) && value.length === 0 ? undefined : value;
42
+ }
43
+ export async function main(argv) {
44
+ const report = await runAppease(parseArgs(argv));
45
+ if (report.audit !== undefined)
46
+ process.stdout.write(`${JSON.stringify(report.audit, omitEmptyUnresolved, 2)}\n`);
47
+ for (const path of report.created)
48
+ process.stdout.write(`${report.dryRun ? "would create" : "created"}: ${path}\n`);
49
+ for (const path of report.modified)
50
+ process.stdout.write(`${report.dryRun ? "would modify" : "modified"}: ${path}\n`);
51
+ for (const path of report.unchanged)
52
+ process.stdout.write(`unchanged: ${path}\n`);
53
+ return 0;
54
+ }
55
+ // Only run when invoked directly (not when imported by tests).
56
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
57
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
58
+ process.stderr.write(`${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
59
+ process.exit(1);
60
+ });
61
+ }
62
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ import type { FormatReport } from "./types.js";
2
+ /**
3
+ * Audit core: detect the current low-level format state of a single file's
4
+ * already-decoded text. Pure and side-effect free (no disk, no Git, no args).
5
+ *
6
+ * Assumes the decoder kept a leading BOM (U+FEFF) in `content` if the file had
7
+ * one (the orchestration decodes raw, without stripping it).
8
+ */
9
+ export declare function analyzeContent(content: string): FormatReport;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Audit core: detect the current low-level format state of a single file's
3
+ * already-decoded text. Pure and side-effect free (no disk, no Git, no args).
4
+ *
5
+ * Assumes the decoder kept a leading BOM (U+FEFF) in `content` if the file had
6
+ * one (the orchestration decodes raw, without stripping it).
7
+ */
8
+ export function analyzeContent(content) {
9
+ const empty = content === "";
10
+ const hasBom = content.charCodeAt(0) === 0xfeff;
11
+ // Count CRLF, CR and LF separately. Each CRLF contains one CR and one LF, so
12
+ // the lone-CR / lone-LF counts come out by subtraction.
13
+ const crlfCount = (content.match(/\r\n/g) ?? []).length;
14
+ const crCount = (content.match(/\r/g) ?? []).length;
15
+ const lfCount = (content.match(/\n/g) ?? []).length;
16
+ const hasCrlf = crlfCount > 0;
17
+ const hasCr = crCount - crlfCount > 0;
18
+ const hasLf = lfCount - crlfCount > 0;
19
+ // Horizontal whitespace right before a line break or at the end of the file.
20
+ const hasTrailingSpaces = /[ \t]+(?:\r?\n|$)/.test(content);
21
+ const finalNewline = detectFinalNewline(content);
22
+ return { empty, hasBom, hasCrlf, hasLf, hasCr, hasTrailingSpaces, finalNewline };
23
+ }
24
+ /** Classify the trailing run of newlines (LF / CRLF only). */
25
+ function detectFinalNewline(content) {
26
+ if (!/(?:\r\n|\n)$/.test(content))
27
+ return "missing";
28
+ return /(?:\r\n|\n){2}$/.test(content) ? "multiple" : "present";
29
+ }
30
+ //# sourceMappingURL=analyze.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analyze.js","sourceRoot":"","sources":["../../src/core/analyze.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,OAAe;IAC5C,MAAM,KAAK,GAAG,OAAO,KAAK,EAAE,CAAC;IAC7B,MAAM,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC;IAEhD,6EAA6E;IAC7E,wDAAwD;IACxD,MAAM,SAAS,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC;IACxD,MAAM,OAAO,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC;IACpD,MAAM,OAAO,GAAG,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC;IACpD,MAAM,OAAO,GAAG,SAAS,GAAG,CAAC,CAAC;IAC9B,MAAM,KAAK,GAAG,OAAO,GAAG,SAAS,GAAG,CAAC,CAAC;IACtC,MAAM,KAAK,GAAG,OAAO,GAAG,SAAS,GAAG,CAAC,CAAC;IAEtC,6EAA6E;IAC7E,MAAM,iBAAiB,GAAG,mBAAmB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAE5D,MAAM,YAAY,GAAG,kBAAkB,CAAC,OAAO,CAAC,CAAC;IAEjD,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,iBAAiB,EAAE,YAAY,EAAE,CAAC;AACnF,CAAC;AAED,8DAA8D;AAC9D,SAAS,kBAAkB,CAAC,OAAe;IACzC,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,SAAS,CAAC;IACpD,OAAO,iBAAiB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC;AAClE,CAAC"}
@@ -0,0 +1,14 @@
1
+ import type { FileAudit, FormatReport, ProjectConfig } from "./types.js";
2
+ /**
3
+ * Evaluate a collection of per-file analyses against the resolved project
4
+ * config. Pure. Returns one `FileAudit` per file that has any finding
5
+ * (deviation or unresolved axis); clean files are omitted.
6
+ *
7
+ * `nativeEol` is this machine's native line ending (CRLF on Windows, LF
8
+ * elsewhere); it is how `eol=auto` is evaluated, since under `text=auto` the
9
+ * working copy is expected to be native. The pure core receives it as input.
10
+ */
11
+ export declare function audit(reports: {
12
+ path: string;
13
+ report: FormatReport;
14
+ }[], config: ProjectConfig, nativeEol: "lf" | "crlf"): FileAudit[];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Evaluate a collection of per-file analyses against the resolved project
3
+ * config. Pure. Returns one `FileAudit` per file that has any finding
4
+ * (deviation or unresolved axis); clean files are omitted.
5
+ *
6
+ * `nativeEol` is this machine's native line ending (CRLF on Windows, LF
7
+ * elsewhere); it is how `eol=auto` is evaluated, since under `text=auto` the
8
+ * working copy is expected to be native. The pure core receives it as input.
9
+ */
10
+ export function audit(reports, config, nativeEol) {
11
+ const files = [];
12
+ for (const { path, report } of reports) {
13
+ const cfg = config.resolve(path);
14
+ const deviations = fileDeviations(report, cfg, nativeEol);
15
+ if (deviations.length > 0 || cfg.unresolved.length > 0)
16
+ files.push({ path, deviations, unresolved: cfg.unresolved });
17
+ }
18
+ return files;
19
+ }
20
+ /** Axes where `report` differs from `cfg`, skipping axes the config could not resolve (case 2). */
21
+ function fileDeviations(report, cfg, nativeEol) {
22
+ const deviations = [];
23
+ const governs = (axis) => !cfg.unresolved.includes(axis);
24
+ if (governs("bom")) {
25
+ if (cfg.bom === "add" && !report.hasBom)
26
+ deviations.push("bom");
27
+ else if (cfg.bom === "remove" && report.hasBom)
28
+ deviations.push("bom");
29
+ }
30
+ if (governs("trailing") && cfg.trailing === "trim" && report.hasTrailingSpaces) {
31
+ deviations.push("trailing");
32
+ }
33
+ if (governs("finalNewline") && cfg.finalNewline === "ensure" && report.finalNewline !== "present") {
34
+ deviations.push("finalNewline");
35
+ }
36
+ if (governs("eol")) {
37
+ const target = cfg.eol === "auto" ? nativeEol : cfg.eol;
38
+ if (target === "lf" && (report.hasCrlf || report.hasCr))
39
+ deviations.push("eol");
40
+ else if (target === "crlf" && (report.hasLf || report.hasCr))
41
+ deviations.push("eol");
42
+ // "binary" -> EOL is not evaluated
43
+ }
44
+ return deviations;
45
+ }
46
+ //# sourceMappingURL=audit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit.js","sourceRoot":"","sources":["../../src/core/audit.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,UAAU,KAAK,CACnB,OAAiD,EACjD,MAAqB,EACrB,SAAwB;IAExB,MAAM,KAAK,GAAgB,EAAE,CAAC;IAC9B,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,OAAO,EAAE,CAAC;QACvC,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACjC,MAAM,UAAU,GAAG,cAAc,CAAC,MAAM,EAAE,GAAG,EAAE,SAAS,CAAC,CAAC;QAC1D,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC;IACvH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,mGAAmG;AACnG,SAAS,cAAc,CAAC,MAAoB,EAAE,GAAuB,EAAE,SAAwB;IAC7F,MAAM,UAAU,GAAoB,EAAE,CAAC;IACvC,MAAM,OAAO,GAAG,CAAC,IAAmB,EAAW,EAAE,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAEjF,IAAI,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACnB,IAAI,GAAG,CAAC,GAAG,KAAK,KAAK,IAAI,CAAC,MAAM,CAAC,MAAM;YAAE,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;aAC3D,IAAI,GAAG,CAAC,GAAG,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM;YAAE,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACzE,CAAC;IACD,IAAI,OAAO,CAAC,UAAU,CAAC,IAAI,GAAG,CAAC,QAAQ,KAAK,MAAM,IAAI,MAAM,CAAC,iBAAiB,EAAE,CAAC;QAC/E,UAAU,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IAC9B,CAAC;IACD,IAAI,OAAO,CAAC,cAAc,CAAC,IAAI,GAAG,CAAC,YAAY,KAAK,QAAQ,IAAI,MAAM,CAAC,YAAY,KAAK,SAAS,EAAE,CAAC;QAClG,UAAU,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;IAClC,CAAC;IACD,IAAI,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACnB,MAAM,MAAM,GAAG,GAAG,CAAC,GAAG,KAAK,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC;QACxD,IAAI,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,KAAK,CAAC;YAAE,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;aAC3E,IAAI,MAAM,KAAK,MAAM,IAAI,CAAC,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,KAAK,CAAC;YAAE,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACrF,mCAAmC;IACrC,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC"}
@@ -0,0 +1,15 @@
1
+ import type { ProjectConfig, RawConfigs } from "./types.js";
2
+ /**
3
+ * Interpret the raw contents of the three config sources into a resolver for
4
+ * per-file options. Pure: takes the already-read contents, touches no disk.
5
+ *
6
+ * Each axis is owned by exactly one source (see LEEME.md): EOL -> .gitattributes,
7
+ * BOM/charset/trailing/final-newline -> .editorconfig, renderWhitespace ->
8
+ * .vscode/settings.json. Only `.editorconfig` and `.gitattributes` drive
9
+ * `resolve`; `.vscode/settings.json` only counts towards `present`.
10
+ */
11
+ export declare function interpretConfigs(raw: RawConfigs): ProjectConfig;
12
+ /** Pure, idempotent defaults for `.editorconfig` (no inspection of the repo's reality). */
13
+ export declare function defaultEditorconfig(): string;
14
+ /** Pure, idempotent defaults for `.gitattributes` (`* text=auto`, ...). */
15
+ export declare function defaultGitattributes(): string;
@@ -0,0 +1,61 @@
1
+ import { parseEditorconfig, resolveEditorconfig } from "./editorconfig.js";
2
+ import { parseGitattributes, resolveGitEol } from "./gitattributes.js";
3
+ /**
4
+ * Interpret the raw contents of the three config sources into a resolver for
5
+ * per-file options. Pure: takes the already-read contents, touches no disk.
6
+ *
7
+ * Each axis is owned by exactly one source (see LEEME.md): EOL -> .gitattributes,
8
+ * BOM/charset/trailing/final-newline -> .editorconfig, renderWhitespace ->
9
+ * .vscode/settings.json. Only `.editorconfig` and `.gitattributes` drive
10
+ * `resolve`; `.vscode/settings.json` only counts towards `present`.
11
+ */
12
+ export function interpretConfigs(raw) {
13
+ const editorconfig = raw.editorconfig !== null ? parseEditorconfig(raw.editorconfig) : { root: false, sections: [] };
14
+ const gitattributes = raw.gitattributes !== null ? parseGitattributes(raw.gitattributes) : { rules: [] };
15
+ return {
16
+ present: {
17
+ editorconfig: raw.editorconfig !== null,
18
+ gitattributes: raw.gitattributes !== null,
19
+ vscodeSettings: raw.vscodeSettings !== null,
20
+ },
21
+ resolve: (path) => resolveFile(editorconfig, gitattributes, path),
22
+ };
23
+ }
24
+ /** Combine the per-source resolutions into the file's resolved config. */
25
+ function resolveFile(editorconfig, gitattributes, path) {
26
+ const ec = resolveEditorconfig(editorconfig, path);
27
+ const gitEol = resolveGitEol(gitattributes, path);
28
+ const unresolved = [...ec.unresolved];
29
+ if (gitEol === "unresolved")
30
+ unresolved.push("eol");
31
+ return {
32
+ bom: ec.charset === "utf-8-bom" ? "add" : ec.charset === "utf-8" ? "remove" : "keep",
33
+ eol: gitEol === "unresolved" ? "auto" : gitEol,
34
+ trailing: ec.trailing,
35
+ finalNewline: ec.finalNewline,
36
+ unresolved,
37
+ };
38
+ }
39
+ /** Pure, idempotent defaults for `.editorconfig` (no inspection of the repo's reality). */
40
+ export function defaultEditorconfig() {
41
+ return [
42
+ "root = true",
43
+ "",
44
+ "# Defaults for every file",
45
+ "[*]",
46
+ "charset = utf-8",
47
+ "trim_trailing_whitespace = true",
48
+ "insert_final_newline = true",
49
+ "indent_style = space",
50
+ "",
51
+ ].join("\n");
52
+ }
53
+ /** Pure, idempotent defaults for `.gitattributes` (`* text=auto`, ...). */
54
+ export function defaultGitattributes() {
55
+ return [
56
+ "# Normalize to LF in the repo on commit; check out with the OS-native EOL.",
57
+ "* text=auto",
58
+ "",
59
+ ].join("\n");
60
+ }
61
+ //# sourceMappingURL=configs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"configs.js","sourceRoot":"","sources":["../../src/core/configs.ts"],"names":[],"mappings":"AACA,OAAO,EAAqB,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC9F,OAAO,EAAsB,kBAAkB,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAE3F;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAe;IAC9C,MAAM,YAAY,GAAiB,GAAG,CAAC,YAAY,KAAK,IAAI,CAAC,CAAC,CAAC,iBAAiB,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;IACnI,MAAM,aAAa,GAAkB,GAAG,CAAC,aAAa,KAAK,IAAI,CAAC,CAAC,CAAC,kBAAkB,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;IACxH,OAAO;QACL,OAAO,EAAE;YACP,YAAY,EAAE,GAAG,CAAC,YAAY,KAAK,IAAI;YACvC,aAAa,EAAE,GAAG,CAAC,aAAa,KAAK,IAAI;YACzC,cAAc,EAAE,GAAG,CAAC,cAAc,KAAK,IAAI;SAC5C;QACD,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,WAAW,CAAC,YAAY,EAAE,aAAa,EAAE,IAAI,CAAC;KAClE,CAAC;AACJ,CAAC;AAED,0EAA0E;AAC1E,SAAS,WAAW,CAAC,YAA0B,EAAE,aAA4B,EAAE,IAAY;IACzF,MAAM,EAAE,GAAG,mBAAmB,CAAC,YAAY,EAAE,IAAI,CAAC,CAAC;IACnD,MAAM,MAAM,GAAG,aAAa,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC;IAClD,MAAM,UAAU,GAAoB,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,CAAC;IACvD,IAAI,MAAM,KAAK,YAAY;QAAE,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACpD,OAAO;QACL,GAAG,EAAE,EAAE,CAAC,OAAO,KAAK,WAAW,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM;QACpF,GAAG,EAAE,MAAM,KAAK,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM;QAC9C,QAAQ,EAAE,EAAE,CAAC,QAAQ;QACrB,YAAY,EAAE,EAAE,CAAC,YAAY;QAC7B,UAAU;KACX,CAAC;AACJ,CAAC;AAED,2FAA2F;AAC3F,MAAM,UAAU,mBAAmB;IACjC,OAAO;QACL,aAAa;QACb,EAAE;QACF,2BAA2B;QAC3B,KAAK;QACL,iBAAiB;QACjB,iCAAiC;QACjC,6BAA6B;QAC7B,sBAAsB;QACtB,EAAE;KACH,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,oBAAoB;IAClC,OAAO;QACL,4EAA4E;QAC5E,aAAa;QACb,EAAE;KACH,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
@@ -0,0 +1,30 @@
1
+ /** Axes an `.editorconfig` can govern for us; an axis here means "skip + report" (case 2). */
2
+ export type EditorconfigAxis = "bom" | "trailing" | "finalNewline";
3
+ /** Resolved `.editorconfig` stance for a path (only the axes we manage). */
4
+ export interface EditorconfigResolution {
5
+ /** `keep` = `unset`, `false`-like, or not specified. */
6
+ charset: "utf-8" | "utf-8-bom" | "keep";
7
+ trailing: "trim" | "keep";
8
+ finalNewline: "ensure" | "keep";
9
+ /** Axes governed by a recognized key with an unrecognized value (case 2). */
10
+ unresolved: EditorconfigAxis[];
11
+ }
12
+ interface Section {
13
+ matches: (path: string) => boolean;
14
+ charset: "utf-8" | "utf-8-bom" | "unset" | "unrecognized" | undefined;
15
+ trim: boolean | "unset" | "unrecognized" | undefined;
16
+ finalNewline: boolean | "unset" | "unrecognized" | undefined;
17
+ }
18
+ export interface Editorconfig {
19
+ root: boolean;
20
+ sections: Section[];
21
+ }
22
+ /**
23
+ * Parse `.editorconfig` content into ordered sections. Pure.
24
+ * Throws on a structurally malformed line (case 3); properties we do not manage
25
+ * (indent_*, max_line_length, ...) are ignored (case 1).
26
+ */
27
+ export declare function parseEditorconfig(content: string): Editorconfig;
28
+ /** Resolve the effective stance for `path` (last matching section wins per property). */
29
+ export declare function resolveEditorconfig(ec: Editorconfig, path: string): EditorconfigResolution;
30
+ export {};