S2 EsLint implementation #4

Merged
wanjakrah merged 1 commits from feature/S2_eslint into main 2026-10-29 22:07:39 +01:00
22 changed files with 2282 additions and 31 deletions

2
.husky/pre-commit Normal file
View File

@@ -0,0 +1,2 @@
cd agiledancers-fe || exit 1
node node_modules/lint-staged/bin/lint-staged.js --concurrent false

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["angular.ng-template", "dbaeumer.vscode-eslint"]
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"eslint.workingDirectories": ["./agiledancers-fe"]
}

View File

@@ -169,6 +169,53 @@ backend on port 8080. Frontend builds and tests run independently of Maven:
`npm run build` and `npm test`. See the frontend README for browser tests and `npm run build` and `npm test`. See the frontend README for browser tests and
production hosting requirements. production hosting requirements.
## Frontend lint and formatting
Run these commands from `agiledancers-fe/`:
```sh
npm run lint
npm run lint:fix
```
`lint` checks maintained application code, inline and external Angular templates,
unit tests, Playwright tests and JavaScript/TypeScript configuration. It uses the
recommended JavaScript, Angular and type-aware TypeScript rules. It changes no
files; both errors and warnings cause failure. Dependencies, generated output
and `package-lock.json` are excluded. `lint:fix` applies ESLint's available fixes;
review the diff and resolve any remaining findings manually. Prettier retains
responsibility for formatting with the existing configuration.
Every branch pipeline runs `npm run lint` immediately after frontend `npm ci`,
before frontend tests/builds and container E2E. Failure prevents image publication;
findings are in the job log and retained `ci/artifacts/verify.log`. CI only checks
and never fixes or stages source files.
Local `npm ci` automatically installs Husky at the repository root. Its commit
hook runs **Prettier only**, through lint-staged with backup, rollback and hiding
of unstaged changes in partially staged files enabled. It formats applicable
staged frontend TypeScript, JavaScript, HTML, CSS and JSON files, then stages the
formatting. Unrelated unstaged edits and files remain outside the commit. A
formatter error or a conflict restoring unstaged edits aborts the commit and
restores the original staged and unstaged state. Generated files are excluded;
commits without applicable frontend changes skip these checks.
The complete Prettier-plus-ESLint hook exceeds the agreed three-second budget
when all maintained files are staged. The selected Prettier-only hook meets the
budget for single-file, mixed and all-file commits. ESLint is therefore run
explicitly during development and always by CI. See [S2 validation](docs/s2-validation.md)
for the measured environment, timings and preservation checks.
Hook installation is skipped when `CI` is set (including Docker builds) or
`HUSKY=0`. Use `HUSKY=0 npm ci` to skip local installation, `HUSKY=0 git commit`
to skip one hook execution, or `git config --local core.hooksPath /dev/null` to
disable hooks for this clone. A subsequent normal `npm ci` enables them again.
VS Code recommends the ESLint extension in both root and frontend workspaces.
With it installed, findings appear in the editor and Problems panel; the root
workspace selects `agiledancers-fe/` as ESLint's working directory. No automatic
fixing or formatting on save is required. `npm run lint` uses the same rules as CI.
## Gitea CI ## Gitea CI
Every branch push builds and tests both applications and their Docker images. Every branch push builds and tests both applications and their Docker images.

View File

@@ -0,0 +1,14 @@
node_modules/
dist/
.angular/
coverage/
test-results/
unit-results/
playwright-report/
package-lock.json
out-tsc/
bazel-out/
tmp/
typings/
__screenshots__/
.history/

View File

@@ -1,4 +1,4 @@
{ {
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"] "recommendations": ["angular.ng-template", "dbaeumer.vscode-eslint"]
} }

View File

@@ -1,9 +1,10 @@
FROM node:24.15.0-bookworm AS build FROM node:24.15.0-bookworm AS build
WORKDIR /workspace WORKDIR /workspace
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci COPY tools/install-hooks.mjs tools/install-hooks.mjs
RUN CI=true npm ci
COPY . . COPY . .
RUN npm test && npm run build RUN npm run lint && npm test && npm run build
FROM nginx:1.28-alpine AS runtime FROM nginx:1.28-alpine AS runtime
ENV BACKEND_UPSTREAM=backend:8080 ENV BACKEND_UPSTREAM=backend:8080

View File

