diff --git a/AGENTS.md b/AGENTS.md index 5adb890..109bd14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ npm ci npm run verify ``` -Gate non-mutating и последовательно проверяет runtime, hygiene, ESLint, application/test TypeScript, risk-based Jest suite и build. `npm run lint:fix` и `npm run format` изменяют files и выполняются только явно; `npm run test:cov` — диагностика без общего threshold. `npm run test:e2e` не входит в passing gate до WL-P2-05 trigger из remediation registry. +Gate non-mutating и последовательно проверяет ESLint, application/test TypeScript, risk-based Jest suite и build. `npm run lint:fix` и `npm run format` изменяют files и выполняются только явно; `npm run test:cov` — диагностика без общего threshold. `npm run test:e2e` не входит в passing gate до WL-P2-05 trigger из remediation registry. `npm run test:openapi-contract` — отдельный contract gate для изменений обеих сторон API contract. Актуальные scripts проверять в `package.json`/`npm run`. ## Enforceable boundaries diff --git a/README.md b/README.md index 995fa21..5fc4467 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ engine останавливает migration до запуска Jest. Точны Из workspace runtime OpenAPI можно read-only сверить с frontend snapshot: ```bash -bash specs/scripts/verify-runtime-openapi.sh +npm run test:openapi-contract ``` Полный multi-repository change проверяется из workspace через -`bash specs/scripts/verify-workspace-change.sh `. +native OpenSpec validation and the canonical gates of each affected repository. diff --git a/package.json b/package.json index 607769f..1527da7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "node": "22.x" }, "scripts": { - "runtime:check": "node scripts/verify-node-version.mjs", "build": "prisma generate && nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", @@ -19,7 +18,6 @@ "lint": "npm run lint:check", "lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "hygiene:check": "node scripts/repository-hygiene-check.mjs", "typecheck:app": "tsc --project tsconfig.typecheck.app.json", "typecheck:test": "tsc --project tsconfig.typecheck.test.json", "test": "jest", @@ -27,7 +25,8 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "DATABASE_URL=file:/tmp/wealth-ledger-e2e.db prisma migrate deploy && NODE_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json", - "verify": "npm run runtime:check && npm run hygiene:check && npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build" + "test:openapi-contract": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json --runInBand openapi-contract.e2e-spec.ts", + "verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build" }, "dependencies": { "@grpc/grpc-js": "^1.14.4", diff --git a/scripts/repository-hygiene-check.mjs b/scripts/repository-hygiene-check.mjs deleted file mode 100644 index 1197bdc..0000000 --- a/scripts/repository-hygiene-check.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); - -function git(args) { - return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); -} - -function isIgnored(file) { - try { - execFileSync('git', ['check-ignore', '--no-index', '-q', file], { - cwd: root, - stdio: 'ignore', - }); - return true; - } catch (error) { - if (error.status === 1) return false; - throw error; - } -} - -function assert(condition, message) { - if (!condition) throw new Error(message); -} - -const trackedDatabases = git([ - 'ls-files', - '--', - 'dev.db', - 'dev.db-*', - 'prisma/*.db', - 'prisma/*.db-*', -]) - .split('\n') - .filter(Boolean); - -assert( - trackedDatabases.length === 0, - `Runtime SQLite files in supported locations must not be tracked; found: ${trackedDatabases.join(', ')}`, -); - -for (const file of [ - 'dev.db', - 'dev.db-wal', - 'dev.db-shm', - 'prisma/dev.db', - 'prisma/dev.db-wal', - 'prisma/dev.db-shm', -]) { - assert(isIgnored(file), `Runtime SQLite file must be ignored: ${file}`); -} - -assert( - !isIgnored('test/fixtures/reference.db'), - 'Intentional fixture must remain versionable', -); - -console.log('Backend repository hygiene verified'); diff --git a/scripts/verify-node-version.mjs b/scripts/verify-node-version.mjs deleted file mode 100644 index d940889..0000000 --- a/scripts/verify-node-version.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const packageJson = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); -const nvmVersion = readFileSync(resolve(root, '.nvmrc'), 'utf8').trim(); -const expectedMajor = nvmVersion.replace(/^v/, '').split('.')[0]; -const expectedEngine = `${expectedMajor}.x`; - -if (packageJson.engines?.node !== expectedEngine) { - throw new Error( - `Node runtime contracts disagree: .nvmrc=${nvmVersion}, package.json engines.node=${packageJson.engines?.node ?? 'missing'}`, - ); -} - -const actualVersion = process.versions.node; -const actualMajor = actualVersion.split('.')[0]; -if (actualMajor !== expectedMajor) { - console.error( - `Unsupported Node.js runtime: expected ${expectedEngine}, actual ${actualVersion}. Activate .nvmrc before running repository checks.`, - ); - process.exit(1); -} - -console.log(`Node.js runtime verified: ${actualVersion} (${expectedEngine})`); diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts new file mode 100644 index 0000000..e09eb34 --- /dev/null +++ b/test/openapi-contract.e2e-spec.ts @@ -0,0 +1,52 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { AppModule } from './../src/app.module'; +import { configureApp } from './../src/main'; + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nestedValue]) => [key, canonicalize(nestedValue)]), + ); + } + return value; +} + +describe('OpenAPI contract (integration)', () => { + let app: INestApplication; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + app = module.createNestApplication(); + configureApp(app); + await app.init(); + }); + + afterAll(() => app.close()); + + it('matches the committed frontend OpenAPI snapshot', async () => { + const runtimeDocument = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('WealthLedger API') + .setVersion('v1') + .build(), + ); + const snapshot = JSON.parse( + await readFile( + resolve(__dirname, '../../frontend/src/shared/api/spec/openapi.json'), + 'utf8', + ), + ) as unknown; + + expect(canonicalize(runtimeDocument)).toEqual(canonicalize(snapshot)); + }); +});