diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index ff6f5d3..908f8a7 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -125,7 +125,27 @@
"Bash(pnpm --filter @ngcompass/scanner build)",
"Bash(pnpm --filter @ngcompass/cache build)",
"Bash(pnpm --filter @ngcompass/scanner test)",
- "Bash(pnpm --filter @ngcompass/cache --ignore-filter='**/.claude/**' test)"
+ "Bash(pnpm --filter @ngcompass/cache --ignore-filter='**/.claude/**' test)",
+ "Bash(find /c/Users/30694/Desktop/ngcompass_/packages -name \"*.ts\" -type f -exec grep -l \"TODO\\\\|FIXME\\\\|XXX\\\\|HACK\" {} \\\\;)",
+ "Read(//c/Users/30694/.claude/projects/C--Users-30694-Desktop-ngcompass-/memory//**)",
+ "Read(//c/Users/30694/.claude/projects/C--Users-30694-Desktop-ngcompass-/memory/**)",
+ "Bash(pnpm --filter @ngcompass/config exec eslint src --ext .ts --fix)",
+ "Bash(pnpm --filter @ngcompass/engine exec eslint src --ext .ts --fix)",
+ "Bash(pnpm --filter @ngcompass/engine exec tsc --noEmit)",
+ "Bash(pnpm --filter @ngcompass/cache exec tsc --noEmit)",
+ "Bash(pnpm --filter @ngcompass/config exec eslint src --ext .ts)",
+ "Bash(pnpm --filter @ngcompass/cache exec eslint src --ext .ts)",
+ "Bash(pnpm --filter @ngcompass/planner exec eslint src --ext .ts)",
+ "Bash(pnpm --filter @ngcompass/planner exec tsc --noEmit)",
+ "Bash(pnpm --filter @ngcompass/ast exec eslint src --ext .ts)",
+ "Bash(pnpm --filter @ngcompass/ast exec tsc --noEmit)",
+ "Bash(pnpm --filter @ngcompass/rules exec eslint src --ext .ts)",
+ "Bash(pnpm --filter @ngcompass/rules lint)",
+ "Bash(pnpm --filter @ngcompass/rules lint -- --fix)",
+ "Bash(pnpm --filter @ngcompass/rules exec eslint src --ext .ts --fix)",
+ "Bash(pnpm --filter @ngcompass/cache lint)",
+ "Bash(echo \"EXIT: $?\")",
+ "Bash(pnpm --filter @ngcompass/planner build)"
]
}
}
diff --git a/.claude/worktrees/nice-taussig/package.json b/.claude/worktrees/nice-taussig/package.json
new file mode 100644
index 0000000..52f0641
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "ngcompass",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Angular static analysis tool for architecture, performance, SSR, and code quality",
+ "type": "module",
+ "packageManager": "pnpm@10.26.0",
+ "scripts": {
+ "build": "turbo build",
+ "build:prod": "NODE_ENV=production turbo build:prod",
+ "build:benchmark": "BENCHMARK=true pnpm run build",
+ "build:profile": "turbo build --profile=profile.json && echo 'Profile saved to profile.json'",
+ "build:trace": "turbo build --trace",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:ui": "vitest --ui",
+ "test:coverage": "vitest run --coverage",
+ "lint": "turbo lint",
+ "clean": "turbo clean && rimraf node_modules .turbo",
+ "clean:cache": "rimraf .turbo/cache",
+ "dev": "turbo dev",
+ "typecheck": "turbo typecheck",
+ "changeset": "changeset",
+ "version-packages": "changeset version",
+ "release": "pnpm run build:prod && changeset publish",
+ "turbo:summary": "turbo build --dry=json | jq '.tasks[] | {task: .taskId, cache: .cache.status, time: .cache.timeSaved}'",
+ "turbo:graph": "turbo build --graph",
+ "turbo:daemon": "turbo daemon",
+ "turbo:daemon:status": "turbo daemon status",
+ "ci": "turbo build test lint typecheck --concurrency=4",
+ "ci:affected": "turbo build test lint typecheck --filter=[HEAD^1]"
+ },
+ "keywords": [
+ "angular",
+ "linter",
+ "static-analysis",
+ "code-quality",
+ "architecture",
+ "performance",
+ "ssr"
+ ],
+ "author": "ngcompass",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0",
+ "pnpm": ">=8.0.0"
+ },
+ "devDependencies": {
+ "@changesets/cli": "^2.29.8",
+ "@swc-node/register": "^1.11.1",
+ "@swc/cli": "^0.7.10",
+ "@swc/core": "^1.15.11",
+ "@swc/helpers": "^0.5.18",
+ "@typescript-eslint/eslint-plugin": "^8.54.0",
+ "@typescript-eslint/parser": "^8.54.0",
+ "@vitest/coverage-v8": "^4.0.18",
+ "@vitest/ui": "^4.0.18",
+ "eslint": "^8.57.1",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-boundaries": "^5.4.0",
+ "eslint-plugin-prettier": "^5.5.5",
+ "fast-check": "^4.5.3",
+ "prettier": "^3.8.1",
+ "rimraf": "^6.1.2",
+ "tsup": "^8.5.1",
+ "turbo": "^2.8.3",
+ "typescript": "^5.9.3",
+ "unplugin-swc": "^1.5.9",
+ "vitest": "^4.0.18"
+ }
+}
diff --git a/.claude/worktrees/nice-taussig/packages/ast/package.json b/.claude/worktrees/nice-taussig/packages/ast/package.json
new file mode 100644
index 0000000..d6bca1e
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@ngcompass/ast",
+ "version": "0.0.1",
+ "description": "AST types, parsers, analyzers and visitor for ngcompass",
+ "sideEffects": false,
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "build:prod": "NODE_ENV=production tsup --minify",
+ "dev": "tsup --watch",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "lint": "eslint src --ext .ts",
+ "typecheck": "tsc --noEmit",
+ "clean": "rimraf dist .turbo"
+ },
+ "dependencies": {
+ "@ngcompass/common": "workspace:*",
+ "angular-html-parser": "^10.4.0",
+ "lightningcss": "^1.31.1",
+ "oxc-parser": "^0.112.0"
+ },
+ "peerDependencies": {
+ "typescript": "catalog:"
+ },
+ "devDependencies": {
+ "@types/node": "^25.1.0"
+ },
+ "keywords": [
+ "angular",
+ "ast",
+ "parser",
+ "analyzer"
+ ],
+ "author": "ngcompass",
+ "license": "MIT"
+}
\ No newline at end of file
diff --git a/.claude/worktrees/nice-taussig/packages/ast/parser-output-utf8.json b/.claude/worktrees/nice-taussig/packages/ast/parser-output-utf8.json
new file mode 100644
index 0000000..3534b04
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/parser-output-utf8.json
@@ -0,0 +1,1518 @@
+[
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n
{{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 0,
+ "line": 0,
+ "col": 0
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 41,
+ "line": 2,
+ "col": 2
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 0,
+ "line": 0,
+ "col": 0
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "\n@for (item of items; track item.id) {\n ",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ "\n@for (item of items; track item.id) {\n "
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 0,
+ "line": 0,
+ "col": 0
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 41,
+ "line": 2,
+ "col": 2
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 0,
+ "line": 0,
+ "col": 0
+ },
+ "details": null
+ }
+ }
+ ]
+ },
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 41,
+ "line": 2,
+ "col": 2
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 67,
+ "line": 2,
+ "col": 28
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 41,
+ "line": 2,
+ "col": 2
+ },
+ "details": null
+ },
+ "kind": "element",
+ "name": "div",
+ "attrs": [],
+ "directives": [],
+ "children": [
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 46,
+ "line": 2,
+ "col": 7
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 61,
+ "line": 2,
+ "col": 22
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 46,
+ "line": 2,
+ "col": 7
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "{{ item.name }}",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ ""
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 46,
+ "line": 2,
+ "col": 7
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 46,
+ "line": 2,
+ "col": 7
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 46,
+ "line": 2,
+ "col": 7
+ },
+ "details": null
+ }
+ },
+ {
+ "type": 8,
+ "parts": [
+ "{{",
+ " item.name ",
+ "}}"
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 46,
+ "line": 2,
+ "col": 7
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 61,
+ "line": 2,
+ "col": 22
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 46,
+ "line": 2,
+ "col": 7
+ },
+ "details": null
+ }
+ },
+ {
+ "type": 5,
+ "parts": [
+ ""
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 61,
+ "line": 2,
+ "col": 22
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 61,
+ "line": 2,
+ "col": 22
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 61,
+ "line": 2,
+ "col": 22
+ },
+ "details": null
+ }
+ }
+ ]
+ }
+ ],
+ "isSelfClosing": false,
+ "startSourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 41,
+ "line": 2,
+ "col": 2
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 46,
+ "line": 2,
+ "col": 7
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 41,
+ "line": 2,
+ "col": 2
+ },
+ "details": null
+ },
+ "endSourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 61,
+ "line": 2,
+ "col": 22
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 67,
+ "line": 2,
+ "col": 28
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 61,
+ "line": 2,
+ "col": 22
+ },
+ "details": null
+ },
+ "nameSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 42,
+ "line": 2,
+ "col": 3
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 45,
+ "line": 2,
+ "col": 6
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 42,
+ "line": 2,
+ "col": 3
+ },
+ "details": null
+ },
+ "isVoid": false
+ },
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 67,
+ "line": 2,
+ "col": 28
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 81,
+ "line": 4,
+ "col": 2
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 67,
+ "line": 2,
+ "col": 28
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "\n} @empty {\n ",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ "\n} @empty {\n "
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 67,
+ "line": 2,
+ "col": 28
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 81,
+ "line": 4,
+ "col": 2
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 67,
+ "line": 2,
+ "col": 28
+ },
+ "details": null
+ }
+ }
+ ]
+ },
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 81,
+ "line": 4,
+ "col": 2
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 93,
+ "line": 4,
+ "col": 14
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 81,
+ "line": 4,
+ "col": 2
+ },
+ "details": null
+ },
+ "kind": "element",
+ "name": "p",
+ "attrs": [],
+ "directives": [],
+ "children": [
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 84,
+ "line": 4,
+ "col": 5
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 89,
+ "line": 4,
+ "col": 10
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 84,
+ "line": 4,
+ "col": 5
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "Empty",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ "Empty"
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 84,
+ "line": 4,
+ "col": 5
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 89,
+ "line": 4,
+ "col": 10
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 84,
+ "line": 4,
+ "col": 5
+ },
+ "details": null
+ }
+ }
+ ]
+ }
+ ],
+ "isSelfClosing": false,
+ "startSourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 81,
+ "line": 4,
+ "col": 2
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 84,
+ "line": 4,
+ "col": 5
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 81,
+ "line": 4,
+ "col": 2
+ },
+ "details": null
+ },
+ "endSourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 89,
+ "line": 4,
+ "col": 10
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 93,
+ "line": 4,
+ "col": 14
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 89,
+ "line": 4,
+ "col": 10
+ },
+ "details": null
+ },
+ "nameSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 82,
+ "line": 4,
+ "col": 3
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 83,
+ "line": 4,
+ "col": 4
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 82,
+ "line": 4,
+ "col": 3
+ },
+ "details": null
+ },
+ "isVoid": false
+ },
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 93,
+ "line": 4,
+ "col": 14
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 117,
+ "line": 8,
+ "col": 2
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 93,
+ "line": 4,
+ "col": 14
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "\n}\n\n@if (condition) {\n ",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ "\n}\n\n@if (condition) {\n "
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 93,
+ "line": 4,
+ "col": 14
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 117,
+ "line": 8,
+ "col": 2
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 93,
+ "line": 4,
+ "col": 14
+ },
+ "details": null
+ }
+ }
+ ]
+ },
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 117,
+ "line": 8,
+ "col": 2
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 132,
+ "line": 8,
+ "col": 17
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 117,
+ "line": 8,
+ "col": 2
+ },
+ "details": null
+ },
+ "kind": "element",
+ "name": "div",
+ "attrs": [],
+ "directives": [],
+ "children": [
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 122,
+ "line": 8,
+ "col": 7
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 126,
+ "line": 8,
+ "col": 11
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 122,
+ "line": 8,
+ "col": 7
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "True",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ "True"
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 122,
+ "line": 8,
+ "col": 7
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 126,
+ "line": 8,
+ "col": 11
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 122,
+ "line": 8,
+ "col": 7
+ },
+ "details": null
+ }
+ }
+ ]
+ }
+ ],
+ "isSelfClosing": false,
+ "startSourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 117,
+ "line": 8,
+ "col": 2
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 122,
+ "line": 8,
+ "col": 7
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 117,
+ "line": 8,
+ "col": 2
+ },
+ "details": null
+ },
+ "endSourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 126,
+ "line": 8,
+ "col": 11
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 132,
+ "line": 8,
+ "col": 17
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 126,
+ "line": 8,
+ "col": 11
+ },
+ "details": null
+ },
+ "nameSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 118,
+ "line": 8,
+ "col": 3
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 121,
+ "line": 8,
+ "col": 6
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 118,
+ "line": 8,
+ "col": 3
+ },
+ "details": null
+ },
+ "isVoid": false
+ },
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 132,
+ "line": 8,
+ "col": 17
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 156,
+ "line": 10,
+ "col": 2
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 132,
+ "line": 8,
+ "col": 17
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "\n} @else if (other) {\n ",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ "\n} @else if (other) {\n "
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 132,
+ "line": 8,
+ "col": 17
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 156,
+ "line": 10,
+ "col": 2
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 132,
+ "line": 8,
+ "col": 17
+ },
+ "details": null
+ }
+ }
+ ]
+ },
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 156,
+ "line": 10,
+ "col": 2
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 172,
+ "line": 10,
+ "col": 18
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 156,
+ "line": 10,
+ "col": 2
+ },
+ "details": null
+ },
+ "kind": "element",
+ "name": "div",
+ "attrs": [],
+ "directives": [],
+ "children": [
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 161,
+ "line": 10,
+ "col": 7
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 166,
+ "line": 10,
+ "col": 12
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 161,
+ "line": 10,
+ "col": 7
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "Other",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ "Other"
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 161,
+ "line": 10,
+ "col": 7
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 166,
+ "line": 10,
+ "col": 12
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 161,
+ "line": 10,
+ "col": 7
+ },
+ "details": null
+ }
+ }
+ ]
+ }
+ ],
+ "isSelfClosing": false,
+ "startSourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 156,
+ "line": 10,
+ "col": 2
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 161,
+ "line": 10,
+ "col": 7
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 156,
+ "line": 10,
+ "col": 2
+ },
+ "details": null
+ },
+ "endSourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 166,
+ "line": 10,
+ "col": 12
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 172,
+ "line": 10,
+ "col": 18
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 166,
+ "line": 10,
+ "col": 12
+ },
+ "details": null
+ },
+ "nameSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 157,
+ "line": 10,
+ "col": 3
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 160,
+ "line": 10,
+ "col": 6
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 157,
+ "line": 10,
+ "col": 3
+ },
+ "details": null
+ },
+ "isVoid": false
+ },
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 172,
+ "line": 10,
+ "col": 18
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 185,
+ "line": 12,
+ "col": 2
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 172,
+ "line": 10,
+ "col": 18
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "\n} @else {\n ",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ "\n} @else {\n "
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 172,
+ "line": 10,
+ "col": 18
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 185,
+ "line": 12,
+ "col": 2
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 172,
+ "line": 10,
+ "col": 18
+ },
+ "details": null
+ }
+ }
+ ]
+ },
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 185,
+ "line": 12,
+ "col": 2
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 201,
+ "line": 12,
+ "col": 18
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 185,
+ "line": 12,
+ "col": 2
+ },
+ "details": null
+ },
+ "kind": "element",
+ "name": "div",
+ "attrs": [],
+ "directives": [],
+ "children": [
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 190,
+ "line": 12,
+ "col": 7
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 195,
+ "line": 12,
+ "col": 12
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 190,
+ "line": 12,
+ "col": 7
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "False",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ "False"
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 190,
+ "line": 12,
+ "col": 7
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 195,
+ "line": 12,
+ "col": 12
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 190,
+ "line": 12,
+ "col": 7
+ },
+ "details": null
+ }
+ }
+ ]
+ }
+ ],
+ "isSelfClosing": false,
+ "startSourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 185,
+ "line": 12,
+ "col": 2
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 190,
+ "line": 12,
+ "col": 7
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 185,
+ "line": 12,
+ "col": 2
+ },
+ "details": null
+ },
+ "endSourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 195,
+ "line": 12,
+ "col": 12
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 201,
+ "line": 12,
+ "col": 18
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 195,
+ "line": 12,
+ "col": 12
+ },
+ "details": null
+ },
+ "nameSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 186,
+ "line": 12,
+ "col": 3
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 189,
+ "line": 12,
+ "col": 6
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 186,
+ "line": 12,
+ "col": 3
+ },
+ "details": null
+ },
+ "isVoid": false
+ },
+ {
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 201,
+ "line": 12,
+ "col": 18
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 204,
+ "line": 14,
+ "col": 0
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 201,
+ "line": 12,
+ "col": 18
+ },
+ "details": null
+ },
+ "kind": "text",
+ "value": "\n}\n",
+ "tokens": [
+ {
+ "type": 5,
+ "parts": [
+ "\n}\n"
+ ],
+ "sourceSpan": {
+ "start": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 201,
+ "line": 12,
+ "col": 18
+ },
+ "end": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 204,
+ "line": 14,
+ "col": 0
+ },
+ "fullStart": {
+ "file": {
+ "content": "\n@for (item of items; track item.id) {\n {{ item.name }}
\n} @empty {\n Empty
\n}\n\n@if (condition) {\n True
\n} @else if (other) {\n Other
\n} @else {\n False
\n}\n",
+ "url": "angular-html-parser"
+ },
+ "offset": 201,
+ "line": 12,
+ "col": 18
+ },
+ "details": null
+ }
+ }
+ ]
+ }
+]
diff --git a/.claude/worktrees/nice-taussig/packages/ast/parser-output.json b/.claude/worktrees/nice-taussig/packages/ast/parser-output.json
new file mode 100644
index 0000000..fc53d4b
Binary files /dev/null and b/.claude/worktrees/nice-taussig/packages/ast/parser-output.json differ
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/analyzers/component-analyzer.ts b/.claude/worktrees/nice-taussig/packages/ast/src/analyzers/component-analyzer.ts
new file mode 100644
index 0000000..3ecee23
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/analyzers/component-analyzer.ts
@@ -0,0 +1,361 @@
+/**
+ * Component Analyzer (Cached, Tri-State, Zero-Copy)
+ *
+ * PERFORMANCE GUARANTEE:
+ * - First call: O(D) where D = decorator properties
+ * - Subsequent calls: O(1) WeakMap lookup
+ * - Zero allocation after cache warm-up
+ *
+ * RULES MUST NEVER:
+ * - Parse decorators themselves
+ * - Retry inference on non-literal values
+ * - Call this analyzer multiple times (it's cached!)
+ */
+
+import type { ClassDeclaration } from '../ast/types.js';
+import {
+ hasDecorator,
+ getDecoratorNameUnsafe,
+ getDecoratorObjectArgUnsafe,
+ getObjectPropertyUnsafe,
+ matchesMemberExpression,
+ getLiteralStringValueUnsafe,
+ getLiteralBooleanValueUnsafe,
+} from '../ast/matchers.js';
+import type { ArrayExpression, ObjectExpression, Expression, Identifier } from '../ast/types.js';
+
+// ============================================
+// TRI-STATE METADATA (Literal | Non-Literal | Missing)
+// ============================================
+
+/**
+ * Tri-state value (no Option wrapper for performance).
+ */
+export type LiteralValue = { readonly kind: 'literal'; readonly value: T };
+export type NonLiteralValue = { readonly kind: 'non-literal' };
+export type MissingValue = { readonly kind: 'missing' };
+
+export type MetadataValue = LiteralValue | NonLiteralValue | MissingValue;
+
+// Pre-allocated singletons (zero allocation)
+const NON_LITERAL: NonLiteralValue = { kind: 'non-literal' };
+const MISSING: MissingValue = { kind: 'missing' };
+
+/**
+ * Creates literal value (only allocation when literal is found).
+ */
+const literal = (value: T): LiteralValue => ({ kind: 'literal', value });
+
+// ============================================
+// COMPONENT METADATA
+// ============================================
+
+export enum ChangeDetectionStrategy {
+ Default = 0,
+ OnPush = 1,
+}
+
+/**
+ * Host directive metadata.
+ */
+export interface HostDirectiveMetadata {
+ readonly directive: string | undefined;
+ readonly inputs: ReadonlyArray<{ readonly internal: string; readonly external: string }>;
+ readonly outputs: ReadonlyArray<{ readonly internal: string; readonly external: string }>;
+}
+
+/**
+ * Component metadata (tri-state for all fields).
+ *
+ * PERFORMANCE: Allocated once per component, cached in WeakMap.
+ */
+export interface ComponentMetadata {
+ readonly className: string | undefined;
+ readonly selector: MetadataValue;
+ readonly changeDetection: MetadataValue;
+ readonly standalone: MetadataValue;
+ readonly templateUrl: MetadataValue;
+ readonly template: MetadataValue;
+ readonly hostDirectives: MetadataValue>;
+ readonly decoratorStart: number; // Position of decorator for error reporting
+ readonly type: 'Component' | 'Directive';
+}
+
+// ============================================
+// CACHE (Per-File Execution)
+// ============================================
+
+/**
+ * Component metadata cache (WeakMap for automatic GC).
+ *
+ * CRITICAL: This is the ONLY place component analysis happens.
+ * Rules MUST call this, never parse decorators themselves.
+ */
+const componentCache = new WeakMap();
+
+/**
+ * Cache statistics (scoped accumulator ā avoids loose mutable globals).
+ */
+interface CacheStatsAccumulator {
+ hits: number;
+ misses: number;
+}
+
+const cacheStats: CacheStatsAccumulator = { hits: 0, misses: 0 };
+
+export const getComponentCacheStats = (): Readonly => ({ hits: cacheStats.hits, misses: cacheStats.misses });
+export const resetComponentCacheStats = (): void => { cacheStats.hits = 0; cacheStats.misses = 0; };
+
+// ============================================
+// MAIN ANALYZER (Cached)
+// ============================================
+
+/**
+ * Analyzes @Component decorator metadata.
+ *
+ * @returns ComponentMetadata if @Component found, null otherwise
+ *
+ * PERFORMANCE:
+ * - First call: O(D) where D = decorator properties
+ * - Subsequent calls: O(1) WeakMap lookup
+ *
+ * RULES MUST:
+ * - Call this once per component
+ * - Handle null (not a component)
+ * - Handle tri-state values (literal/non-literal/missing)
+ *
+ * RULES MUST NOT:
+ * - Retry inference on non-literal values
+ * - Parse decorators themselves
+ * - Allocate collections
+ */
+export const analyzeComponent = (classNode: ClassDeclaration): ComponentMetadata | null => {
+ // Check cache (O(1))
+ const cached = componentCache.get(classNode);
+ if (cached !== undefined) {
+ cacheStats.hits++;
+ return cached;
+ }
+
+ cacheStats.misses++;
+
+ // Not a component or directive? Cache negative result.
+ const isComp = hasDecorator(classNode, 'Component');
+ const isDir = !isComp && hasDecorator(classNode, 'Directive');
+
+ if (!isComp && !isDir) {
+ componentCache.set(classNode, null);
+ return null;
+ }
+
+ const decoratorName = isComp ? 'Component' : 'Directive';
+
+ // Find @Component decorator
+ const decorators = classNode.decorators;
+ if (!decorators) {
+ componentCache.set(classNode, null);
+ return null;
+ }
+
+ let angularDecorator = undefined;
+ for (let i = 0; i < decorators.length; i++) {
+ const name = getDecoratorNameUnsafe(decorators[i]);
+ if (name === decoratorName) {
+ angularDecorator = decorators[i];
+ break;
+ }
+ }
+
+ if (!angularDecorator) {
+ componentCache.set(classNode, null);
+ return null;
+ }
+
+ // Extract metadata object
+ const metadataObject = getDecoratorObjectArgUnsafe(angularDecorator);
+
+ // Build metadata (allocated once, cached)
+ const metadata: ComponentMetadata = {
+ className: classNode.id?.name,
+ selector: metadataObject ? extractSelector(metadataObject) : MISSING,
+ changeDetection: isComp && metadataObject ? extractChangeDetection(metadataObject) : MISSING,
+ standalone: metadataObject ? extractStandalone(metadataObject) : MISSING,
+ templateUrl: isComp && metadataObject ? extractTemplateUrl(metadataObject) : MISSING,
+ template: isComp && metadataObject ? extractTemplate(metadataObject) : MISSING,
+ hostDirectives: metadataObject ? extractHostDirectives(metadataObject) : MISSING,
+ decoratorStart: angularDecorator.start ?? angularDecorator.span?.start ?? 0, // Track decorator position
+ type: decoratorName,
+ };
+
+ componentCache.set(classNode, metadata);
+ return metadata;
+};
+
+// ============================================
+// PRIVATE EXTRACTORS (Tri-State, Zero-Copy)
+// ============================================
+
+const extractSelector = (metadataObject: any): MetadataValue => {
+ const selectorNode = getObjectPropertyUnsafe(metadataObject, 'selector');
+ if (!selectorNode) return MISSING;
+
+ const value = getLiteralStringValueUnsafe(selectorNode);
+ if (value !== undefined) return literal(value);
+
+ return NON_LITERAL;
+};
+
+const extractChangeDetection = (metadataObject: any): MetadataValue => {
+ const cdNode = getObjectPropertyUnsafe(metadataObject, 'changeDetection');
+ if (!cdNode) return MISSING;
+
+ // Identifier: OnPush or Default
+ if (cdNode.type === 'Identifier') {
+ const name = (cdNode as any).name;
+ if (name === 'OnPush') return literal(ChangeDetectionStrategy.OnPush);
+ if (name === 'Default') return literal(ChangeDetectionStrategy.Default);
+ return NON_LITERAL;
+ }
+
+ // Member: ChangeDetectionStrategy.OnPush
+ if (matchesMemberExpression(cdNode, 'ChangeDetectionStrategy', 'OnPush')) {
+ return literal(ChangeDetectionStrategy.OnPush);
+ }
+
+ if (matchesMemberExpression(cdNode, 'ChangeDetectionStrategy', 'Default')) {
+ return literal(ChangeDetectionStrategy.Default);
+ }
+
+ return NON_LITERAL;
+};
+
+const extractStandalone = (metadataObject: any): MetadataValue => {
+ const standaloneNode = getObjectPropertyUnsafe(metadataObject, 'standalone');
+ if (!standaloneNode) return MISSING;
+
+ const value = getLiteralBooleanValueUnsafe(standaloneNode);
+ if (value !== undefined) return literal(value);
+
+ return NON_LITERAL;
+};
+
+const extractTemplateUrl = (metadataObject: any): MetadataValue => {
+ const templateUrlNode = getObjectPropertyUnsafe(metadataObject, 'templateUrl');
+ if (!templateUrlNode) return MISSING;
+
+ const value = getLiteralStringValueUnsafe(templateUrlNode);
+ if (value !== undefined) return literal(value);
+
+ return NON_LITERAL;
+};
+
+const extractTemplate = (metadataObject: any): MetadataValue => {
+ const templateNode = getObjectPropertyUnsafe(metadataObject, 'template');
+ if (!templateNode) return MISSING;
+
+ const value = getLiteralStringValueUnsafe(templateNode);
+ if (value !== undefined) return literal(value);
+
+ return NON_LITERAL;
+};
+
+const extractHostDirectives = (metadataObject: any): MetadataValue> => {
+ const hostNode = getObjectPropertyUnsafe(metadataObject, 'hostDirectives');
+ if (!hostNode) return MISSING;
+
+ if (hostNode.type !== 'ArrayExpression') return NON_LITERAL;
+
+ const hostArr = hostNode as ArrayExpression;
+ const results: HostDirectiveMetadata[] = [];
+ const elements = hostArr.elements;
+
+ for (let i = 0; i < elements.length; i++) {
+ const el = elements[i];
+ if (!el) continue;
+
+ // Case 1: Class reference [MyDir]
+ if (el.type === 'Identifier') {
+ results.push({ directive: (el as Identifier).name, inputs: [], outputs: [] });
+ continue;
+ }
+
+ // Case 2: Object { directive: MyDir, inputs: [...], outputs: [...] }
+ if (el.type === 'ObjectExpression') {
+ const objEl = el as ObjectExpression;
+ const dirNode = getObjectPropertyUnsafe(objEl, 'directive');
+ const directive = dirNode?.type === 'Identifier' ? (dirNode as Identifier).name : undefined;
+
+ const inputs = extractRenames(getObjectPropertyUnsafe(objEl, 'inputs'));
+ const outputs = extractRenames(getObjectPropertyUnsafe(objEl, 'outputs'));
+
+ results.push({ directive, inputs, outputs });
+ continue;
+ }
+
+ // Unknown type
+ }
+
+ return literal(results);
+};
+
+const extractRenames = (node: Expression | undefined): ReadonlyArray<{ internal: string, external: string }> => {
+ if (!node || node.type !== 'ArrayExpression') return [];
+
+ const arrNode = node as ArrayExpression;
+ const renames: { internal: string, external: string }[] = [];
+ const elements = arrNode.elements;
+
+ for (let i = 0; i < elements.length; i++) {
+ const el = elements[i];
+ if (!el) continue;
+
+ const value = getLiteralStringValueUnsafe(el);
+ if (!value) continue;
+
+ if (value.includes(':')) {
+ const [internal, external] = value.split(':').map(s => s.trim());
+ renames.push({ internal, external });
+ } else {
+ renames.push({ internal: value, external: value });
+ }
+ }
+
+ return renames;
+};
+
+// ============================================
+// HIGH-LEVEL CHECKS (Convenience Functions)
+// ============================================
+
+/**
+ * Checks if class is an Angular component.
+ *
+ * PERFORMANCE: O(1) after first call (cached).
+ */
+export const isComponent = (classNode: ClassDeclaration): boolean => {
+ return analyzeComponent(classNode) !== null;
+};
+
+/**
+ * Checks if component uses OnPush (literal value only).
+ *
+ * PERFORMANCE: O(1) after first call.
+ */
+export const usesOnPush = (classNode: ClassDeclaration): boolean => {
+ const component = analyzeComponent(classNode);
+ if (!component) return false;
+
+ const cd = component.changeDetection;
+ return cd.kind === 'literal' && cd.value === ChangeDetectionStrategy.OnPush;
+};
+
+/**
+ * Checks if component is standalone: true (literal value only).
+ */
+export const isStandalone = (classNode: ClassDeclaration): boolean => {
+ const component = analyzeComponent(classNode);
+ if (!component) return false;
+
+ const standalone = component.standalone;
+ return standalone.kind === 'literal' && standalone.value === true;
+};
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/analyzers/index.ts b/.claude/worktrees/nice-taussig/packages/ast/src/analyzers/index.ts
new file mode 100644
index 0000000..eba1a21
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/analyzers/index.ts
@@ -0,0 +1,21 @@
+/**
+ * Cached Analyzers
+ *
+ * Centralized, cached, tri-state metadata extraction.
+ */
+
+export {
+ analyzeComponent,
+ isComponent,
+ usesOnPush,
+ isStandalone,
+ getComponentCacheStats,
+ resetComponentCacheStats,
+ ChangeDetectionStrategy,
+ type ComponentMetadata,
+ type MetadataValue,
+ type LiteralValue,
+ type NonLiteralValue,
+ type MissingValue,
+} from './component-analyzer.js';
+export { analyzeTemplate } from './template-analyzer.js';
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/analyzers/template-analyzer.ts b/.claude/worktrees/nice-taussig/packages/ast/src/analyzers/template-analyzer.ts
new file mode 100644
index 0000000..18ca7fa
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/analyzers/template-analyzer.ts
@@ -0,0 +1,187 @@
+
+export interface HtmlParserResult {
+ rootNodes: readonly unknown[];
+}
+import type { TemplateExpressionNode, TemplateAttributeNode, TemplateBlockNode } from '../node-streams.js';
+import { parseSync } from 'oxc-parser';
+
+/**
+ * Traverses HTML AST and extracts Angular expressions.
+ */
+export const analyzeTemplate = (htmlResult: HtmlParserResult): {
+ expressions: TemplateExpressionNode[];
+ attributes: TemplateAttributeNode[];
+ blocks: TemplateBlockNode[];
+} => {
+ const expressions: TemplateExpressionNode[] = [];
+ const attributes: TemplateAttributeNode[] = [];
+ const blocks: TemplateBlockNode[] = [];
+
+ const visit = (node: any) => {
+ if (!node) return;
+
+ // Recurse into children first (generated AST from angular-html-parser).
+ // for-of replaces forEach to avoid allocating a closure on every call.
+ if (node.children) {
+ for (const child of node.children) visit(child);
+ }
+
+ // 1. Attributes (Inputs, Structural Directives)
+ if (node.attrs) {
+ for (const attr of node.attrs) {
+ const name: string = attr.name;
+ const value: string = attr.value;
+ const attrOffset = attr.valueSpan?.start?.offset ?? 0;
+
+ // Always register to the attributes stream
+ attributes.push({
+ name,
+ value,
+ sourceSpan: {
+ start: attr.sourceSpan?.start?.offset ?? 0,
+ end: attr.sourceSpan?.end?.offset ?? 0,
+ },
+ });
+
+ if (!value) continue;
+
+ // *ngIf="expr" ā structural directives use microsyntax
+ if (name.startsWith('*')) {
+ parseAndAdd(value, attrOffset, expressions);
+ // [prop]="expr" ā property binding
+ } else if (name.startsWith('[') && name.endsWith(']')) {
+ parseAndAdd(value, attrOffset, expressions);
+ // bind-prop="expr" ā long-form property binding
+ } else if (name.startsWith('bind-')) {
+ parseAndAdd(value, attrOffset, expressions);
+ }
+ }
+ }
+
+ // 2. Text and Interpolations
+ const isText = node.kind === 'text' || node.type === 'text' || node.constructor?.name === 'Text';
+ if (isText && node.value) {
+ // Use tokens when available (some angular-html-parser versions provide them)
+ if (node.tokens) {
+ for (const token of node.tokens) {
+ if (token.type === 8 && token.parts?.length === 3) {
+ const expr = token.parts[1];
+ const startOffset = token.sourceSpan.start.offset + token.parts[0].length;
+ parseAndAdd(expr, startOffset, expressions);
+ }
+ }
+ } else {
+ // Manual interpolation extraction: scan for {{ ... }} pairs
+ const textValue = node.value as string;
+ const nodeStart = node.sourceSpan?.start?.offset ?? 0;
+ let lastIndex = 0;
+
+ let start = textValue.indexOf('{{', lastIndex);
+ while (start !== -1) {
+ const end = textValue.indexOf('}}', start + 2);
+ if (end === -1) break;
+
+ parseAndAdd(
+ textValue.substring(start + 2, end),
+ nodeStart + start + 2,
+ expressions
+ );
+
+ lastIndex = end + 2;
+ start = textValue.indexOf('{{', lastIndex);
+ }
+ }
+ }
+
+ // 3. Control Flow Blocks (@for, @if, etc.)
+ if (node.kind === 'block' || node.constructor?.name === 'Block') {
+ const blockName = node.name;
+ const parameters = node.parameters ?? [];
+
+ blocks.push({
+ name: blockName,
+ parameters: parameters.map((p: any) => ({
+ expression: p.expression,
+ sourceSpan: {
+ start: p.sourceSpan?.start?.offset ?? 0,
+ end: p.sourceSpan?.end?.offset ?? 0,
+ }
+ })),
+ sourceSpan: {
+ start: node.sourceSpan?.start?.offset ?? 0,
+ end: node.sourceSpan?.end?.offset ?? 0,
+ }
+ });
+
+ for (const param of parameters) {
+ if (param.expression) {
+ const offset = param.sourceSpan?.start?.offset ?? 0;
+
+ // Special handling for @for microsyntax: (item of items; track item.id)
+ // The parser might provide the whole string.
+ // For now, we'll try to parse the expression part.
+ if (blockName === 'for') {
+ if (param.expression.includes(' track ')) {
+ const trackPart = param.expression.split(' track ')[1];
+ const trackOffset = offset + param.expression.indexOf(' track ') + 7;
+ parseAndAdd(trackPart, trackOffset, expressions);
+ } else if (param.expression.startsWith('track ')) {
+ const trackPart = param.expression.substring(6);
+ const trackOffset = offset + 6;
+ parseAndAdd(trackPart, trackOffset, expressions);
+ }
+ // Also try to parse the collection expression
+ if (param.expression.includes(' of ')) {
+ const collectionPart = param.expression.split(' of ')[1].split(';')[0];
+ const collectionOffset = offset + param.expression.indexOf(' of ') + 4;
+ parseAndAdd(collectionPart, collectionOffset, expressions);
+ }
+ } else {
+ parseAndAdd(param.expression, offset, expressions);
+ }
+ }
+ }
+ }
+ };
+
+ for (const rootNode of htmlResult.rootNodes) visit(rootNode);
+
+ return { expressions, attributes, blocks };
+};
+
+const parseAndAdd = (code: string, offset: number, outcomes: TemplateExpressionNode[]) => {
+ if (!code.trim()) return;
+
+ try {
+ // Wrap in parentheses so that object literals like { color: 'red' } are parsed as
+ // ObjectExpression (inside an ExpressionStatement) rather than as a BlockStatement.
+ // This is safe for all Angular template expressions ā identifiers, calls, ternaries,
+ // binary pipes (a | b), etc. all remain valid when wrapped.
+ const wrappedCode = `(${code})`;
+ const ret = parseSync('template.ts', wrappedCode, { sourceType: 'module', lang: 'ts' });
+
+ if (ret.program.body.length > 0) {
+ const stmt = ret.program.body[0];
+ // Expect ExpressionStatement provided it's an expression
+ if (stmt.type === 'ExpressionStatement' && stmt.expression) {
+ // Unwrap the ParenthesizedExpression node that oxc-parser creates when
+ // the source text is wrapped in `(...)`. Without unwrapping, rules that
+ // test for e.g. ObjectExpression / ArrayExpression / BinaryExpression
+ // would see a ParenthesizedExpression root and miss the match.
+ let expr: any = stmt.expression;
+ if (expr.type === 'ParenthesizedExpression' && expr.expression) {
+ expr = expr.expression;
+ }
+ outcomes.push({
+ expression: expr,
+ sourceSpan: {
+ start: offset,
+ end: offset + code.length
+ }
+ });
+ }
+ }
+ } catch (e) {
+ // Ignore parse errors for fragments
+ }
+};
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/ast/index.ts b/.claude/worktrees/nice-taussig/packages/ast/src/ast/index.ts
new file mode 100644
index 0000000..5ddd3d2
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/ast/index.ts
@@ -0,0 +1,2 @@
+export * from './types.js';
+export * from './matchers.js';
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/ast/matchers.ts b/.claude/worktrees/nice-taussig/packages/ast/src/ast/matchers.ts
new file mode 100644
index 0000000..4c37011
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/ast/matchers.ts
@@ -0,0 +1,309 @@
+/**
+ * AST Matchers (Zero-Allocation, Pure Functions)
+ *
+ * PERFORMANCE RULES:
+ * - No object creation in hot paths
+ * - No array allocations
+ * - No string concatenation
+ * - Return primitives or pre-existing references only
+ *
+ * "Unsafe" suffix convention: May return undefined, caller must handle.
+ */
+
+import type {
+ Decorator,
+ ClassDeclaration,
+ ObjectExpression,
+ Expression,
+ MemberExpression,
+ CallExpression,
+ Identifier,
+ StringLiteral,
+ BooleanLiteral,
+} from './types.js';
+
+// ============================================
+// DECORATOR CHECKS (Zero Allocation)
+// ============================================
+
+/**
+ * Checks if a node has a specific decorator.
+ *
+ * @returns boolean (primitive, zero allocation)
+ */
+export const hasDecorator = (
+ classNode: ClassDeclaration,
+ decoratorName: string
+): boolean => {
+ const decorators = classNode.decorators;
+ if (!decorators) return false;
+
+ for (let i = 0; i < decorators.length; i++) {
+ const decorator = decorators[i];
+ const name = getDecoratorNameUnsafe(decorator);
+ if (name === decoratorName) return true;
+ }
+
+ return false;
+};
+
+/**
+ * Gets decorator name (unsafe: may return undefined).
+ *
+ * PERFORMANCE: Returns string reference from AST (zero copy).
+ * Rules must handle undefined.
+ */
+export const getDecoratorNameUnsafe = (decorator: Decorator): string | undefined => {
+ const expr = decorator.expression;
+ if (!expr) return undefined;
+
+ if (expr.type === 'CallExpression') {
+ const callee = expr.callee;
+
+ // Simple: @Component
+ if (callee.type === 'Identifier') {
+ return (callee as Identifier).name;
+ }
+
+ // Member: @core.Component
+ if (callee.type === 'MemberExpression' || callee.type === 'StaticMemberExpression') {
+ const prop = (callee as MemberExpression).property;
+ if (prop.type === 'Identifier') {
+ return prop.name;
+ }
+ }
+ }
+
+ return undefined;
+};
+
+/**
+ * Gets first decorator argument if it's an object literal.
+ *
+ * PERFORMANCE: Returns AST reference (zero copy).
+ */
+export const getDecoratorObjectArgUnsafe = (
+ decorator: Decorator
+): ObjectExpression | undefined => {
+ const expr = decorator.expression;
+ if (!expr || expr.type !== 'CallExpression') return undefined;
+
+ const args = expr.arguments;
+ if (!args || args.length === 0) return undefined;
+
+ const first = args[0];
+ return first.type === 'ObjectExpression' ? (first as ObjectExpression) : undefined;
+};
+
+// ============================================
+// OBJECT PROPERTY LOOKUP (Zero Allocation)
+// ============================================
+
+/**
+ * Checks if object has a property with given key.
+ *
+ * PERFORMANCE: No allocation, early return.
+ */
+export const hasObjectProperty = (
+ objectExpr: ObjectExpression,
+ keyName: string
+): boolean => {
+ const properties = objectExpr.properties;
+ if (!properties) return false;
+
+ for (let i = 0; i < properties.length; i++) {
+ const prop = properties[i];
+ if (!prop || prop.type === 'SpreadElement') continue;
+
+ const objectProp = prop;
+ const actualKeyName = getKeyNameUnsafe(objectProp.key);
+
+ if (actualKeyName === keyName) return true;
+ }
+
+ return false;
+};
+
+/**
+ * Gets property value by key name (unsafe: may return undefined).
+ *
+ * PERFORMANCE: Returns AST reference (zero copy).
+ */
+export const getObjectPropertyUnsafe = (
+ objectExpr: ObjectExpression,
+ keyName: string
+): Expression | undefined => {
+ const properties = objectExpr.properties;
+ if (!properties) return undefined;
+
+ for (let i = 0; i < properties.length; i++) {
+ const prop = properties[i];
+ if (!prop || prop.type === 'SpreadElement') continue;
+
+ const objectProp = prop;
+ const actualKeyName = getKeyNameUnsafe(objectProp.key);
+
+ if (actualKeyName === keyName) {
+ return objectProp.value;
+ }
+ }
+
+ return undefined;
+};
+
+/**
+ * Gets key name from object key (unsafe).
+ *
+ * PERFORMANCE: Returns string reference from AST.
+ */
+export const getKeyNameUnsafe = (key: Expression): string | undefined => {
+ if (!key) return undefined;
+
+ // Identifier: { foo: ... }
+ if (key.type === 'Identifier') return (key as Identifier).name;
+
+ // String literal: { "foo": ... }
+ if (key.type === 'StringLiteral' || key.type === 'Literal') {
+ const lit = key as StringLiteral;
+ return typeof lit.value === 'string' ? lit.value : undefined;
+ }
+
+ return undefined;
+};
+
+// ============================================
+// MEMBER EXPRESSION CHECKS
+// ============================================
+
+/**
+ * Checks if member expression matches pattern (e.g., ChangeDetectionStrategy.OnPush).
+ *
+ * PERFORMANCE: No allocation, early return.
+ */
+export const matchesMemberExpression = (
+ expr: Expression,
+ objectName: string,
+ propertyName: string
+): boolean => {
+ if (!expr) return false;
+
+ if (expr.type !== 'MemberExpression' && expr.type !== 'StaticMemberExpression') {
+ return false;
+ }
+
+ const memberExpr = expr as MemberExpression;
+
+ // Check property
+ const prop = memberExpr.property;
+ if (!prop || prop.type !== 'Identifier' || prop.name !== propertyName) {
+ return false;
+ }
+
+ // Check object
+ const obj = memberExpr.object;
+ if (!obj) return false;
+
+ // Simple: ChangeDetectionStrategy.OnPush
+ if (obj.type === 'Identifier' && (obj as Identifier).name === objectName) {
+ return true;
+ }
+
+ // Nested: core.ChangeDetectionStrategy.OnPush
+ if ((obj.type === 'MemberExpression' || obj.type === 'StaticMemberExpression') &&
+ (obj as MemberExpression).property.type === 'Identifier' &&
+ (obj as MemberExpression).property.name === objectName) {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Gets literal string value (unsafe: may return undefined).
+ *
+ * PERFORMANCE: Returns string reference from AST.
+ */
+export const getLiteralStringValueUnsafe = (node: Expression): string | undefined => {
+ if (!node) return undefined;
+
+ if (node.type === 'StringLiteral' || node.type === 'Literal') {
+ const value = (node as StringLiteral).value;
+ return typeof value === 'string' ? value : undefined;
+ }
+
+ return undefined;
+};
+
+/**
+ * Gets literal boolean value (unsafe: may return undefined).
+ *
+ * PERFORMANCE: Returns boolean primitive from AST.
+ */
+export const getLiteralBooleanValueUnsafe = (node: Expression): boolean | undefined => {
+ if (!node) return undefined;
+
+ if (node.type === 'BooleanLiteral' || node.type === 'Literal') {
+ const value = (node as BooleanLiteral).value;
+ return typeof value === 'boolean' ? value : undefined;
+ }
+
+ return undefined;
+};
+
+/**
+ * Checks if expression is an Angular input() signal call.
+ */
+export const isInputSignal = (expr: Expression): boolean => {
+ if (!expr || expr.type !== 'CallExpression') return false;
+ const callee = (expr as CallExpression).callee;
+
+ // input()
+ if (callee.type === 'Identifier' && (callee as Identifier).name === 'input') return true;
+
+ // input.required()
+ if (callee.type === 'MemberExpression' || callee.type === 'StaticMemberExpression') {
+ const member = callee as MemberExpression;
+ return member.object.type === 'Identifier' &&
+ (member.object as Identifier).name === 'input' &&
+ member.property.type === 'Identifier' &&
+ member.property.name === 'required';
+ }
+
+ return false;
+};
+
+/**
+ * Extracts alias from input() signal call if present.
+ */
+export const getInputSignalAliasUnsafe = (callExpr: CallExpression): string | undefined => {
+ const callee = callExpr.callee;
+ const args = callExpr.arguments;
+ if (!args || args.length === 0) return undefined;
+
+ const isRequired = callee.type === 'MemberExpression' || callee.type === 'StaticMemberExpression';
+
+ if (isRequired) {
+ // input.required({ alias: 'alias' })
+ const first = args[0];
+ if (first && first.type === 'ObjectExpression') {
+ return getLiteralStringValueUnsafe(getObjectPropertyUnsafe(first as ObjectExpression, 'alias') as Expression);
+ }
+ } else {
+ // input('alias')
+ if (args.length === 1) {
+ const first = args[0];
+ if (first.type === 'StringLiteral' || first.type === 'Literal') {
+ return getLiteralStringValueUnsafe(first);
+ }
+ }
+ // input(val, { alias: 'alias' })
+ if (args.length >= 2) {
+ const second = args[1];
+ if (second && second.type === 'ObjectExpression') {
+ return getLiteralStringValueUnsafe(getObjectPropertyUnsafe(second as ObjectExpression, 'alias') as Expression);
+ }
+ }
+ }
+
+ return undefined;
+};
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/ast/types.ts b/.claude/worktrees/nice-taussig/packages/ast/src/ast/types.ts
new file mode 100644
index 0000000..26f0c35
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/ast/types.ts
@@ -0,0 +1,171 @@
+/**
+ * Minimal AST Type Definitions (Zero-Copy, ESTree-like)
+ *
+ * These types match Oxc's ESTree-compatible AST structure.
+ * Only include fields we actually use to minimize overhead.
+ */
+
+export interface Node {
+ readonly type: string;
+ readonly span?: { start: number; end: number };
+ readonly start?: number;
+ readonly end?: number;
+}
+
+export interface Identifier extends Node {
+ readonly type: 'Identifier';
+ readonly name: string;
+}
+
+export interface Decorator extends Node {
+ readonly type: 'Decorator';
+ readonly expression?: CallExpression | Identifier;
+}
+
+export interface CallExpression extends Node {
+ readonly type: 'CallExpression';
+ readonly callee: Expression;
+ readonly arguments: ReadonlyArray;
+}
+
+export interface NewExpression extends Node {
+ readonly type: 'NewExpression';
+ readonly callee: Expression;
+ readonly arguments: ReadonlyArray;
+}
+
+export interface MemberExpression extends Node {
+ readonly type: 'MemberExpression' | 'StaticMemberExpression';
+ readonly object: Expression;
+ readonly property: Identifier;
+}
+
+export interface ObjectExpression extends Node {
+ readonly type: 'ObjectExpression';
+ readonly properties: ReadonlyArray;
+}
+
+export interface ObjectProperty extends Node {
+ readonly type: 'ObjectProperty' | 'Property';
+ readonly key: Expression;
+ readonly value: Expression;
+}
+
+export interface SpreadElement extends Node {
+ readonly type: 'SpreadElement';
+}
+
+export interface ArrayExpression extends Node {
+ readonly type: 'ArrayExpression';
+ readonly elements: ReadonlyArray;
+}
+
+export interface StringLiteral extends Node {
+ readonly type: 'StringLiteral' | 'Literal';
+ readonly value: string;
+}
+
+export interface BooleanLiteral extends Node {
+ readonly type: 'BooleanLiteral' | 'Literal';
+ readonly value: boolean;
+}
+
+export interface ArrowFunctionExpression extends Node {
+ readonly type: 'ArrowFunctionExpression';
+ readonly body: Expression | BlockStatement;
+ readonly expression: boolean;
+}
+
+export interface FunctionExpression extends Node {
+ readonly type: 'FunctionExpression';
+ readonly body: BlockStatement;
+}
+
+export interface BlockStatement extends Node {
+ readonly type: 'BlockStatement';
+ readonly body: ReadonlyArray;
+}
+
+export interface ExpressionStatement extends Node {
+ readonly type: 'ExpressionStatement';
+ readonly expression: Expression;
+}
+
+export interface AssignmentExpression extends Node {
+ readonly type: 'AssignmentExpression';
+ readonly left: Expression;
+ readonly right: Expression;
+ readonly operator: string;
+}
+
+export interface UpdateExpression extends Node {
+ readonly type: 'UpdateExpression';
+ readonly operator: string;
+ readonly argument: Expression;
+ readonly prefix: boolean;
+}
+
+export interface IfStatement extends Node {
+ readonly type: 'IfStatement';
+ readonly test: Expression;
+ readonly consequent: Node;
+ readonly alternate?: Node;
+}
+
+export interface ReturnStatement extends Node {
+ readonly type: 'ReturnStatement';
+ readonly argument?: Expression;
+}
+
+export interface ClassDeclaration extends Node {
+ readonly type: 'ClassDeclaration';
+ readonly id?: Identifier;
+ readonly decorators?: ReadonlyArray;
+ readonly body?: ClassBody;
+}
+
+export interface ClassBody extends Node {
+ readonly type: 'ClassBody';
+ readonly body: ReadonlyArray;
+}
+
+export interface PropertyDefinition extends Node {
+ readonly type: 'PropertyDefinition';
+ readonly key: Expression;
+ readonly value?: Expression;
+ readonly decorators?: ReadonlyArray;
+}
+
+export interface MethodDefinition extends Node {
+ readonly type: 'MethodDefinition';
+ readonly key: Expression;
+}
+
+export interface TemplateBlock extends Node {
+ readonly type: 'Block';
+ readonly name: string;
+ readonly parameters: ReadonlyArray;
+ readonly children: ReadonlyArray;
+}
+
+export interface TemplateBlockParameter extends Node {
+ readonly type: 'BlockParameter';
+ readonly expression: string;
+}
+
+export type Expression =
+ | Identifier
+ | CallExpression
+ | NewExpression
+ | MemberExpression
+ | ObjectExpression
+ | ArrayExpression
+ | StringLiteral
+ | BooleanLiteral
+ | ArrowFunctionExpression
+ | FunctionExpression
+ | AssignmentExpression
+ | UpdateExpression
+ | TemplateBlock
+ | TemplateBlockParameter
+ | Node;
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/index.ts b/.claude/worktrees/nice-taussig/packages/ast/src/index.ts
new file mode 100644
index 0000000..de5568c
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/index.ts
@@ -0,0 +1,20 @@
+/**
+ * @ngcompass/ast
+ *
+ * AST types, parsers, analyzers, node streams and visitor for the ngcompass analysis engine.
+ */
+
+// AST types and matchers
+export * from './ast/index.js';
+
+// Analyzers (component + template)
+export * from './analyzers/index.js';
+
+// Node streams and stream filter functions
+export * from './node-streams.js';
+
+// Parsers (html, css, ts, template-extractor) ā merged into @ngcompass/ast
+export * from './parsers/index.js';
+
+// AST visitor
+export { walkProgram } from './visitor.js';
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/node-streams.ts b/.claude/worktrees/nice-taussig/packages/ast/src/node-streams.ts
new file mode 100644
index 0000000..398df03
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/node-streams.ts
@@ -0,0 +1,167 @@
+/**
+ * Node Streams (Pre-Filtered Semantic Dispatch)
+ *
+ * PERFORMANCE RULE:
+ * Rules must subscribe to the most specific stream possible.
+ *
+ * FORBIDDEN:
+ * - Rules checking "is this a component?" (use AngularClassStream)
+ * - Rules checking "is this decorated?" (use DecoratedPropertyStream)
+ * - Rules checking node types (dispatcher handles this)
+ */
+
+import type { ClassDeclaration, PropertyDefinition, Decorator, Expression, CallExpression, NewExpression } from './ast/types.js';
+import { analyzeComponent, type ComponentMetadata } from './analyzers/component-analyzer.js';
+import { getDecoratorNameUnsafe } from './ast/matchers.js';
+
+// ============================================
+// STREAM DEFINITIONS
+// ============================================
+
+export interface TemplateExpressionNode {
+ readonly expression: Expression;
+ readonly sourceSpan: { start: number, end: number };
+}
+
+export interface TemplateAttributeNode {
+ readonly name: string;
+ readonly value?: string;
+ readonly sourceSpan: { start: number, end: number };
+}
+
+export interface TemplateBlockNode {
+ readonly name: string;
+ readonly parameters: ReadonlyArray<{
+ readonly expression: string;
+ readonly sourceSpan: { start: number, end: number };
+ }>;
+ readonly sourceSpan: { start: number, end: number };
+}
+
+export interface TemplateAnalysis {
+ readonly expressions: ReadonlyArray;
+ readonly attributes: ReadonlyArray;
+ readonly blocks: ReadonlyArray;
+}
+
+/**
+ * Angular Decorator Stream: ClassDeclaration nodes with @Component or @Directive.
+ *
+ * Rules subscribing to this stream are guaranteed:
+ * - Node is a ClassDeclaration
+ * - Node has @Component or @Directive decorator
+ * - Metadata is pre-analyzed and cached
+ */
+export interface AngularClassNode {
+ readonly node: ClassDeclaration;
+ readonly metadata: ComponentMetadata; // Covers both Components and Directives
+}
+
+/**
+ * Any Angular Decorated Class Stream: ClassDeclaration nodes with ANY Angular decorator.
+ *
+ * Rules subscribing to this stream are guaranteed:
+ * - Node is a ClassDeclaration
+ * - Node has at least one of: @Component, @Directive, @Pipe, @Injectable, @NgModule
+ *
+ * Use this when the rule applies beyond @Component/@Directive (e.g. naming rules
+ * for pipes, services, or guards).
+ */
+export interface AnyAngularClassNode {
+ readonly node: ClassDeclaration;
+ readonly decoratorName: string; // e.g. 'Pipe', 'Injectable', 'Component', ā¦
+ readonly className: string | undefined;
+ readonly decoratorStart: number;
+}
+
+/**
+ * Decorated Property Stream: PropertyDefinition nodes with decorators.
+ *
+ * Rules subscribing to this stream are guaranteed:
+ * - Node is a PropertyDefinition
+ * - Node has at least one decorator (@Input, @Output, @ViewChild, etc.)
+ */
+export interface DecoratedPropertyNode {
+ readonly node: PropertyDefinition;
+ readonly decorators: ReadonlyArray; // Pre-extracted
+}
+
+// ============================================
+// STREAM FILTERS (Called by Engine)
+// ============================================
+
+/**
+ * Filters ClassDeclaration nodes to Angular components or directives.
+ *
+ * PERFORMANCE: O(1) after first call (cached).
+ * Called by engine during traversal, not by rules.
+ */
+export const toAngularClassStream = (
+ classNode: ClassDeclaration
+): AngularClassNode | null => {
+ const metadata = analyzeComponent(classNode);
+ if (!metadata) return null;
+
+ return {
+ node: classNode,
+ metadata, // Already analyzed, zero-copy reference
+ };
+};
+
+/** Angular decorators that qualify a class for the AnyAngularClass stream. */
+const ANY_ANGULAR_DECORATORS = new Set(['Component', 'Directive', 'Pipe', 'Injectable', 'NgModule']);
+
+/**
+ * Filters ClassDeclaration nodes to ANY Angular-decorated class.
+ *
+ * PERFORMANCE: O(D) where D = number of decorators on the class (usually 1).
+ * Called by engine during traversal, not by rules.
+ */
+export const toAnyAngularClassStream = (
+ classNode: ClassDeclaration
+): AnyAngularClassNode | null => {
+ const decorators = classNode.decorators;
+ if (!decorators) return null;
+
+ for (let i = 0; i < decorators.length; i++) {
+ const name = getDecoratorNameUnsafe(decorators[i]);
+ if (name && ANY_ANGULAR_DECORATORS.has(name)) {
+ return {
+ node: classNode,
+ decoratorName: name,
+ className: classNode.id?.name,
+ decoratorStart: decorators[i].start ?? decorators[i].span?.start ?? 0,
+ };
+ }
+ }
+
+ return null;
+};
+
+/**
+ * Filters PropertyDefinition nodes to decorated properties.
+ *
+ * PERFORMANCE: O(1) decorator array access.
+ * Called by engine, not by rules.
+ */
+export const toDecoratedPropertyStream = (
+ propertyNode: PropertyDefinition
+): DecoratedPropertyNode | null => {
+ const decorators = propertyNode.decorators;
+ if (!decorators || decorators.length === 0) return null;
+
+ return {
+ node: propertyNode,
+ decorators, // Zero-copy reference
+ };
+};
+
+/**
+ * Pass-through filter for CallExpression stream.
+ */
+export const toCallExpressionStream = (node: CallExpression): CallExpression => node;
+
+/**
+ * Pass-through filter for NewExpression stream.
+ */
+export const toNewExpressionStream = (node: NewExpression): NewExpression => node;
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/parsers/css.ts b/.claude/worktrees/nice-taussig/packages/ast/src/parsers/css.ts
new file mode 100644
index 0000000..69bbfe9
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/parsers/css.ts
@@ -0,0 +1,49 @@
+import { transform } from "lightningcss";
+
+export interface CssParserResult {
+ code: Buffer | Uint8Array;
+ map?: Buffer | Uint8Array | void;
+}
+
+/**
+ * Result type for CSS parsing and validation.
+ */
+export type CssResult =
+ | { ok: true; code: Buffer | Uint8Array; map?: Buffer | Uint8Array | void }
+ | { ok: false; error: unknown };
+
+/**
+ * Parses and validates CSS using Lightning CSS.
+ *
+ * @param content - CSS source text
+ * @param filePath - Source filename for diagnostics
+ * @returns CssResult with transformed output or an error
+ */
+export const parseCss = (content: string, filePath: string): CssResult => {
+ try {
+ const result = runLightningCssTransform(content, filePath);
+ return { ok: true, ...result };
+ } catch (error) {
+ return { ok: false, error };
+ }
+};
+
+/**
+ * Executes a Lightning CSS transform pass for validation.
+ *
+ * @param content - CSS source text
+ * @param filePath - Source filename for diagnostics
+ * @returns Lightning CSS output payload
+ */
+const runLightningCssTransform = (content: string, filePath: string): CssParserResult => {
+ const code = Buffer.from(content);
+
+ const result = transform({
+ filename: filePath,
+ code,
+ minify: false,
+ sourceMap: false,
+ });
+
+ return { code: result.code, map: result.map };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/parsers/html.ts b/.claude/worktrees/nice-taussig/packages/ast/src/parsers/html.ts
new file mode 100644
index 0000000..19c1512
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/parsers/html.ts
@@ -0,0 +1,45 @@
+import { parse } from "angular-html-parser";
+
+export interface HtmlParserResult {
+ rootNodes: unknown[];
+ errors: unknown[];
+ /**
+ * Byte offset in the *source file* where the template content begins.
+ *
+ * - For external `.html` files this is always `0` ā the HTML file is the
+ * template, so its offsets are already file-absolute.
+ * - For inline templates inside a `.ts` file this is the position of the
+ * first content character (right after the opening quote/backtick) in
+ * the TypeScript source.
+ *
+ * Template rules MUST add this value to `node.sourceSpan.start` before
+ * calling `context.locator.location()` so that reported line/column
+ * numbers are correct for both inline and external templates.
+ */
+ templateStartOffset: number;
+}
+
+/**
+ * Parses Angular HTML template source.
+ *
+ * @param content - Template source text (content only, no surrounding quotes)
+ * @param templateStartOffset - Byte offset in the original source file where
+ * `content[0]` lives. Use `0` for external `.html`
+ * files; supply the value from `extractTemplateFromProgram`
+ * for inline templates in `.ts` files.
+ * @returns Parsed root nodes, parse errors, and the start offset.
+ */
+export const parseHtml = (content: string, templateStartOffset = 0): HtmlParserResult => {
+ const result = runAngularHtmlParse(content);
+ return { rootNodes: result.rootNodes, errors: result.errors, templateStartOffset };
+};
+
+/**
+ * Executes Angular HTML parsing with fixed configuration.
+ *
+ * @param content - Template source text
+ * @returns Parser output
+ */
+const runAngularHtmlParse = (content: string): { rootNodes: any[]; errors: any[] } => {
+ return parse(content, { tokenizeAngularBlocks: true }) as any;
+};
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/parsers/index.ts b/.claude/worktrees/nice-taussig/packages/ast/src/parsers/index.ts
new file mode 100644
index 0000000..5e10a6c
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/parsers/index.ts
@@ -0,0 +1,5 @@
+// Barrel for the parsers sub-folder (now part of @ngcompass/ast)
+export * from './ts.js';
+export * from './html.js';
+export * from './css.js';
+export * from './template-extractor.js';
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/parsers/template-extractor.ts b/.claude/worktrees/nice-taussig/packages/ast/src/parsers/template-extractor.ts
new file mode 100644
index 0000000..73b773c
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/parsers/template-extractor.ts
@@ -0,0 +1,143 @@
+/**
+ * Template Extractor
+ *
+ * Extracts the inline template string from an Oxc-parsed TypeScript program.
+ * Moved from orchestrator.ts to make it independently testable and reusable.
+ *
+ * Uses walkProgram() (the shared visitor) instead of a hand-rolled recursion,
+ * which is consistent with how the rules engine traverses the AST and
+ * benefits from the same early-exit optimisation (returning false stops descent).
+ *
+ * Handles both:
+ * - StringLiteral: template: 'Hello
'
+ * - TemplateLiteral: template: `{{ name }}
`
+ */
+
+import { walkProgram } from '../visitor.js';
+import type { Program } from 'oxc-parser';
+
+// ============================================
+// PUBLIC API
+// ============================================
+
+/**
+ * Result of extracting an inline template from a TypeScript program.
+ */
+export interface ExtractedTemplate {
+ /** The raw HTML content of the template (no surrounding quotes/backticks). */
+ readonly content: string;
+ /**
+ * Byte offset in the TypeScript source file where `content[0]` lives.
+ *
+ * This is used to convert template-relative offsets (produced by the HTML
+ * parser, which only sees the template content) back into file-absolute
+ * offsets (required by `Locator.location()`).
+ *
+ * For external .html files this value is always 0 ā the HTML file IS the
+ * template, so its offsets are already file-absolute.
+ */
+ readonly startOffset: number;
+}
+
+/**
+ * Extracts the inline template string from the first @Component class found
+ * in the given Oxc program.
+ *
+ * @param program - Oxc-parsed Program node
+ * @returns ExtractedTemplate with content and its start offset in the file,
+ * or `{ content: '', startOffset: 0 }` if no template was found.
+ */
+export function extractTemplateFromProgram(program: Program): ExtractedTemplate {
+ let result: ExtractedTemplate = { content: '', startOffset: 0 };
+
+ walkProgram(program, (node: any) => {
+ // Once we have the template, stop walking (false = skip children)
+ if (result.content) return false;
+
+ if (node.type === 'ClassDeclaration' && Array.isArray(node.decorators)) {
+ const extracted = tryExtractFromClass(node);
+ if (extracted) {
+ result = extracted;
+ return false; // stop traversal
+ }
+ }
+ });
+
+ return result;
+}
+
+// ============================================
+// PRIVATE HELPERS
+// ============================================
+
+/**
+ * Attempts to extract the `template` property value from a class decorated
+ * with @Component.
+ */
+function tryExtractFromClass(classNode: any): ExtractedTemplate | null {
+ for (const decorator of classNode.decorators) {
+ const call = decorator?.expression;
+ if (!call || call.type !== 'CallExpression') continue;
+
+ const callee = call.callee;
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'Component') continue;
+
+ const objectArg = call.arguments?.[0];
+ if (!objectArg || objectArg.type !== 'ObjectExpression') continue;
+
+ const templateValue = findPropertyValue(objectArg.properties, 'template');
+ if (templateValue) {
+ return extractStringValueWithOffset(templateValue);
+ }
+ }
+ return null;
+}
+
+/**
+ * Finds the value expression of a named property in an ObjectExpression.
+ */
+function findPropertyValue(properties: any[], keyName: string): any {
+ if (!Array.isArray(properties)) return null;
+ for (const prop of properties) {
+ const key = prop?.key;
+ const value = prop?.value;
+ if (!key || !value) continue;
+ const name = key?.name ?? key?.value;
+ if (name === keyName) return value;
+ }
+ return null;
+}
+
+/**
+ * Extracts a string value AND its start offset from a StringLiteral or
+ * TemplateLiteral AST node.
+ *
+ * The startOffset is the position of the *first content character* in the
+ * original TypeScript source file ā i.e. the position right after the
+ * opening quote or backtick.
+ */
+function extractStringValueWithOffset(node: any): ExtractedTemplate {
+ if (!node) return { content: '', startOffset: 0 };
+
+ if (node.type === 'StringLiteral' || node.type === 'Literal') {
+ // node.start / node.span.start ā position of the opening quote in the file
+ const nodeStart: number = node.start ?? node.span?.start ?? 0;
+ return {
+ content: node.value ?? '',
+ startOffset: nodeStart + 1, // +1 to skip the opening ' or "
+ };
+ }
+
+ if (node.type === 'TemplateLiteral') {
+ const quasis: any[] = node.quasis ?? [];
+ const content = quasis.map((q: any) => q.value?.raw ?? '').join('');
+ // The first quasi's start position points to the opening backtick
+ const firstStart: number = quasis[0]?.start ?? quasis[0]?.span?.start ?? 0;
+ return {
+ content,
+ startOffset: firstStart + 1, // +1 to skip the opening `
+ };
+ }
+
+ return { content: '', startOffset: 0 };
+}
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/parsers/ts.ts b/.claude/worktrees/nice-taussig/packages/ast/src/parsers/ts.ts
new file mode 100644
index 0000000..8c5b124
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/parsers/ts.ts
@@ -0,0 +1,32 @@
+import { parseSync, type Program, type ParseResult } from "oxc-parser";
+
+export interface TsParserResult {
+ program: Program;
+ errors: unknown[];
+}
+
+/**
+ * Parses TypeScript / TSX source code using Oxc.
+ *
+ * @param content - Source text
+ * @param filePath - Source filename (used for diagnostics)
+ * @returns Parsed program and parse errors
+ */
+export const parseTs = (content: string, filePath: string): TsParserResult => {
+ const result = runOxcParse(filePath, content);
+ return { program: result.program, errors: result.errors };
+};
+
+/**
+ * Executes a synchronous Oxc parse with fixed, deterministic options.
+ *
+ * @param filePath - Source filename
+ * @param content - Source text
+ * @returns Oxc ParseResult
+ */
+const runOxcParse = (filePath: string, content: string): ParseResult => {
+ return parseSync(filePath, content, {
+ sourceType: "module",
+ lang: "tsx",
+ });
+};
diff --git a/.claude/worktrees/nice-taussig/packages/ast/src/visitor.ts b/.claude/worktrees/nice-taussig/packages/ast/src/visitor.ts
new file mode 100644
index 0000000..eabaf92
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/src/visitor.ts
@@ -0,0 +1,70 @@
+/**
+ * Keys that are never AST children ā skipped in every node to avoid false traversal.
+ * Defined at module level so the Set is allocated once, not per call.
+ */
+const NON_CHILD_KEYS = new Set(['parent', 'span', 'loc', 'range', 'start', 'end', 'type']);
+
+/**
+ * Iterative pre-order DFS walker for Oxc AST.
+ *
+ * Replaces the previous recursive implementation to eliminate two hotpath costs:
+ * 1. Call-stack overflow risk on deeply nested ASTs (large files with many nested
+ * arrow functions, ternaries, optional chaining, etc.).
+ * 2. `Object.keys()` allocation on every node ā on a 10 000-node AST that was
+ * 10 000 short-lived string arrays per file. `for...in` iterates without
+ * allocating the intermediate array.
+ *
+ * Traversal order: identical to the recursive version (pre-order DFS, children
+ * visited in property-key insertion order). The engine relies only on the guarantee
+ * that every node is visited; it does NOT depend on parent-before-child ordering
+ * beyond what pre-order naturally provides.
+ *
+ * @param root - The Program (or any sub-tree root) to walk
+ * @param visitor - Called for each node. Return `false` to skip that node's children.
+ */
+export function walkProgram(root: any, visitor: (node: any) => void | boolean): void {
+ if (!root) return;
+
+ // Explicit stack replaces the call stack ā no recursion depth limit.
+ // We push children in reverse insertion order so that the first property's
+ // subtree is processed before subsequent ones (matching recursive pre-order).
+ const stack: any[] = [root];
+
+ while (stack.length > 0) {
+ const node = stack.pop();
+ if (!node || typeof node !== 'object' || !node.type) continue;
+
+ const shouldContinue = visitor(node);
+ if (shouldContinue === false) continue;
+
+ // Collect children before pushing so we can reverse them for correct order.
+ // A local temp array here is unavoidable for reversal, but it is tiny
+ // (typically 2ā8 entries) compared to the 50ā100 key array that
+ // Object.keys() was allocating on every node previously.
+ const children: any[] = [];
+
+ for (const key in node) {
+ // Skip non-child properties without allocating an array
+ if (NON_CHILD_KEYS.has(key)) continue;
+
+ const val = node[key];
+ if (!val || typeof val !== 'object') continue;
+
+ if (Array.isArray(val)) {
+ for (let i = 0; i < val.length; i++) {
+ const child = val[i];
+ if (child && typeof child === 'object' && child.type) {
+ children.push(child);
+ }
+ }
+ } else if (val.type) {
+ children.push(val);
+ }
+ }
+
+ // Push in reverse so the first child is popped (processed) first
+ for (let i = children.length - 1; i >= 0; i--) {
+ stack.push(children[i]);
+ }
+ }
+}
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tests/analyzers/component-analyzer.test.ts b/.claude/worktrees/nice-taussig/packages/ast/tests/analyzers/component-analyzer.test.ts
new file mode 100644
index 0000000..d4a2627
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tests/analyzers/component-analyzer.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { analyzeComponent, isComponent, usesOnPush, isStandalone, resetComponentCacheStats, getComponentCacheStats } from '../../src/analyzers/component-analyzer.js';
+import { parseTs } from '../../src/parsers/ts.js';
+
+describe('component-analyzer', () => {
+ beforeEach(() => {
+ resetComponentCacheStats();
+ });
+
+ it('extracts literal scalar metadata from @Component', () => {
+ const source = `
+ @Component({
+ selector: 'app-test',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ templateUrl: './xyz.html',
+ template: '',
+ })
+ class TestComp {}
+ `;
+ const result = parseTs(source, 'test.ts');
+ const cls = result.program.body[0] as any;
+ const metadata = analyzeComponent(cls);
+
+ expect(metadata).not.toBeNull();
+ if (metadata) { // Narrow checks
+ expect(metadata.selector.kind).toBe('literal');
+ expect((metadata.selector as any).value).toBe('app-test');
+
+ expect(metadata.standalone.kind).toBe('literal');
+ expect((metadata.standalone as any).value).toBe(true);
+
+ expect(metadata.changeDetection.kind).toBe('literal');
+ expect((metadata.changeDetection as any).value).toBe(1); // OnPush
+
+ expect(metadata.templateUrl.kind).toBe('literal');
+ expect((metadata.templateUrl as any).value).toBe('./xyz.html');
+
+ expect(metadata.template.kind).toBe('literal');
+ expect((metadata.template as any).value).toBe('');
+ }
+ });
+
+ it('caches successive calls on the same class declaration', () => {
+ const source = `
+ @Component({ selector: 'test' }) class TestComp {}
+ `;
+ const result = parseTs(source, 'test.ts');
+ const cls = result.program.body[0] as any;
+
+ analyzeComponent(cls);
+ analyzeComponent(cls);
+ analyzeComponent(cls);
+
+ const stats = getComponentCacheStats();
+ expect(stats.hits).toBe(2);
+ expect(stats.misses).toBe(1);
+ });
+
+ it('returns null for empty classes', () => {
+ const source = `class TestComp {}`;
+ const result = parseTs(source, 'test.ts');
+ const cls = result.program.body[0] as any;
+
+ expect(analyzeComponent(cls)).toBeNull();
+ expect(isComponent(cls)).toBe(false);
+ expect(usesOnPush(cls)).toBe(false);
+ expect(isStandalone(cls)).toBe(false);
+ });
+});
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tests/analyzers/template-analyzer.test.ts b/.claude/worktrees/nice-taussig/packages/ast/tests/analyzers/template-analyzer.test.ts
new file mode 100644
index 0000000..3dd74fc
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tests/analyzers/template-analyzer.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from 'vitest';
+import { analyzeTemplate } from '../../src/analyzers/template-analyzer.js';
+import { parseHtml } from '../../src/parsers/html.js';
+
+describe('template-analyzer', () => {
+ it('analyzes attributes from HTML elements', () => {
+ const html = '';
+ const parsed = parseHtml(html);
+ const analyzed = analyzeTemplate(parsed);
+
+ expect(analyzed.attributes.length).toBeGreaterThan(0);
+ // Note: Id and Class may either be extracted or ignored depending on internal angular-html-parser representation,
+ // but 'id' and 'class' should emit attributes at the very least.
+ const ids = analyzed.attributes.filter(a => a.name === 'id');
+ expect(ids.length).toBe(1);
+ expect(ids[0].value).toBe('test');
+ });
+
+ it('analyzes structural directives', () => {
+ const html = '';
+ const parsed = parseHtml(html);
+ const analyzed = analyzeTemplate(parsed);
+
+ expect(analyzed.expressions.length).toBeGreaterThan(0);
+ // "show" wrapped expression will be available
+ const expr = analyzed.expressions[0];
+ expect(expr.expression).toBeDefined();
+ // Since it's parsed via oxc into ts AST, it will have type Identifier
+ expect(expr.expression.type).toBe('Identifier');
+ });
+
+ it('analyzes property bindings', () => {
+ const html = '';
+ const parsed = parseHtml(html);
+ const analyzed = analyzeTemplate(parsed);
+
+ expect(analyzed.attributes.some(a => a.name === '[prop]')).toBe(true);
+ expect(analyzed.expressions.length).toBeGreaterThan(0);
+ expect(analyzed.expressions[0].expression.type).toBe('Identifier');
+ });
+});
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tests/ast/matchers.test.ts b/.claude/worktrees/nice-taussig/packages/ast/tests/ast/matchers.test.ts
new file mode 100644
index 0000000..63edd49
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tests/ast/matchers.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import { parseTs } from '../../src/parsers/ts.js';
+import { hasDecorator, getDecoratorNameUnsafe, isInputSignal } from '../../src/ast/matchers.js';
+import { walkProgram } from '../../src/visitor.js';
+
+describe('ast/matchers', () => {
+ it('hasDecorator and getDecoratorNameUnsafe handle standard decorators', () => {
+ const source = `
+ @Component({ selector: 'test' }) class TestComp {}
+ @Injectable() class TestService {}
+ `;
+ const result = parseTs(source, 'test.ts');
+ let componentsFound = 0;
+
+ walkProgram(result.program, node => {
+ if (node.type === 'ClassDeclaration') {
+ if (hasDecorator(node as any, 'Component')) {
+ componentsFound++;
+ const name = getDecoratorNameUnsafe((node as any).decorators[0] as any);
+ expect(name).toBe('Component');
+ }
+ if (hasDecorator(node as any, 'Injectable')) {
+ const name = getDecoratorNameUnsafe((node as any).decorators[0] as any);
+ expect(name).toBe('Injectable');
+ }
+ }
+ });
+ expect(componentsFound).toBe(1);
+ });
+
+ it('isInputSignal handles input() and input.required()', () => {
+ const source = `
+ const a = input();
+ const b = input.required();
+ const c = someOtherCall();
+ `;
+ const result = parseTs(source, 'test.ts');
+ let inputs = 0;
+ let requiredInputs = 0;
+ let missing = 0;
+
+ walkProgram(result.program, node => {
+ if (node.type === 'CallExpression') {
+ if (isInputSignal(node as any)) {
+ if ((node as any).callee.type === 'Identifier') {
+ inputs++;
+ } else {
+ requiredInputs++;
+ }
+ } else {
+ missing++;
+ }
+ }
+ });
+
+ expect(inputs).toBe(1);
+ expect(requiredInputs).toBe(1);
+ expect(missing).toBe(1); // someOtherCall
+ });
+});
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tests/node-streams.test.ts b/.claude/worktrees/nice-taussig/packages/ast/tests/node-streams.test.ts
new file mode 100644
index 0000000..7ac5697
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tests/node-streams.test.ts
@@ -0,0 +1,63 @@
+import { describe, it, expect } from 'vitest';
+import { parseTs } from '../src/parsers/ts.js';
+import { toAngularClassStream, toAnyAngularClassStream, toDecoratedPropertyStream } from '../src/node-streams.js';
+import { walkProgram } from '../src/visitor.js';
+
+describe('node-streams', () => {
+ it('toAngularClassStream identifies @Component', () => {
+ const source = `
+ @Component({ selector: 'test' }) class TestComp {}
+ `;
+ const result = parseTs(source, 'test.ts');
+ let matched = false;
+ walkProgram(result.program, node => {
+ if (node.type === 'ClassDeclaration') {
+ const stream = toAngularClassStream(node as any);
+ if (stream) {
+ matched = true;
+ expect(stream.metadata).toBeDefined();
+ }
+ }
+ });
+ expect(matched).toBe(true);
+ });
+
+ it('toAnyAngularClassStream identifies @Injectable', () => {
+ const source = `
+ @Injectable() class TestService {}
+ `;
+ const result = parseTs(source, 'test.ts');
+ let matched = false;
+ walkProgram(result.program, node => {
+ if (node.type === 'ClassDeclaration') {
+ const stream = toAnyAngularClassStream(node as any);
+ if (stream) {
+ matched = true;
+ expect(stream.decoratorName).toBe('Injectable');
+ expect(stream.className).toBe('TestService');
+ }
+ }
+ });
+ expect(matched).toBe(true);
+ });
+
+ it('toDecoratedPropertyStream identifies @Input()', () => {
+ const source = `
+ class TestComp {
+ @Input() myProp: string;
+ }
+ `;
+ const result = parseTs(source, 'test.ts');
+ let matched = false;
+ walkProgram(result.program, node => {
+ if (node.type === 'PropertyDefinition') {
+ const stream = toDecoratedPropertyStream(node as any);
+ if (stream) {
+ matched = true;
+ expect(stream.decorators.length).toBe(1);
+ }
+ }
+ });
+ expect(matched).toBe(true);
+ });
+});
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/css.test.ts b/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/css.test.ts
new file mode 100644
index 0000000..4b6e22b
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/css.test.ts
@@ -0,0 +1,17 @@
+import { describe, it, expect } from 'vitest';
+import { parseCss } from '../../src/parsers/css.js';
+
+describe('parseCss', () => {
+ it('returns ok for valid css', () => {
+ const result = parseCss('.class { color: red; }', 'test.css');
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.code).toBeDefined();
+ }
+ });
+
+ it('returns error for invalid css', () => {
+ const result = parseCss('.class { color: red;;!@# ', 'test.css');
+ expect(result.ok).toBe(false);
+ });
+});
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/html.test.ts b/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/html.test.ts
new file mode 100644
index 0000000..2894126
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/html.test.ts
@@ -0,0 +1,16 @@
+import { describe, it, expect } from 'vitest';
+import { parseHtml } from '../../src/parsers/html.js';
+
+describe('parseHtml', () => {
+ it('returns parsed root nodes and empty errors for valid html', () => {
+ const result = parseHtml('Hello
', 0);
+ expect(result.errors.length).toBe(0);
+ expect(result.rootNodes.length).toBeGreaterThan(0);
+ expect(result.templateStartOffset).toBe(0);
+ });
+
+ it('handles templateStartOffset correctly', () => {
+ const result = parseHtml('Hi', 10);
+ expect(result.templateStartOffset).toBe(10);
+ });
+});
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/template-extractor.test.ts b/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/template-extractor.test.ts
new file mode 100644
index 0000000..7588271
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/template-extractor.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect } from 'vitest';
+import { extractTemplateFromProgram } from '../../src/parsers/template-extractor.js';
+import { parseTs } from '../../src/parsers/ts.js';
+
+describe('extractTemplateFromProgram', () => {
+ it('extracts StringLiteral templates', () => {
+ const source = `
+ import { Component } from '@angular/core';
+ @Component({
+ template: 'Hello
'
+ })
+ export class TestComponent {}
+ `;
+ const result = parseTs(source, 'test.ts');
+ const template = extractTemplateFromProgram(result.program);
+ expect(template.content).toBe('Hello
');
+ expect(template.startOffset).toBeGreaterThan(0);
+ });
+
+ it('extracts TemplateLiteral templates', () => {
+ const source = `
+ import { Component } from '@angular/core';
+ @Component({
+ template: \`Hi {{name}}\`
+ })
+ export class TestComponent {}
+ `;
+ const result = parseTs(source, 'test.ts');
+ const template = extractTemplateFromProgram(result.program);
+ expect(template.content).toBe('Hi {{name}}');
+ expect(template.startOffset).toBeGreaterThan(0);
+ });
+
+ it('returns empty when no template is present', () => {
+ const source = `
+ import { Component } from '@angular/core';
+ @Component({
+ templateUrl: './xyz.html'
+ })
+ export class TestComponent {}
+ `;
+ const result = parseTs(source, 'test.ts');
+ const template = extractTemplateFromProgram(result.program);
+ expect(template.content).toBe('');
+ expect(template.startOffset).toBe(0);
+ });
+});
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/ts.test.ts b/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/ts.test.ts
new file mode 100644
index 0000000..3114bf5
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tests/parsers/ts.test.ts
@@ -0,0 +1,16 @@
+import { describe, it, expect } from 'vitest';
+import { parseTs } from '../../src/parsers/ts.js';
+
+describe('parseTs', () => {
+ it('parses valid typescript code', () => {
+ const result = parseTs('const x: number = 10;', 'test.ts');
+ expect(result.errors.length).toBe(0);
+ expect(result.program).toBeDefined();
+ expect(result.program.type).toBe('Program');
+ });
+
+ it('captures parsing errors for invalid ts', () => {
+ const result = parseTs('const class class = ;', 'test.ts');
+ expect(result.errors.length).toBeGreaterThan(0);
+ });
+});
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tests/visitor.test.ts b/.claude/worktrees/nice-taussig/packages/ast/tests/visitor.test.ts
new file mode 100644
index 0000000..c6a89dd
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tests/visitor.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect } from 'vitest';
+import { parseTs } from '../src/parsers/ts.js';
+import { walkProgram } from '../src/visitor.js';
+
+describe('visitor', () => {
+ it('visits every node in the AST', () => {
+ const source = 'const x = 1 + 2;';
+ const result = parseTs(source, 'test.ts');
+
+ const nodeTypes: string[] = [];
+ walkProgram(result.program, (node) => {
+ if (node.type) {
+ nodeTypes.push(node.type);
+ }
+ });
+
+ expect(nodeTypes).toContain('Program');
+ expect(nodeTypes).toContain('VariableDeclaration');
+ expect(nodeTypes).toContain('VariableDeclarator');
+ expect(nodeTypes).toContain('BinaryExpression');
+ expect(nodeTypes).toContain('Literal');
+ });
+
+ it('skips children when visitor returns false', () => {
+ const source = 'const x = 1 + 2;';
+ const result = parseTs(source, 'test.ts');
+
+ let binaryExpressionSeen = false;
+ let numericLiteralSeen = false;
+
+ walkProgram(result.program, (node) => {
+ if (node.type === 'BinaryExpression') {
+ binaryExpressionSeen = true;
+ return false; // Skip traversing into the 1 + 2
+ }
+ if (node.type === 'Literal') {
+ numericLiteralSeen = true;
+ }
+ });
+
+ expect(binaryExpressionSeen).toBe(true);
+ expect(numericLiteralSeen).toBe(false); // Should not have visited the children
+ });
+});
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tsconfig.json b/.claude/worktrees/nice-taussig/packages/ast/tsconfig.json
new file mode 100644
index 0000000..c81871f
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "composite": false,
+ "incremental": false,
+ "moduleResolution": "bundler",
+ "module": "esnext",
+ "types": ["vitest/globals", "node"]
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/.claude/worktrees/nice-taussig/packages/ast/tsup.config.ts b/.claude/worktrees/nice-taussig/packages/ast/tsup.config.ts
new file mode 100644
index 0000000..3e0be3c
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/ast/tsup.config.ts
@@ -0,0 +1,5 @@
+import { createConfig } from '../../tsup.config.js';
+
+export default createConfig({
+ entry: ['src/index.ts'],
+});
diff --git a/.claude/worktrees/nice-taussig/packages/cache/package.json b/.claude/worktrees/nice-taussig/packages/cache/package.json
new file mode 100644
index 0000000..bc02567
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "@ngcompass/cache",
+ "version": "0.0.1",
+ "description": "Caching layer for ngcompass",
+ "sideEffects": false,
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "build:prod": "NODE_ENV=production tsup --minify",
+ "dev": "tsup --watch",
+ "test": "vitest run ",
+ "test:watch": "vitest",
+ "lint": "eslint src --ext .ts",
+ "typecheck": "tsc --noEmit",
+ "clean": "rimraf dist .turbo"
+ },
+ "dependencies": {
+ "@ngcompass/common": "workspace:*",
+ "cacache": "^20.0.3",
+ "lru-cache": "^11.2.5",
+ "write-file-atomic": "^7.0.0",
+ "xxhash-wasm": "^1.1.0"
+ },
+ "peerDependencies": {
+ "typescript": "catalog:"
+ },
+ "devDependencies": {
+ "@types/cacache": "^20.0.0",
+ "@types/node": "^25.1.0",
+ "@types/write-file-atomic": "^4.0.3"
+ },
+ "keywords": [
+ "angular",
+ "cache",
+ "ngcompass"
+ ],
+ "author": "ngcompass",
+ "license": "MIT"
+}
\ No newline at end of file
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/constants.ts b/.claude/worktrees/nice-taussig/packages/cache/src/constants.ts
new file mode 100644
index 0000000..a6a2aec
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/constants.ts
@@ -0,0 +1,8 @@
+export const CACHE_VERSION = '1.0.0';
+export const SCHEMA_VERSION = 1;
+
+/**
+ * Prefix for cache keys to ensure versioning.
+ * Format: v{CACHE_VERSION}-s{SCHEMA_VERSION}
+ */
+export const CACHE_KEY_PREFIX = `v${CACHE_VERSION}-s${SCHEMA_VERSION}`;
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/context.ts b/.claude/worktrees/nice-taussig/packages/cache/src/context.ts
new file mode 100644
index 0000000..c3d2687
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/context.ts
@@ -0,0 +1,190 @@
+import path from 'path';
+import { createMemoryDriver } from './drivers/memory.js';
+import { createDiskDriver } from './drivers/disk.js';
+import { createAtomicDriver } from './drivers/atomic.js';
+import { createJsonFileDriver } from './drivers/json-file.js';
+import { createSourceCache, SourceEntry } from './services/source-cache.js';
+import { createAstCache, AstEntry } from './services/ast-cache.js';
+import { createResultCache } from './services/result-cache.js';
+import { createConfigCache } from './services/config-cache.js';
+import { createMetaCache, FileMeta } from './services/meta-cache.js';
+import { createPlanCache } from './services/plan-cache.js';
+import { createFileCache, FileCacheEntry } from './services/file-cache.js';
+import { CacheConfig } from './drivers/types.js';
+import { computeCompositeHash } from './services/hashing.js';
+import { CACHE_VERSION } from './constants.js';
+import { CacheContext } from './types.js';
+
+/**
+ * Initializes the caching system.
+ */
+export const createCacheContext = (config: CacheConfig = {}): CacheContext => {
+ const cwd = process.cwd();
+ const defaultBaseDir = path.resolve(cwd, 'node_modules', '.cache', 'ngcompass');
+
+ // 1. Drivers
+ // Sources: Memory Only
+ const sourceDriver = createMemoryDriver({
+ maxItems: config.memory?.maxItems
+ });
+
+ // ASTs: Tiered (Memory + Disk)
+ const astL1 = createMemoryDriver({
+ maxItems: 200 // Keep top 200 ASTs in memory
+ });
+
+ const astL2 = createDiskDriver({
+ path: path.join(config.disk?.path ?? defaultBaseDir, 'ast'),
+ ttl: config.disk?.ttl
+ });
+
+ const resultDriver = createAtomicDriver({
+ path: path.join(config.disk?.path ?? defaultBaseDir, 'results')
+ });
+
+ const configDriver = createAtomicDriver({
+ path: path.join(config.disk?.path ?? defaultBaseDir, 'config')
+ });
+
+ const metaDriver = createJsonFileDriver({
+ path: path.join(config.disk?.path ?? defaultBaseDir, 'meta')
+ });
+
+ // Use regular disk driver for plan cache
+ const planDriver = createDiskDriver({
+ path: path.join(config.disk?.path ?? defaultBaseDir, 'plans'),
+ });
+
+ const fileDriver = createDiskDriver({
+ path: path.join(config.disk?.path ?? defaultBaseDir, 'files'),
+ });
+
+ // 2. Services
+ const sources = createSourceCache(sourceDriver);
+ const asts = createAstCache(astL1, astL2);
+ const results = createResultCache(resultDriver);
+ const configs = createConfigCache(configDriver);
+ const metas = createMetaCache(metaDriver);
+ const plans = createPlanCache(planDriver);
+ const files = createFileCache(fileDriver);
+
+ // Analysis results cache (disk driver, keyed by global hash)
+ const analysisDriver = createDiskDriver({
+ path: path.join(config.disk?.path ?? defaultBaseDir, 'analysis')
+ });
+ const analysis = createResultCache(analysisDriver);
+
+ // Shared helper: clear every driver ā single source of truth for "clear all".
+ const clearAllDrivers = async (): Promise => {
+ sourceDriver.clear();
+ astL1.clear();
+ await astL2.clear();
+ await resultDriver.clear();
+ await configDriver.clear();
+ await metaDriver.clear();
+ await planDriver.clear();
+ await fileDriver.clear();
+ await analysisDriver.clear();
+ };
+
+ return {
+ sources,
+ asts,
+ results,
+ configs,
+ metas,
+ plans,
+ files,
+ analysis,
+ computeHash: computeCompositeHash,
+ prune: async () => {
+ await astL2.prune();
+ },
+ clear: clearAllDrivers,
+ clearType: async (type) => {
+ switch (type) {
+ case 'ast':
+ astL1.clear();
+ await astL2.clear();
+ break;
+ case 'config':
+ await configDriver.clear();
+ break;
+ case 'results':
+ await resultDriver.clear();
+ await analysisDriver.clear();
+ break;
+ case 'all':
+ await clearAllDrivers();
+ break;
+ }
+ },
+ flush: async () => {
+ await metaDriver.flush();
+ },
+ getCachePath: () => config.disk?.path ?? defaultBaseDir,
+ getInfo: async () => {
+ const astL1Stats = astL1.getStats();
+ const [
+ astL2Stats,
+ configStats,
+ resultStats,
+ metaStats,
+ planStats,
+ fileStats,
+ analysisStats,
+ ] = await Promise.all([
+ astL2.getStats(),
+ configDriver.getStats(),
+ resultDriver.getStats(),
+ metaDriver.getStats(),
+ planDriver.getStats(),
+ fileDriver.getStats(),
+ analysisDriver.getStats(),
+ ]);
+
+ const totalSize =
+ astL1Stats.size +
+ astL2Stats.size +
+ configStats.size +
+ resultStats.size +
+ metaStats.size +
+ planStats.size +
+ fileStats.size +
+ analysisStats.size;
+
+ return {
+ ast: {
+ l1: { entries: astL1Stats.entries, maxEntries: 200, size: astL1Stats.size },
+ l2: { entries: astL2Stats.entries, size: astL2Stats.size }
+ },
+ config: { entries: configStats.entries, size: configStats.size },
+ results: { entries: resultStats.entries, size: resultStats.size },
+ totalSize,
+ location: config.disk?.path ?? defaultBaseDir,
+ version: CACHE_VERSION
+ };
+ }
+ };
+};
+
+let globalCache: CacheContext | null = null;
+
+/**
+ * Returns a globally shared cache context.
+ * Useful for CLI and long-running processes to avoid redundant initializations.
+ */
+export const getCacheContext = (config?: CacheConfig): CacheContext => {
+ if (!globalCache) {
+ globalCache = createCacheContext(config);
+ }
+ return globalCache;
+};
+
+/**
+ * Resets the global cache singleton to null.
+ * Call this in tests between runs to get a clean slate without restarting the process.
+ */
+export const resetGlobalCache = (): void => {
+ globalCache = null;
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/drivers/atomic.ts b/.claude/worktrees/nice-taussig/packages/cache/src/drivers/atomic.ts
new file mode 100644
index 0000000..18130f8
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/drivers/atomic.ts
@@ -0,0 +1,211 @@
+import writeFileAtomic from 'write-file-atomic';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { AsyncDriver, DiskDriverConfig } from './types.js';
+import { getDirectoryStats } from '../utils/fs.js';
+
+/**
+ * Creates a storage driver that writes each key as a separate file.
+ * Uses write-file-atomic for safe writes.
+ *
+ * Performance Optimization (P0):
+ * Maintains an in-memory "Directory Catalog" to avoid thousands of fs.access calls.
+ * - initialized lazily via fs.readdir
+ * - maintained in-sync on set/delete/clear
+ *
+ * Concurrency Safety:
+ * - coalesces concurrent catalog builds
+ * - guards catalog writes with a monotonic generation to avoid clear()/readdir() races
+ * - ensures set/delete update catalog correctly even while initialization is in-flight
+ */
+export const createAtomicDriver = (config: DiskDriverConfig): AsyncDriver => {
+ const cacheDir = config.path;
+
+ let catalog: Set | null = null;
+ let catalogPromise: Promise | null = null;
+ let catalogGeneration = 0;
+
+ const getFilePath = (key: string): string => path.join(cacheDir, `${key}.json`);
+
+ const ensureDir = async (): Promise => {
+ try {
+ await fs.mkdir(cacheDir, { recursive: true });
+ } catch {
+ // Intentionally ignored (directory may already exist or be created concurrently).
+ }
+ };
+
+ const listJsonKeys = (files: string[]): string[] =>
+ files.filter(isJsonFile).map(stripJsonExtension);
+
+ const isJsonFile = (fileName: string): boolean => fileName.endsWith('.json');
+
+ const stripJsonExtension = (fileName: string): string => fileName.slice(0, -5);
+
+ const safeReadDir = async (dir: string): Promise => {
+ try {
+ return await fs.readdir(dir);
+ } catch {
+ // Intentionally returns empty list if directory does not exist or cannot be read.
+ return [];
+ }
+ };
+
+ const tryReadJsonFile = async (filePath: string): Promise => {
+ try {
+ return await fs.readFile(filePath, 'utf-8');
+ } catch {
+ // Intentionally returns undefined when file cannot be read (missing, deleted, etc.).
+ return undefined;
+ }
+ };
+
+ const tryUnlink = async (filePath: string): Promise => {
+ try {
+ await fs.unlink(filePath);
+ } catch {
+ // Intentionally ignored (file may not exist).
+ }
+ };
+
+ const tryRemoveDir = async (dir: string): Promise => {
+ try {
+ await fs.rm(dir, { recursive: true, force: true });
+ } catch {
+ // Intentionally ignored (directory may not exist).
+ }
+ };
+
+ const markMissingInCatalog = (key: string): void => {
+ if (catalog) catalog.delete(key);
+ };
+
+ const addToCatalog = (key: string): void => {
+ if (catalog) catalog.add(key);
+ };
+
+ const removeFromCatalog = (key: string): void => {
+ if (catalog) catalog.delete(key);
+ };
+
+ const resetCatalogEmpty = (): void => {
+ catalog = new Set();
+ };
+
+ const awaitCatalogInitIfInFlight = async (): Promise => {
+ if (catalogPromise) {
+ await catalogPromise;
+ }
+ };
+
+ /**
+ * Initializes the catalog if not already loaded.
+ * Coalesces multiple concurrent calls into a single readdir.
+ * Uses generation guard to avoid clear()/readdir() races.
+ */
+ const ensureCatalog = async (): Promise => {
+ if (catalog) return;
+
+ if (!catalogPromise) {
+ const localGeneration = catalogGeneration;
+
+ catalogPromise = (async () => {
+ try {
+ await ensureDir();
+ const files = await safeReadDir(cacheDir);
+ const keys = listJsonKeys(files);
+
+ // Only publish if generation hasn't changed (e.g., clear() happened).
+ if (catalogGeneration === localGeneration) {
+ catalog = new Set(keys);
+ }
+ } finally {
+ // Always release the promise, even if errors occurred.
+ catalogPromise = null;
+
+ // Ensure catalog is never left null after initialization attempt.
+ if (!catalog) {
+ catalog = new Set();
+ }
+ }
+ })();
+ }
+
+ await catalogPromise;
+ };
+
+ const publishCatalogAfterClear = (): void => {
+ catalogGeneration += 1;
+ resetCatalogEmpty();
+ };
+
+ const syncCatalogAfterSet = async (key: string): Promise => {
+ await awaitCatalogInitIfInFlight();
+ addToCatalog(key);
+ };
+
+ const syncCatalogAfterDelete = async (key: string): Promise => {
+ await awaitCatalogInitIfInFlight();
+ removeFromCatalog(key);
+ };
+
+ return {
+ get: async (key: string): Promise => {
+ // Intentionally avoids forcing catalog initialization for single reads.
+ if (catalog && !catalog.has(key)) {
+ return undefined;
+ }
+
+ const filePath = getFilePath(key);
+ const data = await tryReadJsonFile(filePath);
+
+ if (data === undefined) {
+ markMissingInCatalog(key);
+ return undefined;
+ }
+
+ try {
+ return JSON.parse(data) as T;
+ } catch {
+ // Intentionally treats corrupt entries as cache misses and removes from catalog.
+ markMissingInCatalog(key);
+ return undefined;
+ }
+ },
+
+ set: async (key: string, value: T): Promise => {
+ await ensureDir();
+ const filePath = getFilePath(key);
+
+ await writeFileAtomic(filePath, JSON.stringify(value), { encoding: 'utf-8' });
+
+ if (catalog || catalogPromise) {
+ await syncCatalogAfterSet(key);
+ }
+ },
+
+ has: async (key: string): Promise => {
+ await ensureCatalog();
+ return catalog!.has(key);
+ },
+
+ delete: async (key: string): Promise => {
+ await tryUnlink(getFilePath(key));
+
+ if (catalog || catalogPromise) {
+ await syncCatalogAfterDelete(key);
+ }
+ },
+
+ clear: async (): Promise => {
+ await tryRemoveDir(cacheDir);
+ await ensureDir();
+
+ publishCatalogAfterClear();
+ },
+
+ getStats: async () => {
+ return getDirectoryStats(cacheDir);
+ },
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/drivers/disk.ts b/.claude/worktrees/nice-taussig/packages/cache/src/drivers/disk.ts
new file mode 100644
index 0000000..9543b0a
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/drivers/disk.ts
@@ -0,0 +1,87 @@
+import cacache from 'cacache';
+import v8 from 'node:v8';
+import { AsyncDriver, DiskDriverConfig } from './types.js';
+import { getDirectoryStats } from '../utils/fs.js';
+
+/**
+ * Creates an asynchronous disk storage driver backed by cacache.
+ * Uses V8 serialization for high-performance binary storage.
+ * Includes resilience against deserialization errors (Try-Catch-Delete).
+ */
+export const createDiskDriver = (
+ config: DiskDriverConfig
+): AsyncDriver & { prune: () => Promise } => {
+ const cachePath = config.path;
+
+ return {
+ get: async (key: string): Promise => {
+ try {
+ const tReadStart = performance.now();
+ const result = await cacache.get(cachePath, key);
+ const tReadEnd = performance.now();
+
+ const tDeserStart = performance.now();
+ const deserialized = v8.deserialize(result.data) as T;
+ const tDeserEnd = performance.now();
+
+ // Optional: debug logging for cache driver timing
+ if (process.env.DEBUG_CACHE) {
+ console.log(`[disk-driver] read: ${(tReadEnd - tReadStart).toFixed(2)}ms, deser: ${(tDeserEnd - tDeserStart).toFixed(2)}ms, size: ${(result.data.length / 1024).toFixed(1)}KB`);
+ }
+
+ return deserialized;
+ } catch (err: unknown) {
+ const code = typeof err === 'object' && err !== null && 'code' in err ? (err as { code: unknown }).code : undefined;
+
+ if (code === 'ENOENT') {
+ return undefined; // Not found
+ }
+
+ // Defensive: If deserialization fails (corruption, version mismatch),
+ // delete the entry to self-heal.
+ try {
+ await cacache.rm.entry(cachePath, key);
+ } catch {
+ // Ignore delete errors
+ }
+ return undefined;
+ }
+ },
+
+ set: async (key: string, value: T): Promise => {
+ // Serialize to Buffer using V8
+ const buffer = v8.serialize(value);
+ await cacache.put(cachePath, key, buffer);
+ },
+
+ has: async (key: string): Promise => {
+ try {
+ const info = await cacache.get.info(cachePath, key);
+ return !!info;
+ } catch {
+ return false;
+ }
+ },
+
+ delete: async (key: string): Promise => {
+ await cacache.rm.entry(cachePath, key);
+ },
+
+ clear: async (): Promise => {
+ await cacache.rm.all(cachePath);
+ },
+
+ prune: async (): Promise => {
+ // cleans up old/unused entries
+ await cacache.verify(cachePath);
+ },
+
+ getStats: async () => {
+ // cacache stores content in content-addressable blobs.
+ // Counting entries is hard without listing keys.
+ // Ideally we'd use cacache.ls() but that's slow.
+ // For "size", getting the folder size is accurate enough for user consumption.
+ return getDirectoryStats(cachePath);
+ }
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/drivers/json-file.ts b/.claude/worktrees/nice-taussig/packages/cache/src/drivers/json-file.ts
new file mode 100644
index 0000000..472806f
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/drivers/json-file.ts
@@ -0,0 +1,117 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import writeFileAtomic from 'write-file-atomic';
+import { debug } from '@ngcompass/common';
+import { AsyncDriver, DiskDriverConfig, DriverStats } from './types.js';
+
+export const createJsonFileDriver = (
+ config: DiskDriverConfig
+): AsyncDriver & { flush: () => Promise } => {
+ const filePath = path.join(config.path, 'meta.json');
+ const debounceMs = 2000;
+
+ let cache: Map | null = null;
+ let loadPromise: Promise | null = null;
+ let saveTimeout: NodeJS.Timeout | null = null;
+ let savePromise: Promise | null = null;
+
+ const load = async (): Promise => {
+ if (cache) return;
+ if (loadPromise) return loadPromise;
+
+ loadPromise = (async () => {
+ try {
+ await fs.mkdir(config.path, { recursive: true });
+ const content = await fs.readFile(filePath, 'utf-8');
+ const data = JSON.parse(content);
+ if (data && typeof data === 'object') {
+ cache = new Map(Object.entries(data));
+ } else {
+ cache = new Map();
+ }
+ } catch {
+ cache = new Map();
+ } finally {
+ loadPromise = null;
+ }
+ })();
+
+ return loadPromise;
+ };
+
+ const flush = async (): Promise => {
+ if (saveTimeout) {
+ clearTimeout(saveTimeout);
+ saveTimeout = null;
+ }
+
+ if (!cache) return;
+ if (savePromise) await savePromise;
+
+ savePromise = (async () => {
+ try {
+ await fs.mkdir(config.path, { recursive: true });
+ const data = Object.fromEntries(cache);
+ await writeFileAtomic(filePath, JSON.stringify(data));
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ debug('cache', `Failed to flush metadata cache to ${filePath}: ${msg}`);
+ } finally {
+ savePromise = null;
+ }
+ })();
+
+ await savePromise;
+ };
+
+ const scheduleSave = () => {
+ if (saveTimeout) return;
+ saveTimeout = setTimeout(() => {
+ saveTimeout = null;
+ void flush();
+ }, debounceMs);
+ saveTimeout.unref();
+ };
+
+ return {
+ get: async (key: string): Promise => {
+ await load();
+ return cache!.get(key);
+ },
+ set: async (key: string, value: T): Promise => {
+ await load();
+ cache!.set(key, value);
+ scheduleSave();
+ },
+ has: async (key: string): Promise => {
+ await load();
+ return cache!.has(key);
+ },
+ delete: async (key: string): Promise => {
+ await load();
+ if (cache!.delete(key)) {
+ scheduleSave();
+ }
+ },
+ clear: async (): Promise => {
+ if (saveTimeout) {
+ clearTimeout(saveTimeout);
+ saveTimeout = null;
+ }
+ cache = new Map();
+ try {
+ await fs.unlink(filePath);
+ } catch {
+ // Ignore
+ }
+ },
+ getStats: async (): Promise => {
+ await load();
+ return {
+ entries: cache!.size,
+ size: 0
+ };
+ },
+ flush
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/drivers/memory.ts b/.claude/worktrees/nice-taussig/packages/cache/src/drivers/memory.ts
new file mode 100644
index 0000000..0abf901
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/drivers/memory.ts
@@ -0,0 +1,45 @@
+import { LRUCache } from 'lru-cache';
+import { MemoryDriverConfig, SyncDriver } from './types.js';
+
+/**
+ * Creates a synchronous memory storage driver backed by LRU Cache.
+ */
+export const createMemoryDriver = (
+ config: MemoryDriverConfig = {}
+): SyncDriver => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const cache = new LRUCache({
+ max: config.maxItems ?? 500,
+ maxSize: config.maxSize ?? 50 * 1024 * 1024, // Default 50MB
+ sizeCalculation: (value) => {
+ // Rough estimate using JSON length. Fast enough for writes.
+ try {
+ return JSON.stringify(value).length;
+ } catch {
+ return 1000; // Fallback for circular/non-serializable
+ }
+ },
+ ttl: config.ttl,
+ });
+
+ return {
+ get: (key: string): T | undefined => cache.get(key) as T | undefined,
+
+ set: (key: string, value: T): void => {
+ cache.set(key, value);
+ },
+
+ has: (key: string): boolean => cache.has(key),
+
+ delete: (key: string): void => {
+ cache.delete(key);
+ },
+
+ clear: (): void => cache.clear(),
+
+ getStats: () => ({
+ entries: cache.size,
+ size: cache.calculatedSize
+ }),
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/drivers/types.ts b/.claude/worktrees/nice-taussig/packages/cache/src/drivers/types.ts
new file mode 100644
index 0000000..0153023
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/drivers/types.ts
@@ -0,0 +1,55 @@
+/**
+ * Generic storage driver interface for asynchronous operations.
+ * Used for Disk and Network IO.
+ */
+export interface AsyncDriver {
+ get(key: string): Promise;
+ set(key: string, value: T): Promise;
+ has(key: string): Promise;
+ delete(key: string): Promise;
+ clear(): Promise;
+ getStats(): Promise;
+}
+
+/**
+ * Generic storage driver interface for synchronous operations.
+ * Used for Memory operations.
+ */
+export interface SyncDriver {
+ get(key: string): T | undefined;
+ set(key: string, value: T): void;
+ has(key: string): boolean;
+ delete(key: string): void;
+ clear(): void;
+ getStats(): DriverStats;
+}
+
+export interface DriverStats {
+ entries: number;
+ size: number; // Bytes
+}
+
+/**
+ * Configuration for the memory driver.
+ */
+export interface MemoryDriverConfig {
+ maxItems?: number;
+ maxSize?: number; // Maximum size in bytes
+ ttl?: number;
+}
+
+/**
+ * Configuration for the disk driver.
+ */
+export interface DiskDriverConfig {
+ path: string;
+ ttl?: number;
+}
+
+/**
+ * Global cache configuration.
+ */
+export interface CacheConfig {
+ memory?: MemoryDriverConfig;
+ disk?: Partial;
+}
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/env-fingerprint.ts b/.claude/worktrees/nice-taussig/packages/cache/src/env-fingerprint.ts
new file mode 100644
index 0000000..8b6c6bc
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/env-fingerprint.ts
@@ -0,0 +1,205 @@
+/**
+ * Environment Fingerprint
+ *
+ * Computes a deterministic, hermetic fingerprint representing all non-file
+ * inputs that influence analysis correctness.
+ *
+ * Design: pure functions, explicit dependencies, no module-level mutable state.
+ * The fingerprint is computed once at startup and stored in CacheContext ā
+ * callers receive it via explicit parameter, never via a global singleton.
+ *
+ * Fingerprint components (all declared, none implicit):
+ * toolVersion ā CACHE_VERSION constant
+ * schemaVersion ā SCHEMA_VERSION integer
+ * nodeVersion ā major.minor of process.versions.node
+ * platform ā process.platform
+ * arch ā process.arch
+ * typescriptVersion ā resolved from typescript/package.json
+ * oxcParserVersion ā resolved from oxc-parser/package.json
+ * htmlParserVersion ā resolved from angular-html-parser/package.json
+ * cssParserVersion ā resolved from lightningcss/package.json
+ * ruleRegistryFp ā SHA-256 of sorted registered rule names
+ *
+ * Any change to any component causes a cache miss on all persistent caches ā
+ * no manual cache clearing required.
+ */
+
+import crypto from 'node:crypto';
+import { createRequire } from 'node:module';
+import { debug } from '@ngcompass/common';
+import { CACHE_VERSION, SCHEMA_VERSION } from './constants.js';
+
+// āāā Public types āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+
+/**
+ * All environment inputs that influence cache correctness.
+ * Exposed so callers can log or display individual fields (observability).
+ */
+export interface EnvFingerprintComponents {
+ readonly toolVersion: string;
+ readonly schemaVersion: number;
+ /** Node major.minor ā patch omitted (ABI-stable within minor series) */
+ readonly nodeVersion: string;
+ readonly platform: string;
+ readonly arch: string;
+ readonly typescriptVersion: string;
+ readonly oxcParserVersion: string;
+ readonly htmlParserVersion: string;
+ readonly cssParserVersion: string;
+ /** SHA-256 of sorted registered rule names */
+ readonly ruleRegistryFp: string;
+}
+
+/**
+ * Computed fingerprint + its source components.
+ * Stored in CacheContext and passed explicitly wherever it is needed.
+ */
+export interface EnvFingerprint {
+ /** SHA-256 hex digest ā used as cache key prefix */
+ readonly value: string;
+ /** Raw inputs ā used for diagnostics / cache info output */
+ readonly components: EnvFingerprintComponents;
+}
+
+// āāā Internal pure helpers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+
+/**
+ * Reads `version` from a package.json without throwing.
+ * Returns 'unknown' when the package is absent or unresolvable.
+ */
+const readPackageVersion = (packageName: string): string => {
+ try {
+ const req = createRequire(import.meta.url);
+ const pkg = req(`${packageName}/package.json`) as { version?: string };
+ return pkg.version ?? 'unknown';
+ } catch {
+ return 'unknown';
+ }
+};
+
+/**
+ * Extracts major.minor from a semver string.
+ * "20.11.1" ā "20.11"
+ *
+ * Patch is excluded because Node patch releases are ABI-stable ā V8
+ * serialization format does not change within a major.minor series.
+ */
+const toMajorMinor = (version: string): string => {
+ const [major = '0', minor = '0'] = version.replace(/^v/, '').split('.');
+ return `${major}.${minor}`;
+};
+
+/**
+ * Stable JSON: recursively sorts object keys so identical objects always
+ * produce the same string regardless of property insertion order.
+ */
+const stableJson = (value: unknown): string => {
+ const normalize = (v: unknown): unknown => {
+ if (Array.isArray(v)) return v.map(normalize);
+ if (v !== null && typeof v === 'object') {
+ const sorted: Record = {};
+ for (const k of Object.keys(v).sort()) {
+ sorted[k] = normalize((v as Record)[k]);
+ }
+ return sorted;
+ }
+ return v;
+ };
+ return JSON.stringify(normalize(value));
+};
+
+/** SHA-256 hex digest of a UTF-8 string. */
+const sha256 = (input: string): string =>
+ crypto.createHash('sha256').update(input, 'utf8').digest('hex');
+
+// āāā Rule registry fingerprint āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+
+/**
+ * Computes a deterministic fingerprint of the rule registry.
+ *
+ * Captures: rule additions, removals, and renames (name-set changes).
+ * Does NOT capture inline logic changes to an existing rule without a rename.
+ * For that guarantee, bump SCHEMA_VERSION when shipping rule logic changes.
+ *
+ * Imported lazily to avoid circular deps at module-load time and to ensure
+ * all registerNewEngineRule side-effect imports have run first.
+ */
+const computeRuleRegistryFp = async (): Promise => {
+ try {
+ // Dynamic import to avoid circular dependency (cache ā engine ā rules).
+ // At runtime the CLI has already loaded @ngcompass/rules before this runs.
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const moduleName = '@ngcompass/rules';
+ const mod = await import(/* webpackIgnore: true */ moduleName) as {
+ getGlobalRegistry: () => { getRuleNames: () => Iterable };
+ };
+ const { getGlobalRegistry } = mod;
+ const ruleNames = [...getGlobalRegistry().getRuleNames()].sort();
+
+ if (ruleNames.length === 0) return sha256('empty-registry');
+
+ const fp = sha256(ruleNames.join('\n'));
+ debug('env-fingerprint', `Rule registry fp: ${fp.slice(0, 8)}⦠(${ruleNames.length} rules)`);
+ return fp;
+ } catch (err) {
+ debug('env-fingerprint', `Rule registry fp error: ${String(err)}`);
+ return sha256('registry-error');
+ }
+};
+
+// āāā Component assembly āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+
+const gatherComponents = async (): Promise => {
+ const ruleRegistryFp = await computeRuleRegistryFp();
+
+ return {
+ toolVersion: CACHE_VERSION,
+ schemaVersion: SCHEMA_VERSION,
+ nodeVersion: toMajorMinor(process.versions.node),
+ platform: process.platform,
+ arch: process.arch,
+ typescriptVersion: readPackageVersion('typescript'),
+ oxcParserVersion: readPackageVersion('oxc-parser'),
+ htmlParserVersion: readPackageVersion('angular-html-parser'),
+ cssParserVersion: readPackageVersion('lightningcss'),
+ ruleRegistryFp,
+ };
+};
+
+// āāā Public API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+
+/**
+ * Computes the environment fingerprint.
+ *
+ * Call once at startup; store the result in CacheContext; pass it explicitly
+ * to all cache key builders. Do not call in hot paths.
+ *
+ * The returned `value` is a SHA-256 hex digest that changes whenever any
+ * environment input changes, automatically invalidating all keyed caches.
+ */
+export const computeEnvFingerprint = async (): Promise => {
+ const components = await gatherComponents();
+ const value = sha256(stableJson(components as unknown as Record));
+
+ debug('env-fingerprint', `Env fingerprint: ${value.slice(0, 16)}ā¦`);
+ debug('env-fingerprint', ` tool: ${components.toolVersion} (schema v${components.schemaVersion})`);
+ debug('env-fingerprint', ` node: ${components.nodeVersion} ${components.platform}/${components.arch}`);
+ debug('env-fingerprint', ` ts: ${components.typescriptVersion}`);
+ debug('env-fingerprint', ` oxc: ${components.oxcParserVersion}`);
+ debug('env-fingerprint', ` html: ${components.htmlParserVersion}`);
+ debug('env-fingerprint', ` css: ${components.cssParserVersion}`);
+ debug('env-fingerprint', ` rules: ${components.ruleRegistryFp.slice(0, 8)}ā¦`);
+
+ return { value, components };
+};
+
+/**
+ * Builds an env-scoped cache key: `::`.
+ *
+ * The `::` separator cannot appear in SHA-256 hex digests, so prefixed keys
+ * are always distinguishable from un-prefixed ones.
+ *
+ * Pure function ā same inputs always produce the same output, no side effects.
+ */
+export const buildEnvScopedKey = (fingerprint: EnvFingerprint, originalKey: string): string =>
+ `${fingerprint.value}::${originalKey}`;
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/hashing.ts b/.claude/worktrees/nice-taussig/packages/cache/src/hashing.ts
new file mode 100644
index 0000000..88d888b
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/hashing.ts
@@ -0,0 +1,37 @@
+/**
+ * Content Hashing ā Core utility
+ *
+ * Deterministic hashing for cache invalidation.
+ * Uses xxhash-wasm (fast) with SHA-256 fallback before initialization.
+ * The full planner/hashing.ts (file-level helpers) will import computeHash from here.
+ */
+
+import crypto from 'node:crypto';
+import xxhash from 'xxhash-wasm';
+
+let h64: ((input: string | Uint8Array) => string) | undefined;
+
+/**
+ * Initializes the xxhash hasher (call once at startup).
+ */
+export const initHasher = async (): Promise => {
+ if (h64) return;
+ const { h64: hasher } = await xxhash();
+ h64 = (input: string | Uint8Array) =>
+ hasher(input as any)
+ .toString(16)
+ .padStart(16, '0');
+};
+
+/**
+ * Computes a hash using xxhash if initialized, SHA-256 otherwise.
+ *
+ * @param content - Data to hash
+ * @returns Hex-encoded hash string
+ */
+export const computeHash = (content: string | Uint8Array): string => {
+ if (h64) return h64(content);
+ const hasher = crypto.createHash('sha256');
+ hasher.update(content);
+ return hasher.digest('hex');
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/index.ts b/.claude/worktrees/nice-taussig/packages/cache/src/index.ts
new file mode 100644
index 0000000..70bc8f7
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/index.ts
@@ -0,0 +1,24 @@
+/**
+ * @ngcompass/cache
+ *
+ * Caching layer for the ngcompass analysis engine.
+ * Provides disk, memory, and JSON-file backed drivers,
+ * plus cache services for AST, results, config, and meta data.
+ */
+
+// Core types
+export * from './types.js';
+export * from './constants.js';
+
+// Hashing primitives (also used by @ngcompass/planner)
+export { computeHash, initHasher } from './hashing.js';
+export { stableSerialize, SerializationError } from './utils/stable-serialize.js';
+
+// Cache key context
+export * from './key-context.js';
+
+// Drivers
+export type { CacheConfig } from './drivers/types.js';
+
+// Context (CacheContext factory)
+export * from './context.js';
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/key-context.ts b/.claude/worktrees/nice-taussig/packages/cache/src/key-context.ts
new file mode 100644
index 0000000..9e44c6d
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/key-context.ts
@@ -0,0 +1,135 @@
+/**
+ * Cache Key Context
+ *
+ * Encapsulates all version-related inputs that affect cache validity.
+ * Must be included in EVERY cache key so that stale entries from a previous
+ * version of the tool, parser, or rule set are never reused.
+ *
+ * This resolves the critical result-cache staleness bug where upgrading the
+ * tool changes the globalHash (plan cache miss) but NOT the individual taskIds,
+ * allowing old per-task results from the previous engine version to be served.
+ *
+ * Exact key formulas:
+ *
+ * Config key:
+ * SHA-256(contentHash + "::" + profile + "::" + serializeCacheKeyContext(ctx))
+ *
+ * Global hash (plan + analysis cache key):
+ * hash(sortedFilePairs + "||" + rulesHash + "||||" + serializeCacheKeyContext(ctx))
+ *
+ * Task ID (result cache key):
+ * hash(ctx.toolVersion + "::" + ctx.ruleRegistryHash + "::" + ruleName + "::" + inputs...)
+ */
+
+import { createRequire } from 'node:module';
+import { PACKAGE_VERSION, CACHE_VERSION } from '@ngcompass/common';
+import { computeHash } from './hashing.js';
+import { stableSerialize } from './utils/stable-serialize.js';
+
+// ============================================================
+// INTERFACE
+// ============================================================
+
+export interface CacheKeyContext {
+ /**
+ * The ngcompass package version (e.g. "1.2.3").
+ * Bumped on every release ā invalidates all caches on upgrade.
+ */
+ readonly toolVersion: string;
+
+ /**
+ * The cache schema version (CACHE_VERSION constant).
+ * Bumped when the serialization format of cached values changes.
+ */
+ readonly schemaVersion: string;
+
+ /**
+ * A hash of all registered rule names + versions.
+ * Changes when a rule is added, removed, or its plugin is updated.
+ */
+ readonly ruleRegistryHash: string;
+
+ /**
+ * The oxc-parser version, if detectable; "unknown" otherwise.
+ * Changes when the parser is upgraded, ensuring AST-dependent caches
+ * are invalidated even if tool version is unchanged.
+ */
+ readonly parserVersion: string;
+
+ /**
+ * The OS platform string (e.g. "linux", "darwin", "win32").
+ * Prevents cross-platform cache sharing where path separators or
+ * binary outputs differ.
+ */
+ readonly platform: string;
+}
+
+// ============================================================
+// FACTORY
+// ============================================================
+
+/**
+ * Builds a CacheKeyContext from the current runtime environment and
+ * the registered rule names.
+ *
+ * Call this ONCE per process, after all plugins are loaded and all rules
+ * are registered. Pass the resulting context to buildExecutionPlan() and
+ * resolveConfig().
+ *
+ * @param ruleNames - All registered rule names (from RuleRegistry)
+ */
+export function buildCacheKeyContext(
+ ruleNames: ReadonlyArray
+): CacheKeyContext {
+ const sortedNames = [...ruleNames].sort();
+ const ruleRegistryHash = computeHash(stableSerialize(sortedNames));
+
+ return Object.freeze({
+ toolVersion: PACKAGE_VERSION,
+ schemaVersion: CACHE_VERSION,
+ ruleRegistryHash,
+ parserVersion: resolveParserVersion(),
+ platform: process.platform,
+ });
+}
+
+// ============================================================
+// SERIALIZATION
+// ============================================================
+
+/**
+ * Serializes the context to a stable string for embedding in cache keys.
+ *
+ * Format: "toolVersion::schemaVersion::parserVersion::ruleRegistryHash::platform"
+ *
+ * All components are separated by "::" which cannot appear in the individual
+ * values (version strings and hashes use only alphanumeric characters, dots,
+ * and hyphens).
+ */
+export function serializeCacheKeyContext(ctx: CacheKeyContext): string {
+ return [
+ ctx.toolVersion,
+ ctx.schemaVersion,
+ ctx.parserVersion,
+ ctx.ruleRegistryHash,
+ ctx.platform,
+ ].join('::');
+}
+
+// ============================================================
+// INTERNAL HELPERS
+// ============================================================
+
+/**
+ * Attempts to read the oxc-parser version from its package.json.
+ * Returns "unknown" if the package is not resolvable.
+ */
+function resolveParserVersion(): string {
+ try {
+ const require = createRequire(import.meta.url);
+ const pkg = require('oxc-parser/package.json') as { version?: string };
+ return String(pkg.version ?? 'unknown');
+ } catch {
+ return 'unknown';
+ }
+}
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/services/ast-cache.ts b/.claude/worktrees/nice-taussig/packages/cache/src/services/ast-cache.ts
new file mode 100644
index 0000000..fe7fe0f
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/services/ast-cache.ts
@@ -0,0 +1,60 @@
+import { AsyncDriver, SyncDriver } from '../drivers/types.js';
+import { debug } from '@ngcompass/common';
+
+export interface AstEntry {
+ filePath: string;
+ ast: unknown;
+}
+
+export interface AstCache {
+ get: (hash: string) => Promise;
+ set: (hash: string, entry: AstEntry) => Promise;
+ invalidate: (hash: string) => Promise;
+}
+
+/**
+ * Creates a Two-Tiered AST Cache.
+ * Strategy:
+ * 1. Read: Memory (L1) -> Disk (L2).
+ * 2. Write: Memory (L1) -> Disk (L2).
+ * 3. Promotion: If found in L2, promote to L1.
+ * Key: Content Hash.
+ */
+export const createAstCache = (
+ l1: SyncDriver,
+ l2: AsyncDriver
+): AstCache => {
+ return {
+ get: async (hash: string): Promise => {
+ // 1. Check L1
+ const hot = l1.get(hash);
+ if (hot) return hot;
+
+ // 2. Check L2
+ try {
+ const cold = await l2.get(hash);
+ if (cold) {
+ // 3. Promote to L1
+ l1.set(hash, cold);
+ return cold;
+ }
+ } catch (error) {
+ debug('cache', `Failed to read AST from L2 for ${hash}: ${error instanceof Error ? error.message : String(error)}`);
+ return undefined;
+ }
+
+ return undefined;
+ },
+
+ set: async (hash: string, entry: AstEntry): Promise => {
+ // Write to both
+ l1.set(hash, entry);
+ await l2.set(hash, entry);
+ },
+
+ invalidate: async (hash: string): Promise => {
+ l1.delete(hash);
+ await l2.delete(hash);
+ }
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/services/config-cache.ts b/.claude/worktrees/nice-taussig/packages/cache/src/services/config-cache.ts
new file mode 100644
index 0000000..2189bf2
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/services/config-cache.ts
@@ -0,0 +1,24 @@
+import { ConfigValidationResult } from '@ngcompass/common';
+import { AsyncDriver } from '../drivers/types.js';
+
+export interface ConfigCache {
+ get: (hash: string) => Promise;
+ set: (hash: string, report: ConfigValidationResult) => Promise;
+}
+
+/**
+ * Creates a Configuration Cache.
+ * Strategy: Persistent Disk (Atomic files).
+ * Key: Raw Configuration Hash.
+ */
+export const createConfigCache = (driver: AsyncDriver): ConfigCache => {
+ return {
+ get: async (hash: string): Promise => {
+ return driver.get(hash);
+ },
+
+ set: async (hash: string, report: ConfigValidationResult): Promise => {
+ await driver.set(hash, report);
+ }
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/services/file-cache.ts b/.claude/worktrees/nice-taussig/packages/cache/src/services/file-cache.ts
new file mode 100644
index 0000000..a173a09
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/services/file-cache.ts
@@ -0,0 +1,18 @@
+import { AsyncDriver } from '../drivers/types.js';
+
+export interface FileCacheEntry {
+ files: string[];
+ timestamp: number;
+}
+
+export interface FileCache {
+ get: (key: string) => Promise;
+ set: (key: string, files: string[]) => Promise;
+}
+
+export const createFileCache = (driver: AsyncDriver): FileCache => {
+ return {
+ get: (key) => driver.get(key),
+ set: (key, files) => driver.set(key, { files, timestamp: Date.now() })
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/services/hashing.ts b/.claude/worktrees/nice-taussig/packages/cache/src/services/hashing.ts
new file mode 100644
index 0000000..6f92cae
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/services/hashing.ts
@@ -0,0 +1,15 @@
+import crypto from 'node:crypto';
+import { CACHE_KEY_PREFIX } from '../constants.js';
+
+/**
+ * Computes a customized SHA-256 hash for content.
+ * Accepts an optional salt (e.g., tool version) to ensure cache invalidation on upgrades.
+ */
+export const computeCompositeHash = (content: string, salt: string = CACHE_KEY_PREFIX): string => {
+ const hash = crypto.createHash('sha256');
+ hash.update(content);
+ if (salt) {
+ hash.update(salt);
+ }
+ return hash.digest('hex');
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/services/meta-cache.ts b/.claude/worktrees/nice-taussig/packages/cache/src/services/meta-cache.ts
new file mode 100644
index 0000000..6118ecd
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/services/meta-cache.ts
@@ -0,0 +1,33 @@
+import { AsyncDriver } from '../drivers/types.js';
+
+export interface FileMeta {
+ readonly mtime: number;
+ readonly size: number;
+ readonly hash: string;
+}
+
+export interface MetaCache {
+ get: (filePath: string) => Promise;
+ set: (filePath: string, meta: FileMeta) => Promise;
+ delete: (filePath: string) => Promise;
+ flush: () => Promise;
+}
+
+/**
+ * Creates a File Meta Cache.
+ * Stores file stats (mtime, size) and their computed content hashes.
+ * Strategy: Persistent Disk Storage.
+ * Key: Absolute File Path.
+ */
+export const createMetaCache = (driver: AsyncDriver): MetaCache => {
+ return {
+ get: (filePath) => driver.get(filePath),
+ set: (filePath, meta) => driver.set(filePath, meta),
+ delete: (filePath) => driver.delete(filePath),
+ flush: async () => {
+ if ('flush' in driver && typeof (driver as any).flush === 'function') {
+ await (driver as any).flush();
+ }
+ }
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/services/plan-cache.ts b/.claude/worktrees/nice-taussig/packages/cache/src/services/plan-cache.ts
new file mode 100644
index 0000000..5df9d93
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/services/plan-cache.ts
@@ -0,0 +1,21 @@
+import { AsyncDriver } from '../drivers/types.js';
+
+export interface PlanCache {
+ get: (key: string) => Promise;
+ set: (key: string, plan: any) => Promise;
+ delete: (key: string) => Promise;
+}
+
+/**
+ * Creates a Plan Cache.
+ * Stores the entire built execution plan as a binary blob using V8 serialization.
+ * Strategy: Persistent Disk Storage.
+ * Key: Combined hash of files + rules.
+ */
+export const createPlanCache = (driver: AsyncDriver): PlanCache => {
+ return {
+ get: (key) => driver.get(key),
+ set: (key, plan) => driver.set(key, plan),
+ delete: (key) => driver.delete(key),
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/services/result-cache.ts b/.claude/worktrees/nice-taussig/packages/cache/src/services/result-cache.ts
new file mode 100644
index 0000000..8be3dc7
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/services/result-cache.ts
@@ -0,0 +1,292 @@
+import { AsyncDriver } from '../drivers/types.js';
+import { debug } from '@ngcompass/common';
+
+/**
+ * Cache metadata for tracking usage and freshness
+ */
+export interface CacheMetadata {
+ /** Task ID (content hash) */
+ readonly taskId: string;
+ /** When result was cached */
+ readonly timestamp: number;
+ /** Number of cache hits */
+ readonly hits: number;
+ /** Last access time (for LRU) */
+ readonly lastAccess: number;
+}
+
+/**
+ * Cache entry with metadata
+ */
+export interface CacheEntry {
+ /** The cached result */
+ readonly result: T;
+ /** Cache metadata */
+ readonly metadata: CacheMetadata;
+}
+
+/**
+ * Result Cache interface with bulk operations for Phase 2.0
+ */
+export interface ResultCache {
+ // Single operations (existing)
+ get: (hash: string) => Promise;
+ set: (hash: string, result: T) => Promise;
+ has: (hash: string) => Promise;
+ delete: (hash: string) => Promise;
+
+ // Bulk operations (Phase 2.0)
+ getMany: (hashes: ReadonlyArray) => Promise>;
+ setMany: (entries: ReadonlyArray) => Promise;
+ hasMany: (hashes: ReadonlyArray) => Promise>;
+
+ // Metadata operations (Phase 2.0)
+ getWithMetadata: (hash: string) => Promise | undefined>;
+ getManyWithMetadata: (hashes: ReadonlyArray) => Promise>>;
+ updateMetadata: (hash: string, updates: Partial) => Promise;
+}
+
+/**
+ * Creates a Result Cache with bulk operations and metadata tracking.
+ * Strategy: Distributed Files (One file per result).
+ * Key: Content Hash (taskId).
+ *
+ * Phase 2.0: Enhanced with:
+ * - Bulk operations (getMany, setMany, hasMany)
+ * - Metadata tracking (hits, timestamp, lastAccess)
+ * - Cache warmth analysis
+ */
+/**
+ * Maximum number of result entries to read/write in a single parallel batch.
+ * Sized to stay well within typical OS per-process file-descriptor limits (~1024)
+ * while keeping batch overhead low. Benchmarked at 200 on ext4 and APFS.
+ */
+const RESULT_BATCH_SIZE = 200;
+
+/**
+ * Maximum number of metadata entries to write in a single parallel batch.
+ * Kept lower than RESULT_BATCH_SIZE because metadata writes are paired with
+ * result reads ā running both at 200 would double the concurrent fd usage.
+ */
+const METADATA_BATCH_SIZE = 100;
+
+export const createResultCache = (driver: AsyncDriver): ResultCache => {
+
+ /**
+ * Get metadata key for a task
+ */
+ const getMetadataKey = (hash: string): string => `${hash}.meta`;
+
+ /**
+ * Get or create metadata for a task
+ */
+ const getOrCreateMetadata = async (hash: string): Promise => {
+ const metaKey = getMetadataKey(hash);
+ const existing = await driver.get(metaKey) as CacheMetadata | undefined;
+
+ if (existing) {
+ return existing;
+ }
+
+ const newMeta: CacheMetadata = {
+ taskId: hash,
+ timestamp: Date.now(),
+ hits: 0,
+ lastAccess: Date.now(),
+ };
+
+ await driver.set(metaKey, newMeta);
+ return newMeta;
+ };
+
+ /**
+ * Increment hit count and update last access
+ */
+ const incrementHits = async (hash: string): Promise => {
+ const meta = await getOrCreateMetadata(hash);
+ const updated: CacheMetadata = {
+ ...meta,
+ hits: meta.hits + 1,
+ lastAccess: Date.now(),
+ };
+ await driver.set(getMetadataKey(hash), updated);
+ };
+
+ return {
+ // Single operations
+ get: async (hash: string): Promise => {
+ const result = await driver.get(hash) as T | undefined;
+ if (result !== undefined) {
+ // Track cache hit (fire and forget)
+ incrementHits(hash).catch(err => {
+ debug('cache', `incrementHits failed for ${hash}: ${err instanceof Error ? err.message : String(err)}`);
+ });
+ }
+ return result;
+ },
+
+ set: async (hash: string, result: T): Promise => {
+ await driver.set(hash, result);
+ // Create initial metadata
+ await getOrCreateMetadata(hash);
+ },
+
+ has: async (hash: string): Promise => {
+ return driver.has(hash);
+ },
+
+ delete: async (hash: string): Promise => {
+ await driver.delete(hash);
+ try {
+ await driver.delete(getMetadataKey(hash));
+ } catch (error) {
+ debug('cache', `Failed to delete metadata for ${hash}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ },
+
+ // Bulk operations
+ getMany: async (hashes: ReadonlyArray): Promise> => {
+ if (hashes.length === 0) {
+ return new Map();
+ }
+
+ const results = new Map();
+
+ // Batched concurrency to avoid overwhelming OS with unbounded parallel I/O
+ for (let i = 0; i < hashes.length; i += RESULT_BATCH_SIZE) {
+ const batch = hashes.slice(i, i + RESULT_BATCH_SIZE);
+ await Promise.all(
+ batch.map(async (hash) => {
+ const result = await driver.get(hash) as T | undefined;
+ if (result !== undefined) {
+ results.set(hash, result);
+ // Track hit (fire and forget)
+ incrementHits(hash).catch(err => {
+ debug('cache', `incrementHits failed for ${hash}: ${err instanceof Error ? err.message : String(err)}`);
+ });
+ }
+ })
+ );
+ }
+
+ return results;
+ },
+
+ setMany: async (entries: ReadonlyArray): Promise => {
+ if (entries.length === 0) return;
+
+ // Batched concurrency to avoid overwhelming OS with unbounded parallel writes
+ for (let i = 0; i < entries.length; i += METADATA_BATCH_SIZE) {
+ const batch = entries.slice(i, i + METADATA_BATCH_SIZE);
+ await Promise.all(
+ batch.map(async ([hash, result]) => {
+ await driver.set(hash, result);
+ await getOrCreateMetadata(hash);
+ })
+ );
+ }
+ },
+
+ hasMany: async (hashes: ReadonlyArray): Promise> => {
+ // Short-circuit: nothing to check
+ if (hashes.length === 0) {
+ return new Set();
+ }
+
+ // Short-circuit: if the cache directory is empty/missing, skip all 137K+ fs.access calls
+ try {
+ const stats = await driver.getStats();
+ if (stats.entries === 0) {
+ return new Set();
+ }
+ } catch {
+ // If getStats fails (e.g. dir missing), cache is empty
+ return new Set();
+ }
+
+ const existing = new Set();
+
+ // Batched concurrency to avoid overwhelming the OS with unbounded parallel fs.access
+ for (let i = 0; i < hashes.length; i += RESULT_BATCH_SIZE) {
+ const batch = hashes.slice(i, i + RESULT_BATCH_SIZE);
+ await Promise.all(
+ batch.map(async (hash) => {
+ const exists = await driver.has(hash);
+ if (exists) {
+ existing.add(hash);
+ }
+ })
+ );
+ }
+
+ return existing;
+ },
+
+ // Metadata operations
+ getWithMetadata: async (hash: string): Promise | undefined> => {
+ const result = await driver.get(hash) as T | undefined;
+ if (result === undefined) {
+ return undefined;
+ }
+
+ const metadata = await getOrCreateMetadata(hash);
+
+ // Update access time
+ await incrementHits(hash);
+
+ return { result, metadata };
+ },
+
+ getManyWithMetadata: async (hashes: ReadonlyArray): Promise>> => {
+ if (hashes.length === 0) {
+ return new Map>();
+ }
+
+ const entries = new Map>();
+ const metadataUpdates: Array<[string, CacheMetadata]> = [];
+
+ // Batched concurrency to avoid overwhelming OS with unbounded parallel I/O
+ for (let i = 0; i < hashes.length; i += RESULT_BATCH_SIZE) {
+ const batch = hashes.slice(i, i + RESULT_BATCH_SIZE);
+ await Promise.all(
+ batch.map(async (hash) => {
+ const result = await driver.get(hash) as T | undefined;
+ if (result !== undefined) {
+ const metadata = await getOrCreateMetadata(hash);
+ const updated: CacheMetadata = {
+ ...metadata,
+ hits: metadata.hits + 1,
+ lastAccess: Date.now(),
+ };
+ entries.set(hash, { result, metadata: updated });
+ metadataUpdates.push([hash, updated]);
+ }
+ })
+ );
+ }
+
+ // Batch update all metadata at once (non-blocking)
+ if (metadataUpdates.length > 0) {
+ for (let i = 0; i < metadataUpdates.length; i += METADATA_BATCH_SIZE) {
+ const batch = metadataUpdates.slice(i, i + METADATA_BATCH_SIZE);
+ await Promise.all(
+ batch.map(async ([hash, updated]) => {
+ await driver.set(getMetadataKey(hash), updated);
+ })
+ );
+ }
+ }
+
+ return entries;
+ },
+
+ updateMetadata: async (hash: string, updates: Partial): Promise => {
+ const existing = await driver.get(getMetadataKey(hash)) as CacheMetadata | undefined ?? await getOrCreateMetadata(hash);
+ const updated: CacheMetadata = {
+ ...existing,
+ ...updates,
+ };
+ await driver.set(getMetadataKey(hash), updated);
+ },
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/services/source-cache.ts b/.claude/worktrees/nice-taussig/packages/cache/src/services/source-cache.ts
new file mode 100644
index 0000000..b01b7c3
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/services/source-cache.ts
@@ -0,0 +1,25 @@
+import { SyncDriver } from '../drivers/types.js';
+
+export interface SourceEntry {
+ content: string;
+ filePath: string;
+}
+
+export interface SourceCache {
+ get: (hash: string) => SourceEntry | undefined;
+ set: (hash: string, entry: SourceEntry) => void;
+ has: (hash: string) => boolean;
+}
+
+/**
+ * Creates a Source Cache.
+ * Strategy: Memory Only (Source code needs to be hot).
+ * Key: Content Hash.
+ */
+export const createSourceCache = (driver: SyncDriver): SourceCache => {
+ return {
+ get: (hash) => driver.get(hash),
+ set: (hash, entry) => driver.set(hash, entry),
+ has: (hash) => driver.has(hash),
+ };
+};
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/types.ts b/.claude/worktrees/nice-taussig/packages/cache/src/types.ts
new file mode 100644
index 0000000..f84f5a5
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/types.ts
@@ -0,0 +1,65 @@
+import { SourceCache } from './services/source-cache.js';
+import { AstCache } from './services/ast-cache.js';
+import { ResultCache } from './services/result-cache.js';
+import { ConfigCache } from './services/config-cache.js';
+import { MetaCache } from './services/meta-cache.js';
+import { PlanCache } from './services/plan-cache.js';
+import { FileCache } from './services/file-cache.js';
+
+export type { SourceCache, AstCache, ResultCache, ConfigCache, MetaCache, PlanCache, FileCache };
+export type { FileMeta } from './services/meta-cache.js';
+
+export interface CacheInfo {
+ ast: {
+ l1: { entries: number; maxEntries: number; size: number };
+ l2: { entries: number; size: number };
+ };
+ config: { entries: number; size: number };
+ results: { entries: number; size: number };
+ totalSize: number;
+ location: string;
+ version: string;
+}
+
+export interface CacheContext {
+ sources: SourceCache;
+ asts: AstCache;
+ results: ResultCache;
+ configs: ConfigCache;
+ metas: MetaCache;
+ plans: PlanCache;
+ files: FileCache;
+ /**
+ * Stores full analysis results keyed by global hash (for warm run short-circuiting)
+ */
+ analysis: ResultCache;
+ /**
+ * Computes a hash suitable for caching keys
+ */
+ computeHash: (content: string, salt?: string) => string;
+ /**
+ * Clears old disk cache entries
+ */
+ prune: () => Promise;
+ /**
+ * Clears all caches
+ */
+ clear: () => Promise;
+ /**
+ * Clear specific cache type
+ */
+ clearType: (type: 'ast' | 'config' | 'results' | 'all') => Promise;
+ /**
+ * Get cache statistics and info
+ */
+ getInfo: () => Promise;
+ /**
+ * Get absolute path to cache directory
+ */
+ getCachePath: () => string;
+ /**
+ * Flushes all pending writes to disk.
+ * Call this before process exit to prevent data loss.
+ */
+ flush: () => Promise;
+}
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/utils/fs.ts b/.claude/worktrees/nice-taussig/packages/cache/src/utils/fs.ts
new file mode 100644
index 0000000..6cd93e1
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/utils/fs.ts
@@ -0,0 +1,39 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+/**
+ * Recursively gets the number of files and total size of a directory.
+ */
+export async function getDirectoryStats(dirPath: string): Promise<{ entries: number; size: number }> {
+ let entries = 0;
+ let size = 0;
+
+ try {
+ await fs.access(dirPath);
+ } catch {
+ return { entries: 0, size: 0 };
+ }
+
+ async function traverse(currentPath: string) {
+ const files = await fs.readdir(currentPath, { withFileTypes: true });
+
+ for (const file of files) {
+ const fullPath = path.join(currentPath, file.name);
+ if (file.isDirectory()) {
+ await traverse(fullPath);
+ } else {
+ entries++;
+ const stats = await fs.stat(fullPath);
+ size += stats.size;
+ }
+ }
+ }
+
+ try {
+ await traverse(dirPath);
+ } catch {
+ // Ignore errors during traversal (e.g. race conditions)
+ }
+
+ return { entries, size };
+}
diff --git a/.claude/worktrees/nice-taussig/packages/cache/src/utils/stable-serialize.ts b/.claude/worktrees/nice-taussig/packages/cache/src/utils/stable-serialize.ts
new file mode 100644
index 0000000..c7b9e33
--- /dev/null
+++ b/.claude/worktrees/nice-taussig/packages/cache/src/utils/stable-serialize.ts
@@ -0,0 +1,123 @@
+/**
+ * Stable Serialization Utility
+ *
+ * Produces a deterministic JSON string suitable for use in cache keys and hashes.
+ *
+ * Guarantees:
+ * - Objects: keys sorted alphabetically at every depth level
+ * - Arrays: elements preserved in insertion order (NOT sorted)
+ * - undefined values in objects: omitted (same as JSON.stringify)
+ * - Date: serialized as ISO 8601 string
+ * - RegExp: serialized as its string representation (e.g. "/abc/gi")
+ * - Circular references: throws SerializationError (never silently sanitizes)
+ * - Functions: throws SerializationError
+ * - NaN / Infinity: throws SerializationError
+ *
+ * This replaces bare JSON.stringify() wherever the insertion order of object
+ * keys must not affect the resulting string (e.g. batch keys, cache keys).
+ */
+
+export class SerializationError extends Error {
+ constructor(
+ message: string,
+ public readonly path: string[]
+ ) {
+ super(message);
+ this.name = 'SerializationError';
+ }
+}
+
+/**
+ * Produces a deterministic JSON string by sorting object keys recursively.
+ *
+ * @param value - The value to serialize
+ * @param _path - Internal: tracks key path for error messages
+ * @param _seen - Internal: tracks visited objects for circular detection
+ * @returns A stable, deterministic JSON string
+ * @throws {SerializationError} on circular references, functions, or non-finite numbers
+ */
+export function stableSerialize(
+ value: unknown,
+ _path: string[] = [],
+ _seen: WeakSet