@@ -84,3 +84,9 @@ PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome npm run e2e
Browser test screenshots and failure traces are written to the ignored Browser test screenshots and failure traces are written to the ignored
`test-results` directory. `test-results` directory.
## Lint and commit formatting
See the [root README](../README.md#frontend-lint-and-formatting) for `npm run lint`,
`npm run lint:fix`, editor support, CI enforcement and the measured Prettier-only
commit hook, including installation and local opt-out.

View File

@@ -1,4 +1,5 @@
import { test, expect, Page } from '@playwright/test'; import { test, expect, Page } from '@playwright/test';
import type { School } from '../src/app/core/api-models';
const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080'; const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080';
let schoolToDelete: number | undefined; let schoolToDelete: number | undefined;
@@ -32,7 +33,6 @@ test('manages a school, shared memberships and shows through the real REST API',
page, page,
}) => { }) => {
const schoolName = `E2E Studio ${Date.now()}`; const schoolName = `E2E Studio ${Date.now()}`;
let schoolId: number | undefined;
const browserErrors: string[] = []; const browserErrors: string[] = [];
page.on('pageerror', (error) => browserErrors.push(error.message)); page.on('pageerror', (error) => browserErrors.push(error.message));
await page.goto('/'); await page.goto('/');
@@ -43,8 +43,9 @@ test('manages a school, shared memberships and shows through the real REST API',
response.url().endsWith('/api/dance-schools') && response.request().method() === 'POST', response.url().endsWith('/api/dance-schools') && response.request().method() === 'POST',
); );
await save(page); await save(page);
schoolId = (await (await created).json()).id; const school: unknown = await (await created).json();
schoolToDelete = schoolId; expect(school).toEqual(expect.objectContaining({ id: expect.any(Number) }));
schoolToDelete = (school as School).id;
await page.getByRole('link', { name: new RegExp(schoolName) }).click(); await page.getByRole('link', { name: new RegExp(schoolName) }).click();
await expect(page.getByRole('heading', { name: schoolName, exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: schoolName, exact: true })).toBeVisible();

View File

@@ -0,0 +1,47 @@
import js from '@eslint/js';
import { defineConfig, globalIgnores } from 'eslint/config';
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
export default defineConfig(
globalIgnores([
'**/node_modules/**',
'**/dist/**',
'**/out-tsc/**',
'**/bazel-out/**',
'**/tmp/**',
'**/typings/**',
'**/__screenshots__/**',
'**/.history/**',
'**/.angular/**',
'**/coverage/**',
'**/test-results/**',
'**/unit-results/**',
'**/playwright-report/**',
'package-lock.json',
]),
{
files: ['**/*.{js,mjs,cjs}'],
extends: [js.configs.recommended],
languageOptions: { globals: globals.node },
},
{
files: ['**/*.ts'],
extends: [
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
angular.configs.tsRecommended,
],
languageOptions: {
parserOptions: {
project: './tsconfig.eslint.json',
tsconfigRootDir: import.meta.dirname,
},
},
processor: angular.processInlineTemplates,
},
{ files: ['**/*.html'], extends: [angular.configs.templateRecommended] },
prettier,
);

View File

