refactor: strengthen client boundaries and quality gates
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..');
|
||||
const mode = process.argv[2];
|
||||
if (!['lint', 'format'].includes(mode)) throw new Error('Usage: node run-changed-code-quality.mjs <lint|format>');
|
||||
|
||||
const base = process.env.QUALITY_BASE_REF ?? 'HEAD';
|
||||
const tracked = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMR', base, '--'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
|
||||
const files = [...new Set(`${tracked}\n${untracked}`.split(/\r?\n/).filter(Boolean))].filter((file) =>
|
||||
/^(?:src|api\/src|tools)\/.+\.(?:[cm]?[jt]sx?)$/.test(file),
|
||||
);
|
||||
|
||||
if (!files.length) {
|
||||
console.log(`No changed code files require ${mode}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const executable =
|
||||
process.platform === 'win32'
|
||||
? `${mode === 'lint' ? 'eslint' : 'prettier'}.cmd`
|
||||
: mode === 'lint'
|
||||
? 'eslint'
|
||||
: 'prettier';
|
||||
const args = mode === 'lint' ? files : ['--check', ...files];
|
||||
const result = spawnSync(resolve(root, 'node_modules/.bin', executable), args, { cwd: root, stdio: 'inherit' });
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -5,15 +5,24 @@ import { resolve } from 'node:path';
|
||||
const root = resolve(import.meta.dirname, '../..');
|
||||
const violations = [];
|
||||
|
||||
const productionFiles = execFileSync('git', ['ls-files', 'src/apps/**/*.ts', 'src/apps/**/*.tsx', 'src/components/**/*.ts', 'src/components/**/*.tsx'], { cwd: root, encoding: 'utf8' })
|
||||
.split(/\r?\n/).filter(Boolean);
|
||||
const productionFiles = execFileSync(
|
||||
'git',
|
||||
['ls-files', 'src/apps/**/*.ts', 'src/apps/**/*.tsx', 'src/components/**/*.ts', 'src/components/**/*.tsx'],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
)
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean);
|
||||
for (const file of productionFiles) {
|
||||
const content = readFileSync(resolve(root, file), 'utf8');
|
||||
if (/from\s+['"]@\/mock(?:\/|['"])/.test(content)) violations.push(`${file}: production code imports @/mock`);
|
||||
}
|
||||
|
||||
const clientControllers = execFileSync('git', ['ls-files', 'api/src/**/*.controller.ts'], { cwd: root, encoding: 'utf8' })
|
||||
.split(/\r?\n/).filter(Boolean);
|
||||
const clientControllers = execFileSync('git', ['ls-files', 'api/src/**/*.controller.ts'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean);
|
||||
for (const file of clientControllers) {
|
||||
const content = readFileSync(resolve(root, file), 'utf8');
|
||||
const clientClassOffset = content.indexOf('export class Client');
|
||||
@@ -23,8 +32,26 @@ for (const file of clientControllers) {
|
||||
}
|
||||
}
|
||||
|
||||
const trackedBuildCaches = execFileSync('git', ['ls-files', '*.tsbuildinfo', '**/*.tsbuildinfo'], { cwd: root, encoding: 'utf8' }).trim();
|
||||
if (trackedBuildCaches) violations.push(`tracked TypeScript build caches: ${trackedBuildCaches.replace(/\r?\n/g, ', ')}`);
|
||||
const clientApi = readFileSync(resolve(root, 'src/api/client/client.api.ts'), 'utf8');
|
||||
if (/DEFAULT_CLIENT_TENANT_ID|getSessionTenantId|x-tenant-id/.test(clientApi)) {
|
||||
violations.push(
|
||||
'src/api/client/client.api.ts: client requests must derive tenant scope from the authenticated server session',
|
||||
);
|
||||
}
|
||||
const httpClient = readFileSync(resolve(root, 'src/api/core/httpClient.ts'), 'utf8');
|
||||
if (
|
||||
/DEFAULT_CLIENT_TENANT_ID|getSessionTenantId/.test(httpClient) ||
|
||||
!httpClient.includes("options.tenantId && !path.startsWith('/client')")
|
||||
) {
|
||||
violations.push('src/api/core/httpClient.ts: tenant headers must be reserved for explicit non-client operations');
|
||||
}
|
||||
|
||||
const trackedBuildCaches = execFileSync('git', ['ls-files', '*.tsbuildinfo', '**/*.tsbuildinfo'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
if (trackedBuildCaches)
|
||||
violations.push(`tracked TypeScript build caches: ${trackedBuildCaches.replace(/\r?\n/g, ', ')}`);
|
||||
|
||||
const usersService = readFileSync(resolve(root, 'api/src/users/users.service.ts'), 'utf8');
|
||||
if (/createHash\(['"]sha256['"]\)/.test(usersService)) {
|
||||
@@ -35,14 +62,20 @@ if (/passwordHash\s*===|===\s*[^\n;]*passwordHash/.test(authService)) {
|
||||
violations.push('api/src/auth/auth.service.ts: password hashes must not be compared directly');
|
||||
}
|
||||
const ensureAdmin = readFileSync(resolve(root, 'tools/deploy/ensure-production-admin.mjs'), 'utf8');
|
||||
if (/createHash\(['"]sha256['"]\)|function\s+hashPassword\s*\(/.test(ensureAdmin)
|
||||
|| !ensureAdmin.includes("api/dist/auth/password-hasher.js")) {
|
||||
violations.push('tools/deploy/ensure-production-admin.mjs: administrative password writes must use the API password hasher');
|
||||
if (
|
||||
/createHash\(['"]sha256['"]\)|function\s+hashPassword\s*\(/.test(ensureAdmin) ||
|
||||
!ensureAdmin.includes('api/dist/auth/password-hasher.js')
|
||||
) {
|
||||
violations.push(
|
||||
'tools/deploy/ensure-production-admin.mjs: administrative password writes must use the API password hasher',
|
||||
);
|
||||
}
|
||||
for (const relativePath of ['tools/deploy/ensure-production-admin.mjs', 'tools/smoke/real-env-smoke.mjs']) {
|
||||
const content = readFileSync(resolve(root, relativePath), 'utf8');
|
||||
if (/function\s+hashPassword\s*\(|passwordHash:\s*(?:createHash|legacyHashPassword)/.test(content)
|
||||
|| !content.includes("api/dist/auth/password-hasher.js")) {
|
||||
if (
|
||||
/function\s+hashPassword\s*\(|passwordHash:\s*(?:createHash|legacyHashPassword)/.test(content) ||
|
||||
!content.includes('api/dist/auth/password-hasher.js')
|
||||
) {
|
||||
violations.push(`${relativePath}: user password writes must use the compiled API password hasher`);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +87,18 @@ if (packageJson.dependencies?.['react-router-dom'] !== '7.18.2') {
|
||||
if (packageJson.overrides?.nanoid !== '3.3.18') {
|
||||
violations.push('package.json: nanoid override must remain on the remediated 3.3.18 baseline');
|
||||
}
|
||||
const trackedAlternativeLocks = execFileSync('git', ['ls-files', 'pnpm-lock.yaml', 'yarn.lock'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
if (trackedAlternativeLocks) {
|
||||
violations.push(
|
||||
`${trackedAlternativeLocks.replace(/\r?\n/g, ', ')}: npm/package-lock.json is the only supported committed dependency lock`,
|
||||
);
|
||||
}
|
||||
if (packageJson.packageManager !== 'npm@11.6.2') {
|
||||
violations.push('package.json: packageManager must pin the supported npm baseline');
|
||||
}
|
||||
|
||||
if (violations.length) {
|
||||
console.error(violations.map((item) => `ERROR: ${item}`).join('\n'));
|
||||
|
||||
@@ -9,7 +9,10 @@ const apiLock = JSON.parse(readFileSync(join(workspaceRoot, 'api', 'package-lock
|
||||
assertVersionAtLeast(rootLock.packages['node_modules/postcss']?.version, [8, 5, 18], 'postcss');
|
||||
assertVersionAtLeast(rootLock.packages['node_modules/react-router']?.version, [7, 18, 2], 'react-router');
|
||||
assertVersionAtLeast(rootLock.packages['node_modules/nanoid']?.version, [3, 3, 18], 'nanoid');
|
||||
assertEqual(apiLock.packages['node_modules/brace-expansion-safe']?.version, '5.0.8', 'brace-expansion-safe');
|
||||
assertEqual(apiLock.packages['node_modules/brace-expansion-safe']?.version, '5.0.9', 'brace-expansion-safe');
|
||||
assertEqual(apiLock.packages['node_modules/fast-uri']?.version, '3.1.5', 'fast-uri');
|
||||
assertEqual(apiLock.packages['node_modules/js-yaml']?.version, '4.3.1', 'js-yaml');
|
||||
assertEqual(apiLock.packages['node_modules/@nestjs/swagger']?.version, '11.4.7', '@nestjs/swagger');
|
||||
assertEqual(
|
||||
apiLock.packages['node_modules/brace-expansion']?.resolved,
|
||||
'vendor/brace-expansion-compat',
|
||||
@@ -35,7 +38,7 @@ for (const filePath of sourceFiles(join(workspaceRoot, 'src'))) {
|
||||
const apiRequire = createRequire(join(workspaceRoot, 'api', 'package.json'));
|
||||
const expand = apiRequire('brace-expansion');
|
||||
if (typeof expand !== 'function' || expand.EXPANSION_MAX_LENGTH !== 4_000_000) {
|
||||
throw new Error('brace-expansion compatibility adapter is not using the bounded 5.0.8 implementation');
|
||||
throw new Error('brace-expansion compatibility adapter is not using the bounded 5.0.9 implementation');
|
||||
}
|
||||
assertEqual(expand('{a,b}{1,2}').join(','), 'a1,a2,b1,b2', 'brace-expansion legacy API');
|
||||
|
||||
@@ -51,7 +54,9 @@ for (const relativePath of [
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Dependency mitigations verified: PostCSS, React Router and NanoID patched; RSC unused; brace expansion bounded and compatible.');
|
||||
console.log(
|
||||
'Dependency mitigations verified: frontend baselines, Swagger YAML/URI parsers and bounded brace expansion are patched.',
|
||||
);
|
||||
|
||||
function sourceFiles(directory) {
|
||||
return readdirSync(directory).flatMap((name) => {
|
||||
@@ -64,7 +69,11 @@ function sourceFiles(directory) {
|
||||
function assertVersionAtLeast(actual, minimum, label) {
|
||||
if (!actual) throw new Error(`${label} is missing from package-lock.json`);
|
||||
const parts = actual.split('.').map((part) => Number(part.replace(/\D.*$/u, '')));
|
||||
if (minimum.some((value, index) => parts[index] < value && minimum.slice(0, index).every((item, i) => parts[i] === item))) {
|
||||
if (
|
||||
minimum.some(
|
||||
(value, index) => parts[index] < value && minimum.slice(0, index).every((item, i) => parts[i] === item),
|
||||
)
|
||||
) {
|
||||
throw new Error(`${label} ${actual} is older than ${minimum.join('.')}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user