Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

,\n },\n },\n description: 'Empty catch blocks that silently swallow errors',\n },\n 'console-log': {\n rule: {\n pattern: 'console.log($$ARGS)',\n },\n description: 'console.log calls left in production code',\n },\n 'console-any': {\n rule: {\n pattern: 'console.$METHOD($$ARGS)',\n },\n description: 'Any console method call (log, warn, error, debug, etc.)',\n },\n debugger: {\n rule: {\n kind: 'debugger_statement',\n },\n description: 'Debugger statements left in code',\n },\n 'todo-fixme': {\n rule: {\n kind: 'comment',\n regex: '(?i)(TODO|FIXME|HACK|XXX|BUG)',\n },\n description: 'TODO, FIXME, HACK, XXX, BUG comments',\n },\n 'any-type': {\n rule: {\n kind: 'predefined_type',\n regex: '^any

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

,\n },\n description: 'Explicit `any` type annotations',\n },\n 'type-assertion': {\n rule: {\n kind: 'as_expression',\n },\n description: 'TypeScript type assertions (as X)',\n },\n 'non-null-assertion': {\n rule: {\n kind: 'non_null_expression',\n },\n description: 'Non-null assertions (x!)',\n },\n 'fat-arrow-body': {\n rule: {\n kind: 'arrow_function',\n has: {\n kind: 'statement_block',\n },\n },\n description:\n 'Arrow functions with statement block bodies (could be expression)',\n },\n 'nested-ternary': {\n rule: {\n kind: 'ternary_expression',\n has: {\n kind: 'ternary_expression',\n stopBy: 'end',\n },\n },\n description: 'Nested ternary expressions (hard to read)',\n },\n 'throw-string': {\n rule: {\n kind: 'throw_statement',\n has: {\n kind: 'string',\n },\n },\n description: 'Throwing string literals instead of Error objects',\n },\n 'switch-no-default': {\n rule: {\n kind: 'switch_statement',\n not: {\n has: {\n kind: 'switch_default',\n stopBy: 'end',\n },\n },\n },\n description: 'Switch statements without a default case',\n },\n 'class-declaration': {\n rule: {\n kind: 'class_declaration',\n },\n description: 'All class declarations',\n },\n 'async-function': {\n rule: {\n kind: 'function_declaration',\n regex: '^async ',\n },\n description: 'Async function declarations',\n },\n 'export-default': {\n rule: {\n kind: 'export_statement',\n has: {\n field: 'default',\n },\n },\n description: 'Default exports',\n },\n 'import-star': {\n rule: {\n kind: 'import_statement',\n has: {\n kind: 'namespace_import',\n },\n },\n description: 'Namespace imports (import * as X)',\n },\n 'catch-rethrow': {\n rule: {\n kind: 'catch_clause',\n has: {\n kind: 'statement_block',\n has: {\n kind: 'throw_statement',\n },\n },\n },\n description: 'Catch blocks that only re-throw the caught error',\n },\n 'promise-all': {\n rule: {\n pattern: 'Promise.all($$ARGS)',\n },\n description: 'Promise.all calls (check for missing error handling)',\n },\n 'boolean-param': {\n rule: {\n kind: 'type_annotation',\n has: {\n kind: 'predefined_type',\n regex: '^boolean

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

,\n },\n },\n description: 'Function parameters typed as boolean',\n },\n 'magic-number': {\n rule: {\n kind: 'number',\n not: {\n regex: '^[01]

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

,\n },\n },\n description: 'Numeric literals (excluding 0 and 1) — potential magic numbers',\n },\n 'deep-callback': {\n rule: {\n kind: 'arrow_function',\n inside: {\n kind: 'arrow_function',\n inside: {\n kind: 'arrow_function',\n stopBy: 'end',\n },\n stopBy: 'end',\n },\n },\n description: 'Deeply nested arrow function callbacks (3+ levels)',\n },\n 'unused-var': {\n rule: {\n kind: 'variable_declarator',\n not: {\n has: {\n kind: 'call_expression',\n stopBy: 'end',\n },\n },\n },\n description: 'Variable declarations without call expressions (candidates for dead code)',\n },\n\n // ── Python presets ──────────────────────────────────────────────\n 'py-bare-except': {\n rule: {\n kind: 'except_clause',\n not: {\n has: { kind: 'identifier' },\n },\n },\n description: '[Python] Bare except: clause with no exception type',\n },\n 'py-pass-except': {\n rule: {\n kind: 'except_clause',\n has: {\n kind: 'block',\n has: { kind: 'pass_statement' },\n },\n },\n description: '[Python] except: pass — silently swallowed exception',\n },\n 'py-broad-except': {\n rule: {\n kind: 'except_clause',\n has: {\n kind: 'identifier',\n regex: '^(Exception|BaseException)

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

,\n },\n },\n description: '[Python] Overly broad except (Exception/BaseException)',\n },\n 'py-global-stmt': {\n rule: {\n kind: 'global_statement',\n },\n description: '[Python] Global variable mutation',\n },\n 'py-exec-call': {\n rule: {\n kind: 'call',\n has: { kind: 'identifier', regex: '^exec

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

},\n },\n description: '[Python] exec() — dynamic code execution',\n },\n 'py-eval-call': {\n rule: {\n kind: 'call',\n has: { kind: 'identifier', regex: '^eval

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

},\n },\n description: '[Python] eval() — dynamic evaluation',\n },\n 'py-star-import': {\n rule: {\n kind: 'import_from_statement',\n has: { kind: 'wildcard_import' },\n },\n description: '[Python] from X import * — wildcard import',\n },\n 'py-assert': {\n rule: {\n kind: 'assert_statement',\n },\n description: '[Python] assert statement (stripped with -O flag)',\n },\n 'py-mutable-default': {\n rule: {\n kind: 'default_parameter',\n any: [\n { has: { kind: 'list' } },\n { has: { kind: 'dictionary' } },\n { has: { kind: 'set' } },\n ],\n },\n description: '[Python] Mutable default argument (list/dict/set literal)',\n },\n 'py-todo-fixme': {\n rule: {\n kind: 'comment',\n regex: '(?i)(TODO|FIXME|HACK|XXX|BUG)',\n },\n description: '[Python] TODO, FIXME, HACK, XXX, BUG comments',\n },\n 'py-print-call': {\n rule: {\n kind: 'call',\n has: { kind: 'identifier', regex: '^print

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

},\n },\n description: '[Python] print() calls in production code',\n },\n 'py-class': {\n rule: {\n kind: 'class_definition',\n },\n description: '[Python] All class definitions',\n },\n 'py-async-function': {\n rule: {\n kind: 'function_definition',\n regex: '^async ',\n },\n description: '[Python] Async function definitions',\n },\n};\n\nfunction isTestFile(filePath: string): boolean {\n const base = path.basename(filePath);\n return (\n /\\.(test|spec)\\.(ts|tsx|js|jsx|mjs|cjs)$/.test(base) ||\n base.startsWith('test_') ||\n /^test_.*\\.py$/.test(base) ||\n /_test\\.py$/.test(base) ||\n filePath.includes('__tests__') ||\n filePath.includes('/tests/')\n );\n}\n\nexport function collectSearchFiles(\n root: string,\n opts: Pick\u003cAstSearchOptions, 'includeTests' | 'ignoreDirs'>\n): string[] {\n const files: string[] = [];\n const walk = (dir: string): void => {\n let entries: fs.Dirent[];\n try {\n entries = fs.readdirSync(dir, { withFileTypes: true });\n } catch {\n return;\n }\n entries.sort((a, b) => a.name.localeCompare(b.name));\n for (const entry of entries) {\n if (opts.ignoreDirs.has(entry.name)) continue;\n if (entry.isSymbolicLink()) continue;\n const next = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n walk(next);\n continue;\n }\n if (!entry.isFile()) continue;\n if (entry.name.endsWith('.d.ts')) continue;\n const ext = path.extname(entry.name);\n if (!ALLOWED_EXTS.has(ext)) continue;\n if (!opts.includeTests && isTestFile(next)) continue;\n files.push(next);\n }\n };\n walk(root);\n return files;\n}\n\ntype AstParser = { parse(src: string): SgRoot };\n\nfunction parserForExt(ext: string): AstParser | 'python' {\n switch (ext) {\n case '.py':\n return 'python';\n case '.tsx':\n return astTsx;\n case '.jsx':\n return astTsx;\n case '.js':\n case '.mjs':\n case '.cjs':\n return astJs;\n case '.ts':\n default:\n return astTs;\n }\n}\n\nfunction extractMetaVars(\n node: SgNode,\n pattern: string\n): Record\u003cstring, string> {\n const vars: Record\u003cstring, string> = {};\n let match: RegExpExecArray | null;\n\n const triplePattern = /\\$\\$\\$([A-Z_][A-Z0-9_]*)/g;\n const triplNames = new Set\u003cstring>();\n while ((match = triplePattern.exec(pattern)) !== null) {\n const name = match[1];\n triplNames.add(name);\n const multiMatch = node.getMultipleMatches(name);\n if (multiMatch.length > 0) {\n vars[`$${name}`] = multiMatch.map(n => n.text()).join(', ');\n }\n }\n\n const singlePattern = /(?\u003c!\\$)\\$([A-Z_][A-Z0-9_]*)(?!\\$)/g;\n while ((match = singlePattern.exec(pattern)) !== null) {\n const name = match[1];\n if (triplNames.has(name)) continue;\n const matchNode = node.getMatch(name);\n if (matchNode) vars[`${name}`] = matchNode.text();\n }\n\n return vars;\n}\n\nfunction nodeToMatch(\n node: SgNode,\n file: string,\n pattern: string | null\n): AstMatch {\n const range = node.range();\n const result: AstMatch = {\n file,\n kind: String(node.kind()),\n text: node.text(),\n lineStart: range.start.line + 1,\n lineEnd: range.end.line + 1,\n columnStart: range.start.column,\n columnEnd: range.end.column,\n };\n if (pattern) {\n const vars = extractMetaVars(node, pattern);\n if (Object.keys(vars).length > 0) result.metaVariables = vars;\n }\n return result;\n}\n\nexport function searchFile(\n filePath: string,\n source: string,\n matcher: string | number | NapiConfig,\n patternStr: string | null,\n limit: number\n): AstMatch[] {\n const ext = path.extname(filePath);\n const parser = parserForExt(ext);\n let nodes: SgNode[];\n try {\n const root =\n parser === 'python'\n ? astParse('python', source).root()\n : parser.parse(source).root();\n nodes = root.findAll(matcher);\n } catch {\n return [];\n }\n const matches: AstMatch[] = [];\n for (const node of nodes) {\n if (matches.length >= limit) break;\n matches.push(nodeToMatch(node, filePath, patternStr));\n }\n return matches;\n}\n\nexport function runSearch(\n files: string[],\n opts: AstSearchOptions,\n root: string\n): AstSearchResult {\n let matcher: string | NapiConfig;\n let queryLabel: string;\n let queryType: AstSearchResult['queryType'];\n let patternStr: string | null = null;\n\n if (opts.preset) {\n const preset = PRESETS[opts.preset];\n if (!preset) {\n const available = Object.keys(PRESETS).join(', ');\n throw new Error(\n `Unknown preset: \"${opts.preset}\". Available: ${available}`\n );\n }\n matcher = preset;\n queryLabel = `preset:${opts.preset} — ${preset.description}`;\n queryType = 'preset';\n } else if (opts.rule) {\n matcher = opts.rule;\n queryLabel = `rule:${JSON.stringify(opts.rule)}`;\n queryType = 'rule';\n } else if (opts.kind) {\n matcher = { rule: { kind: opts.kind } } as NapiConfig;\n queryLabel = `kind:${opts.kind}`;\n queryType = 'kind';\n } else if (opts.pattern) {\n matcher = opts.pattern;\n patternStr = opts.pattern;\n queryLabel = `pattern:${opts.pattern}`;\n queryType = 'pattern';\n } else {\n throw new Error('Must provide --pattern, --kind, --preset, or --rule');\n }\n\n const allMatches: AstMatch[] = [];\n const filesWithMatches = new Set\u003cstring>();\n const sourceByFile =\n opts.context > 0 ? new Map\u003cstring, string[]>() : undefined;\n\n for (const filePath of files) {\n if (allMatches.length >= opts.limit) break;\n let source: string;\n try {\n source = fs.readFileSync(filePath, 'utf8');\n } catch {\n continue;\n }\n const relFile = path.relative(root, filePath);\n const remaining = opts.limit - allMatches.length;\n const fileMatches = searchFile(\n relFile,\n source,\n matcher,\n patternStr,\n remaining\n );\n if (fileMatches.length > 0) {\n filesWithMatches.add(relFile);\n allMatches.push(...fileMatches);\n if (sourceByFile) sourceByFile.set(relFile, source.split('\\n'));\n }\n }\n\n const result: AstSearchResult = {\n query: queryLabel,\n queryType,\n totalMatches: allMatches.length,\n totalFiles: filesWithMatches.size,\n matches: allMatches,\n };\n if (sourceByFile) result._sourceByFile = sourceByFile;\n return result;\n}\n\ninterface ParsedSearchArgs {\n opts: AstSearchOptions;\n listPresets: boolean;\n}\n\nexport function parseSearchArgs(argv: string[]): ParsedSearchArgs {\n const opts: AstSearchOptions = {\n root: process.cwd(),\n pattern: null,\n kind: null,\n preset: null,\n rule: null,\n json: false,\n limit: 500,\n includeTests: false,\n ignoreDirs: new Set([\n '.git',\n '.next',\n '.yarn',\n '.cache',\n '.octocode',\n 'node_modules',\n 'dist',\n 'coverage',\n 'out',\n ]),\n context: 0,\n };\n let listPresets = false;\n\n for (let i = 0; i \u003c argv.length; i++) {\n const arg = argv[i];\n if (arg === '--pattern' || arg === '-p') {\n opts.pattern = argv[++i];\n continue;\n }\n if (arg.startsWith('--pattern=')) {\n opts.pattern = arg.slice('--pattern='.length);\n continue;\n }\n if (arg === '--kind' || arg === '-k') {\n opts.kind = argv[++i];\n continue;\n }\n if (arg.startsWith('--kind=')) {\n opts.kind = arg.slice('--kind='.length);\n continue;\n }\n if (arg === '--preset') {\n opts.preset = argv[++i];\n continue;\n }\n if (arg.startsWith('--preset=')) {\n opts.preset = arg.slice('--preset='.length);\n continue;\n }\n if (arg === '--rule') {\n const raw = argv[++i];\n try {\n opts.rule = JSON.parse(raw) as NapiConfig;\n } catch {\n throw new Error(\n `Invalid --rule JSON: ${raw?.slice(0, 100) ?? '(empty)'}`\n );\n }\n continue;\n }\n if (arg === '--root') {\n opts.root = path.resolve(argv[++i]);\n continue;\n }\n if (arg.startsWith('--root=')) {\n opts.root = path.resolve(arg.slice('--root='.length));\n continue;\n }\n if (arg === '--json') {\n opts.json = true;\n continue;\n }\n if (arg === '--limit') {\n opts.limit = parseInt(argv[++i], 10);\n continue;\n }\n if (arg === '--include-tests') {\n opts.includeTests = true;\n continue;\n }\n if (arg === '--context' || arg === '-C') {\n opts.context = parseInt(argv[++i], 10);\n continue;\n }\n if (arg === '--list-presets') {\n listPresets = true;\n continue;\n }\n if (arg === '--help' || arg === '-h') {\n printSearchHelp();\n process.exit(0);\n }\n }\n\n if (Number.isNaN(opts.limit)) opts.limit = 500;\n if (Number.isNaN(opts.context)) opts.context = 0;\n\n return { opts, listPresets };\n}\n\nfunction printSearchHelp(): void {\n console.log(`\nast-search — Structural code search powered by ast-grep\n\nUsage:\n node scripts/ast/search.js [options]\n\nSearch modes (pick one):\n --pattern, -p \u003ccode> Match code structurally (e.g. 'console.log($$ARGS)')\n --kind, -k \u003ckind> Match AST node kind (e.g. 'function_declaration')\n --preset \u003cname> Use a built-in search preset (e.g. 'empty-catch')\n --rule \u003cjson> Raw ast-grep rule object as JSON\n\nOptions:\n --root \u003cpath> Search root directory (default: cwd)\n --json Output as JSON\n --limit N Max matches (default: 500)\n --include-tests Include test files\n --context, -C N Lines of context around matches (text output only)\n --list-presets Show available presets and exit\n --help, -h Show this message\n\nPattern wildcards:\n $NAME Match any single AST node\n $$NAME Match zero or more nodes (variadic)\n\nExamples:\n node scripts/ast/search.js -p 'console.log($$ARGS)' --root ./src\n node scripts/ast/search.js --preset empty-catch --root ./packages\n node scripts/ast/search.js -k function_declaration --json --limit 20\n node scripts/ast/search.js --preset todo-fixme --include-tests\n node scripts/ast/search.js -p 'if ($COND) { return $VAL }' --root ./src\n node scripts/ast/search.js --rule '{\"rule\":{\"kind\":\"catch_clause\"}}' --root ./src\n\nPresets:\n${Object.entries(PRESETS)\n .map(([name, p]) => ` ${name.padEnd(22)} ${p.description}`)\n .join('\\n')}\n`);\n}\n\nexport function formatTextOutput(\n result: AstSearchResult,\n opts: AstSearchOptions,\n _root: string\n): string {\n const lines: string[] = [];\n lines.push(`\\n🔍 ${result.query}`);\n lines.push(\n ` ${result.totalMatches} matches across ${result.totalFiles} files\\n`\n );\n\n const ctx = opts.context;\n const sourceMap = result._sourceByFile;\n\n let currentFile = '';\n for (const m of result.matches) {\n if (m.file !== currentFile) {\n currentFile = m.file;\n lines.push(`\\n── ${currentFile} ──`);\n }\n\n if (ctx > 0 && sourceMap) {\n const srcLines = sourceMap.get(m.file);\n if (srcLines) {\n const start = Math.max(0, m.lineStart - 1 - ctx);\n const end = Math.min(srcLines.length, m.lineEnd + ctx);\n for (let i = start; i \u003c end; i++) {\n const lineNum = i + 1;\n const marker =\n lineNum >= m.lineStart && lineNum \u003c= m.lineEnd ? '>' : ' ';\n lines.push(\n ` ${marker} ${String(lineNum).padStart(4)} | ${srcLines[i]}`\n );\n }\n lines.push('');\n continue;\n }\n }\n\n const truncatedText =\n m.text.length > 200 ? m.text.slice(0, 200) + '…' : m.text;\n const singleLine = truncatedText.replace(/\\n/g, '↵').replace(/\\s+/g, ' ');\n lines.push(\n ` L${m.lineStart}:${m.columnStart} [${m.kind}] ${singleLine}`\n );\n\n if (m.metaVariables && Object.keys(m.metaVariables).length > 0) {\n for (const [k, v] of Object.entries(m.metaVariables)) {\n const truncV = v.length > 80 ? v.slice(0, 80) + '…' : v;\n lines.push(` ${k} = ${truncV}`);\n }\n }\n }\n\n lines.push('');\n return lines.join('\\n');\n}\n\nexport async function main(): Promise\u003cvoid> {\n const { opts, listPresets } = parseSearchArgs(process.argv.slice(2));\n\n if (listPresets) {\n if (opts.json) {\n console.log(JSON.stringify(PRESETS));\n } else {\n console.log('\\nAvailable presets:\\n');\n for (const [name, preset] of Object.entries(PRESETS)) {\n console.log(` ${name.padEnd(26)} ${preset.description}`);\n }\n console.log('');\n }\n return;\n }\n\n if (!opts.pattern && !opts.kind && !opts.preset && !opts.rule) {\n console.error('Error: Must provide --pattern, --kind, --preset, or --rule');\n console.error('Run with --help for usage information.');\n process.exit(1);\n }\n\n const files = collectSearchFiles(opts.root, opts);\n\n if (files.length === 0) {\n console.error(`No files found in ${opts.root}`);\n process.exit(1);\n }\n\n const hasPython = files.some(f => isPythonFile(path.extname(f)));\n if (hasPython) {\n await ensurePythonRegistered();\n }\n\n const result = runSearch(files, opts, opts.root);\n\n if (opts.json) {\n console.log(JSON.stringify(result));\n } else {\n console.log(formatTextOutput(result, opts, opts.root));\n }\n}\n\nexport { ensurePythonRegistered };\n// Direct execution lives in ./search.ts; this file is an internal module.\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":21974,"content_sha256":"cd04ad640aa6cdb2e53e02fa0800d4a47b9fb0708d7ebec673d6e0c619875a3f"},{"filename":"src/ast/search.test.ts","content":"import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';\n\nimport {\n type AstSearchOptions,\n PRESETS,\n collectSearchFiles,\n ensurePythonRegistered,\n formatTextOutput,\n parseSearchArgs,\n runSearch,\n searchFile,\n} from './search-main.js';\n\ndescribe('parseSearchArgs', () => {\n it('returns defaults when no args given', () => {\n const { opts } = parseSearchArgs([]);\n expect(opts.pattern).toBeNull();\n expect(opts.kind).toBeNull();\n expect(opts.preset).toBeNull();\n expect(opts.json).toBe(false);\n expect(opts.limit).toBe(500);\n expect(opts.includeTests).toBe(false);\n });\n\n it('parses --pattern with separate arg', () => {\n const { opts } = parseSearchArgs(['--pattern', 'console.log($A)']);\n expect(opts.pattern).toBe('console.log($A)');\n });\n\n it('parses -p shorthand', () => {\n const { opts } = parseSearchArgs(['-p', 'foo($BAR)']);\n expect(opts.pattern).toBe('foo($BAR)');\n });\n\n it('parses --pattern= syntax', () => {\n const { opts } = parseSearchArgs(['--pattern=console.log($$ARGS)']);\n expect(opts.pattern).toBe('console.log($$ARGS)');\n });\n\n it('parses --kind with separate arg', () => {\n const { opts } = parseSearchArgs(['--kind', 'function_declaration']);\n expect(opts.kind).toBe('function_declaration');\n });\n\n it('parses -k shorthand', () => {\n const { opts } = parseSearchArgs(['-k', 'class_declaration']);\n expect(opts.kind).toBe('class_declaration');\n });\n\n it('parses --kind= syntax', () => {\n const { opts } = parseSearchArgs(['--kind=arrow_function']);\n expect(opts.kind).toBe('arrow_function');\n });\n\n it('parses --preset with separate arg', () => {\n const { opts } = parseSearchArgs(['--preset', 'empty-catch']);\n expect(opts.preset).toBe('empty-catch');\n });\n\n it('parses --preset= syntax', () => {\n const { opts } = parseSearchArgs(['--preset=console-log']);\n expect(opts.preset).toBe('console-log');\n });\n\n it('parses --json flag', () => {\n const { opts } = parseSearchArgs(['--json']);\n expect(opts.json).toBe(true);\n });\n\n it('parses --limit', () => {\n const { opts } = parseSearchArgs(['--limit', '50']);\n expect(opts.limit).toBe(50);\n });\n\n it('parses --include-tests', () => {\n const { opts } = parseSearchArgs(['--include-tests']);\n expect(opts.includeTests).toBe(true);\n });\n\n it('parses --root', () => {\n const { opts } = parseSearchArgs(['--root', '/tmp/myrepo']);\n expect(opts.root).toBe('/tmp/myrepo');\n });\n\n it('parses --root= syntax', () => {\n const { opts } = parseSearchArgs(['--root=/tmp/other']);\n expect(opts.root).toBe('/tmp/other');\n });\n\n it('parses --context / -C', () => {\n expect(parseSearchArgs(['--context', '3']).opts.context).toBe(3);\n expect(parseSearchArgs(['-C', '5']).opts.context).toBe(5);\n });\n\n it('parses --list-presets', () => {\n const { listPresets } = parseSearchArgs(['--list-presets']);\n expect(listPresets).toBe(true);\n });\n\n it('falls back to defaults for NaN limit', () => {\n const { opts } = parseSearchArgs(['--limit', 'abc']);\n expect(opts.limit).toBe(500);\n });\n\n it('handles multiple flags together', () => {\n const { opts } = parseSearchArgs([\n '-p',\n 'console.log($A)',\n '--json',\n '--limit',\n '10',\n '--include-tests',\n ]);\n expect(opts.pattern).toBe('console.log($A)');\n expect(opts.json).toBe(true);\n expect(opts.limit).toBe(10);\n expect(opts.includeTests).toBe(true);\n });\n\n it('parses --rule JSON', () => {\n const rule = '{\"rule\":{\"kind\":\"catch_clause\"}}';\n const { opts } = parseSearchArgs(['--rule', rule]);\n expect(opts.rule).toEqual({ rule: { kind: 'catch_clause' } });\n });\n});\n\ndescribe('PRESETS', () => {\n it('has expected presets defined', () => {\n expect(PRESETS['empty-catch']).toBeDefined();\n expect(PRESETS['console-log']).toBeDefined();\n expect(PRESETS['debugger']).toBeDefined();\n expect(PRESETS['any-type']).toBeDefined();\n expect(PRESETS['todo-fixme']).toBeDefined();\n expect(PRESETS['switch-no-default']).toBeDefined();\n expect(PRESETS['nested-ternary']).toBeDefined();\n expect(PRESETS['throw-string']).toBeDefined();\n });\n\n it('all presets have description and rule', () => {\n for (const [name, preset] of Object.entries(PRESETS)) {\n expect(preset.description, `${name} missing description`).toBeTruthy();\n expect(preset.rule, `${name} missing rule`).toBeDefined();\n }\n });\n});\n\ndescribe('searchFile', () => {\n it('finds pattern matches in TypeScript', () => {\n const source = `\nfunction greet(name: string) {\n console.log(\"Hello\", name);\n console.log(\"Done\");\n}\n`;\n const matches = searchFile(\n 'test.ts',\n source,\n 'console.log($$ARGS)',\n 'console.log($$ARGS)',\n 100\n );\n expect(matches.length).toBe(2);\n expect(matches[0].kind).toBe('call_expression');\n expect(matches[0].lineStart).toBeGreaterThan(0);\n expect(matches[0].file).toBe('test.ts');\n });\n\n it('finds kind matches', () => {\n const source = `\nfunction foo() { return 1; }\nconst bar = () => 2;\nfunction baz() { return 3; }\n`;\n const matches = searchFile(\n 'test.ts',\n source,\n { rule: { kind: 'function_declaration' } },\n null,\n 100\n );\n expect(matches.length).toBe(2);\n expect(matches.every(m => m.kind === 'function_declaration')).toBe(true);\n });\n\n it('respects limit', () => {\n const source = `\nconsole.log(1);\nconsole.log(2);\nconsole.log(3);\nconsole.log(4);\nconsole.log(5);\n`;\n const matches = searchFile(\n 'test.ts',\n source,\n 'console.log($A)',\n 'console.log($A)',\n 3\n );\n expect(matches.length).toBe(3);\n });\n\n it('finds empty catch blocks with preset rule', () => {\n const source = `\ntry { doStuff(); } catch (e) {}\ntry { other(); } catch (e) { handle(e); }\ntry { another(); } catch (e) {\n}\n`;\n const preset = PRESETS['empty-catch'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBeGreaterThanOrEqual(1);\n expect(matches.every(m => m.kind === 'catch_clause')).toBe(true);\n });\n\n it('finds debugger statements', () => {\n const source = `\nfunction debug() {\n debugger;\n return true;\n}\n`;\n const preset = PRESETS['debugger'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(1);\n expect(matches[0].kind).toBe('debugger_statement');\n });\n\n it('finds any type annotations', () => {\n const source = `\nfunction foo(a: any, b: string): any {\n const x: any = {};\n return x;\n}\n`;\n const preset = PRESETS['any-type'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(3);\n });\n\n it('finds nested ternary expressions', () => {\n const source = `\nconst x = a ? (b ? 1 : 2) : 3;\nconst y = a ? 1 : 2;\n`;\n const preset = PRESETS['nested-ternary'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(1);\n });\n\n it('finds switch without default', () => {\n const source = `\nswitch (x) {\n case 1: break;\n case 2: break;\n}\nswitch (y) {\n case 1: break;\n default: break;\n}\n`;\n const preset = PRESETS['switch-no-default'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(1);\n });\n\n it('finds class declarations', () => {\n const source = `\nclass Foo {}\nclass Bar extends Foo {}\nconst fn = () => {};\n`;\n const preset = PRESETS['class-declaration'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(2);\n });\n\n it('finds throw string patterns', () => {\n const source = `\nfunction bad() { throw \"oops\"; }\nfunction good() { throw new Error(\"oops\"); }\n`;\n const preset = PRESETS['throw-string'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(1);\n });\n\n it('finds non-null assertions', () => {\n const source = `\nconst x = obj!.foo;\nconst y = arr![0];\nconst z = normal.foo;\n`;\n const preset = PRESETS['non-null-assertion'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(2);\n });\n\n it('returns empty array when no matches', () => {\n const source = 'const x = 1;';\n const matches = searchFile(\n 'test.ts',\n source,\n 'console.log($A)',\n 'console.log($A)',\n 100\n );\n expect(matches.length).toBe(0);\n });\n\n it('extracts meta variables from pattern', () => {\n const source = 'console.log(\"hello\", 42);';\n const matches = searchFile(\n 'test.ts',\n source,\n 'console.log($$ARGS)',\n 'console.log($$ARGS)',\n 100\n );\n expect(matches.length).toBe(1);\n });\n\n it('handles JSX files', () => {\n const source = `\nfunction App() {\n console.log(\"render\");\n return \u003cdiv>Hello\u003c/div>;\n}\n`;\n const matches = searchFile(\n 'test.tsx',\n source,\n 'console.log($$ARGS)',\n 'console.log($$ARGS)',\n 100\n );\n expect(matches.length).toBe(1);\n });\n});\n\ndescribe('runSearch', () => {\n let tmpDir: string;\n\n function writeFile(name: string, content: string): string {\n const filePath = path.join(tmpDir, name);\n fs.mkdirSync(path.dirname(filePath), { recursive: true });\n fs.writeFileSync(filePath, content, 'utf8');\n return filePath;\n }\n\n function defaultOpts(\n overrides: Partial\u003cAstSearchOptions> = {}\n ): AstSearchOptions {\n return {\n root: tmpDir,\n pattern: null,\n kind: null,\n preset: null,\n rule: null,\n json: false,\n limit: 500,\n includeTests: false,\n ignoreDirs: new Set(['.git', 'node_modules', 'dist']),\n context: 0,\n ...overrides,\n };\n }\n\n beforeEach(() => {\n tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ast-search-test-'));\n });\n\n afterEach(() => {\n fs.rmSync(tmpDir, { recursive: true, force: true });\n });\n\n it('searches with pattern across files', () => {\n const f1 = writeFile('src/a.ts', 'console.log(\"a\");\\nconsole.log(\"b\");');\n const f2 = writeFile('src/b.ts', 'console.log(\"c\");');\n writeFile('src/c.ts', 'const x = 1;');\n\n const opts = defaultOpts({ pattern: 'console.log($A)' });\n const result = runSearch(\n [f1, f2, path.join(tmpDir, 'src/c.ts')],\n opts,\n tmpDir\n );\n expect(result.totalMatches).toBe(3);\n expect(result.totalFiles).toBe(2);\n expect(result.queryType).toBe('pattern');\n });\n\n it('searches with preset', () => {\n const f1 = writeFile(\n 'x.ts',\n 'try { a(); } catch(e) {}\\ntry { b(); } catch(e) { log(e); }'\n );\n const opts = defaultOpts({ preset: 'empty-catch' });\n const result = runSearch([f1], opts, tmpDir);\n expect(result.totalMatches).toBeGreaterThanOrEqual(1);\n expect(result.queryType).toBe('preset');\n });\n\n it('searches with kind', () => {\n const f1 = writeFile(\n 'fn.ts',\n 'function foo() {}\\nfunction bar() {}\\nconst x = 1;'\n );\n const opts = defaultOpts({ kind: 'function_declaration' });\n const result = runSearch([f1], opts, tmpDir);\n expect(result.totalMatches).toBe(2);\n expect(result.queryType).toBe('kind');\n });\n\n it('respects limit across files', () => {\n const f1 = writeFile(\n 'a.ts',\n 'console.log(1);\\nconsole.log(2);\\nconsole.log(3);'\n );\n const f2 = writeFile('b.ts', 'console.log(4);\\nconsole.log(5);');\n const opts = defaultOpts({ pattern: 'console.log($A)', limit: 3 });\n const result = runSearch([f1, f2], opts, tmpDir);\n expect(result.totalMatches).toBe(3);\n });\n\n it('throws on unknown preset', () => {\n const f1 = writeFile('x.ts', 'const x = 1;');\n const opts = defaultOpts({ preset: 'nonexistent' });\n expect(() => runSearch([f1], opts, tmpDir)).toThrow(\n /Unknown preset.*nonexistent/\n );\n });\n\n it('throws when no search mode provided', () => {\n const f1 = writeFile('x.ts', 'const x = 1;');\n const opts = defaultOpts();\n expect(() => runSearch([f1], opts, tmpDir)).toThrow(/Must provide/);\n });\n});\n\ndescribe('collectSearchFiles', () => {\n let tmpDir: string;\n\n beforeEach(() => {\n tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ast-search-files-'));\n });\n\n afterEach(() => {\n fs.rmSync(tmpDir, { recursive: true, force: true });\n });\n\n it('collects .ts and .js files', () => {\n fs.writeFileSync(path.join(tmpDir, 'a.ts'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'b.js'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'c.txt'), '', 'utf8');\n const files = collectSearchFiles(tmpDir, {\n includeTests: false,\n ignoreDirs: new Set(),\n });\n expect(files).toHaveLength(2);\n expect(files.some(f => f.endsWith('a.ts'))).toBe(true);\n expect(files.some(f => f.endsWith('b.js'))).toBe(true);\n });\n\n it('excludes test files by default', () => {\n fs.writeFileSync(path.join(tmpDir, 'foo.ts'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'foo.test.ts'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'foo.spec.ts'), '', 'utf8');\n const files = collectSearchFiles(tmpDir, {\n includeTests: false,\n ignoreDirs: new Set(),\n });\n expect(files).toHaveLength(1);\n expect(files[0]).toMatch(/foo\\.ts$/);\n });\n\n it('includes test files when flag is set', () => {\n fs.writeFileSync(path.join(tmpDir, 'foo.ts'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'foo.test.ts'), '', 'utf8');\n const files = collectSearchFiles(tmpDir, {\n includeTests: true,\n ignoreDirs: new Set(),\n });\n expect(files).toHaveLength(2);\n });\n\n it('skips ignored directories', () => {\n fs.mkdirSync(path.join(tmpDir, 'node_modules'), { recursive: true });\n fs.writeFileSync(path.join(tmpDir, 'node_modules', 'lib.ts'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'main.ts'), '', 'utf8');\n const files = collectSearchFiles(tmpDir, {\n includeTests: false,\n ignoreDirs: new Set(['node_modules']),\n });\n expect(files).toHaveLength(1);\n expect(files[0]).toMatch(/main\\.ts$/);\n });\n\n it('skips .d.ts files', () => {\n fs.writeFileSync(path.join(tmpDir, 'index.ts'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'index.d.ts'), '', 'utf8');\n const files = collectSearchFiles(tmpDir, {\n includeTests: false,\n ignoreDirs: new Set(),\n });\n expect(files).toHaveLength(1);\n expect(files[0]).toMatch(/index\\.ts$/);\n });\n});\n\ndescribe('parseSearchArgs --rule error handling', () => {\n it('throws user-friendly error for invalid JSON', () => {\n expect(() => parseSearchArgs(['--rule', 'not-json'])).toThrow(\n /Invalid --rule JSON/\n );\n });\n\n it('includes the bad input in the error message', () => {\n expect(() => parseSearchArgs(['--rule', '{broken'])).toThrow(/\\{broken/);\n });\n\n it('handles missing --rule value gracefully', () => {\n expect(() => parseSearchArgs(['--rule'])).toThrow(/Invalid --rule JSON/);\n });\n});\n\ndescribe('meta-variable extraction', () => {\n it('extracts single $VAR without duplicating variadic $$VAR', () => {\n const source = 'console.log(\"hello\");';\n const matches = searchFile(\n 'test.ts',\n source,\n 'console.$METHOD($$ARGS)',\n 'console.$METHOD($$ARGS)',\n 100\n );\n expect(matches.length).toBe(1);\n const vars = matches[0].metaVariables!;\n expect(vars['$METHOD']).toBe('log');\n expect(vars['$$ARGS']).toBe('\"hello\"');\n expect(Object.keys(vars)).toHaveLength(2);\n });\n\n it('does not produce spurious single-var entries for variadic names', () => {\n const source = 'fn(1, 2, 3);';\n const matches = searchFile(\n 'test.ts',\n source,\n 'fn($$ITEMS)',\n 'fn($$ITEMS)',\n 100\n );\n expect(matches.length).toBe(1);\n const vars = matches[0].metaVariables!;\n expect(vars['$$ITEMS']).toBeDefined();\n expect(vars['$ITEMS']).toBeUndefined();\n });\n\n it('handles pattern with both single and variadic meta-vars', () => {\n const source = 'import { foo, bar } from \"lodash\";';\n const matches = searchFile(\n 'test.ts',\n source,\n 'import { $$NAMES } from $MOD',\n 'import { $$NAMES } from $MOD',\n 100\n );\n expect(matches.length).toBe(1);\n const vars = matches[0].metaVariables!;\n expect(vars['$MOD']).toBe('\"lodash\"');\n expect(vars['$$NAMES']).toBeDefined();\n });\n});\n\ndescribe('formatTextOutput with context', () => {\n let tmpDir: string;\n\n beforeEach(() => {\n tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ast-ctx-'));\n });\n\n afterEach(() => {\n fs.rmSync(tmpDir, { recursive: true, force: true });\n });\n\n function defaultOpts(\n overrides: Partial\u003cAstSearchOptions> = {}\n ): AstSearchOptions {\n return {\n root: tmpDir,\n pattern: null,\n kind: null,\n preset: null,\n rule: null,\n json: false,\n limit: 500,\n includeTests: false,\n ignoreDirs: new Set(['.git', 'node_modules', 'dist']),\n context: 0,\n ...overrides,\n };\n }\n\n it('shows context lines around matches when context > 0', () => {\n const source = 'line1\\nline2\\nconsole.log(\"hit\");\\nline4\\nline5\\n';\n const filePath = path.join(tmpDir, 'ctx.ts');\n fs.writeFileSync(filePath, source, 'utf8');\n\n const opts = defaultOpts({ pattern: 'console.log($$A)', context: 1 });\n const result = runSearch([filePath], opts, tmpDir);\n\n expect(result._sourceByFile).toBeDefined();\n expect(result._sourceByFile!.size).toBe(1);\n\n const output = formatTextOutput(result, opts, tmpDir);\n expect(output).toContain('line2');\n expect(output).toContain('console.log(\"hit\")');\n expect(output).toContain('line4');\n expect(output).toContain('>');\n });\n\n it('does not include _sourceByFile when context is 0', () => {\n const source = 'console.log(\"x\");\\n';\n const filePath = path.join(tmpDir, 'noctx.ts');\n fs.writeFileSync(filePath, source, 'utf8');\n\n const opts = defaultOpts({ pattern: 'console.log($$A)', context: 0 });\n const result = runSearch([filePath], opts, tmpDir);\n expect(result._sourceByFile).toBeUndefined();\n });\n\n it('clamps context at file boundaries', () => {\n const source = 'console.log(\"first line\");\\nline2\\n';\n const filePath = path.join(tmpDir, 'edge.ts');\n fs.writeFileSync(filePath, source, 'utf8');\n\n const opts = defaultOpts({ pattern: 'console.log($$A)', context: 5 });\n const result = runSearch([filePath], opts, tmpDir);\n const output = formatTextOutput(result, opts, tmpDir);\n expect(output).toContain('console.log(\"first line\")');\n expect(output).toContain('line2');\n expect(output).not.toContain('undefined');\n });\n});\n\ndescribe('new AST search presets', () => {\n it('catch-rethrow: finds catch blocks that re-throw', () => {\n const source = `\ntry { doStuff(); } catch (e) { throw e; }\ntry { doOther(); } catch (e) { console.error(e); throw e; }\ntry { ok(); } catch (e) { handleError(e); }\n`;\n const preset = PRESETS['catch-rethrow'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBeGreaterThanOrEqual(1);\n });\n\n it('catch-rethrow: no match when catch handles error', () => {\n const source = `try { doStuff(); } catch (e) { handleError(e); }`;\n const preset = PRESETS['catch-rethrow'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(0);\n });\n\n it('promise-all: finds Promise.all calls', () => {\n const source = `\nconst results = await Promise.all([fetch('/a'), fetch('/b')]);\nconst single = await fetch('/c');\n`;\n const preset = PRESETS['promise-all'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(1);\n });\n\n it('promise-all: no match without Promise.all', () => {\n const source = `const x = await fetch('/a');`;\n const preset = PRESETS['promise-all'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(0);\n });\n\n it('boolean-param: finds boolean-typed parameters', () => {\n const source = `function toggle(visible: boolean, active: boolean) { return visible && active; }`;\n const preset = PRESETS['boolean-param'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBeGreaterThanOrEqual(1);\n });\n\n it('boolean-param: no match for non-boolean params', () => {\n const source = `function add(a: number, b: number) { return a + b; }`;\n const preset = PRESETS['boolean-param'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(0);\n });\n\n it('magic-number: finds numeric literals excluding 0 and 1', () => {\n const source = `\nconst x = 42;\nconst y = 0;\nconst z = 1;\nconst w = 100;\n`;\n const preset = PRESETS['magic-number'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(2);\n expect(matches.some(m => m.text === '42')).toBe(true);\n expect(matches.some(m => m.text === '100')).toBe(true);\n });\n\n it('magic-number: no match for 0 and 1', () => {\n const source = `const x = 0; const y = 1;`;\n const preset = PRESETS['magic-number'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(0);\n });\n\n it('deep-callback: finds 3+ nested arrow functions', () => {\n const source = `\nconst f = () => {\n return () => {\n return () => {\n return 42;\n };\n };\n};\n`;\n const preset = PRESETS['deep-callback'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBeGreaterThanOrEqual(1);\n });\n\n it('deep-callback: no match for 2 levels', () => {\n const source = `const f = () => { return () => { return 1; }; };`;\n const preset = PRESETS['deep-callback'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBe(0);\n });\n\n it('unused-var: finds variable declarations without call expressions', () => {\n const source = `\nconst x = 42;\nconst y = \"hello\";\n`;\n const preset = PRESETS['unused-var'];\n const matches = searchFile('test.ts', source, preset, null, 100);\n expect(matches.length).toBeGreaterThanOrEqual(2);\n });\n\n it('all new presets are defined in PRESETS object', () => {\n const newPresets = ['catch-rethrow', 'promise-all', 'boolean-param', 'magic-number', 'deep-callback', 'unused-var'];\n for (const name of newPresets) {\n expect(PRESETS[name]).toBeDefined();\n expect(PRESETS[name].description).toBeTruthy();\n expect(PRESETS[name].rule).toBeDefined();\n }\n });\n});\n\ndescribe('Python presets', () => {\n beforeAll(async () => {\n await ensurePythonRegistered();\n });\n\n it('all Python presets are defined in PRESETS', () => {\n const pyPresets = [\n 'py-bare-except', 'py-pass-except', 'py-broad-except',\n 'py-global-stmt', 'py-exec-call', 'py-eval-call',\n 'py-star-import', 'py-assert', 'py-mutable-default',\n 'py-todo-fixme', 'py-print-call', 'py-class', 'py-async-function',\n ];\n for (const name of pyPresets) {\n expect(PRESETS[name], `${name} missing`).toBeDefined();\n expect(PRESETS[name].description).toBeTruthy();\n expect(PRESETS[name].rule).toBeDefined();\n }\n });\n\n it('py-bare-except: finds bare except clause', () => {\n const source = 'try:\\n x = 1\\nexcept:\\n pass\\n';\n const matches = searchFile('test.py', source, PRESETS['py-bare-except'], null, 100);\n expect(matches.length).toBe(1);\n expect(matches[0].kind).toBe('except_clause');\n });\n\n it('py-pass-except: finds except-pass blocks', () => {\n const source = 'try:\\n x = 1\\nexcept Exception:\\n pass\\n';\n const matches = searchFile('test.py', source, PRESETS['py-pass-except'], null, 100);\n expect(matches.length).toBe(1);\n });\n\n it('py-broad-except: finds Exception/BaseException', () => {\n const source = 'try:\\n x = 1\\nexcept Exception:\\n pass\\ntry:\\n y = 2\\nexcept ValueError:\\n pass\\n';\n const matches = searchFile('test.py', source, PRESETS['py-broad-except'], null, 100);\n expect(matches.length).toBe(1);\n });\n\n it('py-global-stmt: finds global statements', () => {\n const source = 'def foo():\\n global x\\n x = 1\\n';\n const matches = searchFile('test.py', source, PRESETS['py-global-stmt'], null, 100);\n expect(matches.length).toBe(1);\n });\n\n it('py-exec-call: finds exec() calls', () => {\n const source = 'exec(\"x = 1\")\\ny = 2\\n';\n const matches = searchFile('test.py', source, PRESETS['py-exec-call'], null, 100);\n expect(matches.length).toBe(1);\n });\n\n it('py-eval-call: finds eval() calls', () => {\n const source = 'result = eval(\"1+1\")\\neval(input())\\n';\n const matches = searchFile('test.py', source, PRESETS['py-eval-call'], null, 100);\n expect(matches.length).toBe(2);\n });\n\n it('py-star-import: finds wildcard imports', () => {\n const source = 'from os import *\\nimport sys\\n';\n const matches = searchFile('test.py', source, PRESETS['py-star-import'], null, 100);\n expect(matches.length).toBe(1);\n });\n\n it('py-assert: finds assert statements', () => {\n const source = 'assert x > 0\\nassert True\\ny = 1\\n';\n const matches = searchFile('test.py', source, PRESETS['py-assert'], null, 100);\n expect(matches.length).toBe(2);\n });\n\n it('py-mutable-default: finds mutable default arguments', () => {\n const source = 'def foo(a=[]):\\n pass\\ndef bar(a={}):\\n pass\\ndef ok(a=None):\\n pass\\n';\n const matches = searchFile('test.py', source, PRESETS['py-mutable-default'], null, 100);\n expect(matches.length).toBe(2);\n });\n\n it('py-todo-fixme: finds TODO/FIXME comments', () => {\n const source = '# TODO: fix this\\nx = 1\\n# FIXME: broken\\n';\n const matches = searchFile('test.py', source, PRESETS['py-todo-fixme'], null, 100);\n expect(matches.length).toBe(2);\n });\n\n it('py-print-call: finds print() calls', () => {\n const source = 'print(\"hello\")\\nprint(1, 2)\\nx = 1\\n';\n const matches = searchFile('test.py', source, PRESETS['py-print-call'], null, 100);\n expect(matches.length).toBe(2);\n });\n\n it('py-class: finds class definitions', () => {\n const source = 'class Foo:\\n pass\\nclass Bar(Foo):\\n pass\\n';\n const matches = searchFile('test.py', source, PRESETS['py-class'], null, 100);\n expect(matches.length).toBe(2);\n });\n\n it('py-async-function: finds async function definitions', () => {\n const source = 'async def foo():\\n await bar()\\ndef normal():\\n pass\\n';\n const matches = searchFile('test.py', source, PRESETS['py-async-function'], null, 100);\n expect(matches.length).toBe(1);\n });\n});\n\ndescribe('Python file collection', () => {\n let tmpDir: string;\n\n beforeEach(() => {\n tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ast-py-collect-'));\n });\n\n afterEach(() => {\n fs.rmSync(tmpDir, { recursive: true, force: true });\n });\n\n it('collects .py files alongside .ts files', () => {\n fs.writeFileSync(path.join(tmpDir, 'a.ts'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'b.py'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'c.txt'), '', 'utf8');\n const files = collectSearchFiles(tmpDir, { includeTests: false, ignoreDirs: new Set() });\n expect(files).toHaveLength(2);\n expect(files.some(f => f.endsWith('.ts'))).toBe(true);\n expect(files.some(f => f.endsWith('.py'))).toBe(true);\n });\n\n it('excludes Python test files by default', () => {\n fs.writeFileSync(path.join(tmpDir, 'main.py'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'test_main.py'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'main_test.py'), '', 'utf8');\n const files = collectSearchFiles(tmpDir, { includeTests: false, ignoreDirs: new Set() });\n expect(files).toHaveLength(1);\n expect(files[0]).toMatch(/main\\.py$/);\n });\n\n it('includes Python test files when flag is set', () => {\n fs.writeFileSync(path.join(tmpDir, 'main.py'), '', 'utf8');\n fs.writeFileSync(path.join(tmpDir, 'test_main.py'), '', 'utf8');\n const files = collectSearchFiles(tmpDir, { includeTests: true, ignoreDirs: new Set() });\n expect(files).toHaveLength(2);\n });\n});\n\ndescribe('Python searchFile integration', () => {\n beforeAll(async () => {\n await ensurePythonRegistered();\n });\n\n it('finds pattern matches in Python source', () => {\n const source = 'def greet(name):\\n print(\"Hello\", name)\\n print(\"Done\")\\n';\n const matches = searchFile('test.py', source, { rule: { kind: 'call', has: { kind: 'identifier', regex: '^print

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

} } }, null, 100);\n expect(matches.length).toBe(2);\n expect(matches[0].lineStart).toBeGreaterThan(0);\n expect(matches[0].file).toBe('test.py');\n });\n\n it('finds kind matches in Python', () => {\n const source = 'def foo():\\n return 1\\ndef bar():\\n return 2\\nx = 1\\n';\n const matches = searchFile('test.py', source, { rule: { kind: 'function_definition' } }, null, 100);\n expect(matches.length).toBe(2);\n expect(matches.every(m => m.kind === 'function_definition')).toBe(true);\n });\n\n it('respects limit for Python files', () => {\n const source = 'print(1)\\nprint(2)\\nprint(3)\\nprint(4)\\nprint(5)\\n';\n const matches = searchFile('test.py', source, { rule: { kind: 'call', has: { kind: 'identifier', regex: '^print

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

} } }, null, 3);\n expect(matches.length).toBe(3);\n });\n\n it('returns empty array when no matches in Python', () => {\n const source = 'x = 1\\n';\n const matches = searchFile('test.py', source, { rule: { kind: 'call', has: { kind: 'identifier', regex: '^print

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

} } }, null, 100);\n expect(matches.length).toBe(0);\n });\n});\n\ndescribe('Python runSearch integration', () => {\n let tmpDir: string;\n\n beforeAll(async () => {\n await ensurePythonRegistered();\n });\n\n beforeEach(() => {\n tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ast-py-search-'));\n });\n\n afterEach(() => {\n fs.rmSync(tmpDir, { recursive: true, force: true });\n });\n\n function defaultOpts(overrides: Partial\u003cAstSearchOptions> = {}): AstSearchOptions {\n return {\n root: tmpDir,\n pattern: null,\n kind: null,\n preset: null,\n rule: null,\n json: false,\n limit: 500,\n includeTests: false,\n ignoreDirs: new Set(['.git', 'node_modules', 'dist']),\n context: 0,\n ...overrides,\n };\n }\n\n it('searches Python files with preset', () => {\n const f1 = path.join(tmpDir, 'main.py');\n fs.writeFileSync(f1, 'try:\\n x = 1\\nexcept:\\n pass\\n', 'utf8');\n const opts = defaultOpts({ preset: 'py-bare-except' });\n const result = runSearch([f1], opts, tmpDir);\n expect(result.totalMatches).toBeGreaterThanOrEqual(1);\n expect(result.queryType).toBe('preset');\n });\n\n it('searches mixed Python and TypeScript files', () => {\n const f1 = path.join(tmpDir, 'a.ts');\n fs.writeFileSync(f1, 'console.log(\"hello\");\\n', 'utf8');\n const f2 = path.join(tmpDir, 'b.py');\n fs.writeFileSync(f2, '# TODO: fix\\n', 'utf8');\n const opts = defaultOpts({ preset: 'todo-fixme' });\n const result = runSearch([f1, f2], opts, tmpDir);\n expect(result.totalMatches).toBeGreaterThanOrEqual(1);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":31266,"content_sha256":"c16e239ad39955d18a8479fa421e82473c3264feb7955c4b9f6fe2cb4428d2b4"},{"filename":"src/ast/search.ts","content":"#!/usr/bin/env node\n/**\n * Entry point for `scripts/ast/search.js`. Verifies the @ast-grep native\n * addons and other runtime deps are installed before loading the main\n * search logic. If missing, the bootstrap detects the user's package\n * manager and installs into the skill directory, or prints an actionable\n * manual command.\n */\nimport { ensureNativeDependencies } from '../common/ensure-deps.js';\n\nensureNativeDependencies(import.meta.url, { tag: '[octocode-ast-search]' });\n\nconst { main } = await import('./search-main.js');\nmain().catch((error: unknown) => {\n console.error(error);\n process.exit(1);\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":617,"content_sha256":"851473783fd615410ed34c3d5b7596288e471d76366dce7a307a4bc2e418481d"},{"filename":"src/ast/tree-search.test.ts","content":"import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport { afterEach, beforeEach, describe, expect, it } from 'vitest';\n\nimport {\n formatAstTreeSearchOutput,\n parseAstTreeSearchArgs,\n resolveAstTreeInput,\n searchAstTree,\n validateAstTreeSearchOptions,\n} from './tree-search.js';\n\ndescribe('ast-tree-search', () => {\n let tmpDir: string;\n let scanRoot: string;\n let latestScanDir: string;\n let olderScanDir: string;\n let astTreePath: string;\n\n beforeEach(() => {\n tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ast-tree-search-'));\n scanRoot = path.join(tmpDir, '.octocode', 'scan');\n olderScanDir = path.join(scanRoot, '2026-03-18T10-00-00-000Z');\n latestScanDir = path.join(scanRoot, '2026-03-19T10-00-00-000Z');\n\n fs.mkdirSync(olderScanDir, { recursive: true });\n fs.mkdirSync(latestScanDir, { recursive: true });\n\n fs.writeFileSync(path.join(olderScanDir, 'ast-trees.txt'), '## pkg — src/older.ts\\nFunctionDeclaration[1:2]\\n', 'utf8');\n\n astTreePath = path.join(latestScanDir, 'ast-trees.txt');\n fs.writeFileSync(astTreePath, [\n '## pkg — src/main.ts',\n 'SourceFile[1:50]',\n ' FunctionDeclaration[3:12]',\n ' IfStatement[5:7] ...',\n ' ClassDeclaration[14:30]',\n '## pkg — src/utils.ts',\n 'SourceFile[1:40]',\n ' function_declaration[4:8]',\n ' ArrowFunction[10:12]',\n ' WhileStatement[14:18]',\n '',\n ].join('\\n'), 'utf8');\n\n const olderTime = new Date('2026-03-18T10:00:00.000Z');\n const newerTime = new Date('2026-03-19T10:00:00.000Z');\n fs.utimesSync(path.join(olderScanDir, 'ast-trees.txt'), olderTime, olderTime);\n fs.utimesSync(path.join(latestScanDir, 'ast-trees.txt'), newerTime, newerTime);\n fs.utimesSync(olderScanDir, olderTime, olderTime);\n fs.utimesSync(latestScanDir, newerTime, newerTime);\n });\n\n afterEach(() => {\n fs.rmSync(tmpDir, { recursive: true, force: true });\n });\n\n it('parses limit, file, and section flags', () => {\n const { opts } = parseAstTreeSearchArgs([\n '--input', scanRoot,\n '--kind', 'FunctionDeclaration',\n '--file', 'src/main',\n '--section', 'pkg',\n '--limit', '10',\n '--context', '2',\n '--json',\n '--ignore-case',\n ]);\n\n expect(opts.input).toBe(scanRoot);\n expect(opts.kind).toBe('FunctionDeclaration');\n expect(opts.file).toBe('src/main');\n expect(opts.section).toBe('pkg');\n expect(opts.limit).toBe(10);\n expect(opts.context).toBe(2);\n expect(opts.json).toBe(true);\n expect(opts.ignoreCase).toBe(true);\n });\n\n it('rejects invalid search options', () => {\n expect(() => validateAstTreeSearchOptions({\n input: scanRoot,\n pattern: null,\n kind: null,\n context: 0,\n json: false,\n ignoreCase: false,\n limit: 50,\n file: null,\n section: null,\n })).toThrow('Must provide --pattern or --kind');\n });\n\n it('resolves the latest scan from the scan root', () => {\n const resolved = resolveAstTreeInput(scanRoot);\n expect(resolved.selectionMode).toBe('latest-scan');\n expect(resolved.inputFile).toBe(astTreePath);\n });\n\n it('resolves a specific scan directory', () => {\n const resolved = resolveAstTreeInput(latestScanDir);\n expect(resolved.selectionMode).toBe('scan-dir');\n expect(resolved.inputFile).toBe(astTreePath);\n });\n\n it('resolves a direct ast-trees.txt input path', () => {\n const resolved = resolveAstTreeInput(astTreePath);\n expect(resolved.selectionMode).toBe('direct-file');\n expect(resolved.inputFile).toBe(astTreePath);\n });\n\n it('matches kind queries with PascalCase and snake_case node names', () => {\n const result = searchAstTree(resolveAstTreeInput(scanRoot), {\n input: scanRoot,\n pattern: null,\n kind: 'function_declaration',\n context: 0,\n json: false,\n ignoreCase: false,\n limit: 0,\n file: null,\n section: null,\n });\n\n expect(result.totalMatches).toBe(2);\n expect(result.matches.map((match) => match.file)).toEqual([\n 'src/main.ts',\n 'src/utils.ts',\n ]);\n });\n\n it('filters by file and section regexes', () => {\n const result = searchAstTree(resolveAstTreeInput(scanRoot), {\n input: scanRoot,\n pattern: 'FunctionDeclaration|function_declaration|ArrowFunction',\n kind: null,\n context: 0,\n json: false,\n ignoreCase: false,\n limit: 0,\n file: 'src/utils',\n section: 'pkg',\n });\n\n expect(result.totalMatches).toBe(2);\n expect(result.matches.every((match) => match.file === 'src/utils.ts')).toBe(true);\n });\n\n it('enforces the default limit and reports truncation metadata', () => {\n const repeatedAst = [\n '## pkg — src/many.ts',\n 'SourceFile[1:200]',\n ...Array.from({ length: 60 }, (_, index) => ` FunctionDeclaration[${index + 1}:${index + 2}]`),\n '',\n ].join('\\n');\n fs.writeFileSync(astTreePath, repeatedAst, 'utf8');\n\n const result = searchAstTree(resolveAstTreeInput(astTreePath), {\n input: astTreePath,\n pattern: null,\n kind: 'FunctionDeclaration',\n context: 0,\n json: false,\n ignoreCase: false,\n limit: 50,\n file: null,\n section: null,\n });\n\n expect(result.totalMatches).toBe(60);\n expect(result.returnedMatches).toBe(50);\n expect(result.truncated).toBe(true);\n });\n\n it('formats output with selected file information and compact summary', () => {\n const opts = {\n input: scanRoot,\n pattern: 'IfStatement|WhileStatement',\n kind: null,\n context: 1,\n json: false,\n ignoreCase: false,\n limit: 25,\n file: null,\n section: null,\n };\n const result = searchAstTree(resolveAstTreeInput(scanRoot), opts);\n const text = formatAstTreeSearchOutput(result, opts);\n\n expect(text).toContain('Requested input:');\n expect(text).toContain('Selected AST file:');\n expect(text).toContain('Matches: 2 total, showing 2');\n expect(text).toContain('Matched files: 2');\n expect(text).toContain('src/main.ts');\n expect(text).toContain('src/utils.ts');\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":6113,"content_sha256":"b303c089c7bf21c413aeef2360bc8ddfe61c489fe9d27b633230e5e96ec744a3"},{"filename":"src/ast/tree-search.ts","content":"#!/usr/bin/env node\n\nimport fs from 'node:fs';\nimport path from 'node:path';\n\nimport { isDirectRun } from '../common/is-direct-run.js';\n\nexport interface AstTreeSearchOptions {\n input: string;\n pattern: string | null;\n kind: string | null;\n context: number;\n json: boolean;\n ignoreCase: boolean;\n limit: number;\n file: string | null;\n section: string | null;\n}\n\nexport interface AstTreeContextLine {\n lineNumber: number;\n line: string;\n}\n\nexport interface AstTreeMatch {\n section: string;\n file: string | null;\n lineNumber: number;\n line: string;\n context: AstTreeContextLine[];\n}\n\nexport interface ResolvedAstTreeInput {\n requestedInput: string;\n inputFile: string;\n selectionMode: 'direct-file' | 'scan-dir' | 'latest-scan';\n}\n\nexport interface AstTreeSearchResult {\n requestedInput: string;\n inputFile: string;\n selectionMode: ResolvedAstTreeInput['selectionMode'];\n query: string;\n limit: number;\n totalMatches: number;\n returnedMatches: number;\n truncated: boolean;\n uniqueFiles: number;\n matches: AstTreeMatch[];\n}\n\nfunction printAstTreeSearchHelp(): void {\n console.log(`\nast-tree-search — Search generated ast-trees.txt output\n\nUsage:\n node scripts/ast/tree-search.js [options]\n\nOptions:\n --input, -i \u003cpath> Path to ast-trees.txt, a scan directory, or .octocode/scan (default: .octocode/scan)\n --pattern, -p \u003cregex> Regex to match against AST tree lines\n --kind, -k \u003ckind> Match a node kind (supports snake_case or PascalCase)\n --file \u003cregex> Filter matches to section file paths that match the regex\n --section \u003cregex> Filter matches to section headers that match the regex\n --limit \u003cn> Max matches to return (default: 50, 0 = all)\n --context, -C \u003cn> Context lines around each match (default: 0)\n --json Output matches as JSON\n --ignore-case Case-insensitive pattern matching\n --help, -h Show this message\n\nExamples:\n node scripts/ast/tree-search.js -i .octocode/scan -k function_declaration --limit 25\n node scripts/ast/tree-search.js -i .octocode/scan/2026-03-18T23-43-21-490Z -k ClassDeclaration --file 'src/index'\n node scripts/ast/tree-search.js -i .octocode/scan -p 'IfStatement|SwitchStatement' --section 'src/'\n`);\n}\n\nfunction parseArgValue(arg: string, argv: string[], index: number): { value: string; nextIndex: number } {\n const equalsIndex = arg.indexOf('=');\n if (equalsIndex !== -1) {\n return { value: arg.slice(equalsIndex + 1), nextIndex: index };\n }\n\n return { value: argv[index + 1] || '', nextIndex: index + 1 };\n}\n\nexport function parseAstTreeSearchArgs(argv: string[]): { opts: AstTreeSearchOptions; showHelp: boolean } {\n const opts: AstTreeSearchOptions = {\n input: '.octocode/scan',\n pattern: null,\n kind: null,\n context: 0,\n json: false,\n ignoreCase: false,\n limit: 50,\n file: null,\n section: null,\n };\n\n let showHelp = false;\n\n for (let i = 0; i \u003c argv.length; i += 1) {\n const arg = argv[i];\n\n if (arg === '--json') {\n opts.json = true;\n continue;\n }\n if (arg === '--ignore-case') {\n opts.ignoreCase = true;\n continue;\n }\n if (arg === '--help' || arg === '-h') {\n showHelp = true;\n continue;\n }\n\n if (arg === '--input' || arg === '-i' || arg.startsWith('--input=')) {\n const parsed = parseArgValue(arg, argv, i);\n opts.input = parsed.value;\n i = parsed.nextIndex;\n continue;\n }\n if (arg === '--pattern' || arg === '-p' || arg.startsWith('--pattern=')) {\n const parsed = parseArgValue(arg, argv, i);\n opts.pattern = parsed.value;\n i = parsed.nextIndex;\n continue;\n }\n if (arg === '--kind' || arg === '-k' || arg.startsWith('--kind=')) {\n const parsed = parseArgValue(arg, argv, i);\n opts.kind = parsed.value;\n i = parsed.nextIndex;\n continue;\n }\n if (arg === '--file' || arg.startsWith('--file=')) {\n const parsed = parseArgValue(arg, argv, i);\n opts.file = parsed.value;\n i = parsed.nextIndex;\n continue;\n }\n if (arg === '--section' || arg.startsWith('--section=')) {\n const parsed = parseArgValue(arg, argv, i);\n opts.section = parsed.value;\n i = parsed.nextIndex;\n continue;\n }\n if (arg === '--limit' || arg.startsWith('--limit=')) {\n const parsed = parseArgValue(arg, argv, i);\n const parsedLimit = Number.parseInt(parsed.value, 10);\n opts.limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;\n i = parsed.nextIndex;\n continue;\n }\n if (arg === '--context' || arg === '-C' || arg.startsWith('--context=')) {\n const parsed = parseArgValue(arg, argv, i);\n const parsedContext = Number.parseInt(parsed.value, 10);\n opts.context = Number.isFinite(parsedContext) ? parsedContext : 0;\n i = parsed.nextIndex;\n continue;\n }\n\n throw new Error(`Unknown argument: ${arg}`);\n }\n\n return { opts, showHelp };\n}\n\nexport function validateAstTreeSearchOptions(opts: AstTreeSearchOptions): void {\n if (!opts.pattern && !opts.kind) {\n throw new Error('Must provide --pattern or --kind');\n }\n if (!Number.isFinite(opts.context) || opts.context \u003c 0) {\n throw new Error('--context must be a non-negative integer');\n }\n if (!Number.isFinite(opts.limit) || opts.limit \u003c 0) {\n throw new Error('--limit must be a non-negative integer');\n }\n}\n\nfunction toSnakeCase(value: string): string {\n return value\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .replace(/[-\\s]+/g, '_')\n .toLowerCase();\n}\n\nfunction toPascalCase(value: string): string {\n return value\n .split('_')\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join('');\n}\n\nfunction escapeRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\{head-tags}');\n}\n\nfunction buildRegex(pattern: string | null, flags: string, label: string): RegExp | null {\n if (!pattern) return null;\n try {\n return new RegExp(pattern, flags);\n } catch (error) {\n throw new Error(`Invalid ${label} regex: ${(error as Error).message}`);\n }\n}\n\nfunction parseSectionFile(section: string): string | null {\n const marker = ' — ';\n const index = section.indexOf(marker);\n if (index === -1) return null;\n return section.slice(index + marker.length).trim() || null;\n}\n\nexport function resolveAstTreeInput(inputPath: string): ResolvedAstTreeInput {\n const requestedInput = path.resolve(inputPath);\n if (!fs.existsSync(requestedInput)) {\n throw new Error(`Input does not exist: ${requestedInput}`);\n }\n\n const stat = fs.statSync(requestedInput);\n if (stat.isFile()) {\n return {\n requestedInput,\n inputFile: requestedInput,\n selectionMode: 'direct-file',\n };\n }\n\n const directAstFile = path.join(requestedInput, 'ast-trees.txt');\n if (fs.existsSync(directAstFile) && fs.statSync(directAstFile).isFile()) {\n return {\n requestedInput,\n inputFile: directAstFile,\n selectionMode: 'scan-dir',\n };\n }\n\n const candidates = fs.readdirSync(requestedInput, { withFileTypes: true })\n .filter((entry) => entry.isDirectory())\n .map((entry) => path.join(requestedInput, entry.name, 'ast-trees.txt'))\n .filter((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile())\n .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);\n\n if (candidates.length === 0) {\n throw new Error(`No ast-trees.txt found under: ${requestedInput}`);\n }\n\n return {\n requestedInput,\n inputFile: candidates[0],\n selectionMode: 'latest-scan',\n };\n}\n\nexport function searchAstTree(\n resolved: ResolvedAstTreeInput,\n opts: AstTreeSearchOptions,\n): AstTreeSearchResult {\n const flags = opts.ignoreCase ? 'i' : '';\n const patternRegex = buildRegex(opts.pattern, flags, 'pattern');\n const fileRegex = buildRegex(opts.file, flags, 'file');\n const sectionRegex = buildRegex(opts.section, flags, 'section');\n\n let kindRegex: RegExp | null = null;\n if (opts.kind) {\n const snake = toSnakeCase(opts.kind);\n const pascal = toPascalCase(snake);\n kindRegex = new RegExp(`\\\\b(?:${escapeRegExp(snake)}|${escapeRegExp(pascal)})\\\\b`, flags);\n }\n\n const lines = fs.readFileSync(resolved.inputFile, 'utf8').split(/\\r?\\n/);\n const allMatches: AstTreeMatch[] = [];\n let currentSection = '';\n let currentFile: string | null = null;\n\n for (let index = 0; index \u003c lines.length; index += 1) {\n const line = lines[index];\n\n if (line.startsWith('## ')) {\n currentSection = line.slice(3).trim();\n currentFile = parseSectionFile(currentSection);\n }\n\n if (patternRegex && !patternRegex.test(line)) continue;\n if (kindRegex && !kindRegex.test(line)) continue;\n if (sectionRegex && !sectionRegex.test(currentSection)) continue;\n if (fileRegex && !fileRegex.test(currentFile ?? '')) continue;\n\n const start = Math.max(0, index - opts.context);\n const end = Math.min(lines.length, index + opts.context + 1);\n\n allMatches.push({\n section: currentSection,\n file: currentFile,\n lineNumber: index + 1,\n line,\n context: lines.slice(start, end).map((contextLine, offset) => ({\n lineNumber: start + offset + 1,\n line: contextLine,\n })),\n });\n }\n\n const returnedMatches = opts.limit === 0 ? allMatches : allMatches.slice(0, opts.limit);\n const uniqueFiles = new Set(allMatches.map((match) => match.file).filter(Boolean)).size;\n const queryParts = [\n opts.kind ? `kind=${opts.kind}` : null,\n opts.pattern ? `pattern=${opts.pattern}` : null,\n opts.file ? `file=${opts.file}` : null,\n opts.section ? `section=${opts.section}` : null,\n ].filter(Boolean);\n\n return {\n requestedInput: resolved.requestedInput,\n inputFile: resolved.inputFile,\n selectionMode: resolved.selectionMode,\n query: queryParts.join(', '),\n limit: opts.limit,\n totalMatches: allMatches.length,\n returnedMatches: returnedMatches.length,\n truncated: returnedMatches.length \u003c allMatches.length,\n uniqueFiles,\n matches: returnedMatches,\n };\n}\n\nexport function formatAstTreeSearchOutput(result: AstTreeSearchResult, opts: AstTreeSearchOptions): string {\n const lines: string[] = [];\n\n lines.push(`\\nAST tree search: ${result.query}`);\n lines.push(`Requested input: ${result.requestedInput}`);\n lines.push(`Selected AST file: ${result.inputFile} (${result.selectionMode})`);\n lines.push(`Matches: ${result.totalMatches} total, showing ${result.returnedMatches}${result.truncated ? ` (limit ${result.limit})` : ''}`);\n lines.push(`Matched files: ${result.uniqueFiles}\\n`);\n\n let currentSection = '';\n for (const match of result.matches) {\n if (match.section !== currentSection) {\n currentSection = match.section;\n lines.push(`-- ${currentSection || '(no section)'} --`);\n }\n\n if (opts.context > 0) {\n for (const contextLine of match.context) {\n const marker = contextLine.lineNumber === match.lineNumber ? '>' : ' ';\n lines.push(` ${marker} ${String(contextLine.lineNumber).padStart(4)} | ${contextLine.line}`);\n }\n lines.push('');\n continue;\n }\n\n const fileLabel = match.file ? ` (${match.file})` : '';\n lines.push(` L${match.lineNumber}${fileLabel} ${match.line}`);\n }\n\n if (result.totalMatches === 0) {\n lines.push('No matches found.');\n }\n\n lines.push('');\n return lines.join('\\n');\n}\n\nasync function main(): Promise\u003cvoid> {\n try {\n const { opts, showHelp } = parseAstTreeSearchArgs(process.argv.slice(2));\n if (showHelp) {\n printAstTreeSearchHelp();\n return;\n }\n\n validateAstTreeSearchOptions(opts);\n const resolved = resolveAstTreeInput(opts.input);\n const result = searchAstTree(resolved, opts);\n\n if (opts.json) {\n console.log(JSON.stringify(result, null, 2));\n return;\n }\n\n console.log(formatAstTreeSearchOutput(result, opts));\n } catch (error) {\n console.error((error as Error).message);\n process.exit(1);\n }\n}\n\nif (isDirectRun(import.meta.url)) {\n void main();\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":12025,"content_sha256":"f70913e40403b7bd051a01e4a7b30f09eeac659ac7a0167513fb4eaa349d1251"},{"filename":"src/ast/tree-sitter.test.ts","content":"import { beforeAll, describe, expect, it, vi } from 'vitest';\n\nimport {\n analyzeTreeSitterFile,\n getTreeSitterRuntime,\n resolveTreeSitter,\n} from './tree-sitter.js';\nimport { DEFAULT_OPTS } from '../types/index.js';\n\nimport type { FlowMaps } from '../types/index.js';\n\nconst testOpts = { ...DEFAULT_OPTS, root: '/repo', emitTree: false };\n\nlet TREE_SITTER_AVAILABLE = false;\n\nbeforeAll(async () => {\n const runtime = await resolveTreeSitter();\n TREE_SITTER_AVAILABLE = runtime.available;\n});\n\nfunction emptyMaps(): FlowMaps {\n return { flowMap: new Map(), controlMap: new Map() };\n}\n\ndescribe('resolveTreeSitter', () => {\n it('returns a TreeSitterRuntime object', async () => {\n const runtime = await resolveTreeSitter();\n expect(runtime).toBeDefined();\n expect(typeof runtime.available).toBe('boolean');\n expect('parserTs' in runtime).toBe(true);\n expect('parserTsx' in runtime).toBe(true);\n });\n\n it('when available, sets parserTs and parserTsx', async () => {\n const runtime = await resolveTreeSitter();\n if (runtime.available) {\n expect(runtime.parserTs).not.toBeNull();\n expect(runtime.parserTsx).not.toBeNull();\n }\n });\n\n it('when not available, sets error and null parsers', async () => {\n const runtime = await resolveTreeSitter();\n if (!runtime.available) {\n expect(runtime.error).toBeDefined();\n expect(typeof runtime.error).toBe('string');\n expect(runtime.parserTs).toBeNull();\n expect(runtime.parserTsx).toBeNull();\n }\n });\n\n it('second call returns cached result (same object)', async () => {\n const first = await resolveTreeSitter();\n const second = await resolveTreeSitter();\n expect(first).toBe(second);\n });\n});\n\ndescribe('getTreeSitterRuntime', () => {\n it('returns null before resolveTreeSitter is called', async () => {\n vi.resetModules();\n const { getTreeSitterRuntime } = await import('./tree-sitter.js');\n expect(getTreeSitterRuntime()).toBeNull();\n });\n\n it('after resolveTreeSitter, returns the same runtime', async () => {\n const resolved = await resolveTreeSitter();\n const fromGet = getTreeSitterRuntime();\n expect(fromGet).not.toBeNull();\n expect(fromGet).toBe(resolved);\n });\n});\n\ndescribe('analyzeTreeSitterFile when runtime not available', () => {\n it('returns null when tree-sitter runtime has available: false', async () => {\n vi.doMock('tree-sitter', () => {\n throw new Error('tree-sitter not installed');\n });\n vi.doMock('tree-sitter-typescript', () => {\n throw new Error('tree-sitter-typescript not installed');\n });\n vi.resetModules();\n const { resolveTreeSitter: resolve, analyzeTreeSitterFile: analyze } =\n await import('./tree-sitter.js');\n const runtime = await resolve();\n expect(runtime.available).toBe(false);\n expect(runtime.error).toBeDefined();\n const result = analyze(\n '/repo/src/test.ts',\n 'function foo() {}',\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).toBeNull();\n });\n});\n\ndescribe.skipIf(!TREE_SITTER_AVAILABLE)(\n 'analyzeTreeSitterFile when runtime available',\n () => {\n it('parses a simple function file and extracts function with correct name, lineStart, complexity', () => {\n const code = `function greet() {\n return \"hello\";\n}`;\n const result = analyzeTreeSitterFile(\n '/repo/src/greet.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n expect(result!.functions.length).toBe(1);\n expect(result!.functions[0].name).toBe('greet');\n expect(result!.functions[0].lineStart).toBe(1);\n expect(result!.functions[0].complexity).toBeGreaterThanOrEqual(1);\n expect(result!.parseEngine).toBe('tree-sitter');\n expect(result!.nodeCount).toBeGreaterThan(0);\n });\n\n it('parses file with multiple functions', () => {\n const code = `\nfunction foo() { return 1; }\nfunction bar() { return 2; }\nconst baz = () => 3;\n`;\n const result = analyzeTreeSitterFile(\n '/repo/src/multi.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n expect(result!.functions.length).toBeGreaterThanOrEqual(2);\n });\n\n it('parses file with if/for/while control flows', () => {\n const code = `\nfunction f(x: boolean) {\n if (x) { return 1; }\n for (let i = 0; i \u003c 10; i++) { }\n while (false) { }\n return 0;\n}\n`;\n const result = analyzeTreeSitterFile(\n '/repo/src/flows.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n expect(result!.flows.length).toBeGreaterThan(0);\n const flowKinds = result!.flows.map(f => f.kind);\n expect(flowKinds).toContain('if_statement');\n expect(flowKinds).toContain('for_statement');\n expect(flowKinds).toContain('while_statement');\n });\n\n it('parses file with nested loops and tracks maxLoopDepth', () => {\n const code = `\nfunction nested() {\n for (let i = 0; i \u003c 10; i++) {\n for (let j = 0; j \u003c 10; j++) {\n for (let k = 0; k \u003c 10; k++) {}\n }\n }\n}\n`;\n const result = analyzeTreeSitterFile(\n '/repo/src/nested.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const fn = result!.functions.find(f => f.name === 'nested');\n expect(fn).toBeDefined();\n expect(fn!.maxLoopDepth).toBe(3);\n expect(fn!.loops).toBe(3);\n });\n\n it('parses file with nested if and tracks maxBranchDepth', () => {\n const code = `\nfunction branched(a: boolean, b: boolean) {\n if (a) {\n if (b) {\n return 1;\n }\n }\n return 0;\n}\n`;\n const result = analyzeTreeSitterFile(\n '/repo/src/branch.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const fn = result!.functions.find(f => f.name === 'branched');\n expect(fn).toBeDefined();\n expect(fn!.maxBranchDepth).toBe(2);\n });\n\n it('parses file with async/await and counts awaits', () => {\n const code = `\nasync function fetchData() {\n const a = await fetch(\"a\");\n const b = await fetch(\"b\");\n return [a, b];\n}\n`;\n const result = analyzeTreeSitterFile(\n '/repo/src/async.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const fn = result!.functions.find(f => f.name === 'fetchData');\n expect(fn).toBeDefined();\n expect(fn!.awaits).toBe(2);\n });\n\n it('arrow function in variable declaration gets correct name', () => {\n const code = `const handler = (x: number) => x + 1;`;\n const result = analyzeTreeSitterFile(\n '/repo/src/arrow.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n expect(result!.functions.length).toBe(1);\n expect(result!.functions[0].name).toBe('handler');\n });\n\n it('anonymous function gets \u003canonymous>', () => {\n const code = `(function() { return 42; })`;\n const result = analyzeTreeSitterFile(\n '/repo/src/anonymous.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n expect(result!.functions.length).toBe(1);\n expect(result!.functions[0].name).toBe('\u003canonymous>');\n });\n\n it('parses TSX file (parser selection)', () => {\n const code = `\nfunction Component() {\n return \u003cdiv>Hello\u003c/div>;\n}\n`;\n const result = analyzeTreeSitterFile(\n '/repo/src/Component.tsx',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n expect(result!.functions.length).toBe(1);\n expect(result!.functions[0].name).toBe('Component');\n });\n\n it('extracts switch_statement as flow', () => {\n const code = `\nfunction f(x: number) {\n switch (x) {\n case 1: return 1;\n case 2: return 2;\n default: return 0;\n }\n}\n`;\n const result = analyzeTreeSitterFile(\n '/repo/src/switch.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const switchFlow = result!.flows.find(f => f.kind === 'switch_statement');\n expect(switchFlow).toBeDefined();\n });\n\n it('counts calls in function body', () => {\n const code = `\nfunction f() {\n a();\n b();\n c();\n}\n`;\n const result = analyzeTreeSitterFile(\n '/repo/src/calls.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const fn = result!.functions[0];\n expect(fn.calls).toBe(3);\n });\n\n it('increments complexity for ternary', () => {\n const code = `function f(x: boolean) { return x ? 1 : 0; }`;\n const result = analyzeTreeSitterFile(\n '/repo/src/ternary.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const fn = result!.functions[0];\n expect(fn.complexity).toBeGreaterThan(1);\n });\n\n it('increments complexity for logical operators', () => {\n const code = `function f(a: boolean, b: boolean) { return a && b || !a; }`;\n const result = analyzeTreeSitterFile(\n '/repo/src/logical.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const fn = result!.functions[0];\n expect(fn.complexity).toBeGreaterThan(1);\n });\n\n it('when emitTree is true, builds tree snapshot', () => {\n const code = `function foo() { return 1; }`;\n const opts = { ...testOpts, emitTree: true };\n const result = analyzeTreeSitterFile(\n '/repo/src/tree.ts',\n code,\n opts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n expect(result!.tree).toBeDefined();\n expect(result!.tree!.kind).toBe('program');\n expect(result!.tree!.children.length).toBeGreaterThan(0);\n });\n\n it('populates maps when minFunctionStatements and minFlowStatements are met', () => {\n const code = `function bigFn() {\n const a = 1; const b = 2; const c = 3;\n const d = 4; const e = 5; const f = 6;\n return a + b + c + d + e + f;\n}`;\n const maps = emptyMaps();\n const opts = {\n ...testOpts,\n thresholds: { ...testOpts.thresholds, minFunctionStatements: 6, minFlowStatements: 1 },\n };\n analyzeTreeSitterFile('/repo/src/big.ts', code, opts, 'test-pkg', maps);\n expect(maps.flowMap.size).toBeGreaterThan(0);\n });\n\n it('extracts function with correct params count', () => {\n const code = `function f(a: number, b: string, c: boolean) { return 1; }`;\n const result = analyzeTreeSitterFile(\n '/repo/src/params.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const fn = result!.functions[0];\n expect(fn.params).toBeGreaterThanOrEqual(3);\n });\n\n it('sets source to tree-sitter on function entries', () => {\n const code = `function f() {}`;\n const result = analyzeTreeSitterFile(\n '/repo/src/source.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n expect(result!.functions[0].source).toBe('tree-sitter');\n });\n\n it('computes cognitiveComplexity > 0 for nested control flow', () => {\n const code = `function complexFn(x: number, y: boolean) {\n if (x > 0) { // +1\n if (y) { // +2 (1 + nesting=1)\n for (let i = 0; i \u003c x; i++) { // +3 (1 + nesting=2)\n if (i % 2 === 0) { // +4 (1 + nesting=3)\n console.log(i);\n }\n }\n }\n }\n return x;\n}`;\n const result = analyzeTreeSitterFile(\n '/repo/src/cognitive.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const fn = result!.functions[0];\n expect(fn.cognitiveComplexity).toBeGreaterThanOrEqual(10);\n });\n\n it('computes cognitiveComplexity = 0 for simple linear function', () => {\n const code = `function simple() {\n const a = 1;\n const b = 2;\n return a + b;\n}`;\n const result = analyzeTreeSitterFile(\n '/repo/src/simple.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n expect(result!.functions[0].cognitiveComplexity).toBe(0);\n });\n\n it('handles else-if without double-counting nesting', () => {\n const code = `function classify(x: number) {\n if (x > 100) {\n return 'high';\n } else if (x > 50) {\n return 'medium';\n } else if (x > 0) {\n return 'low';\n } else {\n return 'none';\n }\n}`;\n const result = analyzeTreeSitterFile(\n '/repo/src/elseif.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const fn = result!.functions[0];\n expect(fn.cognitiveComplexity).toBeGreaterThan(0);\n expect(fn.cognitiveComplexity).toBeLessThan(10);\n });\n\n it('increments for logical operators (&&, ||, ??)', () => {\n const code = `function guard(a: any, b: any, c: any) {\n if (a && b || c) {\n return true;\n }\n return false;\n}`;\n const result = analyzeTreeSitterFile(\n '/repo/src/logical.ts',\n code,\n testOpts,\n 'test-pkg',\n null\n );\n expect(result).not.toBeNull();\n const fn = result!.functions[0];\n expect(fn.cognitiveComplexity).toBeGreaterThanOrEqual(3);\n });\n\n it('cognitiveComplexity reflects nesting depth penalty', () => {\n const shallow = `function shallow(x: boolean) { if (x) { return 1; } return 0; }`;\n const deep = `function deep(x: boolean, y: boolean) {\n if (x) {\n if (y) {\n return 1;\n }\n }\n return 0;\n}`;\n const shallowResult = analyzeTreeSitterFile(\n '/repo/src/shallow.ts',\n shallow,\n testOpts,\n 'test-pkg',\n null\n );\n const deepResult = analyzeTreeSitterFile(\n '/repo/src/deep.ts',\n deep,\n testOpts,\n 'test-pkg',\n null\n );\n expect(shallowResult).not.toBeNull();\n expect(deepResult).not.toBeNull();\n expect(deepResult!.functions[0].cognitiveComplexity).toBeGreaterThan(\n shallowResult!.functions[0].cognitiveComplexity\n );\n });\n }\n);\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":14509,"content_sha256":"8d326d81a8c7d22d3efba5db8a508d04815dd262d142608638c23583b78d3a6e"},{"filename":"src/ast/tree-sitter.ts","content":"import path from 'node:path';\n\nimport {\n buildTreeSitterTree,\n hashString,\n increment,\n makeTreeSitterFingerprint,\n} from '../common/utils.js';\nimport {\n isPythonFile,\n PY_TREE_SITTER_CONTROL_TYPES,\n PY_TREE_SITTER_FUNCTION_TYPES,\n TS_TREE_SITTER_CONTROL_TYPES,\n TS_TREE_SITTER_FUNCTION_TYPES,\n} from '../types/index.js';\n\nimport type {\n AnalysisOptions,\n FlowEntry,\n FlowMaps,\n FunctionEntry,\n Location,\n NodeBudget,\n SyntaxNode,\n TreeSitterFileEntry,\n TreeSitterMetrics,\n TreeSitterRuntime,\n} from '../types/index.js';\nimport type Parser from 'tree-sitter';\n\nlet treeSitterRuntime: TreeSitterRuntime | null = null;\n\nexport function getTreeSitterRuntime(): TreeSitterRuntime | null {\n return treeSitterRuntime;\n}\n\nfunction hasLogicalOperator(node: SyntaxNode): boolean {\n for (const child of node.children) {\n if (!child.isNamed && (child.type === '&&' || child.type === '||'))\n return true;\n }\n return false;\n}\n\nfunction collectTreeSitterMetrics(\n node: SyntaxNode,\n _sourceText: string\n): TreeSitterMetrics {\n const metrics: TreeSitterMetrics = {\n complexity: 1,\n maxBranchDepth: 0,\n maxLoopDepth: 0,\n returns: 0,\n awaits: 0,\n calls: 0,\n loops: 0,\n statements: 0,\n };\n\n const visit = (\n n: SyntaxNode,\n branchDepth: number,\n loopDepth: number\n ): void => {\n metrics.statements += 1;\n\n if (\n [\n 'if_statement',\n 'while_statement',\n 'do_statement',\n 'for_statement',\n 'for_in_statement',\n 'for_of_statement',\n 'for_await_statement',\n 'switch_statement',\n 'catch_clause',\n ].includes(n.type)\n ) {\n metrics.complexity += 1;\n branchDepth += 1;\n metrics.maxBranchDepth = Math.max(metrics.maxBranchDepth, branchDepth);\n }\n\n if (n.type === 'conditional_expression') {\n metrics.complexity += 1;\n }\n\n if (n.type === 'binary_expression' && hasLogicalOperator(n)) {\n metrics.complexity += 1;\n }\n\n if (n.type === 'return_statement' || n.type === 'throw_statement') {\n metrics.returns += 1;\n }\n\n if (n.type === 'await_expression') {\n metrics.awaits += 1;\n }\n\n if (n.type === 'call_expression') {\n metrics.calls += 1;\n }\n\n if (\n [\n 'for_statement',\n 'for_in_statement',\n 'for_of_statement',\n 'for_await_statement',\n 'while_statement',\n 'do_statement',\n ].includes(n.type)\n ) {\n const nextLoopDepth = loopDepth + 1;\n metrics.loops += 1;\n metrics.maxLoopDepth = Math.max(metrics.maxLoopDepth, nextLoopDepth);\n for (const child of n.children) {\n visit(child, branchDepth, nextLoopDepth);\n }\n return;\n }\n\n for (const child of n.children) {\n visit(child, branchDepth, loopDepth);\n }\n };\n\n visit(node, 0, 0);\n return metrics;\n}\n\nconst COGNITIVE_NESTING_TYPES = new Set([\n 'if_statement',\n 'for_statement',\n 'for_in_statement',\n 'for_of_statement',\n 'for_await_statement',\n 'while_statement',\n 'do_statement',\n 'catch_clause',\n 'conditional_expression',\n 'switch_statement',\n]);\n\nconst COGNITIVE_LOGICAL_TYPES = new Set(['&&', '||', '??']);\n\nfunction computeTreeSitterCognitiveComplexity(node: SyntaxNode): number {\n let total = 0;\n\n const visit = (current: SyntaxNode, nesting: number): void => {\n let increment = 0;\n let nestable = false;\n\n if (COGNITIVE_NESTING_TYPES.has(current.type)) {\n increment = 1;\n nestable = true;\n }\n\n if (current.type === 'binary_expression') {\n for (const child of current.children) {\n if (!child.isNamed && COGNITIVE_LOGICAL_TYPES.has(child.type)) {\n increment = 1;\n break;\n }\n }\n }\n\n if (\n current.type === 'if_statement' &&\n current.parent?.type === 'else_clause'\n ) {\n increment = 1;\n nestable = false;\n }\n\n if (nestable) {\n total += increment + nesting;\n for (const child of current.children) {\n visit(child, nesting + 1);\n }\n return;\n }\n\n total += increment;\n for (const child of current.children) {\n visit(child, nesting);\n }\n };\n\n visit(node, 0);\n return total;\n}\n\nfunction inferTreeSitterFunctionName(node: SyntaxNode, _text: string): string {\n const identifier = node.namedChildren.find(child =>\n ['identifier', 'property_identifier', 'type_identifier'].includes(\n child.type\n )\n );\n if (identifier) return identifier.text;\n\n let parent = node.parent;\n while (parent) {\n if (parent.type === 'variable_declarator') {\n const id = parent.namedChildren.find(child =>\n [\n 'identifier',\n 'property_identifier',\n 'array_pattern',\n 'object_pattern',\n 'shorthand_property_identifier_pattern',\n ].includes(child.type)\n );\n if (id && id.type === 'identifier') {\n return id.text;\n }\n break;\n }\n\n if (parent.type === 'pair') {\n const key = parent.namedChildren.find(child =>\n [\n 'identifier',\n 'string',\n 'shorthand_property_identifier_pattern',\n 'property_identifier',\n ].includes(child.type)\n );\n if (key) return key.text;\n break;\n }\n\n if (\n [\n 'assignment_expression',\n 'method_definition',\n 'property_signature',\n 'public_field_definition',\n ].includes(parent.type)\n ) {\n const key = parent.namedChildren.find(child =>\n [\n 'identifier',\n 'property_identifier',\n 'string',\n 'private_property_identifier',\n ].includes(child.type)\n );\n if (key) return key.text;\n }\n\n if (parent.type === 'statement_block' || parent.type === 'program') break;\n parent = parent.parent;\n }\n\n return '\u003canonymous>';\n}\n\nfunction countTreeSitterStatements(node: SyntaxNode): number {\n const body = node.namedChildren.find(\n child => child.type === 'statement_block'\n );\n if (!body) return 1;\n return body.namedChildren.length;\n}\n\nfunction countControlFlowBodyStatements(node: SyntaxNode): number {\n const body = node.namedChildren.find(\n child => child.type === 'statement_block' || child.type === 'switch_body'\n );\n if (body) return body.namedChildren.length;\n return node.namedChildren.length;\n}\n\nfunction makeLocationFromTree(\n node: SyntaxNode,\n repoRoot: string,\n filePath: string\n): Location {\n return {\n file: path.relative(repoRoot, filePath),\n lineStart: node.startPosition.row + 1,\n lineEnd: node.endPosition.row + 1,\n columnStart: node.startPosition.column + 1,\n columnEnd: node.endPosition.column + 1,\n };\n}\n\nexport function analyzeTreeSitterFile(\n filePath: string,\n sourceText: string,\n options: AnalysisOptions,\n packageName: string,\n maps: FlowMaps | null\n): TreeSitterFileEntry | null {\n if (!treeSitterRuntime?.available) return null;\n\n const ext = path.extname(filePath);\n let parser: Parser | null;\n if (isPythonFile(ext)) {\n parser = treeSitterRuntime.parserPy;\n } else if (ext === '.tsx' || ext === '.jsx') {\n parser = treeSitterRuntime.parserTsx;\n } else {\n parser = treeSitterRuntime.parserTs;\n }\n if (!parser) return null;\n\n const tree = parser.parse(sourceText);\n const fileRelative = path.relative(options.root, filePath);\n const fileEntry: TreeSitterFileEntry = {\n parseEngine: 'tree-sitter',\n nodeCount: 0,\n functions: [],\n flows: [],\n };\n\n if (options.emitTree) {\n const nodeBudget: NodeBudget = { size: 8000 };\n const snapshot = buildTreeSitterTree(\n tree.rootNode,\n sourceText,\n options.treeDepth,\n nodeBudget\n );\n if (snapshot) {\n fileEntry.tree = snapshot;\n }\n }\n\n const isPy = isPythonFile(ext);\n const functionTypes = isPy\n ? PY_TREE_SITTER_FUNCTION_TYPES\n : TS_TREE_SITTER_FUNCTION_TYPES;\n const controlTypes = isPy\n ? PY_TREE_SITTER_CONTROL_TYPES\n : TS_TREE_SITTER_CONTROL_TYPES;\n\n const visit = (node: SyntaxNode): void => {\n fileEntry.nodeCount += 1;\n\n if (functionTypes.has(node.type)) {\n const loc = makeLocationFromTree(node, options.root, filePath);\n const metrics = collectTreeSitterMetrics(node, sourceText);\n const statementCount = countTreeSitterStatements(node);\n const name = inferTreeSitterFunctionName(node, sourceText);\n const params = node.childForFieldName('parameters');\n const paramCount = params ? params.namedChildren.length : 0;\n\n const entry: FunctionEntry = {\n kind: node.type,\n name,\n nameHint: name,\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n columnStart: loc.columnStart,\n columnEnd: loc.columnEnd,\n statementCount,\n lengthLines: loc.lineEnd - loc.lineStart + 1,\n params: paramCount,\n complexity: metrics.complexity,\n maxBranchDepth: metrics.maxBranchDepth,\n maxLoopDepth: metrics.maxLoopDepth,\n returns: metrics.returns,\n awaits: metrics.awaits,\n calls: metrics.calls,\n loops: metrics.loops,\n cognitiveComplexity: computeTreeSitterCognitiveComplexity(node),\n source: 'tree-sitter',\n };\n\n fileEntry.functions.push(entry);\n\n if (maps && statementCount >= options.thresholds.minFunctionStatements) {\n const body = node.namedChildren.find(\n child => child.type === 'statement_block'\n );\n const bodyHash = body\n ? makeTreeSitterFingerprint(body)\n : hashString(fileRelative);\n increment(maps.flowMap, `${bodyHash}|${node.type}`, {\n ...entry,\n hash: bodyHash,\n metrics,\n });\n }\n }\n\n if (controlTypes.has(node.type)) {\n const loc = makeLocationFromTree(node, options.root, filePath);\n const statementCount = countControlFlowBodyStatements(node);\n const flowEntry: FlowEntry = {\n kind: node.type,\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n columnStart: loc.columnStart,\n columnEnd: loc.columnEnd,\n statementCount,\n };\n fileEntry.flows.push(flowEntry);\n\n if (maps && statementCount >= options.thresholds.minFlowStatements) {\n const flowHash = makeTreeSitterFingerprint(node);\n increment(maps.controlMap, `${flowHash}|${node.type}`, {\n ...flowEntry,\n hash: flowHash,\n });\n }\n }\n\n for (const child of node.children) {\n visit(child);\n }\n };\n\n visit(tree.rootNode);\n return fileEntry;\n}\n\nexport async function resolveTreeSitter(): Promise\u003cTreeSitterRuntime> {\n if (treeSitterRuntime !== null) return treeSitterRuntime;\n\n try {\n const parserMod = await import('tree-sitter');\n const typescriptMod: Record\u003cstring, unknown> =\n await import('tree-sitter-typescript');\n\n const ParserClass = parserMod.default || parserMod;\n\n const tsLang =\n (typescriptMod as Record\u003cstring, unknown>).typescript ||\n (\n (typescriptMod as Record\u003cstring, unknown>).default as Record\u003c\n string,\n unknown\n >\n )?.typescript;\n const tsxLang =\n (typescriptMod as Record\u003cstring, unknown>).tsx ||\n (\n (typescriptMod as Record\u003cstring, unknown>).default as Record\u003c\n string,\n unknown\n >\n )?.tsx;\n\n if (!ParserClass || !tsLang) {\n throw new Error(\n 'Tree-sitter or tree-sitter-typescript did not expose expected exports'\n );\n }\n\n const parserTs = new (ParserClass as new () => Parser)();\n parserTs.setLanguage(tsLang as Parameters\u003cParser['setLanguage']>[0]);\n\n const parserTsx = new (ParserClass as new () => Parser)();\n parserTsx.setLanguage(\n (tsxLang || tsLang) as Parameters\u003cParser['setLanguage']>[0]\n );\n\n let parserPy: Parser | null = null;\n try {\n const pythonMod: Record\u003cstring, unknown> =\n // @ts-expect-error tree-sitter-python has no type declarations\n await import('tree-sitter-python');\n const pyLang =\n pythonMod.default || pythonMod;\n if (pyLang) {\n parserPy = new (ParserClass as new () => Parser)();\n parserPy.setLanguage(pyLang as Parameters\u003cParser['setLanguage']>[0]);\n }\n } catch {\n // tree-sitter-python not installed — Python analysis via tree-sitter disabled\n }\n\n treeSitterRuntime = {\n available: true,\n parserTs,\n parserTsx,\n parserPy,\n };\n return treeSitterRuntime;\n } catch (error: unknown) {\n treeSitterRuntime = {\n available: false,\n parserTs: null,\n parserTsx: null,\n parserPy: null,\n error: String((error as Error)?.message || error),\n };\n return treeSitterRuntime;\n }\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":12732,"content_sha256":"a4f3984bf5ef7bbcdc995afd6c446bba4fc37dd8aa6e6406f8952f5467681ca9"},{"filename":"src/ast/ts-analyzer.test.ts","content":"import * as ts from 'typescript';\nimport { describe, expect, it } from 'vitest';\n\nimport {\n analyzeSourceFile,\n buildDependencyCriticality,\n collectMetrics,\n computeHalstead,\n computeMaintainabilityIndex,\n countLinesInNode,\n getFunctionName,\n isFunctionLike,\n} from './ts-analyzer.js';\nimport { DEFAULT_OPTS } from '../types/index.js';\n\nimport type {\n DependencyProfile,\n FileEntry,\n FlowMaps,\n PackageFileSummary,\n TreeEntry,\n} from '../types/index.js';\n\nfunction parse(code: string, fileName = '/repo/src/test.ts'): ts.SourceFile {\n return ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true);\n}\n\nfunction firstStatement(src: ts.SourceFile): ts.Node {\n return src.statements[0];\n}\n\nfunction emptyPackageSummary(): PackageFileSummary {\n return {\n fileCount: 0,\n nodeCount: 0,\n functionCount: 0,\n flowCount: 0,\n kindCounts: {},\n functions: [],\n flows: [],\n };\n}\n\nfunction emptyMaps(): FlowMaps {\n return { flowMap: new Map(), controlMap: new Map() };\n}\n\nconst emptyProfile: DependencyProfile = {\n internalDependencies: [],\n externalDependencies: [],\n unresolvedDependencies: [],\n declaredExports: [],\n importedSymbols: [],\n reExports: [],\n};\n\nconst testOpts = { ...DEFAULT_OPTS, root: '/repo', emitTree: false };\n\ndescribe('isFunctionLike', () => {\n it('matches function declarations', () => {\n const src = parse('function foo() {}');\n expect(isFunctionLike(firstStatement(src))).toBe(true);\n });\n\n it('matches arrow functions in variable declarations', () => {\n const src = parse('const f = () => {};');\n const decl = (firstStatement(src) as ts.VariableStatement).declarationList\n .declarations[0];\n expect(isFunctionLike(decl.initializer!)).toBe(true);\n });\n\n it('matches method declarations in class', () => {\n const src = parse('class A { method() {} }');\n const cls = firstStatement(src) as ts.ClassDeclaration;\n const method = cls.members[0];\n expect(isFunctionLike(method)).toBe(true);\n });\n\n it('rejects non-function nodes', () => {\n const src = parse('const x = 1;');\n expect(isFunctionLike(firstStatement(src))).toBe(false);\n });\n\n it('matches getters and setters', () => {\n const src = parse(\n 'class A { get val() { return 1; } set val(v: number) {} }'\n );\n const cls = firstStatement(src) as ts.ClassDeclaration;\n expect(isFunctionLike(cls.members[0])).toBe(true);\n expect(isFunctionLike(cls.members[1])).toBe(true);\n });\n});\n\ndescribe('getFunctionName', () => {\n it('returns name of function declaration', () => {\n const src = parse('function greet() {}');\n expect(getFunctionName(firstStatement(src), src)).toBe('greet');\n });\n\n it('returns variable name for arrow function', () => {\n const src = parse('const handler = () => {};');\n const decl = (firstStatement(src) as ts.VariableStatement).declarationList\n .declarations[0];\n expect(getFunctionName(decl.initializer!, src)).toBe('handler');\n });\n\n it('returns \u003canonymous> for unnamed function expression', () => {\n const src = parse('(function() {})');\n const expr = (firstStatement(src) as ts.ExpressionStatement).expression;\n const paren = (expr as ts.ParenthesizedExpression).expression;\n expect(getFunctionName(paren, src)).toBe('\u003canonymous>');\n });\n});\n\ndescribe('collectMetrics', () => {\n it('returns base complexity of 1 for empty function', () => {\n const src = parse('function f() {}');\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const metrics = collectMetrics(fn.body!);\n expect(metrics.complexity).toBe(1);\n expect(metrics.maxBranchDepth).toBe(0);\n expect(metrics.returns).toBe(0);\n });\n\n it('increments complexity for if statement', () => {\n const src = parse('function f(x: boolean) { if (x) { return; } }');\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const metrics = collectMetrics(fn.body!);\n expect(metrics.complexity).toBeGreaterThan(1);\n });\n\n it('tracks max branch depth', () => {\n const src = parse(`function f(a: boolean, b: boolean) {\n if (a) { if (b) { return; } }\n }`);\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const metrics = collectMetrics(fn.body!);\n expect(metrics.maxBranchDepth).toBe(2);\n });\n\n it('tracks loop depth', () => {\n const src = parse(`function f() {\n for (let i = 0; i \u003c 10; i++) {\n for (let j = 0; j \u003c 10; j++) {}\n }\n }`);\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const metrics = collectMetrics(fn.body!);\n expect(metrics.maxLoopDepth).toBe(2);\n expect(metrics.loops).toBe(2);\n });\n\n it('counts await expressions', () => {\n const src = parse(\n 'async function f() { await fetch(\"x\"); await fetch(\"y\"); }'\n );\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const metrics = collectMetrics(fn.body!);\n expect(metrics.awaits).toBe(2);\n });\n\n it('counts call expressions', () => {\n const src = parse('function f() { a(); b(); c(); }');\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const metrics = collectMetrics(fn.body!);\n expect(metrics.calls).toBe(3);\n });\n\n it('counts return and throw statements', () => {\n const src = parse(\n 'function f(x: boolean) { if (x) return 1; throw new Error(); }'\n );\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const metrics = collectMetrics(fn.body!);\n expect(metrics.returns).toBe(2);\n });\n\n it('counts logical operators as complexity', () => {\n const src = parse(\n 'function f(a: boolean, b: boolean, c: boolean) { return a && b || c; }'\n );\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const metrics = collectMetrics(fn.body!);\n expect(metrics.complexity).toBeGreaterThanOrEqual(3);\n });\n\n it('handles switch + catch', () => {\n const src = parse(`function f(x: number) {\n switch(x) { case 1: break; case 2: break; }\n try {} catch(e) {}\n }`);\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const metrics = collectMetrics(fn.body!);\n expect(metrics.complexity).toBeGreaterThan(2);\n });\n});\n\ndescribe('buildDependencyCriticality', () => {\n it('returns score of 1 for null input', () => {\n const result = buildDependencyCriticality(null, testOpts);\n expect(result.score).toBe(1);\n expect(result.functionCount).toBe(0);\n });\n\n it('computes score based on complexity and function count', () => {\n const entry: FileEntry = {\n package: 'test',\n file: 'src/a.ts',\n parseEngine: 'typescript',\n nodeCount: 100,\n kindCounts: {},\n functions: [\n {\n kind: 'FunctionDeclaration',\n name: 'f1',\n nameHint: 'f1',\n file: 'src/a.ts',\n lineStart: 1,\n lineEnd: 10,\n columnStart: 1,\n columnEnd: 1,\n statementCount: 5,\n complexity: 10,\n maxBranchDepth: 2,\n maxLoopDepth: 1,\n returns: 1,\n awaits: 0,\n calls: 3,\n loops: 1,\n lengthLines: 10,\n cognitiveComplexity: 5,\n },\n ],\n flows: [\n {\n kind: 'IfStatement',\n file: 'src/a.ts',\n lineStart: 2,\n lineEnd: 4,\n columnStart: 1,\n columnEnd: 1,\n statementCount: 2,\n },\n ],\n dependencyProfile: emptyProfile,\n };\n const result = buildDependencyCriticality(entry, testOpts);\n expect(result.score).toBeGreaterThan(1);\n expect(result.functionCount).toBe(1);\n expect(result.flows).toBe(1);\n });\n\n it('counts high complexity functions', () => {\n const entry: FileEntry = {\n package: 'test',\n file: 'src/a.ts',\n parseEngine: 'typescript',\n nodeCount: 100,\n kindCounts: {},\n functions: [\n {\n kind: 'FunctionDeclaration',\n name: 'complex',\n nameHint: 'complex',\n file: 'src/a.ts',\n lineStart: 1,\n lineEnd: 50,\n columnStart: 1,\n columnEnd: 1,\n statementCount: 30,\n complexity: 35,\n maxBranchDepth: 5,\n maxLoopDepth: 3,\n returns: 4,\n awaits: 0,\n calls: 10,\n loops: 3,\n lengthLines: 50,\n cognitiveComplexity: 20,\n },\n ],\n flows: [],\n dependencyProfile: emptyProfile,\n };\n const result = buildDependencyCriticality(entry, testOpts);\n expect(result.highComplexityFunctions).toBe(1);\n });\n});\n\ndescribe('countLinesInNode', () => {\n it('counts lines of single-line node', () => {\n const src = parse('const x = 1;');\n expect(countLinesInNode(src, firstStatement(src))).toBe(1);\n });\n\n it('counts lines of multi-line function', () => {\n const src = parse('function f() {\\n const x = 1;\\n return x;\\n}');\n expect(countLinesInNode(src, firstStatement(src))).toBe(4);\n });\n});\n\ndescribe('analyzeSourceFile', () => {\n it('extracts functions from source file', () => {\n const src = parse(\n 'function greet() { return \"hi\"; }\\nconst add = (a: number, b: number) => a + b;'\n );\n const summary = emptyPackageSummary();\n const maps = emptyMaps();\n const trees: TreeEntry[] = [];\n const result = analyzeSourceFile(\n src,\n 'test-pkg',\n summary,\n testOpts,\n maps,\n trees,\n emptyProfile\n );\n\n expect(result.functions.length).toBe(2);\n expect(result.functions[0].name).toBe('greet');\n expect(result.package).toBe('test-pkg');\n });\n\n it('extracts control flows', () => {\n const src = parse('function f(x: boolean) { if (x) { console.log(x); } }');\n const summary = emptyPackageSummary();\n const maps = emptyMaps();\n const trees: TreeEntry[] = [];\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n maps,\n trees,\n emptyProfile\n );\n\n expect(result.flows.length).toBeGreaterThan(0);\n expect(result.flows[0].kind).toBe('IfStatement');\n });\n\n it('counts nodes', () => {\n const src = parse('const x = 1;\\nfunction f() { return 2; }');\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.nodeCount).toBeGreaterThan(0);\n });\n\n it('populates kindCounts', () => {\n const src = parse('const x = 1;\\nconst y = 2;');\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(Object.keys(result.kindCounts).length).toBeGreaterThan(0);\n });\n\n it('updates package summary stats', () => {\n const src = parse('function f() { return 1; }');\n const summary = emptyPackageSummary();\n analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(summary.fileCount).toBe(1);\n expect(summary.functionCount).toBe(1);\n });\n\n it('adds duplicate functions to flowMap', () => {\n const code = `function bigFn() {\n const a = 1; const b = 2; const c = 3;\n const d = 4; const e = 5; const f = 6;\n return a + b + c + d + e + f;\n }`;\n const src = parse(code);\n const maps = emptyMaps();\n const summary = emptyPackageSummary();\n analyzeSourceFile(\n src,\n 'pkg',\n summary,\n { ...testOpts, thresholds: { ...testOpts.thresholds, minFunctionStatements: 6 } },\n maps,\n [],\n emptyProfile\n );\n expect(maps.flowMap.size).toBeGreaterThan(0);\n });\n\n it('computes cognitive complexity for functions', () => {\n const code = `function complex(a: boolean, b: boolean) {\n if (a) { if (b) { return 1; } } return 0;\n }`;\n const src = parse(code);\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n const fn = result.functions.find(f => f.name === 'complex');\n expect(fn?.cognitiveComplexity).toBeGreaterThan(0);\n });\n\n it('records param count', () => {\n const src = parse('function f(a: number, b: string, c: boolean) {}');\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.functions[0].params).toBe(3);\n });\n\n it('sets declared flag for function declarations', () => {\n const src = parse('function named() {}');\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.functions[0].declared).toBe(true);\n });\n\n it('collects empty catch blocks', () => {\n const src = parse('function f() { try { throw 1; } catch(e) {} }');\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.emptyCatches?.length).toBe(1);\n });\n\n it('does not flag non-empty catch blocks', () => {\n const src = parse('function f() { try {} catch(e) { console.log(e); } }');\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.emptyCatches?.length).toBe(0);\n });\n\n it('collects switches without default', () => {\n const src = parse(\n 'function f(x: number) { switch(x) { case 1: break; case 2: break; } }'\n );\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.switchesWithoutDefault?.length).toBe(1);\n });\n\n it('does not flag switches with default', () => {\n const src = parse(\n 'function f(x: number) { switch(x) { case 1: break; default: break; } }'\n );\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.switchesWithoutDefault?.length).toBe(0);\n });\n\n it('counts any type annotations', () => {\n const src = parse(\n 'const a: any = 1; const b: any = 2; function f(x: any) {}'\n );\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.anyCount).toBeGreaterThanOrEqual(3);\n });\n\n it('collects magic numbers', () => {\n const src = parse(\n 'function f() { let x = 42; let y = 99; return x + y + 300; }'\n );\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.magicNumbers!.length).toBeGreaterThanOrEqual(3);\n });\n\n it('excludes 0 and 1 from magic numbers', () => {\n const src = parse('function f() { return 0 + 1; }');\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.magicNumbers?.length).toBe(0);\n });\n\n it('excludes const declarations from magic numbers', () => {\n const src = parse('const TIMEOUT = 5000;');\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.magicNumbers?.length).toBe(0);\n });\n\n it('computes halstead metrics for functions', () => {\n const src = parse('function f(a: number, b: number) { return a + b * 2; }');\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.functions[0].halstead).toBeDefined();\n expect(result.functions[0].halstead!.volume).toBeGreaterThan(0);\n });\n\n it('computes maintainability index for functions', () => {\n const src = parse('function f(a: number) { return a + 1; }');\n const summary = emptyPackageSummary();\n const result = analyzeSourceFile(\n src,\n 'pkg',\n summary,\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.functions[0].maintainabilityIndex).toBeDefined();\n expect(result.functions[0].maintainabilityIndex!).toBeGreaterThan(0);\n });\n});\n\ndescribe('computeHalstead', () => {\n it('returns zeroes for empty body', () => {\n const src = parse('function f() {}');\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const h = computeHalstead(fn.body!);\n expect(h.length).toBe(0);\n expect(h.volume).toBe(0);\n expect(h.effort).toBe(0);\n });\n\n it('counts operators and operands for simple expression', () => {\n const src = parse('function f(a: number, b: number) { return a + b; }');\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const h = computeHalstead(fn.body!);\n expect(h.distinctOperators).toBeGreaterThan(0);\n expect(h.distinctOperands).toBeGreaterThan(0);\n expect(h.volume).toBeGreaterThan(0);\n });\n\n it('computes estimated bugs based on volume', () => {\n const src = parse(`function f(x: number) {\n const a = x + 1; const b = x - 2; const c = a * b;\n return c / x + a - b;\n }`);\n const fn = firstStatement(src) as ts.FunctionDeclaration;\n const h = computeHalstead(fn.body!);\n expect(h.estimatedBugs).toBeGreaterThan(0);\n expect(h.estimatedBugs).toBe(h.volume / 3000);\n });\n\n it('difficulty increases with repeated operands', () => {\n const simpleCode = 'function f() { const a = 1; return a; }';\n const repetitiveCode =\n 'function f() { const a = 1; const b = a; const c = a; const d = a; return a + b + c + d; }';\n const simple = computeHalstead(\n (firstStatement(parse(simpleCode)) as ts.FunctionDeclaration).body!\n );\n const repetitive = computeHalstead(\n (firstStatement(parse(repetitiveCode)) as ts.FunctionDeclaration).body!\n );\n expect(repetitive.difficulty).toBeGreaterThan(simple.difficulty);\n });\n});\n\ndescribe('computeMaintainabilityIndex', () => {\n it('returns high MI for simple code', () => {\n const mi = computeMaintainabilityIndex(10, 1, 5);\n expect(mi).toBeGreaterThan(50);\n });\n\n it('returns low MI for complex code', () => {\n const mi = computeMaintainabilityIndex(50000, 50, 500);\n expect(mi).toBeLessThan(20);\n });\n\n it('clamps to 0 minimum', () => {\n const mi = computeMaintainabilityIndex(1e12, 1000, 100000);\n expect(mi).toBe(0);\n });\n\n it('returns max ~100 for trivial code', () => {\n const mi = computeMaintainabilityIndex(1, 1, 1);\n expect(mi).toBeGreaterThan(90);\n expect(mi).toBeLessThanOrEqual(100);\n });\n});\n\ndescribe('collectSecurityData (via analyzeSourceFile)', () => {\n it('detects eval() usage', () => {\n const src = parse('function f(s: string) { eval(s); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.evalUsages!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects new Function() usage', () => {\n const src = parse('const fn = new Function(\"return 1\");');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.evalUsages!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects setTimeout with string arg', () => {\n const src = parse('setTimeout(\"alert(1)\", 100);');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.evalUsages!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects innerHTML assignment', () => {\n const src = parse(\n 'function f(el: HTMLElement) { el.innerHTML = \"\u003cb>hi\u003c/b>\"; }'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.unsafeHtmlAssignments!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects outerHTML assignment', () => {\n const src = parse(\n 'function f(el: HTMLElement) { el.outerHTML = \"\u003cdiv/>\"; }'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.unsafeHtmlAssignments!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects dangerouslySetInnerHTML JSX attribute', () => {\n const src = parse(\n '\u003cdiv dangerouslySetInnerHTML={{ __html: s }} />;',\n '/repo/src/test.tsx'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.unsafeHtmlAssignments!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects document.write call', () => {\n const src = parse('document.write(\"\u003ch1>Hello\u003c/h1>\");');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.unsafeHtmlAssignments!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects hardcoded-secret suspicious strings from pattern matches', () => {\n const src = parse(\"const cfg = `password = 'mysecret123'`;\");\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.suspiciousStrings!.some(s => s.kind === 'hardcoded-secret')\n ).toBe(true);\n });\n\n it('skips placeholder patterns for secrets', () => {\n const src = parse('const key = \"YOUR_API_KEY_HERE\";');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n const secrets = result.suspiciousStrings!.filter(\n s => s.kind === 'hardcoded-secret'\n );\n expect(secrets.length).toBe(0);\n });\n\n it('detects high-entropy strings as potential secrets', () => {\n const src = parse('const token = \"aB3dE7gH9jK1mN5pQ8sT0uW2xY4zA6c\";');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.suspiciousStrings!.some(s => s.kind === 'hardcoded-secret')\n ).toBe(true);\n });\n\n it('tags error messages separately from secrets', () => {\n const src = parse(\n 'const msg = \"invalid token provided for authentication service endpoint\";'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n const secrets = result.suspiciousStrings!.filter(\n s => s.kind === 'hardcoded-secret' && s.context === 'error-message'\n );\n expect(secrets.length).toBeGreaterThanOrEqual(0);\n });\n\n it('detects SQL injection risk in template literals', () => {\n const src = parse('const q = `SELECT * FROM users WHERE id = ${userId}`;');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.suspiciousStrings!.some(s => s.kind === 'sql-injection')\n ).toBe(true);\n });\n\n it('does not flag template without SQL keywords', () => {\n const src = parse('const msg = `Hello ${name}`;');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.suspiciousStrings!.filter(s => s.kind === 'sql-injection').length\n ).toBe(0);\n });\n\n it('collects regex literals', () => {\n const src = parse('const re = /^[a-z]+$/;');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.regexLiterals!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('tags regex patterns that contain secret keywords as regex-definition', () => {\n const code = \"const secretRe = /password = 'foo'/i;\";\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n const regexDefs = result.suspiciousStrings!.filter(\n s => s.context === 'regex-definition'\n );\n expect(regexDefs.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects type assertion escapes (as any)', () => {\n const src = parse('const x = (value as any).prop;');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.typeAssertionEscapes!.asAny.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects double assertion (as unknown as T)', () => {\n const src = parse('const x = (value as unknown as string);');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.typeAssertionEscapes!.doubleAssertion.length\n ).toBeGreaterThanOrEqual(1);\n });\n\n it('detects non-null assertions', () => {\n const src = parse('function f(x?: string) { return x!.length; }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.typeAssertionEscapes!.nonNull.length).toBeGreaterThanOrEqual(\n 1\n );\n });\n});\n\ndescribe('async pattern detection (via analyzeSourceFile)', () => {\n it('detects unprotected async (await without try-catch)', () => {\n const src = parse('async function f() { await fetch(\"url\"); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.unprotectedAsync?.length).toBeGreaterThanOrEqual(1);\n });\n\n it('does not flag async with try-catch as unprotected', () => {\n const src = parse(\n 'async function f() { try { await fetch(\"url\"); } catch(e) { console.error(e); } }'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.unprotectedAsync?.length).toBe(0);\n });\n\n it('does not flag async with .catch chain as unprotected', () => {\n const src = parse(\n 'async function f() { await fetch(\"url\").catch(console.error); }'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.unprotectedAsync?.length).toBe(0);\n });\n\n it('skips functions with zero awaits in metrics', () => {\n const src = parse('async function f() { return 1; }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.asyncWithoutAwait?.length ?? 0).toBe(0);\n expect(result.unprotectedAsync?.length ?? 0).toBe(0);\n });\n});\n\ndescribe('collectPerformanceData (via analyzeSourceFile)', () => {\n it('detects await inside for loop', () => {\n const src = parse(`async function f(urls: string[]) {\n for (const url of urls) { await fetch(url); }\n }`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.awaitInLoopLocations!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects await inside while loop', () => {\n const src = parse(`async function f() {\n let i = 0;\n while (i \u003c 10) { await fetch(\"url\"); i++; }\n }`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.awaitInLoopLocations!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('does not flag await outside loop', () => {\n const src = parse('async function f() { await fetch(\"url\"); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.awaitInLoopLocations?.length).toBe(0);\n });\n\n it('detects sync I/O calls (readFileSync)', () => {\n const src = parse('function f() { fs.readFileSync(\"/path\", \"utf8\"); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.syncIoCalls!.some(c => c.name === 'readFileSync')).toBe(true);\n });\n\n it('detects sync I/O calls (writeFileSync)', () => {\n const src = parse('function f() { fs.writeFileSync(\"/path\", \"data\"); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.syncIoCalls!.some(c => c.name === 'writeFileSync')).toBe(\n true\n );\n });\n\n it('detects setInterval timer calls', () => {\n const src = parse('function f() { setInterval(() => {}, 1000); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.timerCalls!.some(t => t.kind === 'setInterval')).toBe(true);\n });\n\n it('detects setTimeout timer calls', () => {\n const src = parse('function f() { setTimeout(() => {}, 500); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.timerCalls!.some(t => t.kind === 'setTimeout')).toBe(true);\n });\n\n it('marks timer as having cleanup when clearInterval present', () => {\n const src = parse(`function f() {\n const id = setInterval(() => {}, 1000);\n clearInterval(id);\n }`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.timerCalls!.some(t => t.kind === 'setInterval' && t.hasCleanup)\n ).toBe(true);\n });\n\n it('marks timer without cleanup', () => {\n const src = parse('function f() { setInterval(() => {}, 1000); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.timerCalls!.some(t => t.kind === 'setInterval' && !t.hasCleanup)\n ).toBe(true);\n });\n\n it('detects addEventListener registrations', () => {\n const src = parse(\n 'function f(el: HTMLElement) { el.addEventListener(\"click\", handler); }'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.listenerRegistrations!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects removeEventListener calls', () => {\n const src = parse(\n 'function f(el: HTMLElement) { el.removeEventListener(\"click\", handler); }'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.listenerRemovals!.length).toBeGreaterThanOrEqual(1);\n });\n\n it('detects .on() and .off() for event listeners', () => {\n const src = parse(`function f(emitter: any) {\n emitter.on(\"data\", handler);\n emitter.off(\"data\", handler);\n }`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.listenerRegistrations!.length).toBeGreaterThanOrEqual(1);\n expect(result.listenerRemovals!.length).toBeGreaterThanOrEqual(1);\n });\n});\n\ndescribe('collectInputSourceProfile (via analyzeSourceFile)', () => {\n it('detects function with req parameter as high-confidence input source', () => {\n const src = parse('function handler(req: any) { eval(req.body); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.inputSources!.length).toBeGreaterThanOrEqual(1);\n expect(result.inputSources![0].paramConfidence).toBe('high');\n expect(result.inputSources![0].sourceParams).toContain('req');\n });\n\n it('detects sinks in function body', () => {\n const src = parse('function handler(req: any) { eval(req.query); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.inputSources![0].hasSinkInBody).toBe(true);\n expect(result.inputSources![0].sinkKinds).toContain('eval');\n });\n\n it('detects validation patterns (typeof check)', () => {\n const src = parse(\n 'function handler(req: any) { if (typeof req === \"object\") { console.log(req); } }'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.inputSources![0].hasValidation).toBe(true);\n });\n\n it('detects validation via schema validators (zod/joi)', () => {\n const src = parse(\n 'function handler(body: any) { const result = z.parse(body); }'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.inputSources![0].hasValidation).toBe(true);\n });\n\n it('does not collect input sources for test files', () => {\n const src = parse(\n 'function handler(req: any) { eval(req); }',\n '/repo/src/test.test.ts'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.inputSources).toBeUndefined();\n });\n\n it('detects medium-confidence params (input, event)', () => {\n const src = parse('function handler(input: any) { console.log(input); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.inputSources![0].paramConfidence).toBe('medium');\n });\n\n it('tracks calls with input args', () => {\n const src = parse('function handler(req: any) { processData(req.body); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.inputSources![0].callsWithInputArgs.length\n ).toBeGreaterThanOrEqual(1);\n });\n\n it('does not collect for functions without source params', () => {\n const src = parse('function helper(count: number) { return count + 1; }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.inputSources?.length ?? 0).toBe(0);\n });\n});\n\ndescribe('collectTopLevelEffects (via analyzeSourceFile)', () => {\n it('detects side-effect imports', () => {\n const src = parse(\"import './polyfill';\");\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.topLevelEffects!.some(e => e.kind === 'side-effect-import')\n ).toBe(true);\n });\n\n it('detects top-level setInterval', () => {\n const src = parse('setInterval(() => {}, 1000);');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.topLevelEffects!.some(e => e.kind === 'timer')).toBe(true);\n });\n\n it('detects top-level eval', () => {\n const src = parse('eval(\"console.log(1)\");');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.topLevelEffects!.some(e => e.kind === 'eval')).toBe(true);\n });\n\n it('detects top-level new Function()', () => {\n const src = parse('new Function(\"return 1\");');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.topLevelEffects!.some(e => e.kind === 'eval')).toBe(true);\n });\n\n it('detects top-level sync I/O in variable initializer', () => {\n const src = parse('const data = fs.readFileSync(\"/path\", \"utf8\");');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.topLevelEffects!.some(e => e.kind === 'sync-io')).toBe(true);\n });\n\n it('detects top-level execSync', () => {\n const src = parse('const out = cp.execSync(\"ls\");');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.topLevelEffects!.some(e => e.kind === 'exec-sync')).toBe(\n true\n );\n });\n\n it('detects top-level process.on listener', () => {\n const src = parse('process.on(\"uncaughtException\", handler);');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.topLevelEffects!.some(e => e.kind === 'process-handler')\n ).toBe(true);\n });\n\n it('does not collect effects for test files', () => {\n const src = parse('eval(\"1\");', '/repo/src/test.test.ts');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.topLevelEffects).toBeUndefined();\n });\n\n it('does not flag function declarations as effects', () => {\n const src = parse('function f() { fs.readFileSync(\"/path\"); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.topLevelEffects?.length ?? 0).toBe(0);\n });\n\n it('skips regular import declarations', () => {\n const src = parse(\"import path from 'node:path';\");\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.topLevelEffects?.some(e => e.kind === 'side-effect-import')\n ).toBeFalsy();\n });\n});\n\ndescribe('collectPrototypePollutionSites (via analyzeSourceFile)', () => {\n it('detects Object.assign with 2+ args', () => {\n const src = parse('function f(a: any, b: any) { Object.assign(a, b); }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.prototypePollutionSites!.some(s => s.kind === 'object-assign')\n ).toBe(true);\n });\n\n it('detects deep merge calls', () => {\n const src = parse(\n 'function f(target: any, src: any) { merge(target, src); }'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.prototypePollutionSites!.some(s => s.kind === 'deep-merge')\n ).toBe(true);\n });\n\n it('detects computed property writes', () => {\n const src = parse(\n 'function f(obj: any, key: string, val: any) { obj[key] = val; }'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.prototypePollutionSites!.some(\n s => s.kind === 'computed-property-write'\n )\n ).toBe(true);\n });\n\n it('marks computed writes from internal iteration as guarded', () => {\n const src =\n parse(`function f(obj: Record\u003cstring, any>, source: Record\u003cstring, any>) {\n for (const key of Object.keys(source)) { obj[key] = source[key]; }\n }`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n const cpw = result.prototypePollutionSites!.filter(\n s => s.kind === 'computed-property-write'\n );\n expect(cpw.length).toBeGreaterThanOrEqual(1);\n expect(cpw.some(s => s.guarded)).toBe(true);\n });\n\n it('marks computed writes with __proto__ guard as guarded', () => {\n const src = parse(`function f(obj: any, key: string, val: any) {\n if (key === '__proto__' || key === 'constructor') return;\n obj[key] = val;\n }`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n const cpw = result.prototypePollutionSites!.filter(\n s => s.kind === 'computed-property-write'\n );\n expect(cpw.some(s => s.guarded)).toBe(true);\n });\n\n it('does not flag string literal property access', () => {\n const src = parse('function f(obj: any) { obj[\"known\"] = 1; }');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n const cpw = (result.prototypePollutionSites ?? []).filter(\n s => s.kind === 'computed-property-write'\n );\n expect(cpw.length).toBe(0);\n });\n\n it('does not collect for test files', () => {\n const src = parse(\n 'function f(obj: any, key: string) { obj[key] = 1; }',\n '/repo/src/test.test.ts'\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.prototypePollutionSites).toBeUndefined();\n });\n});\n\ndescribe('collectTestProfile (via analyzeSourceFile)', () => {\n const testFileName = '/repo/src/feature.test.ts';\n\n function parseTest(code: string): ts.SourceFile {\n return ts.createSourceFile(\n testFileName,\n code,\n ts.ScriptTarget.ESNext,\n true\n );\n }\n\n it('collects test blocks with assertion counts', () => {\n const src = parseTest(`describe('suite', () => {\n it('test', () => { expect(1).toBe(1); });\n });`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.testProfile!.testBlocks.length).toBeGreaterThanOrEqual(1);\n expect(result.testProfile!.testBlocks[0].assertionCount).toBe(1);\n });\n\n it('collects mock calls', () => {\n const src = parseTest(`test('mocked', () => { jest.mock('lodash'); });`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.testProfile!.mockCalls.length).toBeGreaterThanOrEqual(1);\n });\n\n it('collects setup calls (beforeAll, afterEach)', () => {\n const src = parseTest(`describe('s', () => {\n beforeAll(() => {});\n afterEach(() => {});\n it('t', () => {});\n });`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.testProfile!.setupCalls.some(c => c.kind === 'beforeAll')\n ).toBe(true);\n expect(\n result.testProfile!.setupCalls.some(c => c.kind === 'afterEach')\n ).toBe(true);\n });\n\n it('collects mutable state declarations (let at describe scope)', () => {\n const src = parseTest(`describe('s', () => {\n let counter = 0;\n it('t', () => { counter++; });\n });`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.testProfile!.mutableStateDecls.length).toBeGreaterThanOrEqual(\n 1\n );\n });\n\n it('does not flag const at describe scope as mutable', () => {\n const src = parseTest(`describe('s', () => {\n const val = 42;\n it('t', () => { expect(val).toBe(42); });\n });`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.testProfile!.mutableStateDecls.length).toBe(0);\n });\n\n it('collects focused tests (it.only)', () => {\n const src = parseTest(`describe('s', () => {\n it.only('focused', () => { expect(1).toBe(1); });\n });`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.testProfile!.focusedCalls.length).toBeGreaterThanOrEqual(1);\n });\n\n it('collects timer controls (useFakeTimers, useRealTimers)', () => {\n const src = parseTest(`test('timers', () => {\n jest.useFakeTimers();\n jest.useRealTimers();\n });`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.testProfile!.timerControls.some(\n t => t.kind === 'jest.useFakeTimers'\n )\n ).toBe(true);\n expect(\n result.testProfile!.timerControls.some(\n t => t.kind === 'jest.useRealTimers'\n )\n ).toBe(true);\n });\n\n it('collects spy/stub calls', () => {\n const src = parseTest(`test('spy', () => {\n const spy = jest.spyOn(Date, 'now');\n });`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.testProfile!.spyOrStubCalls!.length).toBeGreaterThanOrEqual(\n 1\n );\n expect(result.testProfile!.spyOrStubCalls![0].kind).toBe('spy');\n });\n\n it('collects mockRestore calls', () => {\n const src = parseTest(`test('restore', () => {\n const spy = jest.spyOn(Date, 'now');\n spy.mockRestore();\n });`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.testProfile!.mockRestores!.some(r => r.kind === 'restore')\n ).toBe(true);\n });\n\n it('collects restoreAllMocks calls', () => {\n const src = parseTest(`afterEach(() => { jest.restoreAllMocks(); });`);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(\n result.testProfile!.mockRestores!.some(r => r.kind === 'restoreAll')\n ).toBe(true);\n });\n\n it('does not collect test profile for non-test files', () => {\n const src = parse('function f() {}');\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.testProfile).toBeUndefined();\n });\n\n it('collects test block names from string literals', () => {\n const src = parseTest(\n `it('should work correctly', () => { expect(true).toBe(true); });`\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.testProfile!.testBlocks[0].name).toBe(\n 'should work correctly'\n );\n });\n});\n\ndescribe('collectSmartQualityData', () => {\n it('collects magic strings from === comparisons', () => {\n const code = `\nfunction check(status: string) {\n if (status === 'active') { return 1; }\n if (status === 'active') { return 2; }\n if (status === 'inactive') { return 0; }\n}`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.magicStrings).toBeDefined();\n expect(result.magicStrings!.length).toBeGreaterThanOrEqual(2);\n expect(result.magicStrings!.some(ms => ms.value === 'active')).toBe(true);\n });\n\n it('collects magic strings from switch/case clauses', () => {\n const code = `\nfunction handle(action: string) {\n switch (action) {\n case 'create': return 1;\n case 'update': return 2;\n case 'create': return 3;\n }\n}`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.magicStrings).toBeDefined();\n expect(result.magicStrings!.some(ms => ms.value === 'create')).toBe(true);\n });\n\n it('does not collect magic strings from test files', () => {\n const code = `\nfunction check(s: string) {\n if (s === 'test') return 1;\n if (s === 'test') return 2;\n}`;\n const src = ts.createSourceFile(\n '/repo/src/__tests__/check.test.ts',\n code,\n ts.ScriptTarget.ESNext,\n true\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.magicStrings).toBeUndefined();\n });\n\n it('collects catch-rethrow patterns', () => {\n const code = `\nfunction risky() {\n try {\n doStuff();\n } catch (e) {\n throw e;\n }\n}`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.catchRethrows).toBeDefined();\n expect(result.catchRethrows!.length).toBe(1);\n });\n\n it('does not flag catch blocks that transform errors', () => {\n const code = `\nfunction safe() {\n try { doStuff(); } catch (e) { console.error(e); throw new Error('wrapped'); }\n}`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.catchRethrows).toBeUndefined();\n });\n\n it('does not flag catch blocks with multiple statements', () => {\n const code = `\nfunction logged() {\n try { doStuff(); } catch (e) { console.error(e); throw e; }\n}`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.catchRethrows).toBeUndefined();\n });\n\n it('collects boolean parameter clusters', () => {\n const code = `\nfunction configure(verbose: boolean, debug: boolean, strict: boolean) {\n return { verbose, debug, strict };\n}`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.booleanParamClusters).toBeDefined();\n expect(result.booleanParamClusters!.length).toBe(1);\n expect(result.booleanParamClusters![0].booleanCount).toBe(3);\n expect(result.booleanParamClusters![0].totalParams).toBe(3);\n });\n\n it('does not flag functions with fewer than 3 boolean params', () => {\n const code = `function toggle(a: boolean, b: boolean) { return a && b; }`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.booleanParamClusters).toBeUndefined();\n });\n\n it('collects unhandled Promise.all calls', () => {\n const code = `\nasync function loadAll() {\n const results = await Promise.all([fetch('/a'), fetch('/b')]);\n return results;\n}`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.promiseAllUnhandled).toBeDefined();\n expect(result.promiseAllUnhandled!.length).toBe(1);\n expect(result.promiseAllUnhandled![0].kind).toBe('Promise.all');\n });\n\n it('does not flag Promise.all inside try-catch', () => {\n const code = `\nasync function safeFetch() {\n try {\n const results = await Promise.all([fetch('/a')]);\n return results;\n } catch (e) {\n return [];\n }\n}`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.promiseAllUnhandled).toBeUndefined();\n });\n\n it('does not flag Promise.all with .catch() chain', () => {\n const code = `\nasync function safeFetch() {\n const results = await Promise.all([fetch('/a')]).catch(() => []);\n return results;\n}`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.promiseAllUnhandled).toBeUndefined();\n });\n\n it('detects Promise.race and Promise.any', () => {\n const code = `\nasync function raceAndAny() {\n const first = await Promise.race([fetch('/a'), fetch('/b')]);\n const any = await Promise.any([fetch('/c'), fetch('/d')]);\n}`;\n const src = parse(code);\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.promiseAllUnhandled).toBeDefined();\n expect(result.promiseAllUnhandled!.length).toBe(2);\n const kinds = result.promiseAllUnhandled!.map(p => p.kind);\n expect(kinds).toContain('Promise.race');\n expect(kinds).toContain('Promise.any');\n });\n\n it('does not collect smart quality data from test files', () => {\n const code = `\nfunction check(s: string) {\n if (s === 'test') return 1;\n if (s === 'test') return 2;\n try { x(); } catch (e) { throw e; }\n}`;\n const src = ts.createSourceFile(\n '/repo/src/__tests__/check.test.ts',\n code,\n ts.ScriptTarget.ESNext,\n true\n );\n const result = analyzeSourceFile(\n src,\n 'pkg',\n emptyPackageSummary(),\n testOpts,\n emptyMaps(),\n [],\n emptyProfile\n );\n expect(result.magicStrings).toBeUndefined();\n expect(result.catchRethrows).toBeUndefined();\n expect(result.booleanParamClusters).toBeUndefined();\n expect(result.promiseAllUnhandled).toBeUndefined();\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":56193,"content_sha256":"63155fd49790a2ea5feffac9abd0d0c4d6a5e9f7899519ce192d7de6e89c523b"},{"filename":"src/ast/ts-analyzer.ts","content":"import path from 'node:path';\n\nimport * as ts from 'typescript';\n\nimport { getFunctionName, isFunctionLike } from './helpers.js';\nimport {\n collectMetrics,\n computeHalstead,\n computeMaintainabilityIndex,\n countLinesInNode,\n} from './metrics.js';\nimport { collectMessageChains } from '../collectors/chains.js';\nimport { collectTopLevelEffects } from '../collectors/effects.js';\nimport { collectInputSourceProfile } from '../collectors/input-sources.js';\nimport { collectPerformanceData } from '../collectors/performance.js';\nimport { collectPrototypePollutionSites } from '../collectors/prototype-pollution.js';\nimport { collectSecurityData } from '../collectors/security.js';\nimport { collectTestProfile } from '../collectors/test-profile.js';\nimport {\n buildNodeTree,\n getLineAndCharacter,\n hashString,\n increment,\n isTestFile,\n makeFingerprint,\n} from '../common/utils.js';\nimport { computeCognitiveComplexity } from '../detectors/index.js';\nimport { TS_CONTROL_KINDS } from '../types/index.js';\n\nimport type {\n AnalysisOptions,\n BooleanParamCluster,\n CatchRethrowEntry,\n CodeLocation,\n DependencyProfile,\n FileCriticality,\n FileEntry,\n FlowEntry,\n FlowMaps,\n FunctionEntry,\n Location,\n MagicNumberEntry,\n MagicStringEntry,\n Metrics,\n NodeBudget,\n PackageFileSummary,\n PromiseAllUnhandledEntry,\n TreeEntry,\n} from '../types/index.js';\n\nexport { isFunctionLike, getFunctionName } from './helpers.js';\nexport {\n collectMetrics,\n computeHalstead,\n computeMaintainabilityIndex,\n countLinesInNode,\n} from './metrics.js';\n\nexport function buildDependencyCriticality(\n fileSummary: FileEntry | null,\n options: AnalysisOptions\n): FileCriticality {\n if (!fileSummary || !Array.isArray(fileSummary.functions)) {\n return {\n file: fileSummary?.file || '\u003cunknown>',\n complexityRisk: 1,\n highComplexityFunctions: 0,\n functionCount: 0,\n flows: 0,\n score: 1,\n };\n }\n\n let totalComplexity = 0;\n let highComplexity = 0;\n for (const fn of fileSummary.functions) {\n const complexity = Number(fn.complexity) || 0;\n totalComplexity += complexity;\n if (complexity >= options.thresholds.criticalComplexityThreshold) {\n highComplexity += 1;\n }\n }\n\n const flows = fileSummary.flows ? fileSummary.flows.length : 0;\n const score = Math.max(\n 1,\n Math.round(\n totalComplexity * 0.7 + fileSummary.functions.length * 2 + flows * 0.2\n )\n );\n\n return {\n file: fileSummary.file,\n functionCount: fileSummary.functions.length,\n highComplexityFunctions: highComplexity,\n flows,\n complexitySum: totalComplexity,\n complexityRisk: highComplexity,\n score,\n };\n}\n\nfunction countControlFlowStatements(node: ts.Node): number {\n if (ts.isIfStatement(node)) {\n const then = node.thenStatement;\n return ts.isBlock(then) ? then.statements.length : 1;\n }\n if (ts.isSwitchStatement(node)) {\n return node.caseBlock.clauses.length;\n }\n if (ts.isTryStatement(node)) {\n return node.tryBlock.statements.length;\n }\n if (\n ts.isForStatement(node) ||\n ts.isWhileStatement(node) ||\n ts.isDoStatement(node) ||\n ts.isForOfStatement(node) ||\n ts.isForInStatement(node)\n ) {\n const stmt = node.statement;\n return ts.isBlock(stmt) ? stmt.statements.length : 1;\n }\n return 1;\n}\n\nfunction makeLocationFromTs(\n node: ts.Node,\n sourceFile: ts.SourceFile,\n repoRoot: string\n): Location {\n const loc = getLineAndCharacter(sourceFile, node);\n return {\n file: path.relative(repoRoot, node.getSourceFile().fileName),\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n columnStart: loc.columnStart,\n columnEnd: loc.columnEnd,\n };\n}\n\nexport function analyzeSourceFile(\n sourceFile: ts.SourceFile,\n packageName: string,\n packageFileSummary: PackageFileSummary,\n options: AnalysisOptions,\n maps: FlowMaps,\n trees: TreeEntry[],\n dependencyProfile: DependencyProfile\n): FileEntry {\n const filePath = sourceFile.fileName;\n const fileRelative = path.relative(options.root, filePath);\n packageFileSummary.fileCount += 1;\n packageFileSummary.nodeCount += 1;\n packageFileSummary.kindCounts.SourceFile =\n (packageFileSummary.kindCounts.SourceFile || 0) + 1;\n\n const fileEntry: FileEntry = {\n package: packageName,\n file: fileRelative,\n parseEngine: 'typescript',\n nodeCount: 0,\n kindCounts: {},\n functions: [],\n flows: [],\n dependencyProfile,\n };\n\n if (options.emitTree) {\n const nodeBudget: NodeBudget = { size: 8000 };\n const tree = buildNodeTree(\n sourceFile,\n sourceFile,\n options.treeDepth,\n nodeBudget\n );\n if (tree) {\n trees.push({\n package: packageName,\n file: fileRelative,\n tree,\n });\n }\n }\n\n const controlKinds = TS_CONTROL_KINDS;\n const emptyCatches: CodeLocation[] = [];\n const switchesWithoutDefault: CodeLocation[] = [];\n const magicNumbers: MagicNumberEntry[] = [];\n let anyCount = 0;\n const asAnyLocs: CodeLocation[] = [];\n const doubleAssertionLocs: CodeLocation[] = [];\n const nonNullLocs: CodeLocation[] = [];\n const MAGIC_EXCLUDED = new Set([0, 1, -1, 2, 100]);\n\n const visit = (node: ts.Node): void => {\n fileEntry.nodeCount += 1;\n packageFileSummary.nodeCount += 1;\n const kind = ts.SyntaxKind[node.kind] || 'UNKNOWN';\n fileEntry.kindCounts[kind] = (fileEntry.kindCounts[kind] || 0) + 1;\n packageFileSummary.kindCounts[kind] =\n (packageFileSummary.kindCounts[kind] || 0) + 1;\n\n if (ts.isCatchClause(node)) {\n const block = node.block;\n if (block.statements.length === 0) {\n const loc = getLineAndCharacter(sourceFile, node);\n emptyCatches.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n\n if (ts.isSwitchStatement(node)) {\n const hasDefault = node.caseBlock.clauses.some(\n c => c.kind === ts.SyntaxKind.DefaultClause\n );\n if (!hasDefault) {\n const loc = getLineAndCharacter(sourceFile, node);\n switchesWithoutDefault.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n\n if (node.kind === ts.SyntaxKind.AnyKeyword) {\n anyCount += 1;\n }\n if (\n ts.isAsExpression(node) &&\n node.type.kind === ts.SyntaxKind.AnyKeyword\n ) {\n anyCount += 1;\n }\n\n if (ts.isAsExpression(node)) {\n if (node.type.kind === ts.SyntaxKind.AnyKeyword) {\n const loc = getLineAndCharacter(sourceFile, node);\n asAnyLocs.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n if (\n ts.isAsExpression(node.expression) &&\n node.expression.type.kind === ts.SyntaxKind.UnknownKeyword\n ) {\n const loc = getLineAndCharacter(sourceFile, node);\n doubleAssertionLocs.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n if (ts.isNonNullExpression(node)) {\n const loc = getLineAndCharacter(sourceFile, node);\n nonNullLocs.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n\n if (ts.isNumericLiteral(node)) {\n const value = Number(node.text);\n if (!MAGIC_EXCLUDED.has(value)) {\n const parent = node.parent;\n const inConst =\n parent &&\n ts.isVariableDeclaration(parent) &&\n parent.parent &&\n ts.isVariableDeclarationList(parent.parent) &&\n (parent.parent.flags & ts.NodeFlags.Const) !== 0;\n const inEnum = parent && ts.isEnumMember(parent);\n if (!inConst && !inEnum) {\n const loc = getLineAndCharacter(sourceFile, node);\n magicNumbers.push({\n value,\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n }\n\n if (isFunctionLike(node)) {\n const funcNode = node as ts.FunctionLikeDeclaration;\n const body = funcNode.body;\n const statementCount =\n body && ts.isBlock(body) ? body.statements.length : 1;\n const loc = makeLocationFromTs(node, sourceFile, options.root);\n const signature = getFunctionName(node, sourceFile);\n const metrics: Metrics = body\n ? collectMetrics(body)\n : {\n complexity: 1,\n maxBranchDepth: 0,\n maxLoopDepth: 0,\n returns: 0,\n awaits: 0,\n calls: 0,\n loops: 0,\n };\n\n const entry: FunctionEntry = {\n kind,\n name: signature,\n nameHint: signature,\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n columnStart: loc.columnStart,\n columnEnd: loc.columnEnd,\n statementCount,\n complexity: metrics.complexity,\n maxBranchDepth: metrics.maxBranchDepth,\n maxLoopDepth: metrics.maxLoopDepth,\n returns: metrics.returns,\n awaits: metrics.awaits,\n calls: metrics.calls,\n loops: metrics.loops,\n lengthLines: countLinesInNode(sourceFile, node),\n cognitiveComplexity: body ? computeCognitiveComplexity(body) : 0,\n };\n\n if (body) {\n entry.halstead = computeHalstead(body);\n entry.maintainabilityIndex = computeMaintainabilityIndex(\n entry.halstead.volume,\n metrics.complexity,\n entry.lengthLines\n );\n }\n\n if (ts.isFunctionDeclaration(node)) {\n entry.declared = true;\n }\n\n if (statementCount >= options.thresholds.minFunctionStatements) {\n const bodyHash = body\n ? makeFingerprint(body)\n : hashString(fileRelative);\n increment(maps.flowMap, `${bodyHash}|${node.kind}`, {\n ...entry,\n hash: bodyHash,\n metrics,\n });\n }\n\n if (funcNode.parameters) {\n entry.params = funcNode.parameters.length;\n }\n\n fileEntry.functions.push(entry);\n packageFileSummary.functions.push(entry);\n packageFileSummary.functionCount += 1;\n }\n\n if (controlKinds.has(node.kind)) {\n const statementCount = countControlFlowStatements(node);\n const loc = makeLocationFromTs(node, sourceFile, options.root);\n const flowEntry: FlowEntry = {\n kind,\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n columnStart: loc.columnStart,\n columnEnd: loc.columnEnd,\n statementCount,\n };\n fileEntry.flows.push(flowEntry);\n packageFileSummary.flowCount += 1;\n\n if (statementCount >= options.thresholds.minFlowStatements) {\n const flowHash = makeFingerprint(node);\n increment(maps.controlMap, `${flowHash}|${node.kind}`, {\n ...flowEntry,\n hash: flowHash,\n });\n }\n }\n\n ts.forEachChild(node, visit);\n };\n\n ts.forEachChild(sourceFile, visit);\n\n fileEntry.emptyCatches = emptyCatches;\n fileEntry.switchesWithoutDefault = switchesWithoutDefault;\n fileEntry.anyCount = anyCount;\n fileEntry.magicNumbers = magicNumbers;\n fileEntry.typeAssertionEscapes = {\n asAny: asAnyLocs,\n doubleAssertion: doubleAssertionLocs,\n nonNull: nonNullLocs,\n };\n\n analyzeAsyncPatterns(sourceFile, fileEntry);\n collectFileProfiles(sourceFile, fileRelative, fileEntry);\n collectSmartQualityData(sourceFile, fileRelative, fileEntry);\n\n return fileEntry;\n}\n\nfunction analyzeAsyncPatterns(\n sourceFile: ts.SourceFile,\n fileEntry: FileEntry\n): void {\n const asyncWithoutAwait: Array\u003c{\n name: string;\n lineStart: number;\n lineEnd: number;\n }> = [];\n const unprotectedAsync: Array\u003c{\n name: string;\n awaitCount: number;\n lineStart: number;\n lineEnd: number;\n }> = [];\n for (const fn of fileEntry.functions) {\n if (fn.awaits === 0) continue;\n const fnStart = sourceFile.getPositionOfLineAndCharacter(\n Math.max(0, fn.lineStart - 1),\n 0\n );\n let fnAstNode: ts.Node | undefined;\n const findFnNode = (node: ts.Node): void => {\n if (fnAstNode) return;\n if (isFunctionLike(node) && node.getStart(sourceFile) >= fnStart) {\n const fnLoc = getLineAndCharacter(sourceFile, node);\n if (fnLoc.lineStart === fn.lineStart) {\n fnAstNode = node;\n return;\n }\n }\n ts.forEachChild(node, findFnNode);\n };\n ts.forEachChild(sourceFile, findFnNode);\n if (!fnAstNode) continue;\n const isAsync = (fnAstNode as ts.FunctionLikeDeclaration).modifiers?.some(\n (m: ts.ModifierLike) => m.kind === ts.SyntaxKind.AsyncKeyword\n );\n if (!isAsync) continue;\n\n let awaitCount = 0;\n let hasTryCatch = false;\n let hasCatchChain = false;\n const scanBody = (child: ts.Node): void => {\n if (ts.isAwaitExpression(child)) awaitCount++;\n if (ts.isTryStatement(child)) hasTryCatch = true;\n if (\n ts.isCallExpression(child) &&\n ts.isPropertyAccessExpression(child.expression) &&\n child.expression.name.text === 'catch'\n ) {\n hasCatchChain = true;\n }\n if (isFunctionLike(child) && child !== fnAstNode) return;\n ts.forEachChild(child, scanBody);\n };\n ts.forEachChild(fnAstNode, scanBody);\n\n if (awaitCount === 0) {\n asyncWithoutAwait.push({\n name: fn.name,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n });\n } else if (!hasTryCatch && !hasCatchChain) {\n unprotectedAsync.push({\n name: fn.name,\n awaitCount,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n });\n }\n }\n fileEntry.asyncWithoutAwait = asyncWithoutAwait;\n fileEntry.unprotectedAsync = unprotectedAsync;\n}\n\nfunction collectFileProfiles(\n sourceFile: ts.SourceFile,\n fileRelative: string,\n fileEntry: FileEntry\n): void {\n collectSecurityData(sourceFile, fileRelative, fileEntry);\n if (!isTestFile(fileRelative)) {\n collectInputSourceProfile(sourceFile, fileRelative, fileEntry);\n collectMessageChains(sourceFile, fileRelative, fileEntry);\n }\n collectPerformanceData(sourceFile, fileRelative, fileEntry);\n if (isTestFile(fileRelative)) {\n collectTestProfile(sourceFile, fileRelative, fileEntry);\n }\n\n if (!isTestFile(fileRelative)) {\n const topLevelEffects = collectTopLevelEffects(sourceFile, fileRelative);\n if (topLevelEffects.length > 0) {\n fileEntry.topLevelEffects = topLevelEffects;\n }\n const ppSites = collectPrototypePollutionSites(sourceFile);\n if (ppSites.length > 0) {\n fileEntry.prototypePollutionSites = ppSites;\n }\n }\n}\n\nconst PROMISE_COMBINATORS = new Set(['all', 'allSettled', 'race', 'any']);\nconst PROMISE_KIND_MAP: Record\u003cstring, PromiseAllUnhandledEntry['kind']> = {\n all: 'Promise.all',\n allSettled: 'Promise.allSettled',\n race: 'Promise.race',\n any: 'Promise.any',\n};\n\nfunction collectSmartQualityData(\n sourceFile: ts.SourceFile,\n fileRelative: string,\n fileEntry: FileEntry\n): void {\n if (isTestFile(fileRelative)) return;\n\n const magicStrings: MagicStringEntry[] = [];\n const catchRethrows: CatchRethrowEntry[] = [];\n const booleanParamClusters: BooleanParamCluster[] = [];\n const promiseAllUnhandled: PromiseAllUnhandledEntry[] = [];\n\n const stringCompareValues = new Map\u003cstring, CodeLocation[]>();\n\n const visit = (node: ts.Node): void => {\n if (\n ts.isBinaryExpression(node) &&\n (node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||\n node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken ||\n node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken ||\n node.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsToken)\n ) {\n const checkStringLiteral = (operand: ts.Expression): void => {\n if (ts.isStringLiteral(operand) && operand.text.length > 0) {\n const loc = getLineAndCharacter(sourceFile, operand);\n const locs = stringCompareValues.get(operand.text) || [];\n locs.push({ file: fileRelative, lineStart: loc.lineStart, lineEnd: loc.lineEnd });\n stringCompareValues.set(operand.text, locs);\n }\n };\n checkStringLiteral(node.left);\n checkStringLiteral(node.right);\n }\n\n if (ts.isSwitchStatement(node)) {\n for (const clause of node.caseBlock.clauses) {\n if (ts.isCaseClause(clause) && ts.isStringLiteral(clause.expression)) {\n const text = clause.expression.text;\n if (text.length > 0) {\n const loc = getLineAndCharacter(sourceFile, clause.expression);\n const locs = stringCompareValues.get(text) || [];\n locs.push({ file: fileRelative, lineStart: loc.lineStart, lineEnd: loc.lineEnd });\n stringCompareValues.set(text, locs);\n }\n }\n }\n }\n\n if (ts.isCatchClause(node)) {\n const block = node.block;\n if (\n block.statements.length === 1 &&\n ts.isThrowStatement(block.statements[0])\n ) {\n const throwExpr = block.statements[0].expression;\n const catchParam = node.variableDeclaration?.name;\n if (\n throwExpr &&\n catchParam &&\n ts.isIdentifier(catchParam) &&\n ts.isIdentifier(throwExpr) &&\n throwExpr.text === catchParam.text\n ) {\n const loc = getLineAndCharacter(sourceFile, node);\n catchRethrows.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n }\n\n if (isFunctionLike(node)) {\n const funcNode = node as ts.FunctionLikeDeclaration;\n if (funcNode.parameters && funcNode.parameters.length >= 2) {\n let boolCount = 0;\n for (const param of funcNode.parameters) {\n if (\n param.type &&\n param.type.kind === ts.SyntaxKind.BooleanKeyword\n ) {\n boolCount++;\n }\n }\n if (boolCount >= 3) {\n const name = getFunctionName(node, sourceFile);\n const loc = getLineAndCharacter(sourceFile, node);\n booleanParamClusters.push({\n name,\n booleanCount: boolCount,\n totalParams: funcNode.parameters.length,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n }\n\n if (\n ts.isCallExpression(node) &&\n ts.isPropertyAccessExpression(node.expression) &&\n ts.isIdentifier(node.expression.expression) &&\n node.expression.expression.text === 'Promise' &&\n PROMISE_COMBINATORS.has(node.expression.name.text)\n ) {\n const combinator = node.expression.name.text;\n let hasTryCatch = false;\n let hasCatchChain = false;\n let parent = node.parent;\n while (parent) {\n if (ts.isTryStatement(parent)) {\n hasTryCatch = true;\n break;\n }\n if (\n ts.isCallExpression(parent) &&\n ts.isPropertyAccessExpression(parent.expression) &&\n parent.expression.name.text === 'catch'\n ) {\n hasCatchChain = true;\n break;\n }\n if (isFunctionLike(parent)) break;\n parent = parent.parent;\n }\n\n if (!hasTryCatch && !hasCatchChain) {\n const loc = getLineAndCharacter(sourceFile, node);\n promiseAllUnhandled.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n kind: PROMISE_KIND_MAP[combinator] || 'Promise.all',\n });\n }\n }\n\n ts.forEachChild(node, visit);\n };\n\n ts.forEachChild(sourceFile, visit);\n\n for (const [value, locs] of stringCompareValues) {\n if (locs.length >= 2) {\n for (const loc of locs) {\n magicStrings.push({ ...loc, value });\n }\n }\n }\n\n if (magicStrings.length > 0) fileEntry.magicStrings = magicStrings;\n if (catchRethrows.length > 0) fileEntry.catchRethrows = catchRethrows;\n if (booleanParamClusters.length > 0) fileEntry.booleanParamClusters = booleanParamClusters;\n if (promiseAllUnhandled.length > 0) fileEntry.promiseAllUnhandled = promiseAllUnhandled;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":20153,"content_sha256":"ec1f8baebe92a9486390d822765f39c78e1969ba3192b5a5933ab88e94907a2b"},{"filename":"src/build-output.test.ts","content":"import { execFileSync } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { dirname, join, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nimport { describe, expect, it } from 'vitest';\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst skillRoot = resolve(here, '..');\nconst scriptsDir = join(skillRoot, 'scripts');\n\nconst PROHIBITED_EXTERNALS = ['typescript'];\n\nfunction hasTopLevelImportFrom(src: string, pkg: string): boolean {\n const escaped = pkg.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\{head-tags}');\n const re = new RegExp(\n `(?:^|[;}\\\\s])(?:import|export)[^\"'\\\\n;]*?from\\\\s*[\"']${escaped}[\"']`,\n 'm',\n );\n return re.test(src);\n}\n\ndescribe('build output', () => {\n const runPath = join(scriptsDir, 'run.js');\n const searchPath = join(scriptsDir, 'ast', 'search.js');\n const treeSearchPath = join(scriptsDir, 'ast', 'tree-search.js');\n const indexPath = join(scriptsDir, 'index.js');\n\n it('produces exactly the three documented entry points', () => {\n expect(existsSync(runPath), 'scripts/run.js missing').toBe(true);\n expect(existsSync(searchPath), 'scripts/ast/search.js missing').toBe(true);\n expect(existsSync(treeSearchPath), 'scripts/ast/tree-search.js missing').toBe(true);\n });\n\n it('does not emit the undocumented index.js entry', () => {\n expect(existsSync(indexPath), 'scripts/index.js is dead weight; drop index entry from build.mjs').toBe(false);\n });\n\n describe.each([\n ['run.js', runPath],\n ['ast/search.js', searchPath],\n ['ast/tree-search.js', treeSearchPath],\n ])('%s bundle', (_label, path) => {\n it.each(PROHIBITED_EXTERNALS)(\n 'inlines pure-JS dep %s instead of leaving it external',\n (pkg) => {\n if (!existsSync(path)) return;\n const src = readFileSync(path, 'utf8');\n expect(\n hasTopLevelImportFrom(src, pkg),\n `${pkg} is imported externally; should be inlined into the bundle`,\n ).toBe(false);\n },\n );\n });\n\n it('scripts/run.js --help runs without missing-module errors', () => {\n if (!existsSync(runPath)) return;\n expect(() =>\n execFileSync(process.execPath, [runPath, '--help'], {\n cwd: skillRoot,\n stdio: 'pipe',\n timeout: 15_000,\n }),\n ).not.toThrow();\n });\n\n it('scripts/ast/search.js --list-presets runs without missing-module errors', () => {\n if (!existsSync(searchPath)) return;\n expect(() =>\n execFileSync(process.execPath, [searchPath, '--list-presets'], {\n cwd: skillRoot,\n stdio: 'pipe',\n timeout: 15_000,\n }),\n ).not.toThrow();\n });\n\n it('scripts/ast/tree-search.js --help runs without missing-module errors', () => {\n if (!existsSync(treeSearchPath)) return;\n expect(() =>\n execFileSync(process.execPath, [treeSearchPath, '--help'], {\n cwd: skillRoot,\n stdio: 'pipe',\n timeout: 15_000,\n }),\n ).not.toThrow();\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":2954,"content_sha256":"c886b145742fa540fa2aef38fa616c87ffbb9eee6a879e4ca6e459492cec5e92"},{"filename":"src/collectors/chains.ts","content":"import * as ts from 'typescript';\n\nimport { getLineAndCharacter } from '../common/utils.js';\n\nimport type { FileEntry, MessageChainEntry } from '../types/index.js';\n\n/** Minimum property-access depth to flag as a message chain (Law of Demeter). */\nconst MIN_CHAIN_DEPTH = 4;\n\n/**\n * Walk a property-access or element-access expression to its root and return\n * the full chain text and depth (number of dot-steps).\n *\n * Handles: a.b.c.d a?.b?.c?.d a['b']['c']['d']\n */\nfunction measureChain(node: ts.Node, sourceFile: ts.SourceFile): { text: string; depth: number } | null {\n let depth = 0;\n let current: ts.Node = node;\n\n while (\n ts.isPropertyAccessExpression(current) ||\n ts.isElementAccessExpression(current)\n ) {\n depth++;\n current = (current as ts.PropertyAccessExpression | ts.ElementAccessExpression).expression;\n }\n\n if (depth \u003c MIN_CHAIN_DEPTH) return null;\n\n // Avoid reporting intermediate nodes — only report the outermost chain.\n // (The outermost node is the one whose parent is NOT itself a property/element access)\n const parent = node.parent;\n if (\n ts.isPropertyAccessExpression(parent) ||\n ts.isElementAccessExpression(parent)\n ) {\n return null; // This is an intermediate node; the outermost will be visited.\n }\n\n return { text: node.getText(sourceFile), depth };\n}\n\nexport function collectMessageChains(\n sourceFile: ts.SourceFile,\n _fileRelative: string,\n fileEntry: FileEntry\n): void {\n const chains: MessageChainEntry[] = [];\n\n const visit = (node: ts.Node): void => {\n if (\n ts.isPropertyAccessExpression(node) ||\n ts.isElementAccessExpression(node)\n ) {\n const result = measureChain(node, sourceFile);\n if (result) {\n const loc = getLineAndCharacter(sourceFile, node);\n chains.push({\n chain: result.text.slice(0, 80),\n depth: result.depth,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n ts.forEachChild(node, visit);\n };\n\n ts.forEachChild(sourceFile, visit);\n\n if (chains.length > 0) {\n fileEntry.messageChains = chains;\n }\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":2116,"content_sha256":"7cb7faf05f21a8d3879a1f41432dd9f2002c7a153d761897928bf371d8832d7a"},{"filename":"src/collectors/effects.test.ts","content":"import * as ts from 'typescript';\nimport { describe, expect, it } from 'vitest';\n\nimport {\n blockContainsCall,\n collectTopLevelEffects,\n findParentBlock,\n} from './effects.js';\n\nfunction parse(code: string, fileName = '/repo/src/test.ts'): ts.SourceFile {\n return ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true);\n}\n\ndescribe('collectTopLevelEffects', () => {\n describe('effect kinds', () => {\n it('detects side-effect-import (bare import)', () => {\n const src = parse(\"import './polyfill';\");\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'side-effect-import',\n detail: \"import './polyfill'\",\n weight: 3,\n confidence: 'medium',\n });\n });\n\n it('detects top-level-await', () => {\n const src = parse('await fetch(\"/api\");');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'top-level-await',\n detail: 'top-level await',\n weight: 4,\n confidence: 'high',\n });\n });\n\n it('detects top-level-await inside control flow (scanNodeForEffects path)', () => {\n const src = parse('if (true) { await Promise.resolve(); }');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'top-level-await',\n detail: 'top-level await',\n weight: 4,\n confidence: 'high',\n });\n });\n\n it('detects eval()', () => {\n const src = parse('eval(\"1+1\");');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'eval',\n detail: 'eval()',\n weight: 8,\n confidence: 'high',\n });\n });\n\n it('detects Function() call', () => {\n const src = parse('Function(\"return 1\");');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'eval',\n detail: 'Function()',\n weight: 8,\n confidence: 'high',\n });\n });\n\n it('detects new Function()', () => {\n const src = parse('new Function(\"return 1\");');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'eval',\n detail: 'new Function()',\n weight: 8,\n confidence: 'high',\n });\n });\n\n it('detects setTimeout (timer)', () => {\n const src = parse('setTimeout(() => {}, 0);');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'timer',\n detail: 'setTimeout()',\n weight: 4,\n confidence: 'high',\n });\n });\n\n it('detects setInterval (timer)', () => {\n const src = parse('setInterval(() => {}, 1000);');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'timer',\n detail: 'setInterval()',\n weight: 4,\n confidence: 'high',\n });\n });\n\n it('detects execSync (exec-sync)', () => {\n const src = parse('const out = require(\"child_process\").execSync(\"ls\");');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'exec-sync',\n weight: 8,\n confidence: 'high',\n });\n });\n\n it('detects execFileSync (exec-sync)', () => {\n const src = parse(\n 'const cp = require(\"child_process\"); const x = cp.execFileSync(\"ls\");'\n );\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'exec-sync',\n weight: 8,\n confidence: 'high',\n });\n });\n\n it('detects readFileSync (sync-io)', () => {\n const src = parse(\n 'const fs = require(\"fs\"); const data = fs.readFileSync(\"/path\", \"utf8\");'\n );\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'sync-io',\n weight: 5,\n confidence: 'high',\n });\n });\n\n it('detects writeFileSync (sync-io)', () => {\n const src = parse(\n 'const fs = require(\"fs\"); fs.writeFileSync(\"/tmp/x\", \"data\");'\n );\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'sync-io',\n weight: 5,\n confidence: 'high',\n });\n });\n\n it('detects process.on (process-handler)', () => {\n const src = parse('process.on(\"uncaughtException\", () => {});');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'process-handler',\n detail: 'process.on()',\n weight: 4,\n confidence: 'high',\n });\n });\n\n it('detects process.once (process-handler)', () => {\n const src = parse('process.once(\"SIGINT\", () => {});');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'process-handler',\n detail: 'process.once()',\n weight: 4,\n confidence: 'high',\n });\n });\n\n it('detects process.addListener (process-handler)', () => {\n const src = parse('process.addListener(\"exit\", () => {});');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'process-handler',\n detail: 'process.addListener()',\n weight: 4,\n confidence: 'high',\n });\n });\n\n it('detects addEventListener (listener)', () => {\n const src = parse('window.addEventListener(\"load\", () => {});');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'listener',\n detail: 'window.addEventListener()',\n weight: 4,\n confidence: 'medium',\n });\n });\n\n it('detects .on (listener, non-process)', () => {\n const src = parse('emitter.on(\"event\", () => {});');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'listener',\n weight: 4,\n confidence: 'medium',\n });\n });\n\n it('detects dynamic import()', () => {\n const src = parse(\"import('./lazy');\");\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'dynamic-import',\n detail: 'dynamic import()',\n weight: 3,\n confidence: 'medium',\n });\n });\n });\n\n describe('edge cases - no effects', () => {\n it('empty file produces no effects', () => {\n const src = parse('');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n\n it('regular import (with binding) does NOT produce side-effect-import', () => {\n const src = parse(\"import path from 'node:path';\");\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n\n it('import { x } does NOT produce side-effect-import', () => {\n const src = parse(\"import { foo } from './bar';\");\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n\n it('function declaration does NOT produce effects', () => {\n const src = parse('function f() { fs.readFileSync(\"/path\"); }');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n\n it('class declaration does NOT produce effects', () => {\n const src = parse('class C { m() { setTimeout(() => {}, 0); } }');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n\n it('arrow function in variable does NOT produce effects', () => {\n const src = parse('const f = () => { eval(\"1\"); };');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n });\n\n describe('variable initializers', () => {\n it('variable initializer with call expression', () => {\n const src = parse(\n 'const fs = require(\"fs\"); const data = fs.readFileSync(\"/path\", \"utf8\");'\n );\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0].kind).toBe('sync-io');\n });\n\n it('binary expression with call on right side (assignment)', () => {\n const src = parse('x = setTimeout(() => {}, 0);');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'timer',\n detail: 'setTimeout()',\n });\n });\n\n it('variable initializer as binary with call on right', () => {\n const src = parse('let y; const x = y = eval(\"1\");');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0].kind).toBe('eval');\n });\n });\n\n describe('top-level control flow (scanNodeForEffects)', () => {\n it('if statement with call inside', () => {\n const src = parse('if (true) { setTimeout(() => {}, 0); }');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({ kind: 'timer' });\n });\n\n it('for statement with call inside', () => {\n const src = parse('for (let i = 0; i \u003c 1; i++) { eval(\"1\"); }');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({ kind: 'eval' });\n });\n\n it('while statement with call inside', () => {\n const src = parse('while (false) { new Function(\"1\"); }');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({\n kind: 'eval',\n detail: 'new Function()',\n });\n });\n\n it('switch statement with call inside', () => {\n const src = parse(\n 'switch (1) { case 1: setInterval(() => {}, 100); break; }'\n );\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({ kind: 'timer' });\n });\n\n it('try statement with call inside', () => {\n const src = parse('try { process.on(\"exit\", () => {}); } catch {}');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({ kind: 'process-handler' });\n });\n\n it('do-while with call inside', () => {\n const src = parse(\n 'const cp = require(\"child_process\"); do { cp.execSync(\"ls\"); } while (false);'\n );\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({ kind: 'exec-sync' });\n });\n\n it('for-of with call inside', () => {\n const src = parse(\n 'const fs = require(\"fs\"); for (const x of []) { fs.readFileSync(\"/x\"); }'\n );\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({ kind: 'sync-io' });\n });\n\n it('for-in with call inside', () => {\n const src = parse(\n 'for (const k in {}) { document.addEventListener(\"click\", () => {}); }'\n );\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(1);\n expect(effects[0]).toMatchObject({ kind: 'listener' });\n });\n });\n\n describe('skipped statement types', () => {\n it('skips export declaration', () => {\n const src = parse(\"export { x } from './a';\");\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n\n it('skips type alias', () => {\n const src = parse('type T = string;');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n\n it('skips interface declaration', () => {\n const src = parse('interface I {}');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n\n it('skips enum declaration', () => {\n const src = parse('enum E { A }');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n\n it('skips module declaration', () => {\n const src = parse('declare module \"x\" {}');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects).toHaveLength(0);\n });\n });\n\n describe('process.on with different event names', () => {\n it('detects process.on(\"uncaughtException\")', () => {\n const src = parse('process.on(\"uncaughtException\", () => {});');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects[0]).toMatchObject({ kind: 'process-handler' });\n });\n\n it('detects process.on(\"SIGTERM\")', () => {\n const src = parse('process.on(\"SIGTERM\", () => {});');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects[0]).toMatchObject({ kind: 'process-handler' });\n });\n\n it('detects process.on(\"exit\")', () => {\n const src = parse('process.on(\"exit\", () => {});');\n const effects = collectTopLevelEffects(src, 'src/test.ts');\n expect(effects[0]).toMatchObject({ kind: 'process-handler' });\n });\n });\n});\n\ndescribe('findParentBlock', () => {\n it('returns Block when node is inside a block', () => {\n const src = parse('function f() { const x = 1; }');\n const fn = src.statements[0] as ts.FunctionDeclaration;\n const block = fn.body!;\n const stmt = block.statements[0];\n expect(findParentBlock(stmt)).toBe(block);\n });\n\n it('returns SourceFile when node is top-level statement', () => {\n const src = parse('const x = 1;');\n const stmt = src.statements[0];\n expect(findParentBlock(stmt)).toBe(src);\n });\n\n it('returns null when node has no parent (e.g. SourceFile)', () => {\n const src = parse('const x = 1;');\n expect(findParentBlock(src)).toBe(null);\n });\n\n it('returns Block for node nested inside block', () => {\n const src = parse('if (true) { if (false) { setTimeout(() => {}); } }');\n const outerIf = src.statements[0] as ts.IfStatement;\n const outerBlock = outerIf.thenStatement as ts.Block;\n const innerIf = outerBlock.statements[0] as ts.IfStatement;\n const innerBlock = innerIf.thenStatement as ts.Block;\n const callStmt = innerBlock.statements[0] as ts.ExpressionStatement;\n expect(findParentBlock(callStmt)).toBe(innerBlock);\n });\n});\n\ndescribe('blockContainsCall', () => {\n it('returns true when block contains call with given name', () => {\n const src = parse('function f() { setTimeout(() => {}, 0); }');\n const fn = src.statements[0] as ts.FunctionDeclaration;\n const block = fn.body!;\n expect(blockContainsCall(block, src, 'setTimeout')).toBe(true);\n });\n\n it('returns false when block does not contain call with given name', () => {\n const src = parse('function f() { setInterval(() => {}, 0); }');\n const fn = src.statements[0] as ts.FunctionDeclaration;\n const block = fn.body!;\n expect(blockContainsCall(block, src, 'setTimeout')).toBe(false);\n });\n\n it('returns true for nested call', () => {\n const src = parse('function f() { if (true) { eval(\"1\"); } }');\n const fn = src.statements[0] as ts.FunctionDeclaration;\n const block = fn.body!;\n expect(blockContainsCall(block, src, 'eval')).toBe(true);\n });\n\n it('returns false for empty block', () => {\n const src = parse('function f() {}');\n const fn = src.statements[0] as ts.FunctionDeclaration;\n const block = fn.body!;\n expect(blockContainsCall(block, src, 'setTimeout')).toBe(false);\n });\n\n it('returns true when call is in variable initializer', () => {\n const src = parse(\n 'const fs = require(\"fs\"); function f() { const x = fs.readFileSync(\"/x\"); }'\n );\n const fn = src.statements[1] as ts.FunctionDeclaration;\n const block = fn.body!;\n expect(blockContainsCall(block, src, 'fs.readFileSync')).toBe(true);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":17097,"content_sha256":"13d5c3ede206fc6e1b46ff2c8098e4cb7bf3d1301e6c4956cd0baf1ed96fdb8e"},{"filename":"src/collectors/effects.ts","content":"import * as ts from 'typescript';\n\nimport { isFunctionLike } from '../ast/helpers.js';\nimport { getLineAndCharacter } from '../common/utils.js';\n\nimport type { TopLevelEffect } from '../types/index.js';\n\nconst SYNC_IO_TOP_LEVEL = new Set([\n 'readFileSync',\n 'writeFileSync',\n 'existsSync',\n 'mkdirSync',\n 'readdirSync',\n 'statSync',\n 'lstatSync',\n 'unlinkSync',\n 'rmdirSync',\n 'renameSync',\n 'copyFileSync',\n 'accessSync',\n 'appendFileSync',\n 'chmodSync',\n 'chownSync',\n 'openSync',\n 'closeSync',\n]);\n\nconst EXEC_SYNC_TOP_LEVEL = new Set(['execSync', 'execFileSync', 'spawnSync']);\n\nexport function collectTopLevelEffects(\n sourceFile: ts.SourceFile,\n _fileRelative: string\n): TopLevelEffect[] {\n const effects: TopLevelEffect[] = [];\n\n for (const stmt of sourceFile.statements) {\n if (ts.isImportDeclaration(stmt)) {\n if (!stmt.importClause) {\n const spec = stmt.moduleSpecifier;\n const moduleName = ts.isStringLiteral(spec) ? spec.text : '\u003cunknown>';\n const loc = getLineAndCharacter(sourceFile, stmt);\n effects.push({\n kind: 'side-effect-import',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: `import '${moduleName}'`,\n weight: 3,\n confidence: 'medium',\n });\n }\n continue;\n }\n\n if (ts.isExportDeclaration(stmt) || ts.isExportAssignment(stmt)) continue;\n if (\n ts.isTypeAliasDeclaration(stmt) ||\n ts.isInterfaceDeclaration(stmt) ||\n ts.isEnumDeclaration(stmt)\n )\n continue;\n if (ts.isModuleDeclaration(stmt)) continue;\n\n if (\n isFunctionLike(stmt) ||\n ts.isFunctionDeclaration(stmt) ||\n ts.isClassDeclaration(stmt)\n )\n continue;\n\n if (ts.isVariableStatement(stmt)) {\n for (const decl of stmt.declarationList.declarations) {\n if (decl.initializer) {\n scanExpressionForEffects(decl.initializer, sourceFile, effects);\n }\n }\n continue;\n }\n\n if (ts.isExpressionStatement(stmt)) {\n scanExpressionForEffects(stmt.expression, sourceFile, effects);\n continue;\n }\n\n if (\n ts.isIfStatement(stmt) ||\n ts.isForStatement(stmt) ||\n ts.isWhileStatement(stmt) ||\n ts.isDoStatement(stmt) ||\n ts.isForOfStatement(stmt) ||\n ts.isForInStatement(stmt) ||\n ts.isSwitchStatement(stmt) ||\n ts.isTryStatement(stmt)\n ) {\n scanNodeForEffects(stmt, sourceFile, effects);\n }\n }\n\n return effects;\n}\n\nfunction scanExpressionForEffects(\n expr: ts.Expression,\n sourceFile: ts.SourceFile,\n effects: TopLevelEffect[]\n): void {\n if (ts.isAwaitExpression(expr)) {\n const loc = getLineAndCharacter(sourceFile, expr);\n effects.push({\n kind: 'top-level-await',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: 'top-level await',\n weight: 4,\n confidence: 'high',\n });\n return;\n }\n\n if (ts.isCallExpression(expr)) {\n classifyCall(expr, sourceFile, effects);\n return;\n }\n\n if (\n ts.isNewExpression(expr) &&\n expr.expression.getText(sourceFile) === 'Function'\n ) {\n const loc = getLineAndCharacter(sourceFile, expr);\n effects.push({\n kind: 'eval',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: 'new Function()',\n weight: 8,\n confidence: 'high',\n });\n return;\n }\n\n if (\n ts.isBinaryExpression(expr) &&\n expr.operatorToken.kind === ts.SyntaxKind.EqualsToken\n ) {\n if (ts.isCallExpression(expr.right)) {\n classifyCall(expr.right, sourceFile, effects);\n }\n }\n}\n\nfunction classifyCall(\n call: ts.CallExpression,\n sourceFile: ts.SourceFile,\n effects: TopLevelEffect[]\n): void {\n const text = call.expression.getText(sourceFile);\n const loc = getLineAndCharacter(sourceFile, call);\n\n if (text === 'eval' || text === 'Function') {\n effects.push({\n kind: 'eval',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: `${text}()`,\n weight: 8,\n confidence: 'high',\n });\n return;\n }\n\n if (text === 'setInterval' || text === 'setTimeout') {\n effects.push({\n kind: 'timer',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: `${text}()`,\n weight: 4,\n confidence: 'high',\n });\n return;\n }\n\n if (ts.isPropertyAccessExpression(call.expression)) {\n const method = call.expression.name.getText(sourceFile);\n const obj = call.expression.expression.getText(sourceFile);\n\n if (EXEC_SYNC_TOP_LEVEL.has(method) || EXEC_SYNC_TOP_LEVEL.has(text)) {\n effects.push({\n kind: 'exec-sync',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: text,\n weight: 8,\n confidence: 'high',\n });\n return;\n }\n\n if (SYNC_IO_TOP_LEVEL.has(method)) {\n effects.push({\n kind: 'sync-io',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: text,\n weight: 5,\n confidence: 'high',\n });\n return;\n }\n\n if (\n obj === 'process' &&\n (method === 'on' || method === 'once' || method === 'addListener')\n ) {\n effects.push({\n kind: 'process-handler',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: `${text}()`,\n weight: 4,\n confidence: 'high',\n });\n return;\n }\n\n if (\n method === 'addEventListener' ||\n method === 'on' ||\n method === 'addListener'\n ) {\n effects.push({\n kind: 'listener',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: `${text}()`,\n weight: 4,\n confidence: 'medium',\n });\n return;\n }\n }\n\n if (ts.isCallExpression(call.expression) || text === 'import') {\n if (\n text.startsWith('import(') ||\n (ts.isCallExpression(call) &&\n call.expression.kind === ts.SyntaxKind.ImportKeyword)\n ) {\n effects.push({\n kind: 'dynamic-import',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: 'dynamic import()',\n weight: 3,\n confidence: 'medium',\n });\n }\n }\n}\n\nfunction scanNodeForEffects(\n node: ts.Node,\n sourceFile: ts.SourceFile,\n effects: TopLevelEffect[]\n): void {\n if (isFunctionLike(node) || ts.isClassDeclaration(node)) return;\n if (ts.isCallExpression(node)) {\n classifyCall(node, sourceFile, effects);\n return;\n }\n if (ts.isAwaitExpression(node)) {\n const loc = getLineAndCharacter(sourceFile, node);\n effects.push({\n kind: 'top-level-await',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: 'top-level await',\n weight: 4,\n confidence: 'high',\n });\n return;\n }\n if (\n ts.isNewExpression(node) &&\n node.expression.getText(sourceFile) === 'Function'\n ) {\n const loc = getLineAndCharacter(sourceFile, node);\n effects.push({\n kind: 'eval',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n detail: 'new Function()',\n weight: 8,\n confidence: 'high',\n });\n return;\n }\n ts.forEachChild(node, child =>\n scanNodeForEffects(child, sourceFile, effects)\n );\n}\n\nexport function findParentBlock(\n node: ts.Node\n): ts.Block | ts.SourceFile | null {\n let current = node.parent;\n while (current) {\n if (ts.isBlock(current) || ts.isSourceFile(current)) return current;\n current = current.parent;\n }\n return null;\n}\n\nexport function blockContainsCall(\n block: ts.Node,\n sourceFile: ts.SourceFile,\n callName: string\n): boolean {\n let found = false;\n const search = (n: ts.Node): void => {\n if (found) return;\n if (\n ts.isCallExpression(n) &&\n n.expression.getText(sourceFile) === callName\n ) {\n found = true;\n return;\n }\n ts.forEachChild(n, search);\n };\n ts.forEachChild(block, search);\n return found;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":7852,"content_sha256":"1ea7d83eee91b7b336954477f0bb393bdd243ef124046b513b0d8fd2c38a8fd2"},{"filename":"src/collectors/input-sources.test.ts","content":"import * as ts from 'typescript';\nimport { describe, expect, it } from 'vitest';\n\nimport { collectInputSourceProfile } from './input-sources.js';\n\nimport type { FileEntry } from '../types/index.js';\n\nfunction parse(code: string, fileName = '/repo/src/test.ts'): ts.SourceFile {\n return ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true);\n}\n\nfunction emptyFileEntry(): FileEntry {\n return {\n package: 'test',\n file: 'test.ts',\n parseEngine: 'typescript',\n nodeCount: 0,\n kindCounts: {},\n functions: [],\n flows: [],\n dependencyProfile: {\n internalDependencies: [],\n externalDependencies: [],\n unresolvedDependencies: [],\n declaredExports: [],\n importedSymbols: [],\n reExports: [],\n },\n };\n}\n\ndescribe('collectInputSourceProfile', () => {\n it('function with req param → detects input source with high confidence', () => {\n const code = `function handler(req: Request) { return req.url; }`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectInputSourceProfile(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.inputSources).toBeDefined();\n expect(fileEntry.inputSources!.length).toBeGreaterThan(0);\n const src = fileEntry.inputSources![0];\n expect(src.sourceParams).toContain('req');\n expect(src.paramConfidence).toBe('high');\n });\n\n it('function with input param → detects with medium confidence', () => {\n const code = `function process(input: string) { return input.trim(); }`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectInputSourceProfile(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.inputSources).toBeDefined();\n expect(fileEntry.inputSources!.length).toBeGreaterThan(0);\n const src = fileEntry.inputSources![0];\n expect(src.sourceParams).toContain('input');\n expect(src.paramConfidence).toBe('medium');\n });\n\n it('function with count param → no input source detected', () => {\n const code = `function increment(count: number) { return count + 1; }`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectInputSourceProfile(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.inputSources).toBeDefined();\n expect(fileEntry.inputSources!.length).toBe(0);\n });\n\n it('function with req and eval() sink → hasSinkInBody=true', () => {\n const code = `function bad(req: Request) { eval(req.body); }`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectInputSourceProfile(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.inputSources).toBeDefined();\n expect(fileEntry.inputSources!.length).toBeGreaterThan(0);\n const src = fileEntry.inputSources![0];\n expect(src.hasSinkInBody).toBe(true);\n expect(src.sinkKinds).toContain('eval');\n });\n\n it('function with validation (typeof check) → hasValidation=true', () => {\n const code = `function safe(input: unknown) { if (typeof input === 'string') return input; }`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectInputSourceProfile(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.inputSources).toBeDefined();\n expect(fileEntry.inputSources!.length).toBeGreaterThan(0);\n const src = fileEntry.inputSources![0];\n expect(src.hasValidation).toBe(true);\n });\n\n it('no false positive on non-source parameters', () => {\n const code = `function util(count: number, limit: number) { return count * limit; }`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectInputSourceProfile(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.inputSources).toBeDefined();\n expect(fileEntry.inputSources!.length).toBe(0);\n });\n\n it('detects instanceof validation for source param', () => {\n const code = `\n class UserInput {}\n function parseReq(req: unknown) {\n if (req instanceof UserInput) return req;\n return null;\n }\n `;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectInputSourceProfile(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.inputSources).toBeDefined();\n expect(fileEntry.inputSources!.length).toBeGreaterThan(0);\n expect(fileEntry.inputSources![0].hasValidation).toBe(true);\n });\n\n it('detects optional chaining usage as validation signal', () => {\n const code = `\n function read(req: { body?: { id?: string } }) {\n return req?.body?.id ?? '';\n }\n `;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectInputSourceProfile(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.inputSources).toBeDefined();\n expect(fileEntry.inputSources!.length).toBe(1);\n expect(fileEntry.inputSources![0].hasValidation).toBe(true);\n });\n\n it('captures callsWithInputArgs when source param is passed to sink-like calls', () => {\n const code = `\n function route(req: any, res: any) {\n res.send(req.body);\n return req;\n }\n `;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectInputSourceProfile(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.inputSources).toBeDefined();\n expect(fileEntry.inputSources!.length).toBe(1);\n const calls = fileEntry.inputSources![0].callsWithInputArgs;\n expect(calls.length).toBeGreaterThan(0);\n expect(calls.some(c => c.callee.includes('res.send'))).toBe(true);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":5493,"content_sha256":"e1c5b86691ecc54249791e67a6152674f498492d36f9854e4f12a01bad195576"},{"filename":"src/collectors/input-sources.ts","content":"import * as ts from 'typescript';\n\nimport { getFunctionName, isFunctionLike } from '../ast/helpers.js';\nimport { getLineAndCharacter } from '../common/utils.js';\n\nimport type { FileEntry, InputSourceInfo } from '../types/index.js';\n\nconst HIGH_CONFIDENCE_PARAM =\n /^(req|request|body|rawBody|formData|payload|query|headers|params)$/i;\nconst MEDIUM_CONFIDENCE_PARAM = /^(input|event|message)$/i;\nconst SOURCE_PARAM_PATTERNS =\n /^(req|request|body|input|payload|data|params|query|headers|event|message|ctx|context|args|rawBody|formData)/i;\n\nfunction getParamConfidence(params: string[]): 'high' | 'medium' | 'low' {\n let hasMedium = false;\n for (const p of params) {\n if (HIGH_CONFIDENCE_PARAM.test(p)) return 'high';\n if (MEDIUM_CONFIDENCE_PARAM.test(p)) hasMedium = true;\n }\n return hasMedium ? 'medium' : 'low';\n}\n\nconst SINK_CALL_PATTERNS: Array\u003c{ pattern: RegExp; kind: string }> = [\n { pattern: /^eval$/, kind: 'eval' },\n { pattern: /^Function$/, kind: 'eval' },\n { pattern: /\\.exec(Sync)?$/, kind: 'exec' },\n { pattern: /^child_process\\.(exec|spawn|fork)/, kind: 'exec' },\n { pattern: /^execSync$|^spawnSync$/, kind: 'exec' },\n { pattern: /^cp\\.exec$|^cp\\.spawn$/, kind: 'exec' },\n { pattern: /\\.innerHTML$|\\.outerHTML$/, kind: 'innerHTML' },\n { pattern: /dangerouslySetInnerHTML/, kind: 'innerHTML' },\n { pattern: /\\.query$|\\.execute$/, kind: 'sql' },\n { pattern: /\\.redirect$/, kind: 'redirect' },\n { pattern: /\\.send$|\\.json$|\\.write$/, kind: 'response' },\n { pattern: /fs\\.(writeFile|appendFile)/, kind: 'fs-write' },\n { pattern: /writeFileSync|appendFileSync/, kind: 'fs-write' },\n { pattern: /fs\\.(readFile|readFileSync|createReadStream)/, kind: 'fs-read' },\n { pattern: /readFileSync|readFile/, kind: 'fs-read' },\n { pattern: /path\\.(resolve|join)/, kind: 'path-resolve' },\n { pattern: /^fetch$/, kind: 'ssrf' },\n { pattern: /^(http|https)\\.(request|get)/, kind: 'ssrf' },\n { pattern: /axios\\.(get|post|put|delete|request)/, kind: 'ssrf' },\n];\n\nconst SCHEMA_VALIDATOR_PATTERNS =\n /\\.(validate|parse|safeParse|parseAsync|check|verify)\\s*\\(/;\nconst VALIDATOR_LIB_PATTERNS =\n /^(z|zod|Joi|yup|ajv|validator|superstruct|io-ts)\\./;\n\nexport function collectInputSourceProfile(\n sourceFile: ts.SourceFile,\n _fileRelative: string,\n fileEntry: FileEntry\n): void {\n const inputSources: InputSourceInfo[] = [];\n\n const visitFn = (node: ts.Node): void => {\n if (!isFunctionLike(node)) {\n ts.forEachChild(node, visitFn);\n return;\n }\n\n const fnNode = node as ts.FunctionLikeDeclaration;\n const params = fnNode.parameters;\n const sourceParams: string[] = [];\n for (const p of params) {\n const name = p.name.getText(sourceFile);\n if (SOURCE_PARAM_PATTERNS.test(name)) sourceParams.push(name);\n }\n if (sourceParams.length === 0) {\n ts.forEachChild(node, visitFn);\n return;\n }\n\n const body = fnNode.body;\n if (!body) {\n ts.forEachChild(node, visitFn);\n return;\n }\n\n const sinkKinds = new Set\u003cstring>();\n let hasValidation = false;\n const callsWithInputArgs: Array\u003c{ callee: string; lineStart: number }> = [];\n const sourceParamSet = new Set(sourceParams);\n\n const walkBody = (child: ts.Node): void => {\n if (isFunctionLike(child) && child !== node) return;\n\n if (ts.isCallExpression(child)) {\n const callText = child.expression.getText(sourceFile);\n for (const sink of SINK_CALL_PATTERNS) {\n if (sink.pattern.test(callText)) {\n sinkKinds.add(sink.kind);\n break;\n }\n }\n if (\n SCHEMA_VALIDATOR_PATTERNS.test(callText) ||\n VALIDATOR_LIB_PATTERNS.test(callText)\n ) {\n hasValidation = true;\n }\n for (const arg of child.arguments) {\n const argText = arg.getText(sourceFile);\n for (const sp of sourceParamSet) {\n if (\n argText === sp ||\n argText.startsWith(sp + '.') ||\n argText.startsWith(sp + '[')\n ) {\n const loc = getLineAndCharacter(sourceFile, child);\n callsWithInputArgs.push({\n callee: callText,\n lineStart: loc.lineStart,\n });\n break;\n }\n }\n }\n }\n\n if (ts.isTypeOfExpression(child)) {\n const operand = child.expression.getText(sourceFile);\n if (sourceParamSet.has(operand)) hasValidation = true;\n }\n\n if (\n ts.isPrefixUnaryExpression(child) &&\n child.operator === ts.SyntaxKind.ExclamationToken\n ) {\n const operand = child.operand.getText(sourceFile);\n if (sourceParamSet.has(operand)) hasValidation = true;\n }\n\n if (ts.isIfStatement(child) || ts.isConditionalExpression(child)) {\n const cond = ts.isIfStatement(child)\n ? child.expression\n : child.condition;\n const condText = cond.getText(sourceFile);\n for (const sp of sourceParamSet) {\n if (condText.includes(sp)) {\n hasValidation = true;\n break;\n }\n }\n }\n\n if (\n ts.isCallExpression(child) &&\n child.expression.getText(sourceFile).endsWith('instanceof')\n ) {\n hasValidation = true;\n }\n\n if (\n ts.isBinaryExpression(child) &&\n child.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword\n ) {\n const leftText = child.left.getText(sourceFile);\n if (sourceParamSet.has(leftText)) hasValidation = true;\n }\n\n ts.forEachChild(child, walkBody);\n };\n ts.forEachChild(body, walkBody);\n\n if (ts.isTemplateExpression(body) || ts.isBlock(body)) {\n const bodyText = body.getText(sourceFile);\n for (const sp of sourceParamSet) {\n if (bodyText.includes(sp + '?.')) {\n hasValidation = true;\n break;\n }\n }\n }\n\n const fnLoc = getLineAndCharacter(sourceFile, node);\n const fnName = getFunctionName(node, sourceFile);\n inputSources.push({\n functionName: fnName,\n lineStart: fnLoc.lineStart,\n lineEnd: fnLoc.lineEnd,\n sourceParams,\n hasSinkInBody: sinkKinds.size > 0,\n sinkKinds: [...sinkKinds],\n hasValidation,\n callsWithInputArgs,\n paramConfidence: getParamConfidence(sourceParams),\n });\n\n ts.forEachChild(node, visitFn);\n };\n ts.forEachChild(sourceFile, visitFn);\n\n fileEntry.inputSources = inputSources;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":6456,"content_sha256":"0e364febec2f11c542dcc4bb437ff54f1effb1ebd7add45a69195523dd02ac77"},{"filename":"src/collectors/performance.test.ts","content":"import * as ts from 'typescript';\nimport { describe, expect, it } from 'vitest';\n\nimport { collectPerformanceData } from './performance.js';\n\nimport type { FileEntry } from '../types/index.js';\n\nfunction parse(code: string, fileName = '/repo/src/test.ts'): ts.SourceFile {\n return ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true);\n}\n\nfunction emptyFileEntry(): FileEntry {\n return {\n package: 'test',\n file: 'test.ts',\n parseEngine: 'typescript',\n nodeCount: 0,\n kindCounts: {},\n functions: [],\n flows: [],\n dependencyProfile: {\n internalDependencies: [],\n externalDependencies: [],\n unresolvedDependencies: [],\n declaredExports: [],\n importedSymbols: [],\n reExports: [],\n },\n };\n}\n\ndescribe('collectPerformanceData', () => {\n it('collects await-in-loop locations', () => {\n const sourceFile = parse(`\n async function run(items: string[]) {\n for (const item of items) {\n await fetch(item);\n }\n }\n `);\n const fileEntry = emptyFileEntry();\n collectPerformanceData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.awaitInLoopLocations?.length).toBeGreaterThan(0);\n });\n\n it('collects sync io calls', () => {\n const sourceFile = parse(\n `function read() { fs.readFileSync('/tmp/a.txt'); }`\n );\n const fileEntry = emptyFileEntry();\n collectPerformanceData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.syncIoCalls?.some(c => c.name === 'readFileSync')).toBe(\n true\n );\n });\n\n it('collects timer calls and marks cleanup when clearTimeout exists', () => {\n const sourceFile = parse(`\n function run() {\n setTimeout(() => {}, 5);\n clearTimeout(1 as unknown as NodeJS.Timeout);\n }\n `);\n const fileEntry = emptyFileEntry();\n collectPerformanceData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.timerCalls?.length).toBe(1);\n expect(fileEntry.timerCalls?.[0].kind).toBe('setTimeout');\n expect(fileEntry.timerCalls?.[0].hasCleanup).toBe(true);\n });\n\n it('collects listener registrations and removals', () => {\n const sourceFile = parse(`\n const emitter = new EventEmitter();\n emitter.on('data', () => {});\n emitter.off('data', () => {});\n `);\n const fileEntry = emptyFileEntry();\n collectPerformanceData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.listenerRegistrations?.length).toBeGreaterThan(0);\n expect(fileEntry.listenerRemovals?.length).toBeGreaterThan(0);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":2524,"content_sha256":"d54155661c32aa7a8ce4ea3506cec088f09985471869eb90091661a0b4e80983"},{"filename":"src/collectors/performance.ts","content":"import * as ts from 'typescript';\n\nimport { blockContainsCall, findParentBlock } from './effects.js';\nimport { isFunctionLike } from '../ast/helpers.js';\nimport { getLineAndCharacter } from '../common/utils.js';\n\nimport type { CodeLocation, FileEntry, TimerCall } from '../types/index.js';\n\nconst SYNC_IO_METHODS = new Set([\n 'readFileSync',\n 'writeFileSync',\n 'existsSync',\n 'mkdirSync',\n 'readdirSync',\n 'statSync',\n 'lstatSync',\n 'unlinkSync',\n 'rmdirSync',\n 'renameSync',\n 'copyFileSync',\n 'accessSync',\n 'appendFileSync',\n 'chmodSync',\n 'chownSync',\n 'openSync',\n 'closeSync',\n 'execSync',\n 'execFileSync',\n 'spawnSync',\n]);\n\nexport function collectPerformanceData(\n sourceFile: ts.SourceFile,\n fileRelative: string,\n fileEntry: FileEntry\n): void {\n const awaitInLoopLocations: CodeLocation[] = [];\n const syncIoCalls: Array\u003c{\n name: string;\n lineStart: number;\n lineEnd: number;\n }> = [];\n const timerCalls: TimerCall[] = [];\n const listenerRegistrations: CodeLocation[] = [];\n const listenerRemovals: CodeLocation[] = [];\n\n const isInsideLoop = (node: ts.Node): boolean => {\n let current = node.parent;\n while (current) {\n if (\n ts.isForStatement(current) ||\n ts.isWhileStatement(current) ||\n ts.isDoStatement(current) ||\n ts.isForOfStatement(current) ||\n ts.isForInStatement(current)\n )\n return true;\n if (isFunctionLike(current)) return false;\n current = current.parent;\n }\n return false;\n };\n\n const visit = (node: ts.Node): void => {\n if (ts.isAwaitExpression(node) && isInsideLoop(node)) {\n const loc = getLineAndCharacter(sourceFile, node);\n awaitInLoopLocations.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n\n if (\n ts.isCallExpression(node) &&\n ts.isPropertyAccessExpression(node.expression)\n ) {\n const methodName = node.expression.name.getText(sourceFile);\n if (SYNC_IO_METHODS.has(methodName)) {\n const loc = getLineAndCharacter(sourceFile, node);\n syncIoCalls.push({\n name: methodName,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n if (\n methodName === 'addEventListener' ||\n methodName === 'on' ||\n methodName === 'addListener'\n ) {\n const loc = getLineAndCharacter(sourceFile, node);\n listenerRegistrations.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n if (\n methodName === 'removeEventListener' ||\n methodName === 'off' ||\n methodName === 'removeListener'\n ) {\n const loc = getLineAndCharacter(sourceFile, node);\n listenerRemovals.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n\n if (ts.isCallExpression(node)) {\n const text = node.expression.getText(sourceFile);\n if (text === 'setInterval' || text === 'setTimeout') {\n const loc = getLineAndCharacter(sourceFile, node);\n const clearName =\n text === 'setInterval' ? 'clearInterval' : 'clearTimeout';\n const parentBlock = findParentBlock(node);\n const hasCleanup = parentBlock\n ? blockContainsCall(parentBlock, sourceFile, clearName)\n : false;\n timerCalls.push({\n kind: text as 'setInterval' | 'setTimeout',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n hasCleanup,\n });\n }\n }\n\n ts.forEachChild(node, visit);\n };\n ts.forEachChild(sourceFile, visit);\n\n fileEntry.awaitInLoopLocations = awaitInLoopLocations;\n fileEntry.syncIoCalls = syncIoCalls;\n fileEntry.timerCalls = timerCalls;\n fileEntry.listenerRegistrations = listenerRegistrations;\n fileEntry.listenerRemovals = listenerRemovals;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":3945,"content_sha256":"47d4b48e6dd67b1eb9c9b850a9c3cb27fb066e9a886ad4f544d90591f7259791"},{"filename":"src/collectors/prototype-pollution.test.ts","content":"import * as ts from 'typescript';\nimport { describe, expect, it } from 'vitest';\n\nimport { collectPrototypePollutionSites } from './prototype-pollution.js';\n\nfunction parse(code: string, fileName = '/repo/src/test.ts'): ts.SourceFile {\n return ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true);\n}\n\ndescribe('collectPrototypePollutionSites', () => {\n it('detects Object.assign risk', () => {\n const sourceFile = parse(`\n function merge(a: any, b: any) {\n Object.assign(a, b);\n }\n `);\n const sites = collectPrototypePollutionSites(sourceFile);\n expect(sites.some(s => s.kind === 'object-assign')).toBe(true);\n });\n\n it('detects deep merge risk', () => {\n const sourceFile = parse(`\n function merge(a: any, b: any) {\n deepMerge(a, b);\n }\n `);\n const sites = collectPrototypePollutionSites(sourceFile);\n expect(sites.some(s => s.kind === 'deep-merge')).toBe(true);\n });\n\n it('detects dynamic bracket assignment', () => {\n const sourceFile = parse(`\n function write(obj: Record\u003cstring, unknown>, key: string, val: unknown) {\n obj[key] = val;\n }\n `);\n const sites = collectPrototypePollutionSites(sourceFile);\n expect(sites.some(s => s.kind === 'computed-property-write')).toBe(true);\n });\n\n it('marks guarded writes when iterating known internal keys', () => {\n const sourceFile = parse(`\n function copy(dst: Record\u003cstring, unknown>, src: Record\u003cstring, unknown>) {\n for (const key of Object.keys(src)) {\n dst[key] = src[key];\n }\n }\n `);\n const sites = collectPrototypePollutionSites(sourceFile);\n const guarded = sites.filter(\n s => s.kind === 'computed-property-write' && s.guarded\n );\n expect(guarded.length).toBeGreaterThan(0);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":1804,"content_sha256":"64acfb955352bbf17450ec79ec24f1517c60c49af8dd781a438aa77adbd8f0ab"},{"filename":"src/collectors/prototype-pollution.ts","content":"import * as ts from 'typescript';\n\nimport { findParentBlock } from './effects.js';\nimport { isFunctionLike } from '../ast/helpers.js';\nimport { getLineAndCharacter } from '../common/utils.js';\n\nconst DEEP_MERGE_NAMES = new Set([\n 'merge',\n 'deepMerge',\n 'deepAssign',\n 'extend',\n 'deepExtend',\n 'defaults',\n 'defaultsDeep',\n 'assign',\n 'mixin',\n]);\n\n/** Check if a computed-property-write key comes from a for..of/for..in loop over known internal iteration */\nfunction isKeyFromInternalIteration(\n node: ts.ElementAccessExpression,\n sourceFile: ts.SourceFile\n): boolean {\n const keyExpr = node.argumentExpression;\n if (!keyExpr || !ts.isIdentifier(keyExpr)) return false;\n const keyName = keyExpr.getText(sourceFile);\n\n let current: ts.Node | undefined = node.parent;\n while (current) {\n if (ts.isForOfStatement(current) || ts.isForInStatement(current)) {\n const init = current.initializer;\n if (init) {\n const initText = init.getText(sourceFile);\n if (initText.includes(keyName)) {\n const expr = current.expression.getText(sourceFile);\n if (\n /Object\\.(keys|values|entries|getOwnPropertyNames)\\(/.test(expr) ||\n /\\.keys\\(\\)|\\.values\\(\\)|\\.entries\\(\\)/.test(expr) ||\n /Array\\.from\\(/.test(expr)\n ) {\n return true;\n }\n }\n }\n }\n if (isFunctionLike(current)) break;\n current = current.parent;\n }\n return false;\n}\n\n/** Check if the containing block has a __proto__/constructor/prototype key guard */\nfunction hasProtoKeyGuard(node: ts.Node, sourceFile: ts.SourceFile): boolean {\n const block = findParentBlock(node);\n if (!block) return false;\n const blockText = block.getText(sourceFile);\n return (\n /__proto__|constructor|prototype/.test(blockText) &&\n (blockText.includes('===') ||\n blockText.includes('!==') ||\n blockText.includes('includes(') ||\n blockText.includes('hasOwnProperty'))\n );\n}\n\n/** Check if the target object was created with Object.create(null) or is Map/Set */\nfunction isTargetSafeObject(\n node: ts.ElementAccessExpression,\n sourceFile: ts.SourceFile\n): boolean {\n const objText = node.expression.getText(sourceFile);\n let current: ts.Node | undefined = node.parent;\n while (current) {\n if (ts.isBlock(current) || ts.isSourceFile(current)) {\n const text = current.getText(sourceFile);\n const createNullPattern = new RegExp(\n `${objText.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\{head-tags}')}\\\\s*=\\\\s*Object\\\\.create\\\\(null\\\\)`\n );\n const mapSetPattern = new RegExp(\n `${objText.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\{head-tags}')}\\\\s*=\\\\s*new\\\\s+(Map|Set)\\\\b`\n );\n if (createNullPattern.test(text) || mapSetPattern.test(text)) return true;\n break;\n }\n current = current.parent;\n }\n return false;\n}\n\nexport function collectPrototypePollutionSites(\n sourceFile: ts.SourceFile\n): Array\u003c{\n kind: string;\n detail: string;\n lineStart: number;\n lineEnd: number;\n guarded: boolean;\n}> {\n const sites: Array\u003c{\n kind: string;\n detail: string;\n lineStart: number;\n lineEnd: number;\n guarded: boolean;\n }> = [];\n\n const visit = (node: ts.Node): void => {\n if (ts.isCallExpression(node)) {\n const text = node.expression.getText(sourceFile);\n if (text === 'Object.assign' && node.arguments.length >= 2) {\n const loc = getLineAndCharacter(sourceFile, node);\n sites.push({\n kind: 'object-assign',\n detail: `Object.assign() merges properties without __proto__ guard`,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n guarded: false,\n });\n }\n const calleeName = text.split('.').pop() || '';\n if (DEEP_MERGE_NAMES.has(calleeName) && node.arguments.length >= 1) {\n const loc = getLineAndCharacter(sourceFile, node);\n sites.push({\n kind: 'deep-merge',\n detail: `${calleeName}() deep-merges without prototype guard`,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n guarded: false,\n });\n }\n }\n\n if (\n ts.isElementAccessExpression(node) &&\n node.argumentExpression &&\n !ts.isStringLiteral(node.argumentExpression) &&\n !ts.isNumericLiteral(node.argumentExpression) &&\n node.parent &&\n ts.isBinaryExpression(node.parent) &&\n node.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&\n node.parent.left === node\n ) {\n const guarded =\n isKeyFromInternalIteration(node, sourceFile) ||\n hasProtoKeyGuard(node, sourceFile) ||\n isTargetSafeObject(node, sourceFile);\n const loc = getLineAndCharacter(sourceFile, node);\n sites.push({\n kind: 'computed-property-write',\n detail: `Dynamic bracket assignment: ${node.getText(sourceFile).slice(0, 40)}`,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n guarded,\n });\n }\n\n ts.forEachChild(node, visit);\n };\n\n ts.forEachChild(sourceFile, visit);\n return sites;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":5033,"content_sha256":"765c5342cacec25c5556c5c8b8e70f37f0c3b7092a73e60bd83ea95eb971420c"},{"filename":"src/collectors/security.test.ts","content":"import * as ts from 'typescript';\nimport { describe, expect, it } from 'vitest';\n\nimport { collectSecurityData } from './security.js';\n\nimport type { FileEntry } from '../types/index.js';\n\nfunction parse(code: string, fileName = '/repo/src/test.ts'): ts.SourceFile {\n return ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true);\n}\n\nfunction emptyFileEntry(): FileEntry {\n return {\n package: 'test',\n file: 'test.ts',\n parseEngine: 'typescript',\n nodeCount: 0,\n kindCounts: {},\n functions: [],\n flows: [],\n dependencyProfile: {\n internalDependencies: [],\n externalDependencies: [],\n unresolvedDependencies: [],\n declaredExports: [],\n importedSymbols: [],\n reExports: [],\n },\n };\n}\n\ndescribe('collectSecurityData', () => {\n it('detects eval usage - eval(\"1\") → evalUsages contains entry', () => {\n const code = `eval(\"1\");`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.evalUsages).toBeDefined();\n expect(fileEntry.evalUsages).toHaveLength(1);\n expect(fileEntry.evalUsages![0].file).toBe('test.ts');\n expect(fileEntry.evalUsages![0].lineStart).toBeGreaterThan(0);\n });\n\n it('detects hardcoded secret pattern - API_KEY = \"sk-proj-...\" → suspiciousStrings', () => {\n const code = `const API_KEY = \"sk-proj-abc123def456ghi789\";`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.suspiciousStrings).toBeDefined();\n expect(fileEntry.suspiciousStrings!.length).toBeGreaterThan(0);\n const secretEntry = fileEntry.suspiciousStrings!.find(\n s => s.kind === 'hardcoded-secret'\n );\n expect(secretEntry).toBeDefined();\n expect(secretEntry!.context).toBe('literal');\n });\n\n it('detects SQL injection risk - template literal with SQL keyword and interpolation', () => {\n const code = 'const q = `SELECT * FROM users WHERE id = ${id}`;';\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.suspiciousStrings).toBeDefined();\n const sqlEntry = fileEntry.suspiciousStrings!.find(\n s => s.kind === 'sql-injection'\n );\n expect(sqlEntry).toBeDefined();\n expect(sqlEntry!.snippet).toMatch(/SELECT/i);\n });\n\n it('collects regex literal including potentially unsafe patterns', () => {\n const code = 'const r = /(a+)+/;';\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.regexLiterals).toBeDefined();\n expect(fileEntry.regexLiterals!.length).toBeGreaterThan(0);\n expect(fileEntry.regexLiterals![0].pattern).toContain('a');\n });\n\n it('no false positive for regex in definition context - regex with secret keyword gets regex-definition', () => {\n const code = 'const re = /api_key=\"\"/;';\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.suspiciousStrings).toBeDefined();\n const entry = fileEntry.suspiciousStrings!.find(\n s => s.context === 'regex-definition'\n );\n expect(entry).toBeDefined();\n expect(entry!.kind).toBe('hardcoded-secret');\n });\n\n it('clean code produces no suspicious strings', () => {\n const code = `const x = 1; const msg = \"hello\"; function foo() { return 2; }`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.suspiciousStrings).toBeDefined();\n expect(fileEntry.suspiciousStrings!.length).toBe(0);\n expect(fileEntry.evalUsages).toHaveLength(0);\n });\n\n it('template literal with SQL keyword', () => {\n const code = 'const sql = `INSERT INTO users (name) VALUES (${name})`;';\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.suspiciousStrings).toBeDefined();\n const sqlEntry = fileEntry.suspiciousStrings!.find(\n s => s.kind === 'sql-injection'\n );\n expect(sqlEntry).toBeDefined();\n });\n\n it('process.env access is not flagged as secret', () => {\n const code = 'const key = process.env.API_KEY;';\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.suspiciousStrings).toBeDefined();\n expect(fileEntry.suspiciousStrings!.length).toBe(0);\n });\n\n it('does not mark generic auth/session logs as sensitive without secret values', () => {\n const code = `\n console.log(\"auth flow started\");\n console.info(\"session refreshed successfully\");\n console.warn(\"user auth status changed\");\n `;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.consoleLogs).toBeDefined();\n expect(fileEntry.consoleLogs).toHaveLength(3);\n expect(fileEntry.consoleLogs!.every(log => log.hasSensitiveArg === false)).toBe(\n true\n );\n });\n\n it('marks token-bearing log calls as sensitive', () => {\n const code = `\n const token = \"abc123\";\n console.log(\"token\", token);\n `;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.consoleLogs).toBeDefined();\n expect(fileEntry.consoleLogs).toHaveLength(1);\n expect(fileEntry.consoleLogs![0].hasSensitiveArg).toBe(true);\n });\n\n it('does not mark CLI usage/help templates as sensitive token logs', () => {\n const code = `\n console.error(\\`Unknown \\${flagName}: \"\\${token}\". Use pillar names\\`);\n console.log(\\`\n Usage:\n node scripts/run.js [options]\n Options:\n --root \u003cpath>\n \\`);\n `;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.consoleLogs).toBeDefined();\n expect(fileEntry.consoleLogs).toHaveLength(2);\n expect(fileEntry.consoleLogs![0].hasSensitiveArg).toBe(false);\n expect(fileEntry.consoleLogs![1].hasSensitiveArg).toBe(false);\n });\n\n it('does not flag high-entropy literals without secret-like identifier context', () => {\n const code = `\n const traceId = \"a9F3kLmN2pQr8sTuVwX4yZaB6cDe7fGh\";\n `;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.suspiciousStrings).toBeDefined();\n expect(fileEntry.suspiciousStrings!.length).toBe(0);\n });\n\n it('flags high-entropy literals when assigned to secret-like identifiers', () => {\n const code = `\n const apiToken = \"a9F3kLmN2pQr8sTuVwX4yZaB6cDe7fGh\";\n `;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectSecurityData(sourceFile, 'test.ts', fileEntry);\n expect(fileEntry.suspiciousStrings).toBeDefined();\n const secretEntry = fileEntry.suspiciousStrings!.find(\n s => s.kind === 'hardcoded-secret'\n );\n expect(secretEntry).toBeDefined();\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":7392,"content_sha256":"dddb8ab0b1d51547d9f91ca4cdb42b104cff70a2e778635c3b44a48cb97fe1d1"},{"filename":"src/collectors/security.ts","content":"import * as ts from 'typescript';\n\nimport { getLineAndCharacter } from '../common/utils.js';\n\nimport type { CodeLocation, ConsoleLogEntry, FileEntry, SuspiciousString } from '../types/index.js';\n\nconst HIGH_CONFIDENCE_SENSITIVE_LOG_PATTERNS = [\n /password/i,\n /passwd/i,\n /\\bsecret\\b/i,\n /\\btoken\\b/i,\n /credential/i,\n /credit.?card/i,\n /\\bssn\\b/i,\n /social.?security/i,\n /api[_-]?key/i,\n /private[_-]?key/i,\n /access[_-]?key/i,\n];\n\nconst LOW_CONFIDENCE_SENSITIVE_LOG_PATTERNS = [\n /\\bauth\\b/i,\n /\\bsession\\b/i,\n];\n\nconst NON_SECRET_AUTH_SESSION_CONTEXT =\n /\\b(auth|session)\\b.{0,40}\\b(flow|status|state|start(?:ed)?|success(?:ful|fully)?|fail(?:ed|ure)?|refresh(?:ed)?|renew(?:ed)?|expire(?:d)?|invalid|chang(?:e|ed)|required|created|destroyed)\\b/i;\nconst AUTH_SESSION_VALUE_HINT =\n /\\b(id|sid|jwt|bearer|cookie|header|authorization|credential|secret|token|key)\\b|[:=]|\\{|\\}/i;\nconst NON_SECRET_USAGE_HINT =\n /\\busage:\\b|\\boptions:\\b|--[a-z0-9-]+|\\bunknown\\b.{0,20}\\btoken\\b|\\bpillar names?\\b|\\bcategory names?\\b/i;\n\nconst SECRET_CONTEXT_NAME_PATTERN =\n /(password|passwd|secret|token|api[_-]?key|private[_-]?key|access[_-]?key|credential|auth|session|jwt|bearer|ssn)/i;\n\nconst CONSOLE_LOG_METHODS = new Set([\n 'log', 'debug', 'trace', 'info', 'warn', 'error', 'dir', 'table',\n]);\n\nconst SECRET_PATTERNS = [\n /password\\s*[:=]\\s*['\"`]/i,\n /api[_-]?key\\s*[:=]\\s*['\"`]/i,\n /secret\\s*[:=]\\s*['\"`]/i,\n /token\\s*[:=]\\s*['\"`]/i,\n /-----BEGIN.*KEY/,\n /private[_-]?key\\s*[:=]\\s*['\"`]/i,\n /auth[_-]?token\\s*[:=]\\s*['\"`]/i,\n];\n\nconst SQL_KEYWORDS =\n /\\b(SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE)\\b/i;\n\n/** Strings that look like placeholders, not real secrets */\nconst PLACEHOLDER_PATTERN = /^(YOUR_|REPLACE_ME|\u003c[a-z_-]+>|\\$\\{|{{)/i;\n/** UUID pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx */\nconst UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nfunction isInsideRegexLiteral(node: ts.Node): boolean {\n let current: ts.Node | undefined = node.parent;\n while (current) {\n if (ts.isRegularExpressionLiteral(current)) return true;\n if (\n ts.isNewExpression(current) &&\n current.expression.getText(node.getSourceFile()) === 'RegExp'\n )\n return true;\n current = current.parent;\n }\n return false;\n}\n\nfunction isPlaceholderOrUuid(value: string): boolean {\n return PLACEHOLDER_PATTERN.test(value) || UUID_PATTERN.test(value);\n}\n\n/** Skip strings inside finding metadata fields (suggestedFix, reason, impact, etc.) */\nconst METADATA_PROP_NAMES = new Set([\n 'suggestedFix',\n 'strategy',\n 'steps',\n 'reason',\n 'impact',\n 'expectedResult',\n 'title',\n]);\n\nfunction isInsideMetadataProperty(node: ts.Node): boolean {\n let current: ts.Node | undefined = node.parent;\n while (current) {\n if (ts.isPropertyAssignment(current) && ts.isIdentifier(current.name)) {\n if (METADATA_PROP_NAMES.has(current.name.text)) return true;\n }\n current = current.parent;\n }\n return false;\n}\nfunction computeShannonEntropy(s: string): number {\n const freq = new Map\u003cstring, number>();\n for (const ch of s) freq.set(ch, (freq.get(ch) || 0) + 1);\n let entropy = 0;\n for (const count of freq.values()) {\n const p = count / s.length;\n if (p > 0) entropy -= p * Math.log2(p);\n }\n return entropy;\n}\n\nfunction hasSecretLikeIdentifierContext(\n node: ts.Node,\n sourceFile: ts.SourceFile\n): boolean {\n const parent = node.parent;\n if (ts.isVariableDeclaration(parent)) {\n if (ts.isIdentifier(parent.name)) {\n return SECRET_CONTEXT_NAME_PATTERN.test(parent.name.text);\n }\n return false;\n }\n if (ts.isPropertyAssignment(parent)) {\n if (ts.isIdentifier(parent.name)) {\n return SECRET_CONTEXT_NAME_PATTERN.test(parent.name.text);\n }\n if (ts.isStringLiteral(parent.name) || ts.isNumericLiteral(parent.name)) {\n return SECRET_CONTEXT_NAME_PATTERN.test(parent.name.text);\n }\n }\n if (ts.isBinaryExpression(parent) && ts.isPropertyAccessExpression(parent.left)) {\n return SECRET_CONTEXT_NAME_PATTERN.test(parent.left.name.getText(sourceFile));\n }\n return false;\n}\n\nfunction hasSensitiveLogArgument(argText: string): boolean {\n if (NON_SECRET_USAGE_HINT.test(argText)) return false;\n if (HIGH_CONFIDENCE_SENSITIVE_LOG_PATTERNS.some(p => p.test(argText))) {\n return true;\n }\n const hasLowConfidenceTerm = LOW_CONFIDENCE_SENSITIVE_LOG_PATTERNS.some(p =>\n p.test(argText)\n );\n if (!hasLowConfidenceTerm) return false;\n if (NON_SECRET_AUTH_SESSION_CONTEXT.test(argText)) return false;\n return AUTH_SESSION_VALUE_HINT.test(argText);\n}\n\nexport function collectSecurityData(\n sourceFile: ts.SourceFile,\n fileRelative: string,\n fileEntry: FileEntry\n): void {\n const evalUsages: CodeLocation[] = [];\n const unsafeHtmlAssignments: CodeLocation[] = [];\n const suspiciousStrings: SuspiciousString[] = [];\n const consoleLogs: ConsoleLogEntry[] = [];\n const regexLiterals: Array\u003c{\n lineStart: number;\n lineEnd: number;\n pattern: string;\n }> = [];\n\n const visit = (node: ts.Node): void => {\n if (ts.isDebuggerStatement(node)) {\n const loc = getLineAndCharacter(sourceFile, node);\n consoleLogs.push({\n method: 'debugger',\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n hasSensitiveArg: false,\n });\n }\n\n if (ts.isCallExpression(node)) {\n const expr = node.expression;\n if (ts.isPropertyAccessExpression(expr)) {\n const obj = expr.expression.getText(sourceFile);\n const method = expr.name.getText(sourceFile);\n if (obj === 'console' && CONSOLE_LOG_METHODS.has(method)) {\n const loc = getLineAndCharacter(sourceFile, node);\n const argText = node.arguments.map(a => a.getText(sourceFile)).join(' ');\n const hasSensitiveArg = hasSensitiveLogArgument(argText);\n consoleLogs.push({\n method,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n hasSensitiveArg,\n argSnippet: argText.slice(0, 80),\n });\n }\n }\n }\n\n if (ts.isCallExpression(node)) {\n const text = node.expression.getText(sourceFile);\n if (text === 'eval' || text === 'Function') {\n const loc = getLineAndCharacter(sourceFile, node);\n evalUsages.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n if (text === 'new Function') {\n const loc = getLineAndCharacter(sourceFile, node);\n evalUsages.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n if (\n (text === 'setTimeout' || text === 'setInterval') &&\n node.arguments.length > 0\n ) {\n const firstArg = node.arguments[0];\n if (\n ts.isStringLiteral(firstArg) ||\n ts.isNoSubstitutionTemplateLiteral(firstArg)\n ) {\n const loc = getLineAndCharacter(sourceFile, node);\n evalUsages.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n if (text === 'document.write' || text === 'document.writeln') {\n const loc = getLineAndCharacter(sourceFile, node);\n unsafeHtmlAssignments.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n\n if (\n ts.isNewExpression(node) &&\n node.expression.getText(sourceFile) === 'Function'\n ) {\n const loc = getLineAndCharacter(sourceFile, node);\n evalUsages.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n\n if (\n ts.isBinaryExpression(node) &&\n node.operatorToken.kind === ts.SyntaxKind.EqualsToken\n ) {\n if (ts.isPropertyAccessExpression(node.left)) {\n const prop = node.left.name.getText(sourceFile);\n if (prop === 'innerHTML' || prop === 'outerHTML') {\n const loc = getLineAndCharacter(sourceFile, node);\n unsafeHtmlAssignments.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n }\n\n if (\n ts.isJsxAttribute(node) &&\n node.name.getText(sourceFile) === 'dangerouslySetInnerHTML'\n ) {\n const loc = getLineAndCharacter(sourceFile, node);\n unsafeHtmlAssignments.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n\n if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {\n if (!isInsideMetadataProperty(node) && !isInsideRegexLiteral(node)) {\n const value = node.text;\n if (!isPlaceholderOrUuid(value)) {\n let matchedSecretPattern = false;\n for (const pattern of SECRET_PATTERNS) {\n if (pattern.test(value)) {\n const loc = getLineAndCharacter(sourceFile, node);\n suspiciousStrings.push({\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n kind: 'hardcoded-secret',\n snippet: value.slice(0, 40),\n context: 'literal',\n });\n matchedSecretPattern = true;\n break;\n }\n }\n if (\n !matchedSecretPattern &&\n value.length >= 20 &&\n computeShannonEntropy(value) > 4.5 &&\n hasSecretLikeIdentifierContext(node, sourceFile)\n ) {\n const loc = getLineAndCharacter(sourceFile, node);\n suspiciousStrings.push({\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n kind: 'hardcoded-secret',\n context: 'literal',\n });\n }\n }\n }\n }\n\n if (ts.isRegularExpressionLiteral(node)) {\n const regexText = node.getText(sourceFile);\n for (const pattern of SECRET_PATTERNS) {\n if (pattern.test(regexText)) {\n const loc = getLineAndCharacter(sourceFile, node);\n suspiciousStrings.push({\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n kind: 'hardcoded-secret',\n snippet: regexText.slice(0, 40),\n context: 'regex-definition',\n });\n break;\n }\n }\n }\n\n if (ts.isTemplateExpression(node)) {\n if (!isInsideMetadataProperty(node)) {\n const fullText = node.getText(sourceFile);\n if (SQL_KEYWORDS.test(fullText) && node.templateSpans.length > 0) {\n const loc = getLineAndCharacter(sourceFile, node);\n suspiciousStrings.push({\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n kind: 'sql-injection',\n snippet: fullText.slice(0, 60),\n });\n }\n }\n }\n\n if (ts.isRegularExpressionLiteral(node)) {\n const pattern = node.text;\n const loc = getLineAndCharacter(sourceFile, node);\n regexLiterals.push({\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n pattern,\n });\n }\n\n ts.forEachChild(node, visit);\n };\n ts.forEachChild(sourceFile, visit);\n\n fileEntry.evalUsages = evalUsages;\n fileEntry.unsafeHtmlAssignments = unsafeHtmlAssignments;\n fileEntry.suspiciousStrings = suspiciousStrings;\n fileEntry.consoleLogs = consoleLogs;\n fileEntry.regexLiterals = regexLiterals;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":11476,"content_sha256":"456e3bd112105de84c8af02d5be754438631ff22e472e4a9bd1c214b45956ceb"},{"filename":"src/collectors/test-profile.test.ts","content":"import * as ts from 'typescript';\nimport { describe, expect, it } from 'vitest';\n\nimport { collectTestProfile } from './test-profile.js';\n\nimport type { FileEntry } from '../types/index.js';\n\nfunction parse(\n code: string,\n fileName = '/repo/src/test.spec.ts'\n): ts.SourceFile {\n return ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true);\n}\n\nfunction emptyFileEntry(): FileEntry {\n return {\n package: 'test',\n file: 'test.spec.ts',\n parseEngine: 'typescript',\n nodeCount: 0,\n kindCounts: {},\n functions: [],\n flows: [],\n dependencyProfile: {\n internalDependencies: [],\n externalDependencies: [],\n unresolvedDependencies: [],\n declaredExports: [],\n importedSymbols: [],\n reExports: [],\n },\n };\n}\n\ndescribe('collectTestProfile', () => {\n it('describe + it blocks → detects test blocks', () => {\n const code = `describe('suite', () => { it('test', () => {}); });`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectTestProfile(sourceFile, 'test.spec.ts', fileEntry);\n expect(fileEntry.testProfile).toBeDefined();\n expect(fileEntry.testProfile!.testBlocks).toHaveLength(1);\n expect(fileEntry.testProfile!.testBlocks[0].name).toBe('test');\n });\n\n it('expect().toBe() → detects assertions', () => {\n const code = `it('asserts', () => { expect(1).toBe(1); });`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectTestProfile(sourceFile, 'test.spec.ts', fileEntry);\n expect(fileEntry.testProfile).toBeDefined();\n expect(fileEntry.testProfile!.testBlocks).toHaveLength(1);\n expect(fileEntry.testProfile!.testBlocks[0].assertionCount).toBeGreaterThan(\n 0\n );\n });\n\n it('jest.mock() → detects mocks', () => {\n const code = `jest.mock('./module'); describe('suite', () => { it('test', () => {}); });`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectTestProfile(sourceFile, 'test.spec.ts', fileEntry);\n expect(fileEntry.testProfile).toBeDefined();\n expect(fileEntry.testProfile!.mockCalls).toHaveLength(1);\n });\n\n it('vi.spyOn() → detects spy', () => {\n const code = `const obj = { method: () => {} }; describe('suite', () => { it('test', () => { vi.spyOn(obj, 'method'); }); });`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectTestProfile(sourceFile, 'test.spec.ts', fileEntry);\n expect(fileEntry.testProfile).toBeDefined();\n expect(fileEntry.testProfile!.spyOrStubCalls).toHaveLength(1);\n expect(fileEntry.testProfile!.spyOrStubCalls[0].kind).toBe('spy');\n });\n\n it('beforeEach → detects setup hooks', () => {\n const code = `describe('suite', () => { beforeEach(() => {}); it('test', () => {}); });`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectTestProfile(sourceFile, 'test.spec.ts', fileEntry);\n expect(fileEntry.testProfile).toBeDefined();\n expect(fileEntry.testProfile!.setupCalls).toHaveLength(1);\n expect(fileEntry.testProfile!.setupCalls[0].kind).toBe('beforeEach');\n });\n\n it('no test profile for non-test code', () => {\n const code = `function add(a: number, b: number) { return a + b; }`;\n const sourceFile = parse(code);\n const fileEntry = emptyFileEntry();\n collectTestProfile(sourceFile, 'util.ts', fileEntry);\n expect(fileEntry.testProfile).toBeDefined();\n expect(fileEntry.testProfile!.testBlocks).toHaveLength(0);\n expect(fileEntry.testProfile!.mockCalls).toHaveLength(0);\n expect(fileEntry.testProfile!.setupCalls).toHaveLength(0);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":3632,"content_sha256":"580603600dacd64ca41223e36e6ffc930155f90ea74686cf8157a0f6b1c83790"},{"filename":"src/collectors/test-profile.ts","content":"import * as ts from 'typescript';\n\nimport { getLineAndCharacter } from '../common/utils.js';\n\nimport type {\n CodeLocation,\n FileEntry,\n MockControlCall,\n TestBlock,\n TestProfile,\n} from '../types/index.js';\n\nconst ASSERTION_PATTERNS = new Set(['expect', 'assert', 'should']);\nconst MOCK_PATTERNS = [\n 'jest.mock',\n 'vi.mock',\n 'sinon.stub',\n 'jest.spyOn',\n 'vi.spyOn',\n 'sinon.mock',\n];\nconst RESTORE_PATTERNS = new Set([\n 'jest.restoreAllMocks',\n 'vi.restoreAllMocks',\n]);\nconst SETUP_PATTERNS = new Set([\n 'beforeAll',\n 'beforeEach',\n 'afterAll',\n 'afterEach',\n]);\nconst FOCUSED_PATTERNS = new Set([\n 'it.only',\n 'test.only',\n 'describe.only',\n 'it.skip',\n 'test.skip',\n 'describe.skip',\n 'it.todo',\n 'test.todo',\n]);\nconst USE_FAKE_TIMER_PATTERNS = new Set([\n 'jest.useFakeTimers',\n 'vi.useFakeTimers',\n]);\nconst USE_REAL_TIMER_PATTERNS = new Set([\n 'jest.useRealTimers',\n 'vi.useRealTimers',\n]);\n\nfunction getSpyOrStubKind(\n call: ts.CallExpression,\n sourceFile: ts.SourceFile\n): MockControlCall['kind'] | undefined {\n if (!ts.isPropertyAccessExpression(call.expression)) return undefined;\n const methodName = call.expression.name.getText(sourceFile);\n const receiver = call.expression.expression.getText(sourceFile);\n\n if ((receiver === 'jest' || receiver === 'vi') && methodName === 'spyOn')\n return 'spy';\n if (receiver === 'sinon' && (methodName === 'stub' || methodName === 'mock'))\n return 'stub';\n return undefined;\n}\n\nfunction getMockControlTarget(\n node: ts.Node,\n sourceFile: ts.SourceFile\n): string | undefined {\n let current = node;\n while (current.parent) {\n const parent = current.parent;\n\n if (\n ts.isVariableDeclaration(parent) &&\n parent.initializer === current &&\n ts.isIdentifier(parent.name)\n ) {\n return parent.name.getText(sourceFile);\n }\n\n if (\n ts.isBinaryExpression(parent) &&\n parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&\n parent.right === current\n ) {\n return parent.left.getText(sourceFile).trim();\n }\n\n current = parent;\n }\n\n return undefined;\n}\n\nfunction getMockRestoreTarget(\n node: ts.CallExpression,\n sourceFile: ts.SourceFile\n): string | undefined {\n if (!ts.isPropertyAccessExpression(node.expression)) return undefined;\n return node.expression.expression.getText(sourceFile).trim();\n}\n\nexport function collectTestProfile(\n sourceFile: ts.SourceFile,\n fileRelative: string,\n fileEntry: FileEntry\n): void {\n const testBlocks: TestBlock[] = [];\n const mockCalls: CodeLocation[] = [];\n const setupCalls: TestProfile['setupCalls'] = [];\n const mutableStateDecls: CodeLocation[] = [];\n const focusedCalls: TestProfile['focusedCalls'] = [];\n const timerControls: TestProfile['timerControls'] = [];\n const mockRestores: TestProfile['mockRestores'] = [];\n const spyOrStubCalls: TestProfile['spyOrStubCalls'] = [];\n\n const visit = (\n node: ts.Node,\n insideDescribe: boolean,\n insideTest: boolean\n ): void => {\n if (ts.isCallExpression(node)) {\n const callText = node.expression.getText(sourceFile);\n\n if (FOCUSED_PATTERNS.has(callText)) {\n const loc = getLineAndCharacter(sourceFile, node);\n focusedCalls.push({\n kind: callText as TestProfile['focusedCalls'][0]['kind'],\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n\n if (\n (callText === 'it' ||\n callText === 'test' ||\n callText === 'it.only' ||\n callText === 'test.only') &&\n node.arguments.length >= 2\n ) {\n const nameArg = node.arguments[0];\n const name = ts.isStringLiteral(nameArg) ? nameArg.text : callText;\n const body = node.arguments[1];\n const loc = getLineAndCharacter(sourceFile, node);\n let assertionCount = 0;\n const countAssertions = (n: ts.Node): void => {\n if (ts.isCallExpression(n)) {\n const t = n.expression.getText(sourceFile);\n if (\n ASSERTION_PATTERNS.has(t.split('.')[0]) ||\n t.includes('.to.') ||\n t.includes('.should')\n )\n assertionCount++;\n }\n ts.forEachChild(n, countAssertions);\n };\n ts.forEachChild(body, countAssertions);\n testBlocks.push({\n name,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n assertionCount,\n });\n ts.forEachChild(node, child => visit(child, insideDescribe, true));\n return;\n }\n\n if (\n MOCK_PATTERNS.some(p => callText === p || callText.startsWith(p + '('))\n ) {\n const loc = getLineAndCharacter(sourceFile, node);\n mockCalls.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n const spyOrStubKind = getSpyOrStubKind(node, sourceFile);\n if (spyOrStubKind) {\n spyOrStubCalls.push({\n kind: spyOrStubKind,\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n target: getMockControlTarget(node, sourceFile),\n });\n }\n }\n\n if (USE_FAKE_TIMER_PATTERNS.has(callText)) {\n const loc = getLineAndCharacter(sourceFile, node);\n timerControls.push({\n kind: callText as TestProfile['timerControls'][0]['kind'],\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n\n if (USE_REAL_TIMER_PATTERNS.has(callText)) {\n const loc = getLineAndCharacter(sourceFile, node);\n timerControls.push({\n kind: callText as TestProfile['timerControls'][0]['kind'],\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n\n if (RESTORE_PATTERNS.has(callText)) {\n const loc = getLineAndCharacter(sourceFile, node);\n mockRestores.push({\n kind: 'restoreAll',\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n } else if (callText.endsWith('.mockRestore')) {\n const loc = getLineAndCharacter(sourceFile, node);\n mockRestores.push({\n kind: 'restore',\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n target: getMockRestoreTarget(node, sourceFile),\n });\n }\n\n if (SETUP_PATTERNS.has(callText)) {\n const loc = getLineAndCharacter(sourceFile, node);\n setupCalls.push({\n kind: callText as TestProfile['setupCalls'][0]['kind'],\n lineStart: loc.lineStart,\n });\n }\n\n if (callText === 'describe' || callText === 'describe.only') {\n ts.forEachChild(node, child => visit(child, true, insideTest));\n return;\n }\n }\n\n if (insideDescribe && !insideTest && ts.isVariableStatement(node)) {\n const decl = node.declarationList;\n if (decl.flags & ts.NodeFlags.Let || !(decl.flags & ts.NodeFlags.Const)) {\n const loc = getLineAndCharacter(sourceFile, node);\n mutableStateDecls.push({\n file: fileRelative,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n });\n }\n }\n\n ts.forEachChild(node, child => visit(child, insideDescribe, insideTest));\n };\n\n ts.forEachChild(sourceFile, child => visit(child, false, false));\n\n fileEntry.testProfile = {\n testBlocks,\n mockCalls,\n setupCalls,\n mutableStateDecls,\n focusedCalls,\n timerControls,\n mockRestores,\n spyOrStubCalls,\n };\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":7563,"content_sha256":"492702cbc7404590c2ac9cb964bc1f747beeff6b93c13bfb22526be234070bb4"},{"filename":"src/common/ensure-deps.ts","content":"/**\n * Ensures native and pure-JS dependencies required at runtime are installed.\n *\n * The skill's bundle cannot inline native addons (@ast-grep/napi, tree-sitter\n * variants) — they must exist in the skill's node_modules at runtime. When the\n * skill ships standalone (outside the monorepo's hoisted node_modules), the\n * first run needs to install them. This module detects the user's package\n * manager from lockfiles, runs install against the skill directory, and exits\n * with an actionable message if install fails.\n */\nimport { spawnSync } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport { createRequire } from 'node:module';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst REQUIRED_PACKAGES = [\n 'typescript',\n '@ast-grep/napi',\n '@ast-grep/lang-python',\n 'tree-sitter',\n 'tree-sitter-typescript',\n 'tree-sitter-python',\n];\n\nconst MAX_SKILL_DIR_HOPS = 6;\n\ntype PackageManager = 'pnpm' | 'yarn' | 'npm';\n\ninterface InstallPlan {\n pm: PackageManager;\n cmd: string;\n args: string[];\n humanCommand: string;\n}\n\nfunction detectPackageManager(skillDir: string): PackageManager {\n if (existsSync(join(skillDir, 'pnpm-lock.yaml'))) return 'pnpm';\n if (existsSync(join(skillDir, 'yarn.lock'))) return 'yarn';\n return 'npm';\n}\n\nfunction planInstall(skillDir: string): InstallPlan {\n const pm = detectPackageManager(skillDir);\n switch (pm) {\n case 'pnpm':\n return {\n pm,\n cmd: 'pnpm',\n args: ['install', '--prod=false'],\n humanCommand: 'pnpm install',\n };\n case 'yarn':\n return {\n pm,\n cmd: 'yarn',\n args: ['install'],\n humanCommand: 'yarn install',\n };\n case 'npm':\n return {\n pm,\n cmd: 'npm',\n args: [\n 'install',\n '--prefix',\n skillDir,\n '--no-audit',\n '--no-fund',\n '--legacy-peer-deps',\n ],\n humanCommand: 'npm install --legacy-peer-deps',\n };\n }\n}\n\nfunction findSkillDir(entryUrl: string): string {\n // Walk up from the entry file until we find a package.json — that's the skill root.\n let dir = dirname(fileURLToPath(entryUrl));\n for (let i = 0; i \u003c MAX_SKILL_DIR_HOPS; i++) {\n if (existsSync(join(dir, 'package.json'))) return dir;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n // Fallback: assume scripts/\u003cfile>.js → skill root is one level up.\n return dirname(dirname(fileURLToPath(entryUrl)));\n}\n\nfunction missingPackagesFrom(skillDir: string, entryUrl: string): string[] {\n const nodeModulesDir = join(skillDir, 'node_modules');\n const requireFromEntry = createRequire(entryUrl);\n return REQUIRED_PACKAGES.filter((pkg) => {\n if (existsSync(join(nodeModulesDir, pkg))) return false;\n try {\n requireFromEntry.resolve(pkg, { paths: [skillDir] });\n return false;\n } catch {\n return true;\n }\n });\n}\n\nexport interface EnsureDepsOptions {\n /** If false, print instructions and exit; do not run the installer. */\n autoInstall?: boolean;\n /** Log prefix for user-facing messages. */\n tag?: string;\n}\n\n/**\n * Verify the skill's runtime dependencies are resolvable. If not:\n * - Detect the user's package manager from the skill directory's lockfile.\n * - If autoInstall is true (default), run the installer in the skill dir.\n * - Otherwise, print the exact command for the user and exit 1.\n */\nexport function ensureNativeDependencies(\n entryUrl: string,\n options: EnsureDepsOptions = {},\n): void {\n const envOptOut = process.env.OCTOCODE_NO_AUTO_INSTALL === '1';\n const { autoInstall = !envOptOut, tag = '[octocode-engineer]' } = options;\n const skillDir = findSkillDir(entryUrl);\n\n const missing = missingPackagesFrom(skillDir, entryUrl);\n if (missing.length === 0) return;\n\n const plan = planInstall(skillDir);\n\n process.stderr.write(\n `${tag} Missing runtime dependencies: ${missing.join(', ')}\\n` +\n `${tag} Skill directory: ${skillDir}\\n` +\n `${tag} Detected package manager: ${plan.pm} (from lockfile or default)\\n`,\n );\n\n if (!autoInstall) {\n process.stderr.write(\n `${tag} Auto-install disabled (OCTOCODE_NO_AUTO_INSTALL=1). To install, run:\\n` +\n ` cd ${skillDir}\\n` +\n ` ${plan.humanCommand}\\n`,\n );\n process.exit(1);\n }\n\n process.stderr.write(`${tag} Installing now: ${plan.cmd} ${plan.args.join(' ')}\\n`);\n const result = spawnSync(plan.cmd, plan.args, {\n cwd: skillDir,\n stdio: 'inherit',\n shell: false,\n });\n\n if (result.status !== 0) {\n const hint =\n result.error && (result.error as NodeJS.ErrnoException).code === 'ENOENT'\n ? ` (command \"${plan.cmd}\" not found on PATH)`\n : '';\n process.stderr.write(\n `${tag} Install failed${hint}.\\n` +\n `${tag} Please run manually:\\n` +\n ` cd ${skillDir}\\n` +\n ` ${plan.humanCommand}\\n`,\n );\n process.exit(1);\n }\n\n const stillMissing = missingPackagesFrom(skillDir, entryUrl);\n if (stillMissing.length > 0) {\n process.stderr.write(\n `${tag} Install completed but still missing: ${stillMissing.join(', ')}\\n` +\n `${tag} Check ${skillDir}/node_modules and retry:\\n` +\n ` cd ${skillDir}\\n` +\n ` ${plan.humanCommand}\\n`,\n );\n process.exit(1);\n }\n\n process.stderr.write(`${tag} Dependencies installed.\\n`);\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":5401,"content_sha256":"7354d494c224f9f752e7f82c83d1d3db4a302adc04596b31d64270499b9093ee"},{"filename":"src/common/is-direct-run.test.ts","content":"import path from 'node:path';\nimport { pathToFileURL } from 'node:url';\n\nimport { describe, expect, it } from 'vitest';\n\nimport { isDirectRun } from './is-direct-run.js';\n\ndescribe('isDirectRun', () => {\n it('returns false when argv1 is missing', () => {\n expect(isDirectRun(pathToFileURL('/tmp/example.js').href, undefined)).toBe(false);\n });\n\n it('matches the current module path for absolute argv1', () => {\n const file = path.join(process.cwd(), 'scripts', 'example.js');\n\n expect(isDirectRun(pathToFileURL(file).href, file)).toBe(true);\n });\n\n it('matches the current module path for relative argv1', () => {\n const file = path.join(process.cwd(), 'scripts', 'example.js');\n const relativeFile = path.relative(process.cwd(), file);\n\n expect(isDirectRun(pathToFileURL(file).href, relativeFile)).toBe(true);\n });\n\n it('returns false for imported modules', () => {\n const file = path.join(process.cwd(), 'scripts', 'example.js');\n const differentFile = path.join(process.cwd(), 'scripts', 'other.js');\n\n expect(isDirectRun(pathToFileURL(file).href, differentFile)).toBe(false);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":1124,"content_sha256":"fa53ba512be3b9b9f0608fac93164c9ba0ddd88ddb51777ce120a87ec83a49ca"},{"filename":"src/common/is-direct-run.ts","content":"import path from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nexport function isDirectRun(\n importMetaUrl: string,\n argv1: string | undefined = process.argv[1],\n): boolean {\n if (!argv1) {\n return false;\n }\n\n return fileURLToPath(importMetaUrl) === path.resolve(argv1);\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":291,"content_sha256":"9cc2a4452d932509d6559e33a794c5c25346328d9f67239a62bf38cd6e0a9705"},{"filename":"src/common/utils.test.ts","content":"import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport * as ts from 'typescript';\nimport { describe, expect, it } from 'vitest';\n\nimport {\n addToMapSet,\n buildNodeTree,\n canonicalScriptKind,\n getLineAndCharacter,\n hashString,\n increment,\n isRelativeImport,\n isTestFile,\n makeFingerprint,\n normalizeDependencyValue,\n normalizeNodeKind,\n renderNodeText,\n renderTreesText,\n resolveImportTarget,\n toRepoPath,\n} from './utils.js';\n\nimport type { NodeBudget, TreeEntry } from '../types/index.js';\n\ndescribe('canonicalScriptKind', () => {\n it('maps .tsx to TSX', () => {\n expect(canonicalScriptKind('.tsx')).toBe(ts.ScriptKind.TSX);\n });\n\n it('maps .jsx to JSX', () => {\n expect(canonicalScriptKind('.jsx')).toBe(ts.ScriptKind.JSX);\n });\n\n it('maps .js, .mjs, .cjs to JS', () => {\n expect(canonicalScriptKind('.js')).toBe(ts.ScriptKind.JS);\n expect(canonicalScriptKind('.mjs')).toBe(ts.ScriptKind.JS);\n expect(canonicalScriptKind('.cjs')).toBe(ts.ScriptKind.JS);\n });\n\n it('maps .ts and unknown to TS', () => {\n expect(canonicalScriptKind('.ts')).toBe(ts.ScriptKind.TS);\n expect(canonicalScriptKind('.xyz')).toBe(ts.ScriptKind.TS);\n });\n});\n\ndescribe('hashString', () => {\n it('returns 16-char hex string', () => {\n const h = hashString('test');\n expect(h).toHaveLength(16);\n expect(/^[0-9a-f]+$/.test(h)).toBe(true);\n });\n\n it('is deterministic', () => {\n expect(hashString('hello')).toBe(hashString('hello'));\n });\n\n it('differs for different inputs', () => {\n expect(hashString('a')).not.toBe(hashString('b'));\n });\n});\n\ndescribe('normalizeNodeKind', () => {\n it('returns ID for Identifier', () => {\n expect(normalizeNodeKind(ts.SyntaxKind.Identifier)).toBe('ID');\n });\n\n it('returns STR for string literals', () => {\n expect(normalizeNodeKind(ts.SyntaxKind.StringLiteral)).toBe('STR');\n expect(normalizeNodeKind(ts.SyntaxKind.NoSubstitutionTemplateLiteral)).toBe(\n 'STR'\n );\n });\n\n it('returns NUM for numeric literal', () => {\n expect(normalizeNodeKind(ts.SyntaxKind.NumericLiteral)).toBe('NUM');\n });\n\n it('returns BOOL for boolean keywords', () => {\n expect(normalizeNodeKind(ts.SyntaxKind.TrueKeyword)).toBe('BOOL');\n expect(normalizeNodeKind(ts.SyntaxKind.FalseKeyword)).toBe('BOOL');\n });\n\n it('returns NULL for NullKeyword', () => {\n expect(normalizeNodeKind(ts.SyntaxKind.NullKeyword)).toBe('NULL');\n });\n\n it('returns SyntaxKind name for others', () => {\n const result = normalizeNodeKind(ts.SyntaxKind.IfStatement);\n expect(result).toBe('IfStatement');\n });\n\n it('returns BIGINT for BigIntLiteral', () => {\n expect(normalizeNodeKind(ts.SyntaxKind.BigIntLiteral)).toBe('BIGINT');\n });\n\n it('returns STR for TemplateMiddle and TemplateHead', () => {\n expect(normalizeNodeKind(ts.SyntaxKind.TemplateMiddle)).toBe('STR');\n expect(normalizeNodeKind(ts.SyntaxKind.TemplateHead)).toBe('STR');\n });\n\n it('returns UNKNOWN for unhandled kind when SyntaxKind has no name', () => {\n const unhandledKind = 99999 as ts.SyntaxKind;\n const result = normalizeNodeKind(unhandledKind);\n expect(result === 'UNKNOWN' || typeof result === 'string').toBe(true);\n });\n});\n\ndescribe('makeFingerprint', () => {\n it('returns consistent hash for same AST', () => {\n const src = ts.createSourceFile(\n 'a.ts',\n 'const x = 1;',\n ts.ScriptTarget.ESNext,\n true\n );\n const h1 = makeFingerprint(src);\n const h2 = makeFingerprint(src);\n expect(h1).toBe(h2);\n });\n\n it('returns different hashes for different ASTs', () => {\n const src1 = ts.createSourceFile(\n 'a.ts',\n 'const x = 1;',\n ts.ScriptTarget.ESNext,\n true\n );\n const src2 = ts.createSourceFile(\n 'b.ts',\n 'function f() {}',\n ts.ScriptTarget.ESNext,\n true\n );\n expect(makeFingerprint(src1)).not.toBe(makeFingerprint(src2));\n });\n\n it('uses shared cache via WeakMap', () => {\n const src = ts.createSourceFile(\n 'a.ts',\n 'const x = 1;',\n ts.ScriptTarget.ESNext,\n true\n );\n const cache = new WeakMap\u003cts.Node, string>();\n const h1 = makeFingerprint(src, cache);\n const h2 = makeFingerprint(src, cache);\n expect(h1).toBe(h2);\n });\n});\n\ndescribe('getLineAndCharacter', () => {\n it('returns 1-indexed line and column numbers', () => {\n const src = ts.createSourceFile(\n 'a.ts',\n 'const x = 1;\\nconst y = 2;',\n ts.ScriptTarget.ESNext,\n true\n );\n const firstStmt = src.statements[0];\n const loc = getLineAndCharacter(src, firstStmt);\n expect(loc.lineStart).toBe(1);\n expect(loc.columnStart).toBe(1);\n });\n\n it('correctly positions second line', () => {\n const src = ts.createSourceFile(\n 'a.ts',\n 'const x = 1;\\nconst y = 2;',\n ts.ScriptTarget.ESNext,\n true\n );\n const secondStmt = src.statements[1];\n const loc = getLineAndCharacter(src, secondStmt);\n expect(loc.lineStart).toBe(2);\n });\n});\n\ndescribe('isTestFile', () => {\n it('detects .test.ts files', () => {\n expect(isTestFile('src/foo.test.ts')).toBe(true);\n expect(isTestFile('src/foo.test.tsx')).toBe(true);\n });\n\n it('detects .spec.ts files', () => {\n expect(isTestFile('src/foo.spec.ts')).toBe(true);\n });\n\n it('detects __tests__ directory', () => {\n expect(isTestFile('__tests__/foo.ts')).toBe(true);\n expect(isTestFile('src/__tests__/foo.ts')).toBe(true);\n });\n\n it('detects tests/ directory', () => {\n expect(isTestFile('tests/foo.ts')).toBe(true);\n });\n\n it('returns false for production files', () => {\n expect(isTestFile('src/foo.ts')).toBe(false);\n expect(isTestFile('src/index.ts')).toBe(false);\n expect(isTestFile('src/testing-utils.ts')).toBe(false);\n });\n});\n\ndescribe('toRepoPath', () => {\n it('converts absolute path to relative', () => {\n expect(toRepoPath('/home/user/repo/src/a.ts', '/home/user/repo')).toBe(\n 'src/a.ts'\n );\n });\n\n it('normalizes backslashes to forward slashes', () => {\n expect(toRepoPath('/repo/src\\\\a.ts', '/repo')).toMatch(/^src\\/a\\.ts$/);\n });\n});\n\ndescribe('normalizeDependencyValue', () => {\n it('normalizes path separators', () => {\n const result = normalizeDependencyValue('src/utils/helper');\n expect(result).not.toContain('\\\\');\n expect(result).toContain('/');\n });\n});\n\ndescribe('addToMapSet', () => {\n it('creates new set for new key', () => {\n const map = new Map\u003cstring, Set\u003cstring>>();\n addToMapSet(map, 'key', 'value');\n expect(map.get('key')?.has('value')).toBe(true);\n });\n\n it('adds to existing set', () => {\n const map = new Map\u003cstring, Set\u003cstring>>();\n addToMapSet(map, 'key', 'a');\n addToMapSet(map, 'key', 'b');\n expect(map.get('key')?.size).toBe(2);\n });\n\n it('does not duplicate values', () => {\n const map = new Map\u003cstring, Set\u003cstring>>();\n addToMapSet(map, 'key', 'a');\n addToMapSet(map, 'key', 'a');\n expect(map.get('key')?.size).toBe(1);\n });\n});\n\ndescribe('isRelativeImport', () => {\n it('detects ./ imports', () => {\n expect(isRelativeImport('./foo')).toBe(true);\n });\n\n it('detects ../ imports', () => {\n expect(isRelativeImport('../bar')).toBe(true);\n });\n\n it('rejects bare specifiers', () => {\n expect(isRelativeImport('lodash')).toBe(false);\n expect(isRelativeImport('@scope/pkg')).toBe(false);\n });\n\n it('rejects absolute paths', () => {\n expect(isRelativeImport('/absolute/path')).toBe(false);\n });\n});\n\ndescribe('increment', () => {\n it('creates new array for new key', () => {\n const map = new Map\u003cstring, number[]>();\n increment(map, 'key', 1);\n expect(map.get('key')).toEqual([1]);\n });\n\n it('appends to existing array', () => {\n const map = new Map\u003cstring, number[]>();\n increment(map, 'key', 1);\n increment(map, 'key', 2);\n expect(map.get('key')).toEqual([1, 2]);\n });\n\n it('handles different keys independently', () => {\n const map = new Map\u003cstring, string[]>();\n increment(map, 'a', 'x');\n increment(map, 'b', 'y');\n expect(map.get('a')).toEqual(['x']);\n expect(map.get('b')).toEqual(['y']);\n });\n});\n\ndescribe('buildNodeTree', () => {\n it('builds NodeTree from TS AST with kind, startLine, endLine, children', () => {\n const src = ts.createSourceFile(\n 'a.ts',\n 'const x = 1;\\nconst y = 2;',\n ts.ScriptTarget.ESNext,\n true\n );\n const budget: NodeBudget = { size: 100 };\n const tree = buildNodeTree(src, src, 3, budget);\n expect(tree).not.toBeNull();\n expect(tree!.kind).toBe('SourceFile');\n expect(tree!.startLine).toBe(1);\n expect(tree!.endLine).toBe(2);\n expect(tree!.children.length).toBeGreaterThan(0);\n expect(tree!.truncated).toBeUndefined();\n });\n\n it('depth=0 produces truncated node', () => {\n const src = ts.createSourceFile(\n 'a.ts',\n 'const x = 1;',\n ts.ScriptTarget.ESNext,\n true\n );\n const budget: NodeBudget = { size: 100 };\n const tree = buildNodeTree(src, src, 0, budget);\n expect(tree).not.toBeNull();\n expect(tree!.truncated).toBe(true);\n expect(tree!.children).toEqual([]);\n });\n\n it('maxNodes budget exhausted produces partial tree', () => {\n const src = ts.createSourceFile(\n 'a.ts',\n 'const a = 1; const b = 2; const c = 3;',\n ts.ScriptTarget.ESNext,\n true\n );\n const budget: NodeBudget = { size: 2 };\n const tree = buildNodeTree(src, src, 3, budget);\n expect(tree).not.toBeNull();\n expect(budget.size).toBeLessThanOrEqual(0);\n });\n\n it('seen WeakSet prevents cycles when passing same node twice', () => {\n const src = ts.createSourceFile(\n 'a.ts',\n 'const x = 1;',\n ts.ScriptTarget.ESNext,\n true\n );\n const stmt = src.statements[0];\n const budget: NodeBudget = { size: 100 };\n const seen = new WeakSet\u003cts.Node>();\n const tree1 = buildNodeTree(stmt, src, 3, budget, seen);\n const budget2: NodeBudget = { size: 100 };\n const tree2 = buildNodeTree(stmt, src, 3, budget2, seen);\n expect(tree1).not.toBeNull();\n expect(tree2).not.toBeNull();\n expect(tree2!.truncated).toBe(true);\n });\n});\n\ndescribe('renderNodeText', () => {\n it('single node renders kind[line]', () => {\n const node = { kind: 'SourceFile', startLine: 1, endLine: 1, children: [] };\n expect(renderNodeText(node)).toBe('SourceFile[1]\\n');\n });\n\n it('node with children indents properly', () => {\n const node = {\n kind: 'SourceFile',\n startLine: 1,\n endLine: 2,\n children: [\n { kind: 'VariableStatement', startLine: 1, endLine: 1, children: [] },\n ],\n };\n const out = renderNodeText(node);\n expect(out).toContain('SourceFile[1:2]\\n');\n expect(out).toContain(' VariableStatement[1]\\n');\n });\n\n it('truncated node adds ...', () => {\n const node = {\n kind: 'SourceFile',\n startLine: 1,\n endLine: 1,\n children: [],\n truncated: true,\n };\n expect(renderNodeText(node)).toBe('SourceFile[1] ...\\n');\n });\n});\n\ndescribe('renderTreesText', () => {\n it('renders header and entries with packages/files', () => {\n const entries: TreeEntry[] = [\n {\n package: 'pkg-a',\n file: 'src/a.ts',\n tree: { kind: 'SourceFile', startLine: 1, endLine: 1, children: [] },\n },\n ];\n const out = renderTreesText(entries, '2025-01-01T00:00:00Z');\n expect(out).toContain('# AST Trees — 2025-01-01T00:00:00Z');\n expect(out).toContain('## pkg-a — src/a.ts');\n expect(out).toContain('SourceFile[1]');\n });\n});\n\ndescribe('resolveImportTarget', () => {\n it('resolves relative import with temp files', () => {\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-import-'));\n try {\n const targetPath = path.join(tmpDir, 'foo.ts');\n fs.writeFileSync(targetPath, 'export const x = 1;');\n const resolved = resolveImportTarget(tmpDir, './foo.ts');\n expect(resolved).toBe(targetPath);\n } finally {\n fs.rmSync(tmpDir, { recursive: true });\n }\n });\n\n it('.js extension maps to .ts alternative', () => {\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-import-'));\n try {\n const targetPath = path.join(tmpDir, 'bar.ts');\n fs.writeFileSync(targetPath, 'export const y = 2;');\n const resolved = resolveImportTarget(tmpDir, './bar.js');\n expect(resolved).toBe(targetPath);\n } finally {\n fs.rmSync(tmpDir, { recursive: true });\n }\n });\n\n it('extensionless resolves by trying IMPORT_RESOLVE_EXTS', () => {\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-import-'));\n try {\n const targetPath = path.join(tmpDir, 'baz.ts');\n fs.writeFileSync(targetPath, 'export const z = 3;');\n const resolved = resolveImportTarget(tmpDir, './baz');\n expect(resolved).toBe(targetPath);\n } finally {\n fs.rmSync(tmpDir, { recursive: true });\n }\n });\n\n it('index.ts fallback for directory imports', () => {\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-import-'));\n try {\n const dirPath = path.join(tmpDir, 'lib');\n fs.mkdirSync(dirPath, { recursive: true });\n const indexPath = path.join(dirPath, 'index.ts');\n fs.writeFileSync(indexPath, 'export default {};');\n const resolved = resolveImportTarget(tmpDir, './lib');\n expect(resolved).toBe(indexPath);\n } finally {\n fs.rmSync(tmpDir, { recursive: true });\n }\n });\n\n it('returns null for nonexistent', () => {\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-import-'));\n try {\n const resolved = resolveImportTarget(tmpDir, './nonexistent');\n expect(resolved).toBeNull();\n } finally {\n fs.rmSync(tmpDir, { recursive: true });\n }\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":13716,"content_sha256":"0ca25b4ee94409bbe3df702285946a98e44261434dc1357f7dd6fd9f1a5df770"},{"filename":"src/common/utils.ts","content":"import crypto from 'node:crypto';\nimport fs from 'node:fs';\nimport path from 'node:path';\n\nimport * as ts from 'typescript';\n\nimport { IMPORT_RESOLVE_EXTS } from '../types/index.js';\n\nimport type { NodeBudget, NodeTree, SyntaxNode } from '../types/index.js';\n\nexport function canonicalScriptKind(ext: string): ts.ScriptKind {\n switch (ext) {\n case '.tsx':\n return ts.ScriptKind.TSX;\n case '.jsx':\n return ts.ScriptKind.JSX;\n case '.js':\n case '.mjs':\n case '.cjs':\n return ts.ScriptKind.JS;\n case '.ts':\n default:\n return ts.ScriptKind.TS;\n }\n}\n\nexport function hashString(value: string): string {\n return crypto.createHash('sha1').update(value).digest('hex').slice(0, 16);\n}\n\nexport function normalizeNodeKind(kind: ts.SyntaxKind): string {\n switch (kind) {\n case ts.SyntaxKind.Identifier:\n return 'ID';\n case ts.SyntaxKind.StringLiteral:\n case ts.SyntaxKind.NoSubstitutionTemplateLiteral:\n case ts.SyntaxKind.TemplateMiddle:\n case ts.SyntaxKind.TemplateHead:\n return 'STR';\n case ts.SyntaxKind.NumericLiteral:\n return 'NUM';\n case ts.SyntaxKind.BigIntLiteral:\n return 'BIGINT';\n case ts.SyntaxKind.TrueKeyword:\n case ts.SyntaxKind.FalseKeyword:\n return 'BOOL';\n case ts.SyntaxKind.NullKeyword:\n return 'NULL';\n default:\n return ts.SyntaxKind[kind] || 'UNKNOWN';\n }\n}\n\nexport function makeFingerprint(\n node: ts.Node,\n seen: WeakMap\u003cts.Node, string> = new WeakMap()\n): string {\n if (seen.has(node)) return seen.get(node)!;\n\n const tokens: string[] = [];\n const visit = (current: ts.Node): void => {\n tokens.push(normalizeNodeKind(current.kind));\n ts.forEachChild(current, visit);\n };\n\n visit(node);\n const hash = hashString(tokens.join('|'));\n seen.set(node, hash);\n return hash;\n}\n\nexport function makeTreeSitterFingerprint(node: SyntaxNode): string {\n const tokens: string[] = [];\n const visit = (current: SyntaxNode): void => {\n tokens.push(current.type);\n for (const child of current.children) visit(child);\n };\n visit(node);\n return hashString(tokens.join('|'));\n}\n\nexport function getLineAndCharacter(\n sourceFile: ts.SourceFile,\n node: ts.Node\n): {\n lineStart: number;\n lineEnd: number;\n columnStart: number;\n columnEnd: number;\n} {\n const start = sourceFile.getLineAndCharacterOfPosition(\n node.getStart(sourceFile)\n );\n const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd());\n return {\n lineStart: start.line + 1,\n lineEnd: end.line + 1,\n columnStart: start.character + 1,\n columnEnd: end.character + 1,\n };\n}\n\nexport function buildNodeTree(\n node: ts.Node,\n sourceFile: ts.SourceFile,\n depth: number,\n maxNodes: NodeBudget,\n seen: WeakSet\u003cts.Node> = new WeakSet()\n): NodeTree | null {\n if (!node || maxNodes.size \u003c= 0) return null;\n maxNodes.size -= 1;\n\n const loc = getLineAndCharacter(sourceFile, node);\n const base: NodeTree = {\n kind: ts.SyntaxKind[node.kind] || 'UNKNOWN',\n startLine: loc.lineStart,\n endLine: loc.lineEnd,\n children: [],\n };\n\n if (depth \u003c= 0) {\n base.truncated = true;\n return base;\n }\n\n if (seen.has(node)) {\n base.truncated = true;\n return base;\n }\n seen.add(node);\n\n ts.forEachChild(node, child => {\n if (maxNodes.size \u003c= 0) return;\n const childTree = buildNodeTree(\n child,\n sourceFile,\n depth - 1,\n maxNodes,\n seen\n );\n if (childTree) {\n base.children.push(childTree);\n }\n });\n\n return base;\n}\n\nexport function buildTreeSitterTree(\n node: SyntaxNode,\n _sourceFileText: string,\n depth: number,\n maxNodes: NodeBudget,\n seen: WeakSet\u003cSyntaxNode> = new WeakSet()\n): NodeTree | null {\n if (!node || maxNodes.size \u003c= 0) return null;\n maxNodes.size -= 1;\n\n const base: NodeTree = {\n kind: node.type,\n startLine: node.startPosition.row + 1,\n endLine: node.endPosition.row + 1,\n children: [],\n };\n\n if (depth \u003c= 0) {\n base.truncated = true;\n return base;\n }\n\n if (seen.has(node)) {\n base.truncated = true;\n return base;\n }\n seen.add(node);\n\n for (const child of node.children) {\n if (maxNodes.size \u003c= 0) break;\n const childTree = buildTreeSitterTree(\n child,\n _sourceFileText,\n depth - 1,\n maxNodes,\n seen\n );\n if (childTree) {\n base.children.push(childTree);\n }\n }\n\n return base;\n}\n\nexport function renderNodeText(node: NodeTree, indent: number = 0): string {\n const pad = ' '.repeat(indent);\n const span =\n node.startLine === node.endLine\n ? `${node.startLine}`\n : `${node.startLine}:${node.endLine}`;\n const trunc = node.truncated ? ' ...' : '';\n let line = `${pad}${node.kind}[${span}]${trunc}\\n`;\n for (const child of node.children) {\n line += renderNodeText(child, indent + 1);\n }\n return line;\n}\n\nexport function renderTreesText(\n entries: import('../types/index.js').TreeEntry[],\n generatedAt: string\n): string {\n const lines: string[] = [`# AST Trees — ${generatedAt}`, ''];\n for (const entry of entries) {\n lines.push(`## ${entry.package} — ${entry.file}`);\n lines.push(renderNodeText(entry.tree));\n }\n return lines.join('\\n');\n}\n\nexport function isTestFile(filePath: string): boolean {\n return (\n /(?:^|[\\\\/])(?:__tests__|__test__|tests)(?:[\\\\/]|$)/.test(filePath) ||\n /(?:\\.test|_test|\\.spec)\\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(filePath) ||\n /(?:^|[\\\\/])test_[^/\\\\]*\\.py$/.test(filePath) ||\n /_test\\.py$/.test(filePath) ||\n /(?:^|[\\\\/])conftest\\.py$/.test(filePath)\n );\n}\n\nexport function toRepoPath(filePath: string, root: string): string {\n return path.relative(root, filePath).replace(/\\\\/g, '/');\n}\n\nexport function normalizeDependencyValue(value: string): string {\n return path.normalize(value).replace(/\\\\/g, '/');\n}\n\nexport function addToMapSet(\n map: Map\u003cstring, Set\u003cstring>>,\n key: string,\n value: string\n): void {\n if (!map.has(key)) {\n map.set(key, new Set());\n }\n map.get(key)!.add(value);\n}\n\nexport function isRelativeImport(specifier: string): boolean {\n return (\n specifier.startsWith('./') ||\n specifier.startsWith('../') ||\n specifier.startsWith('.\\\\') ||\n specifier.startsWith('..\\\\')\n );\n}\n\nexport function resolveImportTarget(\n currentDirectory: string,\n specifier: string\n): string | null {\n const cleaned = specifier.replace(/[?#].*$/, '');\n const base = path.resolve(currentDirectory, cleaned);\n const candidates: string[] = [];\n const ext = path.extname(base);\n const jsToTsMap: Record\u003cstring, string[]> = {\n '.js': ['.ts', '.tsx'],\n '.jsx': ['.tsx'],\n '.mjs': ['.ts', '.tsx'],\n '.cjs': ['.ts', '.tsx'],\n };\n\n if (ext) {\n candidates.push(base);\n const altExts = jsToTsMap[ext];\n if (altExts) {\n const noExt = base.slice(0, -ext.length);\n for (const candidateExt of altExts) {\n const withTsExt = `${noExt}${candidateExt}`;\n candidates.push(withTsExt);\n }\n }\n } else {\n for (const ext of IMPORT_RESOLVE_EXTS) {\n candidates.push(`${base}${ext}`);\n }\n for (const ext of IMPORT_RESOLVE_EXTS) {\n candidates.push(path.join(base, `index${ext}`));\n }\n }\n\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) {\n return candidate;\n }\n }\n return null;\n}\n\nexport function increment\u003cT>(\n map: Map\u003cstring, T[]>,\n key: string,\n value: T\n): void {\n if (!map.has(key)) map.set(key, []);\n map.get(key)!.push(value);\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":7420,"content_sha256":"b3fb50a2b467d8f02e06297574c7dd8119a8477b7dbfca0d1634881262d0b7e1"},{"filename":"src/detector-gating.test.ts","content":"import { describe, expect, it } from 'vitest';\n\nimport { resolveEnabledPillars } from './index.js';\n\ndescribe('resolveEnabledPillars', () => {\n it('enables all pillars when no feature filter is provided', () => {\n expect(resolveEnabledPillars(null)).toEqual({\n architecture: true,\n codeQuality: true,\n deadCode: true,\n security: true,\n testQuality: true,\n });\n });\n\n it('enables only security for security-only categories', () => {\n expect(resolveEnabledPillars(new Set(['hardcoded-secret']))).toEqual({\n architecture: false,\n codeQuality: false,\n deadCode: false,\n security: true,\n testQuality: false,\n });\n });\n\n it('enables only test quality for test-quality-only categories', () => {\n expect(resolveEnabledPillars(new Set(['missing-mock-restoration']))).toEqual({\n architecture: false,\n codeQuality: false,\n deadCode: false,\n security: false,\n testQuality: true,\n });\n });\n\n it('enables dead code categories explicitly', () => {\n expect(resolveEnabledPillars(new Set(['dead-export']))).toEqual({\n architecture: false,\n codeQuality: false,\n deadCode: true,\n security: false,\n testQuality: false,\n });\n });\n\n it('enables multiple pillars when categories span pillars', () => {\n expect(\n resolveEnabledPillars(\n new Set(['dependency-cycle', 'cognitive-complexity', 'hardcoded-secret'])\n )\n ).toEqual({\n architecture: true,\n codeQuality: true,\n deadCode: false,\n security: true,\n testQuality: false,\n });\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":1599,"content_sha256":"0b0aa5f8d44f7ed87ad330c842ab9f13e7ac469c9501d691b794769720ae9d18"},{"filename":"src/detectors/code-quality.ts","content":"import * as ts from 'typescript';\n\nimport { canAddFinding } from './shared.js';\nimport { isTestFile } from '../common/utils.js';\n\nimport type { FindingDraft } from './shared.js';\nimport type {\n DependencyState,\n DuplicateGroup,\n FileEntry,\n Finding,\n FlowMapEntry,\n RedundantFlowGroup,\n} from '../types/index.js';\n\nexport function detectDuplicateFunctionBodies(\n duplicates: DuplicateGroup[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const group of duplicates) {\n const sample = group.locations[0];\n const reason =\n `Same ${group.kind} body shape appears in ${group.occurrences} places (` +\n `${group.filesCount} file${group.filesCount > 1 ? 's' : ''}).`;\n const severity: Finding['severity'] =\n group.occurrences >= 6\n ? 'high'\n : group.occurrences >= 3\n ? 'medium'\n : 'low';\n if (!canAddFinding(findings)) break;\n findings.push({\n ...sample,\n severity,\n category: 'duplicate-function-body',\n title: `Deduplicate function body: ${group.signature}`,\n reason,\n files: group.locations.map(\n loc => `${loc.file}:${loc.lineStart}-${loc.lineEnd}`\n ),\n suggestedFix: {\n strategy:\n 'Create a shared helper function once and replace duplicate call sites.',\n steps: [\n 'Extract one function to a dedicated utility module.',\n 'Keep behavior unchanged by passing function-specific differences as params.',\n 'Replace duplicated blocks with calls to the shared helper.',\n 'Add/extend tests around each entry point that previously used duplicates.',\n ],\n },\n impact: `Lower maintenance cost and reduce regression risk when behavior changes.`,\n tags: ['duplication', 'maintainability', 'dryness'],\n lspHints: [\n {\n tool: 'lspGotoDefinition',\n symbolName: group.signature,\n lineHint: sample.lineStart,\n file: sample.file,\n expectedResult: `navigate to one instance to compare implementations side-by-side`,\n },\n ],\n });\n }\n return findings;\n}\n\nexport function detectDuplicateFlowStructures(\n controlDuplicates: RedundantFlowGroup[],\n flowDupThreshold: number\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const group of controlDuplicates) {\n if (group.occurrences \u003c flowDupThreshold) continue;\n const sample = group.locations[0];\n const reason = `${group.kind} structure appears ${group.occurrences} times across ${group.filesCount} file(s).`;\n const severity: Finding['severity'] =\n group.occurrences >= 10 ? 'high' : 'medium';\n if (!canAddFinding(findings)) break;\n findings.push({\n ...sample,\n severity,\n category: 'duplicate-flow-structure',\n title: `Extract repeated flow structure: ${group.kind}`,\n reason,\n files: group.locations.map(\n loc => `${loc.file}:${loc.lineStart}-${loc.lineEnd}`\n ),\n suggestedFix: {\n strategy:\n 'Extract a reusable flow helper around the repeated structure.',\n steps: [\n 'Create one clear helper that accepts varying inputs as parameters.',\n 'Call helper from each repeated site.',\n 'Keep variable names aligned and add local adapter logic where needed.',\n 'Document expected invariants for the shared flow.',\n ],\n },\n impact: `Reduces duplicate control branches and normalizes edge-case handling.`,\n tags: ['duplication', 'control-flow', 'dryness'],\n });\n }\n return findings;\n}\n\nexport function detectFunctionOptimization(\n fileSummaries: FileEntry[],\n criticalComplexityThreshold: number\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const fileEntry of fileSummaries) {\n for (const fn of fileEntry.functions) {\n const alerts: string[] = [];\n if (fn.complexity >= criticalComplexityThreshold)\n alerts.push(\n `Cyclomatic-like complexity is high (>=${criticalComplexityThreshold}).`\n );\n if (fn.maxBranchDepth >= 7)\n alerts.push('Branch depth is very deep and hard to reason about.');\n if (fn.maxLoopDepth >= 4)\n alerts.push('Nested loops are high and likely expensive.');\n if (fn.statementCount >= 24)\n alerts.push(\n 'Function body is large and may be doing multiple responsibilities.'\n );\n\n if (alerts.length === 0) continue;\n\n const isHigh =\n fn.complexity >= criticalComplexityThreshold ||\n fn.maxBranchDepth >= 7 ||\n fn.maxLoopDepth >= 4;\n findings.push({\n ...fn,\n severity: isHigh ? 'high' : 'medium',\n category: 'function-optimization',\n title: `Potential function refactor: ${fn.name}`,\n reason: alerts.join(' '),\n files: [`${fn.file}:${fn.lineStart}-${fn.lineEnd}`],\n suggestedFix: {\n strategy: 'Refactor for readability and testability.',\n steps: [\n 'Split into smaller subroutines with single responsibilities.',\n 'Convert deeply nested branches into guard clauses when safe.',\n 'Replace loops with intent-specific helpers if one loop owns most lines.',\n 'Add unit coverage for each extracted piece before deleting old logic.',\n ],\n },\n impact: 'Cleaner flow, easier review and safer refactors.',\n tags: ['complexity', 'readability', 'refactor'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: fn.name,\n lineHint: fn.lineStart,\n file: fn.file,\n expectedResult: `inspect callers and callees to plan safe decomposition of ${fn.name}`,\n },\n ],\n });\n }\n }\n return findings;\n}\n\nexport function computeCognitiveComplexity(node: ts.Node): number {\n let total = 0;\n\n const visit = (current: ts.Node, nesting: number): void => {\n let increment = 0;\n let nestable = false;\n\n switch (current.kind) {\n case ts.SyntaxKind.IfStatement:\n case ts.SyntaxKind.ForStatement:\n case ts.SyntaxKind.ForInStatement:\n case ts.SyntaxKind.ForOfStatement:\n case ts.SyntaxKind.WhileStatement:\n case ts.SyntaxKind.DoStatement:\n case ts.SyntaxKind.CatchClause:\n case ts.SyntaxKind.ConditionalExpression:\n case ts.SyntaxKind.SwitchStatement:\n increment = 1;\n nestable = true;\n break;\n default:\n break;\n }\n\n if (\n current.kind === ts.SyntaxKind.BinaryExpression &&\n ((current as ts.BinaryExpression).operatorToken.kind ===\n ts.SyntaxKind.AmpersandAmpersandToken ||\n (current as ts.BinaryExpression).operatorToken.kind ===\n ts.SyntaxKind.BarBarToken ||\n (current as ts.BinaryExpression).operatorToken.kind ===\n ts.SyntaxKind.QuestionQuestionToken)\n ) {\n increment = 1;\n }\n\n if (\n current.kind === ts.SyntaxKind.IfStatement &&\n current.parent &&\n ts.isIfStatement(current.parent) &&\n current.parent.elseStatement === current\n ) {\n increment = 1;\n nestable = false;\n }\n\n if (nestable) {\n total += increment + nesting;\n ts.forEachChild(current, child => visit(child, nesting + 1));\n return;\n }\n\n total += increment;\n ts.forEachChild(current, child => visit(child, nesting));\n };\n\n visit(node, 0);\n return total;\n}\n\nexport function detectCognitiveComplexity(\n fileSummaries: FileEntry[],\n threshold: number = 15\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const fn of entry.functions) {\n if (fn.cognitiveComplexity > threshold) {\n findings.push({\n severity: fn.cognitiveComplexity > 25 ? 'high' : 'medium',\n category: 'cognitive-complexity',\n file: entry.file,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n title: `High cognitive complexity: ${fn.name} (${fn.cognitiveComplexity})`,\n reason: `Function cognitive complexity is ${fn.cognitiveComplexity} (threshold: ${threshold}). Nested branches compound reading difficulty.`,\n files: [`${entry.file}:${fn.lineStart}-${fn.lineEnd}`],\n suggestedFix: {\n strategy: 'Reduce nesting and simplify control flow.',\n steps: [\n 'Convert nested branches into early returns / guard clauses.',\n 'Extract deeply nested blocks into named helper functions.',\n 'Replace complex boolean chains with named predicates.',\n ],\n },\n impact:\n 'Lower cognitive complexity directly correlates with fewer bugs and faster code reviews.',\n tags: ['complexity', 'readability', 'nesting'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: fn.name,\n lineHint: fn.lineStart,\n file: entry.file,\n expectedResult: `understand call graph before simplifying ${fn.name}`,\n },\n ],\n });\n }\n }\n }\n\n return findings;\n}\n\nexport function detectExcessiveParameters(\n fileSummaries: FileEntry[],\n threshold: number = 5\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const fn of entry.functions) {\n if (fn.params == null || fn.params \u003c= threshold) continue;\n findings.push({\n severity: fn.params > 7 ? 'high' : 'medium',\n category: 'excessive-parameters',\n file: entry.file,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n title: `Excessive parameters: ${fn.name} (${fn.params} params)`,\n reason: `Function has ${fn.params} parameters (threshold: ${threshold}). High parameter counts make call sites hard to read and signal the function may be doing too much.`,\n files: [`${entry.file}:${fn.lineStart}-${fn.lineEnd}`],\n suggestedFix: {\n strategy: 'Introduce a parameter object or split the function.',\n steps: [\n 'Group related parameters into an options/config object.',\n 'Use destructuring at the function signature for clarity.',\n 'Consider splitting into smaller, focused functions if params serve different concerns.',\n ],\n },\n impact:\n 'Improves call-site readability and makes the API easier to evolve.',\n tags: ['api-design', 'readability', 'refactor'],\n });\n }\n }\n return findings;\n}\n\nexport function detectEmptyCatchBlocks(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n if (!entry.emptyCatches || entry.emptyCatches.length === 0) continue;\n for (const loc of entry.emptyCatches) {\n findings.push({\n severity: 'medium',\n category: 'empty-catch',\n file: entry.file,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n title: `Empty catch block silently swallows errors`,\n reason: `Catch block at line ${loc.lineStart} has no statements — errors are silently ignored.`,\n files: [`${entry.file}:${loc.lineStart}-${loc.lineEnd}`],\n suggestedFix: {\n strategy: 'Log, re-throw, or handle the error explicitly.',\n steps: [\n 'Add error logging (console.error or a logger) at minimum.',\n 'Re-throw if the caller should handle the error.',\n 'Add a comment explaining why swallowing is intentional, if it truly is.',\n ],\n },\n impact:\n 'Prevents silent failures that are extremely hard to debug in production.',\n tags: ['error-handling', 'reliability', 'silent-failure'],\n });\n }\n }\n return findings;\n}\n\nexport function detectSwitchNoDefault(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n if (\n !entry.switchesWithoutDefault ||\n entry.switchesWithoutDefault.length === 0\n )\n continue;\n for (const loc of entry.switchesWithoutDefault) {\n findings.push({\n severity: 'low',\n category: 'switch-no-default',\n file: entry.file,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n title: `Switch statement missing default case`,\n reason: `Switch at line ${loc.lineStart} has no default clause — unexpected values fall through silently.`,\n files: [`${entry.file}:${loc.lineStart}-${loc.lineEnd}`],\n suggestedFix: {\n strategy:\n 'Add a default case with error handling or exhaustive check.',\n steps: [\n 'Add a default clause that throws an unreachable error for exhaustiveness.',\n 'Or log a warning for unexpected values.',\n 'In TypeScript, use `never` type assertion for compile-time exhaustive checks.',\n ],\n },\n impact:\n 'Catches unexpected values early and prevents silent logic bugs.',\n tags: ['control-flow', 'exhaustiveness', 'safety'],\n });\n }\n }\n return findings;\n}\n\nexport function detectUnsafeAny(\n fileSummaries: FileEntry[],\n threshold: number = 5\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n if (entry.anyCount == null || entry.anyCount \u003c= threshold) continue;\n if (!canAddFinding(findings)) break;\n findings.push({\n severity: entry.anyCount > 10 ? 'high' : 'medium',\n category: 'unsafe-any',\n file: entry.file,\n lineStart: 1,\n lineEnd: 1,\n title: `Excessive \\`any\\` usage: ${entry.file} (${entry.anyCount} occurrences)`,\n reason: `File uses \\`any\\` type ${entry.anyCount} times (threshold: ${threshold}). Each \\`any\\` disables type checking and allows silent runtime errors.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Replace `any` with specific types, `unknown`, or generics.',\n steps: [\n 'Replace `any` with `unknown` and add type guards where needed.',\n 'Use generics for functions that operate on multiple types.',\n 'Define proper interfaces for complex data shapes.',\n 'Use `as const` assertions instead of `as any` where possible.',\n ],\n },\n impact:\n 'Restores TypeScript safety and catches bugs at compile time instead of runtime.',\n tags: ['type-safety', 'reliability', 'typescript'],\n });\n }\n return findings;\n}\n\nexport function detectHighHalsteadEffort(\n fileSummaries: FileEntry[],\n effortThreshold: number = 500_000,\n bugThreshold: number = 2.0\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const fn of entry.functions) {\n if (!fn.halstead) continue;\n const { effort, estimatedBugs, volume } = fn.halstead;\n if (effort \u003c= effortThreshold && estimatedBugs \u003c= bugThreshold) continue;\n const reasons: string[] = [];\n if (effort > effortThreshold)\n reasons.push(\n `effort=${Math.round(effort)} (threshold: ${effortThreshold})`\n );\n if (estimatedBugs > bugThreshold)\n reasons.push(\n `estimatedBugs=${estimatedBugs.toFixed(2)} (threshold: ${bugThreshold})`\n );\n findings.push({\n severity:\n effort > effortThreshold * 2 || estimatedBugs > 5 ? 'high' : 'medium',\n category: 'halstead-effort',\n file: entry.file,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n title: `High Halstead complexity: ${fn.name}`,\n reason: `Function has high implementation complexity: ${reasons.join('; ')}. Volume=${Math.round(volume)}.`,\n files: [`${entry.file}:${fn.lineStart}-${fn.lineEnd}`],\n suggestedFix: {\n strategy:\n 'Reduce operator/operand count by extracting helpers and simplifying expressions.',\n steps: [\n 'Extract complex sub-expressions into named intermediate variables.',\n 'Split into smaller functions with fewer unique operators/operands.',\n 'Replace imperative loops with declarative array methods where clearer.',\n ],\n },\n impact:\n 'Lower Halstead effort correlates with fewer bugs and faster comprehension.',\n tags: ['complexity', 'maintainability', 'effort'],\n });\n }\n }\n return findings;\n}\n\nexport function detectLowMaintainability(\n fileSummaries: FileEntry[],\n threshold: number = 20\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const fn of entry.functions) {\n if (\n fn.maintainabilityIndex == null ||\n fn.maintainabilityIndex >= threshold\n )\n continue;\n findings.push({\n severity: fn.maintainabilityIndex \u003c 10 ? 'critical' : 'high',\n category: 'low-maintainability',\n file: entry.file,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n title: `Low maintainability: ${fn.name} (MI=${fn.maintainabilityIndex.toFixed(1)})`,\n reason: `Maintainability Index is ${fn.maintainabilityIndex.toFixed(1)} (threshold: ${threshold}, scale 0-100). Combines Halstead volume, cyclomatic complexity, and lines of code.`,\n files: [`${entry.file}:${fn.lineStart}-${fn.lineEnd}`],\n suggestedFix: {\n strategy:\n 'Reduce complexity, shorten the function, and simplify expressions.',\n steps: [\n 'Split into smaller functions to reduce LOC and cyclomatic complexity.',\n 'Extract complex expressions to reduce Halstead volume.',\n 'Convert nested logic to early returns and guard clauses.',\n 'Consider if parts of the function belong in separate modules.',\n ],\n },\n impact:\n 'Higher MI directly predicts lower maintenance cost and defect rate.',\n tags: ['maintainability', 'complexity', 'technical-debt'],\n });\n }\n }\n return findings;\n}\n\nexport function detectTypeAssertionEscape(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n const esc = entry.typeAssertionEscapes;\n if (!esc) continue;\n\n const total =\n esc.asAny.length + esc.doubleAssertion.length + esc.nonNull.length;\n if (total === 0) continue;\n\n const parts: string[] = [];\n if (esc.asAny.length > 0) parts.push(`${esc.asAny.length} \\`as any\\``);\n if (esc.doubleAssertion.length > 0)\n parts.push(`${esc.doubleAssertion.length} double-assertion`);\n if (esc.nonNull.length > 0)\n parts.push(`${esc.nonNull.length} non-null \\`!\\``);\n const allLines = [...esc.asAny, ...esc.doubleAssertion, ...esc.nonNull].map(\n l => l.lineStart\n );\n const firstLine = Math.min(...allLines);\n\n if (!canAddFinding(findings)) break;\n findings.push({\n severity:\n esc.asAny.length + esc.doubleAssertion.length > 3 ? 'high' : 'medium',\n category: 'type-assertion-escape',\n file: entry.file,\n lineStart: firstLine,\n lineEnd: firstLine,\n title: `Type-safety escapes in ${entry.file} (${total})`,\n reason: `Found ${parts.join(', ')}. Each assertion bypasses TypeScript's type checker.`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Replace type assertions with proper type guards or narrow types.',\n steps: [\n 'Replace `as any` with `unknown` and add runtime type checks.',\n 'Replace `as unknown as T` with proper generic constraints.',\n 'Replace `!` assertions with explicit null checks.',\n ],\n },\n impact:\n 'Type assertions silence the compiler — runtime errors go undetected.',\n tags: ['type-safety', 'assertions', 'code-quality'],\n });\n }\n\n return findings;\n}\n\nexport function detectMissingErrorBoundary(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n if (!entry.unprotectedAsync) continue;\n\n for (const fn of entry.unprotectedAsync) {\n const severity =\n fn.awaitCount >= 4 ? 'high' : fn.awaitCount >= 2 ? 'medium' : 'low';\n findings.push({\n severity,\n category: 'missing-error-boundary',\n file: entry.file,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n title: `Missing error boundary: ${fn.name} (${fn.awaitCount} awaits, no try-catch)`,\n reason: `Async function \"${fn.name}\" has ${fn.awaitCount} await(s) but no try-catch. Rejected promises propagate as unhandled rejections.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Wrap await calls in try-catch or add a .catch() handler.',\n steps: [\n 'Add a try-catch block around the await expressions.',\n 'Handle errors appropriately (log, return default, re-throw with context).',\n 'If the caller handles errors, document it with a comment.',\n ],\n },\n impact:\n 'Unhandled promise rejections crash Node.js processes and cause silent failures in browsers.',\n tags: ['error-handling', 'async', 'reliability'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: fn.name,\n lineHint: fn.lineStart,\n file: entry.file,\n expectedResult: `check if callers wrap this in try-catch or .catch() — if so, the boundary may exist upstream`,\n },\n ],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectPromiseMisuse(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n if (!entry.asyncWithoutAwait) continue;\n\n for (const fn of entry.asyncWithoutAwait) {\n findings.push({\n severity: 'medium',\n category: 'promise-misuse',\n file: entry.file,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n title: `Unnecessary async: ${fn.name} has no await`,\n reason: `Function \"${fn.name}\" is declared \\`async\\` but never uses \\`await\\`. The \\`async\\` keyword adds unnecessary Promise wrapping.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Remove the async keyword or add the missing await.',\n steps: [\n 'If the function does not need to be async, remove the `async` keyword.',\n 'If an `await` was forgotten, add it to the appropriate call.',\n 'Verify callers handle the return value correctly after the change.',\n ],\n },\n impact:\n 'Unnecessary async wrapping adds microtask overhead and misleads readers.',\n tags: ['async', 'performance', 'clarity'],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectAwaitInLoop(fileSummaries: FileEntry[]): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const loc of entry.awaitInLoopLocations || []) {\n findings.push({\n severity: 'high',\n category: 'await-in-loop',\n file: entry.file,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n title: 'await inside loop — sequential async execution',\n reason:\n 'Each await runs serially. For N iterations this takes N * latency instead of max(latency). Use Promise.all() or Promise.allSettled() for parallel execution.',\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Collect promises and await them in parallel with Promise.all().',\n steps: [\n 'Collect all async operations into an array of promises.',\n 'Use await Promise.all(promises) or Promise.allSettled(promises).',\n 'If order matters or rate limiting is needed, use a batching utility.',\n ],\n },\n impact:\n 'Sequential awaits multiply latency by N iterations — parallelizing can reduce total time to max(single-latency).',\n tags: ['performance', 'async', 'n-plus-one'],\n lspHints: [\n {\n tool: 'lspGotoDefinition',\n symbolName: 'await',\n lineHint: loc.lineStart,\n file: entry.file,\n expectedResult: `navigate to the awaited call to check if parallelization is safe`,\n },\n ],\n });\n }\n }\n return findings;\n}\n\nexport function detectSyncIo(fileSummaries: FileEntry[]): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const call of entry.syncIoCalls || []) {\n findings.push({\n severity: 'medium',\n category: 'sync-io',\n file: entry.file,\n lineStart: call.lineStart,\n lineEnd: call.lineEnd,\n title: `Synchronous I/O: ${call.name}`,\n reason: `${call.name} blocks the event loop. In server or UI code this degrades responsiveness for all concurrent operations.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Replace with async equivalent.',\n steps: [\n `Replace ${call.name} with its async counterpart (e.g. fs.promises.readFile).`,\n 'Sync I/O is acceptable in CLI scripts, build tools, or one-time init code.',\n ],\n },\n impact:\n 'Synchronous I/O blocks the event loop, stalling all concurrent requests until the operation completes.',\n tags: ['performance', 'blocking', 'io'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: call.name,\n lineHint: call.lineStart,\n file: entry.file,\n expectedResult: `find callers to assess if this sync I/O is in a hot path`,\n },\n ],\n });\n }\n }\n return findings;\n}\n\nexport function detectUnclearedTimers(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const timer of entry.timerCalls || []) {\n if (timer.kind === 'setInterval' && !timer.hasCleanup) {\n findings.push({\n severity: 'medium',\n category: 'uncleared-timer',\n file: entry.file,\n lineStart: timer.lineStart,\n lineEnd: timer.lineEnd,\n title: 'setInterval without clearInterval in scope',\n reason:\n 'setInterval without cleanup runs indefinitely, causing memory leaks and unexpected behavior after component unmount or scope exit.',\n files: [entry.file],\n suggestedFix: {\n strategy: 'Store the timer ID and call clearInterval in cleanup.',\n steps: [\n 'Assign the return value: const id = setInterval(...).',\n 'Call clearInterval(id) in cleanup (useEffect return, componentWillUnmount, or scope exit).',\n ],\n },\n impact:\n 'Uncleared intervals run indefinitely, leaking memory and CPU cycles after their scope is no longer relevant.',\n tags: ['performance', 'memory-leak', 'timer'],\n });\n }\n }\n }\n return findings;\n}\n\nexport function detectListenerLeakRisk(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n const regs = entry.listenerRegistrations || [];\n const removals = entry.listenerRemovals || [];\n if (regs.length > 0 && removals.length === 0) {\n findings.push({\n severity: 'medium',\n category: 'listener-leak-risk',\n file: entry.file,\n lineStart: regs[0].lineStart,\n lineEnd: regs[regs.length - 1].lineEnd,\n title: `${regs.length} event listener(s) added without any removal`,\n reason:\n 'addEventListener/on without corresponding removeEventListener/off risks memory leaks if the target outlives the subscriber.',\n files: [entry.file],\n suggestedFix: {\n strategy: 'Add corresponding listener removal in cleanup.',\n steps: [\n 'Store the handler reference in a variable.',\n 'Call removeEventListener/off in cleanup (unmount, dispose, close).',\n 'Or use AbortController signal for automatic cleanup.',\n ],\n },\n impact:\n 'Listener references prevent garbage collection of the subscriber, causing memory growth proportional to event-target lifetime.',\n tags: ['performance', 'memory-leak', 'events'],\n });\n }\n }\n return findings;\n}\n\nexport function detectUnboundedCollection(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const fn of entry.functions) {\n if (fn.loops >= 2 && fn.calls >= 5 && fn.maxLoopDepth >= 2) {\n findings.push({\n severity: 'low',\n category: 'unbounded-collection',\n file: entry.file,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n title: `Potential unbounded collection growth in ${fn.name}`,\n reason: `Function \"${fn.name}\" has ${fn.loops} loops nested ${fn.maxLoopDepth} levels deep with ${fn.calls} calls — structural signal for unbounded growth. Validate with tools: read the function body and check for collection mutations (.push, .add, .set) inside loops.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Add size limits, pagination, or streaming.',\n steps: [\n 'Add a maximum size check before adding to collections.',\n 'Use pagination or streaming for large datasets.',\n 'Consider using generators for lazy evaluation.',\n ],\n },\n impact:\n 'Unbounded collection growth inside nested loops can cause out-of-memory crashes under large input.',\n tags: ['performance', 'memory', 'collection'],\n });\n }\n }\n }\n return findings;\n}\n\nexport function detectSimilarFunctionBodies(\n flowMap: Map\u003cstring, import('../types/index.js').FlowMapEntry[]>,\n similarityThreshold: number = 0.85\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n const allEntries: import('../types/index.js').FlowMapEntry[] = [];\n for (const entries of flowMap.values()) {\n for (const e of entries) {\n if (!isTestFile(e.file)) allEntries.push(e);\n }\n }\n\n const buckets = new Map\u003cstring, import('../types/index.js').FlowMapEntry[]>();\n for (const entry of allEntries) {\n const key = `${entry.kind}|${Math.round(entry.statementCount / 3)}`;\n if (!buckets.has(key)) buckets.set(key, []);\n buckets.get(key)!.push(entry);\n }\n\n for (const [, bucket] of buckets) {\n if (bucket.length \u003c 2 || bucket.length > 50) continue;\n\n for (let i = 0; i \u003c bucket.length; i++) {\n for (let j = i + 1; j \u003c bucket.length; j++) {\n const a = bucket[i];\n const b = bucket[j];\n if (a.hash === b.hash) continue;\n if (a.file === b.file && a.lineStart === b.lineStart) continue;\n\n const stmtRatio =\n Math.min(a.statementCount, b.statementCount) /\n Math.max(a.statementCount, b.statementCount);\n if (stmtRatio \u003c 0.8) continue;\n\n const similarity = computeMetricSimilarity(a, b);\n if (similarity >= similarityThreshold) {\n findings.push({\n severity: similarity >= 0.95 ? 'high' : 'medium',\n category: 'similar-function-body',\n file: a.file,\n lineStart: a.lineStart,\n lineEnd: a.lineEnd,\n title: `Similar function: ${a.name} (${(similarity * 100).toFixed(0)}% similar to ${b.name} in ${b.file})`,\n reason: `\"${a.name}\" and \"${b.name}\" have ${(similarity * 100).toFixed(0)}% structural similarity. Near-duplicates diverge over time and should be consolidated.`,\n files: [a.file, b.file],\n suggestedFix: {\n strategy: 'Extract shared logic into a parameterized helper.',\n steps: [\n `Compare ${a.file}:${a.lineStart} with ${b.file}:${b.lineStart}.`,\n 'Identify the varying parts and extract them as parameters.',\n 'Create a shared function and call it from both locations.',\n ],\n },\n impact:\n 'Near-clone functions diverge over time, causing inconsistent behavior and multiplied maintenance cost.',\n tags: ['duplication', 'maintainability', 'near-clone'],\n });\n }\n }\n }\n }\n\n return findings;\n}\n\nfunction computeMetricSimilarity(\n a: import('../types/index.js').FlowMapEntry,\n b: import('../types/index.js').FlowMapEntry\n): number {\n const features = [\n [a.metrics.complexity, b.metrics.complexity],\n [a.metrics.maxBranchDepth, b.metrics.maxBranchDepth],\n [a.metrics.maxLoopDepth, b.metrics.maxLoopDepth],\n [a.metrics.returns, b.metrics.returns],\n [a.metrics.awaits, b.metrics.awaits],\n [a.metrics.calls, b.metrics.calls],\n [a.metrics.loops, b.metrics.loops],\n [a.statementCount, b.statementCount],\n ];\n\n let totalSimilarity = 0;\n for (const [va, vb] of features) {\n const max = Math.max(va, vb, 1);\n totalSimilarity += 1 - Math.abs(va - vb) / max;\n }\n return totalSimilarity / features.length;\n}\n\nexport function detectMessageChains(fileSummaries: FileEntry[]): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (!entry.messageChains || entry.messageChains.length === 0) continue;\n // Group by line start — take the deepest chain at each location\n const byLine = new Map\u003cnumber, typeof entry.messageChains[0]>();\n for (const chain of entry.messageChains) {\n const existing = byLine.get(chain.lineStart);\n if (!existing || chain.depth > existing.depth) {\n byLine.set(chain.lineStart, chain);\n }\n }\n for (const chain of byLine.values()) {\n const severity = chain.depth >= 6 ? 'high' : 'medium';\n findings.push({\n severity,\n category: 'message-chain',\n file: entry.file,\n lineStart: chain.lineStart,\n lineEnd: chain.lineEnd,\n title: `Message chain of depth ${chain.depth}: ${chain.chain.slice(0, 50)}`,\n reason: `A property-access chain of ${chain.depth} steps violates the Law of Demeter — the caller navigates through ${chain.depth - 1} intermediate objects to reach its target. Deep chains tightly couple the caller to internal object structure, making refactoring brittle.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Apply the Law of Demeter — talk only to immediate friends.',\n steps: [\n 'Identify the root object and the final method/property being used.',\n 'Add a delegating method to the root object (Tell, Don\\'t Ask).',\n 'Replace the chain with a single call on the immediate object.',\n 'If the chain crosses module boundaries, consider whether the intermediate objects should be passed directly.',\n ],\n },\n impact:\n 'Deep property chains tightly couple code to internal object structure. When intermediate objects change, every chain accessing them must be updated.',\n tags: ['coupling', 'law-of-demeter', 'maintainability'],\n lspHints: [\n {\n tool: 'lspGotoDefinition',\n symbolName: chain.chain.split('.')[0],\n lineHint: chain.lineStart,\n file: entry.file,\n expectedResult: `find the type of the root object to understand what intermediate types the chain traverses`,\n },\n ],\n });\n }\n }\n return findings;\n}\n\nexport function detectDeepNesting(\n fileSummaries: FileEntry[],\n threshold: number\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const fn of entry.functions) {\n const maxDepth = Math.max(fn.maxBranchDepth, fn.maxLoopDepth);\n if (maxDepth \u003c threshold) continue;\n if (!canAddFinding(findings)) return findings;\n const severity: Finding['severity'] = maxDepth >= threshold + 3 ? 'high' : maxDepth >= threshold + 1 ? 'medium' : 'low';\n findings.push({\n severity,\n category: 'deep-nesting',\n file: entry.file,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n title: `Deep nesting ${maxDepth} levels in ${fn.name || '\u003canon>'}`,\n reason: `Function has ${maxDepth}-level nesting (branch=${fn.maxBranchDepth}, loop=${fn.maxLoopDepth}), exceeding the ${threshold}-level threshold. Each nesting level multiplies the reader's cognitive load and increases the likelihood of logic errors.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Flatten nesting with guard clauses, early returns, or extraction.',\n steps: [\n 'Convert nested if-blocks to guard clauses with early returns.',\n 'Extract deeply nested logic into named helper functions.',\n 'Replace nested loops with array methods (map/filter/reduce).',\n ],\n },\n impact: 'Deeply nested code is hard to read, test, and modify. Each nesting level compounds the number of control-flow paths.',\n tags: ['nesting', 'readability', 'complexity'],\n });\n }\n }\n return findings;\n}\n\nexport function detectMultipleReturnPaths(\n fileSummaries: FileEntry[],\n threshold: number\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const fn of entry.functions) {\n if (fn.returns \u003c threshold) continue;\n if (!canAddFinding(findings)) return findings;\n const severity: Finding['severity'] = fn.returns >= threshold + 4 ? 'high' : fn.returns >= threshold + 2 ? 'medium' : 'low';\n findings.push({\n severity,\n category: 'multiple-return-paths',\n file: entry.file,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n title: `${fn.returns} return paths in ${fn.name || '\u003canon>'}`,\n reason: `Function has ${fn.returns} return/throw points — the reader must track every exit path to understand the function's behavior. This exceeds the ${threshold} threshold.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Consolidate return points to reduce exit-path tracking.',\n steps: [\n 'Replace scattered returns with a single result variable assigned conditionally.',\n 'Use early guard clauses for error cases only.',\n 'Consider splitting into smaller functions with clear single-responsibility.',\n ],\n },\n impact: 'Many return paths make it harder to reason about what a function returns and to add post-processing.',\n tags: ['returns', 'readability', 'flow'],\n });\n }\n }\n return findings;\n}\n\nexport function detectCatchRethrow(fileSummaries: FileEntry[]): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n if (!entry.catchRethrows || entry.catchRethrows.length === 0) continue;\n for (const loc of entry.catchRethrows) {\n if (!canAddFinding(findings)) return findings;\n findings.push({\n severity: 'low',\n category: 'catch-rethrow',\n file: entry.file,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n title: 'Catch-rethrow without transformation',\n reason: 'A catch block that only re-throws the caught error is a no-op — it adds indentation and obscures the stack trace without adding value.',\n files: [entry.file],\n suggestedFix: {\n strategy: 'Remove the try-catch or add meaningful error handling.',\n steps: [\n 'If no transformation is needed, remove the try-catch entirely.',\n 'If logging is intended, add a log statement before re-throwing.',\n 'If wrapping, throw a new error with the original as cause.',\n ],\n },\n impact: 'Pointless catch blocks add noise and can accidentally swallow stack-trace context.',\n tags: ['error-handling', 'noise', 'cleanup'],\n });\n }\n }\n return findings;\n}\n\nexport function detectMagicStrings(\n fileSummaries: FileEntry[],\n minOccurrences: number\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n const globalCounts = new Map\u003cstring, Array\u003c{ file: string; lineStart: number; lineEnd: number }>>();\n\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n if (!entry.magicStrings) continue;\n for (const ms of entry.magicStrings) {\n const list = globalCounts.get(ms.value) || [];\n list.push({ file: ms.file, lineStart: ms.lineStart, lineEnd: ms.lineEnd });\n globalCounts.set(ms.value, list);\n }\n }\n\n for (const [value, locs] of globalCounts) {\n if (locs.length \u003c minOccurrences) continue;\n if (!canAddFinding(findings)) return findings;\n const uniqueFiles = [...new Set(locs.map(l => l.file))];\n const severity: Finding['severity'] = locs.length >= 8 ? 'high' : locs.length >= 5 ? 'medium' : 'low';\n findings.push({\n severity,\n category: 'magic-string',\n file: locs[0].file,\n lineStart: locs[0].lineStart,\n lineEnd: locs[0].lineEnd,\n title: `Magic string \"${value.length > 30 ? value.slice(0, 27) + '...' : value}\" appears ${locs.length} times`,\n reason: `The string literal \"${value}\" is used in ${locs.length} comparisons across ${uniqueFiles.length} file(s). If the value changes, every occurrence must be updated — a classic source of silent bugs.`,\n files: uniqueFiles,\n suggestedFix: {\n strategy: 'Extract to a named constant or enum.',\n steps: [\n `Create a const (e.g. const ${value.toUpperCase().replace(/[^A-Z0-9]/g, '_')} = '${value}').`,\n 'Replace all usages with the constant reference.',\n 'Consider an enum if there are multiple related string values.',\n ],\n },\n impact: 'Magic strings scatter domain knowledge across the codebase and are invisible to refactoring tools.',\n tags: ['magic-value', 'maintainability', 'duplication'],\n });\n }\n return findings;\n}\n\nexport function detectBooleanParameterCluster(\n fileSummaries: FileEntry[],\n threshold: number\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n if (!entry.booleanParamClusters) continue;\n for (const cluster of entry.booleanParamClusters) {\n if (cluster.booleanCount \u003c threshold) continue;\n if (!canAddFinding(findings)) return findings;\n findings.push({\n severity: 'medium',\n category: 'boolean-parameter-cluster',\n file: entry.file,\n lineStart: cluster.lineStart,\n lineEnd: cluster.lineEnd,\n title: `${cluster.booleanCount} boolean params in ${cluster.name || '\u003canon>'}`,\n reason: `Function has ${cluster.booleanCount} boolean parameters out of ${cluster.totalParams} total. Boolean flags are opaque at call sites (e.g. doThing(true, false, true)) and each flag doubles the function's behavior space.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Replace boolean clusters with an options object or separate functions.',\n steps: [\n 'Create an options/config object type with named fields.',\n 'Replace boolean parameters with the options object.',\n 'Consider splitting into distinct functions for each behavior variant.',\n ],\n },\n impact: 'Boolean parameter clusters make call sites unreadable and the function hard to test — 2^N behavior combinations.',\n tags: ['api-design', 'readability', 'parameters'],\n });\n }\n }\n return findings;\n}\n\nexport function detectPromiseAllUnhandled(fileSummaries: FileEntry[]): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n if (!entry.promiseAllUnhandled) continue;\n for (const loc of entry.promiseAllUnhandled) {\n if (!canAddFinding(findings)) return findings;\n findings.push({\n severity: 'medium',\n category: 'promise-all-unhandled',\n file: entry.file,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n title: `${loc.kind} without error handling`,\n reason: `${loc.kind} is called without a surrounding try-catch or .catch() chain. If any of the composed promises reject, the rejection will propagate unhandled.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Wrap in try-catch or add .catch() to the promise chain.',\n steps: [\n 'Add a try-catch around the await expression.',\n 'Or chain a .catch() handler onto the Promise combinator.',\n 'Consider Promise.allSettled if partial failure is acceptable.',\n ],\n },\n impact: 'Unhandled rejections from promise combinators crash Node.js processes and cause silent failures in browsers.',\n tags: ['error-handling', 'async', 'reliability'],\n });\n }\n }\n return findings;\n}\n\nexport function detectExportSurfaceDensity(\n fileSummaries: FileEntry[],\n dependencyState?: DependencyState\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n if (!dependencyState) return findings;\n\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n const depProfile = entry.dependencyProfile;\n if (!depProfile) continue;\n const totalStatements = entry.functions.reduce((s, f) => s + f.statementCount, 0)\n + entry.flows.length;\n if (totalStatements \u003c 20) continue;\n const exportCount = depProfile.declaredExports?.length || 0;\n if (exportCount === 0) continue;\n const ratio = exportCount / totalStatements;\n if (ratio \u003c 0.5) continue;\n if (!canAddFinding(findings)) return findings;\n const severity: Finding['severity'] = ratio >= 0.8 ? 'high' : ratio >= 0.6 ? 'medium' : 'low';\n findings.push({\n severity,\n category: 'export-surface-density',\n file: entry.file,\n lineStart: 1,\n lineEnd: 1,\n title: `${Math.round(ratio * 100)}% export density (${exportCount} exports / ~${totalStatements} statements)`,\n reason: `This module exports ${exportCount} symbols from ~${totalStatements} total statements — a ${Math.round(ratio * 100)}% export surface. High export density means nearly everything is public API, increasing coupling and reducing the ability to refactor internals.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Reduce the public API by making non-essential symbols internal.',\n steps: [\n 'Audit each export — does it need to be consumed externally?',\n 'Convert unnecessary exports to module-private functions.',\n 'Consider splitting into a public facade and private implementation module.',\n ],\n },\n impact: 'High export density couples consumers to internal implementation, making any change a potential breaking change.',\n tags: ['encapsulation', 'api-surface', 'coupling'],\n });\n }\n return findings;\n}\n\nexport function detectChangeRisk(\n fileSummaries: FileEntry[],\n _flowMap: Map\u003cstring, FlowMapEntry[]>,\n _dependencyState?: DependencyState\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n let riskScore = 0;\n const signals: string[] = [];\n\n const avgComplexity = entry.functions.length > 0\n ? entry.functions.reduce((s, f) => s + f.complexity, 0) / entry.functions.length\n : 0;\n if (avgComplexity > 15) {\n riskScore += 2;\n signals.push(`high avg complexity (${avgComplexity.toFixed(1)})`);\n }\n\n const maxCognitive = entry.functions.reduce((m, f) => Math.max(m, f.cognitiveComplexity), 0);\n if (maxCognitive > 20) {\n riskScore += 2;\n signals.push(`high cognitive complexity (${maxCognitive})`);\n }\n\n const lowMiCount = entry.functions.filter(f => f.maintainabilityIndex !== undefined && f.maintainabilityIndex \u003c 20).length;\n if (lowMiCount > 0) {\n riskScore += lowMiCount;\n signals.push(`${lowMiCount} function(s) with low MI`);\n }\n\n if (entry.emptyCatches && entry.emptyCatches.length > 0) {\n riskScore += 1;\n signals.push(`${entry.emptyCatches.length} empty catches`);\n }\n if (entry.promiseAllUnhandled && entry.promiseAllUnhandled.length > 0) {\n riskScore += 1;\n signals.push(`${entry.promiseAllUnhandled.length} unhandled promise combinators`);\n }\n\n const depProfile = entry.dependencyProfile;\n if (depProfile && depProfile.declaredExports) {\n const exportCount = depProfile.declaredExports.length;\n if (exportCount > 15) {\n riskScore += 1;\n signals.push(`${exportCount} exports`);\n }\n }\n\n if (riskScore \u003c 4) continue;\n if (!canAddFinding(findings)) return findings;\n const severity: Finding['severity'] = riskScore >= 8 ? 'critical' : riskScore >= 6 ? 'high' : 'medium';\n findings.push({\n severity,\n category: 'change-risk',\n file: entry.file,\n lineStart: 1,\n lineEnd: 1,\n title: `Change-risk score ${riskScore}: ${signals.slice(0, 3).join(', ')}`,\n reason: `This file has a composite change-risk score of ${riskScore}, derived from: ${signals.join('; ')}. Files with multiple overlapping quality signals are the most likely to introduce regressions when modified.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Reduce risk incrementally — address the highest-impact signal first.',\n steps: [\n 'Check test coverage for this file — add tests if missing.',\n 'Address the highest-severity individual finding first.',\n 'Consider splitting the module to isolate high-risk logic.',\n ],\n },\n impact: 'Files with multiple quality issues compound risk — each change is likely to trigger regressions in hard-to-predict ways.',\n tags: ['risk', 'composite', 'priority'],\n });\n }\n return findings;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":50906,"content_sha256":"4f83b0d8c268a5b857a62306c4e42dd2448ceb5f8b08d469dbdd07d8ed331273"},{"filename":"src/detectors/cohesion.ts","content":"import { findImportLine, isLikelyEntrypoint } from './shared.js';\nimport { canAddFinding } from './shared.js';\nimport { isTestFile } from '../common/utils.js';\n\nimport type { FindingDraft } from './shared.js';\nimport type {\n DependencyState,\n DependencySummary,\n FileCriticality,\n FileEntry,\n Finding,\n HotFile,\n} from '../types/index.js';\n\nexport function detectGodModules(\n fileSummaries: FileEntry[],\n dependencyState: DependencyState,\n stmtThreshold: number = 500,\n exportThreshold: number = 20\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n const totalStmts = entry.functions.reduce(\n (s, fn) => s + fn.statementCount,\n 0\n );\n const exportCount = (\n dependencyState.declaredExportsByFile.get(entry.file) || []\n ).length;\n const reasons: string[] = [];\n if (totalStmts > stmtThreshold)\n reasons.push(`${totalStmts} statements (threshold: ${stmtThreshold})`);\n if (exportCount > exportThreshold)\n reasons.push(`${exportCount} exports (threshold: ${exportThreshold})`);\n if (reasons.length === 0) continue;\n\n if (!canAddFinding(findings)) break;\n findings.push({\n severity: 'high',\n category: 'god-module',\n file: entry.file,\n lineStart: 1,\n lineEnd: 1,\n title: `God module: ${entry.file}`,\n reason: `Module is excessively large: ${reasons.join('; ')}.`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Split module into focused sub-modules with single responsibilities.',\n steps: [\n 'Identify distinct functional groups within the module.',\n 'Extract each group into a dedicated module.',\n 'Create a barrel if backward compatibility is needed.',\n 'Update imports incrementally.',\n ],\n },\n impact: 'Smaller modules are easier to understand, test, and maintain.',\n tags: ['complexity', 'responsibility', 'size'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: entry.file.split('/').pop() || entry.file,\n lineHint: 1,\n file: entry.file,\n expectedResult: `identify consumer clusters to guide module splitting strategy`,\n },\n ],\n });\n }\n\n return findings;\n}\n\nfunction folderOf(filePath: string): string {\n const normalized = filePath.replace(/\\\\/g, '/');\n const idx = normalized.lastIndexOf('/');\n return idx === -1 ? '.' : normalized.slice(0, idx);\n}\n\nexport function detectMegaFolders(\n fileSummaries: FileEntry[],\n minFiles: number = 25,\n concentrationThreshold: number = 0.25\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n const productionFiles = fileSummaries.filter(\n entry => !isTestFile(entry.file)\n );\n if (productionFiles.length === 0) return findings;\n\n const byFolder = new Map\u003cstring, FileEntry[]>();\n for (const entry of productionFiles) {\n const folder = folderOf(entry.file);\n if (!byFolder.has(folder)) byFolder.set(folder, []);\n byFolder.get(folder)!.push(entry);\n }\n\n const sortedFolders = [...byFolder.entries()]\n .map(([folder, entries]) => ({ folder, entries, count: entries.length }))\n .filter(\n ({ count }) =>\n count >= minFiles &&\n count / productionFiles.length >= concentrationThreshold\n )\n .sort((a, b) => b.count - a.count);\n\n for (const candidate of sortedFolders) {\n const concentration = candidate.count / productionFiles.length;\n const severity: Finding['severity'] =\n concentration >= 0.5 || candidate.count >= 50 ? 'high' : 'medium';\n const topFiles = candidate.entries\n .map(entry => entry.file)\n .sort()\n .slice(0, 8);\n const representativeFile = candidate.entries[0]?.file ?? candidate.folder;\n\n if (!canAddFinding(findings)) break;\n findings.push({\n severity,\n category: 'mega-folder',\n file: representativeFile,\n lineStart: 1,\n lineEnd: 1,\n title: `Mega folder: ${candidate.folder} (${candidate.count} files)`,\n reason: `${candidate.folder} contains ${candidate.count} production files (${(concentration * 100).toFixed(1)}% of the codebase), which usually indicates mixed responsibilities and weak module boundaries.`,\n files: topFiles,\n suggestedFix: {\n strategy:\n 'Map the import graph, identify domain clusters, then restructure with an automated migration script.',\n steps: [\n 'Extract the local import graph (rg/localSearchCode) and group files into clusters by what imports what.',\n 'Design target directories that follow the data flow (e.g., types → parsing → analysis → detection → reporting → orchestration).',\n 'Write a disposable migration script that maps old basenames to { dir, name } targets, moves files, and rewrites all relative import paths atomically.',\n 'Validate after each phase: tsc --noEmit, eslint --fix, test suite.',\n 'Move shared primitives into a dedicated common/ folder to avoid cross-domain coupling.',\n ],\n },\n impact:\n 'Improves navigability, ownership boundaries, and change isolation.',\n tags: [\n 'architecture',\n 'modularity',\n 'folder-structure',\n 'maintainability',\n ],\n evidence: {\n folderPath: candidate.folder,\n fileCount: candidate.count,\n totalProductionFiles: productionFiles.length,\n concentration,\n },\n lspHints: [\n {\n tool: 'lspGotoDefinition',\n symbolName: candidate.folder,\n lineHint: 1,\n file: representativeFile,\n expectedResult:\n 'inventory representative modules in this folder before planning decomposition',\n },\n ],\n });\n }\n\n return findings;\n}\n\nexport function detectGodFunctions(\n fileSummaries: FileEntry[],\n stmtThreshold: number = 100,\n miThreshold: number = 10\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n const MIN_LOC_FOR_MI = 30;\n\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const fn of entry.functions) {\n const byStatements = fn.statementCount > stmtThreshold;\n const byMI =\n fn.maintainabilityIndex !== undefined &&\n fn.maintainabilityIndex \u003c miThreshold &&\n fn.lengthLines > MIN_LOC_FOR_MI;\n\n if (byStatements || byMI) {\n const miNote =\n byMI && fn.maintainabilityIndex !== undefined\n ? ` MI=${fn.maintainabilityIndex.toFixed(1)} (threshold: ${miThreshold}).`\n : '';\n const stmtNote = byStatements\n ? `${fn.statementCount} statements (threshold: ${stmtThreshold}).`\n : '';\n findings.push({\n severity: 'high',\n category: 'god-function',\n file: entry.file,\n lineStart: fn.lineStart,\n lineEnd: fn.lineEnd,\n title: `God function: ${fn.name}`,\n reason: `Function \"${fn.name}\" triggers god-function detection. ${stmtNote}${miNote}`.trim(),\n files: [`${entry.file}:${fn.lineStart}-${fn.lineEnd}`],\n suggestedFix: {\n strategy: 'Break down into smaller, focused functions.',\n steps: [\n 'Identify logical steps within the function.',\n 'Extract each step into a named helper.',\n 'Keep the original as a high-level orchestrator.',\n 'Test each extracted function independently.',\n ],\n },\n impact: 'Improves readability, testability, and maintenance.',\n tags: ['complexity', 'responsibility', 'size'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: fn.name,\n lineHint: fn.lineStart,\n file: entry.file,\n expectedResult: `map callers and callees to identify safe extraction boundaries for ${fn.name}`,\n },\n ],\n });\n }\n }\n }\n\n return findings;\n}\n\nexport function detectLowCohesion(\n dependencyState: DependencyState,\n minExports: number = 3\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const file of dependencyState.files) {\n if (isTestFile(file) || isLikelyEntrypoint(file)) continue;\n\n const exports = dependencyState.declaredExportsByFile.get(file);\n if (!exports || exports.length \u003c minExports) continue;\n\n const exportNames = new Set(exports.map(e => e.name));\n\n const symbolConsumers = new Map\u003cstring, Set\u003cstring>>();\n for (const [\n consumer,\n imports,\n ] of dependencyState.importedSymbolsByFile.entries()) {\n for (const imp of imports) {\n if (imp.resolvedModule !== file) continue;\n if (!exportNames.has(imp.importedName)) continue;\n if (!symbolConsumers.has(imp.importedName))\n symbolConsumers.set(imp.importedName, new Set());\n symbolConsumers.get(imp.importedName)!.add(consumer);\n }\n }\n\n const consumedSymbols = [...symbolConsumers.keys()];\n if (consumedSymbols.length \u003c 2) continue;\n\n const adj = new Map\u003cstring, Set\u003cstring>>();\n for (const sym of consumedSymbols) adj.set(sym, new Set());\n\n for (const imports of dependencyState.importedSymbolsByFile.values()) {\n const fromThisFile = imports\n .filter(\n i => i.resolvedModule === file && exportNames.has(i.importedName)\n )\n .map(i => i.importedName);\n for (let i = 0; i \u003c fromThisFile.length; i++) {\n for (let j = i + 1; j \u003c fromThisFile.length; j++) {\n adj.get(fromThisFile[i])?.add(fromThisFile[j]);\n adj.get(fromThisFile[j])?.add(fromThisFile[i]);\n }\n }\n }\n\n const visited = new Set\u003cstring>();\n let components = 0;\n for (const sym of consumedSymbols) {\n if (visited.has(sym)) continue;\n components++;\n const queue = [sym];\n while (queue.length > 0) {\n const curr = queue.pop()!;\n if (visited.has(curr)) continue;\n visited.add(curr);\n for (const neighbor of adj.get(curr) || []) {\n if (!visited.has(neighbor)) queue.push(neighbor);\n }\n }\n }\n\n if (components > 1) {\n findings.push({\n severity: components >= 4 ? 'high' : 'medium',\n category: 'low-cohesion',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `Low cohesion: ${file} (LCOM=${components})`,\n reason: `Module exports ${consumedSymbols.length} consumed symbols that form ${components} independent groups. Consumers never import symbols across groups — the module serves unrelated purposes.`,\n files: [file],\n suggestedFix: {\n strategy: `Split into ${components} focused modules, one per cohesion group.`,\n steps: [\n 'Identify which exports belong to each independent group.',\n 'Create a new module for each group with a descriptive name.',\n 'Move exports and their dependencies to the appropriate module.',\n 'Update consumer imports to point to the new modules.',\n ],\n },\n impact:\n 'Higher cohesion = easier navigation, focused testing, and smaller change blast radius.',\n tags: ['cohesion', 'responsibility', 'architecture'],\n });\n }\n }\n\n return findings;\n}\n\nexport function computeHotFiles(\n dependencyState: DependencyState,\n dependencySummary: DependencySummary,\n fileCriticalityByPath: Map\u003cstring, FileCriticality>,\n maxResults: number = 20\n): HotFile[] {\n const cycleFiles = new Set\u003cstring>();\n for (const cycle of dependencySummary.cycles) {\n for (const node of cycle.path) cycleFiles.add(node);\n }\n\n const criticalPathFiles = new Set\u003cstring>();\n for (const cp of dependencySummary.criticalPaths) {\n for (const node of cp.path) criticalPathFiles.add(node);\n }\n\n const results: HotFile[] = [];\n for (const file of dependencyState.files) {\n if (isTestFile(file)) continue;\n\n const fanIn = (dependencyState.incoming.get(file) || new Set()).size;\n const fanOut = (dependencyState.outgoing.get(file) || new Set()).size;\n const crit = fileCriticalityByPath.get(file);\n const complexityScore = crit?.score ?? 0;\n const exportCount = (dependencyState.declaredExportsByFile.get(file) || [])\n .length;\n const inCycle = cycleFiles.has(file);\n const onCriticalPath = criticalPathFiles.has(file);\n\n const riskScore = Math.round(\n fanIn * 3 +\n complexityScore * 0.5 +\n exportCount * 1.5 +\n fanOut * 0.5 +\n (inCycle ? 20 : 0) +\n (onCriticalPath ? 10 : 0)\n );\n\n if (riskScore > 0) {\n results.push({\n file,\n riskScore,\n fanIn,\n fanOut,\n complexityScore,\n exportCount,\n inCycle,\n onCriticalPath,\n });\n }\n }\n\n results.sort((a, b) => b.riskScore - a.riskScore);\n return results.slice(0, maxResults);\n}\n\nexport function detectUntestedCriticalCode(\n dependencyState: DependencyState,\n hotFiles: HotFile[],\n fileCriticalityByPath: Map\u003cstring, FileCriticality>,\n criticalityScoreThreshold: number = 40\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n const seen = new Set\u003cstring>();\n\n const hasTestCoverage = (file: string): boolean => {\n const testImporters = dependencyState.incomingFromTests.get(file);\n return !!testImporters && testImporters.size > 0;\n };\n\n const addFinding = (\n file: string,\n riskScore: number,\n reasons: string[]\n ): void => {\n if (seen.has(file)) return;\n seen.add(file);\n if (isTestFile(file)) return;\n if (hasTestCoverage(file)) return;\n\n const isCritical = riskScore >= 60;\n if (!canAddFinding(findings)) return;\n findings.push({\n severity: isCritical ? 'critical' : 'high',\n category: 'untested-critical-code',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `Untested critical code: ${file}`,\n reason: `High-risk file has no test imports. ${reasons.join('; ')} (risk score: ${riskScore}).`,\n files: [file],\n suggestedFix: {\n strategy: 'Add test coverage for this critical module.',\n steps: [\n 'Create a test file that imports and exercises the public API of this module.',\n 'Focus on the highest-complexity functions and exported behaviors first.',\n 'Add integration tests if this module sits on a critical dependency path.',\n 'Consider property-based tests for complex data transformations.',\n ],\n },\n impact:\n 'Untested critical code is the highest-risk area for regressions and undetected bugs.',\n tags: ['testing', 'coverage', 'change-risk', 'critical'],\n });\n };\n\n for (const hf of hotFiles) {\n const reasons: string[] = [];\n reasons.push(\n `fan-in=${hf.fanIn}, fan-out=${hf.fanOut}, complexity=${hf.complexityScore}`\n );\n if (hf.inCycle) reasons.push('in dependency cycle');\n if (hf.onCriticalPath) reasons.push('on critical dependency path');\n addFinding(hf.file, hf.riskScore, reasons);\n }\n\n for (const [file, crit] of fileCriticalityByPath) {\n if (crit.score \u003c criticalityScoreThreshold) continue;\n const reasons = [\n `high complexity score (${crit.score}), ${crit.highComplexityFunctions} high-complexity functions`,\n ];\n addFinding(file, crit.score, reasons);\n }\n\n findings.sort((a, b) => {\n const sevOrder: Record\u003cstring, number> = {\n critical: 4,\n high: 3,\n medium: 2,\n low: 1,\n info: 0,\n };\n return (sevOrder[b.severity] || 0) - (sevOrder[a.severity] || 0);\n });\n\n return findings.slice(0, 25);\n}\n\nexport function detectFeatureEnvy(\n dependencyState: DependencyState,\n envyRatio: number = 0.6,\n minSymbols: number = 5\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const [\n file,\n imports,\n ] of dependencyState.importedSymbolsByFile.entries()) {\n if (isTestFile(file)) continue;\n if (!dependencyState.files.has(file)) continue;\n\n const internalImports = imports.filter(\n i => i.resolvedModule && !i.isTypeOnly\n );\n if (internalImports.length \u003c minSymbols) continue;\n\n const countByTarget = new Map\u003cstring, number>();\n for (const imp of internalImports) {\n if (!imp.resolvedModule) continue;\n countByTarget.set(\n imp.resolvedModule,\n (countByTarget.get(imp.resolvedModule) || 0) + 1\n );\n }\n\n for (const [target, count] of countByTarget) {\n const ratio = count / internalImports.length;\n if (ratio >= envyRatio && count >= minSymbols) {\n const importRef = findImportLine(dependencyState, file, target);\n findings.push({\n severity: ratio > 0.8 ? 'high' : 'medium',\n category: 'feature-envy',\n file,\n lineStart: importRef.lineStart,\n lineEnd: importRef.lineEnd,\n title: `Feature envy: ${file} → ${target}`,\n reason: `Module imports ${count}/${internalImports.length} symbols (${(ratio * 100).toFixed(0)}%) from \"${target}\". This suggests the logic may belong in or closer to the target module.`,\n files: [file, target],\n suggestedFix: {\n strategy:\n 'Move dependent logic to the target module or extract a shared module.',\n steps: [\n 'Identify which functions/logic in this file use the imported symbols.',\n 'Move that logic to the target module if it belongs there.',\n 'If shared, extract a dedicated module that both can import from.',\n 'Reduce the import surface by passing data instead of importing behaviors.',\n ],\n },\n impact:\n 'Misplaced logic increases coupling and makes changes ripple across module boundaries.',\n tags: ['coupling', 'responsibility', 'misplaced-logic'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: file.split('/').pop() || file,\n lineHint: importRef.lineStart,\n file,\n expectedResult: `trace which functions use imports from ${target} to decide what to move`,\n },\n {\n tool: 'lspGotoDefinition',\n symbolName: target.split('/').pop() || target,\n lineHint: importRef.lineStart,\n file,\n expectedResult: `inspect target module to evaluate if logic belongs there`,\n },\n ],\n });\n }\n }\n }\n\n return findings;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":18496,"content_sha256":"437347202da86a3cb33bc8afd5569b6f8d8b4dbb33789bb55a620afe48e74c1d"},{"filename":"src/detectors/coupling.ts","content":"import { findImportLine } from './shared.js';\nimport { canAddFinding } from './shared.js';\nimport { isTestFile } from '../common/utils.js';\n\nimport type { FindingDraft } from './shared.js';\nimport type { DependencyState } from '../types/index.js';\n\nexport function computeInstability(\n inboundCount: number,\n outboundCount: number\n): number {\n const total = inboundCount + outboundCount;\n if (total === 0) return 0;\n return outboundCount / total;\n}\n\nexport function detectSdpViolations(\n dependencyState: DependencyState,\n minDelta: number = 0.15,\n maxSourceInstability: number = 0.6\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n const cache = new Map\u003cstring, number>();\n\n const getI = (file: string): number => {\n if (cache.has(file)) return cache.get(file)!;\n const ca = (dependencyState.incoming.get(file) || new Set()).size;\n const ce = (dependencyState.outgoing.get(file) || new Set()).size;\n const i = computeInstability(ca, ce);\n cache.set(file, i);\n return i;\n };\n\n for (const file of dependencyState.files) {\n if (isTestFile(file)) continue;\n const deps = dependencyState.outgoing.get(file) || new Set();\n const iSrc = getI(file);\n\n for (const dep of deps) {\n if (!dependencyState.files.has(dep) || isTestFile(dep)) continue;\n const iTgt = getI(dep);\n const delta = iTgt - iSrc;\n\n if (delta > minDelta && iSrc \u003c maxSourceInstability) {\n const importRef = findImportLine(dependencyState, file, dep);\n findings.push({\n severity: delta > 0.3 ? 'high' : 'medium',\n category: 'architecture-sdp-violation',\n file,\n lineStart: importRef.lineStart,\n lineEnd: importRef.lineEnd,\n title: `SDP violation: stable module depends on unstable module`,\n reason: `\"${file}\" (I=${iSrc.toFixed(2)}) depends on \"${dep}\" (I=${iTgt.toFixed(2)}). Delta=${delta.toFixed(2)}.`,\n files: [file, dep],\n suggestedFix: {\n strategy:\n 'Invert dependency via interface/abstraction or move shared code to a stable utility.',\n steps: [\n 'Extract a stable interface that the stable module depends on.',\n 'Have the unstable module implement that interface.',\n 'Consider moving shared logic to a lower-instability utility module.',\n ],\n },\n impact:\n 'Prevents cascading instability and reduces change propagation risk.',\n tags: ['stability', 'coupling', 'architecture', 'sdp'],\n });\n }\n }\n }\n\n return findings;\n}\n\nexport function detectHighCoupling(\n dependencyState: DependencyState,\n threshold: number = 15\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const file of dependencyState.files) {\n if (isTestFile(file)) continue;\n const ca = (dependencyState.incoming.get(file) || new Set()).size;\n const ce = (dependencyState.outgoing.get(file) || new Set()).size;\n const total = ca + ce;\n\n if (total > threshold) {\n findings.push({\n severity: total > 25 ? 'high' : 'medium',\n category: 'high-coupling',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `High coupling: ${file}`,\n reason: `Module has ${total} total connections (Ca=${ca}, Ce=${ce}). Threshold: ${threshold}.`,\n files: [file],\n suggestedFix: {\n strategy:\n 'Reduce coupling by extracting interfaces or splitting module responsibilities.',\n steps: [\n 'Identify groups of related imports/dependents that can be isolated.',\n 'Extract focused sub-modules with single responsibilities.',\n 'Use dependency inversion to reduce direct coupling.',\n ],\n },\n impact:\n 'Lower coupling reduces change ripple effects and improves testability.',\n tags: ['coupling', 'change-risk', 'architecture'],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectGodModuleCoupling(\n dependencyState: DependencyState,\n fanInThreshold: number = 20,\n fanOutThreshold: number = 15\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const file of dependencyState.files) {\n if (isTestFile(file)) continue;\n const fanIn = (dependencyState.incoming.get(file) || new Set()).size;\n const fanOut = (dependencyState.outgoing.get(file) || new Set()).size;\n\n if (fanIn > fanInThreshold) {\n findings.push({\n severity: fanIn > fanInThreshold * 1.5 ? 'high' : 'medium',\n category: 'god-module-coupling',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `High fan-in bottleneck: ${file}`,\n reason: `Module is depended on by ${fanIn} modules (threshold: ${fanInThreshold}). Changes ripple widely.`,\n files: [file],\n suggestedFix: {\n strategy:\n 'Split this module into focused sub-modules to reduce blast radius.',\n steps: [\n 'Identify distinct groups of consumers using different parts of this module.',\n 'Extract each group into a dedicated module.',\n 'Update import paths incrementally.',\n ],\n },\n impact:\n 'Reduces change blast radius and improves parallel development.',\n tags: ['coupling', 'blast-radius', 'bottleneck'],\n });\n }\n\n if (fanOut > fanOutThreshold) {\n findings.push({\n severity: fanOut > fanOutThreshold * 1.5 ? 'high' : 'medium',\n category: 'god-module-coupling',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `High fan-out: ${file}`,\n reason: `Module depends on ${fanOut} modules (threshold: ${fanOutThreshold}). It may violate single responsibility.`,\n files: [file],\n suggestedFix: {\n strategy:\n 'Reduce dependencies by introducing facade or mediator patterns.',\n steps: [\n 'Group related imports behind a single facade module.',\n 'Consider splitting this module by responsibility.',\n 'Use dependency injection to reduce direct coupling.',\n ],\n },\n impact:\n 'Cleaner architecture and easier testing through reduced dependencies.',\n tags: ['coupling', 'responsibility', 'sprawl'],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectLayerViolations(\n dependencyState: DependencyState,\n layerOrder: string[]\n): FindingDraft[] {\n if (layerOrder.length \u003c 2) return [];\n\n const findings: FindingDraft[] = [];\n\n const getLayer = (file: string): number => {\n for (let i = 0; i \u003c layerOrder.length; i++) {\n if (file.includes(layerOrder[i])) return i;\n }\n return -1;\n };\n\n for (const file of dependencyState.files) {\n if (isTestFile(file)) continue;\n const srcLayer = getLayer(file);\n if (srcLayer === -1) continue;\n\n for (const dep of dependencyState.outgoing.get(file) || new Set()) {\n if (!dependencyState.files.has(dep) || isTestFile(dep)) continue;\n const depLayer = getLayer(dep);\n if (depLayer === -1) continue;\n\n if (depLayer \u003c srcLayer) {\n const importRef = findImportLine(dependencyState, file, dep);\n findings.push({\n severity: 'high',\n category: 'layer-violation',\n file,\n lineStart: importRef.lineStart,\n lineEnd: importRef.lineEnd,\n title: `Layer violation: ${layerOrder[srcLayer]} imports from ${layerOrder[depLayer]}`,\n reason: `\"${file}\" (layer: ${layerOrder[srcLayer]}) imports \"${dep}\" (layer: ${layerOrder[depLayer]}). Layer order: ${layerOrder.join(' → ')}.`,\n files: [file, dep],\n suggestedFix: {\n strategy:\n 'Respect layer boundaries by inverting the dependency or moving shared logic.',\n steps: [\n 'Extract shared contracts to a lower layer that both can depend on.',\n 'Use dependency inversion: define an interface in the lower layer, implement in higher.',\n 'If the dependency is justified, reconsider your layer boundaries.',\n ],\n },\n impact:\n 'Prevents architectural erosion and keeps dependency flow unidirectional.',\n tags: ['architecture', 'layering', 'coupling'],\n });\n }\n }\n }\n\n return findings;\n}\n\nexport function computeAbstractness(\n exports: { name: string; kind: string }[]\n): number {\n if (exports.length === 0) return 0;\n const abstractCount = exports.filter(e => e.kind === 'type').length;\n return abstractCount / exports.length;\n}\n\nexport function detectDistanceFromMainSequence(\n dependencyState: DependencyState,\n distanceThreshold: number = 0.7,\n minCoupling: number = 3\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const file of dependencyState.files) {\n if (isTestFile(file)) continue;\n\n const exports = dependencyState.declaredExportsByFile.get(file);\n if (!exports || exports.length === 0) continue;\n\n const ca = (dependencyState.incoming.get(file) || new Set()).size;\n const ce = (dependencyState.outgoing.get(file) || new Set()).size;\n if (ca + ce \u003c minCoupling) continue;\n\n const I = computeInstability(ca, ce);\n const A = computeAbstractness(exports);\n const D = Math.abs(A + I - 1);\n\n if (D \u003c distanceThreshold) continue;\n\n const isZoneOfPain = A \u003c 0.2 && I \u003c 0.3;\n const isZoneOfUselessness = A > 0.7 && I > 0.7;\n\n let zone = '';\n if (isZoneOfPain)\n zone =\n 'Zone of Pain (concrete + stable): hard to extend, painful to change.';\n else if (isZoneOfUselessness)\n zone =\n 'Zone of Uselessness (abstract + unstable): over-abstracted and unused.';\n else\n zone = `Far from Main Sequence: balance between abstraction and stability is off.`;\n\n if (!canAddFinding(findings)) break;\n findings.push({\n severity: D > 0.85 ? 'high' : 'medium',\n category: 'distance-from-main-sequence',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `Distance from Main Sequence: ${file} (D=${D.toFixed(2)})`,\n reason: `${zone} A=${A.toFixed(2)}, I=${I.toFixed(2)}, D=${D.toFixed(2)} (threshold: ${distanceThreshold}).`,\n files: [file],\n suggestedFix: {\n strategy: isZoneOfPain\n ? 'Add abstractions (interfaces/types) or reduce inbound coupling.'\n : isZoneOfUselessness\n ? 'Add concrete implementations or remove unused abstractions.'\n : 'Rebalance by adjusting abstraction level or dependency direction.',\n steps: isZoneOfPain\n ? [\n 'Extract interfaces for key behaviors to increase abstractness.',\n 'Consider splitting into abstract contracts + concrete implementations.',\n 'Reduce inbound coupling by narrowing the public API surface.',\n ]\n : isZoneOfUselessness\n ? [\n 'Verify abstractions have concrete implementations.',\n 'Remove unused interfaces/types that serve no consumer.',\n 'Consider consolidating with concrete modules.',\n ]\n : [\n 'Review the balance between interfaces/types and concrete exports.',\n 'Adjust dependency direction to move closer to the Main Sequence.',\n 'Consider splitting responsibilities between abstract and concrete modules.',\n ],\n },\n impact:\n 'Modules on the Main Sequence (D≈0) have optimal balance between stability and extensibility.',\n tags: ['architecture', 'stability', 'abstractness', 'sdp'],\n });\n }\n\n return findings;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":11630,"content_sha256":"ca5666fe27db39a3d710c109f392783785fd84acc0ddd3106d715aa51bf78d47"},{"filename":"src/detectors/cycle.ts","content":"import { findImportLine, isLikelyEntrypoint } from './shared.js';\nimport { canAddFinding } from './shared.js';\nimport { isTestFile } from '../common/utils.js';\n\nimport type { FindingDraft } from './shared.js';\nimport type {\n DependencyState,\n DependencySummary,\n} from '../types/index.js';\n\nexport function detectTestOnlyModules(\n dependencySummary: DependencySummary\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n if (dependencySummary.testOnlyModules?.length === 0) return findings;\n for (const file of (dependencySummary.testOnlyModules || []).slice(0, 25)) {\n if (!canAddFinding(findings)) break;\n findings.push({\n severity: 'medium',\n category: 'dependency-test-only',\n file: file.file,\n lineStart: file.lineStart || 1,\n lineEnd: file.lineEnd || 1,\n title: `Module imported only from tests: ${file.file}`,\n reason:\n 'No production file imports this module, but tests do. Verify if this module belongs in test fixtures/helpers.',\n files: [file.file],\n suggestedFix: {\n strategy:\n 'Move test-only utilities to test scope or make production usage explicit.',\n steps: [\n 'Re-run import scanning after moving test-only modules to __tests__ or helper folders.',\n 'If this is shared production utility, add a non-test entrypoint/import.',\n 'Remove dead or stale production references and delete unused module if confirmed.',\n ],\n },\n impact:\n 'Reduces shipping of non-production-only modules and clarifies ownership boundaries.',\n tags: ['testing', 'dead-code', 'dependency'],\n });\n }\n return findings;\n}\n\nexport function detectDependencyCycles(\n dependencySummary: DependencySummary,\n dependencyState: DependencyState\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n if (dependencySummary.cycles?.length === 0) return findings;\n for (const cycle of (dependencySummary.cycles || []).slice(0, 15)) {\n const cycleLine = findImportLine(\n dependencyState,\n cycle.path[0],\n cycle.path[1]\n );\n if (!canAddFinding(findings)) break;\n findings.push({\n severity: 'high',\n category: 'dependency-cycle',\n file: cycle.path[0],\n lineStart: cycleLine.lineStart,\n lineEnd: cycleLine.lineEnd,\n title: `Dependency cycle detected (${cycle.nodeCount} node cycle)`,\n reason: `Import cycle exists across: ${cycle.path.join(' -> ')}`,\n files: cycle.path,\n suggestedFix: {\n strategy:\n 'Break the cycle with a lower-level abstraction or interface module.',\n steps: [\n 'Extract shared contracts/types to a dedicated contract/shared package.',\n 'Move implementation in one direction using dependency inversion.',\n 'Split stateful modules into protocol and runtime layers.',\n ],\n },\n impact:\n 'Cycles increase coupling and make incremental loading/debugging and refactors riskier.',\n tags: ['cycle', 'coupling', 'dependency', 'change-risk'],\n lspHints: [\n {\n tool: 'lspGotoDefinition',\n symbolName: cycle.path[1],\n lineHint: cycleLine.lineStart,\n file: cycle.path[0],\n expectedResult: `navigate to the import that creates the cycle edge`,\n },\n ],\n });\n }\n return findings;\n}\n\nfunction findChainHotspot(\n chainPath: string[],\n dependencyState: DependencyState\n): { module: string; fanOut: number; fanIn: number } {\n let best = { module: chainPath[0], fanOut: 0, fanIn: 0 };\n for (const mod of chainPath) {\n const fanOut = (dependencyState.outgoing.get(mod) || new Set()).size;\n const fanIn = (dependencyState.incoming.get(mod) || new Set()).size;\n if (fanOut > best.fanOut) {\n best = { module: mod, fanOut, fanIn };\n }\n }\n return best;\n}\n\nexport function mergeOverlappingChains(\n findings: FindingDraft[],\n overlapThreshold: number = 0.8\n): FindingDraft[] {\n if (findings.length \u003c= 1) return findings;\n\n const merged: FindingDraft[] = [];\n const consumed = new Set\u003cnumber>();\n\n for (let i = 0; i \u003c findings.length; i++) {\n if (consumed.has(i)) continue;\n const base = findings[i];\n const baseSet = new Set(base.files);\n const entryPoints = [base.file];\n\n for (let j = i + 1; j \u003c findings.length; j++) {\n if (consumed.has(j)) continue;\n const other = findings[j];\n const otherSet = new Set(other.files);\n const intersection = [...baseSet].filter(f => otherSet.has(f)).length;\n const union = new Set([...baseSet, ...otherSet]).size;\n const overlap = union > 0 ? intersection / union : 0;\n\n if (overlap >= overlapThreshold) {\n consumed.add(j);\n entryPoints.push(other.file);\n for (const f of other.files) baseSet.add(f);\n }\n }\n\n if (entryPoints.length > 1) {\n const allFiles = [...baseSet];\n merged.push({\n ...base,\n title: `Critical dependency chain risk: ${allFiles.length} files (${entryPoints.length} entry points)`,\n reason:\n base.reason +\n ` Also reached from: ${entryPoints.slice(1).join(', ')}.`,\n files: allFiles,\n });\n } else {\n merged.push(base);\n }\n }\n\n return merged;\n}\n\nexport function detectCriticalPaths(\n dependencySummary: DependencySummary,\n dependencyState: DependencyState,\n criticalComplexityThreshold: number\n): FindingDraft[] {\n const rawFindings: FindingDraft[] = [];\n if (dependencySummary.criticalPaths?.length === 0) return rawFindings;\n for (const pathEntry of (dependencySummary.criticalPaths || []).slice(\n 0,\n 10\n )) {\n if (pathEntry.score \u003c criticalComplexityThreshold * 3) continue;\n const chainLine = findImportLine(\n dependencyState,\n pathEntry.path[0],\n pathEntry.path[1]\n );\n const hotspot = findChainHotspot(pathEntry.path, dependencyState);\n rawFindings.push({\n severity:\n pathEntry.score >= criticalComplexityThreshold * 6\n ? 'critical'\n : 'high',\n category: 'dependency-critical-path',\n file: pathEntry.path[0],\n lineStart: chainLine.lineStart,\n lineEnd: chainLine.lineEnd,\n title: `Critical dependency chain risk: ${pathEntry.length} files`,\n reason: `Potentially high-change surface: ${pathEntry.path.join(' -> ')} (${pathEntry.score} weight).`,\n files: pathEntry.path,\n suggestedFix: {\n strategy: `Break chain at \\`${hotspot.module}\\` (fan-out: ${hotspot.fanOut}, fan-in: ${hotspot.fanIn}).`,\n steps: [\n `Extract interface from \\`${hotspot.module}\\` — it has ${hotspot.fanOut} outbound dependencies.`,\n 'Downstream modules depend on the interface, not the implementation.',\n 'This splits the chain into two independent segments.',\n ],\n },\n impact:\n 'Critical refactor opportunities; shorter chains reduce blast radius of change.',\n tags: ['change-risk', 'dependency', 'blast-radius'],\n });\n }\n return mergeOverlappingChains(rawFindings);\n}\n\nexport function detectDeadFiles(\n dependencySummary: DependencySummary,\n dependencyState: DependencyState\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const file of dependencySummary.roots || []) {\n if (isTestFile(file)) continue;\n if (isLikelyEntrypoint(file)) continue;\n const incomingCount = (dependencyState.incoming.get(file) || new Set())\n .size;\n const outgoingCount = (dependencyState.outgoing.get(file) || new Set())\n .size;\n if (incomingCount !== 0) continue;\n if (outgoingCount > 0) continue;\n if (!canAddFinding(findings)) break;\n findings.push({\n severity: 'medium',\n category: 'dead-file',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `Potential dead file: ${file}`,\n reason:\n 'File has no inbound imports and no outbound dependencies. It may be stale or orphaned.',\n files: [file],\n suggestedFix: {\n strategy: 'Validate ownership and remove if truly unused.',\n steps: [\n 'Confirm the file is not an explicit runtime entrypoint.',\n 'Search runtime config/router/bootstrap references for this file path.',\n 'Delete file if confirmed dead and re-run scan.',\n ],\n },\n impact: 'Reduces dead surface area and maintenance overhead.',\n tags: ['dead-code', 'cleanup', 'hygiene'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: file.split('/').pop() || file,\n lineHint: 1,\n file,\n expectedResult: `confirm zero references exist before deletion`,\n },\n ],\n });\n }\n return findings;\n}\n\nexport function detectOrphanModules(\n dependencyState: DependencyState\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const file of dependencyState.files) {\n if (isTestFile(file)) continue;\n if (isLikelyEntrypoint(file)) continue;\n\n const ca = (dependencyState.incoming.get(file) || new Set()).size;\n const ce = (dependencyState.outgoing.get(file) || new Set()).size;\n\n if (ca === 0 && ce === 0) {\n findings.push({\n severity: 'medium',\n category: 'orphan-module',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `Orphan module: ${file}`,\n reason:\n 'Module has no inbound or outbound dependencies — completely disconnected from the module graph.',\n files: [file],\n suggestedFix: {\n strategy: 'Delete if truly unused, or wire into module graph.',\n steps: [\n 'Check if the file is a runtime entrypoint, route, or config.',\n 'If truly disconnected, delete and re-run scan.',\n 'If needed, add an explicit import from the appropriate parent module.',\n ],\n },\n impact: 'Removes dead surface area and clarifies module ownership.',\n tags: ['dead-code', 'dependency', 'isolation'],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectUnreachableModules(\n dependencyState: DependencyState\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n const entrypoints = new Set\u003cstring>();\n for (const file of dependencyState.files) {\n if (isLikelyEntrypoint(file)) entrypoints.add(file);\n }\n if (entrypoints.size === 0) {\n for (const file of dependencyState.files) {\n if ((dependencyState.incoming.get(file) || new Set()).size === 0) {\n entrypoints.add(file);\n }\n }\n }\n\n const reachable = new Set\u003cstring>();\n const queue = [...entrypoints];\n while (queue.length > 0) {\n const current = queue.pop()!;\n if (reachable.has(current)) continue;\n reachable.add(current);\n for (const dep of dependencyState.outgoing.get(current) || new Set()) {\n if (dependencyState.files.has(dep) && !reachable.has(dep))\n queue.push(dep);\n }\n }\n\n for (const file of dependencyState.files) {\n if (isTestFile(file) || reachable.has(file) || isLikelyEntrypoint(file))\n continue;\n if (!canAddFinding(findings)) break;\n findings.push({\n severity: 'high',\n category: 'unreachable-module',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `Unreachable module: ${file}`,\n reason:\n 'Module is not reachable from any entrypoint via the import graph.',\n files: [file],\n suggestedFix: {\n strategy: 'Verify reachability and remove if truly dead.',\n steps: [\n 'Check if this module is loaded dynamically or via framework conventions.',\n 'Verify it is not registered as a route, plugin, or middleware.',\n 'If confirmed unreachable, delete and re-run scan.',\n ],\n },\n impact:\n 'Identifies potentially large sections of dead code missed by direct-import checks.',\n tags: ['dead-code', 'dependency', 'reachability'],\n });\n }\n\n return findings;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":11865,"content_sha256":"28004338c8b4cc3ebf161681b1237792a9c17af5c4c6fddae064140b4e3e0f22"},{"filename":"src/detectors/dead-code.ts","content":"import { findImportLine, isLikelyEntrypoint } from './shared.js';\nimport { isTestFile } from '../common/utils.js';\n\nimport type { FindingDraft } from './shared.js';\nimport type { DependencyState } from '../types/index.js';\n\nexport function buildConsumedFromModule(dependencyState: DependencyState): {\n production: Map\u003cstring, Set\u003cstring>>;\n test: Map\u003cstring, Set\u003cstring>>;\n} {\n const production = new Map\u003cstring, Set\u003cstring>>();\n const test = new Map\u003cstring, Set\u003cstring>>();\n for (const [\n file,\n imports,\n ] of dependencyState.importedSymbolsByFile.entries()) {\n const targetMap = isTestFile(file) ? test : production;\n for (const symbol of imports) {\n const target = symbol.resolvedModule;\n if (!target) continue;\n if (!targetMap.has(target)) targetMap.set(target, new Set());\n targetMap.get(target)!.add(symbol.importedName);\n }\n }\n for (const [file, reexports] of dependencyState.reExportsByFile.entries()) {\n const targetMap = isTestFile(file) ? test : production;\n for (const reexport of reexports) {\n const target = reexport.resolvedModule;\n if (!target) continue;\n if (!targetMap.has(target)) targetMap.set(target, new Set());\n targetMap.get(target)!.add(reexport.importedName);\n }\n }\n return { production, test };\n}\n\nexport function detectDeadExports(\n dependencyState: DependencyState,\n consumedFromModule: Map\u003cstring, Set\u003cstring>>,\n testConsumedFromModule?: Map\u003cstring, Set\u003cstring>>\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const [\n file,\n exportsList,\n ] of dependencyState.declaredExportsByFile.entries()) {\n if (isTestFile(file)) continue;\n if (isLikelyEntrypoint(file)) continue;\n const consumed = consumedFromModule.get(file) || new Set\u003cstring>();\n const testConsumed = testConsumedFromModule?.get(file) || new Set\u003cstring>();\n const hasNamespaceUse = consumed.has('*');\n const hasTestNamespaceUse = testConsumed.has('*');\n for (const exported of exportsList) {\n if (exported.name === 'default' && isLikelyEntrypoint(file)) continue;\n if (hasNamespaceUse || consumed.has(exported.name)) continue;\n if (hasTestNamespaceUse || testConsumed.has(exported.name)) continue;\n findings.push({\n severity: exported.kind === 'type' ? 'medium' : 'high',\n category: 'dead-export',\n file,\n lineStart: exported.lineStart || 1,\n lineEnd: exported.lineEnd || exported.lineStart || 1,\n title: `Unused export: ${exported.name}`,\n reason: `Exported symbol \"${exported.name}\" has no observed import or re-export usage in production or test files.`,\n files: [\n `${file}:${exported.lineStart || 1}-${exported.lineEnd || exported.lineStart || 1}`,\n ],\n suggestedFix: {\n strategy: 'Remove or internalize unused exports.',\n steps: [\n 'Confirm symbol is not part of intentional public API surface.',\n 'Remove export modifier or delete symbol if truly unused.',\n 'Re-run scan and tests to ensure no hidden runtime usage.',\n ],\n },\n impact: 'Shrinks public API surface and reduces accidental coupling.',\n tags: ['dead-code', 'api-surface', 'cleanup'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: exported.name,\n lineHint: exported.lineStart || 1,\n file,\n expectedResult: `confirm \"${exported.name}\" has no import references before removing`,\n },\n ],\n });\n }\n }\n return findings;\n}\n\nexport function detectDeadReExports(\n dependencyState: DependencyState,\n consumedFromModule: Map\u003cstring, Set\u003cstring>>\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const [\n barrelFile,\n reexports,\n ] of dependencyState.reExportsByFile.entries()) {\n if (isTestFile(barrelFile)) continue;\n const consumed = consumedFromModule.get(barrelFile) || new Set\u003cstring>();\n const hasNamespaceUse = consumed.has('*');\n const sourceByExportedAs = new Map\u003cstring, Set\u003cstring>>();\n const localExportNames = new Set(\n (dependencyState.declaredExportsByFile.get(barrelFile) || []).map(\n entry => entry.name\n )\n );\n\n for (const ref of reexports) {\n const exportedAs = ref.exportedAs;\n if (!sourceByExportedAs.has(exportedAs))\n sourceByExportedAs.set(exportedAs, new Set());\n sourceByExportedAs\n .get(exportedAs)!\n .add(ref.resolvedModule || ref.sourceModule);\n\n const isUsed =\n hasNamespaceUse ||\n consumed.has(exportedAs) ||\n (ref.isStar && consumed.size > 0);\n if (!isUsed) {\n findings.push({\n severity: 'medium',\n category: 'dead-re-export',\n file: barrelFile,\n lineStart: ref.lineStart || 1,\n lineEnd: ref.lineEnd || ref.lineStart || 1,\n title: `Unused re-export: ${exportedAs}`,\n reason: `Re-exported symbol \"${exportedAs}\" from ${ref.sourceModule} has no observed downstream imports from this module.`,\n files: [\n `${barrelFile}:${ref.lineStart || 1}-${ref.lineEnd || ref.lineStart || 1}`,\n ],\n suggestedFix: {\n strategy: 'Remove stale barrel re-exports.',\n steps: [\n 'Verify no dynamic import/runtime reflection depends on this export.',\n 'Remove the re-export clause.',\n 'Re-run scan to confirm barrel surface is still complete.',\n ],\n },\n impact: 'Keeps barrel modules focused and easier to reason about.',\n tags: ['dead-code', 'barrel', 'cleanup'],\n });\n }\n }\n\n for (const [name, sources] of sourceByExportedAs.entries()) {\n if (sources.size > 1) {\n findings.push({\n severity: 'medium',\n category: 're-export-duplication',\n file: barrelFile,\n lineStart: 1,\n lineEnd: 1,\n title: `Duplicate re-export paths: ${name}`,\n reason: `Symbol \"${name}\" is re-exported from multiple sources in the same barrel.`,\n files: [barrelFile],\n suggestedFix: {\n strategy: 'Keep one canonical re-export source per symbol.',\n steps: [\n 'Select a canonical module for the symbol.',\n 'Remove duplicate re-export paths.',\n 'Document intended public export map for the barrel.',\n ],\n },\n impact: 'Reduces API ambiguity and import inconsistency.',\n tags: ['duplication', 'barrel', 'api-surface'],\n });\n }\n if (name !== '*' && localExportNames.has(name)) {\n findings.push({\n severity: 'high',\n category: 're-export-shadowed',\n file: barrelFile,\n lineStart: 1,\n lineEnd: 1,\n title: `Shadowed export in barrel: ${name}`,\n reason: `Barrel exports \"${name}\" both locally and through re-export, which can hide origin and create ambiguity.`,\n files: [barrelFile],\n suggestedFix: {\n strategy: 'Disambiguate local vs re-exported symbol ownership.',\n steps: [\n 'Pick a single source of truth for the symbol in this barrel.',\n 'Rename or remove the conflicting export path.',\n 'Update import call-sites to use the canonical export.',\n ],\n },\n impact: 'Prevents subtle API conflicts and shadowing confusion.',\n tags: ['barrel', 'api-surface', 'ambiguity'],\n });\n }\n }\n }\n return findings;\n}\n\nexport function detectUnusedNpmDeps(\n externalDeps: Map\u003cstring, Set\u003cstring>>,\n packageJsonDeps: Record\u003cstring, string>,\n devDeps: Record\u003cstring, string> = {}\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n const usedPackages = new Set\u003cstring>();\n for (const depSet of externalDeps.values()) {\n for (const dep of depSet) {\n const parts = dep.split('/');\n usedPackages.add(\n dep.startsWith('@') && parts.length >= 2\n ? `${parts[0]}/${parts[1]}`\n : parts[0]\n );\n }\n }\n\n for (const pkgName of Object.keys(packageJsonDeps)) {\n if (!usedPackages.has(pkgName)) {\n findings.push({\n severity: 'medium',\n category: 'unused-npm-dependency',\n file: 'package.json',\n lineStart: 1,\n lineEnd: 1,\n title: `Unused dependency: ${pkgName}`,\n reason: `Package \"${pkgName}\" is in dependencies but no import was found.`,\n files: ['package.json'],\n suggestedFix: {\n strategy: 'Remove unused dependency from package.json.',\n steps: [\n 'Verify the package is not loaded dynamically or via CLI scripts.',\n 'Check if it is a peer dependency required at runtime.',\n 'Run `npm uninstall` or remove from package.json.',\n ],\n },\n impact: 'Reduces install size and attack surface.',\n tags: ['dependency', 'hygiene', 'bundle-size'],\n });\n }\n }\n\n for (const pkgName of Object.keys(devDeps)) {\n if (!usedPackages.has(pkgName)) {\n findings.push({\n severity: 'low',\n category: 'unused-npm-dependency',\n file: 'package.json',\n lineStart: 1,\n lineEnd: 1,\n title: `Unused devDependency: ${pkgName}`,\n reason: `Package \"${pkgName}\" is in devDependencies but no import was found.`,\n files: ['package.json'],\n suggestedFix: {\n strategy: 'Remove unused devDependency from package.json.',\n steps: [\n 'Verify the package is not used by build scripts, config files, or CLI tools.',\n 'Run `npm uninstall` or remove from package.json.',\n ],\n },\n impact: 'Reduces install size and dependency maintenance burden.',\n tags: ['dependency', 'hygiene', 'dev-tooling'],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectBoundaryViolations(\n dependencyState: DependencyState\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const file of dependencyState.files) {\n if (isTestFile(file)) continue;\n\n const fileMatch = file.match(/^packages\\/([^/]+)\\//);\n if (!fileMatch) continue;\n const filePkg = fileMatch[1];\n\n for (const dep of dependencyState.outgoing.get(file) || new Set()) {\n const depMatch = dep.match(/^packages\\/([^/]+)\\//);\n if (!depMatch) continue;\n if (depMatch[1] === filePkg) continue;\n\n const isPublicApi = /^packages\\/[^/]+\\/(src\\/)?index\\.[mc]?[jt]sx?$/.test(\n dep\n );\n if (!isPublicApi) {\n const isDeep = dep.includes('/internal/') || dep.includes('/private/');\n const importRef = findImportLine(dependencyState, file, dep);\n findings.push({\n severity: isDeep ? 'high' : 'medium',\n category: 'package-boundary-violation',\n file,\n lineStart: importRef.lineStart,\n lineEnd: importRef.lineEnd,\n title: `Cross-package import bypasses public API`,\n reason: `\"${file}\" imports \"${dep}\" directly instead of through the package public entry.`,\n files: [file, dep],\n suggestedFix: {\n strategy: 'Import through the package public API (index file).',\n steps: [\n 'Re-export the needed symbol from the target package index.',\n 'Update the import to use the package name or index path.',\n 'If the symbol is internal, reconsider the dependency.',\n ],\n },\n impact:\n 'Enforces clean package boundaries and prevents coupling to internals.',\n tags: ['boundary', 'coupling', 'encapsulation'],\n });\n }\n }\n }\n\n return findings;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":11757,"content_sha256":"1d96e4133300618e076b1452055142ccd25b6db5d459663eb00f3c62370aa5be"},{"filename":"src/detectors/import-style.ts","content":"import { isLikelyEntrypoint } from './shared.js';\nimport { canAddFinding } from './shared.js';\nimport { isTestFile } from '../common/utils.js';\n\nimport type { FindingDraft } from './shared.js';\nimport type {\n DependencyState,\n DependencySummary,\n FileEntry,\n Finding,\n HotFile,\n} from '../types/index.js';\n\nexport function computeBarrelDepth(\n file: string,\n dependencyState: DependencyState,\n visited: Set\u003cstring> = new Set()\n): number {\n if (visited.has(file)) return 0;\n visited.add(file);\n\n const reexports = dependencyState.reExportsByFile.get(file);\n if (!reexports || reexports.length === 0) return 0;\n\n let maxChild = 0;\n for (const re of reexports) {\n const target = re.resolvedModule;\n if (!target) continue;\n const targetRe = dependencyState.reExportsByFile.get(target);\n if (targetRe && targetRe.length > 0) {\n maxChild = Math.max(\n maxChild,\n computeBarrelDepth(target, dependencyState, visited)\n );\n }\n }\n\n return 1 + maxChild;\n}\n\nexport function detectBarrelExplosion(\n dependencyState: DependencyState,\n symbolThreshold: number = 30\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const [file, reexports] of dependencyState.reExportsByFile.entries()) {\n if (isTestFile(file)) continue;\n if (reexports.length === 0) continue;\n\n if (reexports.length > symbolThreshold) {\n findings.push({\n severity: reexports.length > symbolThreshold * 2 ? 'high' : 'medium',\n category: 'barrel-explosion',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `Barrel explosion: ${file}`,\n reason: `Barrel re-exports ${reexports.length} symbols (threshold: ${symbolThreshold}). Large barrels hurt bundling.`,\n files: [file],\n suggestedFix: {\n strategy:\n 'Split barrel or use direct imports to reduce bundler cost.',\n steps: [\n 'Group re-exports by domain into sub-barrels.',\n 'Let consumers import directly from source modules.',\n 'Remove unused re-exports (check dead-re-export findings).',\n ],\n },\n impact: 'Reduces bundle size and speeds up IDE/tooling.',\n tags: ['barrel', 'bundle-size', 'tree-shaking'],\n });\n }\n\n const depth = computeBarrelDepth(file, dependencyState);\n if (depth > 2) {\n findings.push({\n severity: 'high',\n category: 'barrel-explosion',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `Deep barrel chain: ${file} (depth ${depth})`,\n reason: `Barrel chain is ${depth} levels deep. Deep chains defeat tree-shaking.`,\n files: [file],\n suggestedFix: {\n strategy: 'Flatten barrel chain to at most 2 levels.',\n steps: [\n 'Re-export directly from source modules instead of intermediate barrels.',\n 'Remove intermediate barrel layers that add no value.',\n ],\n },\n impact: 'Improves tree-shaking efficiency and import resolution speed.',\n tags: ['barrel', 'bundle-size', 'tree-shaking'],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectImportSideEffectRisk(\n fileSummaries: FileEntry[],\n dependencyState: DependencyState,\n dependencySummary: DependencySummary,\n hotFiles: HotFile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n const cycleFiles = new Set\u003cstring>();\n for (const cycle of dependencySummary.cycles) {\n for (const node of cycle.path) cycleFiles.add(node);\n }\n const criticalPathFiles = new Set\u003cstring>();\n for (const cp of dependencySummary.criticalPaths) {\n for (const node of cp.path) criticalPathFiles.add(node);\n }\n const hotFileMap = new Map\u003cstring, HotFile>();\n for (const hf of hotFiles) hotFileMap.set(hf.file, hf);\n\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n const effects = entry.topLevelEffects;\n if (!effects || effects.length === 0) continue;\n\n let astBase = 0;\n for (const eff of effects) astBase += eff.weight;\n\n const fanIn = (dependencyState.incoming.get(entry.file) || new Set()).size;\n let impactBoost = 0;\n if (fanIn >= 20) impactBoost += 8;\n else if (fanIn >= 8) impactBoost += 4;\n if (criticalPathFiles.has(entry.file)) impactBoost += 6;\n if (cycleFiles.has(entry.file)) impactBoost += 3;\n\n let roleDiscount = 0;\n if (isLikelyEntrypoint(entry.file)) roleDiscount += 4;\n\n const totalRisk = astBase + impactBoost - roleDiscount;\n if (totalRisk \u003c 4) continue;\n\n const severity: Finding['severity'] =\n totalRisk >= 18\n ? 'critical'\n : totalRisk >= 12\n ? 'high'\n : totalRisk >= 7\n ? 'medium'\n : 'low';\n\n const highConfidenceEffects = effects.filter(e => e.confidence === 'high');\n const confidence: 'high' | 'medium' | 'low' =\n highConfidenceEffects.length > 0\n ? 'high'\n : effects.some(e => e.confidence === 'medium')\n ? 'medium'\n : 'low';\n\n const effectDetails = effects\n .map(e => `${e.detail} (line ${e.lineStart})`)\n .join('; ');\n const impactDetails: string[] = [];\n if (fanIn >= 8) impactDetails.push(`fan-in=${fanIn}`);\n if (criticalPathFiles.has(entry.file))\n impactDetails.push('on critical path');\n if (cycleFiles.has(entry.file)) impactDetails.push('in dependency cycle');\n if (isLikelyEntrypoint(entry.file))\n impactDetails.push('entrypoint (discounted)');\n const impactSuffix =\n impactDetails.length > 0\n ? ` Architecture context: ${impactDetails.join(', ')}.`\n : '';\n\n const firstEffect = effects[0];\n if (!canAddFinding(findings)) break;\n findings.push({\n severity,\n category: 'import-side-effect-risk',\n file: entry.file,\n lineStart: firstEffect.lineStart,\n lineEnd: firstEffect.lineEnd,\n title: `Import-time side effect${effects.length > 1 ? `s (${effects.length})` : ''}: ${entry.file}`,\n reason: `Module executes work at import time: ${effectDetails}. Risk score: ${totalRisk} (ast=${astBase}, impact=+${impactBoost}, role=-${roleDiscount}). Confidence: ${confidence}.${impactSuffix}`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Move import-time side effects behind explicit initialization or lazy loading.',\n steps: [\n 'Wrap startup logic in an exported init() function instead of running at module scope.',\n 'Replace synchronous I/O with async alternatives called at runtime.',\n 'Guard side-effect imports with dynamic import() behind feature checks.',\n 'If this is an intentional entrypoint, consider adding a suppression comment.',\n ],\n },\n impact: `Importing this module triggers ${effects.length} side effect(s). With fan-in=${fanIn}, unintended imports can degrade startup latency and cause surprising runtime behavior.`,\n tags: ['import-side-effect', 'startup', 'architecture', 'performance'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName:\n entry.file\n .split('/')\n .pop()\n ?.replace(/\\.[^.]+$/, '') || entry.file,\n lineHint: 1,\n file: entry.file,\n expectedResult: `find all modules that import this file and may trigger side effects`,\n },\n ],\n });\n }\n\n return findings;\n}\n\nexport function detectNamespaceImport(\n dependencyState: DependencyState\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const [\n file,\n imports,\n ] of dependencyState.importedSymbolsByFile.entries()) {\n if (isTestFile(file)) continue;\n\n for (const ref of imports) {\n if (ref.importedName !== '*') continue;\n if (ref.isTypeOnly) continue;\n if (ref.localName === 'require') continue;\n\n const isInternal = ref.resolvedModule != null;\n const fanIn = isInternal\n ? (dependencyState.incoming.get(ref.resolvedModule!) || new Set()).size\n : 0;\n\n findings.push({\n severity: isInternal && fanIn > 5 ? 'high' : 'medium',\n category: 'namespace-import',\n file,\n lineStart: ref.lineStart || 1,\n lineEnd: ref.lineEnd || ref.lineStart || 1,\n title: `Namespace import blocks tree-shaking: import * as ${ref.localName}`,\n reason: `\\`import * as ${ref.localName} from '${ref.sourceModule}'\\` forces bundlers to include the entire module. Named imports allow dead-code elimination of unused exports.${isInternal ? ` Target module has fan-in=${fanIn}.` : ''}`,\n files: [\n `${file}:${ref.lineStart || 1}-${ref.lineEnd || ref.lineStart || 1}`,\n ],\n suggestedFix: {\n strategy:\n 'Replace namespace import with named imports for used symbols.',\n steps: [\n `Find which properties of \\`${ref.localName}\\` are actually accessed in this file.`,\n `Replace \\`import * as ${ref.localName}\\` with \\`import { usedA, usedB } from '${ref.sourceModule}'\\`.`,\n 'If many properties are used, consider splitting the source module into smaller modules.',\n ],\n },\n impact:\n 'Enables bundlers to tree-shake unused exports, reducing bundle size.',\n tags: ['tree-shaking', 'bundle-size', 'namespace-import'],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectCommonJsInEsm(\n dependencyState: DependencyState\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const [\n file,\n imports,\n ] of dependencyState.importedSymbolsByFile.entries()) {\n if (isTestFile(file)) continue;\n\n const requireImports = imports.filter(\n r => r.localName === 'require' && !r.isTypeOnly\n );\n if (requireImports.length === 0) continue;\n\n const hasEsmImport = imports.some(r => r.localName !== 'require');\n const severity = hasEsmImport ? 'high' : 'medium';\n\n for (const ref of requireImports) {\n findings.push({\n severity,\n category: hasEsmImport ? 'mixed-module-format' : 'commonjs-in-esm',\n file,\n lineStart: ref.lineStart || 1,\n lineEnd: ref.lineEnd || ref.lineStart || 1,\n title: hasEsmImport\n ? `Mixed ESM/CJS: require('${ref.sourceModule}') in ESM file`\n : `CommonJS require blocks tree-shaking: require('${ref.sourceModule}')`,\n reason: hasEsmImport\n ? `File uses both ESM \\`import\\` and CJS \\`require()\\`. Mixed formats force bundlers to treat the module as CJS, disabling tree-shaking entirely. Found ${requireImports.length} require() call(s).`\n : `\\`require('${ref.sourceModule}')\\` is a CommonJS pattern that bundlers cannot statically analyze. ESM \\`import\\` enables tree-shaking.`,\n files: [\n `${file}:${ref.lineStart || 1}-${ref.lineEnd || ref.lineStart || 1}`,\n ],\n suggestedFix: {\n strategy: 'Convert require() to ESM import.',\n steps: [\n `Replace \\`const mod = require('${ref.sourceModule}')\\` with \\`import mod from '${ref.sourceModule}'\\` or named imports.`,\n 'If the require is conditional, use dynamic `import()` instead.',\n 'Ensure the target module supports ESM (check package.json \"type\" or \"module\" field).',\n ],\n },\n impact:\n 'ESM imports enable tree-shaking; CJS requires pull the entire module.',\n tags: ['tree-shaking', 'bundle-size', 'commonjs', 'module-format'],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectExportStarLeak(\n dependencyState: DependencyState\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const [file, reexports] of dependencyState.reExportsByFile.entries()) {\n if (isTestFile(file)) continue;\n\n const starReexports = reexports.filter(r => r.isStar && !r.isTypeOnly);\n if (starReexports.length === 0) continue;\n\n for (const ref of starReexports) {\n const targetExportCount = ref.resolvedModule\n ? (dependencyState.declaredExportsByFile.get(ref.resolvedModule) || [])\n .length\n : 0;\n\n const targetHasStars = ref.resolvedModule\n ? (dependencyState.reExportsByFile.get(ref.resolvedModule) || []).some(\n r => r.isStar\n )\n : false;\n\n const severity = targetHasStars\n ? 'high'\n : targetExportCount > 20\n ? 'high'\n : 'medium';\n\n findings.push({\n severity,\n category: 'export-star-leak',\n file,\n lineStart: ref.lineStart || 1,\n lineEnd: ref.lineEnd || ref.lineStart || 1,\n title: `export * leaks entire module surface: ${ref.sourceModule}`,\n reason: `\\`export * from '${ref.sourceModule}'\\` re-exports every symbol from the source, defeating granular tree-shaking.${targetExportCount > 0 ? ` Target exports ${targetExportCount} symbols.` : ''}${targetHasStars ? ' Target itself contains export-star chains, amplifying the leak.' : ''}`,\n files: [\n `${file}:${ref.lineStart || 1}-${ref.lineEnd || ref.lineStart || 1}`,\n ],\n suggestedFix: {\n strategy: 'Replace export * with explicit named re-exports.',\n steps: [\n `List the symbols actually consumed from \\`${ref.sourceModule}\\` by downstream modules.`,\n `Replace \\`export * from '${ref.sourceModule}'\\` with \\`export { A, B, C } from '${ref.sourceModule}'\\`.`,\n 'This lets bundlers eliminate unused re-exports during tree-shaking.',\n ],\n },\n impact:\n 'Explicit re-exports enable precise tree-shaking and make the public API surface visible.',\n tags: ['tree-shaking', 'bundle-size', 'export-star', 'api-surface'],\n });\n }\n }\n\n return findings;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":13742,"content_sha256":"2db73d849dbf4ad4bec1e2a63de8325e1d503e8df6fac8f3cef1c6569cbac39f"},{"filename":"src/detectors/index.test.ts","content":"import * as ts from 'typescript';\nimport { describe, expect, it } from 'vitest';\n\nimport {\n buildConsumedFromModule,\n computeAbstractness,\n computeBarrelDepth,\n computeCognitiveComplexity,\n computeHotFiles,\n computeInstability,\n detectBarrelExplosion,\n detectBoundaryViolations,\n detectCognitiveComplexity,\n detectCommonJsInEsm,\n detectCriticalPaths,\n detectDeadExports,\n detectDeadFiles,\n detectDeadReExports,\n detectDependencyCycles,\n detectDistanceFromMainSequence,\n detectDuplicateFlowStructures,\n detectDuplicateFunctionBodies,\n detectEmptyCatchBlocks,\n detectExcessiveParameters,\n detectExportStarLeak,\n detectFeatureEnvy,\n detectFunctionOptimization,\n detectGodFunctions,\n detectGodModuleCoupling,\n detectGodModules,\n detectHighCoupling,\n detectHighHalsteadEffort,\n detectLayerViolations,\n detectLowCohesion,\n detectLowMaintainability,\n detectMegaFolders,\n detectNamespaceImport,\n detectOrphanModules,\n detectSdpViolations,\n detectSwitchNoDefault,\n detectTestOnlyModules,\n detectUnreachableModules,\n detectUnsafeAny,\n detectUntestedCriticalCode,\n detectUnusedNpmDeps,\n isLikelyEntrypoint,\n mergeOverlappingChains,\n} from './index.js';\n\nimport type {\n DependencyProfile,\n DependencyState,\n DuplicateGroup,\n FileEntry,\n FunctionEntry,\n RedundantFlowGroup,\n} from '../types/index.js';\nimport type {\n CodeLocation,\n DependencySummary,\n FileCriticality,\n} from '../types/index.js';\n\nfunction emptyState(): DependencyState {\n return {\n files: new Set(),\n outgoing: new Map(),\n incoming: new Map(),\n incomingFromTests: new Map(),\n incomingFromProduction: new Map(),\n externalCounts: new Map(),\n unresolvedCounts: new Map(),\n declaredExportsByFile: new Map(),\n importedSymbolsByFile: new Map(),\n reExportsByFile: new Map(),\n };\n}\n\nfunction addEdge(state: DependencyState, from: string, to: string): void {\n state.files.add(from);\n state.files.add(to);\n if (!state.outgoing.has(from)) state.outgoing.set(from, new Set());\n state.outgoing.get(from)!.add(to);\n if (!state.incoming.has(to)) state.incoming.set(to, new Set());\n state.incoming.get(to)!.add(from);\n}\n\nconst emptyProfile: DependencyProfile = {\n internalDependencies: [],\n externalDependencies: [],\n unresolvedDependencies: [],\n declaredExports: [],\n importedSymbols: [],\n reExports: [],\n};\n\nfunction makeFn(overrides: Partial\u003cFunctionEntry> = {}): FunctionEntry {\n return {\n kind: 'FunctionDeclaration',\n name: 'testFn',\n nameHint: 'testFn',\n file: 'src/file.ts',\n lineStart: 1,\n lineEnd: 10,\n columnStart: 1,\n columnEnd: 1,\n statementCount: 5,\n complexity: 1,\n maxBranchDepth: 0,\n maxLoopDepth: 0,\n returns: 1,\n awaits: 0,\n calls: 0,\n loops: 0,\n lengthLines: 10,\n cognitiveComplexity: 0,\n ...overrides,\n };\n}\n\nfunction makeFileEntry(overrides: Partial\u003cFileEntry> = {}): FileEntry {\n return {\n package: 'test',\n file: 'src/file.ts',\n parseEngine: 'typescript',\n nodeCount: 100,\n kindCounts: {},\n functions: [],\n flows: [],\n dependencyProfile: emptyProfile,\n ...overrides,\n };\n}\n\nfunction minimalDepSummary(\n overrides: Partial\u003cDependencySummary> = {}\n): DependencySummary {\n return {\n totalModules: 0,\n totalEdges: 0,\n unresolvedEdgeCount: 0,\n externalDependencyFiles: 0,\n rootsCount: 0,\n leavesCount: 0,\n roots: [],\n leaves: [],\n criticalModules: [],\n testOnlyModules: [],\n unresolvedSample: [],\n outgoingTop: [],\n inboundTop: [],\n cycles: [],\n criticalPaths: [],\n ...overrides,\n };\n}\n\ndescribe('isLikelyEntrypoint', () => {\n it('matches config files', () => {\n expect(isLikelyEntrypoint('vite.config.ts')).toBe(true);\n expect(isLikelyEntrypoint('webpack.config.js')).toBe(true);\n });\n\n it('matches public entrypoint pattern', () => {\n expect(isLikelyEntrypoint('src/public.ts')).toBe(true);\n });\n});\n\ndescribe('computeInstability', () => {\n it('returns 0 when both counts are 0', () => {\n expect(computeInstability(0, 0)).toBe(0);\n });\n\n it('returns 0 for maximally stable (only depended on)', () => {\n expect(computeInstability(10, 0)).toBe(0);\n });\n\n it('returns 1 for maximally unstable (only depends)', () => {\n expect(computeInstability(0, 10)).toBe(1);\n });\n\n it('returns 0.5 for equal inbound/outbound', () => {\n expect(computeInstability(5, 5)).toBe(0.5);\n });\n\n it('computes fractional instability', () => {\n expect(computeInstability(3, 7)).toBeCloseTo(0.7);\n });\n});\n\ndescribe('detectSdpViolations', () => {\n it('returns empty for no files', () => {\n expect(detectSdpViolations(emptyState())).toEqual([]);\n });\n\n it('detects when stable module depends on unstable module', () => {\n const state = emptyState();\n state.files.add('src/a.ts');\n state.files.add('src/b.ts');\n state.files.add('src/c.ts');\n state.files.add('src/d.ts');\n state.files.add('src/e.ts');\n addEdge(state, 'src/c.ts', 'src/a.ts');\n addEdge(state, 'src/d.ts', 'src/a.ts');\n addEdge(state, 'src/e.ts', 'src/a.ts');\n addEdge(state, 'src/a.ts', 'src/b.ts');\n addEdge(state, 'src/b.ts', 'src/c.ts');\n addEdge(state, 'src/b.ts', 'src/d.ts');\n addEdge(state, 'src/b.ts', 'src/e.ts');\n\n const findings = detectSdpViolations(state);\n expect(findings.length).toBeGreaterThan(0);\n expect(findings[0].category).toBe('architecture-sdp-violation');\n expect(findings[0].file).toBe('src/a.ts');\n });\n\n it('does not flag when stable depends on stable', () => {\n const state = emptyState();\n state.files.add('src/a.ts');\n state.files.add('src/b.ts');\n state.files.add('src/c.ts');\n addEdge(state, 'src/a.ts', 'src/b.ts');\n addEdge(state, 'src/c.ts', 'src/a.ts');\n addEdge(state, 'src/b.ts', 'src/c.ts');\n const findings = detectSdpViolations(state);\n expect(findings).toEqual([]);\n });\n\n it('skips test files', () => {\n const state = emptyState();\n state.files.add('src/a.test.ts');\n state.files.add('src/b.ts');\n addEdge(state, 'src/a.test.ts', 'src/b.ts');\n expect(detectSdpViolations(state)).toEqual([]);\n });\n\n it('assigns high severity for large delta', () => {\n const state = emptyState();\n state.files.add('src/stable.ts');\n state.files.add('src/unstable.ts');\n for (let i = 0; i \u003c 10; i++) {\n const f = `src/dep${i}.ts`;\n state.files.add(f);\n addEdge(state, f, 'src/stable.ts');\n }\n addEdge(state, 'src/stable.ts', 'src/unstable.ts');\n for (let i = 0; i \u003c 10; i++) {\n const f = `src/lib${i}.ts`;\n state.files.add(f);\n addEdge(state, 'src/unstable.ts', f);\n }\n const findings = detectSdpViolations(state);\n const sdp = findings.find(f => f.file === 'src/stable.ts');\n expect(sdp).toBeDefined();\n expect(sdp!.severity).toBe('high');\n });\n});\n\ndescribe('detectHighCoupling', () => {\n it('returns empty for uncoupled modules', () => {\n const state = emptyState();\n state.files.add('src/a.ts');\n expect(detectHighCoupling(state)).toEqual([]);\n });\n\n it('detects modules above threshold', () => {\n const state = emptyState();\n const hub = 'src/hub.ts';\n state.files.add(hub);\n for (let i = 0; i \u003c 10; i++) {\n const f = `src/dep${i}.ts`;\n state.files.add(f);\n addEdge(state, hub, f);\n }\n for (let i = 0; i \u003c 8; i++) {\n const f = `src/consumer${i}.ts`;\n state.files.add(f);\n addEdge(state, f, hub);\n }\n const findings = detectHighCoupling(state, 15);\n const hubFinding = findings.find(f => f.file === hub);\n expect(hubFinding).toBeDefined();\n expect(hubFinding!.category).toBe('high-coupling');\n });\n\n it('assigns high severity above 25', () => {\n const state = emptyState();\n const hub = 'src/hub.ts';\n state.files.add(hub);\n for (let i = 0; i \u003c 26; i++) {\n const f = `src/m${i}.ts`;\n state.files.add(f);\n addEdge(state, f, hub);\n }\n const findings = detectHighCoupling(state, 15);\n expect(findings[0].severity).toBe('high');\n });\n\n it('respects custom threshold', () => {\n const state = emptyState();\n const hub = 'src/hub.ts';\n state.files.add(hub);\n for (let i = 0; i \u003c 5; i++) {\n const f = `src/m${i}.ts`;\n state.files.add(f);\n addEdge(state, f, hub);\n }\n expect(detectHighCoupling(state, 3).length).toBeGreaterThan(0);\n expect(detectHighCoupling(state, 10)).toEqual([]);\n });\n});\n\ndescribe('detectGodModuleCoupling', () => {\n it('returns empty for low fan-in/fan-out', () => {\n const state = emptyState();\n state.files.add('src/a.ts');\n state.files.add('src/b.ts');\n addEdge(state, 'src/a.ts', 'src/b.ts');\n expect(detectGodModuleCoupling(state)).toEqual([]);\n });\n\n it('detects high fan-in', () => {\n const state = emptyState();\n const hub = 'src/utils.ts';\n state.files.add(hub);\n for (let i = 0; i \u003c 22; i++) {\n const f = `src/consumer${i}.ts`;\n state.files.add(f);\n addEdge(state, f, hub);\n }\n const findings = detectGodModuleCoupling(state, 20, 15);\n expect(findings.some(f => f.title.includes('fan-in'))).toBe(true);\n });\n\n it('detects high fan-out', () => {\n const state = emptyState();\n const hub = 'src/orchestrator.ts';\n state.files.add(hub);\n for (let i = 0; i \u003c 18; i++) {\n const f = `src/service${i}.ts`;\n state.files.add(f);\n addEdge(state, hub, f);\n }\n const findings = detectGodModuleCoupling(state, 20, 15);\n expect(findings.some(f => f.title.includes('fan-out'))).toBe(true);\n });\n\n it('can detect both fan-in and fan-out for same module', () => {\n const state = emptyState();\n const hub = 'src/mega.ts';\n state.files.add(hub);\n for (let i = 0; i \u003c 25; i++) {\n const f = `src/in${i}.ts`;\n state.files.add(f);\n addEdge(state, f, hub);\n }\n for (let i = 0; i \u003c 20; i++) {\n const f = `src/out${i}.ts`;\n state.files.add(f);\n addEdge(state, hub, f);\n }\n const findings = detectGodModuleCoupling(state, 20, 15);\n const hubFindings = findings.filter(f => f.file === hub);\n expect(hubFindings.length).toBe(2);\n });\n});\n\ndescribe('detectOrphanModules', () => {\n it('returns empty when all modules are connected', () => {\n const state = emptyState();\n addEdge(state, 'src/a.ts', 'src/b.ts');\n expect(detectOrphanModules(state)).toEqual([]);\n });\n\n it('detects disconnected module', () => {\n const state = emptyState();\n state.files.add('src/orphan.ts');\n addEdge(state, 'src/a.ts', 'src/b.ts');\n const findings = detectOrphanModules(state);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('orphan-module');\n expect(findings[0].file).toBe('src/orphan.ts');\n });\n\n it('skips entrypoints', () => {\n const state = emptyState();\n state.files.add('src/index.ts');\n expect(detectOrphanModules(state)).toEqual([]);\n });\n\n it('skips test files', () => {\n const state = emptyState();\n state.files.add('src/foo.test.ts');\n expect(detectOrphanModules(state)).toEqual([]);\n });\n});\n\ndescribe('detectUnreachableModules', () => {\n it('returns empty when all reachable from entrypoint', () => {\n const state = emptyState();\n addEdge(state, 'src/index.ts', 'src/a.ts');\n addEdge(state, 'src/a.ts', 'src/b.ts');\n expect(detectUnreachableModules(state)).toEqual([]);\n });\n\n it('detects module unreachable from entrypoints', () => {\n const state = emptyState();\n addEdge(state, 'src/index.ts', 'src/a.ts');\n addEdge(state, 'src/island.ts', 'src/leaf.ts');\n const findings = detectUnreachableModules(state);\n const unreachable = findings.map(f => f.file).sort();\n expect(unreachable).toContain('src/island.ts');\n expect(unreachable).toContain('src/leaf.ts');\n });\n\n it('flags subgraphs not reachable from named entrypoints', () => {\n const state = emptyState();\n addEdge(state, 'src/main.ts', 'src/a.ts');\n addEdge(state, 'src/orphan.ts', 'src/b.ts');\n const findings = detectUnreachableModules(state);\n expect(findings.map(f => f.file).sort()).toEqual([\n 'src/b.ts',\n 'src/orphan.ts',\n ]);\n });\n\n it('uses roots as entrypoints when no index/main files exist', () => {\n const state = emptyState();\n addEdge(state, 'src/alpha.ts', 'src/a.ts');\n addEdge(state, 'src/beta.ts', 'src/b.ts');\n const findings = detectUnreachableModules(state);\n expect(findings).toEqual([]);\n });\n\n it('handles cycles gracefully', () => {\n const state = emptyState();\n addEdge(state, 'src/index.ts', 'src/a.ts');\n addEdge(state, 'src/a.ts', 'src/b.ts');\n addEdge(state, 'src/b.ts', 'src/a.ts');\n expect(detectUnreachableModules(state)).toEqual([]);\n });\n});\n\ndescribe('detectUnusedNpmDeps', () => {\n it('returns empty when all deps are used', () => {\n const ext = new Map([['src/a.ts', new Set(['lodash', '@types/node'])]]);\n expect(detectUnusedNpmDeps(ext, { lodash: '^4.0.0' })).toEqual([]);\n });\n\n it('detects unused production dependency', () => {\n const ext = new Map([['src/a.ts', new Set(['lodash'])]]);\n const findings = detectUnusedNpmDeps(ext, { lodash: '^4', express: '^4' });\n expect(findings.length).toBe(1);\n expect(findings[0].title).toContain('express');\n expect(findings[0].severity).toBe('medium');\n });\n\n it('detects unused devDependency with low severity', () => {\n const ext = new Map\u003cstring, Set\u003cstring>>();\n const findings = detectUnusedNpmDeps(ext, {}, { vitest: '^1.0' });\n expect(findings.length).toBe(1);\n expect(findings[0].severity).toBe('low');\n });\n\n it('handles scoped packages correctly', () => {\n const ext = new Map([['src/a.ts', new Set(['@scope/pkg/utils'])]]);\n expect(detectUnusedNpmDeps(ext, { '@scope/pkg': '^1.0' })).toEqual([]);\n });\n\n it('reports both unused prod and dev deps', () => {\n const ext = new Map\u003cstring, Set\u003cstring>>();\n const findings = detectUnusedNpmDeps(\n ext,\n { react: '^18' },\n { jest: '^29' }\n );\n expect(findings.length).toBe(2);\n });\n});\n\ndescribe('detectBoundaryViolations', () => {\n it('returns empty for same-package imports', () => {\n const state = emptyState();\n addEdge(state, 'packages/foo/src/a.ts', 'packages/foo/src/b.ts');\n expect(detectBoundaryViolations(state)).toEqual([]);\n });\n\n it('allows cross-package import via index', () => {\n const state = emptyState();\n addEdge(state, 'packages/foo/src/a.ts', 'packages/bar/src/index.ts');\n expect(detectBoundaryViolations(state)).toEqual([]);\n });\n\n it('detects cross-package import bypassing index', () => {\n const state = emptyState();\n addEdge(state, 'packages/foo/src/a.ts', 'packages/bar/src/utils/helper.ts');\n const findings = detectBoundaryViolations(state);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('package-boundary-violation');\n expect(findings[0].severity).toBe('medium');\n });\n\n it('assigns high severity for internal/ path imports', () => {\n const state = emptyState();\n addEdge(\n state,\n 'packages/foo/src/a.ts',\n 'packages/bar/src/internal/secret.ts'\n );\n const findings = detectBoundaryViolations(state);\n expect(findings[0].severity).toBe('high');\n });\n\n it('skips non-package files', () => {\n const state = emptyState();\n addEdge(state, 'src/a.ts', 'src/b.ts');\n expect(detectBoundaryViolations(state)).toEqual([]);\n });\n});\n\ndescribe('computeBarrelDepth', () => {\n it('returns 0 for file with no re-exports', () => {\n const state = emptyState();\n state.reExportsByFile.set('src/a.ts', []);\n expect(computeBarrelDepth('src/a.ts', state)).toBe(0);\n });\n\n it('returns 1 for single-level barrel', () => {\n const state = emptyState();\n state.reExportsByFile.set('src/index.ts', [\n {\n sourceModule: './a',\n resolvedModule: 'src/a.ts',\n exportedAs: 'A',\n importedName: 'A',\n isStar: false,\n isTypeOnly: false,\n },\n ]);\n state.reExportsByFile.set('src/a.ts', []);\n expect(computeBarrelDepth('src/index.ts', state)).toBe(1);\n });\n\n it('returns 2 for two-level barrel chain', () => {\n const state = emptyState();\n state.reExportsByFile.set('src/index.ts', [\n {\n sourceModule: './sub',\n resolvedModule: 'src/sub/index.ts',\n exportedAs: '*',\n importedName: '*',\n isStar: true,\n isTypeOnly: false,\n },\n ]);\n state.reExportsByFile.set('src/sub/index.ts', [\n {\n sourceModule: './a',\n resolvedModule: 'src/sub/a.ts',\n exportedAs: 'A',\n importedName: 'A',\n isStar: false,\n isTypeOnly: false,\n },\n ]);\n state.reExportsByFile.set('src/sub/a.ts', []);\n expect(computeBarrelDepth('src/index.ts', state)).toBe(2);\n });\n\n it('handles cycles without infinite loop', () => {\n const state = emptyState();\n state.reExportsByFile.set('src/a.ts', [\n {\n sourceModule: './b',\n resolvedModule: 'src/b.ts',\n exportedAs: '*',\n importedName: '*',\n isStar: true,\n isTypeOnly: false,\n },\n ]);\n state.reExportsByFile.set('src/b.ts', [\n {\n sourceModule: './a',\n resolvedModule: 'src/a.ts',\n exportedAs: '*',\n importedName: '*',\n isStar: true,\n isTypeOnly: false,\n },\n ]);\n expect(computeBarrelDepth('src/a.ts', state)).toBe(2);\n });\n});\n\ndescribe('detectBarrelExplosion', () => {\n it('returns empty for small barrel', () => {\n const state = emptyState();\n state.reExportsByFile.set('src/index.ts', [\n {\n sourceModule: './a',\n resolvedModule: 'src/a.ts',\n exportedAs: 'A',\n importedName: 'A',\n isStar: false,\n isTypeOnly: false,\n },\n ]);\n expect(detectBarrelExplosion(state, 30)).toEqual([]);\n });\n\n it('detects barrel exceeding symbol threshold', () => {\n const state = emptyState();\n const reexports = Array.from({ length: 35 }, (_, i) => ({\n sourceModule: `./m${i}`,\n resolvedModule: `src/m${i}.ts`,\n exportedAs: `S${i}`,\n importedName: `S${i}`,\n isStar: false,\n isTypeOnly: false,\n }));\n state.reExportsByFile.set('src/index.ts', reexports);\n const findings = detectBarrelExplosion(state, 30);\n expect(\n findings.some(\n f =>\n f.category === 'barrel-explosion' &&\n f.title.includes('Barrel explosion')\n )\n ).toBe(true);\n });\n});\n\ndescribe('detectGodModules', () => {\n it('returns empty for small modules', () => {\n const state = emptyState();\n const files: FileEntry[] = [\n makeFileEntry({ functions: [makeFn({ statementCount: 10 })] }),\n ];\n expect(detectGodModules(files, state)).toEqual([]);\n });\n\n it('detects module with many statements', () => {\n const state = emptyState();\n const fns = Array.from({ length: 10 }, (_, i) =>\n makeFn({ name: `fn${i}`, statementCount: 60 })\n );\n const files: FileEntry[] = [makeFileEntry({ functions: fns })];\n const findings = detectGodModules(files, state, 500);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('god-module');\n });\n\n it('detects module with many exports', () => {\n const state = emptyState();\n const exports = Array.from({ length: 25 }, (_, i) => ({\n name: `exp${i}`,\n kind: 'value' as const,\n }));\n state.declaredExportsByFile.set('src/file.ts', exports);\n const files: FileEntry[] = [makeFileEntry()];\n const findings = detectGodModules(files, state, 9999, 20);\n expect(findings.length).toBe(1);\n });\n});\n\ndescribe('detectMegaFolders', () => {\n it('returns empty when no folder crosses the concentration threshold', () => {\n const files = [\n makeFileEntry({ file: 'src/a.ts' }),\n makeFileEntry({ file: 'src/b.ts' }),\n makeFileEntry({ file: 'src/feature/c.ts' }),\n makeFileEntry({ file: 'src/feature/d.ts' }),\n ];\n expect(detectMegaFolders(files, 3, 0.6)).toEqual([]);\n });\n\n it('flags large concentrated folder and includes decomposition evidence', () => {\n const files = [\n ...Array.from({ length: 6 }, (_, i) =>\n makeFileEntry({ file: `src/core/file-${i}.ts` })\n ),\n makeFileEntry({ file: 'src/feature/a.ts' }),\n makeFileEntry({ file: 'src/feature/b.ts' }),\n ];\n const findings = detectMegaFolders(files, 5, 0.5);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('mega-folder');\n expect(findings[0].title).toContain('src/core');\n expect((findings[0].evidence as Record\u003cstring, unknown>).fileCount).toBe(6);\n });\n});\n\ndescribe('detectGodFunctions', () => {\n it('returns empty for small functions', () => {\n const files: FileEntry[] = [\n makeFileEntry({ functions: [makeFn({ statementCount: 50 })] }),\n ];\n expect(detectGodFunctions(files, 100)).toEqual([]);\n });\n\n it('detects function exceeding statement threshold', () => {\n const files: FileEntry[] = [\n makeFileEntry({\n functions: [makeFn({ statementCount: 120, name: 'bigFn' })],\n }),\n ];\n const findings = detectGodFunctions(files, 100);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('god-function');\n expect(findings[0].title).toContain('bigFn');\n });\n});\n\ndescribe('computeCognitiveComplexity', () => {\n function parseExpr(code: string): ts.Node {\n const src = ts.createSourceFile(\n 'test.ts',\n code,\n ts.ScriptTarget.ESNext,\n true\n );\n return src.statements[0];\n }\n\n it('returns 0 for simple function', () => {\n const node = parseExpr('function f() { return 1; }');\n expect(computeCognitiveComplexity(node)).toBe(0);\n });\n\n it('counts simple if as 1', () => {\n const node = parseExpr('function f(x: boolean) { if (x) { return 1; } }');\n expect(computeCognitiveComplexity(node)).toBe(1);\n });\n\n it('penalizes nesting', () => {\n const node = parseExpr(\n 'function f(a: boolean, b: boolean) { if (a) { if (b) { return 1; } } }'\n );\n expect(computeCognitiveComplexity(node)).toBe(3);\n });\n\n it('counts for loop', () => {\n const node = parseExpr(\n 'function f(arr: number[]) { for (const x of arr) { console.log(x); } }'\n );\n expect(computeCognitiveComplexity(node)).toBeGreaterThan(0);\n });\n\n it('counts logical operators', () => {\n const node = parseExpr(\n 'function f(a: boolean, b: boolean) { return a && b; }'\n );\n expect(computeCognitiveComplexity(node)).toBe(1);\n });\n\n it('deeply nested structures have high complexity', () => {\n const code = `function f(a: boolean, b: boolean, c: boolean) {\n if (a) {\n for (let i = 0; i \u003c 10; i++) {\n if (b) {\n while (c) {\n break;\n }\n }\n }\n }\n }`;\n const node = parseExpr(code);\n expect(computeCognitiveComplexity(node)).toBe(10);\n });\n});\n\ndescribe('detectCognitiveComplexity', () => {\n it('returns empty for low-complexity functions', () => {\n const files: FileEntry[] = [\n makeFileEntry({ functions: [makeFn({ cognitiveComplexity: 5 })] }),\n ];\n expect(detectCognitiveComplexity(files, 15)).toEqual([]);\n });\n\n it('detects high cognitive complexity', () => {\n const files: FileEntry[] = [\n makeFileEntry({\n functions: [makeFn({ cognitiveComplexity: 20, name: 'complexFn' })],\n }),\n ];\n const findings = detectCognitiveComplexity(files, 15);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('cognitive-complexity');\n });\n\n it('assigns high severity above 25', () => {\n const files: FileEntry[] = [\n makeFileEntry({ functions: [makeFn({ cognitiveComplexity: 30 })] }),\n ];\n const findings = detectCognitiveComplexity(files, 15);\n expect(findings[0].severity).toBe('high');\n });\n});\n\ndescribe('detectLayerViolations', () => {\n it('returns empty with no layer config', () => {\n expect(detectLayerViolations(emptyState(), [])).toEqual([]);\n });\n\n it('returns empty when imports respect layer order', () => {\n const state = emptyState();\n addEdge(state, 'src/ui/page.ts', 'src/service/api.ts');\n addEdge(state, 'src/service/api.ts', 'src/repository/db.ts');\n const findings = detectLayerViolations(state, [\n 'ui',\n 'service',\n 'repository',\n ]);\n expect(findings).toEqual([]);\n });\n\n it('detects backward layer import', () => {\n const state = emptyState();\n addEdge(state, 'src/repository/db.ts', 'src/ui/page.ts');\n const findings = detectLayerViolations(state, [\n 'ui',\n 'service',\n 'repository',\n ]);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('layer-violation');\n expect(findings[0].severity).toBe('high');\n });\n\n it('ignores files not in any layer', () => {\n const state = emptyState();\n addEdge(state, 'src/utils/helper.ts', 'src/ui/page.ts');\n const findings = detectLayerViolations(state, [\n 'ui',\n 'service',\n 'repository',\n ]);\n expect(findings).toEqual([]);\n });\n\n it('detects multiple violations', () => {\n const state = emptyState();\n addEdge(state, 'src/repository/db.ts', 'src/ui/page.ts');\n addEdge(state, 'src/service/api.ts', 'src/ui/button.ts');\n const findings = detectLayerViolations(state, [\n 'ui',\n 'service',\n 'repository',\n ]);\n expect(findings.length).toBe(2);\n });\n});\n\ndescribe('detectLowCohesion', () => {\n it('returns empty when file has few exports', () => {\n const state = emptyState();\n state.files.add('src/small.ts');\n state.declaredExportsByFile.set('src/small.ts', [\n { name: 'a', kind: 'value' },\n { name: 'b', kind: 'value' },\n ]);\n expect(detectLowCohesion(state)).toEqual([]);\n });\n\n it('returns empty for entrypoint files', () => {\n const state = emptyState();\n state.files.add('src/index.ts');\n state.declaredExportsByFile.set('src/index.ts', [\n { name: 'a', kind: 'value' },\n { name: 'b', kind: 'value' },\n { name: 'c', kind: 'value' },\n { name: 'd', kind: 'value' },\n ]);\n expect(detectLowCohesion(state)).toEqual([]);\n });\n\n it('returns empty when all consumers import the same symbols', () => {\n const state = emptyState();\n state.files.add('src/lib.ts');\n state.files.add('src/a.ts');\n state.files.add('src/b.ts');\n state.declaredExportsByFile.set('src/lib.ts', [\n { name: 'x', kind: 'value' },\n { name: 'y', kind: 'value' },\n { name: 'z', kind: 'value' },\n { name: 'w', kind: 'value' },\n ]);\n state.importedSymbolsByFile.set('src/a.ts', [\n {\n sourceModule: './lib',\n resolvedModule: 'src/lib.ts',\n importedName: 'x',\n localName: 'x',\n isTypeOnly: false,\n },\n {\n sourceModule: './lib',\n resolvedModule: 'src/lib.ts',\n importedName: 'y',\n localName: 'y',\n isTypeOnly: false,\n },\n ]);\n state.importedSymbolsByFile.set('src/b.ts', [\n {\n sourceModule: './lib',\n resolvedModule: 'src/lib.ts',\n importedName: 'x',\n localName: 'x',\n isTypeOnly: false,\n },\n {\n sourceModule: './lib',\n resolvedModule: 'src/lib.ts',\n importedName: 'y',\n localName: 'y',\n isTypeOnly: false,\n },\n ]);\n expect(detectLowCohesion(state)).toEqual([]);\n });\n\n it('detects low cohesion when consumers import non-overlapping symbols', () => {\n const state = emptyState();\n state.files.add('src/junkdrawer.ts');\n state.files.add('src/a.ts');\n state.files.add('src/b.ts');\n state.declaredExportsByFile.set('src/junkdrawer.ts', [\n { name: 'alpha', kind: 'value' },\n { name: 'beta', kind: 'value' },\n { name: 'gamma', kind: 'value' },\n { name: 'delta', kind: 'value' },\n ]);\n state.importedSymbolsByFile.set('src/a.ts', [\n {\n sourceModule: './junkdrawer',\n resolvedModule: 'src/junkdrawer.ts',\n importedName: 'alpha',\n localName: 'alpha',\n isTypeOnly: false,\n },\n {\n sourceModule: './junkdrawer',\n resolvedModule: 'src/junkdrawer.ts',\n importedName: 'beta',\n localName: 'beta',\n isTypeOnly: false,\n },\n ]);\n state.importedSymbolsByFile.set('src/b.ts', [\n {\n sourceModule: './junkdrawer',\n resolvedModule: 'src/junkdrawer.ts',\n importedName: 'gamma',\n localName: 'gamma',\n isTypeOnly: false,\n },\n {\n sourceModule: './junkdrawer',\n resolvedModule: 'src/junkdrawer.ts',\n importedName: 'delta',\n localName: 'delta',\n isTypeOnly: false,\n },\n ]);\n const findings = detectLowCohesion(state);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('low-cohesion');\n expect(findings[0].file).toBe('src/junkdrawer.ts');\n });\n\n it('reports LCOM component count in reason', () => {\n const state = emptyState();\n state.files.add('src/utils.ts');\n state.files.add('src/a.ts');\n state.files.add('src/b.ts');\n state.files.add('src/c.ts');\n state.declaredExportsByFile.set('src/utils.ts', [\n { name: 'e1', kind: 'value' },\n { name: 'e2', kind: 'value' },\n { name: 'e3', kind: 'value' },\n { name: 'e4', kind: 'value' },\n ]);\n state.importedSymbolsByFile.set('src/a.ts', [\n {\n sourceModule: './utils',\n resolvedModule: 'src/utils.ts',\n importedName: 'e1',\n localName: 'e1',\n isTypeOnly: false,\n },\n ]);\n state.importedSymbolsByFile.set('src/b.ts', [\n {\n sourceModule: './utils',\n resolvedModule: 'src/utils.ts',\n importedName: 'e2',\n localName: 'e2',\n isTypeOnly: false,\n },\n ]);\n state.importedSymbolsByFile.set('src/c.ts', [\n {\n sourceModule: './utils',\n resolvedModule: 'src/utils.ts',\n importedName: 'e3',\n localName: 'e3',\n isTypeOnly: false,\n },\n ]);\n const findings = detectLowCohesion(state);\n expect(findings.length).toBe(1);\n expect(findings[0].reason).toContain('3');\n });\n\n it('skips test files', () => {\n const state = emptyState();\n state.files.add('src/utils.test.ts');\n state.declaredExportsByFile.set('src/utils.test.ts', [\n { name: 'a', kind: 'value' },\n { name: 'b', kind: 'value' },\n { name: 'c', kind: 'value' },\n { name: 'd', kind: 'value' },\n ]);\n expect(detectLowCohesion(state)).toEqual([]);\n });\n});\n\ndescribe('computeHotFiles', () => {\n function minimalDepSummary(\n overrides: Partial\u003cDependencySummary> = {}\n ): DependencySummary {\n return {\n totalModules: 0,\n totalEdges: 0,\n unresolvedEdgeCount: 0,\n externalDependencyFiles: 0,\n rootsCount: 0,\n leavesCount: 0,\n roots: [],\n leaves: [],\n criticalModules: [],\n testOnlyModules: [],\n unresolvedSample: [],\n outgoingTop: [],\n inboundTop: [],\n cycles: [],\n criticalPaths: [],\n ...overrides,\n };\n }\n\n it('returns empty for empty input', () => {\n const result = computeHotFiles(\n emptyState(),\n minimalDepSummary(),\n new Map()\n );\n expect(result).toEqual([]);\n });\n\n it('scores files by fan-in + complexity', () => {\n const state = emptyState();\n const hub = 'src/hub.ts';\n state.files.add(hub);\n for (let i = 0; i \u003c 10; i++) {\n const f = `src/c${i}.ts`;\n state.files.add(f);\n addEdge(state, f, hub);\n }\n const critMap = new Map\u003cstring, FileCriticality>();\n critMap.set(hub, {\n file: hub,\n complexityRisk: 5,\n highComplexityFunctions: 3,\n functionCount: 8,\n flows: 20,\n score: 50,\n });\n const result = computeHotFiles(state, minimalDepSummary(), critMap);\n expect(result.length).toBeGreaterThan(0);\n expect(result[0].file).toBe(hub);\n expect(result[0].riskScore).toBeGreaterThan(0);\n expect(result[0].fanIn).toBe(10);\n });\n\n it('boosts score for files in cycles', () => {\n const state = emptyState();\n state.files.add('src/a.ts');\n state.files.add('src/b.ts');\n addEdge(state, 'src/a.ts', 'src/b.ts');\n addEdge(state, 'src/b.ts', 'src/a.ts');\n const depSummary = minimalDepSummary({\n cycles: [{ path: ['src/a.ts', 'src/b.ts', 'src/a.ts'], nodeCount: 2 }],\n });\n const critMap = new Map\u003cstring, FileCriticality>();\n critMap.set('src/a.ts', {\n file: 'src/a.ts',\n complexityRisk: 1,\n highComplexityFunctions: 0,\n functionCount: 1,\n flows: 0,\n score: 10,\n });\n critMap.set('src/b.ts', {\n file: 'src/b.ts',\n complexityRisk: 1,\n highComplexityFunctions: 0,\n functionCount: 1,\n flows: 0,\n score: 10,\n });\n const result = computeHotFiles(state, depSummary, critMap);\n expect(result.some(f => f.inCycle)).toBe(true);\n });\n\n it('returns files sorted by riskScore descending', () => {\n const state = emptyState();\n state.files.add('src/hot.ts');\n state.files.add('src/cold.ts');\n for (let i = 0; i \u003c 10; i++) addEdge(state, `src/dep${i}.ts`, 'src/hot.ts');\n const critMap = new Map\u003cstring, FileCriticality>();\n critMap.set('src/hot.ts', {\n file: 'src/hot.ts',\n complexityRisk: 5,\n highComplexityFunctions: 3,\n functionCount: 10,\n flows: 20,\n score: 100,\n });\n critMap.set('src/cold.ts', {\n file: 'src/cold.ts',\n complexityRisk: 1,\n highComplexityFunctions: 0,\n functionCount: 1,\n flows: 0,\n score: 5,\n });\n const result = computeHotFiles(state, minimalDepSummary(), critMap);\n if (result.length >= 2) {\n expect(result[0].riskScore).toBeGreaterThanOrEqual(result[1].riskScore);\n }\n });\n\n it('limits results to top 20', () => {\n const state = emptyState();\n for (let i = 0; i \u003c 50; i++) {\n const f = `src/file${i}.ts`;\n state.files.add(f);\n addEdge(state, `src/consumer${i}.ts`, f);\n }\n const critMap = new Map\u003cstring, FileCriticality>();\n for (const f of state.files) {\n critMap.set(f, {\n file: f,\n complexityRisk: 1,\n highComplexityFunctions: 0,\n functionCount: 1,\n flows: 0,\n score: 5,\n });\n }\n const result = computeHotFiles(state, minimalDepSummary(), critMap);\n expect(result.length).toBeLessThanOrEqual(20);\n });\n\n it('skips test files', () => {\n const state = emptyState();\n state.files.add('src/a.test.ts');\n for (let i = 0; i \u003c 10; i++)\n addEdge(state, `src/c${i}.ts`, 'src/a.test.ts');\n const critMap = new Map\u003cstring, FileCriticality>();\n critMap.set('src/a.test.ts', {\n file: 'src/a.test.ts',\n complexityRisk: 1,\n highComplexityFunctions: 0,\n functionCount: 1,\n flows: 0,\n score: 50,\n });\n const result = computeHotFiles(state, minimalDepSummary(), critMap);\n expect(result.some(f => f.file === 'src/a.test.ts')).toBe(false);\n });\n});\n\ndescribe('buildConsumedFromModule', () => {\n it('returns empty maps for no imports', () => {\n const result = buildConsumedFromModule(emptyState());\n expect(result.production.size).toBe(0);\n expect(result.test.size).toBe(0);\n });\n\n it('collects consumed symbols per module in production map', () => {\n const state = emptyState();\n state.importedSymbolsByFile.set('src/a.ts', [\n {\n sourceModule: './lib',\n resolvedModule: 'src/lib.ts',\n importedName: 'foo',\n localName: 'foo',\n isTypeOnly: false,\n },\n {\n sourceModule: './lib',\n resolvedModule: 'src/lib.ts',\n importedName: 'bar',\n localName: 'bar',\n isTypeOnly: false,\n },\n ]);\n const result = buildConsumedFromModule(state);\n expect(result.production.get('src/lib.ts')?.size).toBe(2);\n expect(result.production.get('src/lib.ts')?.has('foo')).toBe(true);\n });\n\n it('routes test file imports to the test map', () => {\n const state = emptyState();\n state.importedSymbolsByFile.set('src/a.test.ts', [\n {\n sourceModule: './lib',\n resolvedModule: 'src/lib.ts',\n importedName: 'foo',\n localName: 'foo',\n isTypeOnly: false,\n },\n ]);\n const result = buildConsumedFromModule(state);\n expect(result.production.size).toBe(0);\n expect(result.test.get('src/lib.ts')?.has('foo')).toBe(true);\n });\n\n it('collects symbols from re-exports in production map', () => {\n const state = emptyState();\n state.reExportsByFile.set('src/barrel.ts', [\n {\n sourceModule: './lib',\n resolvedModule: 'src/lib.ts',\n exportedAs: 'X',\n importedName: 'X',\n isStar: false,\n isTypeOnly: false,\n },\n ]);\n const result = buildConsumedFromModule(state);\n expect(result.production.get('src/lib.ts')?.has('X')).toBe(true);\n });\n\n it('skips re-exports with unresolved target', () => {\n const state = emptyState();\n state.reExportsByFile.set('src/barrel.ts', [\n {\n sourceModule: './unresolved',\n exportedAs: 'Y',\n importedName: 'Y',\n isStar: false,\n isTypeOnly: false,\n },\n ]);\n const result = buildConsumedFromModule(state);\n expect(result.production.size).toBe(0);\n });\n});\n\ndescribe('detectDuplicateFunctionBodies', () => {\n function makeDupGroup(\n overrides: Partial\u003cDuplicateGroup> = {}\n ): DuplicateGroup {\n return {\n hash: 'abc123',\n signature: 'handleError',\n kind: 'ArrowFunction',\n occurrences: 3,\n filesCount: 2,\n locations: [\n {\n kind: 'ArrowFunction',\n name: 'handleError',\n nameHint: 'handleError',\n file: 'src/a.ts',\n lineStart: 1,\n lineEnd: 10,\n columnStart: 1,\n columnEnd: 1,\n statementCount: 8,\n complexity: 3,\n maxBranchDepth: 1,\n maxLoopDepth: 0,\n returns: 1,\n awaits: 0,\n calls: 2,\n loops: 0,\n lengthLines: 10,\n cognitiveComplexity: 2,\n hash: 'abc',\n metrics: {\n complexity: 3,\n maxBranchDepth: 1,\n maxLoopDepth: 0,\n returns: 1,\n awaits: 0,\n calls: 2,\n loops: 0,\n },\n },\n {\n kind: 'ArrowFunction',\n name: 'handleError',\n nameHint: 'handleError',\n file: 'src/b.ts',\n lineStart: 5,\n lineEnd: 15,\n columnStart: 1,\n columnEnd: 1,\n statementCount: 8,\n complexity: 3,\n maxBranchDepth: 1,\n maxLoopDepth: 0,\n returns: 1,\n awaits: 0,\n calls: 2,\n loops: 0,\n lengthLines: 10,\n cognitiveComplexity: 2,\n hash: 'abc',\n metrics: {\n complexity: 3,\n maxBranchDepth: 1,\n maxLoopDepth: 0,\n returns: 1,\n awaits: 0,\n calls: 2,\n loops: 0,\n },\n },\n ],\n ...overrides,\n };\n }\n\n it('returns empty for empty input', () => {\n expect(detectDuplicateFunctionBodies([])).toEqual([]);\n });\n\n it('creates finding for duplicate group', () => {\n const findings = detectDuplicateFunctionBodies([makeDupGroup()]);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('duplicate-function-body');\n expect(findings[0].title).toContain('handleError');\n });\n\n it('assigns low severity for 2 occurrences', () => {\n const findings = detectDuplicateFunctionBodies([\n makeDupGroup({ occurrences: 2 }),\n ]);\n expect(findings[0].severity).toBe('low');\n });\n\n it('assigns medium severity for 3-5 occurrences', () => {\n const findings = detectDuplicateFunctionBodies([\n makeDupGroup({ occurrences: 4 }),\n ]);\n expect(findings[0].severity).toBe('medium');\n });\n\n it('assigns high severity for 6+ occurrences', () => {\n const findings = detectDuplicateFunctionBodies([\n makeDupGroup({ occurrences: 7 }),\n ]);\n expect(findings[0].severity).toBe('high');\n });\n\n it('includes all file locations in files field', () => {\n const findings = detectDuplicateFunctionBodies([makeDupGroup()]);\n expect(findings[0].files.length).toBe(2);\n });\n\n it('uses plural \"files\" in reason when filesCount > 1', () => {\n const findings = detectDuplicateFunctionBodies([\n makeDupGroup({ filesCount: 3, occurrences: 4 }),\n ]);\n expect(findings[0].reason).toContain('3 file');\n expect(findings[0].reason).toContain('s');\n });\n});\n\ndescribe('detectDuplicateFlowStructures', () => {\n function makeFlowGroup(\n overrides: Partial\u003cRedundantFlowGroup> = {}\n ): RedundantFlowGroup {\n return {\n kind: 'IfStatement',\n occurrences: 5,\n filesCount: 3,\n locations: [\n {\n kind: 'IfStatement',\n file: 'src/a.ts',\n lineStart: 10,\n lineEnd: 20,\n columnStart: 1,\n columnEnd: 1,\n statementCount: 5,\n hash: 'x',\n },\n {\n kind: 'IfStatement',\n file: 'src/b.ts',\n lineStart: 15,\n lineEnd: 25,\n columnStart: 1,\n columnEnd: 1,\n statementCount: 5,\n hash: 'x',\n },\n ],\n ...overrides,\n };\n }\n\n it('returns empty for empty input', () => {\n expect(detectDuplicateFlowStructures([], 3)).toEqual([]);\n });\n\n it('skips groups below threshold', () => {\n const findings = detectDuplicateFlowStructures(\n [makeFlowGroup({ occurrences: 2 })],\n 3\n );\n expect(findings).toEqual([]);\n });\n\n it('creates finding above threshold', () => {\n const findings = detectDuplicateFlowStructures([makeFlowGroup()], 3);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('duplicate-flow-structure');\n });\n\n it('assigns high severity for 10+ occurrences', () => {\n const findings = detectDuplicateFlowStructures(\n [makeFlowGroup({ occurrences: 12 })],\n 3\n );\n expect(findings[0].severity).toBe('high');\n });\n\n it('assigns medium severity for fewer occurrences', () => {\n const findings = detectDuplicateFlowStructures(\n [makeFlowGroup({ occurrences: 5 })],\n 3\n );\n expect(findings[0].severity).toBe('medium');\n });\n});\n\ndescribe('detectFunctionOptimization', () => {\n it('returns empty for simple functions', () => {\n const files = [\n makeFileEntry({\n functions: [\n makeFn({\n complexity: 5,\n maxBranchDepth: 2,\n maxLoopDepth: 1,\n statementCount: 10,\n }),\n ],\n }),\n ];\n expect(detectFunctionOptimization(files, 30)).toEqual([]);\n });\n\n it('flags high complexity', () => {\n const files = [\n makeFileEntry({\n functions: [makeFn({ complexity: 35, name: 'complexFn' })],\n }),\n ];\n const findings = detectFunctionOptimization(files, 30);\n expect(findings.length).toBe(1);\n expect(findings[0].severity).toBe('high');\n });\n\n it('flags deep branch nesting', () => {\n const files = [\n makeFileEntry({\n functions: [makeFn({ maxBranchDepth: 8, name: 'deepFn' })],\n }),\n ];\n const findings = detectFunctionOptimization(files, 30);\n expect(findings.length).toBe(1);\n expect(findings[0].reason).toContain('Branch depth');\n });\n\n it('flags deep loop nesting', () => {\n const files = [\n makeFileEntry({\n functions: [makeFn({ maxLoopDepth: 5, name: 'loopFn' })],\n }),\n ];\n const findings = detectFunctionOptimization(files, 30);\n expect(findings.length).toBe(1);\n expect(findings[0].reason).toContain('Nested loops');\n });\n\n it('flags large function bodies', () => {\n const files = [\n makeFileEntry({\n functions: [makeFn({ statementCount: 30, name: 'bigFn' })],\n }),\n ];\n const findings = detectFunctionOptimization(files, 30);\n expect(findings.length).toBe(1);\n expect(findings[0].severity).toBe('medium');\n });\n\n it('combines multiple alerts', () => {\n const files = [\n makeFileEntry({\n functions: [\n makeFn({ complexity: 40, maxBranchDepth: 8, name: 'badFn' }),\n ],\n }),\n ];\n const findings = detectFunctionOptimization(files, 30);\n expect(findings[0].reason).toContain('Cyclomatic');\n expect(findings[0].reason).toContain('Branch depth');\n });\n});\n\ndescribe('detectTestOnlyModules', () => {\n it('returns empty when no test-only modules', () => {\n expect(detectTestOnlyModules(minimalDepSummary())).toEqual([]);\n });\n\n it('creates finding for test-only module', () => {\n const summary = minimalDepSummary({\n testOnlyModules: [\n {\n file: 'src/test-helper.ts',\n outboundCount: 0,\n inboundCount: 1,\n inboundFromProduction: 0,\n inboundFromTests: 1,\n externalDependencyCount: 0,\n unresolvedDependencyCount: 0,\n },\n ],\n });\n const findings = detectTestOnlyModules(summary);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('dependency-test-only');\n expect(findings[0].severity).toBe('medium');\n });\n\n it('limits to 25 findings', () => {\n const modules = Array.from({ length: 30 }, (_, i) => ({\n file: `src/helper${i}.ts`,\n outboundCount: 0,\n inboundCount: 1,\n inboundFromProduction: 0,\n inboundFromTests: 1,\n externalDependencyCount: 0,\n unresolvedDependencyCount: 0,\n }));\n const findings = detectTestOnlyModules(\n minimalDepSummary({ testOnlyModules: modules })\n );\n expect(findings.length).toBe(25);\n });\n});\n\ndescribe('detectDependencyCycles (detector)', () => {\n it('returns empty for no cycles', () => {\n expect(detectDependencyCycles(minimalDepSummary(), emptyState())).toEqual(\n []\n );\n });\n\n it('creates finding per cycle', () => {\n const state = emptyState();\n state.files.add('a.ts');\n state.files.add('b.ts');\n const summary = minimalDepSummary({\n cycles: [{ path: ['a.ts', 'b.ts', 'a.ts'], nodeCount: 2 }],\n });\n const findings = detectDependencyCycles(summary, state);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('dependency-cycle');\n expect(findings[0].severity).toBe('high');\n });\n\n it('limits to 15 cycle findings', () => {\n const cycles = Array.from({ length: 20 }, (_, i) => ({\n path: [`src/a${i}.ts`, `src/b${i}.ts`, `src/a${i}.ts`],\n nodeCount: 2,\n }));\n const findings = detectDependencyCycles(\n minimalDepSummary({ cycles }),\n emptyState()\n );\n expect(findings.length).toBe(15);\n });\n});\n\ndescribe('detectCriticalPaths (detector)', () => {\n it('returns empty for no critical paths', () => {\n expect(detectCriticalPaths(minimalDepSummary(), emptyState(), 30)).toEqual(\n []\n );\n });\n\n it('creates finding for high-score path', () => {\n const state = emptyState();\n const summary = minimalDepSummary({\n criticalPaths: [\n {\n start: 'a.ts',\n path: ['a.ts', 'b.ts', 'c.ts'],\n score: 300,\n length: 3,\n containsCycle: false,\n },\n ],\n });\n const findings = detectCriticalPaths(summary, state, 30);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('dependency-critical-path');\n });\n\n it('skips paths below score threshold', () => {\n const summary = minimalDepSummary({\n criticalPaths: [\n {\n start: 'a.ts',\n path: ['a.ts', 'b.ts'],\n score: 10,\n length: 2,\n containsCycle: false,\n },\n ],\n });\n const findings = detectCriticalPaths(summary, emptyState(), 30);\n expect(findings).toEqual([]);\n });\n\n it('assigns critical severity for very high scores', () => {\n const summary = minimalDepSummary({\n criticalPaths: [\n {\n start: 'a.ts',\n path: ['a.ts', 'b.ts'],\n score: 500,\n length: 2,\n containsCycle: false,\n },\n ],\n });\n const findings = detectCriticalPaths(summary, emptyState(), 30);\n expect(findings[0].severity).toBe('critical');\n });\n});\n\ndescribe('mergeOverlappingChains', () => {\n type FindingDraft = Omit\u003cimport('../types/index.js').Finding, 'id'>;\n\n const makeChainFinding = (file: string, files: string[]): FindingDraft => ({\n severity: 'high',\n category: 'dependency-critical-path',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `Critical dependency chain risk: ${files.length} files`,\n reason: `Chain from ${file}.`,\n files,\n suggestedFix: { strategy: 'test', steps: ['step1'] },\n });\n\n it('returns input unchanged when 0 or 1 findings', () => {\n expect(mergeOverlappingChains([])).toEqual([]);\n const single = [makeChainFinding('a.ts', ['a.ts', 'b.ts'])];\n expect(mergeOverlappingChains(single)).toEqual(single);\n });\n\n it('merges chains with >80% overlap', () => {\n const shared = Array.from({ length: 10 }, (_, i) => `shared-${i}.ts`);\n const chain1 = makeChainFinding('e1.ts', ['e1.ts', ...shared]);\n const chain2 = makeChainFinding('e2.ts', ['e2.ts', ...shared]);\n const result = mergeOverlappingChains([chain1, chain2]);\n expect(result).toHaveLength(1);\n expect(result[0].title).toContain('2 entry points');\n expect(result[0].reason).toContain('Also reached from: e2.ts');\n expect(result[0].files.length).toBeGreaterThanOrEqual(11);\n });\n\n it('does NOT merge chains with \u003c80% overlap', () => {\n const f1 = makeChainFinding('a.ts', ['a.ts', 'shared.ts']);\n const f2 = makeChainFinding('b.ts', ['b.ts', 'other.ts']);\n const result = mergeOverlappingChains([f1, f2]);\n expect(result).toHaveLength(2);\n });\n\n it('merges multiple chains into one when overlap stays above threshold', () => {\n const shared = Array.from({ length: 20 }, (_, i) => `m${i}.ts`);\n const chains = Array.from({ length: 3 }, (_, i) =>\n makeChainFinding(`entry-${i}.ts`, [`entry-${i}.ts`, ...shared])\n );\n const result = mergeOverlappingChains(chains);\n expect(result).toHaveLength(1);\n expect(result[0].title).toContain('3 entry points');\n });\n\n it('keeps non-overlapping chains separate while merging overlapping ones', () => {\n const shared = Array.from({ length: 10 }, (_, i) => `s${i}.ts`);\n const overlap1 = makeChainFinding('o1.ts', ['o1.ts', ...shared]);\n const overlap2 = makeChainFinding('o2.ts', ['o2.ts', ...shared]);\n const distinct = makeChainFinding('d.ts', [\n 'd.ts',\n 'unique1.ts',\n 'unique2.ts',\n ]);\n\n const result = mergeOverlappingChains([overlap1, overlap2, distinct]);\n expect(result).toHaveLength(2);\n expect(result.find(f => f.title.includes('entry points'))).toBeDefined();\n expect(result.find(f => f.file === 'd.ts')).toBeDefined();\n });\n});\n\ndescribe('detectCriticalPaths — computed fix & merging', () => {\n it('names the highest-fan-out module in suggestedFix.strategy', () => {\n const state = emptyState();\n addEdge(state, 'a.ts', 'hub.ts');\n addEdge(state, 'hub.ts', 'c.ts');\n addEdge(state, 'hub.ts', 'd.ts');\n addEdge(state, 'hub.ts', 'e.ts');\n const summary = minimalDepSummary({\n criticalPaths: [\n {\n start: 'a.ts',\n path: ['a.ts', 'hub.ts', 'c.ts'],\n score: 300,\n length: 3,\n containsCycle: false,\n },\n ],\n });\n const findings = detectCriticalPaths(summary, state, 30);\n expect(findings.length).toBe(1);\n expect(findings[0].suggestedFix.strategy).toContain('hub.ts');\n expect(findings[0].suggestedFix.strategy).toContain('fan-out: 3');\n expect(findings[0].suggestedFix.steps[0]).toContain('hub.ts');\n });\n\n it('uses first module as hotspot when all have zero fan-out', () => {\n const state = emptyState();\n state.files.add('x.ts');\n state.files.add('y.ts');\n const summary = minimalDepSummary({\n criticalPaths: [\n {\n start: 'x.ts',\n path: ['x.ts', 'y.ts'],\n score: 300,\n length: 2,\n containsCycle: false,\n },\n ],\n });\n const findings = detectCriticalPaths(summary, state, 30);\n expect(findings[0].suggestedFix.strategy).toContain('x.ts');\n expect(findings[0].suggestedFix.strategy).toContain('fan-out: 0');\n });\n\n it('includes fan-in in the hotspot strategy', () => {\n const state = emptyState();\n addEdge(state, 'caller1.ts', 'hub.ts');\n addEdge(state, 'caller2.ts', 'hub.ts');\n addEdge(state, 'hub.ts', 'dep.ts');\n addEdge(state, 'hub.ts', 'dep2.ts');\n addEdge(state, 'hub.ts', 'dep3.ts');\n const summary = minimalDepSummary({\n criticalPaths: [\n {\n start: 'caller1.ts',\n path: ['caller1.ts', 'hub.ts', 'dep.ts'],\n score: 300,\n length: 3,\n containsCycle: false,\n },\n ],\n });\n const findings = detectCriticalPaths(summary, state, 30);\n expect(findings[0].suggestedFix.strategy).toContain('fan-in: 2');\n });\n\n it('merges overlapping chain findings', () => {\n const state = emptyState();\n const shared = Array.from({ length: 10 }, (_, i) => `m${i}.ts`);\n for (let i = 0; i \u003c shared.length - 1; i++) {\n addEdge(state, shared[i], shared[i + 1]);\n }\n addEdge(state, 'e1.ts', shared[0]);\n addEdge(state, 'e2.ts', shared[0]);\n\n const summary = minimalDepSummary({\n criticalPaths: [\n {\n start: 'e1.ts',\n path: ['e1.ts', ...shared],\n score: 300,\n length: shared.length + 1,\n containsCycle: false,\n },\n {\n start: 'e2.ts',\n path: ['e2.ts', ...shared],\n score: 300,\n length: shared.length + 1,\n containsCycle: false,\n },\n ],\n });\n const findings = detectCriticalPaths(summary, state, 30);\n expect(findings).toHaveLength(1);\n expect(findings[0].title).toContain('entry points');\n });\n});\n\ndescribe('detectDeadFiles', () => {\n it('returns empty when no dead files', () => {\n const state = emptyState();\n addEdge(state, 'src/a.ts', 'src/b.ts');\n expect(\n detectDeadFiles(minimalDepSummary({ roots: ['src/a.ts'] }), state)\n ).toEqual([]);\n });\n\n it('flags root file with zero outgoing', () => {\n const state = emptyState();\n state.files.add('src/dead.ts');\n const findings = detectDeadFiles(\n minimalDepSummary({ roots: ['src/dead.ts'] }),\n state\n );\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('dead-file');\n });\n\n it('skips entrypoints', () => {\n const state = emptyState();\n state.files.add('src/index.ts');\n const findings = detectDeadFiles(\n minimalDepSummary({ roots: ['src/index.ts'] }),\n state\n );\n expect(findings).toEqual([]);\n });\n\n it('skips test files', () => {\n const state = emptyState();\n state.files.add('src/foo.test.ts');\n const findings = detectDeadFiles(\n minimalDepSummary({ roots: ['src/foo.test.ts'] }),\n state\n );\n expect(findings).toEqual([]);\n });\n\n it('skips roots with outgoing dependencies', () => {\n const state = emptyState();\n addEdge(state, 'src/root.ts', 'src/dep.ts');\n const findings = detectDeadFiles(\n minimalDepSummary({ roots: ['src/root.ts'] }),\n state\n );\n expect(findings).toEqual([]);\n });\n});\n\ndescribe('detectDeadExports', () => {\n it('returns empty when all exports consumed', () => {\n const state = emptyState();\n state.files.add('src/lib.ts');\n state.declaredExportsByFile.set('src/lib.ts', [\n { name: 'foo', kind: 'value' },\n ]);\n const consumed = new Map([['src/lib.ts', new Set(['foo'])]]);\n expect(detectDeadExports(state, consumed)).toEqual([]);\n });\n\n it('flags unused exports', () => {\n const state = emptyState();\n state.files.add('src/lib.ts');\n state.declaredExportsByFile.set('src/lib.ts', [\n { name: 'used', kind: 'value' },\n { name: 'dead', kind: 'value', lineStart: 10 },\n ]);\n const consumed = new Map([['src/lib.ts', new Set(['used'])]]);\n const findings = detectDeadExports(state, consumed);\n expect(findings.length).toBe(1);\n expect(findings[0].title).toContain('dead');\n });\n\n it('assigns medium severity for type exports', () => {\n const state = emptyState();\n state.files.add('src/lib.ts');\n state.declaredExportsByFile.set('src/lib.ts', [\n { name: 'MyType', kind: 'type' },\n ]);\n const findings = detectDeadExports(state, new Map());\n expect(findings[0].severity).toBe('medium');\n });\n\n it('assigns high severity for value exports', () => {\n const state = emptyState();\n state.files.add('src/lib.ts');\n state.declaredExportsByFile.set('src/lib.ts', [\n { name: 'myFn', kind: 'value' },\n ]);\n const findings = detectDeadExports(state, new Map());\n expect(findings[0].severity).toBe('high');\n });\n\n it('skips namespace-imported modules', () => {\n const state = emptyState();\n state.files.add('src/lib.ts');\n state.declaredExportsByFile.set('src/lib.ts', [\n { name: 'foo', kind: 'value' },\n ]);\n const consumed = new Map([['src/lib.ts', new Set(['*'])]]);\n expect(detectDeadExports(state, consumed)).toEqual([]);\n });\n});\n\ndescribe('detectDeadReExports', () => {\n it('returns empty for consumed re-exports', () => {\n const state = emptyState();\n state.reExportsByFile.set('src/index.ts', [\n {\n sourceModule: './a',\n resolvedModule: 'src/a.ts',\n exportedAs: 'X',\n importedName: 'X',\n isStar: false,\n isTypeOnly: false,\n },\n ]);\n const consumed = new Map([['src/index.ts', new Set(['X'])]]);\n expect(detectDeadReExports(state, consumed)).toEqual([]);\n });\n\n it('flags unused re-exports', () => {\n const state = emptyState();\n state.reExportsByFile.set('src/index.ts', [\n {\n sourceModule: './a',\n resolvedModule: 'src/a.ts',\n exportedAs: 'Dead',\n importedName: 'Dead',\n isStar: false,\n isTypeOnly: false,\n },\n ]);\n const findings = detectDeadReExports(state, new Map());\n expect(findings.some(f => f.category === 'dead-re-export')).toBe(true);\n });\n\n it('detects duplicate re-export sources', () => {\n const state = emptyState();\n state.reExportsByFile.set('src/barrel.ts', [\n {\n sourceModule: './a',\n resolvedModule: 'src/a.ts',\n exportedAs: 'Foo',\n importedName: 'Foo',\n isStar: false,\n isTypeOnly: false,\n },\n {\n sourceModule: './b',\n resolvedModule: 'src/b.ts',\n exportedAs: 'Foo',\n importedName: 'Foo',\n isStar: false,\n isTypeOnly: false,\n },\n ]);\n const consumed = new Map([['src/barrel.ts', new Set(['Foo'])]]);\n const findings = detectDeadReExports(state, consumed);\n expect(findings.some(f => f.category === 're-export-duplication')).toBe(\n true\n );\n });\n\n it('detects shadowed re-exports', () => {\n const state = emptyState();\n state.declaredExportsByFile.set('src/barrel.ts', [\n { name: 'Conflict', kind: 'value' },\n ]);\n state.reExportsByFile.set('src/barrel.ts', [\n {\n sourceModule: './a',\n resolvedModule: 'src/a.ts',\n exportedAs: 'Conflict',\n importedName: 'Conflict',\n isStar: false,\n isTypeOnly: false,\n },\n ]);\n const consumed = new Map([['src/barrel.ts', new Set(['Conflict'])]]);\n const findings = detectDeadReExports(state, consumed);\n expect(findings.some(f => f.category === 're-export-shadowed')).toBe(true);\n });\n});\n\ndescribe('detectExcessiveParameters', () => {\n it('returns empty for functions within threshold', () => {\n const files = [makeFileEntry({ functions: [makeFn({ params: 3 })] })];\n expect(detectExcessiveParameters(files, 5)).toEqual([]);\n });\n\n it('flags functions exceeding threshold', () => {\n const files = [\n makeFileEntry({ functions: [makeFn({ params: 7, name: 'manyArgs' })] }),\n ];\n const findings = detectExcessiveParameters(files, 5);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('excessive-parameters');\n expect(findings[0].title).toContain('manyArgs');\n });\n\n it('assigns high severity for >7 params', () => {\n const files = [makeFileEntry({ functions: [makeFn({ params: 9 })] })];\n const findings = detectExcessiveParameters(files, 5);\n expect(findings[0].severity).toBe('high');\n });\n\n it('assigns medium severity for 6-7 params', () => {\n const files = [makeFileEntry({ functions: [makeFn({ params: 6 })] })];\n const findings = detectExcessiveParameters(files, 5);\n expect(findings[0].severity).toBe('medium');\n });\n\n it('skips functions with no param count', () => {\n const files = [makeFileEntry({ functions: [makeFn()] })];\n expect(detectExcessiveParameters(files, 5)).toEqual([]);\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'src/a.test.ts',\n functions: [makeFn({ params: 10 })],\n }),\n ];\n expect(detectExcessiveParameters(files, 5)).toEqual([]);\n });\n});\n\ndescribe('detectEmptyCatchBlocks', () => {\n it('returns empty when no empty catches', () => {\n const files = [makeFileEntry()];\n expect(detectEmptyCatchBlocks(files)).toEqual([]);\n });\n\n it('flags empty catch blocks', () => {\n const loc: CodeLocation = {\n file: 'src/file.ts',\n lineStart: 10,\n lineEnd: 12,\n };\n const files = [makeFileEntry({ emptyCatches: [loc] })];\n const findings = detectEmptyCatchBlocks(files);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('empty-catch');\n expect(findings[0].severity).toBe('medium');\n expect(findings[0].lineStart).toBe(10);\n });\n\n it('creates finding per empty catch', () => {\n const locs: CodeLocation[] = [\n { file: 'src/file.ts', lineStart: 10, lineEnd: 12 },\n { file: 'src/file.ts', lineStart: 30, lineEnd: 32 },\n ];\n const files = [makeFileEntry({ emptyCatches: locs })];\n const findings = detectEmptyCatchBlocks(files);\n expect(findings.length).toBe(2);\n });\n\n it('skips test files', () => {\n const loc: CodeLocation = {\n file: 'src/a.test.ts',\n lineStart: 10,\n lineEnd: 12,\n };\n const files = [\n makeFileEntry({ file: 'src/a.test.ts', emptyCatches: [loc] }),\n ];\n expect(detectEmptyCatchBlocks(files)).toEqual([]);\n });\n});\n\ndescribe('detectSwitchNoDefault', () => {\n it('returns empty when no switches without default', () => {\n const files = [makeFileEntry()];\n expect(detectSwitchNoDefault(files)).toEqual([]);\n });\n\n it('flags switch without default', () => {\n const loc: CodeLocation = {\n file: 'src/file.ts',\n lineStart: 15,\n lineEnd: 30,\n };\n const files = [makeFileEntry({ switchesWithoutDefault: [loc] })];\n const findings = detectSwitchNoDefault(files);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('switch-no-default');\n expect(findings[0].severity).toBe('low');\n });\n\n it('skips test files', () => {\n const loc: CodeLocation = {\n file: 'src/a.test.ts',\n lineStart: 15,\n lineEnd: 30,\n };\n const files = [\n makeFileEntry({ file: 'src/a.test.ts', switchesWithoutDefault: [loc] }),\n ];\n expect(detectSwitchNoDefault(files)).toEqual([]);\n });\n});\n\ndescribe('detectUnsafeAny', () => {\n it('returns empty for files below threshold', () => {\n const files = [makeFileEntry({ anyCount: 3 })];\n expect(detectUnsafeAny(files, 5)).toEqual([]);\n });\n\n it('flags files exceeding threshold', () => {\n const files = [makeFileEntry({ anyCount: 8 })];\n const findings = detectUnsafeAny(files, 5);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('unsafe-any');\n });\n\n it('assigns high severity for >10 any usages', () => {\n const files = [makeFileEntry({ anyCount: 15 })];\n const findings = detectUnsafeAny(files, 5);\n expect(findings[0].severity).toBe('high');\n });\n\n it('assigns medium severity for 6-10 any usages', () => {\n const files = [makeFileEntry({ anyCount: 7 })];\n const findings = detectUnsafeAny(files, 5);\n expect(findings[0].severity).toBe('medium');\n });\n\n it('skips files with no anyCount', () => {\n const files = [makeFileEntry()];\n expect(detectUnsafeAny(files, 5)).toEqual([]);\n });\n});\n\ndescribe('detectHighHalsteadEffort', () => {\n it('returns empty for functions below thresholds', () => {\n const files = [\n makeFileEntry({\n functions: [\n makeFn({\n halstead: {\n operators: 10,\n operands: 10,\n distinctOperators: 5,\n distinctOperands: 5,\n vocabulary: 10,\n length: 20,\n volume: 100,\n difficulty: 5,\n effort: 500,\n time: 28,\n estimatedBugs: 0.03,\n },\n }),\n ],\n }),\n ];\n expect(detectHighHalsteadEffort(files)).toEqual([]);\n });\n\n it('flags functions with high effort', () => {\n const files = [\n makeFileEntry({\n functions: [\n makeFn({\n name: 'heavyFn',\n halstead: {\n operators: 100,\n operands: 200,\n distinctOperators: 30,\n distinctOperands: 50,\n vocabulary: 80,\n length: 300,\n volume: 50000,\n difficulty: 20,\n effort: 1_000_000,\n time: 55556,\n estimatedBugs: 1.5,\n },\n }),\n ],\n }),\n ];\n const findings = detectHighHalsteadEffort(files);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('halstead-effort');\n expect(findings[0].title).toContain('heavyFn');\n });\n\n it('flags functions with high estimated bugs', () => {\n const files = [\n makeFileEntry({\n functions: [\n makeFn({\n halstead: {\n operators: 50,\n operands: 100,\n distinctOperators: 15,\n distinctOperands: 25,\n vocabulary: 40,\n length: 150,\n volume: 10000,\n difficulty: 10,\n effort: 100_000,\n time: 5556,\n estimatedBugs: 3.5,\n },\n }),\n ],\n }),\n ];\n const findings = detectHighHalsteadEffort(files, 500_000, 2.0);\n expect(findings.length).toBe(1);\n expect(findings[0].reason).toContain('estimatedBugs');\n });\n\n it('skips functions without halstead data', () => {\n const files = [makeFileEntry({ functions: [makeFn()] })];\n expect(detectHighHalsteadEffort(files)).toEqual([]);\n });\n});\n\ndescribe('detectLowMaintainability', () => {\n it('returns empty for functions above threshold', () => {\n const files = [\n makeFileEntry({ functions: [makeFn({ maintainabilityIndex: 50 })] }),\n ];\n expect(detectLowMaintainability(files, 20)).toEqual([]);\n });\n\n it('flags functions below threshold', () => {\n const files = [\n makeFileEntry({\n functions: [makeFn({ maintainabilityIndex: 15, name: 'hardFn' })],\n }),\n ];\n const findings = detectLowMaintainability(files, 20);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('low-maintainability');\n expect(findings[0].title).toContain('hardFn');\n });\n\n it('assigns critical severity for MI \u003c 10', () => {\n const files = [\n makeFileEntry({ functions: [makeFn({ maintainabilityIndex: 5 })] }),\n ];\n const findings = detectLowMaintainability(files, 20);\n expect(findings[0].severity).toBe('critical');\n });\n\n it('assigns high severity for MI 10-19', () => {\n const files = [\n makeFileEntry({ functions: [makeFn({ maintainabilityIndex: 15 })] }),\n ];\n const findings = detectLowMaintainability(files, 20);\n expect(findings[0].severity).toBe('high');\n });\n\n it('skips functions without maintainability index', () => {\n const files = [makeFileEntry({ functions: [makeFn()] })];\n expect(detectLowMaintainability(files, 20)).toEqual([]);\n });\n});\n\ndescribe('computeAbstractness', () => {\n it('returns 0 for file with no type exports', () => {\n expect(\n computeAbstractness([\n { name: 'foo', kind: 'value' },\n { name: 'bar', kind: 'value' },\n ])\n ).toBe(0);\n });\n\n it('returns 1 for file with only type exports', () => {\n expect(\n computeAbstractness([\n { name: 'Foo', kind: 'type' },\n { name: 'Bar', kind: 'type' },\n ])\n ).toBe(1);\n });\n\n it('returns 0.5 for equal mix', () => {\n expect(\n computeAbstractness([\n { name: 'Foo', kind: 'type' },\n { name: 'foo', kind: 'value' },\n ])\n ).toBe(0.5);\n });\n\n it('returns 0 for empty exports', () => {\n expect(computeAbstractness([])).toBe(0);\n });\n});\n\ndescribe('detectDistanceFromMainSequence', () => {\n it('returns empty for no files', () => {\n expect(detectDistanceFromMainSequence(emptyState())).toEqual([]);\n });\n\n it('flags files in Zone of Pain (concrete + stable)', () => {\n const state = emptyState();\n const hub = 'src/hub.ts';\n state.files.add(hub);\n state.declaredExportsByFile.set(hub, [\n { name: 'fn1', kind: 'value' },\n { name: 'fn2', kind: 'value' },\n { name: 'fn3', kind: 'value' },\n ]);\n for (let i = 0; i \u003c 10; i++) {\n const f = `src/dep${i}.ts`;\n state.files.add(f);\n addEdge(state, f, hub);\n }\n const findings = detectDistanceFromMainSequence(state);\n const hubFinding = findings.find(f => f.file === hub);\n expect(hubFinding).toBeDefined();\n expect(hubFinding!.category).toBe('distance-from-main-sequence');\n expect(hubFinding!.reason).toContain('Zone of Pain');\n });\n\n it('does not flag files on the main sequence', () => {\n const state = emptyState();\n state.files.add('src/a.ts');\n state.files.add('src/b.ts');\n state.declaredExportsByFile.set('src/a.ts', [\n { name: 'MyType', kind: 'type' },\n ]);\n addEdge(state, 'src/b.ts', 'src/a.ts');\n const findings = detectDistanceFromMainSequence(state);\n expect(findings).toEqual([]);\n });\n\n it('flags files in Zone of Uselessness (abstract + unstable)', () => {\n const state = emptyState();\n const file = 'src/unused-abstractions.ts';\n state.files.add(file);\n state.declaredExportsByFile.set(file, [\n { name: 'IFoo', kind: 'type' },\n { name: 'IBar', kind: 'type' },\n { name: 'IBaz', kind: 'type' },\n ]);\n for (let i = 0; i \u003c 8; i++) {\n const dep = `src/dep${i}.ts`;\n state.files.add(dep);\n addEdge(state, file, dep);\n }\n const findings = detectDistanceFromMainSequence(state);\n const f = findings.find(f => f.file === file);\n expect(f).toBeDefined();\n expect(f!.reason).toContain('Zone of Uselessness');\n });\n\n it('skips test files', () => {\n const state = emptyState();\n state.files.add('src/a.test.ts');\n state.declaredExportsByFile.set('src/a.test.ts', [\n { name: 'fn', kind: 'value' },\n ]);\n expect(detectDistanceFromMainSequence(state)).toEqual([]);\n });\n\n it('skips files with no exports', () => {\n const state = emptyState();\n state.files.add('src/empty.ts');\n expect(detectDistanceFromMainSequence(state)).toEqual([]);\n });\n});\n\ndescribe('detectFeatureEnvy', () => {\n it('returns empty when no envy detected', () => {\n const state = emptyState();\n state.files.add('src/a.ts');\n state.importedSymbolsByFile.set('src/a.ts', [\n {\n sourceModule: './b',\n resolvedModule: 'src/b.ts',\n importedName: 'x',\n localName: 'x',\n isTypeOnly: false,\n },\n {\n sourceModule: './c',\n resolvedModule: 'src/c.ts',\n importedName: 'y',\n localName: 'y',\n isTypeOnly: false,\n },\n ]);\n expect(detectFeatureEnvy(state)).toEqual([]);\n });\n\n it('flags file that imports many symbols from single target', () => {\n const state = emptyState();\n state.files.add('src/envious.ts');\n state.files.add('src/target.ts');\n const imports = Array.from({ length: 8 }, (_, i) => ({\n sourceModule: './target',\n resolvedModule: 'src/target.ts',\n importedName: `sym${i}`,\n localName: `sym${i}`,\n isTypeOnly: false,\n }));\n imports.push({\n sourceModule: './other',\n resolvedModule: 'src/other.ts',\n importedName: 'z',\n localName: 'z',\n isTypeOnly: false,\n });\n state.importedSymbolsByFile.set('src/envious.ts', imports);\n const findings = detectFeatureEnvy(state);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('feature-envy');\n expect(findings[0].file).toBe('src/envious.ts');\n expect(findings[0].reason).toContain('src/target.ts');\n });\n\n it('does not flag when imports are spread evenly', () => {\n const state = emptyState();\n state.files.add('src/balanced.ts');\n const imports = [];\n for (let m = 0; m \u003c 5; m++) {\n for (let s = 0; s \u003c 2; s++) {\n imports.push({\n sourceModule: `./mod${m}`,\n resolvedModule: `src/mod${m}.ts`,\n importedName: `fn${s}`,\n localName: `fn${s}`,\n isTypeOnly: false,\n });\n }\n }\n state.importedSymbolsByFile.set('src/balanced.ts', imports);\n expect(detectFeatureEnvy(state)).toEqual([]);\n });\n\n it('skips test files', () => {\n const state = emptyState();\n state.files.add('src/a.test.ts');\n const imports = Array.from({ length: 10 }, (_, i) => ({\n sourceModule: './target',\n resolvedModule: 'src/target.ts',\n importedName: `sym${i}`,\n localName: `sym${i}`,\n isTypeOnly: false,\n }));\n state.importedSymbolsByFile.set('src/a.test.ts', imports);\n expect(detectFeatureEnvy(state)).toEqual([]);\n });\n\n it('requires minimum total imports', () => {\n const state = emptyState();\n state.files.add('src/small.ts');\n state.importedSymbolsByFile.set('src/small.ts', [\n {\n sourceModule: './target',\n resolvedModule: 'src/target.ts',\n importedName: 'x',\n localName: 'x',\n isTypeOnly: false,\n },\n ]);\n expect(detectFeatureEnvy(state)).toEqual([]);\n });\n});\n\ndescribe('detectUntestedCriticalCode', () => {\n it('returns empty when no hot files provided', () => {\n const state = emptyState();\n const findings = detectUntestedCriticalCode(state, [], new Map());\n expect(findings).toEqual([]);\n });\n\n it('flags hot file with no test imports', () => {\n const state = emptyState();\n state.files.add('src/core.ts');\n state.incomingFromTests.set('src/core.ts', new Set());\n const hotFiles = [\n {\n file: 'src/core.ts',\n riskScore: 50,\n fanIn: 10,\n fanOut: 5,\n complexityScore: 20,\n exportCount: 8,\n inCycle: false,\n onCriticalPath: true,\n },\n ];\n const findings = detectUntestedCriticalCode(state, hotFiles, new Map());\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('untested-critical-code');\n expect(findings[0].file).toBe('src/core.ts');\n expect(findings[0].severity).toBe('high');\n expect(findings[0].tags).toContain('testing');\n expect(findings[0].tags).toContain('coverage');\n });\n\n it('does not flag hot file that has test imports', () => {\n const state = emptyState();\n state.files.add('src/core.ts');\n state.incomingFromTests.set('src/core.ts', new Set(['src/core.test.ts']));\n const hotFiles = [\n {\n file: 'src/core.ts',\n riskScore: 50,\n fanIn: 10,\n fanOut: 5,\n complexityScore: 20,\n exportCount: 8,\n inCycle: false,\n onCriticalPath: true,\n },\n ];\n const findings = detectUntestedCriticalCode(state, hotFiles, new Map());\n expect(findings).toEqual([]);\n });\n\n it('flags critical severity for in-cycle + high risk + untested', () => {\n const state = emptyState();\n state.files.add('src/hub.ts');\n const hotFiles = [\n {\n file: 'src/hub.ts',\n riskScore: 80,\n fanIn: 20,\n fanOut: 10,\n complexityScore: 40,\n exportCount: 15,\n inCycle: true,\n onCriticalPath: true,\n },\n ];\n const findings = detectUntestedCriticalCode(state, hotFiles, new Map());\n expect(findings.length).toBe(1);\n expect(findings[0].severity).toBe('critical');\n });\n\n it('flags high-complexity files on critical paths even if not in hotFiles list', () => {\n const state = emptyState();\n state.files.add('src/deep.ts');\n const critMap = new Map\u003cstring, FileCriticality>([\n [\n 'src/deep.ts',\n {\n file: 'src/deep.ts',\n complexityRisk: 50,\n highComplexityFunctions: 3,\n functionCount: 5,\n flows: 2,\n score: 60,\n },\n ],\n ]);\n const findings = detectUntestedCriticalCode(state, [], critMap);\n expect(findings.length).toBe(1);\n expect(findings[0].file).toBe('src/deep.ts');\n expect(findings[0].reason).toContain('complexity');\n });\n\n it('skips test files themselves', () => {\n const state = emptyState();\n state.files.add('src/core.test.ts');\n const hotFiles = [\n {\n file: 'src/core.test.ts',\n riskScore: 50,\n fanIn: 10,\n fanOut: 5,\n complexityScore: 20,\n exportCount: 8,\n inCycle: false,\n onCriticalPath: true,\n },\n ];\n const findings = detectUntestedCriticalCode(state, hotFiles, new Map());\n expect(findings).toEqual([]);\n });\n\n it('deduplicates files appearing in both hotFiles and criticality map', () => {\n const state = emptyState();\n state.files.add('src/shared.ts');\n const hotFiles = [\n {\n file: 'src/shared.ts',\n riskScore: 50,\n fanIn: 10,\n fanOut: 5,\n complexityScore: 20,\n exportCount: 8,\n inCycle: false,\n onCriticalPath: false,\n },\n ];\n const critMap = new Map\u003cstring, FileCriticality>([\n [\n 'src/shared.ts',\n {\n file: 'src/shared.ts',\n complexityRisk: 50,\n highComplexityFunctions: 3,\n functionCount: 5,\n flows: 2,\n score: 60,\n },\n ],\n ]);\n const findings = detectUntestedCriticalCode(state, hotFiles, critMap);\n expect(findings.length).toBe(1);\n });\n\n it('includes risk details in reason', () => {\n const state = emptyState();\n state.files.add('src/risky.ts');\n const hotFiles = [\n {\n file: 'src/risky.ts',\n riskScore: 60,\n fanIn: 15,\n fanOut: 8,\n complexityScore: 30,\n exportCount: 12,\n inCycle: true,\n onCriticalPath: false,\n },\n ];\n const findings = detectUntestedCriticalCode(state, hotFiles, new Map());\n expect(findings[0].reason).toContain('risk');\n expect(findings[0].reason).toContain('60');\n });\n\n it('limits output to top 25 findings', () => {\n const state = emptyState();\n const hotFiles = Array.from({ length: 40 }, (_, i) => {\n const file = `src/mod${i}.ts`;\n state.files.add(file);\n return {\n file,\n riskScore: 50,\n fanIn: 10,\n fanOut: 5,\n complexityScore: 20,\n exportCount: 8,\n inCycle: false,\n onCriticalPath: true,\n };\n });\n const findings = detectUntestedCriticalCode(state, hotFiles, new Map());\n expect(findings.length).toBeLessThanOrEqual(25);\n });\n});\n\ndescribe('detectNamespaceImport', () => {\n it('flags import * as X from internal module', () => {\n const state = emptyState();\n state.files.add('src/consumer.ts');\n state.files.add('src/utils.ts');\n state.importedSymbolsByFile.set('src/consumer.ts', [\n {\n sourceModule: './utils',\n resolvedModule: 'src/utils.ts',\n importedName: '*',\n localName: 'utils',\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectNamespaceImport(state);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('namespace-import');\n expect(findings[0].title).toContain('import * as utils');\n });\n\n it('flags import * as X from external module', () => {\n const state = emptyState();\n state.files.add('src/app.ts');\n state.importedSymbolsByFile.set('src/app.ts', [\n {\n sourceModule: 'lodash',\n importedName: '*',\n localName: 'lodash',\n isTypeOnly: false,\n lineStart: 3,\n lineEnd: 3,\n },\n ]);\n const findings = detectNamespaceImport(state);\n expect(findings.length).toBe(1);\n expect(findings[0].severity).toBe('medium');\n });\n\n it('skips type-only namespace imports', () => {\n const state = emptyState();\n state.files.add('src/consumer.ts');\n state.importedSymbolsByFile.set('src/consumer.ts', [\n {\n sourceModule: './types',\n resolvedModule: 'src/types.ts',\n importedName: '*',\n localName: 'T',\n isTypeOnly: true,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectNamespaceImport(state);\n expect(findings.length).toBe(0);\n });\n\n it('skips require() calls (handled by CJS detector)', () => {\n const state = emptyState();\n state.files.add('src/app.ts');\n state.importedSymbolsByFile.set('src/app.ts', [\n {\n sourceModule: 'fs',\n importedName: '*',\n localName: 'require',\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectNamespaceImport(state);\n expect(findings.length).toBe(0);\n });\n\n it('skips test files', () => {\n const state = emptyState();\n state.files.add('src/app.test.ts');\n state.importedSymbolsByFile.set('src/app.test.ts', [\n {\n sourceModule: './utils',\n importedName: '*',\n localName: 'utils',\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectNamespaceImport(state);\n expect(findings.length).toBe(0);\n });\n\n it('escalates severity for high-fan-in internal targets', () => {\n const state = emptyState();\n state.files.add('src/consumer.ts');\n state.files.add('src/shared.ts');\n state.importedSymbolsByFile.set('src/consumer.ts', [\n {\n sourceModule: './shared',\n resolvedModule: 'src/shared.ts',\n importedName: '*',\n localName: 'shared',\n isTypeOnly: false,\n lineStart: 2,\n lineEnd: 2,\n },\n ]);\n const incomingSet = new Set([\n 'a.ts',\n 'b.ts',\n 'c.ts',\n 'd.ts',\n 'e.ts',\n 'f.ts',\n ]);\n state.incoming.set('src/shared.ts', incomingSet);\n const findings = detectNamespaceImport(state);\n expect(findings.length).toBe(1);\n expect(findings[0].severity).toBe('high');\n });\n});\n\ndescribe('detectCommonJsInEsm', () => {\n it('flags require() as commonjs-in-esm when file has no ESM imports', () => {\n const state = emptyState();\n state.files.add('src/legacy.ts');\n state.importedSymbolsByFile.set('src/legacy.ts', [\n {\n sourceModule: 'fs',\n importedName: '*',\n localName: 'require',\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectCommonJsInEsm(state);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('commonjs-in-esm');\n expect(findings[0].severity).toBe('medium');\n });\n\n it('flags require() as mixed-module-format when file also has ESM imports', () => {\n const state = emptyState();\n state.files.add('src/hybrid.ts');\n state.importedSymbolsByFile.set('src/hybrid.ts', [\n {\n sourceModule: 'path',\n importedName: 'default',\n localName: 'path',\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n {\n sourceModule: 'fs',\n importedName: '*',\n localName: 'require',\n isTypeOnly: false,\n lineStart: 2,\n lineEnd: 2,\n },\n ]);\n const findings = detectCommonJsInEsm(state);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('mixed-module-format');\n expect(findings[0].severity).toBe('high');\n });\n\n it('skips test files', () => {\n const state = emptyState();\n state.files.add('src/app.test.ts');\n state.importedSymbolsByFile.set('src/app.test.ts', [\n {\n sourceModule: 'fs',\n importedName: '*',\n localName: 'require',\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectCommonJsInEsm(state);\n expect(findings.length).toBe(0);\n });\n\n it('skips files with only named ESM imports', () => {\n const state = emptyState();\n state.files.add('src/clean.ts');\n state.importedSymbolsByFile.set('src/clean.ts', [\n {\n sourceModule: 'path',\n importedName: 'join',\n localName: 'join',\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectCommonJsInEsm(state);\n expect(findings.length).toBe(0);\n });\n\n it('flags multiple require() calls separately', () => {\n const state = emptyState();\n state.files.add('src/legacy.ts');\n state.importedSymbolsByFile.set('src/legacy.ts', [\n {\n sourceModule: 'fs',\n importedName: '*',\n localName: 'require',\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n {\n sourceModule: 'path',\n importedName: '*',\n localName: 'require',\n isTypeOnly: false,\n lineStart: 2,\n lineEnd: 2,\n },\n ]);\n const findings = detectCommonJsInEsm(state);\n expect(findings.length).toBe(2);\n });\n});\n\ndescribe('detectExportStarLeak', () => {\n it('flags export * from internal module', () => {\n const state = emptyState();\n state.files.add('src/index.ts');\n state.files.add('src/utils.ts');\n state.reExportsByFile.set('src/index.ts', [\n {\n sourceModule: './utils',\n resolvedModule: 'src/utils.ts',\n exportedAs: '*',\n importedName: '*',\n isStar: true,\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n state.declaredExportsByFile.set('src/utils.ts', [\n { name: 'foo', kind: 'value' },\n { name: 'bar', kind: 'value' },\n ]);\n const findings = detectExportStarLeak(state);\n expect(findings.length).toBe(1);\n expect(findings[0].category).toBe('export-star-leak');\n expect(findings[0].reason).toContain('2 symbols');\n });\n\n it('escalates severity when target has many exports', () => {\n const state = emptyState();\n state.files.add('src/barrel.ts');\n state.files.add('src/large.ts');\n state.reExportsByFile.set('src/barrel.ts', [\n {\n sourceModule: './large',\n resolvedModule: 'src/large.ts',\n exportedAs: '*',\n importedName: '*',\n isStar: true,\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const exports = Array.from({ length: 25 }, (_, i) => ({\n name: `fn${i}`,\n kind: 'value' as const,\n }));\n state.declaredExportsByFile.set('src/large.ts', exports);\n const findings = detectExportStarLeak(state);\n expect(findings.length).toBe(1);\n expect(findings[0].severity).toBe('high');\n });\n\n it('escalates severity for chained export-star', () => {\n const state = emptyState();\n state.files.add('src/barrel.ts');\n state.files.add('src/sub-barrel.ts');\n state.reExportsByFile.set('src/barrel.ts', [\n {\n sourceModule: './sub-barrel',\n resolvedModule: 'src/sub-barrel.ts',\n exportedAs: '*',\n importedName: '*',\n isStar: true,\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n state.reExportsByFile.set('src/sub-barrel.ts', [\n {\n sourceModule: './deep',\n resolvedModule: 'src/deep.ts',\n exportedAs: '*',\n importedName: '*',\n isStar: true,\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectExportStarLeak(state);\n const barrelFinding = findings.find(f => f.file === 'src/barrel.ts');\n expect(barrelFinding).toBeDefined();\n expect(barrelFinding!.severity).toBe('high');\n expect(barrelFinding!.reason).toContain('chains');\n });\n\n it('skips type-only export *', () => {\n const state = emptyState();\n state.files.add('src/index.ts');\n state.reExportsByFile.set('src/index.ts', [\n {\n sourceModule: './types',\n resolvedModule: 'src/types.ts',\n exportedAs: '*',\n importedName: '*',\n isStar: true,\n isTypeOnly: true,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectExportStarLeak(state);\n expect(findings.length).toBe(0);\n });\n\n it('skips test files', () => {\n const state = emptyState();\n state.files.add('src/test-utils.test.ts');\n state.reExportsByFile.set('src/test-utils.test.ts', [\n {\n sourceModule: './helpers',\n exportedAs: '*',\n importedName: '*',\n isStar: true,\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectExportStarLeak(state);\n expect(findings.length).toBe(0);\n });\n\n it('flags named re-exports as non-leak', () => {\n const state = emptyState();\n state.files.add('src/index.ts');\n state.reExportsByFile.set('src/index.ts', [\n {\n sourceModule: './utils',\n resolvedModule: 'src/utils.ts',\n exportedAs: 'foo',\n importedName: 'foo',\n isStar: false,\n isTypeOnly: false,\n lineStart: 1,\n lineEnd: 1,\n },\n ]);\n const findings = detectExportStarLeak(state);\n expect(findings.length).toBe(0);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":89992,"content_sha256":"e2752cdb9eed3b297dc809068245aec26139d416efe4775bfa52c0db8bb12f28"},{"filename":"src/detectors/index.ts","content":"export type { FindingDraft } from './shared.js';\nexport { isLikelyEntrypoint } from './shared.js';\n\nexport {\n detectTestOnlyModules,\n detectDependencyCycles,\n mergeOverlappingChains,\n detectCriticalPaths,\n detectDeadFiles,\n detectOrphanModules,\n detectUnreachableModules,\n} from './cycle.js';\n\nexport {\n computeInstability,\n detectSdpViolations,\n detectHighCoupling,\n detectGodModuleCoupling,\n detectLayerViolations,\n computeAbstractness,\n detectDistanceFromMainSequence,\n} from './coupling.js';\n\nexport {\n detectGodModules,\n detectMegaFolders,\n detectGodFunctions,\n detectLowCohesion,\n computeHotFiles,\n detectUntestedCriticalCode,\n detectFeatureEnvy,\n} from './cohesion.js';\n\nexport {\n computeBarrelDepth,\n detectBarrelExplosion,\n detectImportSideEffectRisk,\n detectNamespaceImport,\n detectCommonJsInEsm,\n detectExportStarLeak,\n} from './import-style.js';\n\nexport {\n buildConsumedFromModule,\n detectDeadExports,\n detectDeadReExports,\n detectUnusedNpmDeps,\n detectBoundaryViolations,\n} from './dead-code.js';\n\nexport {\n detectDuplicateFunctionBodies,\n detectDuplicateFlowStructures,\n detectFunctionOptimization,\n computeCognitiveComplexity,\n detectCognitiveComplexity,\n detectExcessiveParameters,\n detectEmptyCatchBlocks,\n detectSwitchNoDefault,\n detectUnsafeAny,\n detectHighHalsteadEffort,\n detectLowMaintainability,\n detectTypeAssertionEscape,\n detectMessageChains,\n detectMissingErrorBoundary,\n detectPromiseMisuse,\n detectAwaitInLoop,\n detectSyncIo,\n detectUnclearedTimers,\n detectListenerLeakRisk,\n detectUnboundedCollection,\n detectSimilarFunctionBodies,\n detectDeepNesting,\n detectMultipleReturnPaths,\n detectCatchRethrow,\n detectMagicStrings,\n detectBooleanParameterCluster,\n detectPromiseAllUnhandled,\n detectExportSurfaceDensity,\n detectChangeRisk,\n} from './code-quality.js';\n\nexport {\n detectCommandInjectionRisk,\n detectDebugLogLeakage,\n detectEvalUsage,\n detectHardcodedSecrets,\n detectInputPassthroughRisk,\n detectPathTraversalRisk,\n detectPrototypePollutionRisk,\n detectSensitiveDataLogging,\n detectSqlInjectionRisk,\n detectUnsafeHtml,\n detectUnsafeRegex,\n detectUnvalidatedInputSink,\n} from './security.js';\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":2199,"content_sha256":"a4570e3df5d322391e17e87fd28416ab8a7b3707bf1f4fb70f24914ca37347aa"},{"filename":"src/detectors/security.test.ts","content":"import { describe, expect, it } from 'vitest';\n\nimport {\n detectCommandInjectionRisk,\n detectEvalUsage,\n detectHardcodedSecrets,\n detectInputPassthroughRisk,\n detectPathTraversalRisk,\n detectPrototypePollutionRisk,\n detectSqlInjectionRisk,\n detectUnsafeHtml,\n detectUnsafeRegex,\n detectUnvalidatedInputSink,\n} from './security.js';\n\nimport type { FileEntry, InputSourceInfo } from '../types/index.js';\n\nfunction makeFileEntry(override: Partial\u003cFileEntry> = {}): FileEntry {\n return {\n file: override.file ?? 'src/app.ts',\n package: override.package ?? 'my-pkg',\n parseEngine: override.parseEngine ?? 'typescript',\n nodeCount: override.nodeCount ?? 100,\n kindCounts: override.kindCounts ?? {},\n functions: override.functions ?? [],\n flows: override.flows ?? [],\n dependencyProfile: override.dependencyProfile ?? {\n internalDependencies: [],\n externalDependencies: [],\n unresolvedDependencies: [],\n declaredExports: [],\n importedSymbols: [],\n reExports: [],\n },\n ...override,\n };\n}\n\nfunction makeInputSource(\n override: Partial\u003cInputSourceInfo> = {}\n): InputSourceInfo {\n return {\n functionName: override.functionName ?? 'handleRequest',\n lineStart: override.lineStart ?? 10,\n lineEnd: override.lineEnd ?? 30,\n sourceParams: override.sourceParams ?? ['userInput'],\n hasSinkInBody: override.hasSinkInBody ?? false,\n sinkKinds: override.sinkKinds ?? [],\n hasValidation: override.hasValidation ?? false,\n callsWithInputArgs: override.callsWithInputArgs ?? [],\n paramConfidence: override.paramConfidence ?? 'high',\n ...override,\n };\n}\n\ndescribe('detectHardcodedSecrets', () => {\n it('detects a hardcoded secret with literal context', () => {\n const files = [\n makeFileEntry({\n suspiciousStrings: [\n {\n lineStart: 5,\n lineEnd: 5,\n kind: 'hardcoded-secret',\n context: 'literal',\n snippet: 'AKIAIOSFODNN7EXAMPLE',\n },\n ],\n }),\n ];\n const findings = detectHardcodedSecrets(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('hardcoded-secret');\n expect(findings[0].severity).toBe('high');\n expect(findings[0].title).toContain('Potential hardcoded secret');\n });\n\n it('detects a secret-assignment context', () => {\n const files = [\n makeFileEntry({\n suspiciousStrings: [\n {\n lineStart: 10,\n lineEnd: 10,\n kind: 'hardcoded-secret',\n context: 'literal',\n snippet: 'password=supersecret123',\n },\n ],\n }),\n ];\n const findings = detectHardcodedSecrets(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].title).toContain('password=supersecret');\n });\n\n it('skips error-message context', () => {\n const files = [\n makeFileEntry({\n suspiciousStrings: [\n {\n lineStart: 5,\n lineEnd: 5,\n kind: 'hardcoded-secret',\n context: 'error-message',\n snippet: 'invalid token provided for auth',\n },\n ],\n }),\n ];\n const findings = detectHardcodedSecrets(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips regex-definition context', () => {\n const files = [\n makeFileEntry({\n suspiciousStrings: [\n {\n lineStart: 5,\n lineEnd: 5,\n kind: 'hardcoded-secret',\n context: 'regex-definition',\n snippet: '/password|secret/i',\n },\n ],\n }),\n ];\n const findings = detectHardcodedSecrets(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'src/utils.test.ts',\n suspiciousStrings: [\n {\n lineStart: 5,\n lineEnd: 5,\n kind: 'hardcoded-secret',\n context: 'literal',\n snippet: 'testSecret123',\n },\n ],\n }),\n ];\n const findings = detectHardcodedSecrets(files);\n expect(findings).toHaveLength(0);\n });\n\n it('returns 0 findings for empty suspiciousStrings', () => {\n const files = [makeFileEntry({ suspiciousStrings: [] })];\n const findings = detectHardcodedSecrets(files);\n expect(findings).toHaveLength(0);\n });\n\n it('truncates snippet in title to 20 chars', () => {\n const files = [\n makeFileEntry({\n suspiciousStrings: [\n {\n lineStart: 1,\n lineEnd: 1,\n kind: 'hardcoded-secret',\n context: 'literal',\n snippet: 'abcdefghijklmnopqrstuvwxyz1234567890',\n },\n ],\n }),\n ];\n const findings = detectHardcodedSecrets(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].title).toContain('abcdefghijklmnopqrst');\n expect(findings[0].title).toContain('…');\n });\n});\n\ndescribe('detectEvalUsage', () => {\n it('detects eval usage', () => {\n const files = [\n makeFileEntry({\n evalUsages: [{ file: 'src/app.ts', lineStart: 15, lineEnd: 15 }],\n }),\n ];\n const findings = detectEvalUsage(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('eval-usage');\n expect(findings[0].severity).toBe('critical');\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'src/__tests__/eval.ts',\n evalUsages: [\n { file: 'src/__tests__/eval.ts', lineStart: 1, lineEnd: 1 },\n ],\n }),\n ];\n const findings = detectEvalUsage(files);\n expect(findings).toHaveLength(0);\n });\n\n it('returns 0 findings for empty evalUsages', () => {\n const files = [makeFileEntry({ evalUsages: [] })];\n const findings = detectEvalUsage(files);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('detectUnsafeHtml', () => {\n it('detects unsafe HTML assignments', () => {\n const files = [\n makeFileEntry({\n unsafeHtmlAssignments: [\n { file: 'src/app.ts', lineStart: 20, lineEnd: 20 },\n ],\n }),\n ];\n const findings = detectUnsafeHtml(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('unsafe-html');\n expect(findings[0].severity).toBe('high');\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'tests/render.spec.ts',\n unsafeHtmlAssignments: [\n { file: 'tests/render.spec.ts', lineStart: 5, lineEnd: 5 },\n ],\n }),\n ];\n const findings = detectUnsafeHtml(files);\n expect(findings).toHaveLength(0);\n });\n\n it('returns 0 findings for empty unsafeHtmlAssignments', () => {\n const files = [makeFileEntry({ unsafeHtmlAssignments: [] })];\n const findings = detectUnsafeHtml(files);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('detectSqlInjectionRisk', () => {\n it('detects sql-injection kind suspicious string', () => {\n const files = [\n makeFileEntry({\n suspiciousStrings: [\n {\n lineStart: 30,\n lineEnd: 30,\n kind: 'sql-injection',\n snippet: 'SELECT * FROM users WHERE id=${userId}',\n },\n ],\n }),\n ];\n const findings = detectSqlInjectionRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('sql-injection-risk');\n expect(findings[0].severity).toBe('high');\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'src/db.test.ts',\n suspiciousStrings: [\n {\n lineStart: 5,\n lineEnd: 5,\n kind: 'sql-injection',\n },\n ],\n }),\n ];\n const findings = detectSqlInjectionRisk(files);\n expect(findings).toHaveLength(0);\n });\n\n it('does not pick up hardcoded-secret kind', () => {\n const files = [\n makeFileEntry({\n suspiciousStrings: [\n {\n lineStart: 5,\n lineEnd: 5,\n kind: 'hardcoded-secret',\n context: 'literal',\n snippet: 'not-a-sql-injection',\n },\n ],\n }),\n ];\n const findings = detectSqlInjectionRisk(files);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('detectUnsafeRegex', () => {\n it('detects nested quantifier pattern (a+)+', () => {\n const files = [\n makeFileEntry({\n regexLiterals: [\n {\n lineStart: 10,\n lineEnd: 10,\n pattern: '(a+)+',\n },\n ],\n }),\n ];\n const findings = detectUnsafeRegex(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('unsafe-regex');\n expect(findings[0].severity).toBe('medium');\n });\n\n it('detects another nested quantifier pattern (a?){', () => {\n const files = [\n makeFileEntry({\n regexLiterals: [\n {\n lineStart: 12,\n lineEnd: 12,\n pattern: '(a?){10}',\n },\n ],\n }),\n ];\n const findings = detectUnsafeRegex(files);\n expect(findings).toHaveLength(1);\n });\n\n it('does not flag safe regex', () => {\n const files = [\n makeFileEntry({\n regexLiterals: [\n {\n lineStart: 5,\n lineEnd: 5,\n pattern: '^[a-z]+

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…

,\n },\n ],\n }),\n ];\n const findings = detectUnsafeRegex(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'src/regex.test.ts',\n regexLiterals: [\n {\n lineStart: 5,\n lineEnd: 5,\n pattern: '(a+)+',\n },\n ],\n }),\n ];\n const findings = detectUnsafeRegex(files);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('detectPrototypePollutionRisk', () => {\n it('detects unguarded computed-property-write as high severity', () => {\n const files = [\n makeFileEntry({\n prototypePollutionSites: [\n {\n kind: 'computed-property-write',\n detail: 'obj[key] = value',\n lineStart: 20,\n lineEnd: 20,\n guarded: false,\n },\n ],\n }),\n ];\n const findings = detectPrototypePollutionRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('high');\n expect(findings[0].category).toBe('prototype-pollution-risk');\n });\n\n it('detects object-assign site as medium severity', () => {\n const files = [\n makeFileEntry({\n prototypePollutionSites: [\n {\n kind: 'object-assign',\n detail: 'Object.assign(target, source)',\n lineStart: 25,\n lineEnd: 25,\n guarded: false,\n },\n ],\n }),\n ];\n const findings = detectPrototypePollutionRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('medium');\n });\n\n it('downgrades guarded computed-property-write to low severity', () => {\n const files = [\n makeFileEntry({\n prototypePollutionSites: [\n {\n kind: 'computed-property-write',\n detail: 'obj[key] = value',\n lineStart: 20,\n lineEnd: 20,\n guarded: true,\n },\n ],\n }),\n ];\n const findings = detectPrototypePollutionRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('low');\n expect(findings[0].title).toContain('(guarded)');\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'tests/merge.test.ts',\n prototypePollutionSites: [\n {\n kind: 'computed-property-write',\n detail: 'obj[key] = value',\n lineStart: 5,\n lineEnd: 5,\n guarded: false,\n },\n ],\n }),\n ];\n const findings = detectPrototypePollutionRisk(files);\n expect(findings).toHaveLength(0);\n });\n\n it('returns 0 findings for empty prototypePollutionSites', () => {\n const files = [makeFileEntry({ prototypePollutionSites: [] })];\n const findings = detectPrototypePollutionRisk(files);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('detectUnvalidatedInputSink', () => {\n it('detects high severity when hasSinkInBody=true, hasValidation=false, paramConfidence=high', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n hasSinkInBody: true,\n hasValidation: false,\n paramConfidence: 'high',\n sinkKinds: ['eval'],\n callsWithInputArgs: [{ callee: 'eval', lineStart: 15 }],\n }),\n ],\n }),\n ];\n const findings = detectUnvalidatedInputSink(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('high');\n expect(findings[0].category).toBe('unvalidated-input-sink');\n });\n\n it('detects medium severity when paramConfidence=low', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n hasSinkInBody: true,\n hasValidation: false,\n paramConfidence: 'low',\n sinkKinds: ['eval'],\n callsWithInputArgs: [{ callee: 'eval', lineStart: 15 }],\n }),\n ],\n }),\n ];\n const findings = detectUnvalidatedInputSink(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('medium');\n });\n\n it('skips when hasSinkInBody=false', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n hasSinkInBody: false,\n hasValidation: false,\n paramConfidence: 'high',\n }),\n ],\n }),\n ];\n const findings = detectUnvalidatedInputSink(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips when hasValidation=true', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n hasSinkInBody: true,\n hasValidation: true,\n paramConfidence: 'high',\n sinkKinds: ['eval'],\n }),\n ],\n }),\n ];\n const findings = detectUnvalidatedInputSink(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'src/handler.test.ts',\n inputSources: [\n makeInputSource({\n hasSinkInBody: true,\n hasValidation: false,\n paramConfidence: 'high',\n sinkKinds: ['eval'],\n }),\n ],\n }),\n ];\n const findings = detectUnvalidatedInputSink(files);\n expect(findings).toHaveLength(0);\n });\n\n it('returns 0 findings for empty inputSources', () => {\n const files = [makeFileEntry({ inputSources: [] })];\n const findings = detectUnvalidatedInputSink(files);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('detectInputPassthroughRisk', () => {\n it('detects medium severity when paramConfidence=high, callsWithInputArgs non-empty, no sink, no validation', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n callsWithInputArgs: [{ callee: 'processData', lineStart: 20 }],\n hasValidation: false,\n hasSinkInBody: false,\n paramConfidence: 'high',\n }),\n ],\n }),\n ];\n const findings = detectInputPassthroughRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('medium');\n expect(findings[0].category).toBe('input-passthrough-risk');\n });\n\n it('detects low severity when paramConfidence=medium', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n callsWithInputArgs: [{ callee: 'processData', lineStart: 20 }],\n hasValidation: false,\n hasSinkInBody: false,\n paramConfidence: 'medium',\n }),\n ],\n }),\n ];\n const findings = detectInputPassthroughRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('low');\n });\n\n it('skips when paramConfidence=low', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n callsWithInputArgs: [{ callee: 'processData', lineStart: 20 }],\n hasValidation: false,\n hasSinkInBody: false,\n paramConfidence: 'low',\n }),\n ],\n }),\n ];\n const findings = detectInputPassthroughRisk(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips when hasSinkInBody=true', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n callsWithInputArgs: [{ callee: 'processData', lineStart: 20 }],\n hasValidation: false,\n hasSinkInBody: true,\n paramConfidence: 'high',\n }),\n ],\n }),\n ];\n const findings = detectInputPassthroughRisk(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips when hasValidation=true', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n callsWithInputArgs: [{ callee: 'processData', lineStart: 20 }],\n hasValidation: true,\n hasSinkInBody: false,\n paramConfidence: 'high',\n }),\n ],\n }),\n ];\n const findings = detectInputPassthroughRisk(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'src/api.spec.ts',\n inputSources: [\n makeInputSource({\n callsWithInputArgs: [{ callee: 'processData', lineStart: 20 }],\n hasValidation: false,\n hasSinkInBody: false,\n paramConfidence: 'high',\n }),\n ],\n }),\n ];\n const findings = detectInputPassthroughRisk(files);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('detectPathTraversalRisk', () => {\n it('detects high severity when fs-read sink, paramConfidence=high, no validation', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n sinkKinds: ['fs-read'],\n paramConfidence: 'high',\n hasValidation: false,\n hasSinkInBody: true,\n callsWithInputArgs: [{ callee: 'fs.readFile', lineStart: 15 }],\n }),\n ],\n }),\n ];\n const findings = detectPathTraversalRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('high');\n expect(findings[0].category).toBe('path-traversal-risk');\n });\n\n it('detects medium severity when hasValidation=true', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n sinkKinds: ['fs-read'],\n paramConfidence: 'high',\n hasValidation: true,\n hasSinkInBody: true,\n callsWithInputArgs: [{ callee: 'fs.readFile', lineStart: 15 }],\n }),\n ],\n }),\n ];\n const findings = detectPathTraversalRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('medium');\n });\n\n it('skips when paramConfidence=low', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n sinkKinds: ['fs-read'],\n paramConfidence: 'low',\n hasValidation: false,\n hasSinkInBody: true,\n }),\n ],\n }),\n ];\n const findings = detectPathTraversalRisk(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips when no fs-read or path-resolve sinks', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n sinkKinds: ['eval'],\n paramConfidence: 'high',\n hasValidation: false,\n hasSinkInBody: true,\n }),\n ],\n }),\n ];\n const findings = detectPathTraversalRisk(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'src/file.test.ts',\n inputSources: [\n makeInputSource({\n sinkKinds: ['fs-read'],\n paramConfidence: 'high',\n hasValidation: false,\n hasSinkInBody: true,\n }),\n ],\n }),\n ];\n const findings = detectPathTraversalRisk(files);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('detectCommandInjectionRisk', () => {\n it('detects critical severity for exec callees with paramConfidence=high', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n sinkKinds: ['exec'],\n paramConfidence: 'high',\n hasValidation: false,\n hasSinkInBody: true,\n callsWithInputArgs: [\n { callee: 'child_process.exec', lineStart: 15 },\n ],\n }),\n ],\n }),\n ];\n const findings = detectCommandInjectionRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('critical');\n expect(findings[0].category).toBe('command-injection-risk');\n expect(findings[0].title).toContain('exec');\n });\n\n it('detects high severity for spawn callees (no exec)', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n sinkKinds: ['exec'],\n paramConfidence: 'high',\n hasValidation: false,\n hasSinkInBody: true,\n callsWithInputArgs: [\n { callee: 'child_process.spawn', lineStart: 15 },\n ],\n }),\n ],\n }),\n ];\n const findings = detectCommandInjectionRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].severity).toBe('high');\n expect(findings[0].title).toContain('spawn');\n });\n\n it('skips when paramConfidence=low', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n sinkKinds: ['exec'],\n paramConfidence: 'low',\n hasValidation: false,\n hasSinkInBody: true,\n callsWithInputArgs: [\n { callee: 'child_process.exec', lineStart: 15 },\n ],\n }),\n ],\n }),\n ];\n const findings = detectCommandInjectionRisk(files);\n expect(findings).toHaveLength(0);\n });\n\n it('skips when no exec sinks', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n sinkKinds: ['eval'],\n paramConfidence: 'high',\n hasValidation: false,\n hasSinkInBody: true,\n callsWithInputArgs: [{ callee: 'eval', lineStart: 15 }],\n }),\n ],\n }),\n ];\n const findings = detectCommandInjectionRisk(files);\n expect(findings).toHaveLength(0);\n });\n\n it('only emits exec finding when both exec and spawn callees exist', () => {\n const files = [\n makeFileEntry({\n inputSources: [\n makeInputSource({\n sinkKinds: ['exec'],\n paramConfidence: 'high',\n hasValidation: false,\n hasSinkInBody: true,\n callsWithInputArgs: [\n { callee: 'child_process.exec', lineStart: 15 },\n { callee: 'child_process.spawn', lineStart: 20 },\n ],\n }),\n ],\n }),\n ];\n const findings = detectCommandInjectionRisk(files);\n expect(findings).toHaveLength(1);\n expect(findings[0].title).toContain('exec');\n expect(findings[0].title).not.toContain('spawn');\n });\n\n it('skips test files', () => {\n const files = [\n makeFileEntry({\n file: 'src/exec.test.ts',\n inputSources: [\n makeInputSource({\n sinkKinds: ['exec'],\n paramConfidence: 'high',\n hasValidation: false,\n hasSinkInBody: true,\n callsWithInputArgs: [{ callee: 'exec', lineStart: 5 }],\n }),\n ],\n }),\n ];\n const findings = detectCommandInjectionRisk(files);\n expect(findings).toHaveLength(0);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":24347,"content_sha256":"46b24c543ed3c4a20e2a9d6798be7e580b659b08d0ef4059515694f2d9e0ce1a"},{"filename":"src/detectors/security.ts","content":"import { isTestFile } from '../common/utils.js';\n\nimport type { FileEntry, Finding } from '../types/index.js';\n\ntype FindingDraft = Omit\u003cFinding, 'id'>;\n\nconst NESTED_QUANTIFIER_RE = /(\\(.+[+*]\\))[+*]|(\\(.+\\?\\))\\{/;\n\nconst toSecurityFinding = (\n draft: FindingDraft,\n ruleId: string,\n confidence: 'high' | 'medium' | 'low',\n evidence: Record\u003cstring, unknown>\n): FindingDraft => ({\n ...draft,\n ruleId,\n confidence,\n evidence,\n});\n\nexport function detectHardcodedSecrets(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n const secrets = (entry.suspiciousStrings || []).filter(\n s =>\n s.kind === 'hardcoded-secret' &&\n s.context !== 'regex-definition' &&\n s.context !== 'error-message'\n );\n if (secrets.length === 0) continue;\n for (const s of secrets) {\n findings.push(\n toSecurityFinding(\n {\n severity: 'high',\n category: 'hardcoded-secret',\n file: entry.file,\n lineStart: s.lineStart,\n lineEnd: s.lineEnd,\n title: `Potential hardcoded secret${s.snippet ? `: ${s.snippet.slice(0, 20)}…` : ''}`,\n reason: `String literal matches a secret pattern (password, API key, token, high-entropy string). Secrets in source code risk credential leaks. Validate: use localSearchCode to find the variable, then lspFindReferences to check if it is used in auth or network calls.`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Move secret to environment variable or secrets manager.',\n steps: [\n 'Replace the hardcoded value with process.env.YOUR_SECRET.',\n 'Add the variable to your .env file (excluded from git).',\n 'Verify the secret is not committed in git history.',\n ],\n },\n impact:\n 'Credential leak in source code exposes API access, database credentials, or authentication tokens to anyone with repo access.',\n tags: ['security', 'secrets'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: s.snippet?.split(/[=:]/)[0]?.trim() || 'secret',\n lineHint: s.lineStart,\n file: entry.file,\n expectedResult: `find all usages of this secret value — if used only in tests or as a regex pattern, it is a false positive`,\n },\n ],\n },\n 'security.hardcoded-secret',\n 'high',\n {\n source: s.snippet || '',\n sink: 'runtime usage',\n context: s.context || 'literal',\n sanitizerStatus: 'missing',\n propagationSteps: [`${entry.file}:${s.lineStart}`],\n }\n )\n );\n }\n }\n return findings;\n}\n\nexport function detectEvalUsage(fileSummaries: FileEntry[]): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const loc of entry.evalUsages || []) {\n findings.push(\n toSecurityFinding(\n {\n severity: 'critical',\n category: 'eval-usage',\n file: entry.file,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n title: 'Dynamic code execution (eval/Function)',\n reason:\n 'eval(), new Function(), or string-based setTimeout/setInterval allows arbitrary code execution. This is a code injection vector.',\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Replace dynamic code execution with safe alternatives.',\n steps: [\n 'For JSON parsing: use JSON.parse() instead of eval().',\n 'For dynamic dispatch: use a lookup table or switch statement.',\n 'For setTimeout: pass a function reference, not a string.',\n ],\n },\n impact:\n 'Arbitrary code execution enables full application takeover — the most severe class of injection vulnerability.',\n tags: ['security', 'injection', 'critical'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: 'eval',\n lineHint: loc.lineStart,\n file: entry.file,\n expectedResult: `trace callers to find how user input reaches the eval site`,\n },\n ],\n },\n 'security.eval-usage',\n 'high',\n {\n sink: `eval at ${entry.file}:${loc.lineStart}-${loc.lineEnd}`,\n sanitizerStatus: 'missing',\n }\n )\n );\n }\n }\n return findings;\n}\n\nexport function detectUnsafeHtml(fileSummaries: FileEntry[]): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const loc of entry.unsafeHtmlAssignments || []) {\n findings.push(\n toSecurityFinding(\n {\n severity: 'high',\n category: 'unsafe-html',\n file: entry.file,\n lineStart: loc.lineStart,\n lineEnd: loc.lineEnd,\n title: 'Unsafe HTML manipulation',\n reason:\n 'innerHTML, outerHTML, dangerouslySetInnerHTML, or document.write can execute unsanitized user input as HTML/script. XSS vector.',\n files: [entry.file],\n suggestedFix: {\n strategy: 'Use safe DOM APIs or sanitize input before insertion.',\n steps: [\n 'Replace innerHTML with textContent for plain text.',\n 'Use a sanitizer library (e.g. DOMPurify) if HTML is required.',\n 'In React, avoid dangerouslySetInnerHTML — use JSX instead.',\n ],\n },\n impact:\n 'Unsanitized HTML insertion enables cross-site scripting (XSS) — attackers can steal sessions, credentials, or execute actions as the victim.',\n tags: ['security', 'xss'],\n },\n 'security.unsafe-html',\n 'high',\n {\n sink: 'DOM assignment',\n sanitizerStatus: 'missing',\n propagationSteps: ['html assignment'],\n }\n )\n );\n }\n }\n return findings;\n}\n\nexport function detectSqlInjectionRisk(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n const sqls = (entry.suspiciousStrings || []).filter(\n s => s.kind === 'sql-injection'\n );\n for (const s of sqls) {\n findings.push(\n toSecurityFinding(\n {\n severity: 'high',\n category: 'sql-injection-risk',\n file: entry.file,\n lineStart: s.lineStart,\n lineEnd: s.lineEnd,\n title: 'SQL query built with template literal interpolation',\n reason:\n 'Template literals with SQL keywords and interpolated expressions risk SQL injection if user input flows into the query.',\n files: [entry.file],\n suggestedFix: {\n strategy: 'Use parameterized queries or a query builder.',\n steps: [\n 'Replace template literal with parameterized query (e.g. db.query(sql, [param])).',\n 'Use an ORM or query builder that handles escaping.',\n 'If raw SQL is necessary, validate and sanitize all interpolated values.',\n ],\n },\n impact:\n 'SQL injection can expose, modify, or destroy database contents and potentially escalate to full server compromise.',\n tags: ['security', 'injection', 'sql'],\n },\n 'security.sql-injection-risk',\n 'high',\n {\n sink: `sql template literal`,\n sanitizerStatus: 'missing',\n propagationSteps: [`${entry.file}:${s.lineStart}-${s.lineEnd}`],\n }\n )\n );\n }\n }\n return findings;\n}\n\nexport function detectUnsafeRegex(fileSummaries: FileEntry[]): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const re of entry.regexLiterals || []) {\n if (NESTED_QUANTIFIER_RE.test(re.pattern)) {\n findings.push(\n toSecurityFinding(\n {\n severity: 'medium',\n category: 'unsafe-regex',\n file: entry.file,\n lineStart: re.lineStart,\n lineEnd: re.lineEnd,\n title: 'Regex with catastrophic backtracking risk',\n reason: `Pattern \"${re.pattern.slice(0, 40)}\" has nested quantifiers that can cause exponential backtracking (ReDoS).`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Simplify the regex or use atomic groups / possessive quantifiers.',\n steps: [\n 'Remove nested quantifiers — e.g. change (a+)+ to a+.',\n 'Use a regex linter (e.g. safe-regex) to validate patterns.',\n 'Consider using string methods instead of complex regexes.',\n ],\n },\n impact:\n 'Catastrophic backtracking causes CPU exhaustion — a single crafted input string can hang the event loop (ReDoS).',\n tags: ['security', 'regex', 'performance'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: re.pattern.slice(0, 20),\n lineHint: re.lineStart,\n file: entry.file,\n expectedResult: `find where this regex is used to assess if user input reaches it`,\n },\n ],\n },\n 'security.unsafe-regex',\n 'medium',\n {\n source: re.pattern,\n sink: `Regex execution`,\n sanitizerStatus: 'not-applicable',\n propagationSteps: [`${entry.file}:${re.lineStart}-${re.lineEnd}`],\n }\n )\n );\n }\n }\n }\n return findings;\n}\n\nexport function detectPrototypePollutionRisk(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n if (\n !entry.prototypePollutionSites ||\n entry.prototypePollutionSites.length === 0\n )\n continue;\n for (const site of entry.prototypePollutionSites) {\n let severity: Finding['severity'];\n let confidence: 'high' | 'medium' | 'low';\n if (site.kind === 'computed-property-write') {\n if (site.guarded) {\n severity = 'low';\n confidence = 'low';\n } else {\n severity = 'high';\n confidence = 'medium';\n }\n } else {\n severity = 'medium';\n confidence = 'medium';\n }\n findings.push(\n toSecurityFinding(\n {\n severity,\n category: 'prototype-pollution-risk',\n file: entry.file,\n lineStart: site.lineStart,\n lineEnd: site.lineEnd,\n title: `Prototype pollution risk: ${site.kind}${site.guarded ? ' (guarded)' : ''}`,\n reason: `${site.detail}${site.guarded ? ' — guards detected (internal iteration or key check), likely false positive. Verify the key variable does not trace to external input.' : ''}`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Guard against __proto__, constructor, and prototype keys before merging.',\n steps: [\n 'Validate keys: reject \"__proto__\", \"constructor\", \"prototype\" before assignment.',\n 'Use Object.create(null) as the target for merges when possible.',\n 'Replace custom deep-merge with a hardened library (e.g. lodash.merge with prototype guard).',\n 'For Object.assign, ensure the source is sanitized or use structuredClone().',\n ],\n },\n impact:\n 'Prototype pollution can override built-in methods, bypass security checks, or achieve remote code execution.',\n tags: ['security', 'prototype-pollution', 'injection'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName:\n site.kind === 'computed-property-write'\n ? 'bracket-assignment'\n : site.detail.split('(')[0],\n lineHint: site.lineStart,\n file: entry.file,\n expectedResult: `trace callers to determine if user-controlled data reaches this site — if key comes from Object.keys() on internal object, dismiss as false positive`,\n },\n ],\n },\n 'security.prototype-pollution-risk',\n confidence,\n {\n source: site.kind,\n sink: site.detail,\n guarded: site.guarded,\n sanitizerStatus: site.guarded ? 'present' : 'missing',\n propagationSteps: [`${entry.file}:${site.lineStart}`],\n }\n )\n );\n }\n }\n return findings;\n}\n\nexport function detectUnvalidatedInputSink(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const src of entry.inputSources || []) {\n if (!src.hasSinkInBody || src.hasValidation) continue;\n const sinkLabel = src.sinkKinds.join(', ');\n const severity = src.paramConfidence === 'low' ? 'medium' : 'high';\n findings.push(\n toSecurityFinding(\n {\n severity,\n category: 'unvalidated-input-sink',\n file: entry.file,\n lineStart: src.lineStart,\n lineEnd: src.lineEnd,\n title: `Unvalidated input reaches ${sinkLabel} sink in ${src.functionName}(${src.sourceParams.join(', ')})`,\n reason: `Parameter${src.sourceParams.length > 1 ? 's' : ''} '${src.sourceParams.join(\"', '\")}' (external input) flow${src.sourceParams.length === 1 ? 's' : ''} into ${sinkLabel} without validation (no type guard, schema call, or conditional check).`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Add input validation before the sink operation.',\n steps: [\n 'Add schema validation (e.g. zod, joi) for input parameters.',\n 'Use parameterized APIs instead of template interpolation for SQL/exec.',\n `Trace data flow: lspCallHierarchy(outgoing) on ${src.functionName}.`,\n ],\n },\n impact:\n 'Unvalidated external input reaching a dangerous sink (eval, SQL, exec, innerHTML, file write) enables injection attacks.',\n tags: ['security', 'input-validation', 'injection'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: src.functionName,\n lineHint: src.lineStart,\n file: entry.file,\n expectedResult: `trace outgoing calls to see where ${src.sourceParams.join(', ')} data flows`,\n },\n {\n tool: 'lspFindReferences',\n symbolName: src.sourceParams[0],\n lineHint: src.lineStart,\n file: entry.file,\n expectedResult: `check all usages of ${src.sourceParams[0]} parameter within function`,\n },\n ],\n },\n 'security.unvalidated-input-sink',\n severity === 'high' ? 'high' : 'medium',\n {\n sourceParameters: src.sourceParams,\n sink: sinkLabel,\n sanitizerStatus: src.hasValidation ? 'present' : 'missing',\n propagationSteps: src.callsWithInputArgs.map(\n call => `${call.callee}:${call.lineStart}`\n ),\n }\n )\n );\n }\n }\n return findings;\n}\n\nexport function detectInputPassthroughRisk(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const src of entry.inputSources || []) {\n if (src.callsWithInputArgs.length === 0 || src.hasValidation) continue;\n if (src.hasSinkInBody) continue;\n if (src.paramConfidence === 'low') continue;\n const callees = src.callsWithInputArgs.map(c => c.callee);\n const uniqueCallees = [...new Set(callees)];\n const severity = src.paramConfidence === 'high' ? 'medium' : 'low';\n findings.push(\n toSecurityFinding(\n {\n severity,\n category: 'input-passthrough-risk',\n file: entry.file,\n lineStart: src.lineStart,\n lineEnd: src.lineEnd,\n title: `Input passthrough without validation in ${src.functionName}(${src.sourceParams.join(', ')})`,\n reason: `Parameter${src.sourceParams.length > 1 ? 's' : ''} '${src.sourceParams.join(\"', '\")}' (external input) ${src.sourceParams.length === 1 ? 'is' : 'are'} passed to ${uniqueCallees.join(', ')} without validation. Downstream callees may not validate either.`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Validate input before passing to downstream functions.',\n steps: [\n 'Add schema validation (e.g. zod, joi) at the entry point.',\n `Trace downstream: lspCallHierarchy(outgoing) on ${src.functionName} to verify callees validate.`,\n 'Search for validation middleware: localSearchCode for guard/validate/sanitize patterns.',\n ],\n },\n impact:\n 'Unchecked input passed downstream can reach sinks in callees — validation gaps compound across the call chain.',\n tags: ['security', 'input-validation', 'passthrough'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: src.functionName,\n lineHint: src.lineStart,\n file: entry.file,\n expectedResult: `trace outgoing calls to verify downstream validation of ${src.sourceParams.join(', ')}`,\n },\n {\n tool: 'lspFindReferences',\n symbolName: src.sourceParams[0],\n lineHint: src.lineStart,\n file: entry.file,\n expectedResult: `find all usages of ${src.sourceParams[0]} to check if validation occurs upstream`,\n },\n ],\n },\n 'security.input-passthrough-risk',\n severity,\n {\n sourceParameters: src.sourceParams,\n sink: uniqueCallees.join(', '),\n sanitizerStatus: src.hasValidation ? 'present' : 'missing',\n propagationSteps: src.callsWithInputArgs.map(\n call => `${call.callee}:${call.lineStart}`\n ),\n }\n )\n );\n }\n }\n return findings;\n}\n\nexport function detectPathTraversalRisk(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const src of entry.inputSources || []) {\n const fsReadSinks = src.sinkKinds.filter(\n k => k === 'fs-read' || k === 'path-resolve'\n );\n if (fsReadSinks.length === 0) continue;\n if (src.paramConfidence === 'low') continue;\n\n const hasValidation = src.hasValidation;\n const severity: Finding['severity'] = hasValidation ? 'medium' : 'high';\n const sinkLabel = fsReadSinks.join(', ');\n\n findings.push(\n toSecurityFinding(\n {\n severity,\n category: 'path-traversal-risk',\n file: entry.file,\n lineStart: src.lineStart,\n lineEnd: src.lineEnd,\n title: `Path traversal risk: ${src.functionName}(${src.sourceParams.join(', ')}) → ${sinkLabel}`,\n reason: `Parameter${src.sourceParams.length > 1 ? 's' : ''} '${src.sourceParams.join(\"', '\")}' (external input) flow${src.sourceParams.length === 1 ? 's' : ''} into ${sinkLabel} ${hasValidation ? 'with partial validation — verify path normalization + prefix check + realpath resolution' : 'without validation. Path traversal (e.g. ../../etc/passwd) can read or write arbitrary files'}.`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Add multi-layer path validation before file system operations.',\n steps: [\n 'Normalize the path: path.resolve(basePath, userInput).',\n 'Prefix check: resolvedPath.startsWith(basePath + path.sep).',\n 'Resolve symlinks: fs.realpathSync() to prevent symlink escape.',\n 'Re-validate after symlink resolution.',\n ],\n },\n impact:\n 'Path traversal enables reading sensitive files (credentials, configs, source code) or writing to arbitrary locations (code injection via file overwrite).',\n tags: ['security', 'path-traversal', 'agentic'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: src.functionName,\n lineHint: src.lineStart,\n file: entry.file,\n expectedResult: `trace incoming callers to determine if path parameter comes from user input — then trace outgoing to the fs/path call`,\n },\n ],\n },\n 'security.path-traversal-risk',\n src.paramConfidence === 'high' ? 'high' : 'medium',\n {\n sourceParameters: src.sourceParams,\n sink: sinkLabel,\n sanitizerStatus: hasValidation ? 'partial' : 'missing',\n propagationSteps: src.callsWithInputArgs.map(\n call => `${call.callee}:${call.lineStart}`\n ),\n }\n )\n );\n }\n }\n return findings;\n}\n\nexport function detectCommandInjectionRisk(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const src of entry.inputSources || []) {\n const execSinks = src.sinkKinds.filter(k => k === 'exec');\n if (execSinks.length === 0) continue;\n if (src.paramConfidence === 'low') continue;\n\n const execCallees = src.callsWithInputArgs.filter(c =>\n /\\.exec\\b|^exec$|^execSync$|child_process\\.exec/.test(c.callee)\n );\n const spawnCallees = src.callsWithInputArgs.filter(c =>\n /\\.spawn\\b|^spawn$|^spawnSync$|child_process\\.spawn/.test(c.callee)\n );\n\n if (execCallees.length > 0) {\n const severity: Finding['severity'] =\n src.paramConfidence === 'high' ? 'critical' : 'high';\n findings.push(\n toSecurityFinding(\n {\n severity,\n category: 'command-injection-risk',\n file: entry.file,\n lineStart: src.lineStart,\n lineEnd: src.lineEnd,\n title: `Command injection risk: ${src.functionName}(${src.sourceParams.join(', ')}) → exec`,\n reason: `Parameter${src.sourceParams.length > 1 ? 's' : ''} '${src.sourceParams.join(\"', '\")}' (external input) flow${src.sourceParams.length === 1 ? 's' : ''} into exec/execSync. exec() runs commands through a shell — string interpolation enables command injection.`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Replace exec with spawn using array arguments (no shell interpretation).',\n steps: [\n 'Replace child_process.exec(cmd) with child_process.spawn(binary, [args]).',\n 'Never interpolate user input into command strings.',\n 'Use an allowlist for permitted commands if dynamic dispatch is needed.',\n 'If shell features are required, validate input against a strict allowlist.',\n ],\n },\n impact:\n 'Command injection enables arbitrary OS command execution — full server compromise, data exfiltration, or lateral movement.',\n tags: ['security', 'command-injection', 'critical', 'agentic'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: src.functionName,\n lineHint: src.lineStart,\n file: entry.file,\n expectedResult: `trace incoming callers to verify if user input reaches the exec call — check for allowlist or sanitization`,\n },\n ],\n },\n 'security.command-injection-risk',\n src.paramConfidence === 'high' ? 'high' : 'medium',\n {\n sourceParameters: src.sourceParams,\n sink: 'exec',\n sanitizerStatus: src.hasValidation ? 'partial' : 'missing',\n propagationSteps: execCallees.map(\n call => `${call.callee}:${call.lineStart}`\n ),\n }\n )\n );\n }\n\n if (spawnCallees.length > 0 && execCallees.length === 0) {\n findings.push(\n toSecurityFinding(\n {\n severity: 'high',\n category: 'command-injection-risk',\n file: entry.file,\n lineStart: src.lineStart,\n lineEnd: src.lineEnd,\n title: `Potential command injection: ${src.functionName}(${src.sourceParams.join(', ')}) → spawn`,\n reason: `Parameter${src.sourceParams.length > 1 ? 's' : ''} '${src.sourceParams.join(\"', '\")}' (external input) flow${src.sourceParams.length === 1 ? 's' : ''} into spawn. If shell:true is set, this is equivalent to exec. Verify spawn uses array args without shell option.`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Ensure spawn uses array arguments without shell: true.',\n steps: [\n 'Verify spawn is called as spawn(binary, [arg1, arg2]) — NOT spawn(cmd, { shell: true }).',\n 'Remove shell: true if present.',\n 'Validate command arguments against an allowlist.',\n ],\n },\n impact:\n 'spawn with shell:true enables the same command injection as exec. Without shell:true, spawn with array args is safe from injection.',\n tags: ['security', 'command-injection', 'agentic'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: src.functionName,\n lineHint: src.lineStart,\n file: entry.file,\n expectedResult: `trace incoming callers — check if spawn uses shell:true option`,\n },\n ],\n },\n 'security.command-injection-risk',\n 'medium',\n {\n sourceParameters: src.sourceParams,\n sink: 'spawn',\n sanitizerStatus: src.hasValidation ? 'partial' : 'missing',\n propagationSteps: spawnCallees.map(\n call => `${call.callee}:${call.lineStart}`\n ),\n }\n )\n );\n }\n }\n }\n return findings;\n}\nexport function detectDebugLogLeakage(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const log of entry.consoleLogs || []) {\n if (log.method === 'debugger') {\n findings.push(\n toSecurityFinding(\n {\n severity: 'high',\n category: 'debug-log-leakage',\n file: entry.file,\n lineStart: log.lineStart,\n lineEnd: log.lineEnd,\n title: 'Debugger statement in production code',\n reason:\n 'A `debugger` statement pauses execution when DevTools are open. In production it can expose internal state and halt the application.',\n files: [entry.file],\n suggestedFix: {\n strategy: 'Remove the debugger statement before shipping.',\n steps: [\n 'Delete the `debugger;` line.',\n 'Use structured logging (pino, winston) or feature-flagged debug helpers instead.',\n ],\n },\n impact:\n 'Debugger statements in production can halt request processing and expose internal runtime state to anyone with browser DevTools open.',\n tags: ['security', 'debug', 'production-safety'],\n },\n 'security.debug-log-leakage',\n 'high',\n { method: 'debugger', line: log.lineStart }\n )\n );\n } else if (log.method === 'debug' || log.method === 'trace') {\n findings.push(\n toSecurityFinding(\n {\n severity: 'medium',\n category: 'debug-log-leakage',\n file: entry.file,\n lineStart: log.lineStart,\n lineEnd: log.lineEnd,\n title: `console.${log.method}() in production code`,\n reason: `console.${log.method}() is a development-only call. Left in production it leaks internal state, variable values, and execution paths — all useful to attackers.`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Replace with a structured logger that respects log-level configuration.',\n steps: [\n `Remove or gate the console.${log.method}() call behind a LOG_LEVEL check.`,\n 'Use a structured logger (pino, winston) with level filtering instead.',\n 'Ensure debug/trace levels are disabled in production config.',\n ],\n },\n impact:\n 'Debug/trace logs expose internal object state and execution flow, making reconnaissance easier for attackers and violating minimal disclosure.',\n tags: ['security', 'debug', 'information-disclosure'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: `console.${log.method}`,\n lineHint: log.lineStart,\n file: entry.file,\n expectedResult: 'find all debug/trace log calls in this file to assess total leakage surface',\n },\n ],\n },\n 'security.debug-log-leakage',\n 'medium',\n { method: log.method, snippet: log.argSnippet, line: log.lineStart }\n )\n );\n }\n }\n }\n return findings;\n}\n\nexport function detectSensitiveDataLogging(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (isTestFile(entry.file)) continue;\n for (const log of entry.consoleLogs || []) {\n if (log.method === 'debugger' || !log.hasSensitiveArg) continue;\n findings.push(\n toSecurityFinding(\n {\n severity: 'high',\n category: 'sensitive-data-logging',\n file: entry.file,\n lineStart: log.lineStart,\n lineEnd: log.lineEnd,\n title: `Sensitive data logged via console.${log.method}()${log.argSnippet ? `: ${log.argSnippet.slice(0, 40)}` : ''}`,\n reason: `console.${log.method}() argument matches a sensitive-data pattern (password, token, secret, credential, API key, session, SSN). Logging secrets writes them to stdout/stderr, log aggregators, error monitoring services, and persistent log files.`,\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Remove or redact sensitive values before logging.',\n steps: [\n 'Never log raw passwords, tokens, API keys, or session identifiers.',\n 'If logging for debugging, redact: log({ ...user, password: \"[REDACTED]\" }).',\n 'Use a structured logger with field-level redaction hooks (e.g. pino redact option).',\n 'Audit all log aggregation pipelines (Datadog, Splunk, CloudWatch) for secret exposure.',\n ],\n },\n impact:\n 'Sensitive data in logs is written to stdout/stderr, forwarded to log aggregators (Splunk, Datadog, CloudWatch), and often stored long-term — creating a persistent credential leak accessible to anyone with log access.',\n tags: ['security', 'sensitive-data', 'credential-leak', 'compliance'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: log.method,\n lineHint: log.lineStart,\n file: entry.file,\n expectedResult: `trace incoming callers to understand where sensitive data originates before reaching console.${log.method}`,\n },\n ],\n },\n 'security.sensitive-data-logging',\n 'high',\n {\n method: log.method,\n snippet: log.argSnippet,\n line: log.lineStart,\n sanitizerStatus: 'missing',\n }\n )\n );\n }\n }\n return findings;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":33946,"content_sha256":"a0b98a0a304a685989249a78720737178e423ab4922533b611f525f77bab4f54"},{"filename":"src/detectors/semantic.ts","content":"import path from 'node:path';\n\nimport * as ts from 'typescript';\n\nimport type { SemanticContext, SemanticProfile } from '../analysis/semantic.js';\nimport type { Finding } from '../types/index.js';\n\ntype FindingDraft = Omit\u003cFinding, 'id'>;\n\nexport function detectSemanticDeadExports(\n profiles: SemanticProfile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const profile of profiles) {\n for (const [name, info] of profile.referenceCountByExport) {\n if (info.count === 0) {\n findings.push({\n severity: 'high',\n category: 'semantic-dead-export',\n file: profile.file,\n lineStart: info.lineStart,\n lineEnd: info.lineEnd,\n title: `Semantically dead export: ${name}`,\n reason: `Exported symbol \"${name}\" has zero semantic references across the entire program (confirmed via TypeChecker, not just import matching).`,\n files: [profile.file],\n suggestedFix: {\n strategy:\n 'Remove the export or delete the symbol if unused internally.',\n steps: [\n 'Verify the symbol is not used via dynamic imports or runtime reflection.',\n 'Remove the export keyword, or delete the symbol entirely if also unused locally.',\n 'Re-run scan to confirm finding is resolved.',\n ],\n },\n impact:\n 'Dead exports bloat the public API surface and confuse contributors.',\n tags: ['architecture', 'dead-code', 'semantic'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: name,\n lineHint: info.lineStart,\n file: profile.file,\n expectedResult: 'zero references confirms dead export',\n },\n ],\n });\n }\n }\n }\n\n return findings;\n}\n\nexport function detectOverAbstraction(\n ctx: SemanticContext,\n profiles: SemanticProfile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n const interfaceImplCounts = new Map\u003c\n string,\n { files: Set\u003cstring>; line: number; file: string }\n >();\n\n for (const profile of profiles) {\n const sourceFile = ctx.program.getSourceFile(\n path.resolve(ctx.root, profile.file)\n );\n if (!sourceFile) continue;\n\n const visit = (node: ts.Node): void => {\n if (ts.isInterfaceDeclaration(node) && node.name) {\n const name = node.name.text;\n const line =\n sourceFile!.getLineAndCharacterOfPosition(node.getStart(sourceFile!))\n .line + 1;\n\n const impls = ctx.service.getImplementationAtPosition(\n path.resolve(ctx.root, profile.file),\n node.name.getStart(sourceFile!)\n );\n\n const implFiles = new Set\u003cstring>();\n if (impls) {\n for (const impl of impls) {\n const implFile = impl.fileName;\n if (\n implFile !== path.resolve(ctx.root, profile.file) ||\n impl.textSpan.start !== node.getStart(sourceFile!)\n ) {\n implFiles.add(implFile);\n }\n }\n }\n\n if (!interfaceImplCounts.has(name)) {\n interfaceImplCounts.set(name, {\n files: new Set(),\n line,\n file: profile.file,\n });\n }\n const entry = interfaceImplCounts.get(name)!;\n for (const f of implFiles) entry.files.add(f);\n }\n ts.forEachChild(node, visit);\n };\n ts.forEachChild(sourceFile, visit);\n }\n\n for (const [name, info] of interfaceImplCounts) {\n if (info.files.size === 1) {\n const implFile = [...info.files][0];\n const relImpl = path.relative(ctx.root, implFile);\n findings.push({\n severity: 'medium',\n category: 'over-abstraction',\n file: info.file,\n lineStart: info.line,\n lineEnd: info.line,\n title: `Over-abstraction: interface ${name} has exactly 1 implementor`,\n reason: `Interface \"${name}\" is implemented only by one class in \"${relImpl}\". The abstraction layer adds complexity without enabling polymorphism.`,\n files: [info.file, relImpl],\n suggestedFix: {\n strategy:\n 'Inline the interface into the concrete class or keep it only if future implementors are planned.',\n steps: [\n 'Evaluate whether the interface is needed for testing (mocking) or future extensibility.',\n 'If not, merge the interface declaration into the concrete class.',\n 'Update consumers to depend on the concrete class directly.',\n ],\n },\n impact:\n 'Over-abstraction adds indirection without polymorphic benefit, increasing cognitive load.',\n tags: ['architecture', 'abstraction', 'semantic'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: name,\n lineHint: info.line,\n file: info.file,\n expectedResult:\n 'exactly 1 implementation confirms over-abstraction',\n },\n ],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectConcreteDependency(\n profiles: SemanticProfile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const profile of profiles) {\n for (const imp of profile.concreteImports) {\n findings.push({\n severity: 'medium',\n category: 'concrete-dependency',\n file: profile.file,\n lineStart: imp.lineStart,\n lineEnd: imp.lineStart,\n title: `Concrete dependency: ${profile.file} imports class ${imp.name}`,\n reason: `Module imports concrete class \"${imp.name}\" from \"${imp.targetFile}\" instead of an interface or abstract class. This violates the Dependency Inversion Principle (DIP).`,\n files: [profile.file, imp.targetFile],\n suggestedFix: {\n strategy:\n 'Depend on an interface or abstract class instead of the concrete implementation.',\n steps: [\n 'Extract an interface from the concrete class covering the methods used by this module.',\n 'Update imports to reference the interface instead of the concrete class.',\n 'Use dependency injection to provide the concrete implementation at runtime.',\n ],\n },\n impact:\n 'Concrete dependencies make modules harder to test and tightly coupled to implementation details.',\n tags: ['architecture', 'dip', 'coupling', 'semantic'],\n lspHints: [\n {\n tool: 'lspGotoDefinition',\n symbolName: imp.name,\n lineHint: imp.lineStart,\n file: profile.file,\n expectedResult:\n 'resolves to concrete class (not interface/abstract)',\n },\n ],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectCircularTypeDependency(\n ctx: SemanticContext,\n profiles: SemanticProfile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n const typeGraph = new Map\u003cstring, Set\u003cstring>>();\n\n for (const profile of profiles) {\n const sourceFile = ctx.program.getSourceFile(\n path.resolve(ctx.root, profile.file)\n );\n if (!sourceFile) continue;\n\n const fileTypes = new Set\u003cstring>();\n const fileTypeRefs = new Map\u003cstring, Set\u003cstring>>();\n\n const visit = (node: ts.Node): void => {\n if (\n (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) &&\n node.name\n ) {\n const typeName = `${profile.file}::${node.name.text}`;\n fileTypes.add(typeName);\n const refs = new Set\u003cstring>();\n\n const collectRefs = (child: ts.Node): void => {\n if (\n ts.isTypeReferenceNode(child) &&\n ts.isIdentifier(child.typeName)\n ) {\n const refName = child.typeName.text;\n const sym = ctx.checker.getSymbolAtLocation(child.typeName);\n if (sym) {\n const decl = sym.getDeclarations?.()?.[0];\n if (decl) {\n const declFile = decl.getSourceFile().fileName;\n const relFile = path.relative(ctx.root, declFile);\n refs.add(`${relFile}::${refName}`);\n }\n }\n }\n ts.forEachChild(child, collectRefs);\n };\n ts.forEachChild(node, collectRefs);\n fileTypeRefs.set(typeName, refs);\n }\n ts.forEachChild(node, visit);\n };\n ts.forEachChild(sourceFile, visit);\n\n for (const [typeName, refs] of fileTypeRefs) {\n if (!typeGraph.has(typeName)) typeGraph.set(typeName, new Set());\n for (const ref of refs) typeGraph.get(typeName)!.add(ref);\n }\n }\n\n const visited = new Set\u003cstring>();\n const inStack = new Set\u003cstring>();\n const reportedCycles = new Set\u003cstring>();\n\n const dfs = (node: string, stackPath: string[]): void => {\n if (inStack.has(node)) {\n const cycleStart = stackPath.indexOf(node);\n if (cycleStart >= 0) {\n const cycle = stackPath.slice(cycleStart);\n const key = [...cycle].sort().join('→');\n if (!reportedCycles.has(key) && cycle.length >= 2) {\n reportedCycles.add(key);\n const first = cycle[0];\n const [file] = first.split('::');\n findings.push({\n severity: 'high',\n category: 'circular-type-dependency',\n file,\n lineStart: 1,\n lineEnd: 1,\n title: `Circular type dependency: ${cycle.map(c => c.split('::')[1]).join(' → ')}`,\n reason: `Type-level circular dependency detected: ${cycle.map(c => c.split('::')[1]).join(' → ')} → ${cycle[0].split('::')[1]}. Types reference each other creating a cycle.`,\n files: [...new Set(cycle.map(c => c.split('::')[0]))],\n suggestedFix: {\n strategy:\n 'Break the type cycle by extracting shared type definitions.',\n steps: [\n 'Identify the minimal set of type properties causing the cycle.',\n 'Extract shared types to a dedicated types file that both sides can import.',\n 'Replace direct type references with the shared type.',\n ],\n },\n impact:\n 'Circular type dependencies make types harder to understand, refactor, and can cause issues with type inference.',\n tags: ['architecture', 'types', 'cycle', 'semantic'],\n });\n }\n }\n return;\n }\n if (visited.has(node)) return;\n\n inStack.add(node);\n stackPath.push(node);\n\n for (const neighbor of typeGraph.get(node) ?? []) {\n dfs(neighbor, stackPath);\n }\n\n stackPath.pop();\n inStack.delete(node);\n visited.add(node);\n };\n\n for (const node of typeGraph.keys()) {\n dfs(node, []);\n }\n\n return findings;\n}\n\nexport function detectUnusedParameters(\n profiles: SemanticProfile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const profile of profiles) {\n for (const param of profile.unusedParams) {\n findings.push({\n severity: 'medium',\n category: 'unused-parameter',\n file: profile.file,\n lineStart: param.lineStart,\n lineEnd: param.lineEnd,\n title: `Unused parameter: ${param.paramName} in ${param.functionName}`,\n reason: `Parameter \"${param.paramName}\" in function \"${param.functionName}\" is never referenced in the function body (confirmed via semantic analysis).`,\n files: [profile.file],\n suggestedFix: {\n strategy:\n 'Remove the parameter or prefix with underscore to indicate intentional non-use.',\n steps: [\n 'Check if the parameter is required by an interface or callback signature.',\n 'If not required, remove it and update all call sites.',\n 'If required by contract, prefix with _ (e.g. _unused) to signal intent.',\n ],\n },\n impact:\n 'Unused parameters add noise to function signatures and confuse callers about what the function actually needs.',\n tags: ['code-quality', 'parameters', 'semantic'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: param.paramName,\n lineHint: param.lineStart,\n file: profile.file,\n expectedResult: 'zero non-declaration references confirms unused',\n },\n ],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectDeepOverrideChain(\n profiles: SemanticProfile[],\n threshold: number = 3\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const profile of profiles) {\n for (const chain of profile.overrideChains) {\n if (chain.depth > threshold) {\n findings.push({\n severity: chain.depth > 4 ? 'high' : 'medium',\n category: 'deep-override-chain',\n file: profile.file,\n lineStart: chain.lineStart,\n lineEnd: chain.lineStart,\n title: `Deep override chain: ${chain.className}.${chain.methodName} (depth ${chain.depth})`,\n reason: `Method \"${chain.methodName}\" in class \"${chain.className}\" overrides a method ${chain.depth} levels up in the inheritance chain (threshold: ${threshold}).`,\n files: [profile.file],\n suggestedFix: {\n strategy:\n 'Reduce override depth by flattening the class hierarchy or using the template method pattern.',\n steps: [\n 'Identify if intermediate overrides are necessary or if they just pass through.',\n 'Consider extracting the behavior into a strategy or template method.',\n 'Flatten unnecessary intermediate classes.',\n ],\n },\n impact:\n 'Deep override chains make method behavior unpredictable — understanding what runs requires tracing through many classes.',\n tags: ['code-quality', 'inheritance', 'override', 'semantic'],\n });\n }\n }\n }\n\n return findings;\n}\n\nexport function detectInterfaceCompliance(\n profiles: SemanticProfile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const profile of profiles) {\n for (const impl of profile.interfaceImpls) {\n const issues: string[] = [];\n if (impl.missingMembers.length > 0) {\n issues.push(`missing members: ${impl.missingMembers.join(', ')}`);\n }\n if (impl.anycastMembers.length > 0) {\n issues.push(`any-cast members: ${impl.anycastMembers.join(', ')}`);\n }\n\n if (issues.length > 0) {\n findings.push({\n severity: impl.missingMembers.length > 0 ? 'high' : 'medium',\n category: 'interface-compliance',\n file: impl.classFile,\n lineStart: impl.classLine,\n lineEnd: impl.classLine,\n title: `Fragile interface compliance: ${impl.className} implements ${impl.interfaceName}`,\n reason: `Class \"${impl.className}\" implements \"${impl.interfaceName}\" with issues: ${issues.join('; ')}.`,\n files: [impl.classFile],\n suggestedFix: {\n strategy:\n 'Fix the implementation to fully satisfy the interface contract.',\n steps: [\n ...(impl.missingMembers.length > 0\n ? [\n `Implement missing members: ${impl.missingMembers.join(', ')}.`,\n ]\n : []),\n ...(impl.anycastMembers.length > 0\n ? [\n `Replace \\`any\\` types with proper types for: ${impl.anycastMembers.join(', ')}.`,\n ]\n : []),\n 'Enable strict type checking to catch these at compile time.',\n ],\n },\n impact:\n 'Incomplete interface implementations create runtime surprises and defeat the purpose of type contracts.',\n tags: ['code-quality', 'types', 'interface', 'semantic'],\n lspHints: [\n {\n tool: 'lspGotoDefinition',\n symbolName: impl.interfaceName,\n lineHint: impl.classLine,\n file: impl.classFile,\n expectedResult: 'interface definition showing expected contract',\n },\n ],\n });\n }\n }\n }\n\n return findings;\n}\n\nexport function detectUnusedImports(\n profiles: SemanticProfile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const profile of profiles) {\n for (const imp of profile.unusedImports) {\n findings.push({\n severity: 'low',\n category: 'unused-import',\n file: profile.file,\n lineStart: imp.lineStart,\n lineEnd: imp.lineStart,\n title: `Unused import: ${imp.name}`,\n reason: `Imported symbol \"${imp.name}\" is never referenced in this file (confirmed via semantic analysis, not just text matching).`,\n files: [profile.file],\n suggestedFix: {\n strategy: 'Remove the unused import statement.',\n steps: [\n 'Verify the import is not used for side effects (e.g. polyfills, CSS).',\n 'Remove the import statement.',\n 'If part of a multi-import, remove only the unused symbol.',\n ],\n },\n impact:\n 'Unused imports slow down IDE performance, increase bundle size (if not tree-shaken), and add noise.',\n tags: ['dead-code', 'imports', 'semantic'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: imp.name,\n lineHint: imp.lineStart,\n file: profile.file,\n expectedResult: 'zero usage references confirms unused import',\n },\n ],\n });\n }\n }\n\n return findings;\n}\n\nexport function detectOrphanImplementation(\n ctx: SemanticContext,\n profiles: SemanticProfile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const profile of profiles) {\n const sourceFile = ctx.program.getSourceFile(\n path.resolve(ctx.root, profile.file)\n );\n if (!sourceFile) continue;\n\n const visit = (node: ts.Node): void => {\n if (ts.isClassDeclaration(node) && node.name) {\n const hasHeritage =\n node.heritageClauses && node.heritageClauses.length > 0;\n if (hasHeritage) {\n ts.forEachChild(node, visit);\n return;\n }\n\n const isExported = node.modifiers?.some(\n m => m.kind === ts.SyntaxKind.ExportKeyword\n );\n if (!isExported) {\n ts.forEachChild(node, visit);\n return;\n }\n\n const refs = ctx.service.findReferences(\n path.resolve(ctx.root, profile.file),\n node.name.getStart(sourceFile!)\n );\n\n let externalUsage = 0;\n if (refs) {\n for (const group of refs) {\n for (const ref of group.references) {\n if (!ref.isDefinition) {\n const refFile = ref.fileName;\n if (refFile !== path.resolve(ctx.root, profile.file)) {\n externalUsage++;\n }\n }\n }\n }\n }\n\n if (externalUsage === 0) {\n const line =\n sourceFile!.getLineAndCharacterOfPosition(\n node.getStart(sourceFile!)\n ).line + 1;\n findings.push({\n severity: 'medium',\n category: 'orphan-implementation',\n file: profile.file,\n lineStart: line,\n lineEnd: line,\n title: `Orphan implementation: class ${node.name.text}`,\n reason: `Exported class \"${node.name.text}\" has no external references and does not implement any interface or extend any base class. It may be unreachable dead code.`,\n files: [profile.file],\n suggestedFix: {\n strategy:\n 'Verify the class is needed and wire it in, or remove it.',\n steps: [\n 'Check if the class is used via dynamic imports, reflection, or DI containers.',\n 'If unused, remove the class and its export.',\n 'If needed, wire it into the dependency graph via an interface or direct import.',\n ],\n },\n impact:\n 'Orphan implementations waste maintenance effort and bloat the codebase.',\n tags: ['dead-code', 'class', 'orphan', 'semantic'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: node.name.text,\n lineHint: line,\n file: profile.file,\n expectedResult: 'zero external references confirms orphan',\n },\n ],\n });\n }\n }\n ts.forEachChild(node, visit);\n };\n ts.forEachChild(sourceFile, visit);\n }\n\n return findings;\n}\n\nexport function detectShotgunSurgery(\n profiles: SemanticProfile[],\n threshold: number = 8\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const profile of profiles) {\n for (const [name, info] of profile.referenceCountByExport) {\n if (info.uniqueFiles >= threshold) {\n findings.push({\n severity: info.uniqueFiles > 12 ? 'high' : 'medium',\n category: 'shotgun-surgery',\n file: profile.file,\n lineStart: info.lineStart,\n lineEnd: info.lineEnd,\n title: `Shotgun surgery risk: ${name} used in ${info.uniqueFiles} files`,\n reason: `Exported symbol \"${name}\" is referenced from ${info.uniqueFiles} unique files (threshold: ${threshold}). Any change to this symbol forces coordinated edits across all consumers.`,\n files: [profile.file],\n suggestedFix: {\n strategy:\n 'Reduce coupling by introducing a facade, adapter, or event-based decoupling.',\n steps: [\n 'Identify the consumers and group them by usage pattern.',\n 'Extract a stable interface that consumers depend on instead of the implementation.',\n 'Consider the Mediator or Facade pattern to reduce direct dependencies.',\n 'If the symbol is a utility, ensure it has a single, well-defined responsibility.',\n ],\n },\n impact:\n 'High fan-out symbols are the #1 source of cascading changes during refactoring.',\n tags: ['architecture', 'coupling', 'change-risk', 'semantic'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: name,\n lineHint: info.lineStart,\n file: profile.file,\n expectedResult: `${info.uniqueFiles}+ unique referencing files confirms shotgun surgery risk`,\n },\n ],\n });\n }\n }\n }\n\n return findings;\n}\n\nexport function detectMoveToCaller(\n profiles: SemanticProfile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const profile of profiles) {\n for (const [name, info] of profile.referenceCountByExport) {\n if (info.uniqueFiles === 1 && info.count > 0) {\n findings.push({\n severity: 'low',\n category: 'move-to-caller',\n file: profile.file,\n lineStart: info.lineStart,\n lineEnd: info.lineEnd,\n title: `Single-consumer export: ${name} (used by 1 file)`,\n reason: `Exported symbol \"${name}\" is consumed by exactly 1 file. Consider moving it to the consumer or inlining it to reduce module surface.`,\n files: [profile.file],\n suggestedFix: {\n strategy: 'Move the symbol to its only consumer or inline it.',\n steps: [\n 'Verify no dynamic or reflection-based usage exists.',\n 'Move the function/class/constant to the consumer file.',\n 'Remove the export and the import from the consumer.',\n 'If the symbol is large, keep it but remove the export keyword.',\n ],\n },\n impact:\n 'Single-consumer exports add unnecessary module surface and indirection.',\n tags: ['dead-code', 'module-surface', 'refactoring', 'semantic'],\n lspHints: [\n {\n tool: 'lspFindReferences',\n symbolName: name,\n lineHint: info.lineStart,\n file: profile.file,\n expectedResult:\n 'exactly 1 referencing file confirms single-consumer',\n },\n ],\n });\n }\n }\n }\n\n return findings;\n}\n\nexport function detectNarrowableType(\n profiles: SemanticProfile[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n\n for (const profile of profiles) {\n for (const param of profile.narrowableParams) {\n findings.push({\n severity: 'low',\n category: 'narrowable-type',\n file: profile.file,\n lineStart: param.lineStart,\n lineEnd: param.lineEnd,\n title: `Narrowable param: ${param.functionName}(${param.paramName}: ${param.declaredType}) → ${param.narrowedType}`,\n reason: `Parameter \"${param.paramName}\" in \"${param.functionName}\" is declared as \\`${param.declaredType}\\` but all call sites pass \\`${param.narrowedType}\\`. The type can be safely narrowed.`,\n files: [profile.file],\n suggestedFix: {\n strategy: 'Narrow the parameter type to match actual usage.',\n steps: [\n `Change the parameter type from \\`${param.declaredType}\\` to \\`${param.narrowedType}\\`.`,\n 'Verify no future callers need the broader type.',\n 'If the function is part of a public API, consider keeping the broad type with a narrower overload.',\n ],\n },\n impact:\n 'Overly broad parameter types weaken type checking — narrowing catches bugs at compile time.',\n tags: ['code-quality', 'types', 'refactoring', 'semantic'],\n lspHints: [\n {\n tool: 'lspCallHierarchy',\n symbolName: param.functionName,\n lineHint: param.lineStart,\n file: profile.file,\n expectedResult: `all incoming calls pass ${param.narrowedType}`,\n },\n ],\n });\n }\n }\n\n return findings;\n}\n\nexport function runSemanticDetectors(\n ctx: SemanticContext,\n profiles: SemanticProfile[],\n options: { overrideChainThreshold?: number; shotgunThreshold?: number } = {}\n): FindingDraft[] {\n const all: FindingDraft[] = [];\n\n all.push(...detectOverAbstraction(ctx, profiles));\n all.push(...detectConcreteDependency(profiles));\n all.push(...detectCircularTypeDependency(ctx, profiles));\n all.push(...detectUnusedParameters(profiles));\n all.push(\n ...detectDeepOverrideChain(profiles, options.overrideChainThreshold ?? 3)\n );\n all.push(...detectInterfaceCompliance(profiles));\n all.push(...detectUnusedImports(profiles));\n all.push(...detectOrphanImplementation(ctx, profiles));\n all.push(...detectShotgunSurgery(profiles, options.shotgunThreshold ?? 8));\n all.push(...detectMoveToCaller(profiles));\n all.push(...detectNarrowableType(profiles));\n all.push(...detectSemanticDeadExports(profiles));\n\n return all;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":27045,"content_sha256":"5fabdf4a2a035001232128b9cab346f7090e11cc442f131eead2588bb55c0fb2"},{"filename":"src/detectors/shared.ts","content":"import type { DependencyState, Finding } from '../types/index.js';\n\nexport type FindingDraft = Omit\u003cFinding, 'id'>;\n\nconst MAX_FINDINGS_PER_DETECTOR = 200;\n\nexport function canAddFinding(findings: FindingDraft[]): boolean {\n return findings.length \u003c MAX_FINDINGS_PER_DETECTOR;\n}\n\nexport function findImportLine(\n state: DependencyState,\n fromFile: string,\n toFile: string\n): { lineStart: number; lineEnd: number } {\n const imports = state.importedSymbolsByFile.get(fromFile);\n if (imports) {\n for (const ref of imports) {\n if (ref.resolvedModule === toFile && ref.lineStart) {\n return {\n lineStart: ref.lineStart,\n lineEnd: ref.lineEnd ?? ref.lineStart,\n };\n }\n }\n }\n const reexports = state.reExportsByFile.get(fromFile);\n if (reexports) {\n for (const ref of reexports) {\n if (ref.resolvedModule === toFile && ref.lineStart) {\n return {\n lineStart: ref.lineStart,\n lineEnd: ref.lineEnd ?? ref.lineStart,\n };\n }\n }\n }\n return { lineStart: 1, lineEnd: 1 };\n}\n\nexport function isLikelyEntrypoint(filePath: string): boolean {\n const normalized = filePath.toLowerCase();\n if (\n /(^|\\/)(index|main|app|server|cli|public)\\.[mc]?[jt]sx?$/.test(normalized)\n )\n return true;\n if (/\\.(config)\\.[mc]?[jt]sx?$/.test(normalized)) return true;\n return false;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":1365,"content_sha256":"76372cf3afa01818f4f221f2ff2c030fb74ff2b5cce656c5a1410a87288d4688"},{"filename":"src/detectors/test-quality.test.ts","content":"import * as ts from 'typescript';\nimport { describe, expect, it } from 'vitest';\n\nimport {\n detectExcessiveMocking,\n detectFakeTimersWithoutRestore,\n detectFocusedTests,\n detectLowAssertionDensity,\n detectMissingMockRestoration,\n detectMissingTestCleanup,\n detectSharedMutableState,\n detectTestNoAssertion,\n} from './test-quality.js';\nimport { analyzeSourceFile } from '../ast/ts-analyzer.js';\nimport { DEFAULT_OPTS } from '../types/index.js';\n\nimport type {\n DependencyProfile,\n FileEntry,\n FlowMaps,\n PackageFileSummary,\n} from '../types/index.js';\n\nfunction parse(\n code: string,\n fileName = '/repo/src/feature.spec.ts'\n): ts.SourceFile {\n return ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true);\n}\n\nfunction emptySummary(): PackageFileSummary {\n return {\n fileCount: 0,\n nodeCount: 0,\n functionCount: 0,\n flowCount: 0,\n kindCounts: {},\n functions: [],\n flows: [],\n };\n}\n\nfunction emptyMaps(): FlowMaps {\n return { flowMap: new Map(), controlMap: new Map() };\n}\n\nconst emptyProfile: DependencyProfile = {\n internalDependencies: [],\n externalDependencies: [],\n unresolvedDependencies: [],\n declaredExports: [],\n importedSymbols: [],\n reExports: [],\n};\n\nfunction analyze(code: string): FileEntry {\n const src = parse(code);\n return analyzeSourceFile(\n src,\n 'pkg',\n emptySummary(),\n { ...DEFAULT_OPTS, root: '/repo' },\n emptyMaps(),\n [],\n emptyProfile\n );\n}\n\ndescribe('focused-test detector', () => {\n it('flags it.only in test files', () => {\n const file = analyze(`describe('suite', () => {\n it.only('only test', () => {\n expect(1).toBe(1);\n });\n });`);\n const findings = detectFocusedTests([file]);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('focused-test');\n expect(findings[0].severity).toBe('medium');\n expect(findings[0].lineStart).toBeGreaterThan(0);\n });\n\n it('ignores plain tests without focus markers', () => {\n const file = analyze(`it('normal test', () => {\n expect(1).toBe(1);\n });`);\n const findings = detectFocusedTests([file]);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('fake timer detector', () => {\n it('flags fake timer activation without restore', () => {\n const file = analyze(`test('timer behavior', () => {\n jest.useFakeTimers();\n setTimeout(() => {}, 1000);\n setTimeout(() => {}, 1000);\n });`);\n const findings = detectFakeTimersWithoutRestore([file]);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('fake-timer-no-restore');\n });\n\n it('skips files that restore fake timers', () => {\n const file = analyze(`test('timer behavior', () => {\n vi.useFakeTimers();\n vi.useRealTimers();\n });`);\n const findings = detectFakeTimersWithoutRestore([file]);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('mock restoration detector', () => {\n it('flags spy that is never restored', () => {\n const file = analyze(`test('mocked spy', () => {\n const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(0);\n expect(nowSpy).toBeDefined();\n });`);\n const findings = detectMissingMockRestoration([file]);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('missing-mock-restoration');\n });\n\n it('skips files with explicit spy restore', () => {\n const file = analyze(`test('mocked spy', () => {\n const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(0);\n nowSpy.mockRestore();\n });`);\n const findings = detectMissingMockRestoration([file]);\n expect(findings).toHaveLength(0);\n });\n\n it('does not treat clear/reset all mocks as a restore', () => {\n const file = analyze(`test('mocked spy', () => {\n const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(0);\n jest.clearAllMocks();\n });`);\n const findings = detectMissingMockRestoration([file]);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('missing-mock-restoration');\n });\n\n it('still reports spies not restored when some spies are restored', () => {\n const file = analyze(`test('multiple spies', () => {\n const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(0);\n const randomSpy = jest.spyOn(Math, 'random');\n nowSpy.mockRestore();\n expect(randomSpy).toBeDefined();\n });`);\n const findings = detectMissingMockRestoration([file]);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('missing-mock-restoration');\n expect(findings[0].lineStart).toBe(3);\n });\n\n it('skips non-restorable mocks', () => {\n const file = analyze(`test('module mock', () => {\n jest.mock('path');\n });`);\n const findings = detectMissingMockRestoration([file] as [FileEntry]);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('low assertion density detector', () => {\n it('flags file with tests having \u003c1 assertion per test on average', () => {\n const file = analyze(`describe('suite', () => {\n it('test1', () => { doSomething(); });\n it('test2', () => { doSomething(); });\n it('test3', () => { expect(1).toBe(1); });\n});`);\n const findings = detectLowAssertionDensity([file]);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('low-assertion-density');\n });\n\n it('passes when assertion density >= 1', () => {\n const file = analyze(`describe('suite', () => {\n it('test1', () => { expect(1).toBe(1); });\n it('test2', () => { expect(2).toBe(2); });\n});`);\n const findings = detectLowAssertionDensity([file]);\n expect(findings).toHaveLength(0);\n });\n\n it('skips non-test files', () => {\n const src = parse(\n `describe('suite', () => {\n it('test1', () => { doSomething(); });\n});`,\n '/repo/src/feature.ts'\n );\n const entry = analyzeSourceFile(\n src,\n 'pkg',\n emptySummary(),\n { ...DEFAULT_OPTS, root: '/repo' },\n emptyMaps(),\n [],\n emptyProfile\n );\n const findings = detectLowAssertionDensity([entry]);\n expect(findings).toHaveLength(0);\n });\n\n it('skips files with no test blocks', () => {\n const file = analyze(`export function helper() { return 42; }`);\n const findings = detectLowAssertionDensity([file]);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('test-no-assertion detector', () => {\n it('flags individual test block with 0 assertions', () => {\n const file = analyze(`it('no assertion', () => { doSomething(); });`);\n const findings = detectTestNoAssertion([file]);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('test-no-assertion');\n });\n\n it('does not flag test blocks with assertions', () => {\n const file = analyze(`it('has assertion', () => { expect(1).toBe(1); });`);\n const findings = detectTestNoAssertion([file]);\n expect(findings).toHaveLength(0);\n });\n\n it('flags multiple empty test blocks separately', () => {\n const file = analyze(`describe('suite', () => {\n it('test1', () => { doSomething(); });\n it('test2', () => { doSomethingElse(); });\n});`);\n const findings = detectTestNoAssertion([file]);\n expect(findings).toHaveLength(2);\n });\n\n it('skips non-test files', () => {\n const src = parse(\n `it('test', () => { doSomething(); });`,\n '/repo/src/feature.ts'\n );\n const entry = analyzeSourceFile(\n src,\n 'pkg',\n emptySummary(),\n { ...DEFAULT_OPTS, root: '/repo' },\n emptyMaps(),\n [],\n emptyProfile\n );\n const findings = detectTestNoAssertion([entry]);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('excessive mocking detector', () => {\n it('flags file exceeding mock threshold', () => {\n const file = analyze(`test('mocked', () => {\n jest.mock('a');\n jest.mock('b');\n jest.mock('c');\n});`);\n const findings = detectExcessiveMocking([file], 2);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('excessive-mocking');\n });\n\n it('passes when below threshold', () => {\n const file = analyze(`test('mocked', () => {\n jest.mock('a');\n});`);\n const findings = detectExcessiveMocking([file], 2);\n expect(findings).toHaveLength(0);\n });\n\n it('skips non-test files', () => {\n const src = parse(\n `jest.mock('a'); jest.mock('b'); jest.mock('c');`,\n '/repo/src/feature.ts'\n );\n const entry = analyzeSourceFile(\n src,\n 'pkg',\n emptySummary(),\n { ...DEFAULT_OPTS, root: '/repo' },\n emptyMaps(),\n [],\n emptyProfile\n );\n const findings = detectExcessiveMocking([entry], 2);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('shared mutable state detector', () => {\n it('flags let at describe scope', () => {\n const file = analyze(`describe('suite', () => {\n let counter = 0;\n it('test', () => { counter++; expect(counter).toBe(1); });\n});`);\n const findings = detectSharedMutableState([file]);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('shared-mutable-state');\n });\n\n it('does not flag const declarations', () => {\n const file = analyze(`describe('suite', () => {\n const value = 42;\n it('test', () => { expect(value).toBe(42); });\n});`);\n const findings = detectSharedMutableState([file]);\n expect(findings).toHaveLength(0);\n });\n\n it('skips non-test files', () => {\n const src = parse(`let x = 0;`, '/repo/src/feature.ts');\n const entry = analyzeSourceFile(\n src,\n 'pkg',\n emptySummary(),\n { ...DEFAULT_OPTS, root: '/repo' },\n emptyMaps(),\n [],\n emptyProfile\n );\n const findings = detectSharedMutableState([entry]);\n expect(findings).toHaveLength(0);\n });\n});\n\ndescribe('missing test cleanup detector', () => {\n it('flags beforeAll without afterAll', () => {\n const file = analyze(`describe('suite', () => {\n beforeAll(() => { setup(); });\n it('test', () => { expect(1).toBe(1); });\n});`);\n const findings = detectMissingTestCleanup([file]);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('missing-test-cleanup');\n expect(findings[0].title).toContain('beforeAll');\n });\n\n it('flags beforeEach without afterEach', () => {\n const file = analyze(`describe('suite', () => {\n beforeEach(() => { setup(); });\n it('test', () => { expect(1).toBe(1); });\n});`);\n const findings = detectMissingTestCleanup([file]);\n expect(findings).toHaveLength(1);\n expect(findings[0].category).toBe('missing-test-cleanup');\n expect(findings[0].title).toContain('beforeEach');\n });\n\n it('passes when both beforeAll and afterAll present', () => {\n const file = analyze(`describe('suite', () => {\n beforeAll(() => { setup(); });\n afterAll(() => { teardown(); });\n it('test', () => { expect(1).toBe(1); });\n});`);\n const findings = detectMissingTestCleanup([file]);\n expect(findings).toHaveLength(0);\n });\n\n it('passes when both beforeEach and afterEach present', () => {\n const file = analyze(`describe('suite', () => {\n beforeEach(() => { setup(); });\n afterEach(() => { teardown(); });\n it('test', () => { expect(1).toBe(1); });\n});`);\n const findings = detectMissingTestCleanup([file]);\n expect(findings).toHaveLength(0);\n });\n\n it('skips non-test files', () => {\n const src = parse(`beforeAll(() => { setup(); });`, '/repo/src/feature.ts');\n const entry = analyzeSourceFile(\n src,\n 'pkg',\n emptySummary(),\n { ...DEFAULT_OPTS, root: '/repo' },\n emptyMaps(),\n [],\n emptyProfile\n );\n const findings = detectMissingTestCleanup([entry]);\n expect(findings).toHaveLength(0);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":11602,"content_sha256":"7b4c5961448f2932817f0bc21004bba9430d92d95c486c61bec0b01fc344d71e"},{"filename":"src/detectors/test-quality.ts","content":"import { isTestFile } from '../common/utils.js';\n\nimport type { FileEntry, Finding } from '../types/index.js';\n\ntype FindingDraft = Omit\u003cFinding, 'id'>;\n\nexport function detectLowAssertionDensity(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (!isTestFile(entry.file) || !entry.testProfile) continue;\n const { testBlocks } = entry.testProfile;\n if (testBlocks.length === 0) continue;\n const totalAssertions = testBlocks.reduce(\n (sum, t) => sum + t.assertionCount,\n 0\n );\n const ratio = totalAssertions / testBlocks.length;\n if (ratio \u003c 1) {\n findings.push({\n severity: 'medium',\n category: 'low-assertion-density',\n file: entry.file,\n lineStart: testBlocks[0].lineStart,\n lineEnd: testBlocks[testBlocks.length - 1].lineEnd,\n title: `Low assertion density: ${totalAssertions} assertions across ${testBlocks.length} tests`,\n reason: `Average ${ratio.toFixed(1)} assertions per test. Tests with few assertions may pass without verifying actual behavior.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Add meaningful assertions to each test case.',\n steps: [\n 'Review each test block and add expect() calls that verify outcomes.',\n 'Test both success and failure paths.',\n 'Assert return values, state changes, and side effects.',\n ],\n },\n impact:\n 'Low assertion density means tests pass without verifying behavior — bugs slip through with false confidence.',\n tags: ['test-quality', 'assertions'],\n });\n }\n }\n return findings;\n}\n\nexport function detectTestNoAssertion(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (!isTestFile(entry.file) || !entry.testProfile) continue;\n for (const block of entry.testProfile.testBlocks) {\n if (block.assertionCount === 0) {\n findings.push({\n severity: 'high',\n category: 'test-no-assertion',\n file: entry.file,\n lineStart: block.lineStart,\n lineEnd: block.lineEnd,\n title: `Test \"${block.name}\" has no assertions`,\n reason:\n 'A test without assertions always passes. It provides no verification of behavior.',\n files: [entry.file],\n suggestedFix: {\n strategy: 'Add at least one expect() or assert() call.',\n steps: [\n 'Identify what behavior this test should verify.',\n 'Add expect(result).toBe(expected) or similar assertion.',\n 'If the test only checks that code does not throw, use expect(() => fn()).not.toThrow().',\n ],\n },\n impact:\n 'Zero-assertion tests always pass — they provide no safety net and create a false sense of coverage.',\n tags: ['test-quality', 'assertions', 'false-pass'],\n });\n }\n }\n }\n return findings;\n}\n\nexport function detectExcessiveMocking(\n fileSummaries: FileEntry[],\n mockThreshold: number = 10\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (!isTestFile(entry.file) || !entry.testProfile) continue;\n const { mockCalls } = entry.testProfile;\n if (mockCalls.length > mockThreshold) {\n findings.push({\n severity: 'medium',\n category: 'excessive-mocking',\n file: entry.file,\n lineStart: mockCalls[0].lineStart,\n lineEnd: mockCalls[mockCalls.length - 1].lineEnd,\n title: `${mockCalls.length} mock/spy calls in test file (threshold: ${mockThreshold})`,\n reason:\n 'Excessive mocking couples tests to implementation details, making them brittle and hard to maintain.',\n files: [entry.file],\n suggestedFix: {\n strategy: 'Reduce mocks by testing through public interfaces.',\n steps: [\n 'Identify mocks that can be replaced with real implementations.',\n 'Use dependency injection to simplify test setup.',\n 'Consider integration tests for complex interaction chains.',\n ],\n },\n impact:\n 'Heavy mocking couples tests to implementation details — any refactor breaks them even if behavior is unchanged.',\n tags: ['test-quality', 'mocking', 'brittleness'],\n });\n }\n }\n return findings;\n}\n\nexport function detectSharedMutableState(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (!isTestFile(entry.file) || !entry.testProfile) continue;\n for (const decl of entry.testProfile.mutableStateDecls) {\n findings.push({\n severity: 'medium',\n category: 'shared-mutable-state',\n file: entry.file,\n lineStart: decl.lineStart,\n lineEnd: decl.lineEnd,\n title: 'Mutable variable at describe scope',\n reason:\n 'let/var at describe scope creates shared mutable state between tests. Tests may pass or fail depending on execution order.',\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Move variable declaration inside each test or use beforeEach.',\n steps: [\n 'Move the variable into each it()/test() block that uses it.',\n 'Or initialize it in beforeEach() so each test gets a fresh copy.',\n 'Use const where possible.',\n ],\n },\n impact:\n 'Shared mutable state causes order-dependent test results — tests pass in isolation but fail or flake in suite runs.',\n tags: ['test-quality', 'isolation', 'flaky'],\n });\n }\n }\n return findings;\n}\n\nexport function detectMissingTestCleanup(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (!isTestFile(entry.file) || !entry.testProfile) continue;\n const { setupCalls } = entry.testProfile;\n const hasBeforeAll = setupCalls.some(c => c.kind === 'beforeAll');\n const hasAfterAll = setupCalls.some(c => c.kind === 'afterAll');\n const hasBeforeEach = setupCalls.some(c => c.kind === 'beforeEach');\n const hasAfterEach = setupCalls.some(c => c.kind === 'afterEach');\n\n if (hasBeforeAll && !hasAfterAll) {\n const setup = setupCalls.find(c => c.kind === 'beforeAll')!;\n findings.push({\n severity: 'medium',\n category: 'missing-test-cleanup',\n file: entry.file,\n lineStart: setup.lineStart,\n lineEnd: setup.lineStart,\n title: 'beforeAll without afterAll',\n reason:\n 'Setup in beforeAll without teardown in afterAll can leak state (open connections, modified globals, temp files) across test suites.',\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Add afterAll() to clean up resources allocated in beforeAll().',\n steps: [\n 'Identify what beforeAll() sets up (connections, mocks, temp state).',\n 'Add afterAll() to tear it down.',\n ],\n },\n impact:\n 'Missing teardown leaks resources (connections, file handles, globals) that poison subsequent test suites.',\n tags: ['test-quality', 'cleanup', 'leak'],\n });\n }\n\n if (hasBeforeEach && !hasAfterEach) {\n const setup = setupCalls.find(c => c.kind === 'beforeEach')!;\n findings.push({\n severity: 'medium',\n category: 'missing-test-cleanup',\n file: entry.file,\n lineStart: setup.lineStart,\n lineEnd: setup.lineStart,\n title: 'beforeEach without afterEach',\n reason:\n 'Setup in beforeEach without teardown in afterEach can accumulate side effects across tests.',\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Add afterEach() to clean up resources allocated in beforeEach().',\n steps: [\n 'Identify what beforeEach() sets up.',\n 'Add afterEach() to tear it down or restore state.',\n ],\n },\n impact:\n 'Per-test setup without teardown accumulates side effects, causing cascading failures in later tests.',\n tags: ['test-quality', 'cleanup'],\n });\n }\n }\n return findings;\n}\n\nexport function detectFocusedTests(fileSummaries: FileEntry[]): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (!isTestFile(entry.file) || !entry.testProfile) continue;\n for (const focused of entry.testProfile.focusedCalls) {\n findings.push({\n severity: 'medium',\n category: 'focused-test',\n file: entry.file,\n lineStart: focused.lineStart,\n lineEnd: focused.lineEnd,\n title: `Focused test marker: ${focused.kind}`,\n reason: `${focused.kind} limits scan or production of focused tests; it can hide unrelated failures and reduce suite coverage when committed.`,\n files: [entry.file],\n suggestedFix: {\n strategy: 'Avoid focused/skip patterns in committed tests.',\n steps: [\n 'Remove `.only`/`.skip`/`.todo` markers before merging.',\n 'Use local test filtering only for interactive local debugging.',\n 'If temporarily needed, add a TODO and a tracked follow-up task.',\n ],\n },\n impact:\n 'Focused tests can create a false green signal by skipping broader test coverage.',\n tags: ['test-quality', 'selection', 'flaky', 'coverage'],\n ruleId: 'test-quality.focused-test',\n confidence: 'high',\n evidence: {\n marker: focused.kind,\n lineStart: focused.lineStart,\n lineEnd: focused.lineEnd,\n category: 'focused-test',\n },\n });\n }\n }\n return findings;\n}\n\nexport function detectFakeTimersWithoutRestore(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (!isTestFile(entry.file) || !entry.testProfile) continue;\n const fakeTimerCalls = entry.testProfile.timerControls.filter(\n call =>\n call.kind === 'jest.useFakeTimers' || call.kind === 'vi.useFakeTimers'\n );\n if (fakeTimerCalls.length === 0) continue;\n\n const hasRestore = entry.testProfile.timerControls.some(\n call =>\n call.kind === 'jest.useRealTimers' || call.kind === 'vi.useRealTimers'\n );\n if (hasRestore) continue;\n\n const first = fakeTimerCalls[0];\n findings.push({\n severity: 'medium',\n category: 'fake-timer-no-restore',\n file: entry.file,\n lineStart: first.lineStart,\n lineEnd: first.lineEnd,\n title: 'Fake timers activated without restore',\n reason:\n 'Tests that switch to fake timers without restoring real timers can leak timing behavior into subsequent tests.',\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Pair fake timer activation with a restore in the same test scope.',\n steps: [\n 'Call `jest.useRealTimers()` or `vi.useRealTimers()` in afterEach() or afterAll().',\n 'Prefer per-test setup/teardown with explicit timer cleanup.',\n ],\n },\n impact:\n 'Leaked fake-timer configuration can cause subtle, order-dependent failures across unrelated suites.',\n tags: ['test-quality', 'timers', 'isolation'],\n ruleId: 'test-quality.fake-timer-no-restore',\n confidence: 'medium',\n evidence: {\n fakeTimerActivationLines: fakeTimerCalls.map(\n call => `${call.kind}:${call.lineStart}`\n ),\n },\n });\n }\n return findings;\n}\n\nexport function detectMissingMockRestoration(\n fileSummaries: FileEntry[]\n): FindingDraft[] {\n const findings: FindingDraft[] = [];\n for (const entry of fileSummaries) {\n if (!isTestFile(entry.file) || !entry.testProfile) continue;\n if (entry.testProfile.spyOrStubCalls.length === 0) continue;\n\n const hasRestoreAll = entry.testProfile.mockRestores.some(\n call => call.kind === 'restoreAll'\n );\n if (hasRestoreAll) continue;\n\n const explicitRestores = new Set(\n entry.testProfile.mockRestores\n .filter(call => call.kind === 'restore' && !!call.target)\n .map(call => call.target as string)\n );\n const firstUnrestored = entry.testProfile.spyOrStubCalls.find(\n call => !call.target || !explicitRestores.has(call.target)\n );\n if (!firstUnrestored) continue;\n\n const first = firstUnrestored;\n findings.push({\n severity: 'medium',\n category: 'missing-mock-restoration',\n file: entry.file,\n lineStart: first.lineStart,\n lineEnd: first.lineEnd,\n title: 'Spy/stub not restored',\n reason:\n 'Spies/stubs modify implementation behavior and must be restored to avoid cross-test leakage.',\n files: [entry.file],\n suggestedFix: {\n strategy:\n 'Restore every spy/stub after each test or in a file-level teardown.',\n steps: [\n 'Call `mockRestore()` on each spy/stub returned handle.',\n 'Or use `jest.restoreAllMocks()`/`vi.restoreAllMocks()` in afterEach/afterAll.',\n ],\n },\n impact:\n 'Unrestored spies/stubs make tests order-dependent and can mask regressions.',\n tags: ['test-quality', 'cleanup', 'mocks', 'isolation'],\n ruleId: 'test-quality.missing-mock-restoration',\n confidence: 'high',\n evidence: {\n spyOrStubCalls: entry.testProfile.spyOrStubCalls.map(\n call => `${call.lineStart}`\n ),\n },\n });\n }\n return findings;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":13721,"content_sha256":"9cc81e650704d27d7d6fe897ccd156ab0514ac871cea0166d53973338d911d2b"},{"filename":"src/index.ts","content":"import {\n buildConsumedFromModule,\n computeHotFiles,\n detectAwaitInLoop,\n detectBarrelExplosion,\n detectBooleanParameterCluster,\n detectBoundaryViolations,\n detectCatchRethrow,\n detectChangeRisk,\n detectCognitiveComplexity,\n detectCommonJsInEsm,\n detectCriticalPaths,\n detectDeadExports,\n detectDeadFiles,\n detectDeadReExports,\n detectDeepNesting,\n detectDependencyCycles,\n detectDistanceFromMainSequence,\n detectDuplicateFlowStructures,\n detectDuplicateFunctionBodies,\n detectEmptyCatchBlocks,\n detectExcessiveParameters,\n detectExportStarLeak,\n detectExportSurfaceDensity,\n detectFeatureEnvy,\n detectFunctionOptimization,\n detectGodFunctions,\n detectGodModuleCoupling,\n detectGodModules,\n detectHighCoupling,\n detectHighHalsteadEffort,\n detectImportSideEffectRisk,\n detectLayerViolations,\n detectListenerLeakRisk,\n detectLowCohesion,\n detectLowMaintainability,\n detectMagicStrings,\n detectMegaFolders,\n detectMessageChains,\n detectMissingErrorBoundary,\n detectMultipleReturnPaths,\n detectNamespaceImport,\n detectOrphanModules,\n detectPromiseAllUnhandled,\n detectPromiseMisuse,\n detectSdpViolations,\n detectSimilarFunctionBodies,\n detectSwitchNoDefault,\n detectSyncIo,\n detectTestOnlyModules,\n detectTypeAssertionEscape,\n detectUnboundedCollection,\n detectUnclearedTimers,\n detectUnreachableModules,\n detectUnsafeAny,\n detectUntestedCriticalCode,\n detectUnusedNpmDeps,\n} from './detectors/index.js';\nimport {\n detectCommandInjectionRisk,\n detectDebugLogLeakage,\n detectEvalUsage,\n detectHardcodedSecrets,\n detectInputPassthroughRisk,\n detectPathTraversalRisk,\n detectPrototypePollutionRisk,\n detectSensitiveDataLogging,\n detectSqlInjectionRisk,\n detectUnsafeHtml,\n detectUnsafeRegex,\n detectUnvalidatedInputSink,\n} from './detectors/security.js';\nimport {\n detectExcessiveMocking,\n detectFakeTimersWithoutRestore,\n detectFocusedTests,\n detectLowAssertionDensity,\n detectMissingMockRestoration,\n detectMissingTestCleanup,\n detectSharedMutableState,\n detectTestNoAssertion,\n} from './detectors/test-quality.js';\nimport { diversifyFindings } from './reporting/summary-md.js';\nimport { PILLAR_CATEGORIES, SEVERITY_ORDER } from './types/index.js';\n\nimport type {\n AnalysisOptions,\n DependencyState,\n DependencySummary,\n DuplicateGroup,\n FileCriticality,\n FileEntry,\n Finding,\n FlowMapEntry,\n RedundantFlowGroup,\n} from './types/index.js';\n\nexport { bus } from './pipeline/progress.js';\nexport type { ProgressPhase, ProgressEvent } from './pipeline/progress.js';\nexport { createOptions, OptionsError } from './pipeline/create-options.js';\nexport { HELP_TEXT } from './pipeline/cli.js';\nexport { EXIT_SUCCESS, EXIT_FINDINGS, EXIT_ERROR, computeGateScore } from './pipeline/main.js';\nexport { resolveAffectedFiles } from './pipeline/affected.js';\nexport { saveBaseline, filterKnownFindings } from './pipeline/baseline.js';\nexport { formatFindings } from './pipeline/reporters.js';\nexport { loadConfigFile, mergeConfigIntoDefaults } from './pipeline/config-loader.js';\n\ntype DependencyStateArg = DependencyState | undefined;\n\nexport {\n buildDependencySummary,\n computeDependencyCycles,\n computeDependencyCriticalPaths,\n} from './analysis/dependency-summary.js';\nexport {\n REPORT_SCHEMA_VERSION,\n ARCHITECTURE_CATEGORIES,\n CODE_QUALITY_CATEGORIES,\n DEAD_CODE_CATEGORIES,\n SECURITY_CATEGORIES,\n TEST_QUALITY_CATEGORIES,\n writeMultiFileReport,\n generateMermaidGraph,\n} from './reporting/writer.js';\nexport type { FullReport } from './reporting/writer.js';\nexport {\n severityBreakdown,\n categoryBreakdown,\n computeHealthScore,\n computeFeatureScores,\n computeQualityAspectRatings,\n collectTagCloud,\n formatFileSize,\n diversifyFindings,\n diverseTopRecommendations,\n generateSummaryMd,\n} from './reporting/summary-md.js';\nexport type {\n SummaryMdOptions,\n QualityAspectRating,\n QualityRatingSummary,\n} from './reporting/summary-md.js';\n\ntype FindingDraft = Omit\u003cFinding, 'id'>;\ntype DetectorFn = () => Iterable\u003cFindingDraft>;\n\ninterface EnabledPillars {\n architecture: boolean;\n codeQuality: boolean;\n deadCode: boolean;\n security: boolean;\n testQuality: boolean;\n}\n\nfunction hasEnabledCategory(\n features: Set\u003cstring>,\n categories: string[]\n): boolean {\n return categories.some(category => features.has(category));\n}\n\nexport function resolveEnabledPillars(\n features: Set\u003cstring> | null\n): EnabledPillars {\n if (!features) {\n return {\n architecture: true,\n codeQuality: true,\n deadCode: true,\n security: true,\n testQuality: true,\n };\n }\n return {\n architecture: hasEnabledCategory(features, PILLAR_CATEGORIES['architecture']),\n codeQuality: hasEnabledCategory(features, PILLAR_CATEGORIES['code-quality']),\n deadCode: hasEnabledCategory(features, PILLAR_CATEGORIES['dead-code']),\n security: hasEnabledCategory(features, PILLAR_CATEGORIES['security']),\n testQuality: hasEnabledCategory(features, PILLAR_CATEGORIES['test-quality']),\n };\n}\n\nfunction collectArchitectureFindings(\n dependencySummary: DependencySummary,\n dependencyState: DependencyState,\n fileSummaries: FileEntry[],\n options: AnalysisOptions,\n fileCriticalityByPath: Map\u003cstring, FileCriticality>,\n consumedFromModule: Map\u003cstring, Set\u003cstring>>,\n testConsumedFromModule: Map\u003cstring, Set\u003cstring>>,\n pkgJsonDeps: Record\u003cstring, string>,\n pkgJsonDevDeps: Record\u003cstring, string>\n): DetectorFn[] {\n const hotFiles = computeHotFiles(dependencyState, dependencySummary, fileCriticalityByPath);\n const detectors: DetectorFn[] = [\n () => detectTestOnlyModules(dependencySummary),\n () => detectDependencyCycles(dependencySummary, dependencyState),\n () => detectCriticalPaths(dependencySummary, dependencyState, options.thresholds.criticalComplexityThreshold),\n () => detectDeadFiles(dependencySummary, dependencyState),\n () => detectDeadExports(dependencyState, consumedFromModule, testConsumedFromModule),\n () => detectDeadReExports(dependencyState, consumedFromModule),\n () => detectSdpViolations(dependencyState, options.thresholds.sdpMinDelta, options.thresholds.sdpMaxSourceInstability),\n () => detectHighCoupling(dependencyState, options.thresholds.couplingThreshold),\n () => detectGodModuleCoupling(dependencyState, options.thresholds.fanInThreshold, options.thresholds.fanOutThreshold),\n () => detectOrphanModules(dependencyState),\n () => detectUnreachableModules(dependencyState),\n () => detectUnusedNpmDeps(dependencyState.externalCounts, pkgJsonDeps, pkgJsonDevDeps),\n () => detectBoundaryViolations(dependencyState),\n () => detectBarrelExplosion(dependencyState, options.thresholds.barrelSymbolThreshold),\n () => detectMegaFolders(fileSummaries),\n () => detectLowCohesion(dependencyState),\n () => detectDistanceFromMainSequence(dependencyState),\n () => detectFeatureEnvy(dependencyState),\n () => detectUntestedCriticalCode(dependencyState, hotFiles, fileCriticalityByPath),\n () => detectImportSideEffectRisk(fileSummaries, dependencyState, dependencySummary, hotFiles),\n () => detectNamespaceImport(dependencyState),\n () => detectCommonJsInEsm(dependencyState),\n () => detectExportStarLeak(dependencyState),\n ];\n if (options.thresholds.layerOrder.length >= 2) {\n detectors.push(() => detectLayerViolations(dependencyState, options.thresholds.layerOrder));\n }\n return detectors;\n}\n\nfunction collectCodeQualityFindings(\n duplicates: DuplicateGroup[],\n controlDuplicates: RedundantFlowGroup[],\n fileSummaries: FileEntry[],\n options: AnalysisOptions,\n flowMap: Map\u003cstring, FlowMapEntry[]>,\n dependencyState?: DependencyStateArg\n): DetectorFn[] {\n return [\n () => detectDuplicateFunctionBodies(duplicates),\n () => detectDuplicateFlowStructures(controlDuplicates, options.thresholds.flowDupThreshold),\n () => detectFunctionOptimization(fileSummaries, options.thresholds.criticalComplexityThreshold),\n () => detectGodFunctions(fileSummaries, options.thresholds.godFunctionStatements, options.thresholds.godFunctionMiThreshold),\n () => detectCognitiveComplexity(fileSummaries, options.thresholds.cognitiveComplexityThreshold),\n () => detectExcessiveParameters(fileSummaries, options.thresholds.parameterThreshold),\n () => detectEmptyCatchBlocks(fileSummaries),\n () => detectSwitchNoDefault(fileSummaries),\n () => detectUnsafeAny(fileSummaries, options.thresholds.anyThreshold),\n () => detectHighHalsteadEffort(fileSummaries, options.thresholds.halsteadEffortThreshold),\n () => detectLowMaintainability(fileSummaries, options.thresholds.maintainabilityIndexThreshold),\n () => detectTypeAssertionEscape(fileSummaries),\n () => detectMissingErrorBoundary(fileSummaries),\n () => detectPromiseMisuse(fileSummaries),\n () => detectAwaitInLoop(fileSummaries),\n () => detectSyncIo(fileSummaries),\n () => detectUnclearedTimers(fileSummaries),\n () => detectListenerLeakRisk(fileSummaries),\n () => detectUnboundedCollection(fileSummaries),\n () => detectMessageChains(fileSummaries),\n () => detectSimilarFunctionBodies(flowMap, options.thresholds.similarityThreshold),\n () => detectDeepNesting(fileSummaries, options.thresholds.deepNestingThreshold),\n () => detectMultipleReturnPaths(fileSummaries, options.thresholds.multipleReturnThreshold),\n () => detectCatchRethrow(fileSummaries),\n () => detectMagicStrings(fileSummaries, options.thresholds.magicStringMinOccurrences),\n () => detectBooleanParameterCluster(fileSummaries, options.thresholds.booleanParamThreshold),\n () => detectPromiseAllUnhandled(fileSummaries),\n () => detectExportSurfaceDensity(fileSummaries, dependencyState),\n () => detectChangeRisk(fileSummaries, flowMap, dependencyState),\n ...(dependencyState\n ? [() => detectGodModules(fileSummaries, dependencyState, options.thresholds.godModuleStatements, options.thresholds.godModuleExports)]\n : []),\n ];\n}\n\nfunction collectSecurityFindings(fileSummaries: FileEntry[]): DetectorFn[] {\n return [\n () => detectHardcodedSecrets(fileSummaries),\n () => detectEvalUsage(fileSummaries),\n () => detectUnsafeHtml(fileSummaries),\n () => detectSqlInjectionRisk(fileSummaries),\n () => detectUnsafeRegex(fileSummaries),\n () => detectUnvalidatedInputSink(fileSummaries),\n () => detectInputPassthroughRisk(fileSummaries),\n () => detectPrototypePollutionRisk(fileSummaries),\n () => detectPathTraversalRisk(fileSummaries),\n () => detectCommandInjectionRisk(fileSummaries),\n () => detectDebugLogLeakage(fileSummaries),\n () => detectSensitiveDataLogging(fileSummaries),\n ];\n}\n\nfunction collectTestQualityFindings(\n fileSummaries: FileEntry[],\n options: AnalysisOptions\n): DetectorFn[] {\n return [\n () => detectLowAssertionDensity(fileSummaries),\n () => detectTestNoAssertion(fileSummaries),\n () => detectExcessiveMocking(fileSummaries, options.thresholds.mockThreshold),\n () => detectSharedMutableState(fileSummaries),\n () => detectMissingTestCleanup(fileSummaries),\n () => detectFocusedTests(fileSummaries),\n () => detectFakeTimersWithoutRestore(fileSummaries),\n () => detectMissingMockRestoration(fileSummaries),\n ];\n}\n\nexport function buildIssueCatalog(\n duplicates: DuplicateGroup[],\n controlDuplicates: RedundantFlowGroup[],\n fileSummaries: FileEntry[],\n dependencySummary: DependencySummary,\n dependencyState: DependencyState,\n options: AnalysisOptions,\n pkgJsonDeps: Record\u003cstring, string> = {},\n pkgJsonDevDeps: Record\u003cstring, string> = {},\n fileCriticalityByPath: Map\u003cstring, FileCriticality> = new Map(),\n semanticFindings: Array\u003cFindingDraft> = [],\n flowMap: Map\u003cstring, FlowMapEntry[]> = new Map(),\n additionalFindings: Array\u003cFindingDraft> = []\n): {\n allFindings: Array\u003cFindingDraft>;\n findings: Finding[];\n byFile: Map\u003cstring, string[]>;\n totalBeforeTruncation: number;\n droppedCategories: string[];\n} {\n const rawFindings: Array\u003cFindingDraft> = [];\n\n const addFinding = (finding: FindingDraft): void => {\n if (options.features && !options.features.has(finding.category)) return;\n rawFindings.push(finding);\n };\n\n const { production: consumedFromModule, test: testConsumedFromModule } =\n buildConsumedFromModule(dependencyState);\n const enabledPillars = resolveEnabledPillars(options.features);\n\n const detectors: DetectorFn[] = [\n ...(enabledPillars.architecture || enabledPillars.deadCode\n ? collectArchitectureFindings(\n dependencySummary, dependencyState, fileSummaries, options,\n fileCriticalityByPath, consumedFromModule, testConsumedFromModule,\n pkgJsonDeps, pkgJsonDevDeps\n )\n : []),\n ...(enabledPillars.codeQuality\n ? collectCodeQualityFindings(\n duplicates,\n controlDuplicates,\n fileSummaries,\n options,\n flowMap,\n dependencyState\n )\n : []),\n ...(enabledPillars.security ? collectSecurityFindings(fileSummaries) : []),\n ...(enabledPillars.testQuality\n ? collectTestQualityFindings(fileSummaries, options)\n : []),\n ];\n\n for (const detect of detectors) {\n for (const f of detect()) addFinding(f);\n }\n for (const f of semanticFindings) addFinding(f);\n for (const f of additionalFindings) addFinding(f);\n\n const sorted = rawFindings.sort((a, b) => {\n const bySeverity = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];\n if (bySeverity !== 0) return bySeverity;\n if (a.category \u003c b.category) return -1;\n if (a.category > b.category) return 1;\n return 0;\n });\n\n const { findings: truncated, totalBeforeTruncation, droppedCategories } =\n applyFindingsLimit(sorted, options);\n const { findings, byFile } = assignFindingIds(truncated);\n\n return {\n allFindings: sorted,\n findings,\n byFile,\n totalBeforeTruncation,\n droppedCategories,\n };\n}\n\nexport function applyFindingsLimit\u003cT extends Omit\u003cFinding, 'id'>>(\n sorted: T[],\n options: Pick\u003cAnalysisOptions, 'findingsLimit' | 'noDiversify'>\n): {\n findings: T[];\n totalBeforeTruncation: number;\n droppedCategories: string[];\n} {\n const totalBeforeTruncation = sorted.length;\n const allCategoriesBefore = new Set(sorted.map(f => f.category));\n const limit = options.findingsLimit;\n const truncated =\n !Number.isFinite(limit) || limit == null\n ? sorted\n : options.noDiversify\n ? sorted.slice(0, limit)\n : diversifyFindings(sorted, limit);\n const categoriesAfter = new Set(truncated.map(f => f.category));\n const droppedCategories = [...allCategoriesBefore].filter(\n c => !categoriesAfter.has(c)\n );\n\n return {\n findings: truncated,\n totalBeforeTruncation,\n droppedCategories,\n };\n}\n\nexport function assignFindingIds(\n rawFindings: Array\u003cOmit\u003cFinding, 'id'>>\n): {\n findings: Finding[];\n byFile: Map\u003cstring, string[]>;\n} {\n const findings: Finding[] = [];\n const byFile = new Map\u003cstring, string[]>();\n\n for (const [i, raw] of rawFindings.entries()) {\n const id = `AST-ISSUE-${String(i + 1).padStart(4, '0')}`;\n const full: Finding = { id, ...raw };\n findings.push(full);\n if (full.file) {\n if (!byFile.has(full.file)) byFile.set(full.file, []);\n byFile.get(full.file)!.push(id);\n }\n }\n\n return { findings, byFile };\n}\n\n/**\n * Programmatic scan API — the equivalent of dependency-cruiser's `cruise()`\n * or madge's constructor. Runs the full analysis pipeline without CLI I/O.\n *\n * Usage:\n * ```ts\n * import { scan, DEFAULT_OPTS } from './index.js';\n * const result = await scan({ root: '/path/to/project', graph: true });\n * console.log(result.exitCode); // 0=clean, 1=findings, 2=error\n * ```\n */\nexport async function scan(\n overrides: Partial\u003cAnalysisOptions> = {}\n): Promise\u003cScanResult> {\n const { DEFAULT_OPTS } = await import('./types/constants.js');\n const opts: AnalysisOptions = {\n ...DEFAULT_OPTS,\n ...overrides,\n thresholds: { ...DEFAULT_OPTS.thresholds, ...overrides.thresholds },\n };\n\n const { createOptions } = await import('./pipeline/create-options.js');\n const finalOpts = createOptions({ args: opts });\n\n const { main } = await import('./pipeline/main.js');\n const exitCode = await main(finalOpts);\n\n return { exitCode };\n}\n\nexport interface ScanResult {\n exitCode: number;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":16210,"content_sha256":"6e5c16abc679769d75c4e7499f4ad37170f221bcdb37686fb77ecca4c2eec9e1"},{"filename":"src/pipeline.test.ts","content":"import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport * as discovery from './analysis/discovery.js';\nimport * as cache from './pipeline/cache.js';\nimport * as cli from './pipeline/cli.js';\nimport { main } from './pipeline/main.js';\nimport { DEFAULT_OPTS } from './types/index.js';\n\nfunction makeOptions(overrides: Partial\u003ctypeof DEFAULT_OPTS> = {}) {\n return {\n ...DEFAULT_OPTS,\n ...overrides,\n };\n}\n\ndescribe('pipeline main', () => {\n afterEach(() => {\n vi.restoreAllMocks();\n });\n\n it('clears cache and returns early when clearCache is enabled', async () => {\n const opts = makeOptions({ clearCache: true, root: '/tmp/repo' });\n const parseSpy = vi.spyOn(cli, 'parseArgs').mockReturnValue(opts);\n const clearSpy = vi.spyOn(cache, 'clearCache').mockImplementation(() => {});\n const listSpy = vi.spyOn(discovery, 'listWorkspacePackages');\n const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\n await main();\n\n expect(parseSpy).toHaveBeenCalledTimes(1);\n expect(clearSpy).toHaveBeenCalledWith('/tmp/repo');\n expect(errSpy).toHaveBeenCalledWith('Cache cleared.');\n expect(listSpy).not.toHaveBeenCalled();\n });\n\n it('exits when no packages and no root package.json exist', async () => {\n const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'oq-pipeline-empty-'));\n const opts = makeOptions({\n clearCache: false,\n root: tmp,\n packageRoot: path.join(tmp, 'packages'),\n });\n\n vi.spyOn(cli, 'parseArgs').mockReturnValue(opts);\n vi.spyOn(discovery, 'listWorkspacePackages').mockReturnValue([]);\n vi.spyOn(console, 'error').mockImplementation(() => {});\n\n const exitErr = new Error('exit-1');\n vi.spyOn(process, 'exit').mockImplementation(((\n code?: string | number | null\n ) => {\n throw code === 1 ? exitErr : new Error(`unexpected-exit-${code}`);\n }) as never);\n\n await expect(main()).rejects.toBe(exitErr);\n });\n\n it('exits when fallback root package.json is unreadable', async () => {\n const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'oq-pipeline-badjson-'));\n fs.writeFileSync(path.join(tmp, 'package.json'), '{invalid-json', 'utf8');\n const opts = makeOptions({\n clearCache: false,\n root: tmp,\n packageRoot: path.join(tmp, 'packages'),\n });\n\n vi.spyOn(cli, 'parseArgs').mockReturnValue(opts);\n vi.spyOn(discovery, 'listWorkspacePackages').mockReturnValue([]);\n vi.spyOn(console, 'error').mockImplementation(() => {});\n\n const exitErr = new Error('exit-1');\n vi.spyOn(process, 'exit').mockImplementation(((\n code?: string | number | null\n ) => {\n throw code === 1 ? exitErr : new Error(`unexpected-exit-${code}`);\n }) as never);\n\n await expect(main()).rejects.toBe(exitErr);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":2867,"content_sha256":"3b4aa69e65c1b654939ac6745e04fcac170b685542bbe4f3773b922194bbc33a"},{"filename":"src/pipeline/affected.test.ts","content":"import { describe, expect, it, vi, beforeEach } from 'vitest';\nimport { execSync } from 'node:child_process';\nimport path from 'node:path';\nimport { resolveAffectedFiles } from './affected.js';\nimport type { DependencyState } from '../types/index.js';\n\nvi.mock('node:child_process', () => ({\n execSync: vi.fn(),\n}));\n\nfunction makeDependencyState(\n outgoing: Record\u003cstring, string[]>,\n incoming: Record\u003cstring, string[]>\n): DependencyState {\n return {\n outgoing: new Map(Object.entries(outgoing).map(([k, v]) => [k, new Set(v)])),\n incoming: new Map(Object.entries(incoming).map(([k, v]) => [k, new Set(v)])),\n files: new Set([...Object.keys(outgoing), ...Object.keys(incoming)]),\n externalImports: new Map(),\n packageJsonDeps: new Map(),\n packageJsonDevDeps: new Map(),\n } as unknown as DependencyState;\n}\n\ndescribe('resolveAffectedFiles', () => {\n beforeEach(() => {\n vi.mocked(execSync).mockReset();\n });\n\n it('returns empty when git returns no changed files', () => {\n vi.mocked(execSync).mockReturnValue('');\n const state = makeDependencyState({}, {});\n expect(resolveAffectedFiles('/repo', 'HEAD', state)).toEqual([]);\n });\n\n it('returns changed files and their transitive dependents as relative paths', () => {\n vi.mocked(execSync).mockReturnValue('src/a.ts\\nsrc/b.ts\\n');\n const state = makeDependencyState(\n { 'src/a.ts': ['src/c.ts'], 'src/c.ts': [] },\n {\n 'src/a.ts': ['src/d.ts'],\n 'src/d.ts': ['src/e.ts'],\n }\n );\n\n const result = resolveAffectedFiles('/repo', 'HEAD', state);\n expect(result).toContain('src/a.ts');\n expect(result).toContain('src/b.ts');\n expect(result).toContain('src/d.ts');\n expect(result).toContain('src/e.ts');\n for (const p of result) {\n expect(path.isAbsolute(p)).toBe(false);\n }\n });\n\n it('returns empty when git command fails', () => {\n vi.mocked(execSync).mockImplementation(() => {\n throw new Error('git failed');\n });\n const state = makeDependencyState({}, {});\n expect(resolveAffectedFiles('/repo', 'main', state)).toEqual([]);\n });\n\n it('filters non-ts/js files from git output', () => {\n vi.mocked(execSync).mockReturnValue('README.md\\nsrc/a.ts\\npackage.json\\n');\n const state = makeDependencyState({}, {});\n\n const result = resolveAffectedFiles('/repo', 'HEAD', state);\n expect(result).toHaveLength(1);\n expect(result[0]).toBe('src/a.ts');\n });\n\n it('passes the revision argument to git diff', () => {\n vi.mocked(execSync).mockReturnValue('');\n const state = makeDependencyState({}, {});\n\n resolveAffectedFiles('/repo', 'main~5', state);\n\n expect(execSync).toHaveBeenCalledWith(\n 'git diff --name-only --diff-filter=ACMRT main~5',\n expect.objectContaining({ cwd: '/repo' })\n );\n });\n\n it('returns only changed file when it has no dependents', () => {\n vi.mocked(execSync).mockReturnValue('src/isolated.ts\\n');\n const state = makeDependencyState(\n { 'src/other.ts': ['src/isolated.ts'] },\n {}\n );\n\n const result = resolveAffectedFiles('/repo', 'HEAD', state);\n expect(result).toEqual(['src/isolated.ts']);\n });\n\n it('handles cycles in dependency graph without infinite loop', () => {\n vi.mocked(execSync).mockReturnValue('src/a.ts\\n');\n const state = makeDependencyState(\n {\n 'src/a.ts': ['src/b.ts'],\n 'src/b.ts': ['src/a.ts'],\n },\n {\n 'src/a.ts': ['src/b.ts'],\n 'src/b.ts': ['src/a.ts'],\n }\n );\n\n const result = resolveAffectedFiles('/repo', 'HEAD', state);\n expect(result).toContain('src/a.ts');\n expect(result).toContain('src/b.ts');\n expect(result.length).toBe(2);\n });\n\n it('follows deep transitive chains (A→B→C→D)', () => {\n vi.mocked(execSync).mockReturnValue('src/a.ts\\n');\n const state = makeDependencyState(\n {},\n {\n 'src/a.ts': ['src/b.ts'],\n 'src/b.ts': ['src/c.ts'],\n 'src/c.ts': ['src/d.ts'],\n }\n );\n\n const result = resolveAffectedFiles('/repo', 'HEAD', state);\n expect(result).toHaveLength(4);\n expect(result).toContain('src/a.ts');\n expect(result).toContain('src/b.ts');\n expect(result).toContain('src/c.ts');\n expect(result).toContain('src/d.ts');\n });\n\n it('accepts .jsx, .tsx, .js, .mjs, .cjs extensions', () => {\n vi.mocked(execSync).mockReturnValue(\n 'src/a.ts\\nsrc/b.tsx\\nsrc/c.js\\nsrc/d.jsx\\nsrc/e.mjs\\nsrc/f.cjs\\ndata.csv\\n'\n );\n const state = makeDependencyState({}, {});\n\n const result = resolveAffectedFiles('/repo', 'HEAD', state);\n expect(result.length).toBeGreaterThanOrEqual(4);\n expect(result.some(r => r.endsWith('.ts'))).toBe(true);\n expect(result.some(r => r.endsWith('.tsx'))).toBe(true);\n expect(result.some(r => r.endsWith('.js'))).toBe(true);\n expect(result.every(r => !r.endsWith('.csv'))).toBe(true);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":4870,"content_sha256":"7ea036a50cf89e29e44971e8e022e21b26dfe0bed641c73a17e116a1265c7af9"},{"filename":"src/pipeline/affected.ts","content":"import { execSync } from 'node:child_process';\nimport path from 'node:path';\n\nimport { ALLOWED_EXTS } from '../types/index.js';\n\n\nimport type { DependencyState } from '../types/index.js';\n\n/**\n * Resolves --affected: git changed files + transitive dependents.\n * Inspired by dependency-cruiser's --affected (uses watskeburt internally).\n */\nexport function resolveAffectedFiles(\n root: string,\n revision: string,\n dependencyState: DependencyState\n): string[] {\n const changedFiles = getGitChangedFiles(root, revision);\n if (changedFiles.length === 0) return [];\n\n const changedRelPaths = new Set(changedFiles);\n const affected = new Set(changedRelPaths);\n collectTransitiveDependents(changedRelPaths, dependencyState, affected);\n\n return [...affected];\n}\n\nfunction getGitChangedFiles(root: string, revision: string): string[] {\n try {\n const stdout = execSync(\n `git diff --name-only --diff-filter=ACMRT ${revision}`,\n { cwd: root, encoding: 'utf8', timeout: 10000 }\n ).trim();\n\n if (!stdout) return [];\n\n return stdout\n .split('\\n')\n .filter(f => {\n const ext = path.extname(f);\n return ALLOWED_EXTS.has(ext);\n });\n } catch {\n return [];\n }\n}\n\n/**\n * BFS: walk incoming edges to collect all transitive dependents of changed files.\n */\nfunction collectTransitiveDependents(\n seeds: Set\u003cstring>,\n state: DependencyState,\n result: Set\u003cstring>\n): void {\n const queue = [...seeds];\n while (queue.length > 0) {\n const current = queue.pop()!;\n const dependents = state.incoming.get(current);\n if (!dependents) continue;\n for (const dep of dependents) {\n if (!result.has(dep)) {\n result.add(dep);\n queue.push(dep);\n }\n }\n }\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":1730,"content_sha256":"69b5ee840f697b8a8a22a587a311b70355e0cd883620f7c365b557d6c8a2f6ea"},{"filename":"src/pipeline/baseline.test.ts","content":"import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport { afterEach, describe, expect, it } from 'vitest';\n\nimport { saveBaseline, filterKnownFindings } from './baseline.js';\nimport type { Finding } from '../types/index.js';\n\nfunction makeFinding(overrides: Partial\u003cFinding>): Finding {\n return {\n id: 'f1',\n severity: 'medium',\n category: 'dead-export',\n file: 'src/a.ts',\n lineStart: 1,\n lineEnd: 5,\n title: 'Unused export',\n reason: 'No consumers',\n files: ['src/a.ts'],\n suggestedFix: { strategy: 'remove', steps: ['Delete export'] },\n ...overrides,\n };\n}\n\ndescribe('baseline', () => {\n const tmpDirs: string[] = [];\n\n function makeTmpDir(): string {\n const d = fs.mkdtempSync(path.join(os.tmpdir(), 'baseline-test-'));\n tmpDirs.push(d);\n return d;\n }\n\n afterEach(() => {\n for (const d of tmpDirs) {\n fs.rmSync(d, { recursive: true, force: true });\n }\n tmpDirs.length = 0;\n });\n\n describe('saveBaseline', () => {\n it('writes baseline.json with correct structure', () => {\n const root = makeTmpDir();\n const findings = [\n makeFinding({ category: 'dead-export', file: 'src/a.ts' }),\n makeFinding({ category: 'god-function', file: 'src/b.ts' }),\n ];\n\n const result = saveBaseline(root, findings);\n expect(result).toContain('baseline.json');\n expect(fs.existsSync(result)).toBe(true);\n\n const data = JSON.parse(fs.readFileSync(result, 'utf8'));\n expect(data.count).toBe(2);\n expect(data.entries).toHaveLength(2);\n expect(data.entries[0].category).toBe('dead-export');\n expect(data.entries[1].category).toBe('god-function');\n expect(data.generatedAt).toBeDefined();\n });\n\n it('creates .octocode directory if it does not exist', () => {\n const root = makeTmpDir();\n const result = saveBaseline(root, [makeFinding({})]);\n expect(fs.existsSync(path.dirname(result))).toBe(true);\n });\n\n it('saves empty baseline for empty findings', () => {\n const root = makeTmpDir();\n const result = saveBaseline(root, []);\n\n const data = JSON.parse(fs.readFileSync(result, 'utf8'));\n expect(data.count).toBe(0);\n expect(data.entries).toHaveLength(0);\n });\n\n it('captures category, file, and title per entry', () => {\n const root = makeTmpDir();\n const finding = makeFinding({\n category: 'unsafe-any',\n file: 'lib/utils.ts',\n title: 'Too many any',\n });\n const result = saveBaseline(root, [finding]);\n const data = JSON.parse(fs.readFileSync(result, 'utf8'));\n expect(data.entries[0]).toEqual({\n category: 'unsafe-any',\n file: 'lib/utils.ts',\n title: 'Too many any',\n });\n });\n });\n\n describe('filterKnownFindings', () => {\n it('filters out findings matching baseline entries by (category, file)', () => {\n const root = makeTmpDir();\n const baselinePath = path.join(root, 'baseline.json');\n fs.writeFileSync(\n baselinePath,\n JSON.stringify({\n entries: [\n { category: 'dead-export', file: 'src/a.ts', title: 'old title' },\n ],\n })\n );\n\n const findings = [\n makeFinding({\n category: 'dead-export',\n file: 'src/a.ts',\n title: 'new title',\n }),\n makeFinding({ category: 'god-function', file: 'src/b.ts' }),\n ];\n\n const { filtered, suppressedCount } = filterKnownFindings(\n findings,\n baselinePath,\n root\n );\n expect(suppressedCount).toBe(1);\n expect(filtered).toHaveLength(1);\n expect(filtered[0].category).toBe('god-function');\n });\n\n it('returns all findings when baseline file missing', () => {\n const findings = [makeFinding({})];\n const { filtered, suppressedCount } = filterKnownFindings(\n findings,\n '/nonexistent/baseline.json',\n '/tmp'\n );\n expect(suppressedCount).toBe(0);\n expect(filtered).toHaveLength(1);\n });\n\n it('resolves relative baseline path against root', () => {\n const root = makeTmpDir();\n const dir = path.join(root, '.octocode');\n fs.mkdirSync(dir, { recursive: true });\n const baselinePath = path.join(dir, 'baseline.json');\n fs.writeFileSync(\n baselinePath,\n JSON.stringify({\n entries: [\n { category: 'dead-export', file: 'src/a.ts', title: 't' },\n ],\n })\n );\n\n const findings = [\n makeFinding({ category: 'dead-export', file: 'src/a.ts' }),\n ];\n const { filtered } = filterKnownFindings(\n findings,\n '.octocode/baseline.json',\n root\n );\n expect(filtered).toHaveLength(0);\n });\n\n it('does not suppress same category in a different file', () => {\n const root = makeTmpDir();\n const baselinePath = path.join(root, 'baseline.json');\n fs.writeFileSync(\n baselinePath,\n JSON.stringify({\n entries: [\n { category: 'dead-export', file: 'src/a.ts', title: 'x' },\n ],\n })\n );\n\n const findings = [\n makeFinding({ category: 'dead-export', file: 'src/b.ts' }),\n ];\n const { filtered, suppressedCount } = filterKnownFindings(\n findings,\n baselinePath,\n root\n );\n expect(suppressedCount).toBe(0);\n expect(filtered).toHaveLength(1);\n });\n\n it('handles corrupted baseline JSON gracefully', () => {\n const root = makeTmpDir();\n const baselinePath = path.join(root, 'baseline.json');\n fs.writeFileSync(baselinePath, '{broken json!!!');\n\n const findings = [makeFinding({})];\n const { filtered, suppressedCount } = filterKnownFindings(\n findings,\n baselinePath,\n root\n );\n expect(suppressedCount).toBe(0);\n expect(filtered).toHaveLength(1);\n });\n\n it('handles baseline with empty entries array', () => {\n const root = makeTmpDir();\n const baselinePath = path.join(root, 'baseline.json');\n fs.writeFileSync(baselinePath, JSON.stringify({ entries: [] }));\n\n const findings = [makeFinding({}), makeFinding({ file: 'src/z.ts' })];\n const { filtered, suppressedCount } = filterKnownFindings(\n findings,\n baselinePath,\n root\n );\n expect(suppressedCount).toBe(0);\n expect(filtered).toHaveLength(2);\n });\n\n it('round-trip: save then load filters correctly', () => {\n const root = makeTmpDir();\n const original = [\n makeFinding({ category: 'dead-export', file: 'src/a.ts' }),\n makeFinding({ category: 'god-function', file: 'src/b.ts' }),\n ];\n\n const baselinePath = saveBaseline(root, original);\n\n const newFindings = [\n makeFinding({ category: 'dead-export', file: 'src/a.ts' }),\n makeFinding({ category: 'god-function', file: 'src/b.ts' }),\n makeFinding({ category: 'unsafe-any', file: 'src/c.ts' }),\n ];\n\n const { filtered, suppressedCount } = filterKnownFindings(\n newFindings,\n baselinePath,\n root\n );\n expect(suppressedCount).toBe(2);\n expect(filtered).toHaveLength(1);\n expect(filtered[0].category).toBe('unsafe-any');\n });\n\n it('suppresses multiple findings matching same baseline entry', () => {\n const root = makeTmpDir();\n const baselinePath = path.join(root, 'baseline.json');\n fs.writeFileSync(\n baselinePath,\n JSON.stringify({\n entries: [\n { category: 'dead-export', file: 'src/a.ts', title: 'x' },\n ],\n })\n );\n\n const findings = [\n makeFinding({\n id: 'f1',\n category: 'dead-export',\n file: 'src/a.ts',\n title: 'export A',\n }),\n makeFinding({\n id: 'f2',\n category: 'dead-export',\n file: 'src/a.ts',\n title: 'export B',\n }),\n ];\n const { filtered, suppressedCount } = filterKnownFindings(\n findings,\n baselinePath,\n root\n );\n expect(suppressedCount).toBe(2);\n expect(filtered).toHaveLength(0);\n });\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":8192,"content_sha256":"dde06138842f9c1e17d962ff93b94c2496af69ac5c6cd084d7802d2dacdbe3ef"},{"filename":"src/pipeline/baseline.ts","content":"import fs from 'node:fs';\nimport path from 'node:path';\n\nimport type { Finding } from '../types/index.js';\n\nexport interface BaselineEntry {\n category: string;\n file: string;\n title: string;\n}\n\n/**\n * Saves current findings as a baseline for future --ignore-known runs.\n * Inspired by dependency-cruiser's --ignore-known baseline file.\n */\nexport function saveBaseline(\n root: string,\n findings: Finding[]\n): string {\n const baselinePath = path.join(root, '.octocode', 'baseline.json');\n const dir = path.dirname(baselinePath);\n fs.mkdirSync(dir, { recursive: true });\n\n const entries: BaselineEntry[] = findings.map(f => ({\n category: f.category,\n file: f.file,\n title: f.title,\n }));\n\n const payload = {\n generatedAt: new Date().toISOString(),\n count: entries.length,\n entries,\n };\n\n fs.writeFileSync(baselinePath, JSON.stringify(payload, null, 2), 'utf8');\n return baselinePath;\n}\n\n/**\n * Loads a baseline file and filters out known findings.\n * Match key: (category, file) — title changes don't break the match.\n */\nexport function filterKnownFindings\u003cT extends Pick\u003cFinding, 'category' | 'file'>>(\n findings: T[],\n baselinePath: string,\n root: string\n): { filtered: T[]; suppressedCount: number } {\n const absPath = path.isAbsolute(baselinePath)\n ? baselinePath\n : path.resolve(root, baselinePath);\n\n if (!fs.existsSync(absPath)) {\n return { filtered: findings, suppressedCount: 0 };\n }\n\n try {\n const raw = JSON.parse(fs.readFileSync(absPath, 'utf8'));\n const entries: BaselineEntry[] = raw.entries || [];\n\n const knownKeys = new Set(\n entries.map(e => `${e.category}::${e.file}`)\n );\n\n const filtered = findings.filter(\n f => !knownKeys.has(`${f.category}::${f.file}`)\n );\n\n return {\n filtered,\n suppressedCount: findings.length - filtered.length,\n };\n } catch {\n return { filtered: findings, suppressedCount: 0 };\n }\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":1926,"content_sha256":"e176d0a83deba6b352f21e44772e21bd076a31421520b111068d824e0312edec"},{"filename":"src/pipeline/cache.test.ts","content":"import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport { beforeEach, describe, expect, it } from 'vitest';\n\nimport {\n ANALYSIS_SCHEMA_VERSION,\n clearCache,\n createEmptyCache,\n garbageCollect,\n getCachedResult,\n isCacheHit,\n loadCache,\n saveCache,\n setCacheEntry,\n} from './cache.js';\n\ndescribe('cache', () => {\n let tmpDir: string;\n\n beforeEach(() => {\n tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cache-test-'));\n });\n\n describe('createEmptyCache', () => {\n it('includes schemaVersion', () => {\n const cache = createEmptyCache('/test');\n expect(cache.schemaVersion).toBe(ANALYSIS_SCHEMA_VERSION);\n expect(cache.version).toBe(1);\n expect(cache.root).toBe('/test');\n expect(cache.entries).toEqual({});\n });\n });\n\n describe('loadCache', () => {\n it('returns null for schema version mismatch', () => {\n const cache = createEmptyCache(tmpDir);\n saveCache(tmpDir, cache);\n\n const cachePath = path.join(\n tmpDir,\n '.octocode',\n 'scan',\n '.cache',\n 'analysis-cache.json'\n );\n const data = JSON.parse(fs.readFileSync(cachePath, 'utf8'));\n data.schemaVersion = '0.0.0';\n fs.writeFileSync(cachePath, JSON.stringify(data), 'utf8');\n\n expect(loadCache(tmpDir)).toBeNull();\n });\n\n it('returns null for version mismatch', () => {\n const cache = createEmptyCache(tmpDir);\n saveCache(tmpDir, cache);\n\n const cachePath = path.join(\n tmpDir,\n '.octocode',\n 'scan',\n '.cache',\n 'analysis-cache.json'\n );\n const data = JSON.parse(fs.readFileSync(cachePath, 'utf8'));\n data.version = 999;\n fs.writeFileSync(cachePath, JSON.stringify(data), 'utf8');\n\n expect(loadCache(tmpDir)).toBeNull();\n });\n\n it('returns null for root mismatch', () => {\n const cache = createEmptyCache('/a');\n saveCache(tmpDir, cache);\n\n expect(loadCache('/b')).toBeNull();\n });\n\n it('returns valid cache', () => {\n const cache = createEmptyCache(tmpDir);\n setCacheEntry(\n cache,\n 'file.ts',\n { mtimeMs: 100, size: 50 },\n { issues: [] }\n );\n saveCache(tmpDir, cache);\n\n const loaded = loadCache(tmpDir);\n expect(loaded).not.toBeNull();\n expect(loaded!.root).toBe(tmpDir);\n expect(loaded!.schemaVersion).toBe(ANALYSIS_SCHEMA_VERSION);\n expect(loaded!.entries['file.ts'].result).toEqual({ issues: [] });\n });\n });\n\n describe('setCacheEntry', () => {\n it('sets lastAccessMs', () => {\n const cache = createEmptyCache('/test');\n const before = Date.now();\n setCacheEntry(cache, 'a.ts', { mtimeMs: 1, size: 100 }, {});\n const after = Date.now();\n\n const entry = cache.entries['a.ts'];\n expect(entry.lastAccessMs).toBeGreaterThanOrEqual(before);\n expect(entry.lastAccessMs).toBeLessThanOrEqual(after);\n });\n });\n\n describe('getCachedResult', () => {\n it('refreshes lastAccessMs on read', () => {\n const cache = createEmptyCache('/test');\n setCacheEntry(cache, 'b.ts', { mtimeMs: 2, size: 200 }, { data: 1 });\n\n cache.entries['b.ts'].lastAccessMs = 1000;\n\n const before = Date.now();\n const result = getCachedResult(cache, 'b.ts');\n const after = Date.now();\n\n expect(result).toEqual({ data: 1 });\n expect(cache.entries['b.ts'].lastAccessMs).toBeGreaterThanOrEqual(before);\n expect(cache.entries['b.ts'].lastAccessMs).toBeLessThanOrEqual(after);\n });\n\n it('returns undefined for missing entry', () => {\n const cache = createEmptyCache('/test');\n expect(getCachedResult(cache, 'missing.ts')).toBeUndefined();\n });\n });\n\n describe('garbageCollect', () => {\n it('removes old entries and keeps recent ones', () => {\n const cache = createEmptyCache('/test');\n setCacheEntry(cache, 'old.ts', { mtimeMs: 1, size: 100 }, {});\n cache.entries['old.ts'].lastAccessMs =\n Date.now() - 8 * 24 * 60 * 60 * 1000;\n\n setCacheEntry(cache, 'recent.ts', { mtimeMs: 2, size: 200 }, {});\n\n const removed = garbageCollect(cache);\n expect(removed).toBe(1);\n expect(cache.entries['old.ts']).toBeUndefined();\n expect(cache.entries['recent.ts']).toBeDefined();\n });\n\n it('returns 0 when no entries are expired', () => {\n const cache = createEmptyCache('/test');\n setCacheEntry(cache, 'a.ts', { mtimeMs: 1, size: 100 }, {});\n setCacheEntry(cache, 'b.ts', { mtimeMs: 2, size: 200 }, {});\n\n const removed = garbageCollect(cache);\n expect(removed).toBe(0);\n expect(Object.keys(cache.entries)).toHaveLength(2);\n });\n });\n\n describe('isCacheHit', () => {\n it('returns true when mtime and size match', () => {\n const cache = createEmptyCache('/test');\n setCacheEntry(cache, 'x.ts', { mtimeMs: 10, size: 50 }, {});\n expect(isCacheHit(cache, 'x.ts', { mtimeMs: 10, size: 50 })).toBe(true);\n });\n\n it('returns false when mtime differs', () => {\n const cache = createEmptyCache('/test');\n setCacheEntry(cache, 'x.ts', { mtimeMs: 10, size: 50 }, {});\n expect(isCacheHit(cache, 'x.ts', { mtimeMs: 99, size: 50 })).toBe(false);\n });\n\n it('returns false for null cache', () => {\n expect(isCacheHit(null, 'x.ts', { mtimeMs: 10, size: 50 })).toBe(false);\n });\n });\n\n describe('clearCache', () => {\n it('removes the cache file', () => {\n const cache = createEmptyCache(tmpDir);\n saveCache(tmpDir, cache);\n\n const cachePath = path.join(\n tmpDir,\n '.octocode',\n 'scan',\n '.cache',\n 'analysis-cache.json'\n );\n expect(fs.existsSync(cachePath)).toBe(true);\n\n clearCache(tmpDir);\n expect(fs.existsSync(cachePath)).toBe(false);\n });\n\n it('does not throw if cache file does not exist', () => {\n expect(() => clearCache(tmpDir)).not.toThrow();\n });\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":5943,"content_sha256":"40f75839773eb49eeb81891faf2a8644b6bdca93c93d5d8db0221ca2315d70dc"},{"filename":"src/pipeline/cache.ts","content":"import fs from 'node:fs';\nimport path from 'node:path';\n\nexport const ANALYSIS_SCHEMA_VERSION = '1.1.0'; // Keep in sync with REPORT_SCHEMA_VERSION in index.ts\n\ninterface CacheEntry {\n mtimeMs: number;\n sizeBytes: number;\n result: unknown;\n lastAccessMs: number;\n}\n\ninterface AnalysisCache {\n version: number;\n schemaVersion: string;\n root: string;\n entries: Record\u003cstring, CacheEntry>;\n}\n\nconst CACHE_VERSION = 1;\nconst DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days\n\nexport function loadCache(root: string): AnalysisCache | null {\n const cachePath = path.join(\n root,\n '.octocode',\n 'scan',\n '.cache',\n 'analysis-cache.json'\n );\n try {\n const data = JSON.parse(fs.readFileSync(cachePath, 'utf8'));\n if (\n data.version !== CACHE_VERSION ||\n data.root !== root ||\n data.schemaVersion !== ANALYSIS_SCHEMA_VERSION\n )\n return null;\n return data;\n } catch {\n return null;\n }\n}\n\nexport function saveCache(root: string, cache: AnalysisCache): void {\n const dir = path.join(root, '.octocode', 'scan', '.cache');\n fs.mkdirSync(dir, { recursive: true });\n fs.writeFileSync(\n path.join(dir, 'analysis-cache.json'),\n JSON.stringify(cache),\n 'utf8'\n );\n}\n\nexport function clearCache(root: string): void {\n const cachePath = path.join(\n root,\n '.octocode',\n 'scan',\n '.cache',\n 'analysis-cache.json'\n );\n try {\n fs.unlinkSync(cachePath);\n } catch {\n void 0;\n }\n}\n\nexport function isCacheHit(\n cache: AnalysisCache | null,\n relPath: string,\n stat: { mtimeMs: number; size: number }\n): boolean {\n if (!cache) return false;\n const entry = cache.entries[relPath];\n if (!entry) return false;\n return entry.mtimeMs === stat.mtimeMs && entry.sizeBytes === stat.size;\n}\n\nexport function getCachedResult(\n cache: AnalysisCache,\n relPath: string\n): unknown {\n const entry = cache.entries[relPath];\n if (entry) {\n entry.lastAccessMs = Date.now();\n }\n return entry?.result;\n}\n\nexport function setCacheEntry(\n cache: AnalysisCache,\n relPath: string,\n stat: { mtimeMs: number; size: number },\n result: unknown\n): void {\n cache.entries[relPath] = {\n mtimeMs: stat.mtimeMs,\n sizeBytes: stat.size,\n result,\n lastAccessMs: Date.now(),\n };\n}\n\nexport function createEmptyCache(root: string): AnalysisCache {\n return {\n version: CACHE_VERSION,\n schemaVersion: ANALYSIS_SCHEMA_VERSION,\n root,\n entries: {},\n };\n}\n\nexport function garbageCollect(\n cache: AnalysisCache,\n maxAgeMs: number = DEFAULT_MAX_AGE_MS\n): number {\n const now = Date.now();\n const keysToRemove: string[] = [];\n for (const [key, entry] of Object.entries(cache.entries)) {\n if (now - entry.lastAccessMs > maxAgeMs) {\n keysToRemove.push(key);\n }\n }\n for (const key of keysToRemove) {\n delete cache.entries[key];\n }\n return keysToRemove.length;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":2861,"content_sha256":"0a186373d0ab2c2bcba6442e5bd59b22f8f5ad7c8277d9859e21a0547e85e532"},{"filename":"src/pipeline/cli.test.ts","content":"import { describe, expect, it, vi } from 'vitest';\n\nimport { parseArgs, HELP_TEXT } from './cli.js';\nimport { OptionsError } from './create-options.js';\nimport { DEFAULT_OPTS } from '../types/index.js';\n\ndescribe('parseArgs', () => {\n it('returns defaults when no args given', () => {\n const opts = parseArgs([]);\n expect(opts.json).toBe(false);\n expect(opts.includeTests).toBe(false);\n expect(opts.emitTree).toBe(true);\n expect(opts.graph).toBe(false);\n expect(opts.parser).toBe('auto');\n expect(opts.findingsLimit).toBe(Infinity);\n expect(opts.thresholds.minFunctionStatements).toBe(6);\n expect(opts.thresholds.minFlowStatements).toBe(6);\n expect(opts.thresholds.criticalComplexityThreshold).toBe(30);\n expect(opts.deepLinkTopN).toBe(12);\n expect(opts.treeDepth).toBe(4);\n expect(opts.packageRoot).toMatch(/packages$/);\n });\n\n it('parses --json flag', () => {\n expect(parseArgs(['--json']).json).toBe(true);\n });\n\n it('parses --include-tests flag', () => {\n expect(parseArgs(['--include-tests']).includeTests).toBe(true);\n });\n\n it('parses --emit-tree and --no-tree flags', () => {\n expect(parseArgs(['--emit-tree']).emitTree).toBe(true);\n expect(parseArgs(['--no-tree']).emitTree).toBe(false);\n expect(parseArgs(['--emit-tree', '--no-tree']).emitTree).toBe(false);\n expect(parseArgs(['--no-tree', '--emit-tree']).emitTree).toBe(true);\n });\n\n it('parses --graph flag', () => {\n expect(parseArgs(['--graph']).graph).toBe(true);\n });\n\n it('parses --graph-advanced and --flow flags', () => {\n const opts = parseArgs(['--graph-advanced', '--flow']);\n expect(opts.graphAdvanced).toBe(true);\n expect(opts.flow).toBe(true);\n });\n\n it('parses --parser with valid values', () => {\n expect(parseArgs(['--parser', 'typescript']).parser).toBe('typescript');\n expect(parseArgs(['--parser', 'tree-sitter']).parser).toBe('tree-sitter');\n expect(parseArgs(['--parser', 'auto']).parser).toBe('auto');\n });\n\n it('parses --out as separate arg and --out= syntax', () => {\n expect(parseArgs(['--out', '/tmp/report.json']).out).toBe(\n '/tmp/report.json'\n );\n expect(parseArgs(['--out=/tmp/report.json']).out).toBe('/tmp/report.json');\n });\n\n it('parses --findings-limit', () => {\n expect(parseArgs(['--findings-limit', '500']).findingsLimit).toBe(500);\n });\n\n it('parses --min-function-statements', () => {\n expect(\n parseArgs(['--min-function-statements', '12']).thresholds.minFunctionStatements\n ).toBe(12);\n });\n\n it('parses --min-flow-statements', () => {\n expect(parseArgs(['--min-flow-statements', '8']).thresholds.minFlowStatements).toBe(8);\n });\n\n it('parses --critical-complexity-threshold', () => {\n expect(\n parseArgs(['--critical-complexity-threshold', '25'])\n .thresholds.criticalComplexityThreshold\n ).toBe(25);\n });\n\n it('parses --deep-link-topn', () => {\n expect(parseArgs(['--deep-link-topn', '30']).deepLinkTopN).toBe(30);\n });\n\n it('parses --tree-depth', () => {\n expect(parseArgs(['--tree-depth', '6']).treeDepth).toBe(6);\n });\n\n it('parses --coupling-threshold', () => {\n expect(parseArgs(['--coupling-threshold', '20']).thresholds.couplingThreshold).toBe(\n 20\n );\n });\n\n it('parses --fan-in-threshold', () => {\n expect(parseArgs(['--fan-in-threshold', '25']).thresholds.fanInThreshold).toBe(25);\n });\n\n it('parses --fan-out-threshold', () => {\n expect(parseArgs(['--fan-out-threshold', '18']).thresholds.fanOutThreshold).toBe(18);\n });\n\n it('parses --god-module-statements', () => {\n expect(\n parseArgs(['--god-module-statements', '600']).thresholds.godModuleStatements\n ).toBe(600);\n });\n\n it('parses --god-module-exports', () => {\n expect(parseArgs(['--god-module-exports', '30']).thresholds.godModuleExports).toBe(30);\n });\n\n it('parses --god-function-statements', () => {\n expect(\n parseArgs(['--god-function-statements', '150']).thresholds.godFunctionStatements\n ).toBe(150);\n });\n\n it('parses --cognitive-complexity-threshold', () => {\n expect(\n parseArgs(['--cognitive-complexity-threshold', '20'])\n .thresholds.cognitiveComplexityThreshold\n ).toBe(20);\n });\n\n it('parses --barrel-symbol-threshold', () => {\n expect(\n parseArgs(['--barrel-symbol-threshold', '50']).thresholds.barrelSymbolThreshold\n ).toBe(50);\n });\n\n it('parses --layer-order as comma-separated list', () => {\n const opts = parseArgs(['--layer-order', 'ui,service,repository']);\n expect(opts.thresholds.layerOrder).toEqual(['ui', 'service', 'repository']);\n });\n\n it('trims whitespace in --layer-order values', () => {\n const opts = parseArgs(['--layer-order', ' ui , service , repo ']);\n expect(opts.thresholds.layerOrder).toEqual(['ui', 'service', 'repo']);\n });\n\n it('parses --features with pillar name', () => {\n const opts = parseArgs(['--features=architecture']);\n expect(opts.features).toBeInstanceOf(Set);\n expect(opts.features!.has('dependency-cycle')).toBe(true);\n expect(opts.features!.has('dead-export')).toBe(false);\n });\n\n it('parses --features with individual category', () => {\n const opts = parseArgs(['--features=cognitive-complexity']);\n expect(opts.features).toBeInstanceOf(Set);\n expect(opts.features!.size).toBe(1);\n expect(opts.features!.has('cognitive-complexity')).toBe(true);\n });\n\n it('parses --features with mixed pillar and category', () => {\n const opts = parseArgs(['--features=architecture,empty-catch']);\n expect(opts.features).toBeInstanceOf(Set);\n expect(opts.features!.has('dependency-cycle')).toBe(true);\n expect(opts.features!.has('empty-catch')).toBe(true);\n expect(opts.features!.has('dead-export')).toBe(false);\n });\n\n it('parses --features with space separator', () => {\n const opts = parseArgs(['--features', 'dead-code']);\n expect(opts.features).toBeInstanceOf(Set);\n expect(opts.features!.has('dead-export')).toBe(true);\n });\n\n it('defaults features to null (all enabled)', () => {\n const opts = parseArgs([]);\n expect(opts.features).toBeNull();\n });\n\n it('parses --exclude with pillar name', () => {\n const opts = parseArgs(['--exclude=architecture']);\n expect(opts.features).toBeInstanceOf(Set);\n expect(opts.features!.has('dependency-cycle')).toBe(false);\n expect(opts.features!.has('dead-export')).toBe(true);\n expect(opts.features!.has('cognitive-complexity')).toBe(true);\n });\n\n it('parses --exclude with individual category', () => {\n const opts = parseArgs(['--exclude=dead-export']);\n expect(opts.features).toBeInstanceOf(Set);\n expect(opts.features!.has('dead-export')).toBe(false);\n expect(opts.features!.has('dead-re-export')).toBe(true);\n });\n\n it('falls back to defaults for NaN numeric args', () => {\n const opts = parseArgs([\n '--findings-limit',\n 'abc',\n '--coupling-threshold',\n 'xyz',\n '--fan-in-threshold',\n '',\n '--fan-out-threshold',\n 'NaN',\n '--god-module-statements',\n 'bad',\n '--god-module-exports',\n 'nope',\n '--god-function-statements',\n 'err',\n '--cognitive-complexity-threshold',\n 'x',\n '--barrel-symbol-threshold',\n '!!',\n '--min-function-statements',\n 'no',\n '--min-flow-statements',\n 'no',\n '--critical-complexity-threshold',\n 'no',\n '--deep-link-topn',\n 'no',\n '--tree-depth',\n 'no',\n ]);\n expect(opts.findingsLimit).toBe(DEFAULT_OPTS.findingsLimit);\n expect(opts.thresholds.couplingThreshold).toBe(DEFAULT_OPTS.thresholds.couplingThreshold);\n expect(opts.thresholds.fanInThreshold).toBe(DEFAULT_OPTS.thresholds.fanInThreshold);\n expect(opts.thresholds.fanOutThreshold).toBe(DEFAULT_OPTS.thresholds.fanOutThreshold);\n expect(opts.thresholds.godModuleStatements).toBe(DEFAULT_OPTS.thresholds.godModuleStatements);\n expect(opts.thresholds.godModuleExports).toBe(DEFAULT_OPTS.thresholds.godModuleExports);\n expect(opts.thresholds.godFunctionStatements).toBe(DEFAULT_OPTS.thresholds.godFunctionStatements);\n expect(opts.thresholds.cognitiveComplexityThreshold).toBe(\n DEFAULT_OPTS.thresholds.cognitiveComplexityThreshold\n );\n expect(opts.thresholds.barrelSymbolThreshold).toBe(DEFAULT_OPTS.thresholds.barrelSymbolThreshold);\n expect(opts.thresholds.minFunctionStatements).toBe(DEFAULT_OPTS.thresholds.minFunctionStatements);\n expect(opts.thresholds.minFlowStatements).toBe(DEFAULT_OPTS.thresholds.minFlowStatements);\n expect(opts.thresholds.criticalComplexityThreshold).toBe(\n DEFAULT_OPTS.thresholds.criticalComplexityThreshold\n );\n expect(opts.deepLinkTopN).toBe(DEFAULT_OPTS.deepLinkTopN);\n expect(opts.treeDepth).toBe(DEFAULT_OPTS.treeDepth);\n });\n\n it('handles multiple flags together', () => {\n const opts = parseArgs([\n '--json',\n '--include-tests',\n '--no-tree',\n '--graph',\n '--parser',\n 'typescript',\n '--findings-limit',\n '100',\n '--coupling-threshold',\n '10',\n '--layer-order',\n 'a,b,c',\n ]);\n expect(opts.json).toBe(true);\n expect(opts.includeTests).toBe(true);\n expect(opts.emitTree).toBe(false);\n expect(opts.graph).toBe(true);\n expect(opts.parser).toBe('typescript');\n expect(opts.findingsLimit).toBe(100);\n expect(opts.thresholds.couplingThreshold).toBe(10);\n expect(opts.thresholds.layerOrder).toEqual(['a', 'b', 'c']);\n });\n\n it('sets packageRoot relative to root', () => {\n const opts = parseArgs(['--root', '/tmp/myrepo']);\n expect(opts.packageRoot).toBe('/tmp/myrepo/packages');\n expect(opts.root).toBe('/tmp/myrepo');\n });\n\n it('--semantic enables semantic analysis', () => {\n const opts = parseArgs(['--semantic']);\n expect(opts.semantic).toBe(true);\n });\n\n it('semantic defaults to false', () => {\n const opts = parseArgs([]);\n expect(opts.semantic).toBe(false);\n });\n\n it('--override-chain-threshold sets threshold', () => {\n const opts = parseArgs(['--override-chain-threshold', '5']);\n expect(opts.thresholds.overrideChainThreshold).toBe(5);\n });\n\n it('NaN guards for semantic thresholds', () => {\n const opts = parseArgs(['--override-chain-threshold', 'xyz']);\n expect(opts.thresholds.overrideChainThreshold).toBe(3);\n });\n\n it('--no-diversify sets noDiversify to true', () => {\n expect(parseArgs(['--no-diversify']).noDiversify).toBe(true);\n });\n\n it('noDiversify defaults to false', () => {\n expect(parseArgs([]).noDiversify).toBe(false);\n });\n\n it('--features=test-quality auto-enables includeTests', () => {\n const opts = parseArgs(['--features=test-quality']);\n expect(opts.includeTests).toBe(true);\n });\n\n it('--features=low-assertion-density auto-enables includeTests', () => {\n const opts = parseArgs(['--features=low-assertion-density']);\n expect(opts.includeTests).toBe(true);\n });\n\n it('--features=architecture does not auto-enable includeTests', () => {\n const opts = parseArgs(['--features=architecture']);\n expect(opts.includeTests).toBe(false);\n });\n\n it('parses all boolean flags: --json, --include-tests, --emit-tree, --no-tree, --graph, --semantic, --no-diversify, --no-cache, --clear-cache, --graph-advanced, --flow, --all', () => {\n const opts = parseArgs([\n '--json',\n '--include-tests',\n '--no-tree',\n '--graph',\n '--semantic',\n '--no-diversify',\n '--no-cache',\n '--clear-cache',\n '--graph-advanced',\n '--flow',\n '--all',\n ]);\n expect(opts.json).toBe(true);\n expect(opts.includeTests).toBe(true);\n expect(opts.emitTree).toBe(false);\n expect(opts.graph).toBe(true);\n expect(opts.semantic).toBe(true);\n expect(opts.noDiversify).toBe(true);\n expect(opts.noCache).toBe(true);\n expect(opts.clearCache).toBe(true);\n expect(opts.graphAdvanced).toBe(true);\n expect(opts.flow).toBe(true);\n });\n\n it('parses --all as shorthand for includeTests and semantic', () => {\n const opts = parseArgs(['--all']);\n expect(opts.includeTests).toBe(true);\n expect(opts.semantic).toBe(true);\n });\n\n it('parses --no-cache and --clear-cache', () => {\n expect(parseArgs(['--no-cache']).noCache).toBe(true);\n expect(parseArgs(['--clear-cache']).clearCache).toBe(true);\n });\n\n it('parses --findings-limit 10, --min-function-statements 8, --critical-complexity-threshold 30', () => {\n const opts = parseArgs([\n '--findings-limit',\n '10',\n '--min-function-statements',\n '8',\n '--critical-complexity-threshold',\n '30',\n ]);\n expect(opts.findingsLimit).toBe(10);\n expect(opts.thresholds.minFunctionStatements).toBe(8);\n expect(opts.thresholds.criticalComplexityThreshold).toBe(30);\n });\n\n it('parses --secret-entropy-threshold 4.0 and --similarity-threshold 0.9', () => {\n const opts = parseArgs([\n '--secret-entropy-threshold',\n '4.0',\n '--similarity-threshold',\n '0.9',\n ]);\n expect(opts.thresholds.secretEntropyThreshold).toBe(4);\n expect(opts.thresholds.similarityThreshold).toBe(0.9);\n });\n\n it('parses float flags with decimal values', () => {\n expect(\n parseArgs(['--secret-entropy-threshold', '5.5']).thresholds.secretEntropyThreshold\n ).toBe(5.5);\n expect(\n parseArgs(['--similarity-threshold', '0.75']).thresholds.similarityThreshold\n ).toBe(0.75);\n });\n\n it('parses --parser typescript, --root /some/path, --out result.json, --layer-order ui,service,repo', () => {\n const opts = parseArgs([\n '--parser',\n 'typescript',\n '--root',\n '/some/path',\n '--out',\n 'result.json',\n '--layer-order',\n 'ui,service,repo',\n ]);\n expect(opts.parser).toBe('typescript');\n expect(opts.root).toBe('/some/path');\n expect(opts.out).toBe('result.json');\n expect(opts.thresholds.layerOrder).toEqual(['ui', 'service', 'repo']);\n });\n\n it('parses --out=output.json form', () => {\n const opts = parseArgs(['--out=output.json']);\n expect(opts.out).toBe('output.json');\n });\n\n it('parses --scope with file paths', () => {\n const opts = parseArgs(['--scope', 'packages/foo,packages/bar']);\n expect(opts.scope).toBeInstanceOf(Array);\n expect(opts.scope!.length).toBe(2);\n expect(\n opts.scope!.every(\n p => p.endsWith('packages/foo') || p.endsWith('packages/bar')\n )\n ).toBe(true);\n });\n\n it('parses --scope= with file:symbol syntax', () => {\n const opts = parseArgs(['--scope=packages/foo/session.ts:initSession']);\n expect(opts.scope).toBeInstanceOf(Array);\n expect(opts.scope!.length).toBe(1);\n expect(opts.scopeSymbols).toBeInstanceOf(Map);\n expect(opts.scopeSymbols!.size).toBe(1);\n const symbols = [...opts.scopeSymbols!.values()][0];\n expect(symbols).toContain('initSession');\n });\n\n it('parses --scope with mixed file paths and file:symbol', () => {\n const opts = parseArgs(['--scope=packages/a,packages/b/utils.ts:helper']);\n expect(opts.scope!.length).toBe(2);\n if (opts.scopeSymbols && opts.scopeSymbols.size > 0) {\n const syms = [...opts.scopeSymbols.values()].flat();\n expect(syms).toContain('helper');\n }\n });\n\n it('parses --scope with Windows absolute paths without splitting drive letters', () => {\n const opts = parseArgs(['--scope=C:\\\\repo\\\\pkg\\\\src\\\\a.ts']);\n expect(opts.scope).toBeInstanceOf(Array);\n expect(opts.scope).toHaveLength(1);\n expect(opts.scopeSymbols).toBeNull();\n expect(opts.scope![0]).toContain('C:\\\\repo\\\\pkg\\\\src\\\\a.ts');\n });\n\n it('parses --scope with Windows absolute path and file:symbol syntax', () => {\n const opts = parseArgs(['--scope=C:\\\\repo\\\\pkg\\\\src\\\\a.ts:initSession']);\n expect(opts.scope).toBeInstanceOf(Array);\n expect(opts.scope).toHaveLength(1);\n expect(opts.scopeSymbols).toBeInstanceOf(Map);\n const [filePath, symbols] = [...opts.scopeSymbols!.entries()][0];\n expect(filePath).toContain('C:\\\\repo\\\\pkg\\\\src\\\\a.ts');\n expect(symbols).toEqual(['initSession']);\n });\n\n it('parses --features with pillar name architecture', () => {\n const opts = parseArgs(['--features=architecture']);\n expect(opts.features).toBeInstanceOf(Set);\n expect(opts.features!.has('dependency-cycle')).toBe(true);\n expect(opts.features!.has('dead-export')).toBe(false);\n });\n\n it('parses --features with category name dependency-cycle', () => {\n const opts = parseArgs(['--features=dependency-cycle']);\n expect(opts.features!.has('dependency-cycle')).toBe(true);\n expect(opts.features!.size).toBe(1);\n });\n\n it('parses --exclude with multiple categories', () => {\n const opts = parseArgs(['--exclude=dead-export,cognitive-complexity']);\n expect(opts.features!.has('dead-export')).toBe(false);\n expect(opts.features!.has('cognitive-complexity')).toBe(false);\n expect(opts.features!.has('dead-re-export')).toBe(true);\n });\n\n it('parses --exclude with pillar excludes all its categories', () => {\n const opts = parseArgs(['--exclude=architecture']);\n expect(opts.features!.has('dependency-cycle')).toBe(false);\n expect(opts.features!.has('layer-violation')).toBe(false);\n });\n\n it('--features=excessive-mocking auto-enables includeTests', () => {\n const opts = parseArgs(['--features=excessive-mocking']);\n expect(opts.includeTests).toBe(true);\n });\n\n it('--features=shared-mutable-state auto-enables includeTests', () => {\n const opts = parseArgs(['--features=shared-mutable-state']);\n expect(opts.includeTests).toBe(true);\n });\n\n it('throws OptionsError when --features and --exclude are both provided', () => {\n expect(() =>\n parseArgs(['--features=architecture', '--exclude=dead-code'])\n ).toThrow(OptionsError);\n expect(() =>\n parseArgs(['--features=architecture', '--exclude=dead-code'])\n ).toThrow('mutually exclusive');\n });\n\n it('exports HELP_TEXT as a constant', () => {\n expect(typeof HELP_TEXT).toBe('string');\n expect(HELP_TEXT).toContain('--root');\n expect(HELP_TEXT).toContain('--scope');\n expect(HELP_TEXT).toContain('--features');\n });\n\n it('auto-enables includeTests when features include any test-quality category', () => {\n const opts = parseArgs(['--features=missing-mock-restoration']);\n expect(opts.includeTests).toBe(true);\n });\n\n describe('Tier 1+2 flags', () => {\n it('--affected defaults to HEAD', () => {\n const opts = parseArgs(['--affected']);\n expect(opts.affected).toBe('HEAD');\n });\n\n it('--affected accepts revision', () => {\n const opts = parseArgs(['--affected', 'main']);\n expect(opts.affected).toBe('main');\n });\n\n it('--affected=value inline syntax', () => {\n const opts = parseArgs(['--affected=HEAD~3']);\n expect(opts.affected).toBe('HEAD~3');\n });\n\n it('--save-baseline sets boolean', () => {\n const opts = parseArgs(['--save-baseline']);\n expect(opts.saveBaseline).toBe(true);\n });\n\n it('--ignore-known defaults to .octocode/baseline.json', () => {\n const opts = parseArgs(['--ignore-known']);\n expect(opts.ignoreKnown).toBe('.octocode/baseline.json');\n });\n\n it('--ignore-known accepts custom path', () => {\n const opts = parseArgs(['--ignore-known', 'my-baseline.json']);\n expect(opts.ignoreKnown).toBe('my-baseline.json');\n });\n\n it('--reporter accepts valid formats', () => {\n expect(parseArgs(['--reporter', 'compact']).reporter).toBe('compact');\n expect(parseArgs(['--reporter', 'github-actions']).reporter).toBe('github-actions');\n expect(parseArgs(['--reporter', 'default']).reporter).toBe('default');\n });\n\n it('--focus sets module path', () => {\n const opts = parseArgs(['--focus', 'src/session.ts']);\n expect(opts.focus).toBe('src/session.ts');\n });\n\n it('--focus=value inline syntax', () => {\n const opts = parseArgs(['--focus=src/session.ts']);\n expect(opts.focus).toBe('src/session.ts');\n });\n\n it('--focus-depth sets depth', () => {\n const opts = parseArgs(['--focus-depth', '3']);\n expect(opts.focusDepth).toBe(3);\n });\n\n it('--collapse sets folder depth', () => {\n const opts = parseArgs(['--collapse', '2']);\n expect(opts.collapse).toBe(2);\n });\n\n it('--collapse=N inline syntax', () => {\n const opts = parseArgs(['--collapse=3']);\n expect(opts.collapse).toBe(3);\n });\n\n it('--at-least sets score threshold', () => {\n const opts = parseArgs(['--at-least', '60']);\n expect(opts.atLeast).toBe(60);\n });\n\n it('--at-least=N inline syntax', () => {\n const opts = parseArgs(['--at-least=75']);\n expect(opts.atLeast).toBe(75);\n });\n\n it('--config sets config file path', () => {\n const opts = parseArgs(['--config', '.my-config.json']);\n expect(opts.configFile).toBe('.my-config.json');\n });\n\n it('new flags have correct defaults', () => {\n const opts = parseArgs([]);\n expect(opts.affected).toBeNull();\n expect(opts.saveBaseline).toBe(false);\n expect(opts.ignoreKnown).toBeNull();\n expect(opts.reporter).toBe('default');\n expect(opts.focus).toBeNull();\n expect(opts.focusDepth).toBe(1);\n expect(opts.collapse).toBeNull();\n expect(opts.atLeast).toBeNull();\n expect(opts.configFile).toBeNull();\n });\n\n it('HELP_TEXT includes new flags', () => {\n expect(HELP_TEXT).toContain('--affected');\n expect(HELP_TEXT).toContain('--save-baseline');\n expect(HELP_TEXT).toContain('--ignore-known');\n expect(HELP_TEXT).toContain('--reporter');\n expect(HELP_TEXT).toContain('--focus');\n expect(HELP_TEXT).toContain('--focus-depth');\n expect(HELP_TEXT).toContain('--collapse');\n expect(HELP_TEXT).toContain('--at-least');\n expect(HELP_TEXT).toContain('--config');\n });\n\n it('--affected does not consume the next flag as revision', () => {\n const opts = parseArgs(['--affected', '--graph']);\n expect(opts.affected).toBe('HEAD');\n expect(opts.graph).toBe(true);\n });\n\n it('--ignore-known does not consume the next flag as path', () => {\n const opts = parseArgs(['--ignore-known', '--graph']);\n expect(opts.ignoreKnown).toBe('.octocode/baseline.json');\n expect(opts.graph).toBe(true);\n });\n });\n\n describe('documented common profiles parse without error', () => {\n it('CI gate profile', () => {\n const opts = parseArgs(['--reporter', 'github-actions', '--at-least', '60']);\n expect(opts.reporter).toBe('github-actions');\n expect(opts.atLeast).toBe(60);\n });\n\n it('PR diff check profile', () => {\n const opts = parseArgs(['--affected', 'HEAD~1', '--reporter', 'compact']);\n expect(opts.affected).toBe('HEAD~1');\n expect(opts.reporter).toBe('compact');\n });\n\n it('progressive adoption save profile', () => {\n const opts = parseArgs(['--save-baseline']);\n expect(opts.saveBaseline).toBe(true);\n });\n\n it('progressive adoption check profile', () => {\n const opts = parseArgs(['--ignore-known', '--at-least', '60']);\n expect(opts.ignoreKnown).toBe('.octocode/baseline.json');\n expect(opts.atLeast).toBe(60);\n });\n\n it('module zoom profile', () => {\n const opts = parseArgs([\n '--graph',\n '--focus', 'src/session.ts',\n '--focus-depth', '2',\n ]);\n expect(opts.graph).toBe(true);\n expect(opts.focus).toBe('src/session.ts');\n expect(opts.focusDepth).toBe(2);\n });\n\n it('high-level arch profile', () => {\n const opts = parseArgs(['--graph', '--collapse', '2']);\n expect(opts.graph).toBe(true);\n expect(opts.collapse).toBe(2);\n });\n\n it('full combination profile', () => {\n const opts = parseArgs([\n '--affected', 'main',\n '--reporter', 'compact',\n '--save-baseline',\n '--at-least', '70',\n '--graph',\n '--focus', 'src/tools',\n '--focus-depth', '3',\n '--config', '.my-scan.json',\n ]);\n expect(opts.affected).toBe('main');\n expect(opts.reporter).toBe('compact');\n expect(opts.saveBaseline).toBe(true);\n expect(opts.atLeast).toBe(70);\n expect(opts.graph).toBe(true);\n expect(opts.focus).toBe('src/tools');\n expect(opts.focusDepth).toBe(3);\n expect(opts.configFile).toBe('.my-scan.json');\n });\n });\n\n describe('v2 quality threshold flags', () => {\n it('parses --deep-nesting-threshold', () => {\n expect(\n parseArgs(['--deep-nesting-threshold', '8']).thresholds.deepNestingThreshold\n ).toBe(8);\n });\n\n it('parses --multiple-return-threshold', () => {\n expect(\n parseArgs(['--multiple-return-threshold', '10']).thresholds.multipleReturnThreshold\n ).toBe(10);\n });\n\n it('parses --magic-string-min-occurrences', () => {\n expect(\n parseArgs(['--magic-string-min-occurrences', '5']).thresholds.magicStringMinOccurrences\n ).toBe(5);\n });\n\n it('parses --boolean-param-threshold', () => {\n expect(\n parseArgs(['--boolean-param-threshold', '4']).thresholds.booleanParamThreshold\n ).toBe(4);\n });\n\n it('defaults are correct for new thresholds', () => {\n const opts = parseArgs([]);\n expect(opts.thresholds.deepNestingThreshold).toBe(5);\n expect(opts.thresholds.multipleReturnThreshold).toBe(6);\n expect(opts.thresholds.magicStringMinOccurrences).toBe(3);\n expect(opts.thresholds.booleanParamThreshold).toBe(3);\n });\n\n it('warns on unknown flags', () => {\n const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n parseArgs(['--sematic']);\n expect(spy).toHaveBeenCalledWith(expect.stringContaining('unknown flag'));\n spy.mockRestore();\n });\n\n it('new threshold flags appear in HELP_TEXT', () => {\n expect(HELP_TEXT).toContain('--deep-nesting-threshold');\n expect(HELP_TEXT).toContain('--multiple-return-threshold');\n expect(HELP_TEXT).toContain('--magic-string-min-occurrences');\n expect(HELP_TEXT).toContain('--boolean-param-threshold');\n });\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":26037,"content_sha256":"d61736ccd340e3ba7a7d1eb798edaee207de34bc5eb1a656ae19b5153b602943"},{"filename":"src/pipeline/cli.ts","content":"import path from 'node:path';\n\nimport { ALL_CATEGORIES, DEFAULT_OPTS, PILLAR_CATEGORIES } from '../types/index.js';\nimport { OptionsError, resolveExcludeToFeatures } from './create-options.js';\n\nimport type { AnalysisOptions, Thresholds } from '../types/index.js';\n\nfunction parseNumeric(raw: string | undefined, fallback: number): number {\n const n = parseInt(raw ?? '', 10);\n return Number.isNaN(n) ? fallback : n;\n}\n\nfunction parseDecimal(raw: string | undefined, fallback: number): number {\n const n = parseFloat(raw ?? '');\n return Number.isNaN(n) ? fallback : n;\n}\n\nfunction setIntOpt(\n target: Record\u003cstring, number>,\n key: string,\n raw: string,\n defaults: Record\u003cstring, number>\n): void {\n target[key] = parseNumeric(raw, defaults[key]);\n}\n\nfunction setFloatOpt(\n target: Record\u003cstring, number>,\n key: string,\n raw: string,\n defaults: Record\u003cstring, number>\n): void {\n target[key] = parseDecimal(raw, defaults[key]);\n}\n\nfunction resolveCategories(val: string, flagName: string): Set\u003cstring> {\n const tokens = val\n .split(',')\n .map(s => s.trim())\n .filter(Boolean);\n const resolved = new Set\u003cstring>();\n for (const token of tokens) {\n if (PILLAR_CATEGORIES[token]) {\n for (const cat of PILLAR_CATEGORIES[token]) resolved.add(cat);\n } else if (ALL_CATEGORIES.has(token)) {\n resolved.add(token);\n } else {\n console.error(\n `Unknown ${flagName}: \"${token}\". Use pillar names (${Object.keys(PILLAR_CATEGORIES).join(', ')}) or category names.`\n );\n process.exit(1);\n }\n }\n return resolved;\n}\n\nfunction parseScope(\n val: string,\n root: string\n): { paths: string[]; symbols: Map\u003cstring, string[]> } {\n const splitScopeToken = (\n token: string\n ): { filePath: string; symbolName: string | null } => {\n const colonIdx = token.lastIndexOf(':');\n if (colonIdx \u003c= 0 || colonIdx === token.length - 1) {\n return { filePath: token, symbolName: null };\n }\n const symbolName = token.substring(colonIdx + 1);\n if (symbolName.includes('/') || symbolName.includes('\\\\')) {\n return { filePath: token, symbolName: null };\n }\n return {\n filePath: token.substring(0, colonIdx),\n symbolName,\n };\n };\n\n const paths: string[] = [];\n const symbols = new Map\u003cstring, string[]>();\n for (const token of val\n .split(',')\n .map(s => s.trim())\n .filter(Boolean)) {\n const { filePath, symbolName } = splitScopeToken(token);\n const absFile = path.resolve(root, filePath);\n paths.push(absFile);\n if (symbolName) {\n if (!symbols.has(absFile)) symbols.set(absFile, []);\n symbols.get(absFile)!.push(symbolName);\n }\n }\n return { paths, symbols };\n}\n\ntype FlagHandler = (opts: AnalysisOptions, argv: string[], i: number) => number;\n\nconst BOOL_FLAGS: Record\u003c\n string,\n (opts: AnalysisOptions, value: boolean) => void\n> = {\n '--json': o => {\n o.json = true;\n },\n '--include-tests': o => {\n o.includeTests = true;\n },\n '--emit-tree': o => {\n o.emitTree = true;\n },\n '--no-tree': o => {\n o.emitTree = false;\n },\n '--graph': o => {\n o.graph = true;\n },\n '--semantic': o => {\n o.semantic = true;\n },\n '--no-diversify': o => {\n o.noDiversify = true;\n },\n '--no-cache': o => {\n o.noCache = true;\n },\n '--clear-cache': o => {\n o.clearCache = true;\n },\n '--graph-advanced': o => {\n o.graphAdvanced = true;\n },\n '--flow': o => {\n o.flow = true;\n },\n '--all': o => {\n o.includeTests = true;\n o.semantic = true;\n },\n '--save-baseline': o => {\n o.saveBaseline = true;\n },\n};\n\nconst CORE_INT_FLAGS: Record\u003cstring, keyof AnalysisOptions> = {\n '--findings-limit': 'findingsLimit',\n '--deep-link-topn': 'deepLinkTopN',\n '--tree-depth': 'treeDepth',\n '--max-recs-per-category': 'maxRecsPerCategory',\n '--focus-depth': 'focusDepth',\n};\n\nconst THRESHOLD_INT_FLAGS: Record\u003cstring, keyof Thresholds> = {\n '--min-function-statements': 'minFunctionStatements',\n '--min-flow-statements': 'minFlowStatements',\n '--critical-complexity-threshold': 'criticalComplexityThreshold',\n '--coupling-threshold': 'couplingThreshold',\n '--fan-in-threshold': 'fanInThreshold',\n '--fan-out-threshold': 'fanOutThreshold',\n '--god-module-statements': 'godModuleStatements',\n '--god-module-exports': 'godModuleExports',\n '--god-function-statements': 'godFunctionStatements',\n '--god-function-mi-threshold': 'godFunctionMiThreshold',\n '--cognitive-complexity-threshold': 'cognitiveComplexityThreshold',\n '--barrel-symbol-threshold': 'barrelSymbolThreshold',\n '--parameter-threshold': 'parameterThreshold',\n '--halstead-effort-threshold': 'halsteadEffortThreshold',\n '--maintainability-index-threshold': 'maintainabilityIndexThreshold',\n '--any-threshold': 'anyThreshold',\n '--flow-dup-threshold': 'flowDupThreshold',\n '--override-chain-threshold': 'overrideChainThreshold',\n '--shotgun-threshold': 'shotgunThreshold',\n '--secret-min-length': 'secretMinLength',\n '--mock-threshold': 'mockThreshold',\n '--deep-nesting-threshold': 'deepNestingThreshold',\n '--multiple-return-threshold': 'multipleReturnThreshold',\n '--magic-string-min-occurrences': 'magicStringMinOccurrences',\n '--boolean-param-threshold': 'booleanParamThreshold',\n};\n\nconst THRESHOLD_FLOAT_FLAGS: Record\u003cstring, keyof Thresholds> = {\n '--secret-entropy-threshold': 'secretEntropyThreshold',\n '--similarity-threshold': 'similarityThreshold',\n '--sdp-min-delta': 'sdpMinDelta',\n '--sdp-max-source-instability': 'sdpMaxSourceInstability',\n};\n\nconst SPECIAL_FLAGS: Record\u003cstring, FlagHandler> = {\n '--parser': (opts, argv, i) => {\n const next = argv[i + 1];\n if (!['auto', 'typescript', 'tree-sitter'].includes(next)) {\n console.error(\n `Unsupported parser: ${next}. Use auto|typescript|tree-sitter`\n );\n process.exit(1);\n }\n opts.parser = next as AnalysisOptions['parser'];\n return i + 1;\n },\n '--root': (opts, argv, i) => {\n opts.root = path.resolve(argv[i + 1]);\n return i + 1;\n },\n '--out': (opts, argv, i) => {\n opts.out = argv[i + 1];\n return i + 1;\n },\n '--layer-order': (opts, argv, i) => {\n opts.thresholds.layerOrder = argv[i + 1].split(',').map(s => s.trim());\n return i + 1;\n },\n '--reporter': (opts, argv, i) => {\n const next = argv[i + 1];\n if (!['default', 'compact', 'github-actions'].includes(next)) {\n console.error(`Unsupported reporter: ${next}. Use default|compact|github-actions`);\n process.exit(1);\n }\n opts.reporter = next as AnalysisOptions['reporter'];\n return i + 1;\n },\n '--config': (opts, argv, i) => {\n opts.configFile = argv[i + 1];\n return i + 1;\n },\n '--help': () => {\n printHelp();\n return process.exit(0) as never;\n },\n '-h': () => {\n printHelp();\n return process.exit(0) as never;\n },\n};\n\nexport function parseArgs(argv: string[]): AnalysisOptions {\n const opts: AnalysisOptions = { ...DEFAULT_OPTS, thresholds: { ...DEFAULT_OPTS.thresholds } };\n let excludeSet: Set\u003cstring> | null = null;\n\n for (let i = 0; i \u003c argv.length; i++) {\n const arg = argv[i];\n\n if (BOOL_FLAGS[arg]) {\n BOOL_FLAGS[arg](opts, true);\n continue;\n }\n\n if (CORE_INT_FLAGS[arg]) {\n const key = CORE_INT_FLAGS[arg];\n setIntOpt(\n opts as unknown as Record\u003cstring, number>,\n key, argv[++i],\n DEFAULT_OPTS as unknown as Record\u003cstring, number>\n );\n continue;\n }\n\n if (THRESHOLD_INT_FLAGS[arg]) {\n const key = THRESHOLD_INT_FLAGS[arg];\n setIntOpt(\n opts.thresholds as unknown as Record\u003cstring, number>,\n key, argv[++i],\n DEFAULT_OPTS.thresholds as unknown as Record\u003cstring, number>\n );\n continue;\n }\n\n if (THRESHOLD_FLOAT_FLAGS[arg]) {\n const key = THRESHOLD_FLOAT_FLAGS[arg];\n setFloatOpt(\n opts.thresholds as unknown as Record\u003cstring, number>,\n key, argv[++i],\n DEFAULT_OPTS.thresholds as unknown as Record\u003cstring, number>\n );\n continue;\n }\n\n if (SPECIAL_FLAGS[arg]) {\n i = SPECIAL_FLAGS[arg](opts, argv, i);\n continue;\n }\n\n if (arg.startsWith('--out=')) {\n opts.out = arg.slice('--out='.length);\n continue;\n }\n\n if (arg === '--scope' || arg.startsWith('--scope=')) {\n const val = arg.startsWith('--scope=')\n ? arg.slice('--scope='.length)\n : argv[++i];\n const { paths, symbols } = parseScope(val, opts.root);\n opts.scope = paths;\n if (symbols.size > 0) opts.scopeSymbols = symbols;\n continue;\n }\n\n if (arg === '--features' || arg.startsWith('--features=')) {\n const val = arg.startsWith('--features=')\n ? arg.slice('--features='.length)\n : argv[++i];\n opts.features = resolveCategories(val, 'feature');\n continue;\n }\n\n if (arg === '--exclude' || arg.startsWith('--exclude=')) {\n const val = arg.startsWith('--exclude=')\n ? arg.slice('--exclude='.length)\n : argv[++i];\n excludeSet = resolveCategories(val, 'exclude');\n continue;\n }\n\n if (arg === '--affected' || arg.startsWith('--affected=')) {\n opts.affected = arg.startsWith('--affected=')\n ? arg.slice('--affected='.length)\n : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'HEAD');\n continue;\n }\n\n if (arg === '--ignore-known' || arg.startsWith('--ignore-known=')) {\n opts.ignoreKnown = arg.startsWith('--ignore-known=')\n ? arg.slice('--ignore-known='.length)\n : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : '.octocode/baseline.json');\n continue;\n }\n\n if (arg === '--focus' || arg.startsWith('--focus=')) {\n opts.focus = arg.startsWith('--focus=')\n ? arg.slice('--focus='.length)\n : argv[++i];\n continue;\n }\n\n if (arg === '--collapse' || arg.startsWith('--collapse=')) {\n const val = arg.startsWith('--collapse=')\n ? arg.slice('--collapse='.length)\n : argv[++i];\n opts.collapse = parseNumeric(val, 2);\n continue;\n }\n\n if (arg === '--at-least' || arg.startsWith('--at-least=')) {\n const val = arg.startsWith('--at-least=')\n ? arg.slice('--at-least='.length)\n : argv[++i];\n opts.atLeast = parseNumeric(val, 0);\n continue;\n }\n\n if (arg.startsWith('--')) {\n console.warn(`Warning: unknown flag \"${arg}\" — ignored.`);\n }\n }\n\n opts.packageRoot = path.join(opts.root, 'packages');\n\n if (opts.features !== null && excludeSet !== null) {\n throw new OptionsError(\n '--features and --exclude are mutually exclusive. Use one or the other.'\n );\n }\n if (excludeSet !== null) {\n opts.features = resolveExcludeToFeatures(excludeSet);\n }\n\n if (opts.features !== null) {\n const testQualityCats = new Set(PILLAR_CATEGORIES['test-quality']);\n if ([...opts.features].some(f => testQualityCats.has(f))) {\n opts.includeTests = true;\n }\n }\n\n return opts;\n}\n\nexport const HELP_TEXT = `\nUsage:\n node scripts/run.js [options]\n\nOptions:\n --root \u003cpath> Analyze a different repo root (default: cwd)\n --out \u003cpath> Output directory for split report files (timestamped dir by default).\n If path ends with .json, writes single monolithic file (legacy mode).\n --json Print report JSON to stdout\n --include-tests Include *.test* and *.spec* files\n --parser \u003cauto|typescript|tree-sitter>\n Parser engine for extra AST metadata (default: auto)\n --no-tree Do not write AST trees to report\n --emit-tree Force include tree blocks\n --graph Emit Mermaid dependency graph to .md file alongside JSON\n --graph-advanced Enable advanced graph overlays and additional architecture findings\n --flow Enable lightweight flow enrichment for evidence traces and cfgFlags\n --min-function-statements N Minimum function body statement count for duplicate matching (default 6)\n --min-flow-statements N Minimum control-flow statement count for duplicate matching (default 6)\n --critical-complexity-threshold N\n Complexity threshold for HIGH complexity findings and critical path weighting.\n --findings-limit N Cap findings in the report (default: no limit)\n --deep-link-topn N Max number of critical dependency paths to report (default 12)\n --tree-depth N AST tree depth when tree snapshots are emitted (default 4)\n --coupling-threshold N Ca+Ce threshold for high-coupling findings (default 15)\n --fan-in-threshold N Fan-in threshold for god-module-coupling (default 20)\n --fan-out-threshold N Fan-out threshold for god-module-coupling (default 15)\n --god-module-statements N Statement threshold for god-module findings (default 500)\n --god-module-exports N Export threshold for god-module findings (default 20)\n --god-function-statements N Statement threshold for god-function findings (default 100)\n --god-function-mi-threshold N MI threshold for god-function findings (default 10, fires when MI \u003c N and LOC > 30)\n --cognitive-complexity-threshold N\n Cognitive complexity threshold for findings (default 15)\n --barrel-symbol-threshold N Re-export count threshold for barrel-explosion (default 30)\n --layer-order \u003clayers> Comma-separated layer names for violation detection (e.g. ui,service,repository)\n --parameter-threshold N Max function parameters before flagging (default 5)\n --halstead-effort-threshold N Halstead effort threshold for findings (default 500000)\n --maintainability-index-threshold N\n MI below this triggers a finding (default 20, scale 0-100)\n --any-threshold N Max \\`any\\` type usages per file before flagging (default 5)\n --flow-dup-threshold N Min occurrences for a repeated flow to become a finding (default 3)\n --max-recs-per-category N Max findings per category in top recommendations (default 2)\n --scope=X,Y,Z Limit scan to specific paths, files, or functions. Comma-separated.\n Supports file:functionName to drill into a specific function.\n Examples: --scope=packages/octocode-mcp\n --scope=packages/octocode-mcp/src/tools\n --scope=packages/octocode-mcp/src/session.ts\n --scope=packages/octocode-mcp/src/session.ts:initSession\n --scope=packages/foo,packages/bar\n --features=X,Y,Z Run only selected features. Accepts pillar names (architecture,\n code-quality, dead-code, security, test-quality) or individual\n category names. Comma-separated.\n Examples: --features=architecture\n --features=dead-code,cognitive-complexity\n --features=dependency-cycle,dead-export\n --exclude=X,Y,Z Run everything EXCEPT the given pillars or categories. Mutually\n exclusive with --features. Same pillar/category names as --features.\n Examples: --exclude=architecture\n --exclude=dead-export,unsafe-any\n --semantic Enable semantic analysis phase (TypeChecker + LanguageService).\n Adds 12 categories: over-abstraction, concrete-dependency,\n circular-type-dependency, unused-parameter,\n deep-override-chain, interface-compliance, unused-import,\n orphan-implementation, shotgun-surgery, move-to-caller,\n narrowable-type, semantic-dead-export.\n --override-chain-threshold N Max method override depth before flagging (default 3, requires --semantic)\n --shotgun-threshold N Unique-file threshold for shotgun-surgery (default 8, requires --semantic)\n --sdp-min-delta N Min instability delta for SDP violations (default 0.15)\n --sdp-max-source-instability N Max source instability to report SDP (default 0.6)\n --secret-entropy-threshold N Shannon entropy threshold for secret detection (default 4.5)\n --secret-min-length N Min string length for entropy-based secret detection (default 20)\n --similarity-threshold N Jaccard similarity threshold for near-clone detection (default 0.85)\n --deep-nesting-threshold N Max branch/loop nesting depth before flagging (default 5)\n --multiple-return-threshold N Max return/throw paths per function before flagging (default 6)\n --magic-string-min-occurrences N\n Min repeated string comparisons to flag as magic string (default 3)\n --boolean-param-threshold N Min boolean params per function to flag as cluster (default 3)\n --mock-threshold N Max mock/spy calls per test file (default 10)\n --no-diversify Disable category-aware diversification when truncating findings.\n By default, --findings-limit interleaves categories so the\n truncated list is diverse. Use this to get pure severity ordering.\n --no-cache Disable incremental cache; re-parse all files\n --clear-cache Delete the analysis cache and exit (no scan)\n --all Enable all features: --include-tests --semantic\n\n --affected [revision] Scope to files changed since git revision (default: HEAD) plus\n their transitive dependents. Like dep-cruiser's --affected flag.\n Examples: --affected\n --affected HEAD~3\n --affected main\n --save-baseline Save current findings to .octocode/baseline.json for future\n comparison. Use with --ignore-known for progressive adoption.\n --ignore-known [file] Suppress findings matching a baseline file (default:\n .octocode/baseline.json). Findings are matched by (category, file).\n --reporter \u003cformat> Output format: default|compact|github-actions (default: default)\n compact: one-line per finding for terminal/CI logs\n github-actions: ::warning annotations for GitHub Actions\n --focus \u003cmodule> Show only this module and its neighbors in the dependency graph.\n Requires --graph. Use with --focus-depth to control neighbor hops.\n Examples: --focus src/session.ts\n --focus=src/session.ts\n --focus packages/octocode-mcp/src/tools\n --focus-depth N Neighbor depth for --focus (default 1). 2 = friends-of-friends.\n --collapse N Collapse graph nodes to folder depth N. Reduces large graphs to\n high-level architecture view. Example: --collapse 2\n --at-least N Fail (exit 1) if health score drops below N (0-100). Use in CI\n to enforce a quality floor. Example: --at-least 60\n --config \u003cfile> Path to config file. Also auto-discovers .octocode-scan.json,\n .octocode-scan.jsonc, or package.json#octocode in the project root.\n --help Show this message\n`;\n\nexport function printHelp(): void {\n console.log(HELP_TEXT);\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":19931,"content_sha256":"eda5600e69670a22d572b9c16c5c256a56b86e7b8198198a7e36aa1d21ac706a"},{"filename":"src/pipeline/config-loader.test.ts","content":"import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport { afterEach, describe, expect, it } from 'vitest';\n\nimport { loadConfigFile, mergeConfigIntoDefaults } from './config-loader.js';\nimport { DEFAULT_OPTS } from '../types/index.js';\n\ndescribe('config-loader', () => {\n const tmpDirs: string[] = [];\n\n function makeTmpDir(): string {\n const d = fs.mkdtempSync(path.join(os.tmpdir(), 'config-test-'));\n tmpDirs.push(d);\n return d;\n }\n\n afterEach(() => {\n for (const d of tmpDirs) {\n fs.rmSync(d, { recursive: true, force: true });\n }\n tmpDirs.length = 0;\n });\n\n describe('loadConfigFile', () => {\n it('loads explicit config file', () => {\n const root = makeTmpDir();\n const cfgPath = path.join(root, 'my-config.json');\n fs.writeFileSync(cfgPath, JSON.stringify({ graph: true, semantic: true }));\n\n const config = loadConfigFile(root, cfgPath);\n expect(config).toBeDefined();\n expect(config!.graph).toBe(true);\n expect(config!.semantic).toBe(true);\n });\n\n it('auto-discovers .octocode-scan.json', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.json'),\n JSON.stringify({ flow: true })\n );\n\n const config = loadConfigFile(root, null);\n expect(config).toBeDefined();\n expect(config!.flow).toBe(true);\n });\n\n it('auto-discovers .octocode-scan.jsonc', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.jsonc'),\n '// comment\\n{ \"graph\": true }\\n'\n );\n\n const config = loadConfigFile(root, null);\n expect(config).toBeDefined();\n expect(config!.graph).toBe(true);\n });\n\n it('reads from package.json#octocode', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, 'package.json'),\n JSON.stringify({ name: 'test', octocode: { includeTests: true } })\n );\n\n const config = loadConfigFile(root, null);\n expect(config).toBeDefined();\n expect(config!.includeTests).toBe(true);\n });\n\n it('returns null when no config found', () => {\n const root = makeTmpDir();\n expect(loadConfigFile(root, null)).toBeNull();\n });\n\n it('converts kebab-case keys to camelCase', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.json'),\n JSON.stringify({ 'include-tests': true, 'graph-advanced': true })\n );\n\n const config = loadConfigFile(root, null);\n expect(config!.includeTests).toBe(true);\n expect(config!.graphAdvanced).toBe(true);\n });\n\n it('prefers .octocode-scan.json over package.json', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.json'),\n JSON.stringify({ graph: true })\n );\n fs.writeFileSync(\n path.join(root, 'package.json'),\n JSON.stringify({ name: 'x', octocode: { graph: false, flow: true } })\n );\n\n const config = loadConfigFile(root, null);\n expect(config!.graph).toBe(true);\n expect(config!.flow).toBeUndefined();\n });\n\n it('converts features string to Set', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.json'),\n JSON.stringify({ features: 'architecture, dead-code' })\n );\n\n const config = loadConfigFile(root, null);\n expect(config!.features).toBeInstanceOf(Set);\n expect((config!.features as Set\u003cstring>).has('architecture')).toBe(true);\n expect((config!.features as Set\u003cstring>).has('dead-code')).toBe(true);\n });\n\n it('converts scope string to array', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.json'),\n JSON.stringify({ scope: 'packages/foo,packages/bar' })\n );\n\n const config = loadConfigFile(root, null);\n expect(config!.scope).toEqual(['packages/foo', 'packages/bar']);\n });\n\n it('converts ignoreDirs array to Set', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.json'),\n JSON.stringify({ 'ignore-dirs': ['vendor', 'generated'] })\n );\n\n const config = loadConfigFile(root, null);\n const dirs = config!.ignoreDirs as Set\u003cstring>;\n expect(dirs).toBeInstanceOf(Set);\n expect(dirs.has('vendor')).toBe(true);\n expect(dirs.has('generated')).toBe(true);\n });\n\n it('returns null for invalid JSON', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.json'),\n 'not { valid json !!!'\n );\n expect(loadConfigFile(root, null)).toBeNull();\n });\n\n it('resolves relative explicit path against root', () => {\n const root = makeTmpDir();\n const cfgPath = path.join(root, 'configs', 'scan.json');\n fs.mkdirSync(path.dirname(cfgPath), { recursive: true });\n fs.writeFileSync(cfgPath, JSON.stringify({ semantic: true }));\n\n const config = loadConfigFile(root, 'configs/scan.json');\n expect(config).toBeDefined();\n expect(config!.semantic).toBe(true);\n });\n\n it('strips single-line comments in JSONC', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.json'),\n `{\n // Enable graph\n \"graph\": true,\n \"flow\": false // Not yet\n}`\n );\n const config = loadConfigFile(root, null);\n expect(config!.graph).toBe(true);\n expect(config!.flow).toBe(false);\n });\n\n it('strips block comments in JSONC', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.json'),\n `{\n /* This enables the semantic phase */\n \"semantic\": true\n}`\n );\n const config = loadConfigFile(root, null);\n expect(config!.semantic).toBe(true);\n });\n\n it('passes through threshold objects', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, '.octocode-scan.json'),\n JSON.stringify({ thresholds: { minFunctionStatements: 10 } })\n );\n\n const config = loadConfigFile(root, null);\n const t = config!.thresholds as Record\u003cstring, number>;\n expect(t.minFunctionStatements).toBe(10);\n });\n\n it('ignores package.json without octocode key', () => {\n const root = makeTmpDir();\n fs.writeFileSync(\n path.join(root, 'package.json'),\n JSON.stringify({ name: 'test', version: '1.0.0' })\n );\n expect(loadConfigFile(root, null)).toBeNull();\n });\n });\n\n describe('mergeConfigIntoDefaults', () => {\n it('config values override defaults', () => {\n const config = { graph: true, semantic: true };\n const cliArgs = { ...DEFAULT_OPTS };\n\n const result = mergeConfigIntoDefaults(DEFAULT_OPTS, config, cliArgs);\n expect(result.graph).toBe(true);\n expect(result.semantic).toBe(true);\n });\n\n it('CLI args override config when they differ from defaults', () => {\n const config = { graph: true, semantic: true };\n const cliArgs = { ...DEFAULT_OPTS, findingsLimit: 50 };\n\n const result = mergeConfigIntoDefaults(DEFAULT_OPTS, config, cliArgs);\n expect(result.graph).toBe(true);\n expect(result.semantic).toBe(true);\n expect(result.findingsLimit).toBe(50);\n });\n\n it('merges threshold overrides', () => {\n const config = { thresholds: { minFunctionStatements: 10 } };\n const cliArgs = { ...DEFAULT_OPTS };\n\n const result = mergeConfigIntoDefaults(DEFAULT_OPTS, config, cliArgs);\n expect(result.thresholds.minFunctionStatements).toBe(10);\n expect(result.thresholds.minFlowStatements).toBe(\n DEFAULT_OPTS.thresholds.minFlowStatements\n );\n });\n\n it('preserves defaults when config and CLI are empty', () => {\n const result = mergeConfigIntoDefaults(\n DEFAULT_OPTS,\n {},\n { ...DEFAULT_OPTS }\n );\n expect(result).toEqual(DEFAULT_OPTS);\n });\n\n it('config does not override root or packageRoot', () => {\n const config = { json: true };\n const cliArgs = { ...DEFAULT_OPTS };\n\n const result = mergeConfigIntoDefaults(DEFAULT_OPTS, config, cliArgs);\n expect(result.root).toBe(DEFAULT_OPTS.root);\n expect(result.json).toBe(true);\n });\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":8377,"content_sha256":"0074175de4cd03b3efc1edf115108fdafed3b5a7fe3fda8909851d400475c073"},{"filename":"src/pipeline/config-loader.ts","content":"import fs from 'node:fs';\nimport path from 'node:path';\n\nimport type { AnalysisOptions } from '../types/index.js';\n\ntype ConfigOverrides = Partial\u003cOmit\u003cAnalysisOptions, 'root' | 'packageRoot' | 'clearCache'>>;\n\nconst CONFIG_NAMES = ['.octocode-scan.json', '.octocode-scan.jsonc'];\n\n/**\n * Loads config from file, auto-discovered config, or package.json#octocode.\n * CLI flags always win over config file values.\n * Inspired by knip's .knip.json and eslint's flat config.\n */\nexport function loadConfigFile(\n root: string,\n explicitPath: string | null\n): ConfigOverrides | null {\n if (explicitPath) {\n const abs = path.isAbsolute(explicitPath)\n ? explicitPath\n : path.resolve(root, explicitPath);\n return readJsonConfig(abs);\n }\n\n for (const name of CONFIG_NAMES) {\n const candidate = path.join(root, name);\n if (fs.existsSync(candidate)) {\n return readJsonConfig(candidate);\n }\n }\n\n const pkgJsonPath = path.join(root, 'package.json');\n if (fs.existsSync(pkgJsonPath)) {\n try {\n const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));\n if (pkg.octocode && typeof pkg.octocode === 'object') {\n return normalizeConfig(pkg.octocode);\n }\n } catch { /* skip */ }\n }\n\n return null;\n}\n\nfunction readJsonConfig(filePath: string): ConfigOverrides | null {\n try {\n let raw = fs.readFileSync(filePath, 'utf8');\n raw = raw.replace(/\\/\\/.*$/gm, '').replace(/\\/\\*[\\s\\S]*?\\*\\//g, '');\n return normalizeConfig(JSON.parse(raw));\n } catch {\n return null;\n }\n}\n\nfunction normalizeConfig(obj: Record\u003cstring, unknown>): ConfigOverrides {\n const result: Record\u003cstring, unknown> = {};\n\n for (const [key, value] of Object.entries(obj)) {\n const camelKey = key.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());\n\n if (camelKey === 'features' && typeof value === 'string') {\n result[camelKey] = new Set(value.split(',').map(s => s.trim()));\n } else if (camelKey === 'scope' && typeof value === 'string') {\n result[camelKey] = value.split(',').map(s => s.trim());\n } else if (camelKey === 'ignoreDirs' && Array.isArray(value)) {\n result[camelKey] = new Set(value as string[]);\n } else if (camelKey === 'thresholds' && typeof value === 'object' && value !== null) {\n result[camelKey] = value;\n } else {\n result[camelKey] = value;\n }\n }\n\n return result as ConfigOverrides;\n}\n\n/**\n * Merges config file overrides into defaults, then CLI overrides on top.\n * CLI args that differ from defaults always win.\n */\nexport function mergeConfigIntoDefaults(\n defaults: AnalysisOptions,\n config: ConfigOverrides,\n cliArgs: AnalysisOptions\n): AnalysisOptions {\n const merged = { ...defaults };\n\n for (const [key, value] of Object.entries(config)) {\n if (key === 'thresholds' && typeof value === 'object' && value !== null) {\n merged.thresholds = {\n ...merged.thresholds,\n ...(value as unknown as Record\u003cstring, number>),\n };\n } else {\n (merged as Record\u003cstring, unknown>)[key] = value;\n }\n }\n\n for (const key of Object.keys(cliArgs)) {\n const cliVal = (cliArgs as unknown as Record\u003cstring, unknown>)[key];\n const defVal = (defaults as unknown as Record\u003cstring, unknown>)[key];\n if (cliVal !== defVal) {\n (merged as unknown as Record\u003cstring, unknown>)[key] = cliVal;\n }\n }\n\n return merged;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":3360,"content_sha256":"1d3a2a13ff7ff7e40d4a375b08e562d76459f50b85ff04373e85851f1d535615"},{"filename":"src/pipeline/create-options.ts","content":"import path from 'node:path';\n\nimport { ALL_CATEGORIES, PILLAR_CATEGORIES } from '../types/index.js';\n\nimport type { AnalysisOptions } from '../types/index.js';\n\nexport interface CreateOptionsInput {\n args: AnalysisOptions;\n}\n\n/**\n * Transforms raw parsed CLI args into validated, normalized runtime options.\n * This is Layer 2 in the 3-layer CLI pattern (args → options → engine).\n *\n * Responsible for:\n * - Deriving computed fields (packageRoot)\n * - Auto-enabling flags based on feature selection (test-quality → includeTests)\n *\n * Inspired by knip's create-options.ts, eslint's translate-cli-options.js,\n * and dependency-cruiser's normalize-cli-options.mjs.\n */\nexport function createOptions({ args }: CreateOptionsInput): AnalysisOptions {\n const opts = { ...args };\n\n opts.packageRoot = path.join(opts.root, 'packages');\n autoEnableTestQuality(opts);\n\n return opts;\n}\n\nfunction autoEnableTestQuality(opts: AnalysisOptions): void {\n if (opts.features === null) return;\n\n const testQualityCats = new Set(PILLAR_CATEGORIES['test-quality']);\n if ([...opts.features].some(f => testQualityCats.has(f))) {\n opts.includeTests = true;\n }\n}\n\n/**\n * Resolves `--exclude` into a features set by subtracting from ALL_CATEGORIES.\n * Called during CLI arg parsing when --exclude is used.\n */\nexport function resolveExcludeToFeatures(\n excludeSet: Set\u003cstring>\n): Set\u003cstring> {\n return new Set([...ALL_CATEGORIES].filter(c => !excludeSet.has(c)));\n}\n\nexport class OptionsError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'OptionsError';\n }\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":1596,"content_sha256":"26df8a94a9f392dcf99c1fe4471c9dec52d126a6df7b0a8177c4d66f454ac54b"},{"filename":"src/pipeline/health-score.test.ts","content":"import { describe, expect, it } from 'vitest';\n\nimport { computeGateScore } from './main.js';\n\ndescribe('computeGateScore', () => {\n it('returns 100 for 0 findings', () => {\n expect(computeGateScore(0, 100)).toBe(100);\n });\n\n it('returns 100 for 0 findings and 0 files', () => {\n expect(computeGateScore(0, 0)).toBe(100);\n });\n\n it('decreases as findings increase', () => {\n const score10 = computeGateScore(10, 100);\n const score50 = computeGateScore(50, 100);\n const score200 = computeGateScore(200, 100);\n\n expect(score10).toBeGreaterThan(score50);\n expect(score50).toBeGreaterThan(score200);\n });\n\n it('returns higher score for same findings with more files', () => {\n const smallProject = computeGateScore(50, 10);\n const bigProject = computeGateScore(50, 1000);\n\n expect(bigProject).toBeGreaterThan(smallProject);\n });\n\n it('is always between 0 and 100', () => {\n for (const [f, t] of [\n [0, 1],\n [1, 1],\n [100, 10],\n [1000, 50],\n [10000, 100],\n ]) {\n const score = computeGateScore(f, t);\n expect(score).toBeGreaterThanOrEqual(0);\n expect(score).toBeLessThanOrEqual(100);\n }\n });\n\n it('returns reasonable score for typical project (10 findings / 100 files)', () => {\n const score = computeGateScore(10, 100);\n expect(score).toBeGreaterThan(90);\n });\n\n it('returns low score for finding-heavy project (500 findings / 50 files)', () => {\n const score = computeGateScore(500, 50);\n expect(score).toBeLessThanOrEqual(50);\n });\n\n it('handles totalFiles=0 without division by zero', () => {\n expect(() => computeGateScore(10, 0)).not.toThrow();\n const score = computeGateScore(10, 0);\n expect(score).toBeGreaterThanOrEqual(0);\n expect(score).toBeLessThanOrEqual(100);\n });\n\n it('returns integer values', () => {\n const score = computeGateScore(17, 33);\n expect(Number.isInteger(score)).toBe(true);\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":1935,"content_sha256":"b8d1c63a15a5aa5eb818dc297a84996f8ec325b55e5711dc80c680887f9c15be"},{"filename":"src/pipeline/main.test.ts","content":"import fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport { afterEach, describe, expect, it, vi } from 'vitest';\n\nimport * as cache from './cache.js';\nimport * as cli from './cli.js';\nimport { main } from './main.js';\nimport * as discovery from '../analysis/discovery.js';\nimport { DEFAULT_OPTS } from '../types/index.js';\n\nfunction makeOptions(overrides: Partial\u003ctypeof DEFAULT_OPTS> = {}) {\n return {\n ...DEFAULT_OPTS,\n ...overrides,\n };\n}\n\nfunction createFixtureProject(): string {\n const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'oq-pipeline-scope-'));\n fs.writeFileSync(\n path.join(tmp, 'package.json'),\n JSON.stringify({ name: 'fixture', version: '1.0.0' }),\n 'utf8'\n );\n const srcDir = path.join(tmp, 'src');\n fs.mkdirSync(srcDir, { recursive: true });\n fs.writeFileSync(\n path.join(srcDir, 'lib.ts'),\n [\n 'export function greet(name: string): string {',\n ' return `Hello, ${name}!`;',\n '}',\n '',\n 'export function farewell(name: string): string {',\n ' return `Goodbye, ${name}!`;',\n '}',\n ].join('\\n'),\n 'utf8'\n );\n return tmp;\n}\n\ndescribe('pipeline main', () => {\n afterEach(() => {\n vi.restoreAllMocks();\n });\n\n it('clears cache and returns early when clearCache is enabled', async () => {\n const opts = makeOptions({ clearCache: true, root: '/tmp/repo' });\n const parseSpy = vi.spyOn(cli, 'parseArgs').mockReturnValue(opts);\n const clearSpy = vi.spyOn(cache, 'clearCache').mockImplementation(() => {});\n const listSpy = vi.spyOn(discovery, 'listWorkspacePackages');\n const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});\n\n await main();\n\n expect(parseSpy).toHaveBeenCalledTimes(1);\n expect(clearSpy).toHaveBeenCalledWith('/tmp/repo');\n expect(errSpy).toHaveBeenCalledWith('Cache cleared.');\n expect(listSpy).not.toHaveBeenCalled();\n });\n\n it('exits when no packages and no root package.json exist', async () => {\n const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'oq-pipeline-empty-'));\n const opts = makeOptions({\n clearCache: false,\n root: tmp,\n packageRoot: path.join(tmp, 'packages'),\n });\n\n vi.spyOn(cli, 'parseArgs').mockReturnValue(opts);\n vi.spyOn(discovery, 'listWorkspacePackages').mockReturnValue([]);\n vi.spyOn(console, 'error').mockImplementation(() => {});\n\n const exitErr = new Error('exit-1');\n vi.spyOn(process, 'exit').mockImplementation(((\n code?: string | number | null\n ) => {\n throw code === 1 ? exitErr : new Error(`unexpected-exit-${code}`);\n }) as never);\n\n await expect(main()).rejects.toBe(exitErr);\n });\n\n it('exits when fallback root package.json is unreadable', async () => {\n const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'oq-pipeline-badjson-'));\n fs.writeFileSync(path.join(tmp, 'package.json'), '{invalid-json', 'utf8');\n const opts = makeOptions({\n clearCache: false,\n root: tmp,\n packageRoot: path.join(tmp, 'packages'),\n });\n\n vi.spyOn(cli, 'parseArgs').mockReturnValue(opts);\n vi.spyOn(discovery, 'listWorkspacePackages').mockReturnValue([]);\n vi.spyOn(console, 'error').mockImplementation(() => {});\n\n const exitErr = new Error('exit-1');\n vi.spyOn(process, 'exit').mockImplementation(((\n code?: string | number | null\n ) => {\n throw code === 1 ? exitErr : new Error(`unexpected-exit-${code}`);\n }) as never);\n\n await expect(main()).rejects.toBe(exitErr);\n });\n});\n\ndescribe('pipeline scope symbol resolution', () => {\n afterEach(() => {\n vi.restoreAllMocks();\n });\n\n it('warns when --scope=file:symbol cannot resolve the symbol', async () => {\n const tmp = createFixtureProject();\n const absLib = path.join(tmp, 'src', 'lib.ts');\n const scopeSymbols = new Map\u003cstring, string[]>();\n scopeSymbols.set(absLib, ['nonExistentFunction']);\n\n const opts = makeOptions({\n root: tmp,\n packageRoot: path.join(tmp, 'packages'),\n scope: [absLib],\n scopeSymbols,\n json: false,\n noCache: true,\n emitTree: false,\n });\n\n vi.spyOn(cli, 'parseArgs').mockReturnValue(opts);\n vi.spyOn(console, 'log').mockImplementation(() => {});\n vi.spyOn(console, 'error').mockImplementation(() => {});\n const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\n await main();\n\n expect(warnSpy).toHaveBeenCalledWith(\n expect.stringContaining('symbol scope could not resolve')\n );\n expect(warnSpy).toHaveBeenCalledWith(\n expect.stringContaining('nonExistentFunction')\n );\n });\n\n it('does not warn when --scope=file:symbol resolves successfully', async () => {\n const tmp = createFixtureProject();\n const absLib = path.join(tmp, 'src', 'lib.ts');\n const scopeSymbols = new Map\u003cstring, string[]>();\n scopeSymbols.set(absLib, ['greet']);\n\n const opts = makeOptions({\n root: tmp,\n packageRoot: path.join(tmp, 'packages'),\n scope: [absLib],\n scopeSymbols,\n json: false,\n noCache: true,\n emitTree: false,\n });\n\n vi.spyOn(cli, 'parseArgs').mockReturnValue(opts);\n vi.spyOn(console, 'log').mockImplementation(() => {});\n vi.spyOn(console, 'error').mockImplementation(() => {});\n const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n\n await main();\n\n expect(warnSpy).not.toHaveBeenCalledWith(\n expect.stringContaining('symbol scope could not resolve')\n );\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":5475,"content_sha256":"033dd7c9013adc2afbc3cac9d72cbb6f604c34ebd475cea638abdb95dd9d715b"},{"filename":"src/pipeline/progress.ts","content":"import { EventEmitter } from 'node:events';\n\nexport type ProgressPhase =\n | 'startup'\n | 'cache-check'\n | 'discovery'\n | 'parse'\n | 'dependencies'\n | 'semantic'\n | 'detect'\n | 'graph'\n | 'report'\n | 'write'\n | 'done';\n\nexport interface ProgressEvent {\n phase: ProgressPhase;\n message: string;\n progress?: number;\n detail?: string;\n}\n\nclass ScanBus extends EventEmitter {\n progress(phase: ProgressPhase, message: string, detail?: string): void {\n this.emit('progress', { phase, message, detail } satisfies ProgressEvent);\n }\n\n summary(message: string): void {\n this.emit('summary', message);\n }\n\n error(message: string, detail?: string): void {\n this.emit('error', { message, detail });\n }\n\n reset(): void {\n this.removeAllListeners();\n }\n}\n\nexport const bus = new ScanBus();\n\nlet consoleFeedbackAttached = false;\n\nexport function attachConsoleFeedback(): void {\n if (consoleFeedbackAttached) return;\n consoleFeedbackAttached = true;\n bus.on('progress', (event: ProgressEvent) => {\n if (event.detail) {\n process.stderr.write(`[${event.phase}] ${event.message}: ${event.detail}\\n`);\n } else {\n process.stderr.write(`[${event.phase}] ${event.message}\\n`);\n }\n });\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":1221,"content_sha256":"d0a2ffb85cc75a74f51311407e7f92361da6df584719e93adaad3da30b6e395d"},{"filename":"src/pipeline/reporters.test.ts","content":"import { describe, expect, it } from 'vitest';\n\nimport { formatFindings } from './reporters.js';\nimport type { Finding } from '../types/index.js';\n\nfunction makeFinding(overrides: Partial\u003cFinding>): Finding {\n return {\n id: 'f1',\n severity: 'high',\n category: 'dead-export',\n file: '/repo/src/a.ts',\n lineStart: 10,\n lineEnd: 15,\n title: 'Unused export foo',\n reason: 'No consumers',\n files: ['/repo/src/a.ts'],\n suggestedFix: { strategy: 'remove', steps: ['Delete'] },\n ...overrides,\n };\n}\n\ndescribe('formatFindings', () => {\n describe('compact reporter', () => {\n it('formats one-line per finding with line number', () => {\n const findings = [\n makeFinding({\n severity: 'high',\n file: '/repo/src/a.ts',\n lineStart: 10,\n category: 'dead-export',\n title: 'Unused foo',\n }),\n ];\n\n const result = formatFindings(findings, 'compact', '/repo');\n expect(result).toBe('high:src/a.ts:10 - [dead-export] Unused foo');\n });\n\n it('omits line number when lineStart is 0', () => {\n const findings = [\n makeFinding({\n severity: 'low',\n file: '/repo/src/b.ts',\n lineStart: 0,\n category: 'god-function',\n title: 'Big func',\n }),\n ];\n\n const result = formatFindings(findings, 'compact', '/repo');\n expect(result).toBe('low:src/b.ts - [god-function] Big func');\n });\n\n it('handles empty findings list', () => {\n expect(formatFindings([], 'compact', '/repo')).toBe('');\n });\n\n it('formats multiple findings separated by newlines', () => {\n const findings = [\n makeFinding({ severity: 'critical', title: 'A' }),\n makeFinding({ severity: 'low', title: 'B' }),\n ];\n const lines = formatFindings(findings, 'compact', '/repo').split('\\n');\n expect(lines).toHaveLength(2);\n });\n\n it('handles file paths not under root', () => {\n const findings = [\n makeFinding({ file: '/other/place/x.ts', lineStart: 5, title: 'Out' }),\n ];\n const result = formatFindings(findings, 'compact', '/repo');\n expect(result).toContain('/other/place/x.ts:5');\n });\n });\n\n describe('github-actions reporter', () => {\n it('maps critical severity to ::error', () => {\n const findings = [\n makeFinding({ severity: 'critical', lineStart: 5, title: 'Leak' }),\n ];\n const result = formatFindings(findings, 'github-actions', '/repo');\n expect(result.startsWith('::error ')).toBe(true);\n });\n\n it('maps high severity to ::error', () => {\n const findings = [\n makeFinding({ severity: 'high', lineStart: 5, title: 'Bad' }),\n ];\n const result = formatFindings(findings, 'github-actions', '/repo');\n expect(result.startsWith('::error ')).toBe(true);\n });\n\n it('maps medium severity to ::warning', () => {\n const findings = [\n makeFinding({ severity: 'medium', lineStart: 5, title: 'Warn' }),\n ];\n const result = formatFindings(findings, 'github-actions', '/repo');\n expect(result.startsWith('::warning ')).toBe(true);\n });\n\n it('maps low severity to ::notice', () => {\n const findings = [\n makeFinding({ severity: 'low', lineStart: 5, title: 'Info' }),\n ];\n const result = formatFindings(findings, 'github-actions', '/repo');\n expect(result.startsWith('::notice ')).toBe(true);\n });\n\n it('maps info severity to ::warning (default)', () => {\n const findings = [\n makeFinding({ severity: 'info', lineStart: 5, title: 'Note' }),\n ];\n const result = formatFindings(findings, 'github-actions', '/repo');\n expect(result.startsWith('::warning ')).toBe(true);\n });\n\n it('includes file and line in annotation', () => {\n const findings = [\n makeFinding({\n severity: 'high',\n file: '/repo/src/a.ts',\n lineStart: 42,\n title: 'Found it',\n category: 'unsafe-any',\n }),\n ];\n const result = formatFindings(findings, 'github-actions', '/repo');\n expect(result).toBe(\n '::error file=src/a.ts,line=42::Found it [unsafe-any]'\n );\n });\n\n it('defaults to line 1 when lineStart is 0', () => {\n const findings = [\n makeFinding({ severity: 'medium', lineStart: 0, title: 'X' }),\n ];\n const result = formatFindings(findings, 'github-actions', '/repo');\n expect(result).toContain('line=1');\n });\n\n it('handles empty findings list', () => {\n expect(formatFindings([], 'github-actions', '/repo')).toBe('');\n });\n });\n\n describe('default reporter', () => {\n it('returns empty string for default format', () => {\n expect(formatFindings([], 'default', '/repo')).toBe('');\n });\n\n it('returns empty string even with findings', () => {\n expect(formatFindings([makeFinding({})], 'default', '/repo')).toBe('');\n });\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":4951,"content_sha256":"c8d309f6d2bbbf69e8b815011274ec9f37f151fbcfa2c8157c253db78139966c"},{"filename":"src/pipeline/reporters.ts","content":"import type { Finding, ReporterFormat } from '../types/index.js';\n\n/**\n * Formats findings for alternative output targets.\n * Inspired by eslint's --format flag and dependency-cruiser's reporter plugins.\n */\n\nexport function formatFindings(\n findings: Finding[],\n format: ReporterFormat,\n root: string\n): string {\n switch (format) {\n case 'compact':\n return formatCompact(findings, root);\n case 'github-actions':\n return formatGitHubActions(findings, root);\n default:\n return '';\n }\n}\n\nfunction relativePath(file: string, root: string): string {\n return file.startsWith(root) ? file.slice(root.length + 1) : file;\n}\n\nfunction severityToLevel(severity: string): string {\n switch (severity) {\n case 'critical': return 'error';\n case 'high': return 'error';\n case 'medium': return 'warning';\n case 'low': return 'notice';\n default: return 'warning';\n }\n}\n\n/**\n * Compact: one-line per finding for terminal/CI logs.\n * Format: severity:file:line - [category] title\n */\nfunction formatCompact(findings: Finding[], root: string): string {\n return findings\n .map(f => {\n const file = relativePath(f.file, root);\n const loc = f.lineStart ? `${file}:${f.lineStart}` : file;\n return `${f.severity}:${loc} - [${f.category}] ${f.title}`;\n })\n .join('\\n');\n}\n\n/**\n * GitHub Actions: ::warning / ::error annotations.\n * These appear inline on PR diffs.\n */\nfunction formatGitHubActions(findings: Finding[], root: string): string {\n return findings\n .map(f => {\n const file = relativePath(f.file, root);\n const line = f.lineStart || 1;\n const level = severityToLevel(f.severity);\n return `::${level} file=${file},line=${line}::${f.title} [${f.category}]`;\n })\n .join('\\n');\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":1763,"content_sha256":"169deee1bce7af707f07b597e310e64df501b4960b53766d28df397c823bf857"},{"filename":"src/reporting/analysis.test.ts","content":"import { describe, expect, it } from 'vitest';\n\nimport {\n computeReportAnalysisSummary,\n enrichFileInventoryEntries,\n enrichFindings,\n} from './analysis.js';\n\nimport type { GraphAnalyticsSummary } from '../analysis/graph-analytics.js';\nimport type { FileEntry, Finding } from '../types/index.js';\n\nfunction makeFileEntry(override: Partial\u003cFileEntry> = {}): FileEntry {\n return {\n package: 'pkg',\n file: 'src/file.ts',\n parseEngine: 'typescript',\n nodeCount: 1,\n kindCounts: {},\n functions: [],\n flows: [],\n dependencyProfile: {\n internalDependencies: [],\n externalDependencies: [],\n unresolvedDependencies: [],\n declaredExports: [],\n importedSymbols: [],\n reExports: [],\n },\n ...override,\n };\n}\n\nfunction makeFinding(override: Partial\u003cFinding> = {}): Finding {\n return {\n id: 'test-1',\n severity: 'medium',\n category: 'function-optimization',\n file: 'src/file.ts',\n lineStart: 10,\n lineEnd: 20,\n title: 'Test finding',\n reason: 'Test reason',\n files: ['src/file.ts'],\n suggestedFix: { strategy: 'strategy', steps: ['step1'] },\n ...override,\n };\n}\n\nfunction makeGraphAnalytics(\n override: Partial\u003cGraphAnalyticsSummary> = {}\n): GraphAnalyticsSummary {\n return {\n sccClusters: [],\n chokepoints: [],\n packageGraphSummary: {\n packageCount: 0,\n edgeCount: 0,\n packages: [],\n hotspots: [],\n },\n articulationPoints: [],\n bridgeEdges: [],\n ...override,\n };\n}\n\ndescribe('enrichFileInventoryEntries', () => {\n it('adds effectProfile when topLevelEffects present', () => {\n const entry = makeFileEntry({\n topLevelEffects: [\n {\n kind: 'eval',\n lineStart: 1,\n lineEnd: 1,\n detail: 'eval call',\n weight: 10,\n confidence: 'high',\n },\n ],\n });\n const [enriched] = enrichFileInventoryEntries([entry]);\n expect(enriched.effectProfile).toBeDefined();\n expect(enriched.effectProfile!.totalEffects).toBe(1);\n expect(enriched.effectProfile!.highestRisk).toBe('eval');\n });\n\n it('adds symbolUsageSummary with declaredExportCount and importedSymbolCount', () => {\n const entry = makeFileEntry({\n dependencyProfile: {\n internalDependencies: [],\n externalDependencies: ['lodash'],\n unresolvedDependencies: [],\n declaredExports: [\n { name: 'foo', kind: 'value' },\n { name: 'bar', kind: 'value' },\n ],\n importedSymbols: [\n {\n sourceModule: 'lodash',\n importedName: 'get',\n localName: 'get',\n isTypeOnly: false,\n },\n ],\n reExports: [],\n },\n });\n const [enriched] = enrichFileInventoryEntries([entry]);\n expect(enriched.symbolUsageSummary).toBeDefined();\n expect(enriched.symbolUsageSummary!.declaredExportCount).toBe(2);\n expect(enriched.symbolUsageSummary!.importedSymbolCount).toBe(1);\n });\n\n it('adds boundaryRoleHints based on file path', () => {\n const entry = makeFileEntry({ file: 'src/index.ts' });\n const [enriched] = enrichFileInventoryEntries([entry]);\n expect(enriched.boundaryRoleHints).toBeDefined();\n expect(enriched.boundaryRoleHints!.length).toBeGreaterThan(0);\n expect(enriched.boundaryRoleHints!.some(h => h.role === 'entrypoint')).toBe(\n true\n );\n });\n\n it('adds cfgFlags when flowEnabled=true', () => {\n const entry = makeFileEntry();\n const [enriched] = enrichFileInventoryEntries([entry], {\n flowEnabled: true,\n });\n expect(enriched.cfgFlags).toBeDefined();\n expect(typeof enriched.cfgFlags!.exitPointCount).toBe('number');\n expect(typeof enriched.cfgFlags!.hasValidationChecks).toBe('boolean');\n });\n\n it('does NOT add cfgFlags when flowEnabled=false', () => {\n const entry = makeFileEntry();\n const [enriched] = enrichFileInventoryEntries([entry], {\n flowEnabled: false,\n });\n expect(enriched.cfgFlags).toBeUndefined();\n });\n\n it('preserves existing effectProfile if already set', () => {\n const existing = {\n totalEffects: 99,\n totalWeight: 100,\n byKind: {},\n highestRisk: null,\n };\n const entry = makeFileEntry({\n effectProfile: existing,\n topLevelEffects: [\n {\n kind: 'eval',\n lineStart: 1,\n lineEnd: 1,\n detail: 'eval call',\n weight: 10,\n confidence: 'high',\n },\n ],\n });\n const [enriched] = enrichFileInventoryEntries([entry]);\n expect(enriched.effectProfile!.totalEffects).toBe(99);\n });\n\n it('empty entry gets symbolUsageSummary with zeros', () => {\n const entry = makeFileEntry();\n const [enriched] = enrichFileInventoryEntries([entry]);\n expect(enriched.symbolUsageSummary).toBeDefined();\n expect(enriched.symbolUsageSummary!.declaredExportCount).toBe(0);\n expect(enriched.symbolUsageSummary!.importedSymbolCount).toBe(0);\n expect(enriched.symbolUsageSummary!.internalImportCount).toBe(0);\n expect(enriched.symbolUsageSummary!.externalImportCount).toBe(0);\n expect(enriched.symbolUsageSummary!.reExportCount).toBe(0);\n });\n\n it('adds shared-utility boundary role hint when many exports and few imports', () => {\n const entry = makeFileEntry({\n file: 'src/utils/helpers.ts',\n dependencyProfile: {\n internalDependencies: [],\n externalDependencies: [],\n unresolvedDependencies: [],\n declaredExports: Array.from({ length: 10 }, (_, i) => ({\n name: `fn${i}`,\n kind: 'value' as const,\n })),\n importedSymbols: [\n {\n sourceModule: './other',\n resolvedModule: 'src/other.ts',\n importedName: 'x',\n localName: 'x',\n isTypeOnly: false,\n },\n ],\n reExports: [],\n },\n });\n const [enriched] = enrichFileInventoryEntries([entry]);\n expect(\n enriched.boundaryRoleHints!.some(h => h.role === 'shared-utility')\n ).toBe(true);\n });\n\n it('adds runtime-bootstrap hint when topLevelEffects present', () => {\n const entry = makeFileEntry({\n file: 'src/bootstrap.ts',\n topLevelEffects: [\n {\n kind: 'eval',\n lineStart: 1,\n lineEnd: 1,\n detail: 'eval',\n weight: 5,\n confidence: 'medium',\n },\n ],\n });\n const [enriched] = enrichFileInventoryEntries([entry]);\n expect(\n enriched.boundaryRoleHints!.some(h => h.role === 'runtime-bootstrap')\n ).toBe(true);\n });\n});\n\ndescribe('enrichFindings', () => {\n it('adds analysisLens based on category', () => {\n const graphFinding = makeFinding({\n id: 'g1',\n category: 'dependency-cycle',\n });\n const astFinding = makeFinding({\n id: 'a1',\n category: 'function-optimization',\n });\n const hybridFinding = makeFinding({\n id: 'h1',\n category: 'hardcoded-secret',\n });\n const results = enrichFindings(\n [graphFinding, astFinding, hybridFinding],\n [],\n [],\n null\n );\n expect(results[0].analysisLens).toBe('graph');\n expect(results[1].analysisLens).toBe('ast');\n expect(results[2].analysisLens).toBe('hybrid');\n });\n\n it('adds ruleId in format {lens}.{category} when not set', () => {\n const finding = makeFinding({ category: 'function-optimization' });\n const [enriched] = enrichFindings([finding], [], [], null);\n expect(enriched.ruleId).toBe('ast.function-optimization');\n });\n\n it('preserves existing ruleId when already set', () => {\n const finding = makeFinding({ ruleId: 'custom.rule' });\n const [enriched] = enrichFindings([finding], [], [], null);\n expect(enriched.ruleId).toBe('custom.rule');\n });\n\n it('adds confidence based on severity and lens', () => {\n const critical = makeFinding({ id: 'c1', severity: 'critical' });\n const graphMedium = makeFinding({\n id: 'g1',\n severity: 'medium',\n category: 'dependency-cycle',\n });\n const astLow = makeFinding({\n id: 'a1',\n severity: 'low',\n category: 'function-optimization',\n });\n const results = enrichFindings(\n [critical, graphMedium, astLow],\n [],\n [],\n null\n );\n expect(results[0].confidence).toBe('high');\n expect(results[1].confidence).toBe('medium');\n expect(results[2].confidence).toBe('low');\n });\n\n it('adds correlatedSignals including hot-file when file is in hotFiles', () => {\n const finding = makeFinding({ file: 'src/hot.ts' });\n const hotFiles = [\n {\n file: 'src/hot.ts',\n riskScore: 10,\n fanIn: 5,\n fanOut: 5,\n complexityScore: 3,\n exportCount: 2,\n inCycle: false,\n onCriticalPath: false,\n },\n ];\n const [enriched] = enrichFindings([finding], [], hotFiles, null);\n expect(enriched.correlatedSignals).toContain('hot-file');\n });\n\n it('adds correlatedSignals cycle-context when file is in SCC cluster', () => {\n const finding = makeFinding({ file: 'src/cycle.ts' });\n const ga = makeGraphAnalytics({\n sccClusters: [\n {\n id: 'scc-0',\n files: ['src/cycle.ts', 'src/other.ts'],\n nodeCount: 2,\n edgeCount: 2,\n entryEdges: 1,\n exitEdges: 1,\n hubFiles: [],\n },\n ],\n });\n const [enriched] = enrichFindings([finding], [], [], ga);\n expect(enriched.correlatedSignals).toContain('cycle-context');\n });\n\n it('adds recommendedValidation with tools array', () => {\n const finding = makeFinding();\n const [enriched] = enrichFindings([finding], [], [], null);\n expect(enriched.recommendedValidation).toBeDefined();\n expect(Array.isArray(enriched.recommendedValidation!.tools)).toBe(true);\n expect(enriched.recommendedValidation!.tools.length).toBeGreaterThan(0);\n });\n\n it('adds flowTrace when flowEnabled=true and evidence has propagationSteps', () => {\n const finding = makeFinding({\n evidence: { propagationSteps: ['src/a.ts:10-20', 'src/b.ts:30'] },\n });\n const [enriched] = enrichFindings([finding], [], [], null, {\n flowEnabled: true,\n });\n expect(enriched.flowTrace).toBeDefined();\n expect(enriched.flowTrace!.length).toBe(2);\n expect(enriched.flowTrace![0].file).toBe('src/a.ts');\n });\n\n it('does NOT add flowTrace when flowEnabled=false', () => {\n const finding = makeFinding({\n evidence: { propagationSteps: ['src/a.ts:10-20'] },\n });\n const [enriched] = enrichFindings([finding], [], [], null, {\n flowEnabled: false,\n });\n expect(enriched.flowTrace).toBeUndefined();\n });\n\n it('adds paired:{category} to correlatedSignals for multiple findings on same file', () => {\n const f1 = makeFinding({\n id: 'f1',\n category: 'function-optimization',\n file: 'src/shared.ts',\n });\n const f2 = makeFinding({\n id: 'f2',\n category: 'cognitive-complexity',\n file: 'src/shared.ts',\n });\n const results = enrichFindings([f1, f2], [], [], null);\n expect(results[0].correlatedSignals).toContain(\n 'paired:cognitive-complexity'\n );\n expect(results[1].correlatedSignals).toContain(\n 'paired:function-optimization'\n );\n });\n});\n\ndescribe('computeReportAnalysisSummary', () => {\n it('returns graphSignals when graphAnalytics has chokepoints', () => {\n const ga = makeGraphAnalytics({\n chokepoints: [\n {\n file: 'src/hub.ts',\n score: 50,\n reasons: ['high fan-in'],\n fanIn: 10,\n fanOut: 5,\n articulation: true,\n bridgeCount: 1,\n cycleClusterCount: 0,\n onCriticalPath: true,\n },\n ],\n });\n const result = computeReportAnalysisSummary([], [], [], ga);\n expect(result.graphSignals.length).toBeGreaterThan(0);\n expect(result.graphSignals[0].kind).toBe('structural-chokepoint');\n });\n\n it('returns graphSignals for cycle clusters', () => {\n const ga = makeGraphAnalytics({\n sccClusters: [\n {\n id: 'scc-0',\n files: ['a.ts', 'b.ts'],\n nodeCount: 2,\n edgeCount: 3,\n entryEdges: 1,\n exitEdges: 1,\n hubFiles: ['a.ts'],\n },\n ],\n });\n const result = computeReportAnalysisSummary([], [], [], ga);\n expect(result.graphSignals.some(s => s.kind === 'cycle-cluster')).toBe(\n true\n );\n });\n\n it('returns astSignals for low-cohesion + feature-envy pair', () => {\n const entry = makeFileEntry({ file: 'src/leak.ts' });\n const findings = [\n makeFinding({ id: 'lc', category: 'low-cohesion', file: 'src/leak.ts' }),\n makeFinding({ id: 'fe', category: 'feature-envy', file: 'src/leak.ts' }),\n ];\n const enriched = enrichFindings(findings, [entry], [], null);\n const result = computeReportAnalysisSummary(enriched, [entry], [], null);\n expect(result.astSignals.length).toBeGreaterThan(0);\n expect(result.astSignals[0].kind).toBe('boundary-leak-shape');\n });\n\n it('returns combined interpretation with shared file and confidence high', () => {\n const entry = makeFileEntry({ file: 'src/shared.ts' });\n const ga = makeGraphAnalytics({\n chokepoints: [\n {\n file: 'src/shared.ts',\n score: 50,\n reasons: ['high fan-in'],\n fanIn: 10,\n fanOut: 5,\n articulation: true,\n bridgeCount: 1,\n cycleClusterCount: 0,\n onCriticalPath: true,\n },\n ],\n });\n const findings = [\n makeFinding({\n id: 'lc',\n category: 'low-cohesion',\n file: 'src/shared.ts',\n }),\n makeFinding({\n id: 'fe',\n category: 'feature-envy',\n file: 'src/shared.ts',\n }),\n ];\n const enriched = enrichFindings(findings, [entry], [], ga);\n const result = computeReportAnalysisSummary(enriched, [entry], [], ga);\n expect(result.combinedInterpretation).not.toBeNull();\n expect(result.combinedInterpretation!.confidence).toBe('high');\n });\n\n it('returns combined interpretation with different files and confidence medium', () => {\n const entry1 = makeFileEntry({ file: 'src/graph-file.ts' });\n const entry2 = makeFileEntry({ file: 'src/ast-file.ts' });\n const ga = makeGraphAnalytics({\n chokepoints: [\n {\n file: 'src/graph-file.ts',\n score: 50,\n reasons: ['high fan-in'],\n fanIn: 10,\n fanOut: 5,\n articulation: false,\n bridgeCount: 0,\n cycleClusterCount: 0,\n onCriticalPath: true,\n },\n ],\n });\n const findings = [\n makeFinding({\n id: 'lc',\n category: 'low-cohesion',\n file: 'src/ast-file.ts',\n }),\n makeFinding({\n id: 'fe',\n category: 'feature-envy',\n file: 'src/ast-file.ts',\n }),\n ];\n const enriched = enrichFindings(findings, [entry1, entry2], [], ga);\n const result = computeReportAnalysisSummary(\n enriched,\n [entry1, entry2],\n [],\n ga\n );\n expect(result.combinedInterpretation).not.toBeNull();\n expect(result.combinedInterpretation!.confidence).toBe('medium');\n });\n\n it('returns investigationPrompts based on signals', () => {\n const ga = makeGraphAnalytics({\n chokepoints: [\n {\n file: 'src/hub.ts',\n score: 50,\n reasons: ['high fan-in'],\n fanIn: 10,\n fanOut: 5,\n articulation: true,\n bridgeCount: 1,\n cycleClusterCount: 0,\n onCriticalPath: true,\n },\n ],\n });\n const result = computeReportAnalysisSummary([], [], [], ga);\n expect(Array.isArray(result.investigationPrompts)).toBe(true);\n expect(result.investigationPrompts.length).toBeGreaterThan(0);\n });\n\n it('empty inputs produce all fields present but empty/null', () => {\n const result = computeReportAnalysisSummary([], [], [], null);\n expect(result.graphSignals).toEqual([]);\n expect(result.astSignals).toEqual([]);\n expect(result.combinedSignals).toEqual([]);\n expect(result.strongestGraphSignal).toBeNull();\n expect(result.strongestAstSignal).toBeNull();\n expect(result.combinedInterpretation).toBeNull();\n expect(result.recommendedValidation).toBeNull();\n expect(Array.isArray(result.investigationPrompts)).toBe(true);\n });\n\n it('only graph signal produces combined interpretation using that signal', () => {\n const ga = makeGraphAnalytics({\n chokepoints: [\n {\n file: 'src/hub.ts',\n score: 50,\n reasons: ['high fan-in'],\n fanIn: 10,\n fanOut: 5,\n articulation: false,\n bridgeCount: 0,\n cycleClusterCount: 0,\n onCriticalPath: false,\n },\n ],\n });\n const result = computeReportAnalysisSummary([], [], [], ga);\n expect(result.strongestGraphSignal).not.toBeNull();\n expect(result.strongestAstSignal).toBeNull();\n expect(result.combinedInterpretation).not.toBeNull();\n expect(result.combinedInterpretation!.lens).toBe('graph');\n });\n\n it('returns high confidence for cycle cluster with nodeCount >= 5', () => {\n const ga = makeGraphAnalytics({\n sccClusters: [\n {\n id: 'scc-large',\n files: ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts'],\n nodeCount: 5,\n edgeCount: 8,\n entryEdges: 1,\n exitEdges: 1,\n hubFiles: ['a.ts'],\n },\n ],\n });\n const result = computeReportAnalysisSummary([], [], [], ga);\n const cycleSignal = result.graphSignals.find(\n s => s.kind === 'cycle-cluster'\n );\n expect(cycleSignal).toBeDefined();\n expect(cycleSignal!.confidence).toBe('high');\n });\n\n it('adds hybrid investigation prompt when combinedInterpretation exists but confidence is not high', () => {\n const entry1 = makeFileEntry({ file: 'src/graph-only.ts' });\n const entry2 = makeFileEntry({ file: 'src/ast-only.ts' });\n const ga = makeGraphAnalytics({\n chokepoints: [\n {\n file: 'src/graph-only.ts',\n score: 50,\n reasons: ['high fan-in'],\n fanIn: 10,\n fanOut: 5,\n articulation: false,\n bridgeCount: 0,\n cycleClusterCount: 0,\n onCriticalPath: true,\n },\n ],\n });\n const findings = [\n makeFinding({\n id: 'lc',\n category: 'low-cohesion',\n file: 'src/ast-only.ts',\n }),\n makeFinding({\n id: 'fe',\n category: 'feature-envy',\n file: 'src/ast-only.ts',\n }),\n ];\n const enriched = enrichFindings(findings, [entry1, entry2], [], ga);\n const result = computeReportAnalysisSummary(\n enriched,\n [entry1, entry2],\n [],\n ga\n );\n expect(result.combinedInterpretation).not.toBeNull();\n expect(result.combinedInterpretation!.confidence).toBe('medium');\n expect(\n result.investigationPrompts.some(p => p.includes('hybrid investigation'))\n ).toBe(true);\n });\n\n it('returns high confidence for package hotspot with edges >= 8', () => {\n const ga = makeGraphAnalytics({\n packageGraphSummary: {\n packageCount: 2,\n edgeCount: 20,\n packages: [],\n hotspots: [{ from: 'pkg-a', to: 'pkg-b', edges: 10 }],\n },\n });\n const result = computeReportAnalysisSummary([], [], [], ga);\n const pkgSignal = result.graphSignals.find(\n s => s.kind === 'package-chatter'\n );\n expect(pkgSignal).toBeDefined();\n expect(pkgSignal!.confidence).toBe('high');\n });\n\n it('returns mega-folder graph signal when mega-folder finding is present', () => {\n const findings = [\n makeFinding({\n id: 'mega',\n category: 'mega-folder',\n file: 'src/core/index.ts',\n files: ['src/core/a.ts', 'src/core/b.ts'],\n evidence: {\n folderPath: 'src/core',\n fileCount: 42,\n concentration: 0.54,\n },\n }),\n ];\n const result = computeReportAnalysisSummary(findings, [], [], null);\n const mega = result.graphSignals.find(\n s => s.kind === 'mega-folder-cluster'\n );\n expect(mega).toBeDefined();\n expect(mega!.summary).toContain('src/core');\n expect(\n result.investigationPrompts.some(p => p.includes('migration script'))\n ).toBe(true);\n });\n\n it('returns hidden-initialization ast signal when entry has effects and import-side-effect-risk', () => {\n const entry = makeFileEntry({\n file: 'src/init.ts',\n effectProfile: {\n totalEffects: 2,\n totalWeight: 15,\n byKind: {},\n highestRisk: 'eval' as const,\n },\n topLevelEffects: [\n {\n kind: 'eval',\n lineStart: 1,\n lineEnd: 1,\n detail: 'eval',\n weight: 10,\n confidence: 'high',\n },\n ],\n });\n const findings = [\n makeFinding({\n id: 'es',\n category: 'import-side-effect-risk',\n file: 'src/init.ts',\n }),\n ];\n const enriched = enrichFindings(findings, [entry], [], null);\n const result = computeReportAnalysisSummary(enriched, [entry], [], null);\n const hiddenSignal = result.astSignals.find(\n s => s.kind === 'hidden-initialization'\n );\n expect(hiddenSignal).toBeDefined();\n });\n\n it('returns orchestration-duplication ast signal when file has duplicate-flow-structure and function-optimization', () => {\n const entry = makeFileEntry({ file: 'src/orchestrate.ts' });\n const findings = [\n makeFinding({\n id: 'df',\n category: 'duplicate-flow-structure',\n file: 'src/orchestrate.ts',\n }),\n makeFinding({\n id: 'fo',\n category: 'function-optimization',\n file: 'src/orchestrate.ts',\n }),\n ];\n const enriched = enrichFindings(findings, [entry], [], null);\n const result = computeReportAnalysisSummary(enriched, [entry], [], null);\n const orchSignal = result.astSignals.find(\n s => s.kind === 'orchestration-duplication'\n );\n expect(orchSignal).toBeDefined();\n });\n\n it('adds hotFiles prompt when hotFiles provided', () => {\n const hotFiles = [\n {\n file: 'src/hot.ts',\n riskScore: 50,\n fanIn: 5,\n fanOut: 5,\n complexityScore: 3,\n exportCount: 2,\n inCycle: false,\n onCriticalPath: false,\n },\n ];\n const result = computeReportAnalysisSummary([], [], hotFiles, null);\n expect(\n result.investigationPrompts.some(\n p => p.includes('hotspot') && p.includes('hot.ts')\n )\n ).toBe(true);\n });\n\n it('adds critical-path-context to correlatedSignals when finding file is on critical path', () => {\n const finding = makeFinding({ file: 'src/critical.ts' });\n const ga = makeGraphAnalytics({\n chokepoints: [\n {\n file: 'src/critical.ts',\n score: 50,\n reasons: ['on critical path'],\n fanIn: 5,\n fanOut: 5,\n articulation: false,\n bridgeCount: 0,\n cycleClusterCount: 0,\n onCriticalPath: true,\n },\n ],\n });\n const [enriched] = enrichFindings([finding], [], [], ga);\n expect(enriched.correlatedSignals).toContain('critical-path-context');\n });\n\n it('adds top-level-effects to correlatedSignals when entry has effectProfile', () => {\n const entry = makeFileEntry({\n file: 'src/effects.ts',\n effectProfile: {\n totalEffects: 2,\n totalWeight: 10,\n byKind: {},\n highestRisk: 'eval' as const,\n },\n });\n const finding = makeFinding({ file: 'src/effects.ts' });\n const [enriched] = enrichFindings([finding], [entry], [], null);\n expect(enriched.correlatedSignals).toContain('top-level-effects');\n });\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":23723,"content_sha256":"b96ad68c79f2574a00d20f903c45ad53a3ed2392e260351ebdba889a3e54df62"},{"filename":"src/run.ts","content":"#!/usr/bin/env node\n/**\n * Entry point for the octocode-engineer scanner. Verifies runtime\n * dependencies (native addons that cannot be bundled + the TypeScript\n * compiler) are installed before loading the pipeline. If missing, the\n * bootstrap detects the user's package manager from lockfiles and installs\n * into the skill's own node_modules, or prints an actionable manual command.\n */\nimport { ensureNativeDependencies } from './common/ensure-deps.js';\n\nensureNativeDependencies(import.meta.url, { tag: '[octocode-scan]' });\n\nconst { main, EXIT_ERROR } = await import('./pipeline/main.js');\nconst { OptionsError } = await import('./pipeline/create-options.js');\ntry {\n const exitCode = await main();\n process.exitCode = exitCode;\n} catch (err: unknown) {\n if (err instanceof OptionsError) {\n process.stderr.write(`${err.message}\\n`);\n } else {\n console.error(err);\n }\n process.exitCode = EXIT_ERROR;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":921,"content_sha256":"7a27386f86f809d7fbee845921a62589561b629b7fc515c4553da585e7498885"},{"filename":"src/types/analysis.ts","content":"export type AnalysisLens = 'graph' | 'ast' | 'hybrid';\n\nexport interface RecommendedValidation {\n summary: string;\n tools: string[];\n}\n\nexport interface FlowTraceStep {\n file: string;\n lineStart: number;\n lineEnd: number;\n label: string;\n}\n\nexport interface AnalysisSignal {\n kind: string;\n lens: AnalysisLens;\n title: string;\n summary: string;\n confidence: 'high' | 'medium' | 'low';\n score: number;\n files: string[];\n categories: string[];\n evidence: Record\u003cstring, unknown>;\n}\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":495,"content_sha256":"451c6ed2dd65186f74a5e1f419580173786d78dbf2d9028005ffe39478233c7e"},{"filename":"vitest.config.ts","content":"import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n test: {\n include: ['src/**/*.test.ts'],\n exclude: ['scripts/**', 'node_modules/**'],\n },\n});\n","content_type":"text/typescript; charset=utf-8","language":"typescript","size":179,"content_sha256":"226d35bf1e19c5fa9ffbe5fe9f323fdc55147790cb0ec63721d244ebe6f8ef13"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Octocode Engineer","type":"text"}]},{"type":"paragraph","content":[{"text":"Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"What you get (user view)","type":"text"}]},{"type":"paragraph","content":[{"text":"A structured ","type":"text"},{"text":"understanding artifact","type":"text","marks":[{"type":"strong"}]},{"text":", grounded in evidence, every claim cited ","type":"text"},{"text":"file:line","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"System summary","type":"text","marks":[{"type":"strong"}]},{"text":" (what/who/invariants) · ","type":"text"},{"text":"Control flows","type":"text","marks":[{"type":"strong"}]},{"text":" (numbered call paths) · ","type":"text"},{"text":"Data flows","type":"text","marks":[{"type":"strong"}]},{"text":" (writers/readers/txn/cache per entity) · ","type":"text"},{"text":"Types & protocols","type":"text","marks":[{"type":"strong"}]},{"text":" (DTOs, schemas, wire contracts, compat)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Boundaries & ownership","type":"text","marks":[{"type":"strong"}]},{"text":" (owners, ports, contract tests) · ","type":"text"},{"text":"Structure health","type":"text","marks":[{"type":"strong"}]},{"text":" (folder bloat, file/folder naming, project-fit) · ","type":"text"},{"text":"Duplication inventory","type":"text","marks":[{"type":"strong"}]},{"text":" (near-clones, missing abstraction) · ","type":"text"},{"text":"Execution profile","type":"text","marks":[{"type":"strong"}]},{"text":" (hot paths, async/sync, retries/timeouts/lifecycles)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Architecture health","type":"text","marks":[{"type":"strong"}]},{"text":" (per-principle + per-dimension, ","type":"text"},{"text":"confirmed|likely|uncertain","type":"text","marks":[{"type":"code_inline"}]},{"text":") · ","type":"text"},{"text":"Clean-code hotspots","type":"text","marks":[{"type":"strong"}]},{"text":" · ","type":"text"},{"text":"Next step","type":"text","marks":[{"type":"strong"}]},{"text":" (1 sentence)","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"For a change: change flow, data-flow impact, contract impact, blast radius, risk vector. ","type":"text"},{"text":"Safety built in","type":"text","marks":[{"type":"strong"}]},{"text":" — hard gates stop for your decision before public-contract changes, cross-layer edits, destructive actions, or large blast radius.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"How to invoke (user view)","type":"text"}]},{"type":"paragraph","content":[{"text":"Ask in natural language. The skill activates on phrases like: ","type":"text"},{"text":"\"understand this codebase\"","type":"text","marks":[{"type":"em"}]},{"text":", ","type":"text"},{"text":"\"deep-dive this feature\"","type":"text","marks":[{"type":"em"}]},{"text":", ","type":"text"},{"text":"\"review the architecture of X\"","type":"text","marks":[{"type":"em"}]},{"text":", ","type":"text"},{"text":"\"why is this slow / flaky / coupled?\"","type":"text","marks":[{"type":"em"}]},{"text":", ","type":"text"},{"text":"\"is this PR safe?\"","type":"text","marks":[{"type":"em"}]},{"text":", ","type":"text"},{"text":"\"what breaks if I change Y?\"","type":"text","marks":[{"type":"em"}]},{"text":", ","type":"text"},{"text":"\"prepare to refactor Z\"","type":"text","marks":[{"type":"em"}]},{"text":", ","type":"text"},{"text":"\"validate this RFC against the code\"","type":"text","marks":[{"type":"em"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick decision cheatsheet (agent view)","type":"text"}]},{"type":"paragraph","content":[{"text":"Use this first to pick the cheapest proof path. Every LSP call needs a ","type":"text"},{"text":"lineHint","type":"text","marks":[{"type":"code_inline"}]},{"text":" from ","type":"text"},{"text":"localSearchCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"never guess","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Question","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tool chain","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Where is X defined?","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localSearchCode(X)","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"lspGotoDefinition(lineHint=N)","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Who calls function X?","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localSearchCode(X)","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"lspCallHierarchy(incoming, lineHint=N)","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What does X call?","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localSearchCode(X)","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"lspCallHierarchy(outgoing, lineHint=N)","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"All usages of a type / var / non-function X?","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localSearchCode(X)","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"lspFindReferences(lineHint=N)","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Is this pattern duplicated?","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/ast/search.js --pattern","type":"text","marks":[{"type":"code_inline"}]},{"text":" → scanner ","type":"text"},{"text":"duplicate-*","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Is this shape an antipattern?","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/ast/search.js --preset \u003cname>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (list: ","type":"text"},{"text":"--list-presets","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Is this module structurally unhealthy?","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/run.js --graph --scope=\u003cpath>","type":"text","marks":[{"type":"code_inline"}]},{"text":" → read ","type":"text"},{"text":"scan.json","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Is the project structure healthy?","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localViewStructure","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"localFindFiles","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"scripts/run.js --scope=\u003cpath> --graph","type":"text","marks":[{"type":"code_inline"}]},{"text":" → inspect ","type":"text"},{"text":"qualityRating","type":"text","marks":[{"type":"code_inline"}]},{"text":" folder/naming/consistency signals + ","type":"text"},{"text":"mega-folder","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Which layer/boundary does this cross?","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Scanner layer output + ","type":"text"},{"text":"lspGotoDefinition","type":"text","marks":[{"type":"code_inline"}]},{"text":" across packages","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What breaks if I change Y?","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"lspFindReferences(Y)","type":"text","marks":[{"type":"code_inline"}]},{"text":" → label consumers by layer","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Find files by name / churn / size","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localFindFiles","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Read implementation (last resort)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localGetFileContent","type":"text","marks":[{"type":"code_inline"}]},{"text":" with ","type":"text"},{"text":"matchString","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"For longer research recipes and end-to-end tool sequences, see ","type":"text"},{"text":"tool-workflows.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/tool-workflows.md","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"References index","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Situation","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reference","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tool workflows, research recipes","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"tool-workflows.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/tool-workflows.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Scanner flags, thresholds, scope syntax, exit codes","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cli-reference.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/cli-reference.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reading scan artifacts","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"output-files.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/output-files.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AST presets, pattern syntax, Python kinds","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ast-reference.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/ast-reference.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Confirming / dismissing a finding","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"validation-playbooks.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/validation-playbooks.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Detector catalog, metrics, severities","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"quality-indicators.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/quality-indicators.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"How to present findings","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"output-format.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/output-format.md","title":null}}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"eslint, tsc, knip, ruff, mypy","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"externals.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/externals.md","title":null}}]}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Operating contract (agent view)","type":"text"}]},{"type":"paragraph","content":[{"text":"Every ","type":"text"},{"text":"non-trivial","type":"text","marks":[{"type":"strong"}]},{"text":" task MUST satisfy this contract:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Scope","type":"text","marks":[{"type":"strong"}]},{"text":" — restate the goal and constraints in one line before touching tools.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Lenses","type":"text","marks":[{"type":"strong"}]},{"text":" — apply both required lenses defined in §Clean Architecture & Clean Code: the five Clean-Architecture principles and the six analytic dimensions.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Evidence","type":"text","marks":[{"type":"strong"}]},{"text":" — prove every architectural or code-quality claim with at least one of: Octocode local tools, LSP, AST, scanner. Mark confidence (","type":"text"},{"text":"confirmed|likely|uncertain","type":"text","marks":[{"type":"code_inline"}]},{"text":") with source.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Artifact","type":"text","marks":[{"type":"strong"}]},{"text":" — produce the understanding artifact (§Required output) before recommending action.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gates","type":"text","marks":[{"type":"strong"}]},{"text":" — stop at every hard gate in §User-Ask Gates.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Tool universe","type":"text","marks":[{"type":"strong"}]},{"text":" — never fall back to native Claude Code search tools (","type":"text"},{"text":"Grep","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Glob","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"Read","type":"text","marks":[{"type":"code_inline"}]},{"text":") while Octocode MCP is registered. A warning inside a successful Octocode response is not a failure; see §Fallback Mode for the only legitimate fallback conditions.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When To Use It","type":"text"}]},{"type":"paragraph","content":[{"text":"Use when the user asks to ","type":"text"},{"text":"understand","type":"text","marks":[{"type":"strong"}]},{"text":" a codebase/feature end-to-end, ","type":"text"},{"text":"change","type":"text","marks":[{"type":"strong"}]},{"text":" unclear/shared/cross-file code, ","type":"text"},{"text":"review","type":"text","marks":[{"type":"strong"}]},{"text":" quality/architecture/tech-debt/dead-code/security/build issues, or ","type":"text"},{"text":"decide","type":"text","marks":[{"type":"strong"}]},{"text":" architecture and validate RFCs against real behavior. Any language; strongest on Node/TypeScript and Python. For formal RFCs with migration strategy, pair with ","type":"text"},{"text":"octocode-rfc-generator","type":"text","marks":[{"type":"link","attrs":{"href":"https://skills.sh/bgauryy/octocode-mcp/octocode-rfc-generator","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Trivial vs. non-trivial — when the contract binds","type":"text"}]},{"type":"paragraph","content":[{"text":"The contract, lenses, and artifact apply to ","type":"text"},{"text":"non-trivial","type":"text","marks":[{"type":"strong"}]},{"text":" tasks. A task is ","type":"text"},{"text":"trivial","type":"text","marks":[{"type":"strong"}]},{"text":" only when ALL hold: single file; no public/exported symbol touched; 0 consumers (per ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]},{"text":") or behavior-preserving for all; no contract/schema/protocol/config/migration touched; ≤ ~20 lines; no cross-layer/cross-package edit. Otherwise non-trivial (default on doubt). Trivial tasks: deliver the one-line next step + verification only.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Clean Architecture & Clean Code (Required Lenses)","type":"text"}]},{"type":"paragraph","content":[{"text":"Non-trivial investigations MUST go through both lenses. Prove every claim with the listed tools — no unevidenced architectural or code-quality facts.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Clean Architecture — what to enforce, how to verify","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dependency rule","type":"text","marks":[{"type":"strong"}]},{"text":" — source code dependencies point inward. Domain never imports infrastructure/UI; use cases never import frameworks.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Layer boundaries","type":"text","marks":[{"type":"strong"}]},{"text":" — entities → use cases → interface adapters → frameworks & drivers. Concerns stay in their layer.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stable abstractions","type":"text","marks":[{"type":"strong"}]},{"text":" — volatile details depend on stable policy, never the reverse.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Boundary ownership","type":"text","marks":[{"type":"strong"}]},{"text":" — every cross-boundary call goes through an explicit port (interface/DTO). Implementation types do not leak.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Single responsibility per module","type":"text","marks":[{"type":"strong"}]},{"text":" — one reason to change; one axis of volatility.","type":"text"}]}]}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Principle","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tool","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Evidence to collect","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Dependency rule","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/run.js --graph","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"layer-violation / SDP findings; inward-pointing edges only","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Layer boundaries","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localSearchCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" on import lines + scanner layer output","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"UI→DB, domain→HTTP, adapter→framework leaks","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Stable abstractions","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scanner ","type":"text"},{"text":"distance-from-main-sequence","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"concrete high-fan-in modules, unstable abstractions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Boundary ownership","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"lspGotoDefinition","type":"text","marks":[{"type":"code_inline"}]},{"text":" across package boundaries","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"types crossing boundaries without a port","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Single responsibility","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scanner + ","type":"text"},{"text":"scripts/ast/search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"--preset class-declaration","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"god-function","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"god modules, multi-purpose classes, wide exports","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Architect's analytic dimensions","type":"text"}]},{"type":"paragraph","content":[{"text":"Cover all six on a full review; on a scoped task, cover those the change touches and mark the rest ","type":"text"},{"text":"N/A","type":"text","marks":[{"type":"code_inline"}]},{"text":" with a one-line reason (","type":"text"},{"text":"N/A","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a claim, not silence). Mapping to artifact sections is encoded in §Required output. On a change, state which dimensions it stresses — that is the risk vector.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"#","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Dimension","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Verify","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Anti-patterns","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flows","type":"text","marks":[{"type":"strong"}]},{"text":" — entry → collaborators → side effects → return/emit","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localSearchCode","type":"text","marks":[{"type":"code_inline"}]},{"text":"(entry,lineHint) → ","type":"text"},{"text":"lspCallHierarchy","type":"text","marks":[{"type":"code_inline"}]},{"text":" incoming/outgoing → ","type":"text"},{"text":"scripts/run.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" flow/graph on hot paths","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"hidden event jumps; unenumerable middleware chains; untested error branches","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Duplication","type":"text","marks":[{"type":"strong"}]},{"text":" — same logic in two places drifts","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scanner (","type":"text"},{"text":"duplicate-function-body","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"duplicate-flow-structure","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"similar-function-body","type":"text","marks":[{"type":"code_inline"}]},{"text":") → ","type":"text"},{"text":"scripts/ast/search.js --pattern","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]},{"text":" on canonical version","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"two sources of truth; drifting copies; per-caller reinvention","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Types","type":"text","marks":[{"type":"strong"}]},{"text":" — in-process contracts","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"lspGotoDefinition","type":"text","marks":[{"type":"code_inline"}]},{"text":" on boundary params → ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]},{"text":" on type → ","type":"text"},{"text":"scripts/ast/search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" presets (","type":"text"},{"text":"any-type","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"type-assertion","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"non-null-assertion","type":"text","marks":[{"type":"code_inline"}]},{"text":") → scanner (","type":"text"},{"text":"unsafe-any","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"type-assertion-escape","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"narrowable-type","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"any","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":" at public boundary; casts silencing compiler; always-populated \"optional\" fields","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Protocols & schemas","type":"text","marks":[{"type":"strong"}]},{"text":" — wire contracts (HTTP/gRPC/GraphQL/SQL/events/config)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localFindFiles","type":"text","marks":[{"type":"code_inline"}]},{"text":" on ","type":"text"},{"text":"*.proto","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"*.graphql","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"*.sql","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"openapi*","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"schema*","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"migrations/*","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"localGetFileContent","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]},{"text":" on generated types → ","type":"text"},{"text":"githubSearchPullRequests","type":"text","marks":[{"type":"code_inline"}]},{"text":" for external changes","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"schema drift; implicit required fields; defaults in code not schema; version bumps without compat windows; null/missing/empty ambiguity","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Data flows","type":"text","marks":[{"type":"strong"}]},{"text":" — state, ownership, mutation","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"schema + repository/DAO → ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]},{"text":" on write fns (","type":"text"},{"text":"save","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"update","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"insert","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"publish","type":"text","marks":[{"type":"code_inline"}]},{"text":") → ","type":"text"},{"text":"scripts/run.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" graph/flow on write paths → ","type":"text"},{"text":"scripts/ast/search.js --kind","type":"text","marks":[{"type":"code_inline"}]},{"text":" on mutations","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"multi-writers on one field; read-your-writes across async; cache/write races; write paths bypassing validator; projections without consistency guarantees","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"6","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Execution","type":"text","marks":[{"type":"strong"}]},{"text":" — runtime (sync/async, I/O, retries, timeouts, startup/shutdown, lifecycles)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/ast/search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" presets (","type":"text"},{"text":"async-function","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"await-in-loop","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sync-io","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"promise-all","type":"text","marks":[{"type":"code_inline"}]},{"text":") → scanner (","type":"text"},{"text":"await-in-loop","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"sync-io","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"uncleared-timer","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"unbounded-collection","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"startup-risk-hub","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"listener-leak-risk","type":"text","marks":[{"type":"code_inline"}]},{"text":") → ","type":"text"},{"text":"lspCallHierarchy","type":"text","marks":[{"type":"code_inline"}]},{"text":" on hot path → tests/benchmarks","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"await","type":"text","marks":[{"type":"code_inline"}]},{"text":" in tight loops; sync I/O on request path; timers/listeners without lifecycle; startup assuming init order; retries without backoff/idempotency","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Clean Code — what to enforce, how to verify","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Names reveal intent","type":"text","marks":[{"type":"strong"}]},{"text":" — symbols describe what, not how.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Small, single-purpose functions","type":"text","marks":[{"type":"strong"}]},{"text":" — one level of abstraction; short; ≤ ~3 params.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No dead or duplicated logic","type":"text","marks":[{"type":"strong"}]},{"text":" — every branch reachable; each pattern lives in one place.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fail loudly, never silently","type":"text","marks":[{"type":"strong"}]},{"text":" — no empty catches, no swallowed errors, no bare ","type":"text"},{"text":"except","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Types are precise at boundaries","type":"text","marks":[{"type":"strong"}]},{"text":" — no ","type":"text"},{"text":"any","type":"text","marks":[{"type":"code_inline"}]},{"text":" / no bare ","type":"text"},{"text":"except","type":"text","marks":[{"type":"code_inline"}]},{"text":" / no unchecked casts at contracts.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Comments explain ","type":"text","marks":[{"type":"strong"}]},{"text":"why","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":", not ","type":"text","marks":[{"type":"strong"}]},{"text":"what","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" — if the comment restates the code, delete one.","type":"text"}]}]}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rule","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tool","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Preset / signal","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Small functions","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/run.js","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"god-function","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"cognitive-complexity","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"halstead-effort","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"excessive-parameters","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Duplication","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/run.js","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"duplicate-function-body","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"duplicate-flow-structure","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"similar-function-body","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Silent failures","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/ast/search.js","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--preset empty-catch","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--preset py-bare-except","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--preset py-pass-except","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--preset catch-rethrow","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Loose types","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/ast/search.js","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"--preset any-type","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--preset type-assertion","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"--preset non-null-assertion","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Intent-revealing names","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"code read + ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"widely-used cryptic symbols, abbreviations that spread","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Dead / unreachable","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scanner + ","type":"text"},{"text":"knip","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dead-export","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dead-file","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"unused-import","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"unused-npm-dependency","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"For the full detector catalog, metric definitions, and severity rubric, see ","type":"text"},{"text":"quality-indicators.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/quality-indicators.md","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Required output: understanding artifact","type":"text"}]},{"type":"paragraph","content":[{"text":"Produce before recommending action. ","type":"text"},{"text":"required","type":"text","marks":[{"type":"strong"}]},{"text":" sections always appear (use ","type":"text"},{"text":"N/A","type":"text","marks":[{"type":"code_inline"}]},{"text":" + reason if not applicable); ","type":"text"},{"text":"applicable","type":"text","marks":[{"type":"strong"}]},{"text":" sections appear only when the task touches that surface. Keep each section ≤ 2 min read.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"#","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Section","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"When","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Source dimensions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"1","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"System summary","type":"text","marks":[{"type":"strong"}]},{"text":" — what it does, who consumes it, invariants","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"2","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Control flows","type":"text","marks":[{"type":"strong"}]},{"text":" — numbered call paths, each step cited ","type":"text"},{"text":"file:line","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flows","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"3","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Data flows","type":"text","marks":[{"type":"strong"}]},{"text":" — writers, readers, transaction boundaries, caches per entity","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"applicable (stateful tasks)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Data flows","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"4","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Types & protocols","type":"text","marks":[{"type":"strong"}]},{"text":" — boundary DTOs/schemas/wire contracts, compatibility posture","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"applicable (contract tasks)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Types, Protocols & schemas","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"5","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Boundaries & ownership","type":"text","marks":[{"type":"strong"}]},{"text":" — module ownership, ports, contract tests","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"6","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Duplication inventory","type":"text","marks":[{"type":"strong"}]},{"text":" — top near-clones and the missing abstraction","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"applicable (refactor / quality)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Duplication","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"7","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Execution profile","type":"text","marks":[{"type":"strong"}]},{"text":" — hot paths, async/sync posture, retry/timeout/lifecycle, runtime risks","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"applicable (perf / reliability)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Execution","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"8","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Architecture health","type":"text","marks":[{"type":"strong"}]},{"text":" — one line per principle and per dimension, with `confirmed","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"likely","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"uncertain` + source","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"9","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Clean-code hotspots","type":"text","marks":[{"type":"strong"}]},{"text":" — top AST/scanner findings worth fixing, cited ","type":"text"},{"text":"file:line","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"applicable (quality work)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"10","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Next step","type":"text","marks":[{"type":"strong"}]},{"text":" — one sentence","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"required","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"—","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Trivial tasks: section 10 + verification only (see §Trivial vs. non-trivial). Section ordering, phrasing, examples: ","type":"text"},{"text":"output-format.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/output-format.md","title":null}}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"If the task involves a change, also include:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Change flow","type":"text","marks":[{"type":"strong"}]},{"text":" — the specific call path the change traverses. ","type":"text"},{"text":"(required for any change)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Data-flow impact","type":"text","marks":[{"type":"strong"}]},{"text":" — entities read/written and how transaction/cache semantics are preserved. ","type":"text"},{"text":"(required if section 3 applied)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Contract impact","type":"text","marks":[{"type":"strong"}]},{"text":" — types/schemas/protocols touched and compatibility posture (backwards-compatible / breaking-with-migration / additive-only). ","type":"text"},{"text":"(required if section 4 applied)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Blast radius","type":"text","marks":[{"type":"strong"}]},{"text":" — callers and consumers touched, from ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]},{"text":", labeled by layer. ","type":"text"},{"text":"(required for any change with consumers)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Risk vector","type":"text","marks":[{"type":"strong"}]},{"text":" — which clean-architecture principles and which analytic dimensions the change stresses, and how each is preserved. ","type":"text"},{"text":"(required for any change)","type":"text","marks":[{"type":"em"}]}]}]}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Artifact self-check — before closing","type":"text"}]},{"type":"paragraph","content":[{"text":"A good artifact answers all of: ownership/boundary; blast radius (consumers, layers); contract safety (types/schemas/protocols); local vs structural vs architectural; build/config involvement; reliability under failure/retry/concurrency; observability sufficiency; rollout/migration reversibility; folder bloat and file/folder naming fitness; modularity trajectory; documented assumptions; safest next move. If the answer only explains one file, it is usually incomplete.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Tool Families And Their Jobs","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"1. Local Octocode tools","type":"text"}]},{"type":"paragraph","content":[{"text":"First tools for workspace mapping — not a fallback.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tool","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use it for","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localViewStructure","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Package/module layout, folder depth, source spread","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localFindFiles","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Large files, recent churn, suspicious filenames, likely hotspots","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localSearchCode","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fast discovery, symbol search, text patterns, and ","type":"text"},{"text":"lineHint","type":"text","marks":[{"type":"code_inline"}]},{"text":" for LSP","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"localGetFileContent","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Final code reading after you know what you are looking at","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Rules:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not start with a full-file read when discovery tools can narrow the target first.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When ","type":"text"},{"text":"localSearchCode","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns zero matches: (1) widen the pattern (drop regex meta-chars, try a substring), (2) fall back to ","type":"text"},{"text":"localFindFiles","type":"text","marks":[{"type":"code_inline"}]},{"text":" on likely filename patterns, (3) retry with the literal symbol name. Only after that may you broaden to ","type":"text"},{"text":"localViewStructure","type":"text","marks":[{"type":"code_inline"}]},{"text":" for layout reconnaissance.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"2. LSP tools","type":"text"}]},{"type":"paragraph","content":[{"text":"Use LSP tools to understand real semantic relationships. ","type":"text"},{"text":"lineHint","type":"text","marks":[{"type":"code_inline"}]},{"text":" rule stated in §Quick decision cheatsheet — applies to every entry below.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Tool","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Use it for","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"lspGotoDefinition","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What symbol is this really?","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Blast radius, all usages, dead-code checks (types, vars, anything)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"lspCallHierarchy","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Function call flow only: incoming callers and outgoing callees","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"3. AST tools — structural proof","type":"text"}]},{"type":"paragraph","content":[{"text":"Authoritative proof for code-shape, redundancy, and smell claims; use when text search is too weak.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Script","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Role","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Example invocation","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/ast/search.js","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Live ast-grep search on current source — authoritative","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"node scripts/ast/search.js --preset empty-catch --root ./src","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/ast/search.js","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Project-specific structural claim","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"node scripts/ast/search.js --pattern 'if ($C) { return $V }' --json","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/ast/tree-search.js","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fast triage over cached AST trees from a prior scan","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"node scripts/ast/tree-search.js -i .octocode/scan -k function_declaration --limit 25","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Rules:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Presets cover the common clean-code rules; list them with ","type":"text"},{"text":"node scripts/ast/search.js --list-presets","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Python presets are prefixed ","type":"text"},{"text":"py-","type":"text","marks":[{"type":"code_inline"}]},{"text":" (e.g. ","type":"text"},{"text":"py-bare-except","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"py-mutable-default","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"tree-search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" first to narrow, then ","type":"text"},{"text":"search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" to confirm on live code.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If a structural claim matters to a decision, confirm it with AST before presenting it as fact.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pair every match with its ","type":"text"},{"text":"file:line","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the summary.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For preset catalog, pattern syntax, and Python node kinds, see ","type":"text"},{"text":"ast-reference.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/ast-reference.md","title":null}}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"4. Scanner — architecture and flow","type":"text"}]},{"type":"paragraph","content":[{"text":"Use ","type":"text"},{"text":"scripts/run.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the question is bigger than one symbol or one file. It surfaces dependency cycles, chokepoints, coupling pressure, layer violations, dead-code clusters, security sinks, test gaps, and hot paths — the issues local reading misses.","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Script","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Role","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Example invocation","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/run.js","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Default scoped scan","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"node scripts/run.js --scope=packages/my-pkg","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/run.js --graph","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Architecture graph (cycles, SDP, coupling)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"node scripts/run.js --graph --out .octocode/scan/scan.json","type":"text","marks":[{"type":"code_inline"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scripts/run.js --json","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Machine-readable findings","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"node scripts/run.js --json --out .octocode/scan/scan.json","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Use scanner output to reason about: where change risk concentrates, whether a module is structurally unhealthy, whether a local fix ignores a broader architectural problem, which area to refactor first. Flags, thresholds, scope syntax, and exit codes: ","type":"text"},{"text":"cli-reference.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/cli-reference.md","title":null}}]},{"text":". Reading the scan artifacts: ","type":"text"},{"text":"output-files.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/output-files.md","title":null}}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"First-run install.","type":"text","marks":[{"type":"strong"}]},{"text":" Scripts auto-install native deps (","type":"text"},{"text":"tree-search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" needs none) using the detected package manager (pnpm-lock.yaml → pnpm, yarn.lock → yarn, else npm); on failure they exit non-zero with the manual command. Opt out with ","type":"text"},{"text":"OCTOCODE_NO_AUTO_INSTALL=1","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Invoking the scripts.","type":"text","marks":[{"type":"strong"}]},{"text":" Skill is ","type":"text"},{"text":"private: true","type":"text","marks":[{"type":"code_inline"}]},{"text":" with no ","type":"text"},{"text":"bin","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"npx octocode-engineer-*","type":"text","marks":[{"type":"code_inline"}]},{"text":" is ","type":"text"},{"text":"invalid","type":"text","marks":[{"type":"strong"}]},{"text":". ","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" applies only to externals in ","type":"text"},{"text":"externals.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/externals.md","title":null}}]},{"text":". Forms:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Form","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Example","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"When","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Absolute path","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"node \u003cSKILL_DIR>/scripts/run.js --scope=packages/my-pkg","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"From any cwd (default)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"yarn","type":"text","marks":[{"type":"code_inline"}]},{"text":" alias","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cd \u003cSKILL_DIR> && yarn analyze|analyze:full|analyze:graph|analyze:json","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Idiomatic in-skill shortcut","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"yarn","type":"text","marks":[{"type":"code_inline"}]},{"text":" alias (AST)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cd \u003cSKILL_DIR> && yarn search|search:json|search:presets|search:trees|search:trees:json","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AST scripts","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Raw node (cwd-local)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cd \u003cSKILL_DIR> && node scripts/run.js [flags]","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"When you need flags not covered by an alias","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Full flag catalog + exit codes: ","type":"text"},{"text":"cli-reference.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/cli-reference.md","title":null}}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"Cost.","type":"text","marks":[{"type":"strong"}]},{"text":" Prefer ","type":"text"},{"text":"--scope=\u003cpath>","type":"text","marks":[{"type":"code_inline"}]},{"text":" over full-repo; reuse existing artifacts when they answer the question; on staleness re-run only the minimal scope.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"5. Cross-cutting quality checks","type":"text"}]},{"type":"paragraph","content":[{"text":"The Clean-Architecture principles and the six analytic dimensions already cover naming, cohesion, duplication, layering, contracts, types, data flow, and execution. Use this section only for concerns ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" directly named there:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Check","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Focus","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Reliability & resilience","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"retry policy, timeout handling, failure isolation, idempotency, fallback behavior","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Observability & operability","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"logging quality, metric/tracing coverage, diagnosability, alert/runbook readiness","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Rollout & migration","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"feature flags, backward-compatibility windows, rollback path, migration sequencing","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Build & config","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"ESM/CJS mismatch, module resolution, script wiring, runtime assumptions","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Structure health","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"leaf-folder bloat, vague shared/helper buckets, depth balance, source spread, file and folder naming consistency for the project","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Docs","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"whether critical assumptions, contracts, flows, setup, migrations, and risks are documented","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CSS hygiene","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"selector scope, token reuse, naming clarity, dead styles (when frontend styling is touched)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"knip","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"unused exports, files, dependencies, dead integration edges (run on refactors)","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Skip items that do not apply. For concrete ","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" commands for ","type":"text"},{"text":"eslint","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"tsc","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"knip","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"stylelint","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"type-coverage","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"dep-cruiser","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"ruff","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"mypy","type":"text","marks":[{"type":"code_inline"}]},{"text":", and related externals, see ","type":"text"},{"text":"externals.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/externals.md","title":null}}]},{"text":" — ","type":"text"},{"text":"ask before running","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"6. Execution discipline","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Per-step","type":"text","marks":[{"type":"strong"}]},{"text":": declare the next tool and why it is the cheapest proof; separate facts from inference; carry forward concrete identifiers (","type":"text"},{"text":"lineHint","type":"text","marks":[{"type":"code_inline"}]},{"text":", paths, symbols); verify explicitly after edits.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Status updates","type":"text","marks":[{"type":"strong"}]},{"text":": say what was checked and what remains — no vague progress, no \"looks fine\" without evidence, no switching to edits without a short flow summary.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Depth control","type":"text","marks":[{"type":"strong"}]},{"text":": mark ","type":"text"},{"text":"N/A","type":"text","marks":[{"type":"code_inline"}]},{"text":" on irrelevant checks; go deeper only where risk/uncertainty is meaningful; pick the lightest evidence path.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Token efficiency","type":"text","marks":[{"type":"strong"}]},{"text":": one investigation thread at a time; reference prior checkpoints instead of restating evidence; stop research when confidence is sufficient; summarize findings as ","type":"text"},{"text":"issue → evidence → impact → action","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Task tracking","type":"text","marks":[{"type":"strong"}]},{"text":": use todos when the work spans research → plan → implement → verify → docs. Track investigation, decision, implementation, verification, docs follow-up.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Default Working Order","type":"text"}]},{"type":"paragraph","content":[{"text":"Non-trivial tasks follow this arc (recommended, not mandatory): clarify the question → create todos if multi-step → map layout with local tools → trace symbols with LSP → identify critical and failure paths → validate structure with AST → check architecture/contracts/reliability/build/docs with the scanner → read the code in context → validate design docs or RFCs against current flows and contracts → apply both lenses (","type":"text"},{"text":"confirmed|likely|uncertain","type":"text","marks":[{"type":"code_inline"}]},{"text":" with evidence) → produce the artifact → pause at any hard gate → decide to explain, plan, or edit.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Task shapes","type":"text"}]},{"type":"paragraph","content":[{"text":"Same working order; emphasis differs:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Task","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Weight on","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Notes","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Code understanding","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"steps 3–8 (layout → LSP → AST → scoped scanner → read)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Deliverable is the artifact.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Bug fixing","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Flows + Execution from failing behavior to entry point; adjacent error/retry/contract risk","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fix the smallest layer that solves the root cause; escalate via Smallest-fix vs. safest-fix gate when systemic.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Refactor","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"blast radius (","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]},{"text":"), scoped scan, duplication inventory, then plan","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Prefer extracting modules, clarifying contracts, simplifying flows over cosmetic reshuffling. Verify per batch.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Architecture review","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scanner ","type":"text"},{"text":"--graph","type":"text","marks":[{"type":"code_inline"}]},{"text":" first, then LSP on candidate modules, then read representatives","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Report both local and system-level causes.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"RFC / design validation","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"map each claim to code ownership; verify flow, contract, and architecture alignment","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Mark claims `confirmed","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Before / During / After A Change","type":"text"}]},{"type":"paragraph","content":[{"text":"Operational actions per phase (investigation substance = lenses + artifact):","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Before","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Produce the understanding artifact.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Map design-doc / RFC claims to concrete code ownership when one exists.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Look for an existing local pattern before inventing a new one.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"During","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Keep edits in the smallest responsible layer; preserve boundaries unless the plan intentionally changes them.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Maintain contract/protocol compatibility unless an explicit migration is in scope.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If root cause turns out structural mid-task, stop and hit the Smallest-fix vs. safest-fix gate instead of continuing with a cosmetic patch.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"After","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run tests, lint, and build/type-check.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Re-check changed symbols with LSP after renames/moves; run a scoped scanner pass for non-trivial changes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Run ","type":"text"},{"text":"knip","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the refactor may leave dead artifacts; CSS checks when styles changed.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Re-validate the artifact's dimensions against the final implementation; note any remaining architectural risk even if the code now works.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sync docs / RFC sections touched by the change.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Confidence Rules","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Level","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Meaning","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Example","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"confirmed","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"≥2 approaches agree, or one authoritative source","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AST proves empty catch + LSP shows function widely used","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"likely","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"good evidence, one angle still missing","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scanner hotspot agrees with code shape, blast radius unverified","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"uncertain","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"conflicting / incomplete / single weak source","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"text search suggests dead code, LSP unavailable","type":"text"}]}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Evidence conflict resolution","type":"text"}]},{"type":"paragraph","content":[{"text":"When sources disagree on a claim that affects a decision, prefer the source whose domain it is, then re-verify the weaker source:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Claim type","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Authoritative source","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Corroborator","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Symbol identity, references, callers/callees","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"LSP (","type":"text"},{"text":"lspGotoDefinition","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"lspCallHierarchy","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AST + code read","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Structural shape (empty catch, ","type":"text"},{"text":"any","type":"text","marks":[{"type":"code_inline"}]},{"text":" usage, nested ternary, preset match)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AST (","type":"text"},{"text":"scripts/ast/search.js","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scanner + code read","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Runtime behavior and side effects","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"targeted code read + tests","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"AST + scanner","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Architecture pressure (coupling, cycles, SDP, hot paths)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scanner (","type":"text"},{"text":"scripts/run.js","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"LSP references + code read","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Contract/schema shape at a boundary","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"the schema/IDL file itself + ","type":"text"},{"text":"lspGotoDefinition","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"references to generated types","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"If the authoritative source contradicts a weaker one, mark the weaker one as \"re-verify\" in the artifact and note the resolution. Never present conflicting evidence as resolved without a recorded tiebreak.","type":"text"}]},{"type":"paragraph","content":[{"text":"For step-by-step playbooks that confirm or dismiss a finding (dead code, duplicate, unsafe ","type":"text"},{"text":"any","type":"text","marks":[{"type":"code_inline"}]},{"text":", layer violation, cycle, etc.), see ","type":"text"},{"text":"validation-playbooks.md","type":"text","marks":[{"type":"link","attrs":{"href":"./references/validation-playbooks.md","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"User-Ask Gates","type":"text"}]},{"type":"paragraph","content":[{"text":"A gate is a ","type":"text"},{"text":"hard stop","type":"text","marks":[{"type":"strong"}]},{"text":" — do not proceed without the user's explicit decision.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Hard gates (always stop and ask)","type":"text"}]},{"type":"paragraph","content":[{"text":"State situation in ≤3 lines, list options, name tradeoff, recommend one.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Ambiguous scope","type":"text","marks":[{"type":"strong"}]},{"text":" — the task has more than one reasonable interpretation and the right one changes the plan. ","type":"text"},{"text":"Ambiguous:","type":"text","marks":[{"type":"em"}]},{"text":" \"fix the login bug\". ","type":"text"},{"text":"Unambiguous:","type":"text","marks":[{"type":"em"}]},{"text":" \"fix 500 on /api/login when password field is empty\".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Public contract change","type":"text","marks":[{"type":"strong"}]},{"text":" — a public API, exported symbol, event schema, DB schema, CLI flag, or wire protocol would change. ","type":"text"},{"text":"Fires:","type":"text","marks":[{"type":"em"}]},{"text":" renaming an exported function with external consumers. ","type":"text"},{"text":"Does not fire:","type":"text","marks":[{"type":"em"}]},{"text":" renaming a private helper with no references.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Cross-layer/cross-package change","type":"text","marks":[{"type":"strong"}]},{"text":" — the fix requires editing more than one layer or crosses a workspace boundary. ","type":"text"},{"text":"Fires:","type":"text","marks":[{"type":"em"}]},{"text":" bug fix needs changes in ","type":"text"},{"text":"packages/domain","type":"text","marks":[{"type":"code_inline"}]},{"text":" AND ","type":"text"},{"text":"packages/http-adapter","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"Does not fire:","type":"text","marks":[{"type":"em"}]},{"text":" edit contained in one package.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dependency-rule violation required","type":"text","marks":[{"type":"strong"}]},{"text":" — the cleanest fix would break the dependency rule, break a boundary, or introduce a new cycle. ","type":"text"},{"text":"Fires:","type":"text","marks":[{"type":"em"}]},{"text":" domain module would need to import the HTTP adapter. ","type":"text"},{"text":"Does not fire:","type":"text","marks":[{"type":"em"}]},{"text":" adapter importing domain (correct direction).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Destructive or irreversible action","type":"text","marks":[{"type":"strong"}]},{"text":" — delete/rename shared files, drop tables, reset branches, force-push, publish packages, send messages/PRs on the user's behalf. ","type":"text"},{"text":"Fires:","type":"text","marks":[{"type":"em"}]},{"text":" ","type":"text"},{"text":"git reset --hard","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"rm -rf","type":"text","marks":[{"type":"code_inline"}]},{"text":", publishing an npm version. ","type":"text"},{"text":"Does not fire:","type":"text","marks":[{"type":"em"}]},{"text":" local file edits on a feature branch.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Blast radius > ~5 consumers","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns many callers and the change alters their behavior. ","type":"text"},{"text":"Fires:","type":"text","marks":[{"type":"em"}]},{"text":" changing a utility called by 20 files. ","type":"text"},{"text":"Does not fire:","type":"text","marks":[{"type":"em"}]},{"text":" changing a helper with 2 callers, both of which are co-edited.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Two refinement attempts failed","type":"text","marks":[{"type":"strong"}]},{"text":" — same approach tried twice and the evidence still doesn't line up. ","type":"text"},{"text":"Fires:","type":"text","marks":[{"type":"em"}]},{"text":" two different search patterns both return empty for a symbol you expected. ","type":"text"},{"text":"Does not fire:","type":"text","marks":[{"type":"em"}]},{"text":" one failed attempt with a clear next angle.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Missing gate prerequisite","type":"text","marks":[{"type":"strong"}]},{"text":" — no tests exist for the area, no owner documented, no schema available, and the change needs one. ","type":"text"},{"text":"Fires:","type":"text","marks":[{"type":"em"}]},{"text":" user asks for a refactor of untested legacy code. ","type":"text"},{"text":"Does not fire:","type":"text","marks":[{"type":"em"}]},{"text":" tests exist and cover the change surface.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Conflicting evidence","type":"text","marks":[{"type":"strong"}]},{"text":" — authoritative and corroborating sources (see §Evidence conflict resolution) disagree on a claim that matters to the decision. ","type":"text"},{"text":"Fires:","type":"text","marks":[{"type":"em"}]},{"text":" LSP says 0 references, AST shows an import of the symbol. ","type":"text"},{"text":"Does not fire:","type":"text","marks":[{"type":"em"}]},{"text":" scanner is noisy but LSP is clear.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Smallest-fix vs. safest-fix conflict","type":"text","marks":[{"type":"strong"}]},{"text":" — a narrow patch would work but the root cause is structural. ","type":"text"},{"text":"Fires:","type":"text","marks":[{"type":"em"}]},{"text":" bug can be fixed by adding a null check but the real cause is a missing contract between two layers. ","type":"text"},{"text":"Does not fire:","type":"text","marks":[{"type":"em"}]},{"text":" the narrow patch IS the right layer.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Soft gates (ask if material)","type":"text"}]},{"type":"paragraph","content":[{"text":"Ask when the decision materially changes the outcome; otherwise proceed and note the assumption.","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Multiple reasonable architectures exist for a greenfield area.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Framework / library choice where the project has no established pattern.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Rollout strategy (feature flag vs direct deploy) for a behavior change.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Migration sequencing when old and new consumers coexist.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Whether to fix adjacent smells discovered mid-task or log them as follow-ups.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Ask template","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Gate:","type":"text","marks":[{"type":"strong"}]},{"text":" \u003cwhat triggered it, 1 line> ","type":"text"},{"text":"Options:","type":"text","marks":[{"type":"strong"}]}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\u003coption A> — tradeoff","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\u003coption B> — tradeoff ","type":"text"},{"text":"Recommendation:","type":"text","marks":[{"type":"strong"}]},{"text":" \u003cA or B, 1 line why> ","type":"text"},{"text":"Blocking:","type":"text","marks":[{"type":"strong"}]},{"text":" \u003cwhat I will not do until you decide>","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"Keep it short. The user should be able to respond in one sentence. If the user picks an option you did not recommend, record the decision and the stated reason in the ","type":"text"},{"text":"Architecture health","type":"text","marks":[{"type":"strong"}]},{"text":" / ","type":"text"},{"text":"Risk vector","type":"text","marks":[{"type":"strong"}]},{"text":" sections and proceed without re-asking. Do not argue against a decided option — raise residual risks once, then execute.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Gate discipline","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not ask when the answer is obvious from the code, CLAUDE.md, or prior context.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not silently continue past a hard gate because \"it seemed fine\" — that is the failure gates prevent.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If a gate fires mid-implementation, stop at a clean checkpoint (commit if appropriate, revert if not) and ask.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"After a decision, record it in the artifact so future steps carry it forward.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Gates bind regardless of fallback state.","type":"text","marks":[{"type":"strong"}]},{"text":" If an Octocode tool is unavailable and you are in §Fallback Mode, gates still fire on the same conditions; lower confidence does not weaken the rule.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Hard Rules (recap)","type":"text"}]},{"type":"paragraph","content":[{"text":"Non-negotiable guardrails beyond the §Operating contract (which already binds gates and tool universe):","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not present raw detector output as unquestioned fact.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not use ","type":"text"},{"text":"lspCallHierarchy","type":"text","marks":[{"type":"code_inline"}]},{"text":" on non-function symbols — use ","type":"text"},{"text":"lspFindReferences","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not judge shared modules from one file read alone.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not claim design/RFC compliance without claim-by-claim evidence.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not ignore build/config evidence when runtime behavior may depend on it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not apply a quick patch when the real issue is contracts, boundaries, duplication, or architecture — hit the Smallest-fix vs. safest-fix gate.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Check blast radius before changing shared symbols.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Re-sync docs/RFCs when implementation changes architecture, contracts, rollout assumptions, or constraints.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Fallback Mode","type":"text"}]},{"type":"paragraph","content":[{"text":"Fallback applies only when an Octocode tool is truly ","type":"text"},{"text":"unavailable","type":"text","marks":[{"type":"strong"}]},{"text":" — not registered, unreachable, or returning hard errors. A warning inside a successful response is ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" a failure.","type":"text"}]},{"type":"paragraph","content":[{"text":"If unavailable:","type":"text","marks":[{"type":"strong"}]},{"text":" continue with AST tools and the scanner; rely more on local search and direct code reading within this skill's tool universe; reduce confidence on semantic claims; label proven vs. inferred.","type":"text"}]},{"type":"paragraph","content":[{"text":"If degraded but completed:","type":"text","marks":[{"type":"strong"}]},{"text":" treat the response as valid; on empty/wrong results, retry with a simpler input (drop regex meta-characters, switch to literal search, narrow the path). ","type":"text"},{"text":"Do not","type":"text","marks":[{"type":"strong"}]},{"text":" switch to native Claude Code tools — that leaves the skill's evidence model.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Companion Skill","type":"text"}]},{"type":"paragraph","content":[{"text":"Pair with the RFC skill when architecture options, trade-offs, or migration strategy need a formal proposal before coding:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"octocode-rfc-generator","type":"text","marks":[{"type":"link","attrs":{"href":"https://skills.sh/bgauryy/octocode-mcp/octocode-rfc-generator","title":null}}]},{"text":" — generate a smart RFC from validated system evidence.","type":"text"}]}]}]},{"type":"hr","attrs":{"markup":"---"}}]},"metadata":{"date":"2026-06-05","name":"octocode-engineer","author":"@skillopedia","source":{"stars":854,"repo_name":"octocode-mcp","origin_url":"https://github.com/bgauryy/octocode-mcp/blob/HEAD/skills/octocode-engineer/SKILL.md","repo_owner":"bgauryy","body_sha256":"900d9fb5754079db778f335568d6be3c64399b04af2e64061014ff6633b1e5c9","cluster_key":"f97fbd9fc44bf8eb5c812d68604bb19ef0e5f3f8399638b57c78d59731f30233","clean_bundle":{"format":"clean-skill-bundle-v1","source":"bgauryy/octocode-mcp/skills/octocode-engineer/SKILL.md","attachments":[{"id":"0735313f-e475-5030-9ee3-ce2ebc35b6a9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0735313f-e475-5030-9ee3-ce2ebc35b6a9/attachment","path":".gitignore","size":30,"sha256":"daa5bfa757fd69d81a0908b946c9364598f167b053c48bee35bafb4f84ea3d69","contentType":"text/plain; charset=utf-8"},{"id":"60c217ff-f3cb-5047-acea-ac4cac9a7daa","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/60c217ff-f3cb-5047-acea-ac4cac9a7daa/attachment.md","path":"README.md","size":5604,"sha256":"8a21fafe851c9132b8cc63b004c7c2aeac420f0193347964ee4c54322e3484df","contentType":"text/markdown; charset=utf-8"},{"id":"5f01a353-5204-5c09-babd-18e790052816","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5f01a353-5204-5c09-babd-18e790052816/attachment.mjs","path":"build.mjs","size":1112,"sha256":"7e5c2fe3e9dc17a496857154a95870e9084e07d132a24f7d5d18f4bc31b52253","contentType":"text/javascript"},{"id":"6838d670-657f-582a-b4da-bb864fb2640d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6838d670-657f-582a-b4da-bb864fb2640d/attachment.mjs","path":"eslint.config.mjs","size":1134,"sha256":"14a29d1affe8faa488c9ff7e0b1f2a2982c568001bc1070f2501d313a8ee9c6e","contentType":"text/javascript"},{"id":"96eeaab1-9602-578c-abcf-8640ee345526","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/96eeaab1-9602-578c-abcf-8640ee345526/attachment.json","path":"package.json","size":2128,"sha256":"277d98b7dc221cc7dff2e4062ad02c62f67edb8eb272b878ea887786245bf71a","contentType":"application/json; charset=utf-8"},{"id":"5868a364-1f33-5e1d-9c60-e0ae23846dc3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5868a364-1f33-5e1d-9c60-e0ae23846dc3/attachment.md","path":"references/ast-reference.md","size":8893,"sha256":"d33a9154e4a5e5200c7c00cb434d7a9573dedce31b24168de512db75dedc68c9","contentType":"text/markdown; charset=utf-8"},{"id":"d4228c8e-3e8f-5d17-9590-47194237b796","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d4228c8e-3e8f-5d17-9590-47194237b796/attachment.md","path":"references/cli-reference.md","size":14092,"sha256":"2a61e24ce554a319532876e351ba8d2986210ac47654385366aa59b92ae58e4e","contentType":"text/markdown; charset=utf-8"},{"id":"73ce6b24-d0e7-5ead-a6fd-0a048fda11fd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/73ce6b24-d0e7-5ead-a6fd-0a048fda11fd/attachment.md","path":"references/externals.md","size":4487,"sha256":"010140c44a5b44aa365fd9cf24d97818511dc84c32605016d447fbeabf77d5af","contentType":"text/markdown; charset=utf-8"},{"id":"a25470b1-e7f5-5334-b308-7a9a8f2400dc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a25470b1-e7f5-5334-b308-7a9a8f2400dc/attachment.md","path":"references/output-files.md","size":9952,"sha256":"c0e1b9282d4e7754c2a500403bc80f8e34203661a3da223027f9e2a907589368","contentType":"text/markdown; charset=utf-8"},{"id":"aecdaf22-a440-53a4-a187-24b6061d4177","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/aecdaf22-a440-53a4-a187-24b6061d4177/attachment.md","path":"references/output-format.md","size":4079,"sha256":"7954f17710e6896cc5f1fe084b5e88e1a2374d5d03fe9cfca712c64cc3d2eaf7","contentType":"text/markdown; charset=utf-8"},{"id":"cf890739-3918-5f94-8c52-1bf4449cda4f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cf890739-3918-5f94-8c52-1bf4449cda4f/attachment.md","path":"references/quality-indicators.md","size":12018,"sha256":"d20174c649c671e974dcadd0921ab88e6b466d2836c52cfdaae21297316e0977","contentType":"text/markdown; charset=utf-8"},{"id":"19fa4a17-7ea4-5ac7-a76d-b89c21df8ec9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/19fa4a17-7ea4-5ac7-a76d-b89c21df8ec9/attachment.md","path":"references/tool-workflows.md","size":17520,"sha256":"784400f422c6e170f711eb8272d7987fad20b5d49af7c2cb3245706d3cd49ae8","contentType":"text/markdown; charset=utf-8"},{"id":"73bd7609-a383-561c-8f3c-788dcd5dbf9d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/73bd7609-a383-561c-8f3c-788dcd5dbf9d/attachment.md","path":"references/validation-playbooks.md","size":6751,"sha256":"5c182d93ae0627236eead9873a89f0f4961cd6441bf4c8bf74981b2fcf56eb19","contentType":"text/markdown; charset=utf-8"},{"id":"9f90ad65-243c-5e0c-bee0-74a1442ee23f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/9f90ad65-243c-5e0c-bee0-74a1442ee23f/attachment.js","path":"scripts/ast/search.js","size":3621695,"sha256":"709cdfec1bdb385b312564f336c8ca6707ea441c70e139e7309c506cc5ba82a4","contentType":"application/javascript; charset=utf-8"},{"id":"3f6bf196-b694-54b7-b2e0-2f0b5187f94a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3f6bf196-b694-54b7-b2e0-2f0b5187f94a/attachment.js","path":"scripts/ast/tree-search.js","size":6696,"sha256":"4fcca1def74de1a705a2f0e95923092b545635f2db0412da04772d35c6a0ba46","contentType":"application/javascript; charset=utf-8"},{"id":"a760b95e-1126-5ee8-951f-3d6f9eab8be9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a760b95e-1126-5ee8-951f-3d6f9eab8be9/attachment.js","path":"scripts/run.js","size":3887978,"sha256":"0b494bf83359daa2cf5fdccc7acd4a79a61a67a4ae341c34e5165449be5f7125","contentType":"application/javascript; charset=utf-8"},{"id":"ffca9607-07cf-5c2f-b347-c569ab7350fb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ffca9607-07cf-5c2f-b347-c569ab7350fb/attachment.ts","path":"src/analysis/dependencies.test.ts","size":18348,"sha256":"2fc44a2ac743f232651f68f5f32697441f1574e5823d7dbfb2a6d476305e8288","contentType":"text/typescript; charset=utf-8"},{"id":"e1565fc4-fbf5-5da8-8725-356ad01fa3eb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e1565fc4-fbf5-5da8-8725-356ad01fa3eb/attachment.ts","path":"src/analysis/dependencies.ts","size":10540,"sha256":"e075e03c2b6adb60d486d93bf99be7bc4cb1702e23dfddc06b1b0e476a640290","contentType":"text/typescript; charset=utf-8"},{"id":"63d8aebe-dde8-5be4-bb28-aa9ea168a99d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/63d8aebe-dde8-5be4-bb28-aa9ea168a99d/attachment.ts","path":"src/analysis/dependency-summary.test.ts","size":19299,"sha256":"b66824ec3575c3d4f416366be9b96f09db09288429fbc1488cfae3ccdc32ba86","contentType":"text/typescript; charset=utf-8"},{"id":"cfdd9653-765a-5edb-8f47-e9cb5d14eb5d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cfdd9653-765a-5edb-8f47-e9cb5d14eb5d/attachment.ts","path":"src/analysis/dependency-summary.ts","size":7392,"sha256":"33f0bfce81685fd5eea596d86e0c9934e6a23feef83eff540fbd774bae708dee","contentType":"text/typescript; charset=utf-8"},{"id":"7b0645b9-b82e-52d7-971c-8c2c6c671050","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7b0645b9-b82e-52d7-971c-8c2c6c671050/attachment.ts","path":"src/analysis/discovery.test.ts","size":16823,"sha256":"a2efea2d57478a90fad9a2ecfb91b5edb8a79d0fa1d570f7c98c4756930b7255","contentType":"text/typescript; charset=utf-8"},{"id":"a84a284f-da45-5775-a86a-c8ad4a25e6de","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a84a284f-da45-5775-a86a-c8ad4a25e6de/attachment.ts","path":"src/analysis/discovery.ts","size":3735,"sha256":"56f20ea0632c537a733b11e19d709967f6caa048904834d733b399cbecd35e63","contentType":"text/typescript; charset=utf-8"},{"id":"4c318133-0ce9-5ea8-a1d9-816258ffb48a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4c318133-0ce9-5ea8-a1d9-816258ffb48a/attachment.ts","path":"src/analysis/graph-analytics.test.ts","size":13513,"sha256":"d3d1d7f5bbff6af9485ef87c6b658fa5c1574bef95dcdad86f0370baf9644b19","contentType":"text/typescript; charset=utf-8"},{"id":"8a589ae5-e7a8-51d3-ad04-4a332bac4000","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8a589ae5-e7a8-51d3-ad04-4a332bac4000/attachment.ts","path":"src/analysis/graph-analytics.ts","size":18117,"sha256":"128830a2cb2f669f9f4736657e44e5c268eba73bf45f8b9e191a47816eb1ad55","contentType":"text/typescript; charset=utf-8"},{"id":"89df4307-ae0f-54a1-93fd-60b041f9d389","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/89df4307-ae0f-54a1-93fd-60b041f9d389/attachment.ts","path":"src/analysis/semantic.test.ts","size":48213,"sha256":"619233e857614ab849b0218b6bd24b2349a9b4c9de5e92c624a6a38a18eee813","contentType":"text/typescript; charset=utf-8"},{"id":"2b13ab81-7ac1-5c20-adf7-979390da1a11","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2b13ab81-7ac1-5c20-adf7-979390da1a11/attachment.ts","path":"src/analysis/semantic.ts","size":23889,"sha256":"046341b54e3744d814680d383be95b81a7b9aca441032b7db89657d972ee11f9","contentType":"text/typescript; charset=utf-8"},{"id":"45fe3594-4b20-5d37-805f-0799c813a61f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/45fe3594-4b20-5d37-805f-0799c813a61f/attachment.ts","path":"src/ast/helpers.test.ts","size":6372,"sha256":"3366e02ec3d2f8a2be0bafdda28b1e5941a4ab2eb7b69cd95b6402748114f92e","contentType":"text/typescript; charset=utf-8"},{"id":"71f22260-bc72-5875-87e8-13ffe254cd51","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/71f22260-bc72-5875-87e8-13ffe254cd51/attachment.ts","path":"src/ast/helpers.ts","size":1450,"sha256":"1412867dc34747f70e0caa5a870ea28e40cc5c6cf18bb191da6b04764fe87124","contentType":"text/typescript; charset=utf-8"},{"id":"6f6e7620-7f88-54e5-9aa2-96db1a0179ea","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6f6e7620-7f88-54e5-9aa2-96db1a0179ea/attachment.ts","path":"src/ast/metrics.test.ts","size":9245,"sha256":"3a5a9a689726deef15cbff1ff95785c2228e79491bf1e308fe23054a4585cf4c","contentType":"text/typescript; charset=utf-8"},{"id":"759a0327-2045-579d-890d-a0d0134961e8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/759a0327-2045-579d-890d-a0d0134961e8/attachment.ts","path":"src/ast/metrics.ts","size":5994,"sha256":"54e1a49f7fe1be5e680ab9caed4d1feb6724e64369a099aa3f12c704aaee16bc","contentType":"text/typescript; charset=utf-8"},{"id":"ad6e957c-403d-5d1e-b541-03b3e8a69773","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/ad6e957c-403d-5d1e-b541-03b3e8a69773/attachment.ts","path":"src/ast/search-main.ts","size":21974,"sha256":"cd04ad640aa6cdb2e53e02fa0800d4a47b9fb0708d7ebec673d6e0c619875a3f","contentType":"text/typescript; charset=utf-8"},{"id":"109278ce-5c58-531c-9d0f-fa1ef6cf61b1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/109278ce-5c58-531c-9d0f-fa1ef6cf61b1/attachment.ts","path":"src/ast/search.test.ts","size":31266,"sha256":"c16e239ad39955d18a8479fa421e82473c3264feb7955c4b9f6fe2cb4428d2b4","contentType":"text/typescript; charset=utf-8"},{"id":"24a36baf-772a-5c18-a479-8bd28f06246c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/24a36baf-772a-5c18-a479-8bd28f06246c/attachment.ts","path":"src/ast/search.ts","size":617,"sha256":"851473783fd615410ed34c3d5b7596288e471d76366dce7a307a4bc2e418481d","contentType":"text/typescript; charset=utf-8"},{"id":"0f0da509-0cf7-556e-8964-af4214529173","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0f0da509-0cf7-556e-8964-af4214529173/attachment.ts","path":"src/ast/tree-search.test.ts","size":6113,"sha256":"b303c089c7bf21c413aeef2360bc8ddfe61c489fe9d27b633230e5e96ec744a3","contentType":"text/typescript; charset=utf-8"},{"id":"e52ccaba-7930-50a4-bc35-7d73f265ae6e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e52ccaba-7930-50a4-bc35-7d73f265ae6e/attachment.ts","path":"src/ast/tree-search.ts","size":12025,"sha256":"f70913e40403b7bd051a01e4a7b30f09eeac659ac7a0167513fb4eaa349d1251","contentType":"text/typescript; charset=utf-8"},{"id":"a68a5845-73df-5dee-b642-54ce5c06a21a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a68a5845-73df-5dee-b642-54ce5c06a21a/attachment.ts","path":"src/ast/tree-sitter.test.ts","size":14509,"sha256":"8d326d81a8c7d22d3efba5db8a508d04815dd262d142608638c23583b78d3a6e","contentType":"text/typescript; charset=utf-8"},{"id":"3de99f45-2972-5909-8983-cbf3365ddd2c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3de99f45-2972-5909-8983-cbf3365ddd2c/attachment.ts","path":"src/ast/tree-sitter.ts","size":12732,"sha256":"a4f3984bf5ef7bbcdc995afd6c446bba4fc37dd8aa6e6406f8952f5467681ca9","contentType":"text/typescript; charset=utf-8"},{"id":"d6025688-0ea4-5ad8-8a14-ad4f41bcdb2d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d6025688-0ea4-5ad8-8a14-ad4f41bcdb2d/attachment.ts","path":"src/ast/ts-analyzer.test.ts","size":56193,"sha256":"63155fd49790a2ea5feffac9abd0d0c4d6a5e9f7899519ce192d7de6e89c523b","contentType":"text/typescript; charset=utf-8"},{"id":"cf9fe322-e161-5b22-9ad2-fa5d2589ac16","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cf9fe322-e161-5b22-9ad2-fa5d2589ac16/attachment.ts","path":"src/ast/ts-analyzer.ts","size":20153,"sha256":"ec1f8baebe92a9486390d822765f39c78e1969ba3192b5a5933ab88e94907a2b","contentType":"text/typescript; charset=utf-8"},{"id":"bb9e1c2f-f76f-5b0d-b332-c4420a0e4e41","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/bb9e1c2f-f76f-5b0d-b332-c4420a0e4e41/attachment.ts","path":"src/build-output.test.ts","size":2954,"sha256":"c886b145742fa540fa2aef38fa616c87ffbb9eee6a879e4ca6e459492cec5e92","contentType":"text/typescript; charset=utf-8"},{"id":"7d7d604d-c049-55be-b043-cc276a849109","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7d7d604d-c049-55be-b043-cc276a849109/attachment.ts","path":"src/collectors/chains.ts","size":2116,"sha256":"7cb7faf05f21a8d3879a1f41432dd9f2002c7a153d761897928bf371d8832d7a","contentType":"text/typescript; charset=utf-8"},{"id":"6838f472-e3d8-5367-a7d2-f706fbe7e456","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6838f472-e3d8-5367-a7d2-f706fbe7e456/attachment.ts","path":"src/collectors/effects.test.ts","size":17097,"sha256":"13d5c3ede206fc6e1b46ff2c8098e4cb7bf3d1301e6c4956cd0baf1ed96fdb8e","contentType":"text/typescript; charset=utf-8"},{"id":"d5a82a1d-302f-5420-a622-932d39f1edbc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d5a82a1d-302f-5420-a622-932d39f1edbc/attachment.ts","path":"src/collectors/effects.ts","size":7852,"sha256":"1ea7d83eee91b7b336954477f0bb393bdd243ef124046b513b0d8fd2c38a8fd2","contentType":"text/typescript; charset=utf-8"},{"id":"4deeefeb-9e86-5cf3-bb52-27928b6ed2a8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4deeefeb-9e86-5cf3-bb52-27928b6ed2a8/attachment.ts","path":"src/collectors/input-sources.test.ts","size":5493,"sha256":"e1c5b86691ecc54249791e67a6152674f498492d36f9854e4f12a01bad195576","contentType":"text/typescript; charset=utf-8"},{"id":"86377d34-0eb4-5a55-bdea-8487b6b78194","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/86377d34-0eb4-5a55-bdea-8487b6b78194/attachment.ts","path":"src/collectors/input-sources.ts","size":6456,"sha256":"0e364febec2f11c542dcc4bb437ff54f1effb1ebd7add45a69195523dd02ac77","contentType":"text/typescript; charset=utf-8"},{"id":"379b50b2-975c-54f6-b85b-b2b4fcfea1c8","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/379b50b2-975c-54f6-b85b-b2b4fcfea1c8/attachment.ts","path":"src/collectors/performance.test.ts","size":2524,"sha256":"d54155661c32aa7a8ce4ea3506cec088f09985471869eb90091661a0b4e80983","contentType":"text/typescript; charset=utf-8"},{"id":"3240d688-329f-52b0-803a-f94d04e7cb26","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3240d688-329f-52b0-803a-f94d04e7cb26/attachment.ts","path":"src/collectors/performance.ts","size":3945,"sha256":"47d4b48e6dd67b1eb9c9b850a9c3cb27fb066e9a886ad4f544d90591f7259791","contentType":"text/typescript; charset=utf-8"},{"id":"4b7f2774-9947-54a7-9f0b-cb9294de5e81","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4b7f2774-9947-54a7-9f0b-cb9294de5e81/attachment.ts","path":"src/collectors/prototype-pollution.test.ts","size":1804,"sha256":"64acfb955352bbf17450ec79ec24f1517c60c49af8dd781a438aa77adbd8f0ab","contentType":"text/typescript; charset=utf-8"},{"id":"2435d3db-db45-58f3-b045-bb225a1db00e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2435d3db-db45-58f3-b045-bb225a1db00e/attachment.ts","path":"src/collectors/prototype-pollution.ts","size":5033,"sha256":"765c5342cacec25c5556c5c8b8e70f37f0c3b7092a73e60bd83ea95eb971420c","contentType":"text/typescript; charset=utf-8"},{"id":"0cc1a09f-6faf-5363-83ff-cfea0e7ba343","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0cc1a09f-6faf-5363-83ff-cfea0e7ba343/attachment.ts","path":"src/collectors/security.test.ts","size":7392,"sha256":"dddb8ab0b1d51547d9f91ca4cdb42b104cff70a2e778635c3b44a48cb97fe1d1","contentType":"text/typescript; charset=utf-8"},{"id":"1d7ad79f-cbc0-530b-9243-659c86963add","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1d7ad79f-cbc0-530b-9243-659c86963add/attachment.ts","path":"src/collectors/security.ts","size":11476,"sha256":"456e3bd112105de84c8af02d5be754438631ff22e472e4a9bd1c214b45956ceb","contentType":"text/typescript; charset=utf-8"},{"id":"8fe82dfb-049f-524e-9a21-d0e4a04c5ffc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8fe82dfb-049f-524e-9a21-d0e4a04c5ffc/attachment.ts","path":"src/collectors/test-profile.test.ts","size":3632,"sha256":"580603600dacd64ca41223e36e6ffc930155f90ea74686cf8157a0f6b1c83790","contentType":"text/typescript; charset=utf-8"},{"id":"1c205e1c-4a51-5013-8d42-24e8f1868503","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/1c205e1c-4a51-5013-8d42-24e8f1868503/attachment.ts","path":"src/collectors/test-profile.ts","size":7563,"sha256":"492702cbc7404590c2ac9cb964bc1f747beeff6b93c13bfb22526be234070bb4","contentType":"text/typescript; charset=utf-8"},{"id":"487c9f25-cf4a-5c53-b4d7-a79da1debf31","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/487c9f25-cf4a-5c53-b4d7-a79da1debf31/attachment.ts","path":"src/common/ensure-deps.ts","size":5401,"sha256":"7354d494c224f9f752e7f82c83d1d3db4a302adc04596b31d64270499b9093ee","contentType":"text/typescript; charset=utf-8"},{"id":"58536d96-4a68-544d-9776-cb4ff52a72b6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/58536d96-4a68-544d-9776-cb4ff52a72b6/attachment.ts","path":"src/common/is-direct-run.test.ts","size":1124,"sha256":"fa53ba512be3b9b9f0608fac93164c9ba0ddd88ddb51777ce120a87ec83a49ca","contentType":"text/typescript; charset=utf-8"},{"id":"53373062-a529-5c37-b7f0-b3297c091d16","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/53373062-a529-5c37-b7f0-b3297c091d16/attachment.ts","path":"src/common/is-direct-run.ts","size":291,"sha256":"9cc2a4452d932509d6559e33a794c5c25346328d9f67239a62bf38cd6e0a9705","contentType":"text/typescript; charset=utf-8"},{"id":"011d4ee9-d4e4-5eb8-8ca8-9f25d0cf8a6b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/011d4ee9-d4e4-5eb8-8ca8-9f25d0cf8a6b/attachment.ts","path":"src/common/utils.test.ts","size":13716,"sha256":"0ca25b4ee94409bbe3df702285946a98e44261434dc1357f7dd6fd9f1a5df770","contentType":"text/typescript; charset=utf-8"},{"id":"c6703463-ee4a-5d98-9f6c-3c77a5c58ef9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c6703463-ee4a-5d98-9f6c-3c77a5c58ef9/attachment.ts","path":"src/common/utils.ts","size":7420,"sha256":"b3fb50a2b467d8f02e06297574c7dd8119a8477b7dbfca0d1634881262d0b7e1","contentType":"text/typescript; charset=utf-8"},{"id":"7988c576-9d55-55c8-8a08-b996892b012f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/7988c576-9d55-55c8-8a08-b996892b012f/attachment.ts","path":"src/detector-gating.test.ts","size":1599,"sha256":"0b0aa5f8d44f7ed87ad330c842ab9f13e7ac469c9501d691b794769720ae9d18","contentType":"text/typescript; charset=utf-8"},{"id":"0e21b305-6bce-554a-9173-aa0df6ccad91","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0e21b305-6bce-554a-9173-aa0df6ccad91/attachment.ts","path":"src/detectors/code-quality.ts","size":50906,"sha256":"4f83b0d8c268a5b857a62306c4e42dd2448ceb5f8b08d469dbdd07d8ed331273","contentType":"text/typescript; charset=utf-8"},{"id":"8719ef5c-4ebb-543d-b5a4-f67ddffc0195","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8719ef5c-4ebb-543d-b5a4-f67ddffc0195/attachment.ts","path":"src/detectors/cohesion.ts","size":18496,"sha256":"437347202da86a3cb33bc8afd5569b6f8d8b4dbb33789bb55a620afe48e74c1d","contentType":"text/typescript; charset=utf-8"},{"id":"130a23f9-2ca8-5927-8171-d8998ff9957a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/130a23f9-2ca8-5927-8171-d8998ff9957a/attachment.ts","path":"src/detectors/coupling.ts","size":11630,"sha256":"ca5666fe27db39a3d710c109f392783785fd84acc0ddd3106d715aa51bf78d47","contentType":"text/typescript; charset=utf-8"},{"id":"40d25462-c4ad-5d96-8e5a-4ab867820ebe","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/40d25462-c4ad-5d96-8e5a-4ab867820ebe/attachment.ts","path":"src/detectors/cycle.ts","size":11865,"sha256":"28004338c8b4cc3ebf161681b1237792a9c17af5c4c6fddae064140b4e3e0f22","contentType":"text/typescript; charset=utf-8"},{"id":"cc010ec0-b17f-54c2-b0cb-e5763aab29bf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/cc010ec0-b17f-54c2-b0cb-e5763aab29bf/attachment.ts","path":"src/detectors/dead-code.ts","size":11757,"sha256":"1d96e4133300618e076b1452055142ccd25b6db5d459663eb00f3c62370aa5be","contentType":"text/typescript; charset=utf-8"},{"id":"d4d1690c-cbd0-57fb-941d-a291496fb748","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d4d1690c-cbd0-57fb-941d-a291496fb748/attachment.ts","path":"src/detectors/import-style.ts","size":13742,"sha256":"2db73d849dbf4ad4bec1e2a63de8325e1d503e8df6fac8f3cef1c6569cbac39f","contentType":"text/typescript; charset=utf-8"},{"id":"be3a90c9-f580-57e9-89d8-bd2508858f3a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/be3a90c9-f580-57e9-89d8-bd2508858f3a/attachment.ts","path":"src/detectors/index.test.ts","size":89992,"sha256":"e2752cdb9eed3b297dc809068245aec26139d416efe4775bfa52c0db8bb12f28","contentType":"text/typescript; charset=utf-8"},{"id":"11021d45-d5ae-5032-9ba5-b4f12556ba33","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/11021d45-d5ae-5032-9ba5-b4f12556ba33/attachment.ts","path":"src/detectors/index.ts","size":2199,"sha256":"a4570e3df5d322391e17e87fd28416ab8a7b3707bf1f4fb70f24914ca37347aa","contentType":"text/typescript; charset=utf-8"},{"id":"239012c5-5992-5811-b6e5-b308bcba5b59","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/239012c5-5992-5811-b6e5-b308bcba5b59/attachment.ts","path":"src/detectors/security.test.ts","size":24347,"sha256":"46b24c543ed3c4a20e2a9d6798be7e580b659b08d0ef4059515694f2d9e0ce1a","contentType":"text/typescript; charset=utf-8"},{"id":"3220ad85-3ba3-56b3-9fd1-818c58b27838","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3220ad85-3ba3-56b3-9fd1-818c58b27838/attachment.ts","path":"src/detectors/security.ts","size":33946,"sha256":"a0b98a0a304a685989249a78720737178e423ab4922533b611f525f77bab4f54","contentType":"text/typescript; charset=utf-8"},{"id":"41c96573-09ae-5d9c-9d39-6a7896122c5b","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/41c96573-09ae-5d9c-9d39-6a7896122c5b/attachment.ts","path":"src/detectors/semantic.ts","size":27045,"sha256":"5fabdf4a2a035001232128b9cab346f7090e11cc442f131eead2588bb55c0fb2","contentType":"text/typescript; charset=utf-8"},{"id":"3a239de0-8189-56a7-9dc4-6e1e90f0fc1d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3a239de0-8189-56a7-9dc4-6e1e90f0fc1d/attachment.ts","path":"src/detectors/shared.ts","size":1365,"sha256":"76372cf3afa01818f4f221f2ff2c030fb74ff2b5cce656c5a1410a87288d4688","contentType":"text/typescript; charset=utf-8"},{"id":"f8ae4cfd-77b1-520d-9ec3-37e72ade3e8d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f8ae4cfd-77b1-520d-9ec3-37e72ade3e8d/attachment.ts","path":"src/detectors/test-quality.test.ts","size":11602,"sha256":"7b4c5961448f2932817f0bc21004bba9430d92d95c486c61bec0b01fc344d71e","contentType":"text/typescript; charset=utf-8"},{"id":"b8832d31-42fa-5407-b256-b23b12e439ec","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b8832d31-42fa-5407-b256-b23b12e439ec/attachment.ts","path":"src/detectors/test-quality.ts","size":13721,"sha256":"9cc81e650704d27d7d6fe897ccd156ab0514ac871cea0166d53973338d911d2b","contentType":"text/typescript; charset=utf-8"},{"id":"b3c90f80-1c2d-52b1-85e6-4c537f55147d","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b3c90f80-1c2d-52b1-85e6-4c537f55147d/attachment.ts","path":"src/index.test.ts","size":147387,"sha256":"646b05c3e066573a08a6e1bd65a9b0c49adad907881e27afa53dc885d0b8d28f","contentType":"text/typescript; charset=utf-8"},{"id":"eab5f6d4-9f81-5fe5-b71d-3aa5cb0d9ceb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/eab5f6d4-9f81-5fe5-b71d-3aa5cb0d9ceb/attachment.ts","path":"src/index.ts","size":16210,"sha256":"6e5c16abc679769d75c4e7499f4ad37170f221bcdb37686fb77ecca4c2eec9e1","contentType":"text/typescript; charset=utf-8"},{"id":"4024d05f-597d-5e37-9f92-714a79fe5a8c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4024d05f-597d-5e37-9f92-714a79fe5a8c/attachment.ts","path":"src/pipeline.test.ts","size":2867,"sha256":"3b4aa69e65c1b654939ac6745e04fcac170b685542bbe4f3773b922194bbc33a","contentType":"text/typescript; charset=utf-8"},{"id":"b5ed39ba-54ec-5039-a35e-e1e681314913","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b5ed39ba-54ec-5039-a35e-e1e681314913/attachment.ts","path":"src/pipeline/affected.test.ts","size":4870,"sha256":"7ea036a50cf89e29e44971e8e022e21b26dfe0bed641c73a17e116a1265c7af9","contentType":"text/typescript; charset=utf-8"},{"id":"890427f9-bc6b-5710-b2a3-934e92f6f046","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/890427f9-bc6b-5710-b2a3-934e92f6f046/attachment.ts","path":"src/pipeline/affected.ts","size":1730,"sha256":"69b5ee840f697b8a8a22a587a311b70355e0cd883620f7c365b557d6c8a2f6ea","contentType":"text/typescript; charset=utf-8"},{"id":"25bc890c-35f2-5cfe-9eb4-404fb6906e1c","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/25bc890c-35f2-5cfe-9eb4-404fb6906e1c/attachment.ts","path":"src/pipeline/baseline.test.ts","size":8192,"sha256":"dde06138842f9c1e17d962ff93b94c2496af69ac5c6cd084d7802d2dacdbe3ef","contentType":"text/typescript; charset=utf-8"},{"id":"4610ce3d-a885-517b-be01-c988f0a4e042","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4610ce3d-a885-517b-be01-c988f0a4e042/attachment.ts","path":"src/pipeline/baseline.ts","size":1926,"sha256":"e176d0a83deba6b352f21e44772e21bd076a31421520b111068d824e0312edec","contentType":"text/typescript; charset=utf-8"},{"id":"b96a8e8c-97a0-5ed8-a05a-6103a38db9d6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/b96a8e8c-97a0-5ed8-a05a-6103a38db9d6/attachment.ts","path":"src/pipeline/cache.test.ts","size":5943,"sha256":"40f75839773eb49eeb81891faf2a8644b6bdca93c93d5d8db0221ca2315d70dc","contentType":"text/typescript; charset=utf-8"},{"id":"08eff197-b5a7-51da-bb6c-ea28d5bdb578","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/08eff197-b5a7-51da-bb6c-ea28d5bdb578/attachment.ts","path":"src/pipeline/cache.ts","size":2861,"sha256":"0a186373d0ab2c2bcba6442e5bd59b22f8f5ad7c8277d9859e21a0547e85e532","contentType":"text/typescript; charset=utf-8"},{"id":"162d49cd-12d8-528f-bda4-cb01b052e21f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/162d49cd-12d8-528f-bda4-cb01b052e21f/attachment.ts","path":"src/pipeline/cli.test.ts","size":26037,"sha256":"d61736ccd340e3ba7a7d1eb798edaee207de34bc5eb1a656ae19b5153b602943","contentType":"text/typescript; charset=utf-8"},{"id":"3d00832e-ca6f-5e97-9310-eec6f8c923fc","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3d00832e-ca6f-5e97-9310-eec6f8c923fc/attachment.ts","path":"src/pipeline/cli.ts","size":19931,"sha256":"eda5600e69670a22d572b9c16c5c256a56b86e7b8198198a7e36aa1d21ac706a","contentType":"text/typescript; charset=utf-8"},{"id":"06f5fd7f-d4b3-5c66-ac9b-c13c8afe8e4f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/06f5fd7f-d4b3-5c66-ac9b-c13c8afe8e4f/attachment.ts","path":"src/pipeline/config-loader.test.ts","size":8377,"sha256":"0074175de4cd03b3efc1edf115108fdafed3b5a7fe3fda8909851d400475c073","contentType":"text/typescript; charset=utf-8"},{"id":"e8224354-fa5d-525c-b494-9efa175572dd","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e8224354-fa5d-525c-b494-9efa175572dd/attachment.ts","path":"src/pipeline/config-loader.ts","size":3360,"sha256":"1d3a2a13ff7ff7e40d4a375b08e562d76459f50b85ff04373e85851f1d535615","contentType":"text/typescript; charset=utf-8"},{"id":"40cf703e-c720-5050-9367-7b41f69430e6","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/40cf703e-c720-5050-9367-7b41f69430e6/attachment.ts","path":"src/pipeline/create-options.ts","size":1596,"sha256":"26df8a94a9f392dcf99c1fe4471c9dec52d126a6df7b0a8177c4d66f454ac54b","contentType":"text/typescript; charset=utf-8"},{"id":"0b3c7c9e-56ad-5d6e-bf28-983be7a387ff","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/0b3c7c9e-56ad-5d6e-bf28-983be7a387ff/attachment.ts","path":"src/pipeline/health-score.test.ts","size":1935,"sha256":"b8d1c63a15a5aa5eb818dc297a84996f8ec325b55e5711dc80c680887f9c15be","contentType":"text/typescript; charset=utf-8"},{"id":"f49e214a-f928-58fb-a50a-b423f54f694a","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f49e214a-f928-58fb-a50a-b423f54f694a/attachment.ts","path":"src/pipeline/main.test.ts","size":5475,"sha256":"033dd7c9013adc2afbc3cac9d72cbb6f604c34ebd475cea638abdb95dd9d715b","contentType":"text/typescript; charset=utf-8"},{"id":"d93921a1-f642-550d-8adb-b11bb491ae78","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/d93921a1-f642-550d-8adb-b11bb491ae78/attachment.ts","path":"src/pipeline/main.ts","size":40078,"sha256":"2182e10985d799b859c22eafa4c9bd28f1d733548baf23d193795d8d230c5f17","contentType":"text/typescript; charset=utf-8"},{"id":"5c394c1a-cfd7-5564-a48c-05dcb3df2974","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5c394c1a-cfd7-5564-a48c-05dcb3df2974/attachment.ts","path":"src/pipeline/progress.ts","size":1221,"sha256":"d0a2ffb85cc75a74f51311407e7f92361da6df584719e93adaad3da30b6e395d","contentType":"text/typescript; charset=utf-8"},{"id":"4e759ada-8847-5c80-8176-84195d89f034","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4e759ada-8847-5c80-8176-84195d89f034/attachment.ts","path":"src/pipeline/reporters.test.ts","size":4951,"sha256":"c8d309f6d2bbbf69e8b815011274ec9f37f151fbcfa2c8157c253db78139966c","contentType":"text/typescript; charset=utf-8"},{"id":"087c398b-36d7-59ae-9615-a93f6f4c4edf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/087c398b-36d7-59ae-9615-a93f6f4c4edf/attachment.ts","path":"src/pipeline/reporters.ts","size":1763,"sha256":"169deee1bce7af707f07b597e310e64df501b4960b53766d28df397c823bf857","contentType":"text/typescript; charset=utf-8"},{"id":"25c0f880-cd43-5c12-b261-5dc6c783d094","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/25c0f880-cd43-5c12-b261-5dc6c783d094/attachment.ts","path":"src/reporting/analysis.test.ts","size":23723,"sha256":"b96ad68c79f2574a00d20f903c45ad53a3ed2392e260351ebdba889a3e54df62","contentType":"text/typescript; charset=utf-8"},{"id":"a911a6bf-3b24-518d-8828-96ea73c73dba","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/a911a6bf-3b24-518d-8828-96ea73c73dba/attachment.ts","path":"src/reporting/analysis.ts","size":21632,"sha256":"727555a13a023df796d2ea3893bc9685e315e60f1b2dcc1e670e5c11dbdd0ada","contentType":"text/typescript; charset=utf-8"},{"id":"e4244394-5804-5034-9eba-8efecc6371eb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/e4244394-5804-5034-9eba-8efecc6371eb/attachment.ts","path":"src/reporting/graph-features.test.ts","size":7830,"sha256":"76432ba4910ef31d197eb7eb82f32ee1787b0cc9c3fe90c31e087c72a3def68b","contentType":"text/typescript; charset=utf-8"},{"id":"35d3882f-7110-5ba2-ad28-4b528a9c0fcf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/35d3882f-7110-5ba2-ad28-4b528a9c0fcf/attachment.ts","path":"src/reporting/output-contract.test.ts","size":14861,"sha256":"c4bbc8920b0efba6624ec28c86a959c92b2ea0e79e430dc8c8e77cb5cf75fb3e","contentType":"text/typescript; charset=utf-8"},{"id":"8b72fe6f-9293-5bf1-9fc5-7905493034cb","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8b72fe6f-9293-5bf1-9fc5-7905493034cb/attachment.ts","path":"src/reporting/summary-md.test.ts","size":34172,"sha256":"63c8ae27731020c434121ed9eaa9ca675828483cb18f1350e13327a4172f4e6d","contentType":"text/typescript; charset=utf-8"},{"id":"fc2777dd-063a-54f2-bbab-e838e827cc38","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/fc2777dd-063a-54f2-bbab-e838e827cc38/attachment.ts","path":"src/reporting/summary-md.ts","size":58524,"sha256":"543e50375bcb5f81185f53dc2e6fa76de4ae3f8c40426601291d3fd63bfd6249","contentType":"text/typescript; charset=utf-8"},{"id":"2412d40b-c2d2-5c51-994f-8f407800d65f","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/2412d40b-c2d2-5c51-994f-8f407800d65f/attachment.ts","path":"src/reporting/writer.ts","size":17392,"sha256":"4a279c59d7cc4845518a87a1cdbb46f5c8c20f4676dfd45e9a530bfb05f852ea","contentType":"text/typescript; charset=utf-8"},{"id":"8f326b03-baea-510b-b0fd-9ff284eb2220","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8f326b03-baea-510b-b0fd-9ff284eb2220/attachment.ts","path":"src/run.ts","size":921,"sha256":"7a27386f86f809d7fbee845921a62589561b629b7fc515c4553da585e7498885","contentType":"text/typescript; charset=utf-8"},{"id":"c76b0ff9-a7d5-5693-9f97-3288349805a3","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c76b0ff9-a7d5-5693-9f97-3288349805a3/attachment.ts","path":"src/sanity.test.ts","size":1444,"sha256":"80ba8788823689dabeb0f81f748e7cabeff875833296944c5ad76ad5e1c56048","contentType":"text/typescript; charset=utf-8"},{"id":"c88d92e7-ad2e-5e6a-ba0a-e7e67b26f125","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/c88d92e7-ad2e-5e6a-ba0a-e7e67b26f125/attachment.ts","path":"src/types/analysis.ts","size":495,"sha256":"451c6ed2dd65186f74a5e1f419580173786d78dbf2d9028005ffe39478233c7e","contentType":"text/typescript; charset=utf-8"},{"id":"4372ad56-3ab7-51cc-a648-2823734e3d24","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/4372ad56-3ab7-51cc-a648-2823734e3d24/attachment.ts","path":"src/types/collectors.ts","size":2927,"sha256":"7adc69179a0f91ad3b2118808e87419c7defc5c9cc61d2539ec922618091cd2f","contentType":"text/typescript; charset=utf-8"},{"id":"3dea330e-3806-587f-b926-5960f6b917c9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3dea330e-3806-587f-b926-5960f6b917c9/attachment.ts","path":"src/types/constants.ts","size":6917,"sha256":"a796267a3a878432a3dc129fb67bddc5449b6880a0fb4d3d16f5150c759c4104","contentType":"text/typescript; charset=utf-8"},{"id":"3a488851-9c06-55ce-840f-0ebeabbc34f9","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/3a488851-9c06-55ce-840f-0ebeabbc34f9/attachment.ts","path":"src/types/core.ts","size":4359,"sha256":"e25e2a90169ef8500a86a1e8d3780534234ae12d3c94e3564f78f915e0a12222","contentType":"text/typescript; charset=utf-8"},{"id":"8f083e9e-2615-5873-b14e-522caf4c5aa1","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/8f083e9e-2615-5873-b14e-522caf4c5aa1/attachment.ts","path":"src/types/dependency.ts","size":4481,"sha256":"cc8f08298db31dbbbb877457b1413a43fa2bde5b4eb2aac7df763a41a9ae3c5d","contentType":"text/typescript; charset=utf-8"},{"id":"6e93de3a-c7c3-597d-89ab-d43d1a6b5ecf","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6e93de3a-c7c3-597d-89ab-d43d1a6b5ecf/attachment.ts","path":"src/types/file-entry.ts","size":2694,"sha256":"d967fda098c6ce4680b9bc2d5a072ad1df7273c3919f90025e1eace4756824d5","contentType":"text/typescript; charset=utf-8"},{"id":"683e2326-606b-59cb-ac8a-c77a59b61943","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/683e2326-606b-59cb-ac8a-c77a59b61943/attachment.ts","path":"src/types/findings.ts","size":2559,"sha256":"80a70ffe26b6e69288e07fcd1e73845df067f2b35ae5142f23352cd980014960","contentType":"text/typescript; charset=utf-8"},{"id":"09c55201-23f7-567d-ad56-90d9fb489225","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/09c55201-23f7-567d-ad56-90d9fb489225/attachment.ts","path":"src/types/index.ts","size":2020,"sha256":"fbbb4bccf90209c7b9d952824f1c86cf5d7fc1e9d89eed9681b2fc2630df3029","contentType":"text/typescript; charset=utf-8"},{"id":"38fef194-42b8-519e-85bc-9e1bcd00e883","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/38fef194-42b8-519e-85bc-9e1bcd00e883/attachment.ts","path":"src/types/tree-sitter.ts","size":731,"sha256":"d43868a8a54644cdf0c32d278053121d45ca0ef43f4e36e08a6144432528787b","contentType":"text/typescript; charset=utf-8"},{"id":"5821ef0c-1d76-5868-aed8-5271f9e17b83","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/5821ef0c-1d76-5868-aed8-5271f9e17b83/attachment.json","path":"tsconfig.json","size":498,"sha256":"e432f89bcc870415df2a2100c89add72f52a1ee2999f13db68931bda21de8311","contentType":"application/json; charset=utf-8"},{"id":"6c058031-147e-5df4-b669-cccea54e8456","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/6c058031-147e-5df4-b669-cccea54e8456/attachment.ts","path":"vitest.config.ts","size":179,"sha256":"226d35bf1e19c5fa9ffbe5fe9f323fdc55147790cb0ec63721d244ebe6f8ef13","contentType":"text/typescript; charset=utf-8"}],"bundle_sha256":"afbb9e9d46e1c9f4772b52f529185bdab96181d956a9803f72a95d82453e08ae","attachment_count":113,"text_attachments":112,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":1,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/octocode-engineer/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"software-engineering","category_label":"Engineering"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"software-engineering","import_tag":"clean-skills-v1","description":"System-engineering skill for codebase understanding, bug investigation, refactors, PR safety, architecture review, and RFC validation. Enforces Clean Architecture and Clean Code with AST, LSP, and scanner evidence. Produces a flows / boundaries / architecture-health artifact with file:line citations before recommending action."}},"renderedAt":1782980898195}

Octocode Engineer Understand, change, and verify a codebase with system awareness. Single-file reading misses root causes — they live in boundaries, flow ownership, contracts, data paths, and runtime assumptions. This skill makes those visible before you act, and keeps them verified after. What you get (user view) A structured understanding artifact , grounded in evidence, every claim cited : - System summary (what/who/invariants) · Control flows (numbered call paths) · Data flows (writers/readers/txn/cache per entity) · Types & protocols (DTOs, schemas, wire contracts, compat) - Boundaries &…