@@ -0,0 +1,21 @@
import path from 'node:path';
export default {
'*.{ts,js,mjs,cjs,html,css,json,prettierrc}': (files) => {
const maintained = files.filter((file) => {
const relative = path
.relative(import.meta.dirname, file)
.split(path.sep)
.join('/');
return (
!relative.startsWith('../') &&
!/^(node_modules|dist|out-tsc|bazel-out|tmp|typings|__screenshots__|\.history|\.angular|coverage|test-results|unit-results|playwright-report)\//.test(
relative,
) &&
relative !== 'package-lock.json'
);
});
if (!maintained.length) return [];
return [`prettier --write ${maintained.map((file) => JSON.stringify(file)).join(' ')}`];
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,10 @@
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test --watch=false", "test": "ng test --watch=false",
"e2e": "playwright test" "e2e": "playwright test",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix --max-warnings 0",
"prepare": "node tools/install-hooks.mjs"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
@@ -24,10 +27,19 @@
"@angular/build": "22.2.4", "@angular/build": "22.2.4",
"@angular/cli": "22.2.4", "@angular/cli": "22.2.4",
"@angular/compiler-cli": "22.2.3", "@angular/compiler-cli": "22.2.3",
"@eslint/js": "10.0.1",
"@playwright/test": "1.64.1", "@playwright/test": "1.64.1",
"@types/node": "22.20.5",
"angular-eslint": "22.6.0",
"eslint": "10.13.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.13.0",
"husky": "9.1.7",
"jsdom": "30.1.3", "jsdom": "30.1.3",
"lint-staged": "17.6.0",
"prettier": "3.9.10", "prettier": "3.9.10",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.72.1",
"vitest": "5.0.5" "vitest": "5.0.5"
}, },
"engines": { "engines": {

View File

@@ -16,7 +16,10 @@ describe('School JSON import', () => {
afterEach(() => http.verify()); afterEach(() => http.verify());
async function select() { async function select() {
const selected = component.select({ const selected = component.select({
target: { files: [{ text: async () => '{"formatVersion":1}' }], value: 'file.json' }, target: {
files: [{ text: () => Promise.resolve('{"formatVersion":1}') }],
value: 'file.json',
},
} as unknown as Event); } as unknown as Event);
await Promise.resolve(); await Promise.resolve();
return { selected, request: http.expectOne('/api/dance-schools/import/preview') }; return { selected, request: http.expectOne('/api/dance-schools/import/preview') };

View File

@@ -0,0 +1,245 @@
/** Isolated hook acceptance checks and full-process timing; never stages this checkout. */
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, readFileSync, writeFileSync, symlinkSync } from 'node:fs';
import { tmpdir, cpus, platform, release } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { performance } from 'node:perf_hooks';
const source = fileURLToPath(new URL('../../', import.meta.url));
const root = mkdtempSync(path.join(tmpdir(), 's2-hook-acceptance-'));
const frontend = path.join(root, 'agiledancers-fe');
const excluded = new Set([
'node_modules',
'dist',
'.angular',
'coverage',
'test-results',
'unit-results',
'playwright-report',
'_',
]);
cpSync(path.join(source, 'agiledancers-fe'), frontend, {
recursive: true,
filter: (file) => !excluded.has(path.basename(file)),
});
cpSync(path.join(source, '.husky'), path.join(root, '.husky'), {
recursive: true,
filter: (file) => !excluded.has(path.basename(file)),
});
symlinkSync(
path.join(source, 'agiledancers-fe/node_modules'),
path.join(frontend, 'node_modules'),
'dir',
);
const env = { ...process.env };
delete env.CI;
delete env.HUSKY;
function run(command, args, cwd = root, environment = env) {
return execFileSync(command, args, {
cwd,
env: environment,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trimEnd();
}
const git = (...args) => run('git', args);
const write = (file, content) => writeFileSync(path.join(root, file), content);
const read = (file) => readFileSync(path.join(root, file), 'utf8');
const fixture = 'agiledancers-fe/src/hook-fixture.ts';
const baseline =
'export const hookValue = { value: 1 };\n\n// Independent edits below.\n\nexport const otherValue = 1;\n';
write(fixture, baseline);
write('unrelated.txt', 'baseline\n');
git('init', '-q');
git('config', 'user.name', 'S2 validation');
git('config', 'user.email', 's2@example.invalid');
git('add', '.');
git('-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'Fixture baseline');
git('config', 'core.hooksPath', '/dev/null');
run('node', ['tools/install-hooks.mjs'], frontend, { ...env, CI: 'true' });
assert.equal(git('config', '--get', 'core.hooksPath'), '/dev/null');
run('node', ['tools/install-hooks.mjs'], frontend, { ...env, HUSKY: '0' });
assert.equal(git('config', '--get', 'core.hooksPath'), '/dev/null');
console.log('PASS CI and HUSKY=0 skip hook installation');
run('node', ['tools/install-hooks.mjs'], frontend);
assert.equal(git('config', '--get', 'core.hooksPath'), '.husky/_');
const reset = () => git('reset', '--hard', '-q', 'HEAD');
const snapshot = () =>
JSON.stringify([
git('diff', '--cached', '--binary'),
git('diff', '--binary'),
git('status', '--porcelain'),
git('stash', 'list'),
]);
function hook() {
const start = performance.now();
const result = spawnSync('sh', ['.husky/_/pre-commit'], { cwd: root, env, encoding: 'utf8' });
return {
seconds: Number(((performance.now() - start) / 1000).toFixed(3)),
exit: result.status,
output: result.stdout + result.stderr,
};
}
function passed(name) {
console.log(`PASS ${name}`);
}
// Format only the index version while independently staged/unstaged edits survive.
write(fixture, baseline.replace('{ value: 1 }', '{value:2}'));
git('add', fixture);
write(fixture, read(fixture).replace('otherValue = 1', 'otherValue = 2'));
write('unrelated.txt', 'staged\n');
git('add', 'unrelated.txt');
write('unrelated.txt', 'staged\nunstaged\n');
const result = hook();
assert.equal(result.exit, 0, result.output);
assert.match(git('show', `:${fixture}`), /hookValue = \{ value: 2 \}/);
assert.match(git('show', `:${fixture}`), /otherValue = 1/);
assert.match(read(fixture), /otherValue = 2/);
assert.equal(git('show', ':unrelated.txt'), 'staged');
assert.equal(read('unrelated.txt'), 'staged\nunstaged\n');
assert.equal(git('stash', 'list'), '');
passed('partially staged formatting preserves unrelated staged and unstaged changes');
reset();
write('unrelated.txt', 'only backend/documentation\n');
git('add', 'unrelated.txt');
let before = snapshot();
assert.equal(hook().exit, 0);
assert.equal(snapshot(), before);
passed('commits without frontend changes leave state unchanged');
reset();
write('agiledancers-fe/package-lock.json', read('agiledancers-fe/package-lock.json') + '\n\n');
git('add', 'agiledancers-fe/package-lock.json');
before = snapshot();
assert.equal(hook().exit, 0);
assert.equal(snapshot(), before);
passed('generated lockfile is excluded');
reset();
write(
'agiledancers-fe/src/app/app.ts',
read('agiledancers-fe/src/app/app.ts') + '\nexport const formatterCheck={value:2};\n',
);
write(fixture, 'export const broken = ;\n');
git('add', 'agiledancers-fe/src/app/app.ts', fixture);
write('unrelated.txt', 'unstaged survives failures\n');
before = snapshot();
const failed = hook();
assert.notEqual(failed.exit, 0);
assert.match(failed.output, /SyntaxError|Unexpected token/);
assert.equal(snapshot(), before);
passed('formatter failure restores index, worktree and stash');
reset();
write(fixture, baseline.replace('{ value: 1 }', '{value:2}'));
git('add', fixture);
write(fixture, read(fixture).replace('{value:2}', '{value:3}'));
before = snapshot();
const conflict = hook();
assert.notEqual(conflict.exit, 0, conflict.output);
assert.match(conflict.output, /conflict|Failed to restore unstaged/i);
assert.equal(snapshot(), before);
passed('unstaged reconciliation conflict restores original state');
reset();
// Benchmark the rejected combined candidate using the same hook and state protections.
const selectedConfig = read('agiledancers-fe/lint-staged.config.mjs');
write(
'agiledancers-fe/lint-staged.combined.mjs',
`import base from './lint-staged.config.mjs';
export default Object.fromEntries(Object.entries(base).map(([pattern, task]) => [pattern, files => {
const commands = task(files);
const lintable = files.filter(file => /\\.(ts|js|mjs|cjs|html)$/.test(file));
return commands.length && lintable.length
? [...commands, 'eslint --max-warnings 0 ' + lintable.map(file => JSON.stringify(file)).join(' ')] : commands;
}]));\n`,
);
const selectedHook = read('.husky/pre-commit');
write(
'.husky/pre-commit',
selectedHook.replace(
'--concurrent false',
'--concurrent false --config lint-staged.combined.mjs',
),
);
write(fixture, baseline.replace('{ value: 1 }', '{value:2}'));
write(
'agiledancers-fe/src/app/app.ts',
read('agiledancers-fe/src/app/app.ts') + '\nexport const badLint: any = 1;\n',
);
git('add', fixture, 'agiledancers-fe/src/app/app.ts');
before = snapshot();
const lintFailure = hook();
assert.notEqual(lintFailure.exit, 0);
assert.match(lintFailure.output, /no-explicit-any/);
assert.equal(snapshot(), before);
passed('combined candidate restores formatting after ESLint failure');
reset();
const all = git('ls-files', 'agiledancers-fe')
.split('\n')
.filter((file) => !file.endsWith('/package-lock.json'));
const scopes = {
single: ['agiledancers-fe/src/app/app.ts'],
mixed: [
'agiledancers-fe/src/app/app.ts',
'agiledancers-fe/src/app/app.html',
'agiledancers-fe/src/app/app.css',
'agiledancers-fe/angular.json',
'agiledancers-fe/eslint.config.mjs',
'agiledancers-fe/e2e/school-management.spec.ts',
],
all,
};
const timings = [];
for (const mode of ['combined', 'prettier-only']) {
for (const [scope, files] of Object.entries(scopes)) {
for (let repeat = 1; repeat <= 3; repeat++) {
reset();
write(
'.husky/pre-commit',
mode === 'combined'
? selectedHook.replace(
'--concurrent false',
'--concurrent false --config lint-staged.combined.mjs',
)
: selectedHook,
);
for (const file of files) {
const addition = /\.(ts|js|mjs|cjs)$/.test(file)
? '\n// Hook timing fixture.\n'
: file.endsWith('.html')
? '\n<!-- Hook timing fixture. -->\n'
: file.endsWith('.css')
? '\n/* Hook timing fixture. */\n'
: '\n';
write(file, read(file) + addition);
}
git('add', '--', ...files);
const measurement = hook();
assert.equal(measurement.exit, 0, measurement.output);
timings.push({ mode, scope, repeat, files, seconds: measurement.seconds });
console.log(`TIMING ${mode} ${scope} ${repeat}: ${measurement.seconds}s`);
}
}
}
assert.equal(read('agiledancers-fe/lint-staged.config.mjs').includes(selectedConfig.trim()), true);
const report = {
machine: cpus()[0].model,
system: `${platform()} ${release()}`,
node: process.version,
npm: run('npm', ['--version']),
root,
timings,
};
writeFileSync(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n');
console.log(`Evidence: ${path.join(root, 'report.json')}`);
const selected = timings.filter((result) => result.mode === 'prettier-only');
assert.ok(
selected.every((result) => result.seconds <= 3),
'Prettier-only exceeds the three-second budget; do not enable the hook.',
);

View File

@@ -0,0 +1,13 @@
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import husky from 'husky';
// Docker uses CI=true; CI and local opt-out must never change Git configuration.
if (!process.env.CI && process.env.HUSKY !== '0') {
const root = fileURLToPath(new URL('../../', import.meta.url));
if (existsSync(new URL('../../.git', import.meta.url))) {
process.chdir(root);
const error = husky('.husky');
if (error) throw new Error(error);
}
}

View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "types": ["vitest/globals", "node"], "noEmit": true },
"include": ["src/**/*.ts", "e2e/**/*.ts", "*.ts"],
"exclude": [
"node_modules",
"dist",
".angular",
"coverage",
"test-results",
"unit-results",
"playwright-report"
]
}

View File

@@ -102,6 +102,21 @@ class CiTests {
equal(0L,f.calls("build")); equal(0L,f.calls("push")); equal(0L,f.calls("build")); equal(0L,f.calls("push"));
} }
}); });
test("Frontend lint runs after install and blocks tests and build on failure", () -> {
try (Fixture f = new Fixture()) {
f.fake.lintFailure = true;
try { f.pipeline.verifyFrontend(); throw new AssertionError("Expected failure"); }
catch (Commands.Failure error) { equal(1,error.exitCode); }
equal(List.of(List.of("npm", "ci"), List.of("npm", "run", "lint")), f.fake.calls);
}
});
test("Frontend verification checks before testing and building", () -> {
try (Fixture f = new Fixture()) {
f.pipeline.verifyFrontend();
equal(List.of("ci", "run lint", "test -- --reporters=default --reporters=junit --output-file=unit-results/junit.xml", "run build"),
f.fake.calls.stream().map(a -> String.join(" ", a.subList(1,a.size()))).toList());
}
});
test("Process execution enforces its deadline", () -> { test("Process execution enforces its deadline", () -> {
Path dir = Files.createTempDirectory("ci-process-test-"); Path dir = Files.createTempDirectory("ci-process-test-");
try { try {
@@ -135,13 +150,14 @@ class CiTests {
final List<List<String>> calls = new ArrayList<>(); final List<List<String>> calls = new ArrayList<>();
final Map<String,String> images = new HashMap<>(), tags = new HashMap<>(); final Map<String,String> images = new HashMap<>(), tags = new HashMap<>();
String head = SHA, pushFailure = "", pullFailure = ""; String head = SHA, pushFailure = "", pullFailure = "";
boolean manifestError, mavenFailure; boolean manifestError, mavenFailure, lintFailure;
boolean dockerAvailable = true; boolean dockerAvailable = true;
FakeCommands() { super(Duration.ofMinutes(1)); } FakeCommands() { super(Duration.ofMinutes(1)); }
@Override Result execute(List<String> args, Path cwd, Map<String,String> env, String input, boolean echo) { @Override Result execute(List<String> args, Path cwd, Map<String,String> env, String input, boolean echo) {
calls.add(List.copyOf(args)); calls.add(List.copyOf(args));
if (args.getFirst().equals("git")) return new Result(0,SHA); if (args.getFirst().equals("git")) return new Result(0,SHA);
if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42,"deliberate failure"); if (args.getFirst().equals("mvn") && mavenFailure) return new Result(42,"deliberate failure");
if (args.equals(List.of("npm", "run", "lint")) && lintFailure) return new Result(1,"deliberate lint failure");
if (!args.getFirst().equals("docker")) return new Result(0,""); if (!args.getFirst().equals("docker")) return new Result(0,"");
String command = args.get(1), ref = args.getLast(); String command = args.get(1), ref = args.getLast();
if (command.equals("info")) return new Result(dockerAvailable ? 0 : 1,args.contains("{{.Architecture}}") ? "amd64" : "linux"); if (command.equals("info")) return new Result(dockerAvailable ? 0 : 1,args.contains("{{.Architecture}}") ? "amd64" : "linux");

View File

@@ -69,10 +69,7 @@ class Pipeline {
run("java", "-version"); run("node", "--version"); run("mvn", "-B", "-ntp", "clean", "verify"); run("java", "-version"); run("node", "--version"); run("mvn", "-B", "-ntp", "clean", "verify");
CiTests.run(); CiTests.run();
Path frontend = root.resolve("agiledancers-fe"); Path frontend = root.resolve("agiledancers-fe");
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked(); verifyFrontend();
commands.execute(List.of("npm", "test", "--", "--reporters=default", "--reporters=junit",
"--output-file=unit-results/junit.xml"), frontend, env, null, true).checked();
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true).checked();
String platform = env.get("TARGET_PLATFORM"); String platform = env.get("TARGET_PLATFORM");
run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label", run("docker", "build", "--platform", platform, "--target", "prebuilt", "--label",
"org.opencontainers.image.revision=" + commit, "-t", backendImage, "."); "org.opencontainers.image.revision=" + commit, "-t", backendImage, ".");
@@ -101,6 +98,14 @@ class Pipeline {
} }
} }
} }
void verifyFrontend() throws Exception {
Path frontend = root.resolve("agiledancers-fe");
commands.execute(List.of("npm", "ci"), frontend, env, null, true).checked();
commands.execute(List.of("npm", "run", "lint"), frontend, env, null, true).checked();
commands.execute(List.of("npm", "test", "--", "--reporters=default", "--reporters=junit",
"--output-file=unit-results/junit.xml"), frontend, env, null, true).checked();
commands.execute(List.of("npm", "run", "build"), frontend, env, null, true).checked();
}
void waitForApplication() throws Exception { void waitForApplication() throws Exception {
for (int i = 0; i < 60; i++) { for (int i = 0; i < 60; i++) {
if (result(false,"docker", "exec", runName + "-frontend", "wget", "-q", "-O", "-", if (result(false,"docker", "exec", runName + "-frontend", "wget", "-q", "-O", "-",

184
docs/s2-hook-timings.json Normal file
View File

@@ -0,0 +1,184 @@
{
"date": "2026-10-29",
"machine": "AMD Ryzen 7 7730U with Radeon Graphics",
"system": "linux 7.0.0-38-generic",
"node": "v22.22.3",
"npm": "10.9.8",
"scopes": {
"single": [
"agiledancers-fe/src/app/app.ts"
],
"mixed": [
"agiledancers-fe/src/app/app.ts",
"agiledancers-fe/src/app/app.html",
"agiledancers-fe/src/app/app.css",
"agiledancers-fe/angular.json",
"agiledancers-fe/eslint.config.mjs",
"agiledancers-fe/e2e/school-management.spec.ts"
],
"all": [
"agiledancers-fe/.dockerignore",
"agiledancers-fe/.editorconfig",
"agiledancers-fe/.gitignore",
"agiledancers-fe/.nvmrc",
"agiledancers-fe/.prettierignore",
"agiledancers-fe/.prettierrc",
"agiledancers-fe/.vscode/extensions.json",
"agiledancers-fe/.vscode/launch.json",
"agiledancers-fe/.vscode/tasks.json",
"agiledancers-fe/Dockerfile",
"agiledancers-fe/README.md",
"agiledancers-fe/angular.json",
"agiledancers-fe/e2e/school-management.spec.ts",
"agiledancers-fe/eslint.config.mjs",
"agiledancers-fe/lint-staged.config.mjs",
"agiledancers-fe/nginx/default.conf.template",
"agiledancers-fe/package.json",
"agiledancers-fe/playwright.config.ts",
"agiledancers-fe/proxy.conf.json",
"agiledancers-fe/public/favicon.svg",
"agiledancers-fe/src/app/app.config.ts",
"agiledancers-fe/src/app/app.css",
"agiledancers-fe/src/app/app.html",
"agiledancers-fe/src/app/app.routes.ts",
"agiledancers-fe/src/app/app.spec.ts",
"agiledancers-fe/src/app/app.ts",
"agiledancers-fe/src/app/core/api-error.ts",
"agiledancers-fe/src/app/core/api-models.ts",
"agiledancers-fe/src/app/core/school-api.spec.ts",
"agiledancers-fe/src/app/core/school-api.ts",
"agiledancers-fe/src/app/schools/school-page.html",
"agiledancers-fe/src/app/schools/school-page.ts",
"agiledancers-fe/src/app/schools/schools-page.html",
"agiledancers-fe/src/app/schools/schools-page.ts",
"agiledancers-fe/src/app/shared/confirm-dialog.ts",
"agiledancers-fe/src/app/shared/dialog-frame.ts",
"agiledancers-fe/src/app/shared/entity-editor.spec.ts",
"agiledancers-fe/src/app/shared/entity-editor.ts",
"agiledancers-fe/src/app/shared/group-members.spec.ts",
"agiledancers-fe/src/app/shared/group-members.ts",
"agiledancers-fe/src/app/shared/school-transfer.spec.ts",
"agiledancers-fe/src/app/shared/school-transfer.ts",
"agiledancers-fe/src/hook-fixture.ts",
"agiledancers-fe/src/index.html",
"agiledancers-fe/src/main.ts",
"agiledancers-fe/src/styles.css",
"agiledancers-fe/tools/check-hook.mjs",
"agiledancers-fe/tools/install-hooks.mjs",
"agiledancers-fe/tsconfig.app.json",
"agiledancers-fe/tsconfig.eslint.json",
"agiledancers-fe/tsconfig.json",
"agiledancers-fe/tsconfig.spec.json"
]
},
"timings": [
{
"mode": "combined",
"scope": "single",
"repeat": 1,
"seconds": 2.875
},
{
"mode": "combined",
"scope": "single",
"repeat": 2,
"seconds": 2.776
},
{
"mode": "combined",
"scope": "single",
"repeat": 3,
"seconds": 2.894
},
{
"mode": "combined",
"scope": "mixed",
"repeat": 1,
"seconds": 3.779
},
{
"mode": "combined",
"scope": "mixed",
"repeat": 2,
"seconds": 4.099
},
{
"mode": "combined",
"scope": "mixed",
"repeat": 3,
"seconds": 4.478
},
{
"mode": "combined",
"scope": "all",
"repeat": 1,
"seconds": 5.916
},
{
"mode": "combined",
"scope": "all",
"repeat": 2,
"seconds": 6.137
},
{
"mode": "combined",
"scope": "all",
"repeat": 3,
"seconds": 4.728
},
{
"mode": "prettier-only",
"scope": "single",
"repeat": 1,
"seconds": 0.313
},
{
"mode": "prettier-only",
"scope": "single",
"repeat": 2,
"seconds": 0.338
},
{
"mode": "prettier-only",
"scope": "single",
"repeat": 3,
"seconds": 0.401
},
{
"mode": "prettier-only",
"scope": "mixed",
"repeat": 1,
"seconds": 0.648
},
{
"mode": "prettier-only",
"scope": "mixed",
"repeat": 2,
"seconds": 0.72
},
{
"mode": "prettier-only",
"scope": "mixed",
"repeat": 3,
"seconds": 0.647
},
{
"mode": "prettier-only",
"scope": "all",
"repeat": 1,
"seconds": 1.573
},
{
"mode": "prettier-only",
"scope": "all",
"repeat": 2,
"seconds": 1.441
},
{
"mode": "prettier-only",
"scope": "all",
"repeat": 3,
"seconds": 1.276
}
]
}

126
docs/s2-validation.md Normal file
View File

@@ -0,0 +1,126 @@
# S2 implementation evidence
Validated on the development mini-PC on 2026-10-29: AMD Ryzen 7 7730U (8 cores,
16 threads), Ubuntu 26.04.1 LTS, Linux 7.0.0-38-generic x86_64, approximately
27 GiB RAM, Node v22.22.3, npm 10.9.8. The standalone Docker build uses the
existing Node 24.15.0 image.
## Lint scope and rules
ESLint 10.13.0, typescript-eslint 8.72.1 and angular-eslint 22.6.0 support the
existing Angular 22 and TypeScript 6.0.3 versions. Framework versions are unchanged.
The flat configuration applies recommended JavaScript, recommended type-checked
TypeScript and recommended Angular rules, plus the recommended template rules
for both inline and external templates. No rule suppressions were needed.
`eslint-config-prettier` prevents conflicts with the existing formatting settings.
`tsconfig.eslint.json` supplies one explicit type-checking project covering
application files, unit tests, Playwright tests and root TypeScript configuration.
This includes E2E and Playwright configuration outside Angular's app/test
compilation projects, without changing the application compilation scope.
Node types resolve `process.env` in those files. The existing test findings were
fixed by treating Playwright response JSON as unknown before checking its shape,
and returning an explicit resolved promise from the fake file reader.
Injected probes confirmed `no-floating-promises` for application, unit-test,
Playwright and configuration TypeScript; `no-unused-vars` for JavaScript;
and `template/no-negated-async` in both external and inline Angular templates.
Generated files are excluded. A warning-only CLI probe returned exit status 1
with `--max-warnings 0`.
## Complete hook measurements
Run from the repository root, with dependencies installed:
```sh
node agiledancers-fe/tools/check-hook.mjs
```
This creates an isolated temporary Git repository from the current frontend and
hook sources, links the installed dependency directory, makes an initial fixture
commit and installs the real Husky wrapper. The real checkout and its index are
not staged or committed by the checks. The temporary repository is retained and
its report path is printed so failures can be inspected.
Each timed invocation runs `sh .husky/_/pre-commit` in a new process. Timing
starts before Husky and includes file selection, lint-staged backup, hiding
unstaged edits, fresh tool startup, formatting/checking, restaging and cleanup.
No ESLint or Prettier cache is enabled. The final measurement ran while the
standalone Docker build was also running; all selected-hook runs still passed. Three fresh executions per scenario and
candidate are recorded; there is no runtime timeout interrupting writers.
The combined candidate uses the same hook, adding type-aware ESLint after
Prettier. The selected configuration uses only Prettier. Staged scopes are:
- Single: `src/app/app.ts`.
- Mixed: application TS, HTML and CSS, `angular.json`, `eslint.config.mjs` and
the Playwright spec (six files).
- All: all 52 maintained frontend files, including non-applicable files that the
hook must skip, plus a small TS preservation-test fixture; generated lockfile
excluded. Exact paths and raw measurements are in
[the timing report](s2-hook-timings.json).
| Candidate | Staged scope | Run 1 | Run 2 | Run 3 |
| --- | --- | --- | --- | --- |
| combined | single | 2.875 s | 2.776 s | 2.894 s |
| combined | mixed | 3.779 s | 4.099 s | 4.478 s |
| combined | all | 5.916 s | 6.137 s | 4.728 s |
| prettier-only | single | 0.313 s | 0.338 s | 0.401 s |
| prettier-only | mixed | 0.648 s | 0.720 s | 0.647 s |
| prettier-only | all | 1.573 s | 1.441 s | 1.276 s |
The combined candidate exceeds three seconds, so it is rejected. Every measured
Prettier-only execution is below three seconds, so that hook is installed. These
are measurements on this mini-PC, not a guarantee for other machines or future
repository sizes. Rerun the checks when the tooling or scope changes.
## Preservation and installation checks
The isolated checks assert all of the following against the real hook, comparing
both cached and unstaged binary diffs, status and stash state where appropriate:
- Partially staged formatting updates only the intended index version and
retains independent unstaged changes in the same file.
- An unrelated file with both staged and unstaged edits remains intact.
- A commit without frontend changes leaves the state unchanged.
- `package-lock.json` is not formatted or restaged.
- A formatter syntax error after another file has been formatted aborts and
restores the original index and worktree.
- A conflict restoring an unstaged change on the formatted line aborts and
restores the original index and worktree.
- The rejected combined candidate also restores all changes when ESLint fails
after successful formatting.
- No backup stash remains after successful recovery.
- `CI=true` and `HUSKY=0` leave an existing hooks configuration untouched;
normal local installation selects `.husky/_`.
A real local `npm ci` successfully installed `.husky/_` in this checkout.
Docker sets `CI=true` for installation and copies the prepare entry point before
`npm ci`, so the lifecycle script can safely skip hook installation. The
standalone build runs lint, tests and production compilation after copying sources.
## Build and pipeline verification
The full Java-based pipeline was executed locally without invoking publication:
```sh
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 \
PATH=/usr/lib/jvm/java-25-openjdk-amd64/bin:/home/wanja-krah/.nvm/versions/node/v22.22.3/bin:/usr/local/bin:/usr/bin:/bin \
TARGET_PLATFORM=linux/amd64 CI_RUN_ID=s2-local CI_RUN_ATTEMPT=2 \
java ci/Ci.java verify
```
Results: 20 backend tests, 17 CI-helper tests, clean frontend lint, 14 frontend
unit tests, production build, both application images and both container E2E
tests passed. Diagnostics remain in the ignored `ci/artifacts/` directory.
A Gitea workflow run for these uncommitted changes has not been executed.
The CI-helper regression checks assert that lint immediately follows `npm ci`
and precedes tests/build; a lint failure stops frontend verification with its
nonzero status. Existing failed-verification and publication guards ensure only
a completely verified commit can publish. The shared command runner writes lint
output to the job log and retained `verify.log`. CI never invokes `lint:fix`.
`npm run lint:fix` followed by `npm run lint` also passed without further changes.
The standalone Docker build was checked separately with an isolated Docker
configuration to avoid the desktop credential helper; no images were published.

View File

@@ -1,6 +1,6 @@
# S2 — ESLint for the frontend # S2 — ESLint for the frontend
Status: REFINED Status: IN_REVIEW
Agreed after the grill-me review; shared understanding confirmed. Agreed after the grill-me review; shared understanding confirmed.
@@ -183,36 +183,43 @@ consistent. Record the performance evidence with the implementation documentatio
## Acceptance criteria ## Acceptance criteria
- [ ] Compatible tooling checks maintained application TypeScript/JavaScript, - [x] Compatible tooling checks maintained application TypeScript/JavaScript,
Angular templates, unit tests, Playwright tests and applicable frontend Angular templates, unit tests, Playwright tests and applicable frontend
configuration using the agreed recommended and type-aware rules. configuration using the agreed recommended and type-aware rules.
- [ ] All existing violations are fixed; there is no legacy baseline allowance. - [x] All existing violations are fixed; there is no legacy baseline allowance.
Frontend builds, unit tests and container end-to-end tests still pass. Frontend builds, unit tests and container end-to-end tests still pass.
- [ ] `npm run lint` checks the full scope without modifications and fails on - [x] `npm run lint` checks the full scope without modifications and fails on
either errors or warnings. either errors or warnings.
- [ ] `npm run lint:fix` offers explicit local autofixes; CI does not modify source. - [x] `npm run lint:fix` offers explicit local autofixes; CI does not modify source.
- [ ] Formatting remains under Prettier, without conflicting ESLint rules. - [x] Formatting remains under Prettier, without conflicting ESLint rules.
- [ ] Any necessary rule suppressions are narrow and concretely justified. - [x] Any necessary rule suppressions are narrow and concretely justified.
- [ ] Every branch pipeline runs lint immediately after frontend `npm ci` and - [x] Every branch pipeline runs lint immediately after frontend `npm ci` and
before frontend tests/builds and container E2E; lint failure prevents before frontend tests/builds and container E2E; lint failure prevents
publication and leaves actionable diagnostic output. publication and leaves actionable diagnostic output.
- [ ] VS Code recommends ESLint without mandatory fix/format-on-save settings. - [x] VS Code recommends ESLint without mandatory fix/format-on-save settings.
- [ ] Complete-hook measurements cover the agreed single-file, mixed-file and - [x] Complete-hook measurements cover the agreed single-file, mixed-file and
all-maintained-files cases with fresh processes and no tool cache. all-maintained-files cases with fresh processes and no tool cache.
- [ ] The measured three-second budget determines combined tools, Prettier-only - [x] The measured three-second budget determines combined tools, Prettier-only
or no active hook; no writing formatter is forcibly interrupted to meet it. or no active hook; no writing formatter is forcibly interrupted to meet it.
- [ ] If installed, the hook processes only applicable staged frontend files, - [x] If installed, the hook processes only applicable staged frontend files,
applies Prettier formatting before any ESLint check, and preserves unrelated applies Prettier formatting before any ESLint check, and preserves unrelated
files and unstaged edits, including partially staged files. files and unstaged edits, including partially staged files.
- [ ] A failed installed hook restores the original staged and unstaged state and - [x] A failed installed hook restores the original staged and unstaged state and
blocks the commit with a concrete explanation. blocks the commit with a concrete explanation.
- [ ] Hook installation is automatic for local `npm ci` when applicable, skipped - [x] Hook installation is automatic for local `npm ci` when applicable, skipped
in CI/Docker, and has documented local opt-out instructions. in CI/Docker, and has documented local opt-out instructions.
- [ ] Root `README.md` explains `npm run lint`, `npm run lint:fix`, CI enforcement - [x] Root `README.md` explains `npm run lint`, `npm run lint:fix`, CI enforcement
and the selected hook behavior; measurement evidence is included. and the selected hook behavior; measurement evidence is included.
## Implementation handoff ## Implementation handoff
This specification records agreed behavior. It does not install ESLint, change The agreed behavior has been implemented and is ready for review. See the
application code, modify the pipeline, install a hook or update command usage in implementation evidence below.
the README yet. Those changes and the timing measurements are implementation work.
## Implementation evidence
Implemented with ESLint flat configuration, type-aware TypeScript and Angular
template checks. The measured three-second budget selects a Prettier-only
Husky/lint-staged hook; ESLint remains mandatory in the Java-based pipeline.
See [validation and timings](../../docs/s2-validation.md) for scope, preservation
checks, local installation and successful builds/unit/container E2E tests.