diff --git a/package.json b/package.json index 8b9a001..f5a4b48 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "build": "pnpm -r run build", + "lint": "node --test tests/formatting-policy.test.mjs", "test": "node --test \"tests/**/*.test.mjs\"", "typecheck": "pnpm -r run typecheck" }, diff --git a/tests/formatting-policy.test.mjs b/tests/formatting-policy.test.mjs new file mode 100644 index 0000000..34a8b44 --- /dev/null +++ b/tests/formatting-policy.test.mjs @@ -0,0 +1,249 @@ +/** + * Formatting/lint policy test — locks in the workspace formatting and lint + * policy (E00-S05-T01, CI stage 3: formatting/lint). + * + * Policy (every file tracked by git, i.e. every committed text file): + * - LF line endings: no carriage returns (no CRLF, no lone CR) + * - no UTF-8 byte-order mark + * - no trailing whitespace on any line + * - no tab characters anywhere (indentation is spaces) + * - exactly one final newline: the file must end with `\n`, with no blank + * line left at the end of the file + * JSON files additionally must: + * - parse as strict JSON (no trailing commas, no comments) + * - contain no duplicate object keys + * - use 2-space indentation (every line's leading spaces are an even count) + * + * The scan is scoped to files tracked by git (`git ls-files`), so ignored and + * generated files (node_modules/, dist/, .env, probe scratch files) never + * enter the policy. Binary files (containing a NUL byte) are skipped. + * + * Mutation probes prove every rule is non-vacuous: each violation below is + * injected into a temp file and must be reported. + * + * Run: `pnpm lint` (== `node --test tests/formatting-policy.test.mjs`) + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** True for binary files: the policy applies to text files only. */ +const isBinary = (content) => content.includes('\0'); + +/** + * Returns the list of tracked files (git ls-files, NUL-delimited) under the + * given repo root, or fails the suite when git is unavailable. + */ +function trackedFiles(root = REPO_ROOT) { + const result = spawnSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8' }); + assert.equal(result.status, 0, `git ls-files must succeed in ${root}`); + return result.stdout.split('\0').filter((p) => p.length > 0); +} + +/** + * Collects the duplicate object keys of a JSON document. JSON.parse collapses + * duplicate keys (last value wins) before any reviver or post-parse walker can + * see them, so this scans the raw text: a string literal immediately followed + * by `:` is an object key, and keys are tracked per enclosing `{…}` frame, so + * same-named keys in different objects stay legal while a repeated key inside + * one object is reported. + */ +function collectDuplicateKeys(text, out) { + const frames = []; + let i = 0; + const n = text.length; + while (i < n) { + const ch = text[i]; + if (ch === '"') { + let j = i + 1; + while (j < n && text[j] !== '"') { + if (text[j] === '\\') j += 1; + j += 1; + } + const key = text.slice(i + 1, j).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + let k = j + 1; + while (k < n && (text[k] === ' ' || text[k] === '\t' || text[k] === '\n' || text[k] === '\r')) k += 1; + if (text[k] === ':' && frames.length > 0) { + const seen = frames[frames.length - 1]; + if (seen.has(key)) out.push(`duplicate JSON key: ${key}`); + seen.add(key); + } + i = k; + continue; + } + if (ch === '{') { + frames.push(new Set()); + i += 1; + continue; + } + if (ch === '}') { + frames.pop(); + i += 1; + continue; + } + i += 1; + } +} + +/** + * Returns the formatting/lint violations for one file relative to `root`, or + * [] when the file conforms. Binary files are out of policy scope. + */ +function violationsForFile(root, relPath) { + let content; + try { + content = readFileSync(path.join(root, relPath), 'utf8'); + } catch (err) { + return [`${relPath}: unreadable: ${err.message}`]; + } + if (isBinary(content)) return []; + + const label = (message) => `${relPath}: ${message}`; + const out = []; + + if (content.includes('\r')) out.push(label('carriage return (use LF line endings)')); + if (content.startsWith('\uFEFF')) out.push(label('UTF-8 byte-order mark')); + if (content.includes('\t')) out.push(label('tab character (indentation must be spaces)')); + + const lines = content.split('\n'); + lines.forEach((line, i) => { + if (/[ \t]+$/.test(line)) out.push(label(`trailing whitespace on line ${i + 1}`)); + }); + + if (content.length > 0) { + if (lines[lines.length - 1] !== '') out.push(label('missing final newline')); + else if (lines[lines.length - 2] === '') out.push(label('blank line at end of file')); + } + + if (relPath.endsWith('.json')) { + let parsed; + try { + parsed = JSON.parse(content); + } catch (err) { + out.push(label(`invalid JSON: ${err.message}`)); + return out; + } + const duplicateKeys = []; + collectDuplicateKeys(content, duplicateKeys); + for (const message of duplicateKeys) out.push(label(message)); + lines.forEach((line, i) => { + const indent = /^([ ]*)\S/.exec(line); + if (indent && indent[1].length % 2 !== 0) { + out.push(label(`JSON indentation must be 2 spaces per level (line ${i + 1})`)); + } + }); + } + + return out; +} + +/** Writes a probe file into a fresh temp dir and returns its violations. */ +function probeViolations(filename, content) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'eppp-format-policy-')); + try { + writeFileSync(path.join(dir, filename), content); + return violationsForFile(dir, filename); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +// --------------------------------------------------------------------------- +// Real-tree scan +// --------------------------------------------------------------------------- + +test('every tracked text file conforms to the formatting/lint policy', () => { + const files = trackedFiles(); + assert.ok( + files.length >= 25, + `expected a meaningful tracked file set to scan (got ${files.length})`, + ); + const violations = []; + for (const file of files) violations.push(...violationsForFile(REPO_ROOT, file)); + assert.deepEqual(violations, [], `formatting/lint violations:\n${violations.join('\n')}`); +}); + +test('the scan covers the workspace source, docs, manifests and workflow', () => { + const files = trackedFiles(); + for (const expected of [ + 'apps/server/src/index.ts', + 'packages/config/src/env.ts', + 'docs/development/non-container.md', + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'compose.yaml', + '.gitea/workflows/ci.yml', + ]) { + assert.ok(files.includes(expected), `tracked file set must include ${expected}`); + } +}); + +// --------------------------------------------------------------------------- +// Mutation probes — every rule is non-vacuous +// --------------------------------------------------------------------------- + +test('a trailing whitespace is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', 'line with trailing space \n'); + assert.ok(violations.some((v) => /trailing whitespace/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a tab character is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', 'line\twith tab\n'); + assert.ok(violations.some((v) => /tab character/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a CRLF line ending is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', 'line\r\n'); + assert.ok(violations.some((v) => /carriage return/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a UTF-8 byte-order mark is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', '\uFEFFline\n'); + assert.ok(violations.some((v) => /byte-order mark/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a missing final newline is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', 'no final newline'); + assert.ok(violations.some((v) => /missing final newline/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a blank line at the end of the file is reported (mutation probe)', () => { + const violations = probeViolations('probe.txt', 'line\n\n'); + assert.ok(violations.some((v) => /blank line at end of file/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('invalid JSON is reported (mutation probe)', () => { + const violations = probeViolations('probe.json', '{"a": 1,}\n'); + assert.ok(violations.some((v) => /invalid JSON/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('a duplicate JSON key is reported (mutation probe)', () => { + const violations = probeViolations('probe.json', '{"a": 1, "a": 2}\n'); + assert.ok(violations.some((v) => /duplicate JSON key/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('odd JSON indentation is reported (mutation probe)', () => { + const violations = probeViolations('probe.json', '{\n "a": 1,\n "b": 2\n}\n'); + assert.ok(violations.some((v) => /2 spaces per level/.test(v)), `got: ${violations.join('; ')}`); +}); + +test('binary files are out of policy scope (mutation probe)', () => { + const violations = probeViolations('probe.bin', 'a\0b'); + assert.deepEqual(violations, []); +}); + +test('a conforming file yields no violations (mutation probe)', () => { + const violations = probeViolations('probe.json', '{\n "a": 1\n}\n'); + assert.deepEqual(violations, []); +});