feat: add dependency-free formatting/lint policy suite and root lint script (E00-S05-T01)
The formatting-policy suite (tests/formatting-policy.test.mjs) locks in the workspace formatting and lint policy: LF line endings, no BOM, no trailing whitespace, no tab indentation, exactly one final newline, and valid JSON with 2-space indentation and no duplicate keys. Every rule has a mutation probe. The root `lint` script runs the suite; the CI formatting-lint stage executes it.
This commit is contained in:
@@ -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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user