From 975c864f94714221fa3e13dabbbd34456f7cdd2f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 12:57:24 +0700 Subject: [PATCH 1/5] chore(maintainability): add baseline metrics gate --- docs/metrics/maintainability-baseline.json | 9 + docs/project-roadmap.md | 15 ++ package.json | 2 + scripts/maintainability-baseline.js | 270 +++++++++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 docs/metrics/maintainability-baseline.json create mode 100644 scripts/maintainability-baseline.js diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json new file mode 100644 index 00000000..b4cf2516 --- /dev/null +++ b/docs/metrics/maintainability-baseline.json @@ -0,0 +1,9 @@ +{ + "sourceDirectory": "src", + "largeFileThresholdLoc": 350, + "typeScriptFileCount": 338, + "locInSrc": 66207, + "processExitReferenceCount": 152, + "synchronousFsApiReferenceCount": 842, + "largeFileCountOver350Loc": 52 +} diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 7f257d11..22d14fa5 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -191,6 +191,21 @@ All criteria achieved: - [x] Clear domain boundaries - [x] Consistent naming conventions +## Maintainability Gate (Issue #539 Foundation) + +- Baseline metrics artifact: `docs/metrics/maintainability-baseline.json` +- Generate or refresh baseline: + - `bun run maintainability:baseline` + - `npm run maintainability:baseline` +- Run regression check gate: + - `bun run maintainability:check` + - `npm run maintainability:check` + +The check mode supports a maintainability regression gate that blocks increases in: +- `process.exit` references +- synchronous fs API references +- files over 350 LOC + --- ## Related Documentation diff --git a/package.json b/package.json index e3c9a39b..69d4d772 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,8 @@ "format:check": "prettier --check src/", "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test:all", "verify:bundle": "node scripts/verify-bundle.js", + "maintainability:baseline": "node scripts/maintainability-baseline.js --out docs/metrics/maintainability-baseline.json", + "maintainability:check": "node scripts/maintainability-baseline.js --check docs/metrics/maintainability-baseline.json", "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", "test:all": "bun test tests/unit tests/integration tests/npm", diff --git a/scripts/maintainability-baseline.js b/scripts/maintainability-baseline.js new file mode 100644 index 00000000..8258f945 --- /dev/null +++ b/scripts/maintainability-baseline.js @@ -0,0 +1,270 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const SRC_DIR = path.join(PROJECT_ROOT, 'src'); +const DEFAULT_BASELINE_PATH = path.join( + PROJECT_ROOT, + 'docs', + 'metrics', + 'maintainability-baseline.json' +); + +const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.cts', '.mts']); +const LARGE_FILE_THRESHOLD_LOC = 350; + +const FS_SYNC_APIS = [ + 'accessSync', + 'appendFileSync', + 'chmodSync', + 'chownSync', + 'closeSync', + 'copyFileSync', + 'cpSync', + 'existsSync', + 'fchmodSync', + 'fchownSync', + 'fdatasyncSync', + 'fstatSync', + 'fsyncSync', + 'ftruncateSync', + 'futimesSync', + 'lchmodSync', + 'lchownSync', + 'linkSync', + 'lstatSync', + 'lutimesSync', + 'mkdirSync', + 'mkdtempSync', + 'openSync', + 'opendirSync', + 'readFileSync', + 'readdirSync', + 'readlinkSync', + 'readSync', + 'realpathSync', + 'renameSync', + 'rmSync', + 'rmdirSync', + 'statSync', + 'symlinkSync', + 'truncateSync', + 'unlinkSync', + 'utimesSync', + 'writeFileSync', + 'writeSync', + 'writevSync', +]; + +const PROCESS_EXIT_PATTERN = /\bprocess\s*\.\s*exit\b/g; +const FS_SYNC_PATTERN = new RegExp(`\\b(?:${FS_SYNC_APIS.join('|')})\\b`, 'g'); + +function printUsage() { + console.log( + [ + 'Usage:', + ' node scripts/maintainability-baseline.js', + ' node scripts/maintainability-baseline.js --out [path]', + ' node scripts/maintainability-baseline.js --check [path]', + '', + 'Defaults:', + ` baseline path: ${path.relative(PROJECT_ROOT, DEFAULT_BASELINE_PATH)}`, + ].join('\n') + ); +} + +function parseArgs(argv) { + const options = { + outPath: null, + checkPath: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-h') { + printUsage(); + process.exit(0); + } + + if (arg === '--out' || arg === '--write') { + const nextArg = argv[index + 1]; + if (nextArg && !nextArg.startsWith('--')) { + options.outPath = nextArg; + index += 1; + } else { + options.outPath = path.relative(process.cwd(), DEFAULT_BASELINE_PATH); + } + continue; + } + + if (arg === '--check') { + const nextArg = argv[index + 1]; + if (nextArg && !nextArg.startsWith('--')) { + options.checkPath = nextArg; + index += 1; + } else { + options.checkPath = path.relative(process.cwd(), DEFAULT_BASELINE_PATH); + } + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function collectFiles(dirPath) { + const collected = []; + const entries = fs + .readdirSync(dirPath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + collected.push(...collectFiles(fullPath)); + continue; + } + + if (entry.isFile()) { + collected.push(fullPath); + } + } + + return collected; +} + +function countLines(content) { + if (content.length === 0) { + return 0; + } + return content.split(/\r?\n/).length; +} + +function countMatches(content, pattern) { + const matches = content.match(pattern); + return matches ? matches.length : 0; +} + +function collectMetrics() { + if (!fs.existsSync(SRC_DIR)) { + throw new Error(`Directory not found: ${SRC_DIR}`); + } + + const files = collectFiles(SRC_DIR); + + let typeScriptFileCount = 0; + let locInSrc = 0; + let processExitReferenceCount = 0; + let synchronousFsApiReferenceCount = 0; + let largeFileCountOver350Loc = 0; + + for (const filePath of files) { + const content = fs.readFileSync(filePath, 'utf8'); + const loc = countLines(content); + const extension = path.extname(filePath).toLowerCase(); + const isTypeScriptFile = TYPESCRIPT_EXTENSIONS.has(extension); + + locInSrc += loc; + processExitReferenceCount += countMatches(content, PROCESS_EXIT_PATTERN); + synchronousFsApiReferenceCount += countMatches(content, FS_SYNC_PATTERN); + + if (isTypeScriptFile) { + typeScriptFileCount += 1; + if (loc > LARGE_FILE_THRESHOLD_LOC) { + largeFileCountOver350Loc += 1; + } + } + } + + return { + sourceDirectory: 'src', + largeFileThresholdLoc: LARGE_FILE_THRESHOLD_LOC, + typeScriptFileCount, + locInSrc, + processExitReferenceCount, + synchronousFsApiReferenceCount, + largeFileCountOver350Loc, + }; +} + +function writeMetrics(outPath, metrics) { + const resolvedOutPath = path.resolve(process.cwd(), outPath); + fs.mkdirSync(path.dirname(resolvedOutPath), { recursive: true }); + fs.writeFileSync(resolvedOutPath, `${JSON.stringify(metrics, null, 2)}\n`, 'utf8'); +} + +function runCheck(checkPath, currentMetrics) { + const resolvedCheckPath = path.resolve(process.cwd(), checkPath); + const baselineContent = fs.readFileSync(resolvedCheckPath, 'utf8'); + const baselineMetrics = JSON.parse(baselineContent); + + const gatedKeys = [ + 'processExitReferenceCount', + 'synchronousFsApiReferenceCount', + 'largeFileCountOver350Loc', + ]; + + const violations = []; + for (const key of gatedKeys) { + if (typeof baselineMetrics[key] !== 'number') { + throw new Error(`Baseline is missing numeric metric: ${key}`); + } + + if (currentMetrics[key] > baselineMetrics[key]) { + violations.push({ + metric: key, + baseline: baselineMetrics[key], + current: currentMetrics[key], + }); + } + } + + return { + gate: 'maintainability-baseline', + baselinePath: path.relative(PROJECT_ROOT, resolvedCheckPath), + passed: violations.length === 0, + comparedMetrics: gatedKeys, + baseline: { + typeScriptFileCount: baselineMetrics.typeScriptFileCount, + locInSrc: baselineMetrics.locInSrc, + processExitReferenceCount: baselineMetrics.processExitReferenceCount, + synchronousFsApiReferenceCount: baselineMetrics.synchronousFsApiReferenceCount, + largeFileCountOver350Loc: baselineMetrics.largeFileCountOver350Loc, + }, + current: { + typeScriptFileCount: currentMetrics.typeScriptFileCount, + locInSrc: currentMetrics.locInSrc, + processExitReferenceCount: currentMetrics.processExitReferenceCount, + synchronousFsApiReferenceCount: currentMetrics.synchronousFsApiReferenceCount, + largeFileCountOver350Loc: currentMetrics.largeFileCountOver350Loc, + }, + violations, + }; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const metrics = collectMetrics(); + + if (options.outPath) { + writeMetrics(options.outPath, metrics); + } + + if (options.checkPath) { + const checkResult = runCheck(options.checkPath, metrics); + console.log(JSON.stringify(checkResult, null, 2)); + if (!checkResult.passed) { + process.exit(1); + } + return; + } + + console.log(JSON.stringify(metrics, null, 2)); +} + +main(); From 2610971d2e023af24c3364d94fc1a7be7e00843f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 14:18:48 +0700 Subject: [PATCH 2/5] fix(maintainability): enforce gate and correct loc metric --- docs/metrics/maintainability-baseline.json | 2 +- docs/project-roadmap.md | 2 +- package.json | 2 +- scripts/maintainability-baseline.js | 7 ++++++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/metrics/maintainability-baseline.json b/docs/metrics/maintainability-baseline.json index b4cf2516..5afe7f98 100644 --- a/docs/metrics/maintainability-baseline.json +++ b/docs/metrics/maintainability-baseline.json @@ -2,7 +2,7 @@ "sourceDirectory": "src", "largeFileThresholdLoc": 350, "typeScriptFileCount": 338, - "locInSrc": 66207, + "locInSrc": 65869, "processExitReferenceCount": 152, "synchronousFsApiReferenceCount": 842, "largeFileCountOver350Loc": 52 diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 22d14fa5..aa518236 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -204,7 +204,7 @@ All criteria achieved: The check mode supports a maintainability regression gate that blocks increases in: - `process.exit` references - synchronous fs API references -- files over 350 LOC +- TypeScript files over 350 LOC --- diff --git a/package.json b/package.json index 69d4d772..e5dd6017 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "lint:fix": "eslint src/ --fix", "format": "prettier --write src/", "format:check": "prettier --check src/", - "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test:all", + "validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run maintainability:check && bun run test:all", "verify:bundle": "node scripts/verify-bundle.js", "maintainability:baseline": "node scripts/maintainability-baseline.js --out docs/metrics/maintainability-baseline.json", "maintainability:check": "node scripts/maintainability-baseline.js --check docs/metrics/maintainability-baseline.json", diff --git a/scripts/maintainability-baseline.js b/scripts/maintainability-baseline.js index 8258f945..00cd9f68 100644 --- a/scripts/maintainability-baseline.js +++ b/scripts/maintainability-baseline.js @@ -142,7 +142,12 @@ function countLines(content) { if (content.length === 0) { return 0; } - return content.split(/\r?\n/).length; + + const lineBreakMatches = content.match(/\r\n|\n|\r/g); + const lineBreakCount = lineBreakMatches ? lineBreakMatches.length : 0; + const endsWithLineBreak = content.endsWith('\n') || content.endsWith('\r'); + + return endsWithLineBreak ? lineBreakCount : lineBreakCount + 1; } function countMatches(content, pattern) { From 8f0ba481edf22fd87da1ad44e4eea2c257695d75 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 14:40:45 +0700 Subject: [PATCH 3/5] fix(maintainability): scan tracked src files for stable gate --- docs/project-roadmap.md | 2 ++ scripts/maintainability-baseline.js | 50 +++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index aa518236..3c3a3d64 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -201,6 +201,8 @@ All criteria achieved: - `bun run maintainability:check` - `npm run maintainability:check` +The baseline/check scripts enumerate git-tracked files under `src` for deterministic results, with filesystem traversal fallback when git is unavailable. + The check mode supports a maintainability regression gate that blocks increases in: - `process.exit` references - synchronous fs API references diff --git a/scripts/maintainability-baseline.js b/scripts/maintainability-baseline.js index 00cd9f68..f312a4f3 100644 --- a/scripts/maintainability-baseline.js +++ b/scripts/maintainability-baseline.js @@ -1,5 +1,6 @@ #!/usr/bin/env node +const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); @@ -117,7 +118,7 @@ function parseArgs(argv) { return options; } -function collectFiles(dirPath) { +function collectFilesFromFileSystem(dirPath) { const collected = []; const entries = fs .readdirSync(dirPath, { withFileTypes: true }) @@ -126,7 +127,7 @@ function collectFiles(dirPath) { for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { - collected.push(...collectFiles(fullPath)); + collected.push(...collectFilesFromFileSystem(fullPath)); continue; } @@ -138,6 +139,49 @@ function collectFiles(dirPath) { return collected; } +function collectTrackedFilesFromGit() { + try { + const output = execFileSync('git', ['ls-files', '-z', '--', 'src'], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + + if (!output) { + return []; + } + + return output + .split('\0') + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)) + .map(relativePath => path.resolve(PROJECT_ROOT, relativePath)) + .filter(filePath => { + const relativeToSrc = path.relative(SRC_DIR, filePath); + if (relativeToSrc.startsWith('..') || path.isAbsolute(relativeToSrc)) { + return false; + } + + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } + }); + } catch { + return null; + } +} + +function collectFilesInSrc() { + const trackedFiles = collectTrackedFilesFromGit(); + if (trackedFiles !== null) { + return trackedFiles; + } + + return collectFilesFromFileSystem(SRC_DIR); +} + function countLines(content) { if (content.length === 0) { return 0; @@ -160,7 +204,7 @@ function collectMetrics() { throw new Error(`Directory not found: ${SRC_DIR}`); } - const files = collectFiles(SRC_DIR); + const files = collectFilesInSrc(); let typeScriptFileCount = 0; let locInSrc = 0; From b7481cf346c80fc3b178cb7c60c9459865a52f8c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 14:50:54 +0700 Subject: [PATCH 4/5] fix(maintainability): require git-tracked scan for gate --- docs/project-roadmap.md | 2 +- scripts/maintainability-baseline.js | 32 ++++------------------------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 3c3a3d64..2ef95bca 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -201,7 +201,7 @@ All criteria achieved: - `bun run maintainability:check` - `npm run maintainability:check` -The baseline/check scripts enumerate git-tracked files under `src` for deterministic results, with filesystem traversal fallback when git is unavailable. +The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable. The check mode supports a maintainability regression gate that blocks increases in: - `process.exit` references diff --git a/scripts/maintainability-baseline.js b/scripts/maintainability-baseline.js index f312a4f3..83c7cf4e 100644 --- a/scripts/maintainability-baseline.js +++ b/scripts/maintainability-baseline.js @@ -118,27 +118,6 @@ function parseArgs(argv) { return options; } -function collectFilesFromFileSystem(dirPath) { - const collected = []; - const entries = fs - .readdirSync(dirPath, { withFileTypes: true }) - .sort((left, right) => left.name.localeCompare(right.name)); - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - collected.push(...collectFilesFromFileSystem(fullPath)); - continue; - } - - if (entry.isFile()) { - collected.push(fullPath); - } - } - - return collected; -} - function collectTrackedFilesFromGit() { try { const output = execFileSync('git', ['ls-files', '-z', '--', 'src'], { @@ -169,17 +148,14 @@ function collectTrackedFilesFromGit() { } }); } catch { - return null; + throw new Error( + 'Unable to enumerate tracked files via git. Run this command from a git checkout with git installed.' + ); } } function collectFilesInSrc() { - const trackedFiles = collectTrackedFilesFromGit(); - if (trackedFiles !== null) { - return trackedFiles; - } - - return collectFilesFromFileSystem(SRC_DIR); + return collectTrackedFilesFromGit(); } function countLines(content) { From 33e9a8849420b2cdf68f1ddd5ef93d22fafc50f7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 15:01:33 +0700 Subject: [PATCH 5/5] fix(maintainability): harden tracked scan and baseline checks --- scripts/maintainability-baseline.js | 60 ++++++++++++++++++----------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/scripts/maintainability-baseline.js b/scripts/maintainability-baseline.js index 83c7cf4e..0216b841 100644 --- a/scripts/maintainability-baseline.js +++ b/scripts/maintainability-baseline.js @@ -119,39 +119,41 @@ function parseArgs(argv) { } function collectTrackedFilesFromGit() { + let output; try { - const output = execFileSync('git', ['ls-files', '-z', '--', 'src'], { + output = execFileSync('git', ['ls-files', '-z', '--', 'src'], { cwd: PROJECT_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }); - - if (!output) { - return []; - } - - return output - .split('\0') - .filter(Boolean) - .sort((left, right) => left.localeCompare(right)) - .map(relativePath => path.resolve(PROJECT_ROOT, relativePath)) - .filter(filePath => { - const relativeToSrc = path.relative(SRC_DIR, filePath); - if (relativeToSrc.startsWith('..') || path.isAbsolute(relativeToSrc)) { - return false; - } - - try { - return fs.statSync(filePath).isFile(); - } catch { - return false; - } - }); } catch { throw new Error( 'Unable to enumerate tracked files via git. Run this command from a git checkout with git installed.' ); } + + if (!output) { + return []; + } + + return output + .split('\0') + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)) + .map(relativePath => path.resolve(PROJECT_ROOT, relativePath)) + .filter(filePath => { + const relativeToSrc = path.relative(SRC_DIR, filePath); + if (relativeToSrc.startsWith('..') || path.isAbsolute(relativeToSrc)) { + return false; + } + + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + throw new Error(`Tracked path is not a file: ${path.relative(PROJECT_ROOT, filePath)}`); + } + + return true; + }); } function collectFilesInSrc() { @@ -228,6 +230,18 @@ function runCheck(checkPath, currentMetrics) { const baselineContent = fs.readFileSync(resolvedCheckPath, 'utf8'); const baselineMetrics = JSON.parse(baselineContent); + if (baselineMetrics.sourceDirectory !== currentMetrics.sourceDirectory) { + throw new Error( + `Baseline sourceDirectory mismatch: expected "${currentMetrics.sourceDirectory}", got "${baselineMetrics.sourceDirectory}"` + ); + } + + if (baselineMetrics.largeFileThresholdLoc !== currentMetrics.largeFileThresholdLoc) { + throw new Error( + `Baseline largeFileThresholdLoc mismatch: expected ${currentMetrics.largeFileThresholdLoc}, got ${baselineMetrics.largeFileThresholdLoc}` + ); + } + const gatedKeys = [ 'processExitReferenceCount', 'synchronousFsApiReferenceCount',