Files
hermes-agent/scripts/code_health/ts_units.mjs
Teknium 6cc7d4a570 fix(code-health): TS recursive rename ignores nested-scope bindings
bodyWithoutSelf treated a declaration of the function's name at any depth as a rebind,
so a renamed recursive function containing an unchanged `.map((legacy) => legacy)` kept
its old self-call text, no longer matched its base body, and lost its cap ("CC 22 >
target 20 (new code)"). Only the function's own scope (parameters, a named function
expression's name, declarations outside nested functions and classes) now stops the
normalization, and nested functions that bind the name keep their own reads, as the
Python measurer already does.
2026-10-04 22:35:16 -07:00

302 lines
12 KiB
JavaScript

// TypeScript structure for the code-health ratchet: per-function CC, length, nesting.
// Parses only (no type check), so it is fast and needs nothing but the `typescript` package.
//
// node ts_units.mjs <typescript-module-path> <root> < paths.json > units.json
//
// CC is classic McCabe, like ruff's: 1 + if, ?:, case, loops, catch, &&, ||, ??.
// It is NOT ESLint's `complexity` rule, which also counts these (so ESLint's numbers are
// higher on the same code): optional chaining (`a?.b`, `f?.()`), the logical assignments
// `&&=`, `||=`, `??=`, and default parameter values (`f(x = 1)`). They are left out on purpose,
// to stay comparable with Python's count; the ratchet compares this count only with itself.
//
// Identity: a unit's name is its path of classes, functions and named object literals
// (`Cls.method`, `outer.inner`, `obj.method` for `const obj = { method() {} }`); a repeated
// name gets `#2`, `#3` in source order, and the comparison pairs repeated names by body, not
// by that number. Signatures without a body (overloads, `declare`, abstract) are not units.
// The body hash covers the token stream (comments and whitespace excluded), so a renamed or
// re-commented function is still the same code. A file with syntax errors is an error, never
// a partial measurement.
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
const [tsPath, root] = process.argv.slice(2)
const ts = createRequire(import.meta.url)(tsPath)
const K = ts.SyntaxKind
const FUNCTION_KINDS = new Set([
K.FunctionDeclaration, K.MethodDeclaration, K.ArrowFunction, K.FunctionExpression,
K.Constructor, K.GetAccessor, K.SetAccessor
])
const BRANCH_KINDS = new Set([
K.IfStatement, K.ConditionalExpression, K.ForStatement, K.ForInStatement, K.ForOfStatement,
K.WhileStatement, K.DoStatement, K.CatchClause
])
const BLOCK_KINDS = new Set([
K.IfStatement, K.ForStatement, K.ForInStatement, K.ForOfStatement, K.WhileStatement,
K.DoStatement, K.SwitchStatement, K.TryStatement
])
const LOGICAL_OPS = new Set([
K.AmpersandAmpersandToken, K.BarBarToken, K.QuestionQuestionToken
])
function complexity(fn) {
let cc = 1
const visit = node => {
if (node !== fn && FUNCTION_KINDS.has(node.kind)) return
if (BRANCH_KINDS.has(node.kind)) cc++
else if (node.kind === K.CaseClause) cc++
else if (node.kind === K.BinaryExpression && LOGICAL_OPS.has(node.operatorToken.kind)) cc++
ts.forEachChild(node, visit)
}
visit(fn)
return cc
}
function nesting(fn) {
let deepest = 0
const visit = (node, depth) => {
if (node !== fn && FUNCTION_KINDS.has(node.kind)) return
let next = depth
// `else if` stays at the depth of its `if`.
const isElseIf = node.kind === K.IfStatement && node.parent?.kind === K.IfStatement &&
node.parent.elseStatement === node
if (BLOCK_KINDS.has(node.kind) && !isElseIf) {
next = depth + 1
deepest = Math.max(deepest, next)
}
ts.forEachChild(node, child => visit(child, next))
}
visit(fn, 0)
return deepest
}
function propertyName(name) {
if (!name) return null
if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name) || ts.isStringLiteral(name) ||
ts.isNumericLiteral(name)) return name.text
return null
}
// Name an anonymous function from where it is bound: `const x = () => {}`,
// `x: () => {}`, `const x = useCallback(() => {})`.
function bindingName(node) {
let current = node.parent
while (current && (ts.isCallExpression(current) || ts.isParenthesizedExpression(current) ||
ts.isAsExpression(current) || ts.isSatisfiesExpression?.(current))) {
if (ts.isCallExpression(current) && current.parent && ts.isExpressionStatement(current.parent)) {
return null
}
current = current.parent
}
if (!current) return null
if (ts.isVariableDeclaration(current) || ts.isPropertyAssignment(current) ||
ts.isPropertyDeclaration(current)) return propertyName(current.name)
return null
}
function ownName(node) {
if (node.kind === K.Constructor) return 'constructor'
const named = propertyName(node.name)
if (named) return node.kind === K.GetAccessor ? `get ${named}` : node.kind === K.SetAccessor ? `set ${named}` : named
return bindingName(node)
}
// The file's code tokens in order (comments, whitespace and JSDoc excluded), collected once:
// a function's hash input is its slice of them, so a comment edit is not a code edit.
function fileTokens(sf) {
const starts = []
const texts = []
const visit = n => {
if (n.kind >= K.FirstJSDocNode && n.kind <= K.LastJSDocNode) return
const children = n.getChildren(sf)
if (children.length === 0) {
const text = ts.isJsxText(n) ? n.getText(sf).replace(/\s+/g, ' ').trim() : n.getText(sf)
if (text) {
starts.push(n.getStart(sf))
texts.push(text)
}
return
}
for (const child of children) visit(child)
}
visit(sf)
return { starts, texts }
}
function lowerBound(sorted, value) {
let lo = 0
let hi = sorted.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (sorted[mid] < value) lo = mid + 1
else hi = mid
}
return lo
}
// The tokens of `node`, without those starting at a position in `cuts`.
function tokenText(tokens, node, sf, cuts) {
const out = []
const end = lowerBound(tokens.starts, node.getEnd())
for (let i = lowerBound(tokens.starts, node.getStart(sf)); i < end; i++) {
if (!cuts.has(tokens.starts[i])) out.push(tokens.texts[i])
}
return out.join(' ')
}
// Whether `n` declares `name` (a variable, parameter, function or class of that name).
function declaresName(n, name) {
const declares = ts.isVariableDeclaration(n) || ts.isParameter(n) ||
ts.isFunctionDeclaration(n) || ts.isClassDeclaration(n)
return Boolean(declares && n.name && ts.isIdentifier(n.name) && n.name.text === name)
}
// Whether function `fn`'s own scope binds `name`: a parameter, a named function expression's
// own name, or a declaration in its body outside any nested function or class (those bind
// only inside themselves).
function bindsOwn(fn, name) {
if (fn.parameters?.some(p => declaresName(p, name))) return true
if (ts.isFunctionExpression(fn) && fn.name?.text === name) return true
let found = false
const walk = n => {
if (found) return
if (declaresName(n, name)) found = true
else if (!ts.isFunctionLike(n) && !ts.isClassLike(n)) ts.forEachChild(n, walk)
}
if (fn.body) walk(fn.body)
return found
}
// The body's tokens with its own references to `name` removed (`name(...)`, `this.name(...)`),
// unless the function's own scope rebinds that name: renaming a recursive function together
// with its self-call is still the same code. A nested function that binds the name
// (`.map((legacy) => legacy)`) keeps its own reads. Strings and other objects' `.name`
// members are untouched.
function bodyWithoutSelf(tokens, node, sf, name) {
if (!node.body) return ''
const cuts = new Set()
if (!name || bindsOwn(node, name)) return tokenText(tokens, node.body, sf, cuts)
const visit = n => {
if (n !== node.body && ts.isFunctionLike(n) && bindsOwn(n, name)) return
if (ts.isIdentifier(n) && n.text === name) {
const p = n.parent
const member = p && ts.isPropertyAccessExpression(p) && p.name === n
const key = p && (ts.isPropertyAssignment(p) || ts.isMethodDeclaration(p) ||
ts.isPropertyDeclaration(p)) && p.name === n
if ((!member && !key) || (member && p.expression.kind === K.ThisKeyword)) {
cuts.add(n.getStart(sf))
}
}
ts.forEachChild(n, visit)
}
visit(node.body)
return tokenText(tokens, node.body, sf, cuts)
}
// The name an object literal is bound to (`const a = {...}`, `a: {...}`), so its methods are
// `a.f` and `b.f`, not `f` and `f#2` paired by position. Unbound objects add nothing.
function objectName(node) {
let current = node.parent
while (current && (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) ||
ts.isSatisfiesExpression?.(current) || ts.isTypeAssertionExpression(current))) {
current = current.parent
}
if (current && (ts.isVariableDeclaration(current) || ts.isPropertyAssignment(current) ||
ts.isPropertyDeclaration(current))) return propertyName(current.name)
return null
}
function parseError(sf) {
const diag = sf.parseDiagnostics?.[0]
if (!diag) return null
const line = sf.getLineAndCharacterOfPosition(diag.start ?? 0).line + 1
return `does not parse: ${ts.flattenDiagnosticMessageText(diag.messageText, ' ')} (line ${line})`
}
// Comment trivia (not string or template text) that carries a `health: allow` directive.
function allowComments(sf, text) {
const found = new Map()
const collect = ranges => {
for (const range of ranges ?? []) {
const body = text.slice(range.pos, range.end)
if (body.includes('health:')) {
found.set(range.pos, [sf.getLineAndCharacterOfPosition(range.pos).line + 1, body])
}
}
}
const visit = node => {
collect(ts.getLeadingCommentRanges(text, node.pos))
collect(ts.getTrailingCommentRanges(text, node.end))
ts.forEachChild(node, visit)
}
visit(sf)
collect(ts.getLeadingCommentRanges(text, sf.endOfFileToken.pos))
return [...found.values()]
}
function measureFile(path) {
const text = readFileSync(join(root, path), 'utf8')
const kind = path.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sf = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, kind)
// The parser recovers from syntax errors instead of throwing: a broken file must fail
// closed (MEASURE), not read as whatever functions survived recovery.
const error = parseError(sf)
if (error) return { error }
const tokens = fileTokens(sf)
const units = []
const seen = new Map()
const unique = q => {
const n = (seen.get(q) ?? 0) + 1
seen.set(q, n)
return n === 1 ? q : `${q}#${n}`
}
// `lines` is a function's OWN lines: nested functions are their own units, so editing a
// closure inside a hook never counts against the hook itself. A line is owned by the
// innermost function that covers it, so siblings sharing a line give it up once, not twice.
const owner = new Map() // line -> unit
const walk = (node, stack, parent) => {
let next = stack
let nextParent = parent
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
next = [...stack, propertyName(node.name) ?? '<class>']
} else if (ts.isObjectLiteralExpression(node)) {
const name = objectName(node)
if (name) next = [...stack, name]
} else if (FUNCTION_KINDS.has(node.kind) && node.body) {
const qual = unique([...stack, ownName(node) ?? '<anon>'].join('.'))
const start = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1
const end = sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1
// Name-independent: parameters + body only, so a rename (or a move) keeps the cap.
const params = node.parameters.map(p => tokenText(tokens, p, sf, new Set())).join(' , ')
const selfName = propertyName(node.name) ?? bindingName(node)
const body = params + ' => ' + bodyWithoutSelf(tokens, node, sf, selfName)
const unit = {
q: qual, line: start, end, cc: complexity(node), nesting: nesting(node),
hash: createHash('sha1').update(body).digest('hex').slice(0, 16)
}
for (let line = start; line <= end; line++) owner.set(line, unit)
units.push(unit)
next = [...stack, qual.split('.').pop()]
nextParent = unit
}
ts.forEachChild(node, child => walk(child, next, nextParent))
}
walk(sf, [], null)
for (const unit of units) unit.lines = 0
for (const unit of owner.values()) unit.lines++
return { units, comments: allowComments(sf, text) }
}
const paths = JSON.parse(readFileSync(0, 'utf8'))
const out = {}
for (const path of paths) {
try {
out[path] = measureFile(path)
} catch (error) {
out[path] = { error: String(error) }
}
}
process.stdout.write(JSON.stringify(out))