mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 06:15:03 +00:00
feat(lint): P7 enforcement gates (no-new-throw-error + max-lines) + docs
Epic P7. Locks in the epic's gains with ESLint gates so the adoption work does not regress. - ccs/no-new-throw-error (error): custom flat-config rule that flags NEW throw new Error(...) outside a generated baseline allowlist. Forces the typed-error taxonomy (src/errors/error-types.ts). Existing 338 sites are grandfathered in eslint-rules/throw-error-baseline.json; only NEW violations error. Rule normalizes the filename to repo-root-relative to match baseline keys regardless of how ESLint reports paths. - max-lines (warn, 400, skipBlankLines/skipComments): warns on files over 400 LOC (goal of P5/P6 god-file splits). Currently 51 warnings on the not-yet-split god-files (P6 territory). - scripts/generate-throw-error-baseline.js: emits the allowlist from raw source (superset of real throws; never undercounts). Run after intentionally grandfathering a site, or quarterly to prune. - tests/unit/eslint-rules/no-throw-new-error.test.ts: rule logic (flags off-allowlist, passes on-allowlist/typed/rethrow, line-sensitivity). - docs/code-standards.md: Lint Enforcement Gates section. - docs/logging-contract.md: error.code -> ExitCode table (from P4). validate + validate:ci-parity green.
This commit is contained in:
1 parent
919be3c722
commit
2f94f35ec3
7 files changed
+642
No files matched your search
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate the baseline allowlist for eslint-rules/no-new-throw-error.js.
|
||||
*
|
||||
* Walks src/ (non-test), finds every `throw new Error(...)` site using the same
|
||||
* comment-stripping as scripts/maintainability-metrics.js (so the baseline
|
||||
* matches what the ESLint AST sees — comments are not ThrowStatement nodes),
|
||||
* and writes eslint-rules/throw-error-baseline.json as a sorted array of
|
||||
* `${relativePath}:${line}` keys.
|
||||
*
|
||||
* Run after intentionally adding a new grandfathered throw, or quarterly to
|
||||
* prune entries that have since been converted to typed errors.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const SRC_DIR = path.join(ROOT_DIR, 'src');
|
||||
const OUTPUT_PATH = path.join(ROOT_DIR, 'eslint-rules', 'throw-error-baseline.json');
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
||||
const THROW_NEW_ERROR_REGEX = /\bthrow\s+new\s+Error\s*\(/g;
|
||||
|
||||
function isTestPath(relPath) {
|
||||
return (
|
||||
/(?:^|\/)(?:__tests__|tests?)\//.test(relPath) ||
|
||||
/\.test\./.test(relPath) ||
|
||||
/\.spec\./.test(relPath)
|
||||
);
|
||||
}
|
||||
|
||||
function toPosix(p) {
|
||||
return p.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function relPath(fullPath) {
|
||||
return toPosix(path.relative(ROOT_DIR, fullPath));
|
||||
}
|
||||
|
||||
// Lazy require: hardening-inventory.js requires this module's sibling maintainability-metrics.js
|
||||
// at top level; requiring it back at top level here would capture a partial module.exports.
|
||||
// A function-scope require resolves against the fully-loaded module at call time.
|
||||
function stripCommentsOnce(sourceText) {
|
||||
return require('./hardening-inventory.js').stripComments(sourceText);
|
||||
}
|
||||
|
||||
function walkFiles(dirPath) {
|
||||
const out = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
|
||||
const full = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push.apply(out, walkFiles(full));
|
||||
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(full))) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectSites() {
|
||||
const sites = [];
|
||||
for (const full of walkFiles(SRC_DIR)) {
|
||||
const rel = relPath(full);
|
||||
if (isTestPath(rel)) continue;
|
||||
// Match on the RAW source (not comment-stripped). ESLint lints the raw
|
||||
// file, so the baseline must reflect real throw lines as the AST sees them.
|
||||
// stripComments can undercount on files whose regex/template literals confuse
|
||||
// its state machine; raw matching is a superset (may include comment/string
|
||||
// mentions, which are harmless unused allowlist entries) and never undercounts.
|
||||
const sourceText = fs.readFileSync(full, 'utf8');
|
||||
const re = new RegExp(THROW_NEW_ERROR_REGEX.source, 'g');
|
||||
let match;
|
||||
while ((match = re.exec(sourceText)) !== null) {
|
||||
const line = sourceText.slice(0, match.index).split(/\r?\n/).length;
|
||||
sites.push(`${rel}:${line}`);
|
||||
}
|
||||
}
|
||||
return sites.sort();
|
||||
}
|
||||
|
||||
function main() {
|
||||
const sites = collectSites();
|
||||
fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
|
||||
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(sites, null, 2) + '\n', 'utf8');
|
||||
console.log(`[throw-error-baseline] ${sites.length} sites -> ${relPath(OUTPUT_PATH)}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { collectSites, OUTPUT_PATH };
|
||||
Reference in new issue
Block a user