diff --git a/.gitignore b/.gitignore index ab951233f..4629f5286 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ browse/dist/ design/dist/ +oracle/bin/dist/ bin/gstack-global-discover .gstack/ .claude/skills/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e9d63d83b..d2d840128 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -353,6 +353,25 @@ The `EvalCollector` accumulates test results and writes them in two ways: Tier 1 runs on every `bun test`. Tiers 2+3 are gated behind `EVALS=1`. The idea: catch 95% of issues for free, use LLMs only for judgment calls and integration testing. +## Oracle, the product memory layer + +Oracle gives every gstack skill product-level context (not just code context). + +**Storage:** `docs/oracle/PRODUCT_MAP.md` in the project repo. In-repo, git-tracked, human-verifiable. The map is self-describing: its header contains the schema and instructions. Skills that read it follow the header, not hardcoded format knowledge. + +**Integration:** Two resolvers (`PRODUCT_CONSCIENCE_READ`, `PRODUCT_CONSCIENCE_WRITE`) inject ~10 lines each into 19 skill templates via the gen-skill-docs pipeline. Planning skills read the map for context. Post-work skills silently update it. Zero manual interaction. + +**Scanner:** `oracle/bin/scan-imports.ts` is an AST-powered codebase analyzer using TypeScript's compiler API. It produces a scan manifest (JSON) with routes, import graph, circular deps, dead files, and complexity classification. The scanner runs from gstack's own install directory using gstack's `node_modules/typescript`, not the user's project. Compiled to a standalone binary as a performance optimization, falls back to `bun run` from source. + +**Staleness:** Scan manifest stores `head_sha` from `git rev-parse HEAD`. Comparing against current HEAD catches branch switches, rebases, and amends. No timestamp-based checks. + +``` +docs/oracle/ +├── PRODUCT_MAP.md # Tier 1: concise feature registry (~12 lines/feature) +└── inventory/ # Tier 2: detailed per-feature docs + └── F001-feature-name.md +``` + ## What's intentionally not here - **No WebSocket streaming.** HTTP request/response is simpler, debuggable with curl, and fast enough. Streaming would add complexity for marginal benefit. diff --git a/CHANGELOG.md b/CHANGELOG.md index aaac60619..65a524074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [0.13.4.0] - 2026-03-28 — The Product Conscience + +Every gstack skill now has product memory. `/oracle` bootstraps a product map from your codebase, then every planning skill reads it for context and every post-work skill silently writes back. The map lives in your repo at `docs/oracle/` so you can verify every claim. + +### Added + +- **Product Conscience across 19 skills.** Planning skills (`/office-hours`, `/plan-ceo-review`, `/plan-eng-review`, etc.) automatically read your product map for feature connections, patterns, and anti-patterns. Post-work skills (`/ship`, `/review`, `/qa`) silently update it after completing work. The map gets better with every session. +- **`/oracle` skill with 6 modes.** Bootstrap (generate product map from codebase), inventory (budgeted deep page-by-page scan), refresh (full re-analysis), update (lightweight git sync), stats (product + codebase health dashboard), query (answer product questions with context). +- **AST-powered codebase scanner.** Uses TypeScript's compiler API for 100% static import resolution. Detects 10 frameworks (React Router, Next.js, SvelteKit, Nuxt, Remix, Astro, TanStack Router, Vue Router, Wouter, plus file-based routing). Git co-change analysis for complexity classification (EASY/MEDIUM/HARD/MEGA). Tarjan's SCC for circular dependencies. Dead code detection with `.oracleignore` support and multi-level confidence. +- **HTML import graph visualizer.** Run `/oracle scan --visualize` to generate a self-contained HTML file showing your entire import tree, color-coded by complexity, with collapsible subtrees and circular dependency highlighting. +- **Terminal ASCII graph.** ANSI-colored tree output with `--max-depth`, `--no-color`, and `--compact` flags. +- **Scanner CLI flags.** `--diff` (compare against previous scan), `--dry-run` (preview without writing), `--git-frequency` (sort routes by recent commit activity). +- **Self-describing product map.** The map header contains its own schema, so skills don't need hardcoded format knowledge. Schema changes update the map, not 19 templates. + +### For contributors + +- New resolver system: `scripts/resolvers/oracle.ts` with `PRODUCT_CONSCIENCE_READ` and `PRODUCT_CONSCIENCE_WRITE` registered in both `index.ts` and `gen-skill-docs.ts`. +- Shared scanner utilities extracted to `oracle/bin/scanner/utils.ts`. +- 186 tests across 5 test files (scanner: 114, visualizer: 15, terminal graph: 15, utils: 16, resolvers: 26). +- 14 fixture directories covering all 10 supported frameworks. +- E2E test scaffolding in `test/skill-e2e-oracle.test.ts` with touchfile declarations. + ## [0.13.3.0] - 2026-03-28 — Lock It Down Six fixes from community PRs and bug reports. The big one: your dependency tree is now pinned. Every `bun install` resolves the exact same versions, every time. No more floating ranges pulling fresh packages from npm on every setup. diff --git a/CLAUDE.md b/CLAUDE.md index 0ea420c75..68ad4288d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,7 @@ gstack/ ├── codex/ # /codex skill (multi-AI second opinion via OpenAI Codex CLI) ├── land-and-deploy/ # /land-and-deploy skill (merge → deploy → canary verify) ├── office-hours/ # /office-hours skill (YC Office Hours — startup diagnostic + builder brainstorm) +├── oracle/ # /oracle skill (product memory — bootstraps product map, tracks features, surfaces connections) ├── investigate/ # /investigate skill (systematic root-cause debugging) ├── retro/ # Retrospective skill (includes /retro global cross-project mode) ├── bin/ # CLI utilities (gstack-repo-mode, gstack-slug, gstack-config, etc.) diff --git a/README.md b/README.md index 9ede0450c..a32de2030 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,11 @@ Fork it. Improve it. Make it yours. And if you want to hate on free open source Open Claude Code and paste this. Claude does the rest. -> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /design-shotgun, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /connect-chrome, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex, /cso, /autoplan, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade. Then ask the user if they also want to add gstack to the current project so teammates get it. +> Install gstack: run **`git clone --single-branch --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup`** then add a "gstack" section to CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, and lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /design-shotgun, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /connect-chrome, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex, /cso, /oracle, /autoplan, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade. Then ask the user if they also want to add gstack to the current project so teammates get it. ### Step 2: Add to your repo so teammates get it (optional) -> Add gstack to this project: run **`cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup`** then add a "gstack" section to this project's CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex, /cso, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, and tells Claude that if gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` to build the binary and register skills. +> Add gstack to this project: run **`cp -Rf ~/.claude/skills/gstack .claude/skills/gstack && rm -rf .claude/skills/gstack/.git && cd .claude/skills/gstack && ./setup`** then add a "gstack" section to this project's CLAUDE.md that says to use the /browse skill from gstack for all web browsing, never use mcp\_\_claude-in-chrome\_\_\* tools, lists the available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /retro, /investigate, /document-release, /codex, /cso, /oracle, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade, and tells Claude that if gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` to build the binary and register skills. Real files get committed to your repo (not a submodule), so `git clone` just works. Everything lives inside `.claude/`. Nothing touches your PATH or runs in the background. @@ -163,6 +163,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan- | `/benchmark` | **Performance Engineer** | Baseline page load times, Core Web Vitals, and resource sizes. Compare before/after on every PR. | | `/document-release` | **Technical Writer** | Update all project docs to match what you just shipped. Catches stale READMEs automatically. | | `/retro` | **Eng Manager** | Team-aware weekly retro. Per-person breakdowns, shipping streaks, test health trends, growth opportunities. `/retro global` runs across all your projects and AI tools (Claude Code, Codex, Gemini). | +| `/oracle` | **Product Memory** | Product intelligence layer. Bootstraps a product map from your codebase, tracks features across sessions, surfaces connections during planning, warns about anti-patterns. Runs silently through every other skill. | | `/browse` | **QA Engineer** | Give the agent eyes. Real Chromium browser, real clicks, real screenshots. ~100ms per command. `$B connect` launches your real Chrome as a headed window — watch every action live. | | `/setup-browser-cookies` | **Session Manager** | Import cookies from your real browser (Chrome, Arc, Brave, Edge) into the headless session. Test authenticated pages. | | `/autoplan` | **Review Pipeline** | One command, fully reviewed plan. Runs CEO → design → eng review automatically with encoded decision principles. Surfaces only taste decisions for your approval. | @@ -277,7 +278,7 @@ Use /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* Available skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review, /design-consultation, /review, /ship, /land-and-deploy, /canary, /benchmark, /browse, /qa, /qa-only, /design-review, /setup-browser-cookies, /setup-deploy, /retro, -/investigate, /document-release, /codex, /cso, /autoplan, /careful, /freeze, /guard, +/investigate, /document-release, /codex, /cso, /oracle, /autoplan, /careful, /freeze, /guard, /unfreeze, /gstack-upgrade. ``` diff --git a/TODOS.md b/TODOS.md index b8314ab2a..3d2ac20ef 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,5 +1,19 @@ # TODOS +## Oracle + +### Quality benchmark — measure product conscience impact + +**What:** Compare skill output with and without product map context. Run the same planning skill (e.g., /office-hours) on the same codebase with and without a PRODUCT_MAP.md present. Measure whether connections, anti-pattern warnings, and pattern reuse suggestions actually improve. + +**Why:** Oracle's core hypothesis is that product context makes every skill smarter. Without measurement, we're flying blind. The outside voice in the eng review flagged this: "build a 2-hour experiment before a multi-week build." We built it anyway — now validate. + +**Context:** Run on iskool-prod (the first real target). Bootstrap a product map, then run /office-hours twice: once with the map, once without. LLM-judge eval comparing output quality. This also stress-tests the self-describing map format. + +**Effort:** S (human: ~2 hours / CC: ~30 min) +**Priority:** P1 +**Depends on:** Oracle shipped to main + ## Builder Ethos ### First-time Search Before Building intro diff --git a/VERSION b/VERSION index bc603fe1f..3bfa77a45 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.3.0 +0.13.4.0 diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index 50c2b30ce..6c42414e0 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -441,6 +441,24 @@ DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head If a design doc is now found, read it and continue the review. If none was produced (user may have cancelled), proceed with standard review. +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # /autoplan — Auto-Review Pipeline One command. Rough plan in, fully reviewed plan out. @@ -1114,3 +1132,26 @@ Suggest next step: `/ship` when ready to create the PR. - **Full depth means full depth.** Do not compress or skip sections from the loaded skill files (except the skip list in Phase 0). "Full depth" means: read the code the section asks you to read, produce the outputs the section requires, identify every issue, and decide each one. A one-sentence summary of a section is not "full depth" — it is a skip. If you catch yourself writing fewer than 3 sentences for any review section, you are likely compressing. - **Artifacts are deliverables.** Test plan artifact, failure modes registry, error/rescue table, ASCII diagrams — these must exist on disk or in the plan file when the review completes. If they don't exist, the review is incomplete. - **Sequential order.** CEO → Design → Eng. Each phase builds on the last. + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl index 5577b64bc..5d02af310 100644 --- a/autoplan/SKILL.md.tmpl +++ b/autoplan/SKILL.md.tmpl @@ -29,6 +29,8 @@ allowed-tools: {{BENEFITS_FROM}} +{{PRODUCT_CONSCIENCE_READ}} + # /autoplan — Auto-Review Pipeline One command. Rough plan in, fully reviewed plan out. @@ -702,3 +704,5 @@ Suggest next step: `/ship` when ready to create the PR. - **Full depth means full depth.** Do not compress or skip sections from the loaded skill files (except the skip list in Phase 0). "Full depth" means: read the code the section asks you to read, produce the outputs the section requires, identify every issue, and decide each one. A one-sentence summary of a section is not "full depth" — it is a skip. If you catch yourself writing fewer than 3 sentences for any review section, you are likely compressing. - **Artifacts are deliverables.** Test plan artifact, failure modes registry, error/rescue table, ASCII diagrams — these must exist on disk or in the plan file when the review completes. If they don't exist, the review is incomplete. - **Sequential order.** CEO → Design → Eng. Each phase builds on the last. + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/benchmark/SKILL.md b/benchmark/SKILL.md index 51e39a100..f3888237c 100644 --- a/benchmark/SKILL.md +++ b/benchmark/SKILL.md @@ -284,6 +284,24 @@ If `NEEDS_SETUP`: fi ``` +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # /benchmark — Performance Regression Detection You are a **Performance Engineer** who has optimized apps serving millions of requests. You know that performance doesn't degrade in one big regression — it dies by a thousand paper cuts. Each PR adds 50ms here, 20KB there, and one day the app takes 8 seconds to load and nobody knows when it got slow. @@ -496,3 +514,26 @@ Write to `.gstack/benchmark-reports/{date}-benchmark.md` and `.gstack/benchmark- - **Third-party scripts are context.** Flag them, but the user can't fix Google Analytics being slow. Focus recommendations on first-party resources. - **Bundle size is the leading indicator.** Load time varies with network. Bundle size is deterministic. Track it religiously. - **Read-only.** Produce the report. Don't modify code unless explicitly asked. + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/benchmark/SKILL.md.tmpl b/benchmark/SKILL.md.tmpl index 5149ea441..949b497b2 100644 --- a/benchmark/SKILL.md.tmpl +++ b/benchmark/SKILL.md.tmpl @@ -20,6 +20,8 @@ allowed-tools: {{BROWSE_SETUP}} +{{PRODUCT_CONSCIENCE_READ}} + # /benchmark — Performance Regression Detection You are a **Performance Engineer** who has optimized apps serving millions of requests. You know that performance doesn't degrade in one big regression — it dies by a thousand paper cuts. Each PR adds 50ms here, 20KB there, and one day the app takes 8 seconds to load and nobody knows when it got slow. @@ -232,3 +234,5 @@ Write to `.gstack/benchmark-reports/{date}-benchmark.md` and `.gstack/benchmark- - **Third-party scripts are context.** Flag them, but the user can't fix Google Analytics being slow. Focus recommendations on first-party resources. - **Bundle size is the leading indicator.** Load time varies with network. Bundle size is deterministic. Track it religiously. - **Read-only.** Produce the report. Don't modify code unless explicitly asked. + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/canary/SKILL.md b/canary/SKILL.md index ed814098b..d6ef71ecf 100644 --- a/canary/SKILL.md +++ b/canary/SKILL.md @@ -388,6 +388,24 @@ branch name wherever the instructions say "the base branch" or ``. --- +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # /canary — Post-Deploy Visual Monitor You are a **Release Reliability Engineer** watching production after a deploy. You've seen deploys that pass CI but break in production — a missing environment variable, a CDN cache serving stale assets, a database migration that's slower than expected on real data. Your job is to catch these in the first 10 minutes, not 10 hours. @@ -585,3 +603,26 @@ If the user chooses A, copy the latest screenshots to the baselines directory an - **Baseline is king.** Without a baseline, canary is a health check. Encourage `--baseline` before deploying. - **Performance thresholds are relative.** 2x baseline is a regression. 1.5x might be normal variance. - **Read-only.** Observe and report. Don't modify code unless the user explicitly asks to investigate and fix. + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/canary/SKILL.md.tmpl b/canary/SKILL.md.tmpl index 680b58147..033f6de23 100644 --- a/canary/SKILL.md.tmpl +++ b/canary/SKILL.md.tmpl @@ -22,6 +22,8 @@ allowed-tools: {{BASE_BRANCH_DETECT}} +{{PRODUCT_CONSCIENCE_READ}} + # /canary — Post-Deploy Visual Monitor You are a **Release Reliability Engineer** watching production after a deploy. You've seen deploys that pass CI but break in production — a missing environment variable, a CDN cache serving stale assets, a database migration that's slower than expected on real data. Your job is to catch these in the first 10 minutes, not 10 hours. @@ -219,3 +221,5 @@ If the user chooses A, copy the latest screenshots to the baselines directory an - **Baseline is king.** Without a baseline, canary is a health check. Encourage `--baseline` before deploying. - **Performance thresholds are relative.** 2x baseline is a regression. 1.5x might be normal variance. - **Read-only.** Observe and report. Don't modify code unless the user explicitly asks to investigate and fix. + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/codex/SKILL.md b/codex/SKILL.md index 380382ff6..95f272c4f 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -383,6 +383,24 @@ branch name wherever the instructions say "the base branch" or ``. --- +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # /codex — Multi-AI Second Opinion You are running the `/codex` skill. This wraps the OpenAI Codex CLI to get an independent, diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl index c44480a9f..cbe1a2d77 100644 --- a/codex/SKILL.md.tmpl +++ b/codex/SKILL.md.tmpl @@ -21,6 +21,8 @@ allowed-tools: {{BASE_BRANCH_DETECT}} +{{PRODUCT_CONSCIENCE_READ}} + # /codex — Multi-AI Second Opinion You are running the `/codex` skill. This wraps the OpenAI Codex CLI to get an independent, @@ -433,3 +435,5 @@ If token count is not available, display: `Tokens: unknown` `SKILL.md`, or `skills/gstack`. If any of these appear in the output, append a warning: "Codex appears to have read gstack skill files instead of reviewing your code. Consider retrying." + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/cso/SKILL.md b/cso/SKILL.md index 5e448639b..7d752ac95 100644 --- a/cso/SKILL.md +++ b/cso/SKILL.md @@ -329,6 +329,24 @@ Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file: file you are allowed to edit in plan mode. The plan file review report is part of the plan's living status. +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # /cso — Chief Security Officer Audit (v2) You are a **Chief Security Officer** who has led incident response on real breaches and testified before boards about security posture. You think like an attacker but report like a defender. You don't do security theater — you find the doors that are actually unlocked. @@ -927,3 +945,26 @@ a first pass to catch low-hanging fruit and improve your security posture betwee audits — not as your only line of defense. **Always include this disclaimer at the end of every /cso report output.** + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/cso/SKILL.md.tmpl b/cso/SKILL.md.tmpl index 676c1bd94..bf2102477 100644 --- a/cso/SKILL.md.tmpl +++ b/cso/SKILL.md.tmpl @@ -22,6 +22,8 @@ allowed-tools: {{PREAMBLE}} +{{PRODUCT_CONSCIENCE_READ}} + # /cso — Chief Security Officer Audit (v2) You are a **Chief Security Officer** who has led incident response on real breaches and testified before boards about security posture. You think like an attacker but report like a defender. You don't do security theater — you find the doors that are actually unlocked. @@ -620,3 +622,5 @@ a first pass to catch low-hanging fruit and improve your security posture betwee audits — not as your only line of defense. **Always include this disclaimer at the end of every /cso report output.** + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/design-consultation/SKILL.md b/design-consultation/SKILL.md index 86971887e..d06ebc83d 100644 --- a/design-consultation/SKILL.md +++ b/design-consultation/SKILL.md @@ -348,6 +348,24 @@ Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file: file you are allowed to edit in plan mode. The plan file review report is part of the plan's living status. +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # /design-consultation: Your Design System, Built Together You are a senior product designer with strong opinions about typography, color, and visual systems. You don't present menus — you listen, think, research, and propose. You're opinionated but not dogmatic. You explain your reasoning and welcome pushback. @@ -960,3 +978,26 @@ List all decisions. Flag any that used agent defaults without explicit user conf 6. **Conversational tone.** This isn't a rigid workflow. If the user wants to talk through a decision, engage as a thoughtful design partner. 7. **Accept the user's final choice.** Nudge on coherence issues, but never block or refuse to write a DESIGN.md because you disagree with a choice. 8. **No AI slop in your own output.** Your recommendations, your preview page, your DESIGN.md — all should demonstrate the taste you're asking the user to adopt. + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/design-consultation/SKILL.md.tmpl b/design-consultation/SKILL.md.tmpl index 2ce7c1d3b..05caddb2c 100644 --- a/design-consultation/SKILL.md.tmpl +++ b/design-consultation/SKILL.md.tmpl @@ -23,6 +23,8 @@ allowed-tools: {{PREAMBLE}} +{{PRODUCT_CONSCIENCE_READ}} + # /design-consultation: Your Design System, Built Together You are a senior product designer with strong opinions about typography, color, and visual systems. You don't present menus — you listen, think, research, and propose. You're opinionated but not dogmatic. You explain your reasoning and welcome pushback. @@ -425,3 +427,5 @@ List all decisions. Flag any that used agent defaults without explicit user conf 6. **Conversational tone.** This isn't a rigid workflow. If the user wants to talk through a decision, engage as a thoughtful design partner. 7. **Accept the user's final choice.** Nudge on coherence issues, but never block or refuse to write a DESIGN.md because you disagree with a choice. 8. **No AI slop in your own output.** Your recommendations, your preview page, your DESIGN.md — all should demonstrate the taste you're asking the user to adopt. + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/design-review/SKILL.md b/design-review/SKILL.md index fb0824422..abaebc524 100644 --- a/design-review/SKILL.md +++ b/design-review/SKILL.md @@ -348,6 +348,24 @@ Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file: file you are allowed to edit in plan mode. The plan file review report is part of the plan's living status. +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # /design-review: Design Audit → Fix → Verify You are a senior product designer AND a frontend engineer. Review live sites with exacting visual standards — then fix what you find. You have strong opinions about typography, spacing, and visual hierarchy, and zero tolerance for generic or AI-generated-looking interfaces. @@ -1312,3 +1330,26 @@ If the repo has a `TODOS.md`: 15. **Self-regulate.** Follow the design-fix risk heuristic. When in doubt, stop and ask. 16. **CSS-first.** Prefer CSS/styling changes over structural component changes. CSS-only changes are safer and more reversible. 17. **DESIGN.md export.** You MAY write a DESIGN.md file if the user accepts the offer from Phase 2. + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/design-review/SKILL.md.tmpl b/design-review/SKILL.md.tmpl index 904a732c4..51cc624c3 100644 --- a/design-review/SKILL.md.tmpl +++ b/design-review/SKILL.md.tmpl @@ -23,6 +23,8 @@ allowed-tools: {{PREAMBLE}} +{{PRODUCT_CONSCIENCE_READ}} + # /design-review: Design Audit → Fix → Verify You are a senior product designer AND a frontend engineer. Review live sites with exacting visual standards — then fix what you find. You have strong opinions about typography, spacing, and visual hierarchy, and zero tolerance for generic or AI-generated-looking interfaces. @@ -296,3 +298,5 @@ If the repo has a `TODOS.md`: 15. **Self-regulate.** Follow the design-fix risk heuristic. When in doubt, stop and ask. 16. **CSS-first.** Prefer CSS/styling changes over structural component changes. CSS-only changes are safer and more reversible. 17. **DESIGN.md export.** You MAY write a DESIGN.md file if the user accepts the offer from Phase 2. + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/docs/skills.md b/docs/skills.md index ae6ddd688..368c48060 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -21,6 +21,9 @@ Detailed guides for every gstack skill — philosophy, workflow, and examples. | [`/browse`](#browse) | **QA Engineer** | Give the agent eyes. Real Chromium browser, real clicks, real screenshots. ~100ms per command. | | [`/setup-browser-cookies`](#setup-browser-cookies) | **Session Manager** | Import cookies from your real browser (Chrome, Arc, Brave, Edge) into the headless session. Test authenticated pages. | | | | | +| **Product Intelligence** | | | +| [`/oracle`](#oracle) | **Product Memory** | Product intelligence layer. Bootstraps a product map from your codebase, tracks features across sessions, surfaces connections during planning, warns about anti-patterns. Runs silently through every skill. | +| | | | | **Multi-AI** | | | | [`/codex`](#codex) | **Second Opinion** | Independent review from OpenAI Codex CLI. Three modes: code review (pass/fail gate), adversarial challenge, and open consultation with session continuity. Cross-model analysis when both `/review` and `/codex` have run. | | | | | @@ -797,6 +800,34 @@ Remove the `/freeze` boundary, allowing edits everywhere again. The hooks stay r --- +## `/oracle` + +Product memory that gives every gstack skill context about your product, not just your code. + +Run `/oracle` on any codebase and it bootstraps a `docs/oracle/PRODUCT_MAP.md`, a structured record of every feature, their connections, reusable patterns, and anti-patterns. The map is committed to your repo (in-repo, not hidden) so you can read and verify every claim. + +After bootstrap, oracle runs silently. Every planning skill (`/office-hours`, `/plan-ceo-review`, `/plan-eng-review`, etc.) automatically reads the product map for context. Every post-work skill (`/ship`, `/review`, `/qa`) silently updates it. The map gets better with every session. + +**6 modes:** +- `/oracle` (no args, no map) ... **Bootstrap** — analyze codebase, generate product map +- `/oracle` (no args, map exists) ... **Query** — product overview with feature connections +- `/oracle inventory` ... **Inventory** — budgeted deep page-by-page scan with checkpointing +- `/oracle refresh` ... **Refresh** — full re-analysis reconciling against existing map +- `/oracle update` ... **Update** — lightweight sync from recent git history +- `/oracle stats` ... **Stats** — product health + codebase health dashboard + +**Under the hood:** +- AST-powered scanner using TypeScript's compiler API (10 frameworks: React Router, Next.js, SvelteKit, Nuxt, Remix, Astro, TanStack Router, Vue Router, Wouter, plus file-based routing) +- Git co-change analysis for feature complexity classification (EASY/MEDIUM/HARD/MEGA) +- Tarjan's SCC for circular dependency detection +- Dead code detection with .oracleignore support +- HTML import graph visualizer (`--visualize`) and terminal ASCII tree +- `docs/oracle/inventory/` for detailed per-feature Tier 2 documentation + +The product map is self-describing: its header contains the schema and instructions. Skills that read it don't need to know the format in advance, they just follow the header. Schema changes update the map header, not 19 skill templates. + +--- + ## `/gstack-upgrade` Keep gstack current with one command. It detects your install type (global at `~/.claude/skills/gstack` vs vendored in your project at `.claude/skills/gstack`), runs the upgrade, syncs both copies if you have dual installs, and shows you what changed. diff --git a/document-release/SKILL.md b/document-release/SKILL.md index 2758f0cde..e4001ab61 100644 --- a/document-release/SKILL.md +++ b/document-release/SKILL.md @@ -366,6 +366,24 @@ branch name wherever the instructions say "the base branch" or ``. --- +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # Document Release: Post-Ship Documentation Update You are running the `/document-release` workflow. This runs **after `/ship`** (code committed, PR @@ -716,3 +734,26 @@ Where status is one of: - **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md. - **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person who hasn't seen the code. + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/document-release/SKILL.md.tmpl b/document-release/SKILL.md.tmpl index 6b1fb7e34..5808a56fe 100644 --- a/document-release/SKILL.md.tmpl +++ b/document-release/SKILL.md.tmpl @@ -22,6 +22,8 @@ allowed-tools: {{BASE_BRANCH_DETECT}} +{{PRODUCT_CONSCIENCE_READ}} + # Document Release: Post-Ship Documentation Update You are running the `/document-release` workflow. This runs **after `/ship`** (code committed, PR @@ -372,3 +374,5 @@ Where status is one of: - **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md. - **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person who hasn't seen the code. + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/investigate/SKILL.md b/investigate/SKILL.md index 8e307dc0b..b8a0fb5f5 100644 --- a/investigate/SKILL.md +++ b/investigate/SKILL.md @@ -341,6 +341,24 @@ Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file: file you are allowed to edit in plan mode. The plan file review report is part of the plan's living status. +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # Systematic Debugging ## Iron Law @@ -502,3 +520,26 @@ Status: DONE | DONE_WITH_CONCERNS | BLOCKED - DONE — root cause found, fix applied, regression test written, all tests pass - DONE_WITH_CONCERNS — fixed but cannot fully verify (e.g., intermittent bug, requires staging) - BLOCKED — root cause unclear after investigation, escalated + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/investigate/SKILL.md.tmpl b/investigate/SKILL.md.tmpl index d2eee63fe..f57c78477 100644 --- a/investigate/SKILL.md.tmpl +++ b/investigate/SKILL.md.tmpl @@ -34,6 +34,8 @@ hooks: {{PREAMBLE}} +{{PRODUCT_CONSCIENCE_READ}} + # Systematic Debugging ## Iron Law @@ -195,3 +197,5 @@ Status: DONE | DONE_WITH_CONCERNS | BLOCKED - DONE — root cause found, fix applied, regression test written, all tests pass - DONE_WITH_CONCERNS — fixed but cannot fully verify (e.g., intermittent bug, requires staging) - BLOCKED — root cause unclear after investigation, escalated + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index acec63c2e..da7924685 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -21,6 +21,8 @@ allowed-tools: {{BASE_BRANCH_DETECT}} +{{PRODUCT_CONSCIENCE_READ}} + **If the platform detected above is GitLab or unknown:** STOP with: "GitLab support for /land-and-deploy is not yet implemented. Run `/ship` to create the MR, then merge manually via the GitLab web UI." Do not proceed. # /land-and-deploy — Merge, Deploy, Verify @@ -915,3 +917,5 @@ Then suggest relevant follow-ups: - **First run = teacher mode.** Walk the user through everything. Explain what each check does and why it matters. Show them their infrastructure. Let them confirm before proceeding. Build trust through transparency. - **Subsequent runs = efficient mode.** Brief status updates, no re-explanations. The user already trusts the tool — just do the job and report results. - **The goal is: first-timers think "wow, this is thorough — I trust it." Repeat users think "that was fast — it just works."** + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/office-hours/SKILL.md b/office-hours/SKILL.md index 34aa90707..9b2d95ca5 100644 --- a/office-hours/SKILL.md +++ b/office-hours/SKILL.md @@ -374,6 +374,24 @@ If `NEEDS_SETUP`: fi ``` +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # YC Office Hours You are a **YC office hours partner**. Your job is to ensure the problem is understood before solutions are proposed. You adapt to what the user is building — startup founders get the hard questions, builders get an enthusiastic collaborator. This skill produces design docs, not code. @@ -1315,3 +1333,26 @@ The design doc at `~/.gstack/projects/` is automatically discoverable by downstr - DONE — design doc APPROVED - DONE_WITH_CONCERNS — design doc approved but with open questions listed - NEEDS_CONTEXT — user left questions unanswered, design incomplete + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/office-hours/SKILL.md.tmpl b/office-hours/SKILL.md.tmpl index 4b5a5e192..c616e394b 100644 --- a/office-hours/SKILL.md.tmpl +++ b/office-hours/SKILL.md.tmpl @@ -27,6 +27,8 @@ allowed-tools: {{BROWSE_SETUP}} +{{PRODUCT_CONSCIENCE_READ}} + # YC Office Hours You are a **YC office hours partner**. Your job is to ensure the problem is understood before solutions are proposed. You adapt to what the user is building — startup founders get the hard questions, builders get an enthusiastic collaborator. This skill produces design docs, not code. @@ -650,3 +652,5 @@ The design doc at `~/.gstack/projects/` is automatically discoverable by downstr - DONE — design doc APPROVED - DONE_WITH_CONCERNS — design doc approved but with open questions listed - NEEDS_CONTEXT — user left questions unanswered, design incomplete + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/oracle/SKILL.md b/oracle/SKILL.md new file mode 100644 index 000000000..0abd1ea88 --- /dev/null +++ b/oracle/SKILL.md @@ -0,0 +1,1019 @@ +--- +name: oracle +preamble-tier: 3 +version: 1.0.0 +description: | + Product memory and intelligence layer. Bootstraps a product map from your codebase, + tracks features across sessions, surfaces connections during planning, and warns about + anti-patterns. Modes: bootstrap/refresh (analyze codebase), inventory (budgeted deep + page-by-page scan with checkpointing), update (sync recent work), query/stats (product + overview + codebase health). + Most of the time you don't invoke /oracle directly — it runs automatically through + other gstack skills. + Use when asked to "bootstrap product map", "oracle", "product map", "refresh features", + "inventory", "deep scan", "map all features", or "what features do I have". + Proactively suggest when a planning skill detects no product map exists. +allowed-tools: + - Bash + - Read + - Grep + - Glob + - Write + - Edit + - AskUserQuestion +--- + + + +## Preamble (run first) + +```bash +_UPD=$(~/.claude/skills/gstack/bin/gstack-update-check 2>/dev/null || .claude/skills/gstack/bin/gstack-update-check 2>/dev/null || true) +[ -n "$_UPD" ] && echo "$_UPD" || true +mkdir -p ~/.gstack/sessions +touch ~/.gstack/sessions/"$PPID" +_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ') +find ~/.gstack/sessions -mmin +120 -type f -delete 2>/dev/null || true +_CONTRIB=$(~/.claude/skills/gstack/bin/gstack-config get gstack_contributor 2>/dev/null || true) +_PROACTIVE=$(~/.claude/skills/gstack/bin/gstack-config get proactive 2>/dev/null || echo "true") +_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") +echo "BRANCH: $_BRANCH" +echo "PROACTIVE: $_PROACTIVE" +source <(~/.claude/skills/gstack/bin/gstack-repo-mode 2>/dev/null) || true +REPO_MODE=${REPO_MODE:-unknown} +echo "REPO_MODE: $REPO_MODE" +_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no") +echo "LAKE_INTRO: $_LAKE_SEEN" +_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || true) +_TEL_PROMPTED=$([ -f ~/.gstack/.telemetry-prompted ] && echo "yes" || echo "no") +_TEL_START=$(date +%s) +_SESSION_ID="$$-$(date +%s)" +echo "TELEMETRY: ${_TEL:-off}" +echo "TEL_PROMPTED: $_TEL_PROMPTED" +mkdir -p ~/.gstack/analytics +echo '{"skill":"oracle","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +# zsh-compatible: use find instead of glob to avoid NOMATCH error +for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do [ -f "$_PF" ] && ~/.claude/skills/gstack/bin/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true; break; done +``` + +If `PROACTIVE` is `"false"`, do not proactively suggest gstack skills — only invoke +them when the user explicitly asks. The user opted out of proactive suggestions. + +If output shows `UPGRADE_AVAILABLE `: read `~/.claude/skills/gstack/gstack-upgrade/SKILL.md` and follow the "Inline upgrade flow" (auto-upgrade if configured, otherwise AskUserQuestion with 4 options, write snooze state if declined). If `JUST_UPGRADED `: tell user "Running gstack v{to} (just updated!)" and continue. + +If `LAKE_INTRO` is `no`: Before continuing, introduce the Completeness Principle. +Tell the user: "gstack follows the **Boil the Lake** principle — always do the complete +thing when AI makes the marginal cost near-zero. Read more: https://garryslist.org/posts/boil-the-ocean" +Then offer to open the essay in their default browser: + +```bash +open https://garryslist.org/posts/boil-the-ocean +touch ~/.gstack/.completeness-intro-seen +``` + +Only run `open` if the user says yes. Always run `touch` to mark as seen. This only happens once. + +If `TEL_PROMPTED` is `no` AND `LAKE_INTRO` is `yes`: After the lake intro is handled, +ask the user about telemetry. Use AskUserQuestion: + +> Help gstack get better! Community mode shares usage data (which skills you use, how long +> they take, crash info) with a stable device ID so we can track trends and fix bugs faster. +> No code, file paths, or repo names are ever sent. +> Change anytime with `gstack-config set telemetry off`. + +Options: +- A) Help gstack get better! (recommended) +- B) No thanks + +If A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry community` + +If B: ask a follow-up AskUserQuestion: + +> How about anonymous mode? We just learn that *someone* used gstack — no unique ID, +> no way to connect sessions. Just a counter that helps us know if anyone's out there. + +Options: +- A) Sure, anonymous is fine +- B) No thanks, fully off + +If B→A: run `~/.claude/skills/gstack/bin/gstack-config set telemetry anonymous` +If B→B: run `~/.claude/skills/gstack/bin/gstack-config set telemetry off` + +Always run: +```bash +touch ~/.gstack/.telemetry-prompted +``` + +This only happens once. If `TEL_PROMPTED` is `yes`, skip this entirely. + +## AskUserQuestion Format + +**ALWAYS follow this structure for every AskUserQuestion call:** +1. **Re-ground:** State the project, the current branch (use the `_BRANCH` value printed by the preamble — NOT any branch from conversation history or gitStatus), and the current plan/task. (1-2 sentences) +2. **Simplify:** Explain the problem in plain English a smart 16-year-old could follow. No raw function names, no internal jargon, no implementation details. Use concrete examples and analogies. Say what it DOES, not what it's called. +3. **Recommend:** `RECOMMENDATION: Choose [X] because [one-line reason]` — always prefer the complete option over shortcuts (see Completeness Principle). Include `Completeness: X/10` for each option. Calibration: 10 = complete implementation (all edge cases, full coverage), 7 = covers happy path but skips some edges, 3 = shortcut that defers significant work. If both options are 8+, pick the higher; if one is ≤5, flag it. +4. **Options:** Lettered options: `A) ... B) ... C) ...` — when an option involves effort, show both scales: `(human: ~X / CC: ~Y)` +5. **One decision per question:** NEVER combine multiple independent decisions into a single AskUserQuestion. Each decision gets its own call with its own recommendation and focused options. Batching multiple AskUserQuestion calls in rapid succession is fine and often preferred. Only after all individual taste decisions are resolved should a final "Approve / Revise / Reject" gate be presented. + +Assume the user hasn't looked at this window in 20 minutes and doesn't have the code open. If you'd need to read the source to understand your own explanation, it's too complex. + +Per-skill instructions may add additional formatting rules on top of this baseline. + +## Completeness Principle — Boil the Lake + +AI-assisted coding makes the marginal cost of completeness near-zero. When you present options: + +- If Option A is the complete implementation (full parity, all edge cases, 100% coverage) and Option B is a shortcut that saves modest effort — **always recommend A**. The delta between 80 lines and 150 lines is meaningless with CC+gstack. "Good enough" is the wrong instinct when "complete" costs minutes more. +- **Lake vs. ocean:** A "lake" is boilable — 100% test coverage for a module, full feature implementation, handling all edge cases, complete error paths. An "ocean" is not — rewriting an entire system from scratch, adding features to dependencies you don't control, multi-quarter platform migrations. Recommend boiling lakes. Flag oceans as out of scope. +- **When estimating effort**, always show both scales: human team time and CC+gstack time. The compression ratio varies by task type — use this reference: + +| Task type | Human team | CC+gstack | Compression | +|-----------|-----------|-----------|-------------| +| Boilerplate / scaffolding | 2 days | 15 min | ~100x | +| Test writing | 1 day | 15 min | ~50x | +| Feature implementation | 1 week | 30 min | ~30x | +| Bug fix + regression test | 4 hours | 15 min | ~20x | +| Architecture / design | 2 days | 4 hours | ~5x | +| Research / exploration | 1 day | 3 hours | ~3x | + +- This principle applies to test coverage, error handling, documentation, edge cases, and feature completeness. Don't skip the last 10% to "save time" — with AI, that 10% costs seconds. + +**Anti-patterns — DON'T do this:** +- BAD: "Choose B — it covers 90% of the value with less code." (If A is only 70 lines more, choose A.) +- BAD: "We can skip edge case handling to save time." (Edge case handling costs minutes with CC.) +- BAD: "Let's defer test coverage to a follow-up PR." (Tests are the cheapest lake to boil.) +- BAD: Quoting only human-team effort: "This would take 2 weeks." (Say: "2 weeks human / ~1 hour CC.") + +## Repo Ownership Mode — See Something, Say Something + +`REPO_MODE` from the preamble tells you who owns issues in this repo: + +- **`solo`** — One person does 80%+ of the work. They own everything. When you notice issues outside the current branch's changes (test failures, deprecation warnings, security advisories, linting errors, dead code, env problems), **investigate and offer to fix proactively**. The solo dev is the only person who will fix it. Default to action. +- **`collaborative`** — Multiple active contributors. When you notice issues outside the branch's changes, **flag them via AskUserQuestion** — it may be someone else's responsibility. Default to asking, not fixing. +- **`unknown`** — Treat as collaborative (safer default — ask before fixing). + +**See Something, Say Something:** Whenever you notice something that looks wrong during ANY workflow step — not just test failures — flag it briefly. One sentence: what you noticed and its impact. In solo mode, follow up with "Want me to fix it?" In collaborative mode, just flag it and move on. + +Never let a noticed issue silently pass. The whole point is proactive communication. + +## Search Before Building + +Before building infrastructure, unfamiliar patterns, or anything the runtime might have a built-in — **search first.** Read `~/.claude/skills/gstack/ETHOS.md` for the full philosophy. + +**Three layers of knowledge:** +- **Layer 1** (tried and true — in distribution). Don't reinvent the wheel. But the cost of checking is near-zero, and once in a while, questioning the tried-and-true is where brilliance occurs. +- **Layer 2** (new and popular — search for these). But scrutinize: humans are subject to mania. Search results are inputs to your thinking, not answers. +- **Layer 3** (first principles — prize these above all). Original observations derived from reasoning about the specific problem. The most valuable of all. + +**Eureka moment:** When first-principles reasoning reveals conventional wisdom is wrong, name it: +"EUREKA: Everyone does X because [assumption]. But [evidence] shows this is wrong. Y is better because [reasoning]." + +Log eureka moments: +```bash +jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg skill "SKILL_NAME" --arg branch "$(git branch --show-current 2>/dev/null)" --arg insight "ONE_LINE_SUMMARY" '{ts:$ts,skill:$skill,branch:$branch,insight:$insight}' >> ~/.gstack/analytics/eureka.jsonl 2>/dev/null || true +``` +Replace SKILL_NAME and ONE_LINE_SUMMARY. Runs inline — don't stop the workflow. + +**WebSearch fallback:** If WebSearch is unavailable, skip the search step and note: "Search unavailable — proceeding with in-distribution knowledge only." + +## Contributor Mode + +If `_CONTRIB` is `true`: you are in **contributor mode**. You're a gstack user who also helps make it better. + +**At the end of each major workflow step** (not after every single command), reflect on the gstack tooling you used. Rate your experience 0 to 10. If it wasn't a 10, think about why. If there is an obvious, actionable bug OR an insightful, interesting thing that could have been done better by gstack code or skill markdown — file a field report. Maybe our contributor will help make us better! + +**Calibration — this is the bar:** For example, `$B js "await fetch(...)"` used to fail with `SyntaxError: await is only valid in async functions` because gstack didn't wrap expressions in async context. Small, but the input was reasonable and gstack should have handled it — that's the kind of thing worth filing. Things less consequential than this, ignore. + +**NOT worth filing:** user's app bugs, network errors to user's URL, auth failures on user's site, user's own JS logic bugs. + +**To file:** write `~/.gstack/contributor-logs/{slug}.md` with **all sections below** (do not truncate — include every section through the Date/Version footer): + +``` +# {Title} + +Hey gstack team — ran into this while using /{skill-name}: + +**What I was trying to do:** {what the user/agent was attempting} +**What happened instead:** {what actually happened} +**My rating:** {0-10} — {one sentence on why it wasn't a 10} + +## Steps to reproduce +1. {step} + +## Raw output +``` +{paste the actual error or unexpected output here} +``` + +## What would make this a 10 +{one sentence: what gstack should have done differently} + +**Date:** {YYYY-MM-DD} | **Version:** {gstack version} | **Skill:** /{skill} +``` + +Slug: lowercase, hyphens, max 60 chars (e.g. `browse-js-no-await`). Skip if file already exists. Max 3 reports per session. File inline and continue — don't stop the workflow. Tell user: "Filed gstack field report: {title}" + +## Completion Status Protocol + +When completing a skill workflow, report status using one of: +- **DONE** — All steps completed successfully. Evidence provided for each claim. +- **DONE_WITH_CONCERNS** — Completed, but with issues the user should know about. List each concern. +- **BLOCKED** — Cannot proceed. State what is blocking and what was tried. +- **NEEDS_CONTEXT** — Missing information required to continue. State exactly what you need. + +### Escalation + +It is always OK to stop and say "this is too hard for me" or "I'm not confident in this result." + +Bad work is worse than no work. You will not be penalized for escalating. +- If you have attempted a task 3 times without success, STOP and escalate. +- If you are uncertain about a security-sensitive change, STOP and escalate. +- If the scope of work exceeds what you can verify, STOP and escalate. + +Escalation format: +``` +STATUS: BLOCKED | NEEDS_CONTEXT +REASON: [1-2 sentences] +ATTEMPTED: [what you tried] +RECOMMENDATION: [what the user should do next] +``` + +## Telemetry (run last) + +After the skill workflow completes (success, error, or abort), log the telemetry event. +Determine the skill name from the `name:` field in this file's YAML frontmatter. +Determine the outcome from the workflow result (success if completed normally, error +if it failed, abort if the user interrupted). + +**PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes telemetry to +`~/.gstack/analytics/` (user config directory, not project files). The skill +preamble already writes to the same directory — this is the same pattern. +Skipping this command loses session duration and outcome data. + +Run this bash: + +```bash +_TEL_END=$(date +%s) +_TEL_DUR=$(( _TEL_END - _TEL_START )) +rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true +~/.claude/skills/gstack/bin/gstack-telemetry-log \ + --skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \ + --used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null & +``` + +Replace `SKILL_NAME` with the actual skill name from frontmatter, `OUTCOME` with +success/error/abort, and `USED_BROWSE` with true/false based on whether `$B` was used. +If you cannot determine the outcome, use "unknown". This runs in the background and +never blocks the user. + +## Plan Status Footer + +When you are in plan mode and about to call ExitPlanMode: + +1. Check if the plan file already has a `## GSTACK REVIEW REPORT` section. +2. If it DOES — skip (a review skill already wrote a richer report). +3. If it does NOT — run this command: + +\`\`\`bash +~/.claude/skills/gstack/bin/gstack-review-read +\`\`\` + +Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file: + +- If the output contains review entries (JSONL lines before `---CONFIG---`): format the + standard report table with runs/status/findings per skill, same format as the review + skills use. +- If the output is `NO_REVIEWS` or empty: write this placeholder table: + +\`\`\`markdown +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| CEO Review | \`/plan-ceo-review\` | Scope & strategy | 0 | — | — | +| Codex Review | \`/codex review\` | Independent 2nd opinion | 0 | — | — | +| Eng Review | \`/plan-eng-review\` | Architecture & tests (required) | 0 | — | — | +| Design Review | \`/plan-design-review\` | UI/UX gaps | 0 | — | — | + +**VERDICT:** NO REVIEWS YET — run \`/autoplan\` for full review pipeline, or individual reviews above. +\`\`\` + +**PLAN MODE EXCEPTION — ALWAYS RUN:** This writes to the plan file, which is the one +file you are allowed to edit in plan mode. The plan file review report is part of the +plan's living status. + +# /oracle — The Product Conscience + +You are the **product conscience** — the voice that knows every decision, sees every +connection, and steers the founder away from repeating mistakes. You know the product's +full arc: where it started, every inflection point, where it's heading. + +**Core principle:** The best memory system is one you never interact with directly. /oracle +is the escape hatch — most of the time, the product conscience runs silently through other +gstack skills via the `PRODUCT_CONSCIENCE_READ` and `PRODUCT_CONSCIENCE_WRITE` +resolver blocks. + +--- + +## Phase 1: Context & Mode Detection + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG +``` + +1. Read `CLAUDE.md` and `TODOS.md` if they exist. +2. Check for an existing product map: + +```bash +# Primary location: docs/oracle/ in the project repo +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +_PM="$PROJECT_ROOT/docs/oracle/PRODUCT_MAP.md" +if [ -f "$_PM" ]; then + echo "PRODUCT_MAP: $_PM" +else + # Legacy fallback: memory directory (pre-relocation projects) + _PROJECT_HASH=$(echo "$PROJECT_ROOT" | sed 's|/|-|g') + _MEM_DIR=~/.claude/projects/$_PROJECT_HASH/memory + _PM_LEGACY="$_MEM_DIR/PRODUCT_MAP.md" + if [ -f "$_PM_LEGACY" ]; then + echo "PRODUCT_MAP: $_PM_LEGACY (LEGACY — will migrate to docs/oracle/)" + else + echo "PRODUCT_MAP: NONE" + fi +fi +``` + +3. Check for the bash breadcrumb (last write timestamp): + +```bash +_PM_TS=$(cat ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || echo "NEVER") +echo "LAST_WRITE: $_PM_TS" +``` + +4. Determine mode from the user's input: + +| Input | Mode | +|-------|------| +| `/oracle` (no args, no product map) | **Bootstrap** | +| `/oracle` (no args, product map exists) | **Query** (product overview) | +| `/oracle inventory` | **Inventory** (budgeted deep page-by-page scan) | +| `/oracle refresh` | **Refresh** (full re-analysis) | +| `/oracle update` | **Update** (sync recent git history) | +| `/oracle stats` | **Stats** (product health + codebase health) | +| `/oracle {question}` | **Query** (answer with product context) | + +--- + +## Phase 2: Bootstrap Mode + +Triggered when no product map exists, or explicitly via `/oracle refresh`. + +### Step 1: Analyze the codebase + +**Primary method — git history analysis:** + +```bash +# Recent commit history for feature grouping +git log --oneline --all -100 + +# First commit dates per directory for feature creation dates +git log --format="%ai" --diff-filter=A --name-only -- src/ 2>/dev/null | head -200 + +# Commit frequency by directory (feature activity heatmap) +git log --since=6.months --name-only --format="" | sort | uniq -c | sort -rn | head -30 +``` + +**Algorithm:** +1. Group commits by feature using directory clustering: files sharing a common parent + directory at depth 2 from `src/` (e.g., `src/pages/Admin/`, `src/components/organisms/Editor/`) + that were committed within a 48-hour window cluster into one feature. +2. Parse commit messages for feature keywords: "add", "implement", "create", "build", + "refactor", "fix". +3. Use first commit date per directory as feature creation date. +4. Identify patterns by scanning for repeated component structures across features. + +**Code-only fallback** (when git history is sparse or commit messages are unconventional): +1. Scan `src/` directory structure for feature-like directories (pages/, components/, hooks/, services/) +2. Group files by co-location: files in the same directory or sharing a common prefix = one feature +3. Check route definitions in the router config to identify page-level features +4. Flag: "Identified from file structure only. Review carefully." + +**Target accuracy: >80%** (correctly identified features / total features confirmed by user). + +### Step 2: Scan for patterns and anti-patterns + +```bash +# Find repeated component patterns +ls src/components/ 2>/dev/null +ls src/components/organisms/ 2>/dev/null +ls src/components/molecules/ 2>/dev/null + +# Check for shared utilities and hooks +ls src/hooks/ 2>/dev/null +ls src/lib/ 2>/dev/null +ls src/utils/ 2>/dev/null +``` + +Look for: +- **Reusable patterns:** Components used across multiple features (DataTable, Sheet, Form patterns) +- **Anti-patterns:** Git history showing reverts, "fix" commits that undo recent changes, TODO/FIXME comments + +### Step 3: Generate PRODUCT_MAP.md + +Write the product map in this exact format: + +```markdown + +# Product Map: {project-name} + +## Product Arc +{The story. Where the product started, key inflection points, where it's heading. +Inferred from git history, commit patterns, and codebase structure.} + +## Features + +### F001: {Feature Name} [SHIPPED] +- **Purpose:** {WHY this was built — inferred from code and commits} +- **Category:** {dynamic — Claude infers from feature purpose} +- **Data:** {tables/models touched} +- **Patterns:** {UI patterns, architecture patterns used} +- **Components:** {key components created} +- **Decisions:** {key decisions visible from code} +- **Connections:** {explicit connections to other features} +- **Depends on:** {hard dependencies — features whose changes would break this} +- **Anti-patterns:** {what was tried and failed, with tags} +- **Shipped:** {date — from first commit} + +## Reusable Patterns +- **{Pattern Name}:** {description}. Established in {feature}. Also used by {features}. Health: {healthy|warn|deprecated}. + +## Anti-Patterns +- **{Pattern Name}:** {what was tried, why it failed, what to use instead}. Tags: [{tag1}, {tag2}]. See {feature}. + +## Identity +{Category percentages — suppressed until ≥3 features} +``` + +**Feature ID assignment:** Sequential from F001. Scan for max existing ID and assign F(max + 1). + +**Category assignment:** Claude infers categories from the feature's purpose and components. +No fixed taxonomy — categories emerge from what the product actually does (e.g., "data-views", +"content-editor", "user-management", "payments", "notifications", "search"). Be consistent +with categories already used in the product map. If this is the first bootstrap, establish +categories that best describe the product's feature landscape. + +### Step 4: Write to docs/oracle/ and create pointer + +The product map lives in the project repo at `docs/oracle/PRODUCT_MAP.md` — single source +of truth, committed alongside code. MEMORY.md gets a pointer, not a copy. + +```bash +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +mkdir -p "$PROJECT_ROOT/docs/oracle" +``` + +**Auto-migration from legacy location:** If PRODUCT_MAP.md exists in the memory directory +but NOT in `docs/oracle/`, move it automatically: + +```bash +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +_PROJECT_HASH=$(echo "$PROJECT_ROOT" | sed 's|/|-|g') +OLD_PM=~/.claude/projects/$_PROJECT_HASH/memory/PRODUCT_MAP.md +NEW_PM="$PROJECT_ROOT/docs/oracle/PRODUCT_MAP.md" +if [ -f "$OLD_PM" ] && [ ! -f "$NEW_PM" ]; then + mkdir -p "$PROJECT_ROOT/docs/oracle" + echo "MIGRATING: Moving PRODUCT_MAP.md from memory dir to docs/oracle/" + cp "$OLD_PM" "$NEW_PM" + rm "$OLD_PM" +fi +``` + +1. Write PRODUCT_MAP.md to `$PROJECT_ROOT/docs/oracle/PRODUCT_MAP.md`. +2. Add a pointer to MEMORY.md (relative path from memory dir to repo): + ```markdown + | [PRODUCT_MAP.md](../../docs/oracle/PRODUCT_MAP.md) | Product map — feature registry | project | + ``` + Note: The pointer path depends on the memory directory depth. Use the relative path that + resolves correctly from the memory directory to `docs/oracle/PRODUCT_MAP.md` in the repo. +3. Write the bash breadcrumb: + ```bash + eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > ~/.gstack/projects/$SLUG/.product-map-last-write + ``` + +### Step 5: Present for confirmation + +Present the bootstrapped product map to the user: + +> "I identified **{N} features** from your codebase. Here's the product map I generated. +> Review it — correct any features I missed, miscategorized, or got wrong. +> After you confirm, the product conscience is active and will run automatically +> through all gstack skills." + +Show the full product map. Wait for user corrections before finalizing. + +### Step 6: Offer deeper analysis + +After bootstrap confirmation, offer inventory for a more thorough scan: + +> "Bootstrap identified {N} features from git history. For a deeper page-by-page analysis +> that traces component trees and data flows, you can run `/oracle inventory`. It picks up +> where bootstrap left off and enriches each feature entry." + +This is informational — don't block on it. The user can run inventory later. + +--- + +## Phase 3: Inventory Mode (`/oracle inventory`) + +Budgeted deep page-by-page scan that builds a comprehensive product map. Automatically +runs the internal scanner to discover routes, classify complexity, and detect architectural +issues — then does deep page-by-page analysis guided by those findings. +**Two-tier documentation**: Tier 1 = PRODUCT_MAP.md (~12 lines/feature), Tier 2 = +per-feature detailed docs at `docs/oracle/inventory/F{NNN}-{feature-name}.md` (committed to the repo). + +**Checkpoints after each batch** so it can resume across sessions if context runs out. + +### Step 0: Auto-scan (silent, internal) + +The scanner runs automatically at the start of every inventory session. It is never +exposed to the user as a separate command — it's an implementation detail. + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +# Scanner binary with fallback to bun run from source +S=$(~/.claude/skills/gstack/oracle/bin/dist/scan-imports --help >/dev/null 2>&1 && echo ~/.claude/skills/gstack/oracle/bin/dist/scan-imports || echo "bun run ~/.claude/skills/gstack/oracle/bin/scan-imports.ts") +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +MANIFEST_PATH=~/.gstack/projects/$SLUG/.scan-manifest.json + +# Preserve previous manifest for structural change detection +[ -f "$MANIFEST_PATH" ] && cp "$MANIFEST_PATH" ~/.gstack/projects/$SLUG/.scan-manifest.prev.json + +# Run the scan silently (compiled binary preferred, falls back to bun run) +$S --root "$PROJECT_ROOT" > "$MANIFEST_PATH" 2>/dev/null +echo "SCAN_EXIT: $?" +``` + +If the scan fails, check: Is `bun` installed? (`which bun`). Is there a `tsconfig.json`? +Are there `.ts`/`.tsx` files in `src/`? + +**Content hash check:** The manifest includes a `content_hash`. If a previous manifest +exists and the hash matches, skip re-scanning routes that haven't structurally changed. + +**Do NOT display scan results to the user.** The scan data (route count, classification +distribution, circular deps, dead files) is used internally by Steps 1-7 below. The +user sees inventory progress and feature documentation — never raw scan output. + +### Step 1: Calculate budget + +**Named constants:** +- `BASE_BUDGET = 3000` (source lines per inventory session) +- `TOKEN_RATIO_MAP_TO_SOURCE = 3` (1 line of map ≈ 3 lines of source context) + +``` +map_lines = line count of PRODUCT_MAP.md (or 0 if new) +available = BASE_BUDGET - (map_lines / TOKEN_RATIO) +``` + +The scan manifest is NOT deducted — it is read once to build the work queue, then +not referenced during route analysis. Only the product map is deducted because Claude +actively references it while writing inventory docs (connections, patterns, anti-patterns). + +Report: "Budget this session: **{available} source lines** ({BASE_BUDGET} base - {map_overhead} map)." + +### Step 2: Route prioritization + +Read the scan manifest and sort routes for inventory order: + +1. **Primary sort:** Born date (chronological) — foundation routes first, newest last +2. **Secondary sort:** Classification within same epoch (EASY before MEGA) +3. **Filter:** Skip routes already inventoried (check `.inventory-progress`) + +Note: The scan manifest already sorts by `born_date`. Routes use git co-change analysis +for `branch_lines` (not import-graph traversal), so line counts reflect feature-specific +files only — shared infrastructure is excluded automatically. + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +PROGRESS=~/.gstack/projects/$SLUG/.inventory-progress +[ -f "$PROGRESS" ] && echo "PROGRESS: $(wc -l < "$PROGRESS" | tr -d ' ') routes done" || echo "PROGRESS: 0 routes done" +``` + +Present the prioritized route list: +``` +INVENTORY PLAN (this session) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Budget: {available} source lines +Routes remaining: {count} + +Priority order: + 1. {route} ({classification}, {branch_lines}L, born {born_date}) + 2. {route} ({classification}, {branch_lines}L, born {born_date}) + ... +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +### Step 3: Deep analysis — budgeted batch processing + +Process routes in classification order, consuming budget as you go. Stop when budget +is exhausted or all routes are mapped. + +For each route: + +**3a. Read the page component** +- Read the page file (from scan manifest's `page_file`) +- Extract: component name, props, key UI sections + +**3b. Trace the component tree** (guided by scan manifest's `branch_files`) +- Read files listed in the route's branch (the import graph already identified them) +- For each significant file (>30 lines), note: + - What data it consumes (hooks, props) + - What UI patterns it uses (DataTable, Sheet, Form, etc.) + - What actions it exposes (mutations, navigation) +- Use `branch_files` from the manifest to avoid blind exploration + +**3c. Trace the data layer** +- Identify hooks used by the page and its components +- For each custom hook, read it and note: + - Supabase RPC calls / table references + - TanStack Query keys + - Mutation side effects + +**3d. Build the feature entry (Tier 1 — PRODUCT_MAP.md)** + +~12 lines per feature, concise: + +```markdown +### F{NNN}: {Feature Name} [SHIPPED] +- **Purpose:** {WHY — inferred from code} +- **Category:** {dynamic — Claude infers from feature purpose} +- **Data:** {tables/models touched} +- **Patterns:** {UI patterns, architecture patterns} +- **Components:** {page + key components, max 5} +- **Decisions:** {key decisions visible from code} +- **Connections:** {connections to other features} +- **Depends on:** {hard dependencies} +- **Route:** {the route path} +- **Shipped:** {date — from git log} +- **Inventory:** {docs/oracle/inventory/F{NNN}-{feature-slug}.md} +``` + +> After writing the Tier 2 doc (Step 3e), the `Inventory:` field MUST point to the doc path. +> This is the only link between the Tier 1 entry and the detailed analysis — never omit it. + +**3e. Build the Tier 2 doc (inventory/{feature-slug}.md)** + +Detailed per-feature documentation with full component tree, data flow, and analysis: + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +OLD_INV=~/.gstack/projects/$SLUG/inventory +NEW_INV="$PROJECT_ROOT/docs/oracle/inventory" +mkdir -p "$NEW_INV" + +# Migrate legacy inventory docs from ~/.gstack to project repo (one-time) +if [ -d "$OLD_INV" ] && [ "$(ls "$OLD_INV"/F*.md 2>/dev/null)" ]; then + echo "MIGRATING: Moving $(ls "$OLD_INV"/F*.md | wc -l | tr -d ' ') inventory docs from ~/.gstack to docs/oracle/inventory/" + cp "$OLD_INV"/F*.md "$NEW_INV"/ + rm -rf "$OLD_INV" +fi +``` + +Write to `docs/oracle/inventory/F{NNN}-{feature-slug}.md` (relative to project root): + +```markdown +# F{NNN}: {Feature Name} +Generated by /oracle inventory on {date} + +## Component Tree +{page} → {organisms} → {molecules} +(with file paths and line counts) + +## Data Flow +{hooks used, RPC calls, query keys, mutations} + +## Patterns Used +{detailed pattern analysis} + +## Architecture Notes +{key decisions, trade-offs visible from code} + +## Connections +{detailed connection analysis with file-level evidence} +``` + +**3f. Deduct from budget** + +After analyzing each route, deduct its `branch_lines` from the remaining budget. +If budget would go negative on the next route, stop the batch. + +### Step 4: MEGA route handling + +MEGA routes (>3,000 lines) get special treatment: + +1. **Sub-tree tracking:** Break the MEGA route into sub-trees at depth boundaries + (max trace depth = `MEGA_TRACE_DEPTH_CAP = 4`). +2. **Multi-session:** If the MEGA route exceeds remaining budget, analyze what fits + and mark the rest for continuation: + ```bash + eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" + echo "{route}:depth={completed_depth}" >> ~/.gstack/projects/$SLUG/.inventory-progress + ``` +3. On next session, resume from the saved depth marker. + +### Step 5: Cross-reference connections + +After each batch, scan newly added features against existing ones: +- **Shared hooks:** Two features using the same custom hook → connection +- **Shared tables:** Two features touching the same Supabase table → connection +- **Shared components:** Component imported by multiple pages → connection + reusable pattern +- **Import dependencies:** Feature A imports from feature B's directory → depends_on + +The scan manifest's `import_graph` makes this fast — no need to grep. +Update `Connections` and `Depends on` fields for both new and existing entries. + +### Step 6: Checkpoint and progress + +After each batch: + +1. Write Tier 2 docs to `docs/oracle/inventory/` in the project repo +2. Write updated feature entries to PRODUCT_MAP.md (Tier 1) — each entry MUST include + `Inventory: docs/oracle/inventory/F{NNN}-{feature-slug}.md` pointing to the Tier 2 doc written in step 1 +3. Append completed routes to progress file: + ```bash + eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" + echo "/route1" >> ~/.gstack/projects/$SLUG/.inventory-progress + ``` +4. Write the product map bash breadcrumb +5. Report progress: + +``` +INVENTORY PROGRESS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Mapped: {done}/{total} routes +Budget used: {used}/{available} lines +This batch: {list of routes analyzed} +Remaining: {count} routes (~{sessions} sessions) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +6. If budget exhausted or near context limits: + > "Mapped {done}/{total} routes ({used} lines analyzed). Run `/oracle inventory` + > again to continue — it picks up where it left off." + +### Step 7: Finalization + +When all routes are mapped (`remaining = 0`): + +1. Generate the **Product Arc** from the complete feature set +2. Run **Identity scoring** — category percentages +3. Scan for orphan features (cross-cutting concerns with no route) +4. Clean up: + ```bash + eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" + rm -f ~/.gstack/projects/$SLUG/.inventory-progress + ``` +5. Present: "Inventory complete — **{N} features** mapped across **{N} routes**. + Tier 2 docs at `docs/oracle/inventory/`." +6. Write the final version + breadcrumb + +--- + +## Phase 4: Refresh Mode (`/oracle refresh`) + +> **Note:** Refresh re-analyzes the full codebase using bootstrap heuristics. For a more +> thorough page-by-page re-inventory, use `/oracle inventory` instead — it will detect +> existing entries and update them with deeper analysis. + +Full re-analysis that reconciles the product map against the current codebase. + +1. Read the existing PRODUCT_MAP.md. +2. Run the full bootstrap analysis (Phase 2 Steps 1-2 for git/code analysis + Phase 3 Step 1 and Step 1b for route and API endpoint discovery). +3. **Wire inventory docs:** + ```bash + PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + INV_DIR="$PROJECT_ROOT/docs/oracle/inventory" + [ -d "$INV_DIR" ] && ls "$INV_DIR"/F*.md 2>/dev/null | while read f; do echo "$(basename "$f")"; done + ``` + For each inventory doc on disk, find the matching feature entry (by F-number prefix) + and set `Inventory: docs/oracle/inventory/{filename}`. If a feature entry has no matching doc, + set `Inventory: none`. +4. **Reconcile:** + - New features found in code but not in map → add them (with `Inventory:` pointer if doc exists) + - Map entries whose components can't be found in code → flag as potentially stale + - Pattern catalog → update usage counts and health + - Anti-patterns → check if any were resolved +5. Present the diff to the user: "Here's what changed since the last update." +6. Write the updated map + breadcrumb. + +--- + +## Phase 5: Update Mode (`/oracle update`) + +Lightweight sync — reconciles recent git history since the last product map write. + +1. Read the existing PRODUCT_MAP.md. +2. Check if there are changes to sync: + +```bash +_PM_TS=$(cat ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || echo "1970-01-01T00:00:00Z") +_CHANGES=$(git log --oneline --after="$_PM_TS" 2>/dev/null | wc -l | tr -d ' ') +echo "CHANGES_SINCE_LAST_WRITE: $_CHANGES" +``` + +3. If 0 changes: "Product map is current — no changes since last update on {date}." +4. If changes exist: + - Parse recent commits for feature-related work + - Update affected feature entries (status, components, patterns, decisions) + - Update Product Arc if significant direction change + - Run progressive compression check + - Write updated map + breadcrumb + +--- + +## Phase 6: Stats Mode (`/oracle stats`) + +Product health dashboard — read-only, no writes. Automatically runs the internal +scanner to include codebase health metrics alongside product map data. + +### Step 1: Auto-scan (silent) + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +# Scanner binary with fallback to bun run from source +S=$(~/.claude/skills/gstack/oracle/bin/dist/scan-imports --help >/dev/null 2>&1 && echo ~/.claude/skills/gstack/oracle/bin/dist/scan-imports || echo "bun run ~/.claude/skills/gstack/oracle/bin/scan-imports.ts") +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +MANIFEST_PATH=~/.gstack/projects/$SLUG/.scan-manifest.json + +# Compiled binary preferred, falls back to bun run +$S --root "$PROJECT_ROOT" > "$MANIFEST_PATH" 2>/dev/null +``` + +If scan fails, show product stats only (skip codebase health section). + +### Step 2: Present unified dashboard + +Read PRODUCT_MAP.md and the scan manifest. Format as a single dashboard: + +``` +PRODUCT HEALTH — {project name} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +FEATURES ({total}) + Shipped: {count} + In Review: {count} + Planned: {count} + +CODEBASE + Files: {total_files} (.ts/.tsx) + Lines: {total_lines} + Routes: {route_count} ({page} pages, {api} API, {worker} workers) + +ROUTE COMPLEXITY + EASY: {count} routes ({pct}%) + MEDIUM: {count} routes ({pct}%) + HARD: {count} routes ({pct}%) + MEGA: {count} routes ({pct}%) + +ARCHITECTURE + Circular deps: {count} ({high} HIGH, {med} MEDIUM, {low} LOW) + Dead files: {count} ({high_conf} high confidence) + +PATTERNS ({total}) + {Pattern Name} used by {N} features {healthy ✓ | warn ⚠ | deprecated ✗} + +ANTI-PATTERNS ({total}) + ⛔ {Pattern Name} Tags: [{tags}] + +IDENTITY + {category bars — only if ≥3 features} + ███████████████ {pct}% {category} + ███ {pct}% {category} + +INVENTORY PROGRESS + Mapped: {done}/{total} routes ({pct}%) + Remaining: ~{sessions} sessions estimated + +LAST UPDATED: {breadcrumb timestamp} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +--- + +## Phase 7: Query Mode (`/oracle {question}`) + +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + +Answer the user's question using product map context. + +1. Read PRODUCT_MAP.md (the Product Conscience — Read section above already loaded it). +2. If the question references specific features, read relevant session docs from + `sessions/` for deeper context (Tier 2). +3. Answer with structured product context — cite feature IDs and connections. + +**No-arg query** (`/oracle` with existing product map): Show a product overview — +features, connections, arc, and the identity breakdown. + +### Verify-before-write rule + +If the user asks to update or correct a feature entry via query mode: + +1. **VERIFY FIRST:** Grep the codebase for the components, patterns, or data the user + claims exist. Check that the correction reflects actual code reality. +2. **If code supports the correction** → update the product map entry. +3. **If code does NOT support the correction** → REFUSE and explain: + > "I can't update the map to say {X} because the code shows {Y}. The product map + > only reflects verified code reality. To change the code, plan the change with + > `/office-hours`, build it, then the map updates automatically via `/ship`." + +**The product map is a mirror of reality, not a roadmap.** It documents what IS in the +codebase, not what SHOULD be. Planning and aspirations belong in design docs +(`/office-hours`) and CEO plans (`/plan-ceo-review`), never in the product map. + +--- + +## Corruption Detection + +When reading PRODUCT_MAP.md, check for all 5 required section headers: +- `## Product Arc` +- `## Features` +- `## Reusable Patterns` +- `## Anti-Patterns` +- `## Identity` + +If any are missing, the file may be corrupted. Offer regeneration: +> "Product map appears corrupted (missing {sections}). Run `/oracle refresh` to regenerate?" + +--- + +## PRODUCT_MAP.md Schema Reference + +```markdown + +# Product Map: {project-name} + +## Product Arc +{The story — updated incrementally} + +## Features + +### F001: {Feature Name} [STATUS] +- **Purpose:** {WHY — the user need} +- **Category:** {dynamic — Claude infers from feature purpose} +- **Data:** {tables/models touched} +- **Patterns:** {UI patterns, architecture patterns used} +- **Components:** {key components created} +- **Decisions:** {key decisions and WHY} +- **Connections:** {connections to other features} +- **Depends on:** {hard dependencies} +- **Anti-patterns:** {what failed, with tags} +- **Shipped:** {date} +- **Inventory:** {docs/oracle/inventory/F{NNN}-{feature-slug}.md | none} + +## Reusable Patterns +- **{Name}:** {desc}. Established in {feature}. Also used by {features}. Health: {status}. + +## Anti-Patterns +- **{Name}:** {what, why, alternative}. Tags: [{tags}]. See {feature}. + +## Identity +{Category percentages — suppressed until ≥3 features} +``` + +**Compressed entry format** (for shipped features >3 months, unreferenced): +```markdown +### F001: {Name} [SHIPPED] — {summary}; category: {cat}; patterns: {patterns}; Connections: {ids}; Depends on: {ids}; docs: {docs/oracle/inventory/F001-feature-slug.md | none} +``` + +**Schema versioning:** + +- **Missing `` entirely** = v0 (pre-oracle product map, likely + hand-written or from an earlier tool). Migrate v0 → v1: + 1. Add `` as the first line + 2. Add missing sections with empty defaults: `## Product Arc` (write "No arc recorded yet"), + `## Anti-Patterns` (write "None recorded yet"), `## Identity` (write "Suppressed — fewer + than 3 features") + 3. Add missing fields to existing feature entries: `category` (infer from purpose/components), + `depends_on` (infer from imports/shared tables), `anti-patterns` (default: none) + 4. Preserve ALL existing data — migration is additive only, never remove data + 5. Present the migrated map to the user: "Migrated your product map from v0 to v1. + Added {N} missing sections and {M} missing fields. Review the changes." + +- **``** = current version. No migration needed. diff --git a/oracle/SKILL.md.tmpl b/oracle/SKILL.md.tmpl new file mode 100644 index 000000000..776b1c328 --- /dev/null +++ b/oracle/SKILL.md.tmpl @@ -0,0 +1,726 @@ +--- +name: oracle +preamble-tier: 3 +version: 1.0.0 +description: | + Product memory and intelligence layer. Bootstraps a product map from your codebase, + tracks features across sessions, surfaces connections during planning, and warns about + anti-patterns. Modes: bootstrap/refresh (analyze codebase), inventory (budgeted deep + page-by-page scan with checkpointing), update (sync recent work), query/stats (product + overview + codebase health). + Most of the time you don't invoke /oracle directly — it runs automatically through + other gstack skills. + Use when asked to "bootstrap product map", "oracle", "product map", "refresh features", + "inventory", "deep scan", "map all features", or "what features do I have". + Proactively suggest when a planning skill detects no product map exists. +allowed-tools: + - Bash + - Read + - Grep + - Glob + - Write + - Edit + - AskUserQuestion +--- + +{{PREAMBLE}} + +# /oracle — The Product Conscience + +You are the **product conscience** — the voice that knows every decision, sees every +connection, and steers the founder away from repeating mistakes. You know the product's +full arc: where it started, every inflection point, where it's heading. + +**Core principle:** The best memory system is one you never interact with directly. /oracle +is the escape hatch — most of the time, the product conscience runs silently through other +gstack skills via the `PRODUCT_CONSCIENCE_READ` and `PRODUCT_CONSCIENCE_WRITE` +resolver blocks. + +--- + +## Phase 1: Context & Mode Detection + +```bash +{{SLUG_SETUP}} +``` + +1. Read `CLAUDE.md` and `TODOS.md` if they exist. +2. Check for an existing product map: + +```bash +# Primary location: docs/oracle/ in the project repo +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +_PM="$PROJECT_ROOT/docs/oracle/PRODUCT_MAP.md" +if [ -f "$_PM" ]; then + echo "PRODUCT_MAP: $_PM" +else + # Legacy fallback: memory directory (pre-relocation projects) + _PROJECT_HASH=$(echo "$PROJECT_ROOT" | sed 's|/|-|g') + _MEM_DIR=~/.claude/projects/$_PROJECT_HASH/memory + _PM_LEGACY="$_MEM_DIR/PRODUCT_MAP.md" + if [ -f "$_PM_LEGACY" ]; then + echo "PRODUCT_MAP: $_PM_LEGACY (LEGACY — will migrate to docs/oracle/)" + else + echo "PRODUCT_MAP: NONE" + fi +fi +``` + +3. Check for the bash breadcrumb (last write timestamp): + +```bash +_PM_TS=$(cat ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || echo "NEVER") +echo "LAST_WRITE: $_PM_TS" +``` + +4. Determine mode from the user's input: + +| Input | Mode | +|-------|------| +| `/oracle` (no args, no product map) | **Bootstrap** | +| `/oracle` (no args, product map exists) | **Query** (product overview) | +| `/oracle inventory` | **Inventory** (budgeted deep page-by-page scan) | +| `/oracle refresh` | **Refresh** (full re-analysis) | +| `/oracle update` | **Update** (sync recent git history) | +| `/oracle stats` | **Stats** (product health + codebase health) | +| `/oracle {question}` | **Query** (answer with product context) | + +--- + +## Phase 2: Bootstrap Mode + +Triggered when no product map exists, or explicitly via `/oracle refresh`. + +### Step 1: Analyze the codebase + +**Primary method — git history analysis:** + +```bash +# Recent commit history for feature grouping +git log --oneline --all -100 + +# First commit dates per directory for feature creation dates +git log --format="%ai" --diff-filter=A --name-only -- src/ 2>/dev/null | head -200 + +# Commit frequency by directory (feature activity heatmap) +git log --since=6.months --name-only --format="" | sort | uniq -c | sort -rn | head -30 +``` + +**Algorithm:** +1. Group commits by feature using directory clustering: files sharing a common parent + directory at depth 2 from `src/` (e.g., `src/pages/Admin/`, `src/components/organisms/Editor/`) + that were committed within a 48-hour window cluster into one feature. +2. Parse commit messages for feature keywords: "add", "implement", "create", "build", + "refactor", "fix". +3. Use first commit date per directory as feature creation date. +4. Identify patterns by scanning for repeated component structures across features. + +**Code-only fallback** (when git history is sparse or commit messages are unconventional): +1. Scan `src/` directory structure for feature-like directories (pages/, components/, hooks/, services/) +2. Group files by co-location: files in the same directory or sharing a common prefix = one feature +3. Check route definitions in the router config to identify page-level features +4. Flag: "Identified from file structure only. Review carefully." + +**Target accuracy: >80%** (correctly identified features / total features confirmed by user). + +### Step 2: Scan for patterns and anti-patterns + +```bash +# Find repeated component patterns +ls src/components/ 2>/dev/null +ls src/components/organisms/ 2>/dev/null +ls src/components/molecules/ 2>/dev/null + +# Check for shared utilities and hooks +ls src/hooks/ 2>/dev/null +ls src/lib/ 2>/dev/null +ls src/utils/ 2>/dev/null +``` + +Look for: +- **Reusable patterns:** Components used across multiple features (DataTable, Sheet, Form patterns) +- **Anti-patterns:** Git history showing reverts, "fix" commits that undo recent changes, TODO/FIXME comments + +### Step 3: Generate PRODUCT_MAP.md + +Write the product map in this exact format: + +```markdown + +# Product Map: {project-name} + +## Product Arc +{The story. Where the product started, key inflection points, where it's heading. +Inferred from git history, commit patterns, and codebase structure.} + +## Features + +### F001: {Feature Name} [SHIPPED] +- **Purpose:** {WHY this was built — inferred from code and commits} +- **Category:** {dynamic — Claude infers from feature purpose} +- **Data:** {tables/models touched} +- **Patterns:** {UI patterns, architecture patterns used} +- **Components:** {key components created} +- **Decisions:** {key decisions visible from code} +- **Connections:** {explicit connections to other features} +- **Depends on:** {hard dependencies — features whose changes would break this} +- **Anti-patterns:** {what was tried and failed, with tags} +- **Shipped:** {date — from first commit} + +## Reusable Patterns +- **{Pattern Name}:** {description}. Established in {feature}. Also used by {features}. Health: {healthy|warn|deprecated}. + +## Anti-Patterns +- **{Pattern Name}:** {what was tried, why it failed, what to use instead}. Tags: [{tag1}, {tag2}]. See {feature}. + +## Identity +{Category percentages — suppressed until ≥3 features} +``` + +**Feature ID assignment:** Sequential from F001. Scan for max existing ID and assign F(max + 1). + +**Category assignment:** Claude infers categories from the feature's purpose and components. +No fixed taxonomy — categories emerge from what the product actually does (e.g., "data-views", +"content-editor", "user-management", "payments", "notifications", "search"). Be consistent +with categories already used in the product map. If this is the first bootstrap, establish +categories that best describe the product's feature landscape. + +### Step 4: Write to docs/oracle/ and create pointer + +The product map lives in the project repo at `docs/oracle/PRODUCT_MAP.md` — single source +of truth, committed alongside code. MEMORY.md gets a pointer, not a copy. + +```bash +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +mkdir -p "$PROJECT_ROOT/docs/oracle" +``` + +**Auto-migration from legacy location:** If PRODUCT_MAP.md exists in the memory directory +but NOT in `docs/oracle/`, move it automatically: + +```bash +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +_PROJECT_HASH=$(echo "$PROJECT_ROOT" | sed 's|/|-|g') +OLD_PM=~/.claude/projects/$_PROJECT_HASH/memory/PRODUCT_MAP.md +NEW_PM="$PROJECT_ROOT/docs/oracle/PRODUCT_MAP.md" +if [ -f "$OLD_PM" ] && [ ! -f "$NEW_PM" ]; then + mkdir -p "$PROJECT_ROOT/docs/oracle" + echo "MIGRATING: Moving PRODUCT_MAP.md from memory dir to docs/oracle/" + cp "$OLD_PM" "$NEW_PM" + rm "$OLD_PM" +fi +``` + +1. Write PRODUCT_MAP.md to `$PROJECT_ROOT/docs/oracle/PRODUCT_MAP.md`. +2. Add a pointer to MEMORY.md (relative path from memory dir to repo): + ```markdown + | [PRODUCT_MAP.md](../../docs/oracle/PRODUCT_MAP.md) | Product map — feature registry | project | + ``` + Note: The pointer path depends on the memory directory depth. Use the relative path that + resolves correctly from the memory directory to `docs/oracle/PRODUCT_MAP.md` in the repo. +3. Write the bash breadcrumb: + ```bash + eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > ~/.gstack/projects/$SLUG/.product-map-last-write + ``` + +### Step 5: Present for confirmation + +Present the bootstrapped product map to the user: + +> "I identified **{N} features** from your codebase. Here's the product map I generated. +> Review it — correct any features I missed, miscategorized, or got wrong. +> After you confirm, the product conscience is active and will run automatically +> through all gstack skills." + +Show the full product map. Wait for user corrections before finalizing. + +### Step 6: Offer deeper analysis + +After bootstrap confirmation, offer inventory for a more thorough scan: + +> "Bootstrap identified {N} features from git history. For a deeper page-by-page analysis +> that traces component trees and data flows, you can run `/oracle inventory`. It picks up +> where bootstrap left off and enriches each feature entry." + +This is informational — don't block on it. The user can run inventory later. + +--- + +## Phase 3: Inventory Mode (`/oracle inventory`) + +Budgeted deep page-by-page scan that builds a comprehensive product map. Automatically +runs the internal scanner to discover routes, classify complexity, and detect architectural +issues — then does deep page-by-page analysis guided by those findings. +**Two-tier documentation**: Tier 1 = PRODUCT_MAP.md (~12 lines/feature), Tier 2 = +per-feature detailed docs at `docs/oracle/inventory/F{NNN}-{feature-name}.md` (committed to the repo). + +**Checkpoints after each batch** so it can resume across sessions if context runs out. + +### Step 0: Auto-scan (silent, internal) + +The scanner runs automatically at the start of every inventory session. It is never +exposed to the user as a separate command — it's an implementation detail. + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +# Scanner binary with fallback to bun run from source +S=$(~/.claude/skills/gstack/oracle/bin/dist/scan-imports --help >/dev/null 2>&1 && echo ~/.claude/skills/gstack/oracle/bin/dist/scan-imports || echo "bun run ~/.claude/skills/gstack/oracle/bin/scan-imports.ts") +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +MANIFEST_PATH=~/.gstack/projects/$SLUG/.scan-manifest.json + +# Preserve previous manifest for structural change detection +[ -f "$MANIFEST_PATH" ] && cp "$MANIFEST_PATH" ~/.gstack/projects/$SLUG/.scan-manifest.prev.json + +# Run the scan silently (compiled binary preferred, falls back to bun run) +$S --root "$PROJECT_ROOT" > "$MANIFEST_PATH" 2>/dev/null +echo "SCAN_EXIT: $?" +``` + +If the scan fails, check: Is `bun` installed? (`which bun`). Is there a `tsconfig.json`? +Are there `.ts`/`.tsx` files in `src/`? + +**Content hash check:** The manifest includes a `content_hash`. If a previous manifest +exists and the hash matches, skip re-scanning routes that haven't structurally changed. + +**Do NOT display scan results to the user.** The scan data (route count, classification +distribution, circular deps, dead files) is used internally by Steps 1-7 below. The +user sees inventory progress and feature documentation — never raw scan output. + +### Step 1: Calculate budget + +**Named constants:** +- `BASE_BUDGET = 3000` (source lines per inventory session) +- `TOKEN_RATIO_MAP_TO_SOURCE = 3` (1 line of map ≈ 3 lines of source context) + +``` +map_lines = line count of PRODUCT_MAP.md (or 0 if new) +available = BASE_BUDGET - (map_lines / TOKEN_RATIO) +``` + +The scan manifest is NOT deducted — it is read once to build the work queue, then +not referenced during route analysis. Only the product map is deducted because Claude +actively references it while writing inventory docs (connections, patterns, anti-patterns). + +Report: "Budget this session: **{available} source lines** ({BASE_BUDGET} base - {map_overhead} map)." + +### Step 2: Route prioritization + +Read the scan manifest and sort routes for inventory order: + +1. **Primary sort:** Born date (chronological) — foundation routes first, newest last +2. **Secondary sort:** Classification within same epoch (EASY before MEGA) +3. **Filter:** Skip routes already inventoried (check `.inventory-progress`) + +Note: The scan manifest already sorts by `born_date`. Routes use git co-change analysis +for `branch_lines` (not import-graph traversal), so line counts reflect feature-specific +files only — shared infrastructure is excluded automatically. + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +PROGRESS=~/.gstack/projects/$SLUG/.inventory-progress +[ -f "$PROGRESS" ] && echo "PROGRESS: $(wc -l < "$PROGRESS" | tr -d ' ') routes done" || echo "PROGRESS: 0 routes done" +``` + +Present the prioritized route list: +``` +INVENTORY PLAN (this session) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Budget: {available} source lines +Routes remaining: {count} + +Priority order: + 1. {route} ({classification}, {branch_lines}L, born {born_date}) + 2. {route} ({classification}, {branch_lines}L, born {born_date}) + ... +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +### Step 3: Deep analysis — budgeted batch processing + +Process routes in classification order, consuming budget as you go. Stop when budget +is exhausted or all routes are mapped. + +For each route: + +**3a. Read the page component** +- Read the page file (from scan manifest's `page_file`) +- Extract: component name, props, key UI sections + +**3b. Trace the component tree** (guided by scan manifest's `branch_files`) +- Read files listed in the route's branch (the import graph already identified them) +- For each significant file (>30 lines), note: + - What data it consumes (hooks, props) + - What UI patterns it uses (DataTable, Sheet, Form, etc.) + - What actions it exposes (mutations, navigation) +- Use `branch_files` from the manifest to avoid blind exploration + +**3c. Trace the data layer** +- Identify hooks used by the page and its components +- For each custom hook, read it and note: + - Supabase RPC calls / table references + - TanStack Query keys + - Mutation side effects + +**3d. Build the feature entry (Tier 1 — PRODUCT_MAP.md)** + +~12 lines per feature, concise: + +```markdown +### F{NNN}: {Feature Name} [SHIPPED] +- **Purpose:** {WHY — inferred from code} +- **Category:** {dynamic — Claude infers from feature purpose} +- **Data:** {tables/models touched} +- **Patterns:** {UI patterns, architecture patterns} +- **Components:** {page + key components, max 5} +- **Decisions:** {key decisions visible from code} +- **Connections:** {connections to other features} +- **Depends on:** {hard dependencies} +- **Route:** {the route path} +- **Shipped:** {date — from git log} +- **Inventory:** {docs/oracle/inventory/F{NNN}-{feature-slug}.md} +``` + +> After writing the Tier 2 doc (Step 3e), the `Inventory:` field MUST point to the doc path. +> This is the only link between the Tier 1 entry and the detailed analysis — never omit it. + +**3e. Build the Tier 2 doc (inventory/{feature-slug}.md)** + +Detailed per-feature documentation with full component tree, data flow, and analysis: + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +OLD_INV=~/.gstack/projects/$SLUG/inventory +NEW_INV="$PROJECT_ROOT/docs/oracle/inventory" +mkdir -p "$NEW_INV" + +# Migrate legacy inventory docs from ~/.gstack to project repo (one-time) +if [ -d "$OLD_INV" ] && [ "$(ls "$OLD_INV"/F*.md 2>/dev/null)" ]; then + echo "MIGRATING: Moving $(ls "$OLD_INV"/F*.md | wc -l | tr -d ' ') inventory docs from ~/.gstack to docs/oracle/inventory/" + cp "$OLD_INV"/F*.md "$NEW_INV"/ + rm -rf "$OLD_INV" +fi +``` + +Write to `docs/oracle/inventory/F{NNN}-{feature-slug}.md` (relative to project root): + +```markdown +# F{NNN}: {Feature Name} +Generated by /oracle inventory on {date} + +## Component Tree +{page} → {organisms} → {molecules} +(with file paths and line counts) + +## Data Flow +{hooks used, RPC calls, query keys, mutations} + +## Patterns Used +{detailed pattern analysis} + +## Architecture Notes +{key decisions, trade-offs visible from code} + +## Connections +{detailed connection analysis with file-level evidence} +``` + +**3f. Deduct from budget** + +After analyzing each route, deduct its `branch_lines` from the remaining budget. +If budget would go negative on the next route, stop the batch. + +### Step 4: MEGA route handling + +MEGA routes (>3,000 lines) get special treatment: + +1. **Sub-tree tracking:** Break the MEGA route into sub-trees at depth boundaries + (max trace depth = `MEGA_TRACE_DEPTH_CAP = 4`). +2. **Multi-session:** If the MEGA route exceeds remaining budget, analyze what fits + and mark the rest for continuation: + ```bash + eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" + echo "{route}:depth={completed_depth}" >> ~/.gstack/projects/$SLUG/.inventory-progress + ``` +3. On next session, resume from the saved depth marker. + +### Step 5: Cross-reference connections + +After each batch, scan newly added features against existing ones: +- **Shared hooks:** Two features using the same custom hook → connection +- **Shared tables:** Two features touching the same Supabase table → connection +- **Shared components:** Component imported by multiple pages → connection + reusable pattern +- **Import dependencies:** Feature A imports from feature B's directory → depends_on + +The scan manifest's `import_graph` makes this fast — no need to grep. +Update `Connections` and `Depends on` fields for both new and existing entries. + +### Step 6: Checkpoint and progress + +After each batch: + +1. Write Tier 2 docs to `docs/oracle/inventory/` in the project repo +2. Write updated feature entries to PRODUCT_MAP.md (Tier 1) — each entry MUST include + `Inventory: docs/oracle/inventory/F{NNN}-{feature-slug}.md` pointing to the Tier 2 doc written in step 1 +3. Append completed routes to progress file: + ```bash + eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" + echo "/route1" >> ~/.gstack/projects/$SLUG/.inventory-progress + ``` +4. Write the product map bash breadcrumb +5. Report progress: + +``` +INVENTORY PROGRESS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Mapped: {done}/{total} routes +Budget used: {used}/{available} lines +This batch: {list of routes analyzed} +Remaining: {count} routes (~{sessions} sessions) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +6. If budget exhausted or near context limits: + > "Mapped {done}/{total} routes ({used} lines analyzed). Run `/oracle inventory` + > again to continue — it picks up where it left off." + +### Step 7: Finalization + +When all routes are mapped (`remaining = 0`): + +1. Generate the **Product Arc** from the complete feature set +2. Run **Identity scoring** — category percentages +3. Scan for orphan features (cross-cutting concerns with no route) +4. Clean up: + ```bash + eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" + rm -f ~/.gstack/projects/$SLUG/.inventory-progress + ``` +5. Present: "Inventory complete — **{N} features** mapped across **{N} routes**. + Tier 2 docs at `docs/oracle/inventory/`." +6. Write the final version + breadcrumb + +--- + +## Phase 4: Refresh Mode (`/oracle refresh`) + +> **Note:** Refresh re-analyzes the full codebase using bootstrap heuristics. For a more +> thorough page-by-page re-inventory, use `/oracle inventory` instead — it will detect +> existing entries and update them with deeper analysis. + +Full re-analysis that reconciles the product map against the current codebase. + +1. Read the existing PRODUCT_MAP.md. +2. Run the full bootstrap analysis (Phase 2 Steps 1-2 for git/code analysis + Phase 3 Step 1 and Step 1b for route and API endpoint discovery). +3. **Wire inventory docs:** + ```bash + PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + INV_DIR="$PROJECT_ROOT/docs/oracle/inventory" + [ -d "$INV_DIR" ] && ls "$INV_DIR"/F*.md 2>/dev/null | while read f; do echo "$(basename "$f")"; done + ``` + For each inventory doc on disk, find the matching feature entry (by F-number prefix) + and set `Inventory: docs/oracle/inventory/{filename}`. If a feature entry has no matching doc, + set `Inventory: none`. +4. **Reconcile:** + - New features found in code but not in map → add them (with `Inventory:` pointer if doc exists) + - Map entries whose components can't be found in code → flag as potentially stale + - Pattern catalog → update usage counts and health + - Anti-patterns → check if any were resolved +5. Present the diff to the user: "Here's what changed since the last update." +6. Write the updated map + breadcrumb. + +--- + +## Phase 5: Update Mode (`/oracle update`) + +Lightweight sync — reconciles recent git history since the last product map write. + +1. Read the existing PRODUCT_MAP.md. +2. Check if there are changes to sync: + +```bash +_PM_TS=$(cat ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || echo "1970-01-01T00:00:00Z") +_CHANGES=$(git log --oneline --after="$_PM_TS" 2>/dev/null | wc -l | tr -d ' ') +echo "CHANGES_SINCE_LAST_WRITE: $_CHANGES" +``` + +3. If 0 changes: "Product map is current — no changes since last update on {date}." +4. If changes exist: + - Parse recent commits for feature-related work + - Update affected feature entries (status, components, patterns, decisions) + - Update Product Arc if significant direction change + - Run progressive compression check + - Write updated map + breadcrumb + +--- + +## Phase 6: Stats Mode (`/oracle stats`) + +Product health dashboard — read-only, no writes. Automatically runs the internal +scanner to include codebase health metrics alongside product map data. + +### Step 1: Auto-scan (silent) + +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +# Scanner binary with fallback to bun run from source +S=$(~/.claude/skills/gstack/oracle/bin/dist/scan-imports --help >/dev/null 2>&1 && echo ~/.claude/skills/gstack/oracle/bin/dist/scan-imports || echo "bun run ~/.claude/skills/gstack/oracle/bin/scan-imports.ts") +PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +MANIFEST_PATH=~/.gstack/projects/$SLUG/.scan-manifest.json + +# Compiled binary preferred, falls back to bun run +$S --root "$PROJECT_ROOT" > "$MANIFEST_PATH" 2>/dev/null +``` + +If scan fails, show product stats only (skip codebase health section). + +### Step 2: Present unified dashboard + +Read PRODUCT_MAP.md and the scan manifest. Format as a single dashboard: + +``` +PRODUCT HEALTH — {project name} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +FEATURES ({total}) + Shipped: {count} + In Review: {count} + Planned: {count} + +CODEBASE + Files: {total_files} (.ts/.tsx) + Lines: {total_lines} + Routes: {route_count} ({page} pages, {api} API, {worker} workers) + +ROUTE COMPLEXITY + EASY: {count} routes ({pct}%) + MEDIUM: {count} routes ({pct}%) + HARD: {count} routes ({pct}%) + MEGA: {count} routes ({pct}%) + +ARCHITECTURE + Circular deps: {count} ({high} HIGH, {med} MEDIUM, {low} LOW) + Dead files: {count} ({high_conf} high confidence) + +PATTERNS ({total}) + {Pattern Name} used by {N} features {healthy ✓ | warn ⚠ | deprecated ✗} + +ANTI-PATTERNS ({total}) + ⛔ {Pattern Name} Tags: [{tags}] + +IDENTITY + {category bars — only if ≥3 features} + ███████████████ {pct}% {category} + ███ {pct}% {category} + +INVENTORY PROGRESS + Mapped: {done}/{total} routes ({pct}%) + Remaining: ~{sessions} sessions estimated + +LAST UPDATED: {breadcrumb timestamp} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +--- + +## Phase 7: Query Mode (`/oracle {question}`) + +{{PRODUCT_CONSCIENCE_READ}} + +Answer the user's question using product map context. + +1. Read PRODUCT_MAP.md (the Product Conscience — Read section above already loaded it). +2. If the question references specific features, read relevant session docs from + `sessions/` for deeper context (Tier 2). +3. Answer with structured product context — cite feature IDs and connections. + +**No-arg query** (`/oracle` with existing product map): Show a product overview — +features, connections, arc, and the identity breakdown. + +### Verify-before-write rule + +If the user asks to update or correct a feature entry via query mode: + +1. **VERIFY FIRST:** Grep the codebase for the components, patterns, or data the user + claims exist. Check that the correction reflects actual code reality. +2. **If code supports the correction** → update the product map entry. +3. **If code does NOT support the correction** → REFUSE and explain: + > "I can't update the map to say {X} because the code shows {Y}. The product map + > only reflects verified code reality. To change the code, plan the change with + > `/office-hours`, build it, then the map updates automatically via `/ship`." + +**The product map is a mirror of reality, not a roadmap.** It documents what IS in the +codebase, not what SHOULD be. Planning and aspirations belong in design docs +(`/office-hours`) and CEO plans (`/plan-ceo-review`), never in the product map. + +--- + +## Corruption Detection + +When reading PRODUCT_MAP.md, check for all 5 required section headers: +- `## Product Arc` +- `## Features` +- `## Reusable Patterns` +- `## Anti-Patterns` +- `## Identity` + +If any are missing, the file may be corrupted. Offer regeneration: +> "Product map appears corrupted (missing {sections}). Run `/oracle refresh` to regenerate?" + +--- + +## PRODUCT_MAP.md Schema Reference + +```markdown + +# Product Map: {project-name} + +## Product Arc +{The story — updated incrementally} + +## Features + +### F001: {Feature Name} [STATUS] +- **Purpose:** {WHY — the user need} +- **Category:** {dynamic — Claude infers from feature purpose} +- **Data:** {tables/models touched} +- **Patterns:** {UI patterns, architecture patterns used} +- **Components:** {key components created} +- **Decisions:** {key decisions and WHY} +- **Connections:** {connections to other features} +- **Depends on:** {hard dependencies} +- **Anti-patterns:** {what failed, with tags} +- **Shipped:** {date} +- **Inventory:** {docs/oracle/inventory/F{NNN}-{feature-slug}.md | none} + +## Reusable Patterns +- **{Name}:** {desc}. Established in {feature}. Also used by {features}. Health: {status}. + +## Anti-Patterns +- **{Name}:** {what, why, alternative}. Tags: [{tags}]. See {feature}. + +## Identity +{Category percentages — suppressed until ≥3 features} +``` + +**Compressed entry format** (for shipped features >3 months, unreferenced): +```markdown +### F001: {Name} [SHIPPED] — {summary}; category: {cat}; patterns: {patterns}; Connections: {ids}; Depends on: {ids}; docs: {docs/oracle/inventory/F001-feature-slug.md | none} +``` + +**Schema versioning:** + +- **Missing `` entirely** = v0 (pre-oracle product map, likely + hand-written or from an earlier tool). Migrate v0 → v1: + 1. Add `` as the first line + 2. Add missing sections with empty defaults: `## Product Arc` (write "No arc recorded yet"), + `## Anti-Patterns` (write "None recorded yet"), `## Identity` (write "Suppressed — fewer + than 3 features") + 3. Add missing fields to existing feature entries: `category` (infer from purpose/components), + `depends_on` (infer from imports/shared tables), `anti-patterns` (default: none) + 4. Preserve ALL existing data — migration is additive only, never remove data + 5. Present the migrated map to the user: "Migrated your product map from v0 to v1. + Added {N} missing sections and {M} missing fields. Review the changes." + +- **``** = current version. No migration needed. diff --git a/oracle/bin/__fixtures__/astro-project/package.json b/oracle/bin/__fixtures__/astro-project/package.json new file mode 100644 index 000000000..35ac4bbfa --- /dev/null +++ b/oracle/bin/__fixtures__/astro-project/package.json @@ -0,0 +1 @@ +{"name":"astro-test","dependencies":{"astro":"^4.0.0"}} diff --git a/oracle/bin/__fixtures__/astro-project/src/pages/about.astro b/oracle/bin/__fixtures__/astro-project/src/pages/about.astro new file mode 100644 index 000000000..ff577150a --- /dev/null +++ b/oracle/bin/__fixtures__/astro-project/src/pages/about.astro @@ -0,0 +1,3 @@ +--- +--- +

About

diff --git a/oracle/bin/__fixtures__/astro-project/src/pages/index.astro b/oracle/bin/__fixtures__/astro-project/src/pages/index.astro new file mode 100644 index 000000000..e61d7be54 --- /dev/null +++ b/oracle/bin/__fixtures__/astro-project/src/pages/index.astro @@ -0,0 +1,3 @@ +--- +--- +

Home

diff --git a/oracle/bin/__fixtures__/css-project/package.json b/oracle/bin/__fixtures__/css-project/package.json new file mode 100644 index 000000000..101e7d498 --- /dev/null +++ b/oracle/bin/__fixtures__/css-project/package.json @@ -0,0 +1,4 @@ +{ + "name": "css-project", + "version": "1.0.0" +} diff --git a/oracle/bin/__fixtures__/css-project/src/index.ts b/oracle/bin/__fixtures__/css-project/src/index.ts new file mode 100644 index 000000000..54451d8dd --- /dev/null +++ b/oracle/bin/__fixtures__/css-project/src/index.ts @@ -0,0 +1,3 @@ +import "./styles/main.css"; + +console.log("CSS project entry point"); diff --git a/oracle/bin/__fixtures__/css-project/src/styles/colors.css b/oracle/bin/__fixtures__/css-project/src/styles/colors.css new file mode 100644 index 000000000..2bc44957e --- /dev/null +++ b/oracle/bin/__fixtures__/css-project/src/styles/colors.css @@ -0,0 +1,5 @@ +:root { + --primary: blue; + --secondary: green; + --background: white; +} diff --git a/oracle/bin/__fixtures__/css-project/src/styles/main.css b/oracle/bin/__fixtures__/css-project/src/styles/main.css new file mode 100644 index 000000000..018a1da1d --- /dev/null +++ b/oracle/bin/__fixtures__/css-project/src/styles/main.css @@ -0,0 +1,7 @@ +@import "./theme.css"; + +body { + margin: 0; + padding: 0; + font-family: sans-serif; +} diff --git a/oracle/bin/__fixtures__/css-project/src/styles/styles.scss b/oracle/bin/__fixtures__/css-project/src/styles/styles.scss new file mode 100644 index 000000000..8d7843f5e --- /dev/null +++ b/oracle/bin/__fixtures__/css-project/src/styles/styles.scss @@ -0,0 +1,6 @@ +@use "./colors.css"; + +.component { + color: var(--primary); + background: var(--background); +} diff --git a/oracle/bin/__fixtures__/css-project/src/styles/theme.css b/oracle/bin/__fixtures__/css-project/src/styles/theme.css new file mode 100644 index 000000000..0baeb13a7 --- /dev/null +++ b/oracle/bin/__fixtures__/css-project/src/styles/theme.css @@ -0,0 +1,6 @@ +@import "./colors.css"; + +.theme { + font-size: 16px; + line-height: 1.5; +} diff --git a/oracle/bin/__fixtures__/deferred-imports/src/class-loader.ts b/oracle/bin/__fixtures__/deferred-imports/src/class-loader.ts new file mode 100644 index 000000000..81b69f48e --- /dev/null +++ b/oracle/bin/__fixtures__/deferred-imports/src/class-loader.ts @@ -0,0 +1,8 @@ +// Class method with dynamic import — deferred (NOT eager) +class Loader { + load() { + return import("./pages/C"); + } +} + +export { Loader }; diff --git a/oracle/bin/__fixtures__/deferred-imports/src/iife.ts b/oracle/bin/__fixtures__/deferred-imports/src/iife.ts new file mode 100644 index 000000000..04f8ccbe5 --- /dev/null +++ b/oracle/bin/__fixtures__/deferred-imports/src/iife.ts @@ -0,0 +1,4 @@ +// IIFE with dynamic import — eager (NOT deferred) +(async () => { + await import("./pages/B"); +})(); diff --git a/oracle/bin/__fixtures__/deferred-imports/src/main.ts b/oracle/bin/__fixtures__/deferred-imports/src/main.ts new file mode 100644 index 000000000..fd478296a --- /dev/null +++ b/oracle/bin/__fixtures__/deferred-imports/src/main.ts @@ -0,0 +1,6 @@ +import { routes } from "./route-map"; + +// Top-level dynamic import — eager (NOT deferred) +import("./pages/A"); + +console.log("Routes:", routes); diff --git a/oracle/bin/__fixtures__/deferred-imports/src/pages/A.tsx b/oracle/bin/__fixtures__/deferred-imports/src/pages/A.tsx new file mode 100644 index 000000000..8343349a9 --- /dev/null +++ b/oracle/bin/__fixtures__/deferred-imports/src/pages/A.tsx @@ -0,0 +1,3 @@ +export default function A() { + return
Page A
; +} diff --git a/oracle/bin/__fixtures__/deferred-imports/src/pages/B.tsx b/oracle/bin/__fixtures__/deferred-imports/src/pages/B.tsx new file mode 100644 index 000000000..dbe45a3a5 --- /dev/null +++ b/oracle/bin/__fixtures__/deferred-imports/src/pages/B.tsx @@ -0,0 +1,3 @@ +export default function B() { + return
Page B
; +} diff --git a/oracle/bin/__fixtures__/deferred-imports/src/pages/C.tsx b/oracle/bin/__fixtures__/deferred-imports/src/pages/C.tsx new file mode 100644 index 000000000..543116848 --- /dev/null +++ b/oracle/bin/__fixtures__/deferred-imports/src/pages/C.tsx @@ -0,0 +1,3 @@ +export default function C() { + return
Page C
; +} diff --git a/oracle/bin/__fixtures__/deferred-imports/src/route-map.ts b/oracle/bin/__fixtures__/deferred-imports/src/route-map.ts new file mode 100644 index 000000000..62e8cf8fb --- /dev/null +++ b/oracle/bin/__fixtures__/deferred-imports/src/route-map.ts @@ -0,0 +1,6 @@ +// All imports here are in arrow functions — deferred (NOT eager) +export const routes = { + a: () => import("./pages/A"), + b: () => import("./pages/B"), + c: () => import("./pages/C"), +}; diff --git a/oracle/bin/__fixtures__/deferred-imports/tsconfig.json b/oracle/bin/__fixtures__/deferred-imports/tsconfig.json new file mode 100644 index 000000000..29bd2bc99 --- /dev/null +++ b/oracle/bin/__fixtures__/deferred-imports/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "module": "esnext", + "target": "es2020", + "moduleResolution": "bundler", + "baseUrl": ".", + "strict": true + }, + "include": ["src"] +} diff --git a/oracle/bin/__fixtures__/empty-project/package.json b/oracle/bin/__fixtures__/empty-project/package.json new file mode 100644 index 000000000..a0d311fba --- /dev/null +++ b/oracle/bin/__fixtures__/empty-project/package.json @@ -0,0 +1,4 @@ +{ + "name": "empty", + "version": "1.0.0" +} diff --git a/oracle/bin/__fixtures__/monorepo-project/package.json b/oracle/bin/__fixtures__/monorepo-project/package.json new file mode 100644 index 000000000..e0df9a912 --- /dev/null +++ b/oracle/bin/__fixtures__/monorepo-project/package.json @@ -0,0 +1,6 @@ +{ + "name": "monorepo", + "version": "1.0.0", + "private": true, + "workspaces": ["packages/*"] +} diff --git a/oracle/bin/__fixtures__/monorepo-project/packages/app/package.json b/oracle/bin/__fixtures__/monorepo-project/packages/app/package.json new file mode 100644 index 000000000..93407f99c --- /dev/null +++ b/oracle/bin/__fixtures__/monorepo-project/packages/app/package.json @@ -0,0 +1,4 @@ +{ + "name": "@mono/app", + "version": "1.0.0" +} diff --git a/oracle/bin/__fixtures__/monorepo-project/packages/ui/package.json b/oracle/bin/__fixtures__/monorepo-project/packages/ui/package.json new file mode 100644 index 000000000..476543dc7 --- /dev/null +++ b/oracle/bin/__fixtures__/monorepo-project/packages/ui/package.json @@ -0,0 +1,4 @@ +{ + "name": "@mono/ui", + "version": "1.0.0" +} diff --git a/oracle/bin/__fixtures__/nextjs-project/app/dashboard/page.tsx b/oracle/bin/__fixtures__/nextjs-project/app/dashboard/page.tsx new file mode 100644 index 000000000..d1f1029d8 --- /dev/null +++ b/oracle/bin/__fixtures__/nextjs-project/app/dashboard/page.tsx @@ -0,0 +1,3 @@ +export default function Dashboard() { + return
Dashboard
; +} diff --git a/oracle/bin/__fixtures__/nextjs-project/app/page.tsx b/oracle/bin/__fixtures__/nextjs-project/app/page.tsx new file mode 100644 index 000000000..aa5883279 --- /dev/null +++ b/oracle/bin/__fixtures__/nextjs-project/app/page.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return
Home
; +} diff --git a/oracle/bin/__fixtures__/nextjs-project/package.json b/oracle/bin/__fixtures__/nextjs-project/package.json new file mode 100644 index 000000000..f967fe538 --- /dev/null +++ b/oracle/bin/__fixtures__/nextjs-project/package.json @@ -0,0 +1,9 @@ +{ + "name": "nextjs-project", + "version": "1.0.0", + "dependencies": { + "next": "^14.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} diff --git a/oracle/bin/__fixtures__/nextjs-project/tsconfig.json b/oracle/bin/__fixtures__/nextjs-project/tsconfig.json new file mode 100644 index 000000000..a81ff3ece --- /dev/null +++ b/oracle/bin/__fixtures__/nextjs-project/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "module": "esnext", + "target": "es2020", + "moduleResolution": "bundler", + "strict": true + }, + "include": ["."] +} diff --git a/oracle/bin/__fixtures__/nuxt-project/package.json b/oracle/bin/__fixtures__/nuxt-project/package.json new file mode 100644 index 000000000..0cfab5f50 --- /dev/null +++ b/oracle/bin/__fixtures__/nuxt-project/package.json @@ -0,0 +1 @@ +{"name":"nuxt-test","dependencies":{"nuxt":"^3.0.0"}} diff --git a/oracle/bin/__fixtures__/nuxt-project/pages/about.vue b/oracle/bin/__fixtures__/nuxt-project/pages/about.vue new file mode 100644 index 000000000..8a0166b3d --- /dev/null +++ b/oracle/bin/__fixtures__/nuxt-project/pages/about.vue @@ -0,0 +1 @@ + diff --git a/oracle/bin/__fixtures__/nuxt-project/pages/index.vue b/oracle/bin/__fixtures__/nuxt-project/pages/index.vue new file mode 100644 index 000000000..ec32009c6 --- /dev/null +++ b/oracle/bin/__fixtures__/nuxt-project/pages/index.vue @@ -0,0 +1 @@ + diff --git a/oracle/bin/__fixtures__/nuxt-project/server/api/hello.ts b/oracle/bin/__fixtures__/nuxt-project/server/api/hello.ts new file mode 100644 index 000000000..7314d2d53 --- /dev/null +++ b/oracle/bin/__fixtures__/nuxt-project/server/api/hello.ts @@ -0,0 +1 @@ +export default defineEventHandler(() => "hello") diff --git a/oracle/bin/__fixtures__/react-router-project/package.json b/oracle/bin/__fixtures__/react-router-project/package.json new file mode 100644 index 000000000..ac213f298 --- /dev/null +++ b/oracle/bin/__fixtures__/react-router-project/package.json @@ -0,0 +1,9 @@ +{ + "name": "react-router-project", + "version": "1.0.0", + "dependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-router-dom": "^6.0.0" + } +} diff --git a/oracle/bin/__fixtures__/react-router-project/src/pages/About.tsx b/oracle/bin/__fixtures__/react-router-project/src/pages/About.tsx new file mode 100644 index 000000000..15a73fb42 --- /dev/null +++ b/oracle/bin/__fixtures__/react-router-project/src/pages/About.tsx @@ -0,0 +1,3 @@ +export default function About() { + return
About Page
; +} diff --git a/oracle/bin/__fixtures__/react-router-project/src/pages/Home.tsx b/oracle/bin/__fixtures__/react-router-project/src/pages/Home.tsx new file mode 100644 index 000000000..6cf02ae7c --- /dev/null +++ b/oracle/bin/__fixtures__/react-router-project/src/pages/Home.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return
Home Page
; +} diff --git a/oracle/bin/__fixtures__/react-router-project/src/pages/Lazy.tsx b/oracle/bin/__fixtures__/react-router-project/src/pages/Lazy.tsx new file mode 100644 index 000000000..51ea6be21 --- /dev/null +++ b/oracle/bin/__fixtures__/react-router-project/src/pages/Lazy.tsx @@ -0,0 +1,3 @@ +export default function Lazy() { + return
Lazy Loaded Page
; +} diff --git a/oracle/bin/__fixtures__/react-router-project/tsconfig.json b/oracle/bin/__fixtures__/react-router-project/tsconfig.json new file mode 100644 index 000000000..8b07495a6 --- /dev/null +++ b/oracle/bin/__fixtures__/react-router-project/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "module": "esnext", + "target": "es2020", + "moduleResolution": "bundler", + "strict": true + }, + "include": ["src"] +} diff --git a/oracle/bin/__fixtures__/remix-project/app/routes/_index.tsx b/oracle/bin/__fixtures__/remix-project/app/routes/_index.tsx new file mode 100644 index 000000000..e612ceabc --- /dev/null +++ b/oracle/bin/__fixtures__/remix-project/app/routes/_index.tsx @@ -0,0 +1 @@ +export default function Index() { return

Home

; } diff --git a/oracle/bin/__fixtures__/remix-project/app/routes/about.tsx b/oracle/bin/__fixtures__/remix-project/app/routes/about.tsx new file mode 100644 index 000000000..ffb53ea2c --- /dev/null +++ b/oracle/bin/__fixtures__/remix-project/app/routes/about.tsx @@ -0,0 +1 @@ +export default function About() { return

About

; } diff --git a/oracle/bin/__fixtures__/remix-project/package.json b/oracle/bin/__fixtures__/remix-project/package.json new file mode 100644 index 000000000..0a5096d2e --- /dev/null +++ b/oracle/bin/__fixtures__/remix-project/package.json @@ -0,0 +1 @@ +{"name":"remix-test","dependencies":{"@remix-run/react":"^2.0.0"}} diff --git a/oracle/bin/__fixtures__/sveltekit-project/package.json b/oracle/bin/__fixtures__/sveltekit-project/package.json new file mode 100644 index 000000000..16837fffc --- /dev/null +++ b/oracle/bin/__fixtures__/sveltekit-project/package.json @@ -0,0 +1 @@ +{"name":"sveltekit-test","dependencies":{"@sveltejs/kit":"^2.0.0"}} diff --git a/oracle/bin/__fixtures__/sveltekit-project/src/routes/+page.svelte b/oracle/bin/__fixtures__/sveltekit-project/src/routes/+page.svelte new file mode 100644 index 000000000..f95bef307 --- /dev/null +++ b/oracle/bin/__fixtures__/sveltekit-project/src/routes/+page.svelte @@ -0,0 +1 @@ +

Home

diff --git a/oracle/bin/__fixtures__/sveltekit-project/src/routes/about/+page.svelte b/oracle/bin/__fixtures__/sveltekit-project/src/routes/about/+page.svelte new file mode 100644 index 000000000..ae068f616 --- /dev/null +++ b/oracle/bin/__fixtures__/sveltekit-project/src/routes/about/+page.svelte @@ -0,0 +1 @@ +

About

diff --git a/oracle/bin/__fixtures__/sveltekit-project/src/routes/api/hello/+server.ts b/oracle/bin/__fixtures__/sveltekit-project/src/routes/api/hello/+server.ts new file mode 100644 index 000000000..5b9b90f62 --- /dev/null +++ b/oracle/bin/__fixtures__/sveltekit-project/src/routes/api/hello/+server.ts @@ -0,0 +1 @@ +export function GET() { return new Response("hi"); } diff --git a/oracle/bin/__fixtures__/tanstack-router-project/package.json b/oracle/bin/__fixtures__/tanstack-router-project/package.json new file mode 100644 index 000000000..564f73b3d --- /dev/null +++ b/oracle/bin/__fixtures__/tanstack-router-project/package.json @@ -0,0 +1 @@ +{"name":"tanstack-test","dependencies":{"@tanstack/react-router":"^1.0.0"}} diff --git a/oracle/bin/__fixtures__/tanstack-router-project/src/routeTree.gen.ts b/oracle/bin/__fixtures__/tanstack-router-project/src/routeTree.gen.ts new file mode 100644 index 000000000..7ce7e1c16 --- /dev/null +++ b/oracle/bin/__fixtures__/tanstack-router-project/src/routeTree.gen.ts @@ -0,0 +1,6 @@ +export const routeTree = { + routes: [ + { path: "/", component: () => import("./routes/index") }, + { path: "/about", component: () => import("./routes/about") }, + ], +}; diff --git a/oracle/bin/__fixtures__/tanstack-router-project/src/routes/index.tsx b/oracle/bin/__fixtures__/tanstack-router-project/src/routes/index.tsx new file mode 100644 index 000000000..f3efb8131 --- /dev/null +++ b/oracle/bin/__fixtures__/tanstack-router-project/src/routes/index.tsx @@ -0,0 +1 @@ +export default function Home() { return

Home

; } diff --git a/oracle/bin/__fixtures__/vite-aliases/package.json b/oracle/bin/__fixtures__/vite-aliases/package.json new file mode 100644 index 000000000..168623ffd --- /dev/null +++ b/oracle/bin/__fixtures__/vite-aliases/package.json @@ -0,0 +1,7 @@ +{ + "name": "vite-aliases", + "version": "1.0.0", + "devDependencies": { + "vite": "^5.0.0" + } +} diff --git a/oracle/bin/__fixtures__/vite-aliases/src/components/index.ts b/oracle/bin/__fixtures__/vite-aliases/src/components/index.ts new file mode 100644 index 000000000..d53f515b7 --- /dev/null +++ b/oracle/bin/__fixtures__/vite-aliases/src/components/index.ts @@ -0,0 +1,2 @@ +// Placeholder for @components alias resolution +export {}; diff --git a/oracle/bin/__fixtures__/vite-aliases/src/index.ts b/oracle/bin/__fixtures__/vite-aliases/src/index.ts new file mode 100644 index 000000000..ef87d70c8 --- /dev/null +++ b/oracle/bin/__fixtures__/vite-aliases/src/index.ts @@ -0,0 +1,2 @@ +// Placeholder for alias resolution testing +export {}; diff --git a/oracle/bin/__fixtures__/vite-aliases/vite.config.ts b/oracle/bin/__fixtures__/vite-aliases/vite.config.ts new file mode 100644 index 000000000..546315d91 --- /dev/null +++ b/oracle/bin/__fixtures__/vite-aliases/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import path from "path"; + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + "@components": path.resolve(__dirname, "src/components"), + }, + }, +}); diff --git a/oracle/bin/__fixtures__/vue-router-project/package.json b/oracle/bin/__fixtures__/vue-router-project/package.json new file mode 100644 index 000000000..3d4d97446 --- /dev/null +++ b/oracle/bin/__fixtures__/vue-router-project/package.json @@ -0,0 +1 @@ +{"name":"vue-test","dependencies":{"vue-router":"^4.0.0"}} diff --git a/oracle/bin/__fixtures__/vue-router-project/src/router/index.ts b/oracle/bin/__fixtures__/vue-router-project/src/router/index.ts new file mode 100644 index 000000000..0b22e2c2d --- /dev/null +++ b/oracle/bin/__fixtures__/vue-router-project/src/router/index.ts @@ -0,0 +1,6 @@ +import { createRouter, createWebHistory } from 'vue-router'; +const routes = [ + { path: '/', component: () => import('../views/Home.vue') }, + { path: '/about', component: () => import('../views/About.vue') }, +]; +export default createRouter({ history: createWebHistory(), routes }); diff --git a/oracle/bin/__fixtures__/vue-router-project/src/views/Home.vue b/oracle/bin/__fixtures__/vue-router-project/src/views/Home.vue new file mode 100644 index 000000000..ec32009c6 --- /dev/null +++ b/oracle/bin/__fixtures__/vue-router-project/src/views/Home.vue @@ -0,0 +1 @@ + diff --git a/oracle/bin/__fixtures__/wouter-project/package.json b/oracle/bin/__fixtures__/wouter-project/package.json new file mode 100644 index 000000000..8e630634a --- /dev/null +++ b/oracle/bin/__fixtures__/wouter-project/package.json @@ -0,0 +1 @@ +{"name":"wouter-test","dependencies":{"wouter":"^3.0.0","react":"^18.0.0"}} diff --git a/oracle/bin/__fixtures__/wouter-project/src/App.tsx b/oracle/bin/__fixtures__/wouter-project/src/App.tsx new file mode 100644 index 000000000..acb87ade9 --- /dev/null +++ b/oracle/bin/__fixtures__/wouter-project/src/App.tsx @@ -0,0 +1,9 @@ +import { Route } from "wouter"; +export default function App() { + return ( + <> + + + + ); +} diff --git a/oracle/bin/__fixtures__/wouter-project/src/pages/About.tsx b/oracle/bin/__fixtures__/wouter-project/src/pages/About.tsx new file mode 100644 index 000000000..ffb53ea2c --- /dev/null +++ b/oracle/bin/__fixtures__/wouter-project/src/pages/About.tsx @@ -0,0 +1 @@ +export default function About() { return

About

; } diff --git a/oracle/bin/__fixtures__/wouter-project/src/pages/Home.tsx b/oracle/bin/__fixtures__/wouter-project/src/pages/Home.tsx new file mode 100644 index 000000000..f3efb8131 --- /dev/null +++ b/oracle/bin/__fixtures__/wouter-project/src/pages/Home.tsx @@ -0,0 +1 @@ +export default function Home() { return

Home

; } diff --git a/oracle/bin/__fixtures__/wouter-project/tsconfig.json b/oracle/bin/__fixtures__/wouter-project/tsconfig.json new file mode 100644 index 000000000..fe4df8536 --- /dev/null +++ b/oracle/bin/__fixtures__/wouter-project/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"jsx":"react-jsx"}} diff --git a/oracle/bin/scan-imports.test.ts b/oracle/bin/scan-imports.test.ts new file mode 100644 index 000000000..7d476cd0d --- /dev/null +++ b/oracle/bin/scan-imports.test.ts @@ -0,0 +1,1553 @@ +/** + * scan-imports.test.ts — Scanner module tests (~55 tests) + * + * Tests all scanner modules: core, aliases, routes, dead-code, css, monorepo, non-ts + * Uses bun:test (built-in, free). Fixture directories in __fixtures__/. + */ + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; + +// ─── Scanner module imports ────────────────────────────────────────────────── +import { + findTsFiles, + buildImportGraph, + unifiedTraversal, + findCircularDeps, + classify, + estimateSessions, + computeContentHash, + findEntryPoints, + isDeferredImport, + getGitCoChangeComplexity, + getGitBornDate, + BASE_BUDGET, + EASY_THRESHOLD, + MEDIUM_THRESHOLD, + MEGA_TRACE_DEPTH_CAP, + MAX_FILE_DISCOVERY_DEPTH, + type FileNode, + type RouteEntry, +} from "./scanner/core"; +import * as os from "os"; + +import { + parseViteAliases, + parseViteAliasesDetailed, +} from "./scanner/aliases"; + +import { + detectFramework, + discoverRoutes, + findPageFileForRoute, + type FrameworkDetectionResult, +} from "./scanner/routes"; + +import { findDeadFiles } from "./scanner/dead-code"; +import { buildCssGraph } from "./scanner/css"; +import { detectMonorepo } from "./scanner/monorepo"; +import { discoverNonTsFiles } from "./scanner/non-ts"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const FIXTURES = path.join(__dirname, "__fixtures__"); + +function makeGraph(entries: Record): Record { + const graph: Record = {}; + for (const [file, { lines, imports, dynamic_imports }] of Object.entries(entries)) { + graph[file] = { + lines, + content_hash: file, + imports, + unresolved_imports: [], + is_css: file.endsWith(".css") || file.endsWith(".scss"), + dynamic_imports, + }; + } + return graph; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CORE MODULE +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("core: classify()", () => { + test("classifies EASY for < 800 lines", () => { + expect(classify(0)).toBe("easy"); + expect(classify(799)).toBe("easy"); + }); + + test("classifies MEDIUM for 800-2499 lines", () => { + expect(classify(800)).toBe("medium"); + expect(classify(2499)).toBe("medium"); + }); + + test("classifies HARD for 2500-3000 lines", () => { + expect(classify(2500)).toBe("hard"); + expect(classify(3000)).toBe("hard"); + }); + + test("classifies MEGA for > 3000 lines", () => { + expect(classify(3001)).toBe("mega"); + expect(classify(10000)).toBe("mega"); + }); +}); + +describe("core: estimateSessions()", () => { + test("counts sessions per tier", () => { + const routes: RouteEntry[] = [ + { path: "/", type: "page", page_file: "a.ts", branch_lines: 400, branch_files: 5, classification: "easy", session_slots: 1, status: "not_started" }, + { path: "/b", type: "page", page_file: "b.ts", branch_lines: 1500, branch_files: 10, classification: "medium", session_slots: 2, status: "not_started" }, + { path: "/c", type: "api", page_file: "c.ts", branch_lines: 4000, branch_files: 20, classification: "mega", session_slots: 3, status: "not_started" }, + ]; + const result = estimateSessions(routes); + expect(result.easy).toBe(1); + expect(result.medium).toBe(2); + expect(result.mega).toBe(3); + expect(result.total_max).toBe(6); + expect(result.total_min).toBeLessThanOrEqual(result.total_max); + }); + + test("skips unknown classification", () => { + const routes: RouteEntry[] = [ + { path: "/x", type: "page", page_file: "x.ts", branch_lines: 0, branch_files: 0, classification: "unknown" as any, session_slots: 5, status: "not_started" }, + ]; + const result = estimateSessions(routes); + expect(result.total_max).toBe(0); + }); +}); + +describe("core: unifiedTraversal()", () => { + test("computes branch membership from route roots", () => { + const graph = makeGraph({ + "src/pages/Home.tsx": { lines: 100, imports: ["src/components/Header.tsx"] }, + "src/components/Header.tsx": { lines: 50, imports: ["src/lib/utils.ts"] }, + "src/lib/utils.ts": { lines: 30, imports: [] }, + "src/pages/About.tsx": { lines: 80, imports: ["src/components/Header.tsx"] }, + }); + + const routeRoots = new Map([ + ["/", "src/pages/Home.tsx"], + ["/about", "src/pages/About.tsx"], + ]); + + const result = unifiedTraversal(graph, routeRoots, []); + + const homeBranch = result.branches.get("/")!; + expect(homeBranch.files.has("src/pages/Home.tsx")).toBe(true); + expect(homeBranch.files.has("src/components/Header.tsx")).toBe(true); + expect(homeBranch.files.has("src/lib/utils.ts")).toBe(true); + expect(homeBranch.fileCount).toBe(3); + expect(homeBranch.totalLines).toBe(180); + + const aboutBranch = result.branches.get("/about")!; + expect(aboutBranch.files.has("src/pages/About.tsx")).toBe(true); + expect(aboutBranch.files.has("src/components/Header.tsx")).toBe(true); + }); + + test("marks all traversed files as reachable", () => { + const graph = makeGraph({ + "a.ts": { lines: 10, imports: ["b.ts"] }, + "b.ts": { lines: 20, imports: [] }, + "dead.ts": { lines: 50, imports: [] }, + }); + const routeRoots = new Map([["/", "a.ts"]]); + const result = unifiedTraversal(graph, routeRoots, []); + expect(result.reachable.has("a.ts")).toBe(true); + expect(result.reachable.has("b.ts")).toBe(true); + expect(result.reachable.has("dead.ts")).toBe(false); + }); + + test("entry points contribute to reachability but not route branches", () => { + const graph = makeGraph({ + "src/main.tsx": { lines: 10, imports: ["src/lib/init.ts"] }, + "src/lib/init.ts": { lines: 20, imports: [] }, + "src/pages/Home.tsx": { lines: 100, imports: [] }, + }); + const routeRoots = new Map([["/", "src/pages/Home.tsx"]]); + const result = unifiedTraversal(graph, routeRoots, ["src/main.tsx"]); + + expect(result.reachable.has("src/main.tsx")).toBe(true); + expect(result.reachable.has("src/lib/init.ts")).toBe(true); + const homeBranch = result.branches.get("/")!; + expect(homeBranch.files.has("src/main.tsx")).toBe(false); + }); + + test("tracks route membership per file", () => { + const graph = makeGraph({ + "shared.ts": { lines: 10, imports: [] }, + "a.ts": { lines: 10, imports: ["shared.ts"] }, + "b.ts": { lines: 10, imports: ["shared.ts"] }, + }); + const routeRoots = new Map([ + ["/a", "a.ts"], + ["/b", "b.ts"], + ]); + const result = unifiedTraversal(graph, routeRoots, []); + const sharedMembership = result.routeMembership.get("shared.ts"); + expect(sharedMembership?.has("/a")).toBe(true); + expect(sharedMembership?.has("/b")).toBe(true); + }); + + test("respects MEGA depth cap", () => { + // Create a deep chain that crosses into MEGA territory (total: 3400L = mega) + const graph = makeGraph({ + "root.ts": { lines: 2500, imports: ["d1.ts"] }, + "d1.ts": { lines: 200, imports: ["d2.ts"] }, + "d2.ts": { lines: 200, imports: ["d3.ts"] }, + "d3.ts": { lines: 200, imports: ["d4.ts"] }, + "d4.ts": { lines: 200, imports: ["d5.ts"] }, + "d5.ts": { lines: 100, imports: [] }, + }); + const routeRoots = new Map([["/mega", "root.ts"]]); + // With depth cap of 4, d5.ts (depth 5) should be excluded from the branch + const result = unifiedTraversal(graph, routeRoots, [], 4); + const branch = result.branches.get("/mega")!; + expect(branch.files.has("root.ts")).toBe(true); // depth 0 + expect(branch.files.has("d1.ts")).toBe(true); // depth 1 + expect(branch.files.has("d4.ts")).toBe(true); // depth 4 (at cap) + expect(branch.files.has("d5.ts")).toBe(false); // depth 5 (beyond cap, pruned) + expect(branch.maxDepth).toBe(4); + }); + + test("post-hoc prune: mega route with many files beyond depth cap", () => { + // Wide tree: root has 3 children, each with children — total well over 3000L + const graph = makeGraph({ + "root.ts": { lines: 1000, imports: ["a1.ts", "b1.ts", "c1.ts"] }, + "a1.ts": { lines: 500, imports: ["a2.ts"] }, + "a2.ts": { lines: 500, imports: ["a3.ts"] }, + "a3.ts": { lines: 300, imports: [] }, // depth 3 + "b1.ts": { lines: 500, imports: ["b2.ts"] }, + "b2.ts": { lines: 300, imports: [] }, // depth 2 + "c1.ts": { lines: 500, imports: ["c2.ts"] }, + "c2.ts": { lines: 400, imports: ["c3.ts"] }, + "c3.ts": { lines: 200, imports: [] }, // depth 3 + }); + // Total: 4200L = mega. With cap of 2, files at depth > 2 pruned + const routeRoots = new Map([["/wide", "root.ts"]]); + const result = unifiedTraversal(graph, routeRoots, [], 2); + const branch = result.branches.get("/wide")!; + expect(branch.files.has("root.ts")).toBe(true); // depth 0 + expect(branch.files.has("a1.ts")).toBe(true); // depth 1 + expect(branch.files.has("a2.ts")).toBe(true); // depth 2 (at cap) + expect(branch.files.has("a3.ts")).toBe(false); // depth 3 (pruned) + expect(branch.files.has("c3.ts")).toBe(false); // depth 3 (pruned) + expect(branch.maxDepth).toBe(2); + }); + + test("non-mega route is NOT depth-capped", () => { + // Total: 160L — well below mega threshold, all depths preserved + const graph = makeGraph({ + "root.ts": { lines: 10, imports: ["d1.ts"] }, + "d1.ts": { lines: 10, imports: ["d2.ts"] }, + "d2.ts": { lines: 10, imports: ["d3.ts"] }, + "d3.ts": { lines: 10, imports: ["d4.ts"] }, + "d4.ts": { lines: 10, imports: ["d5.ts"] }, + "d5.ts": { lines: 10, imports: ["d6.ts"] }, + "d6.ts": { lines: 10, imports: ["d7.ts"] }, + "d7.ts": { lines: 10, imports: ["d8.ts"] }, + "d8.ts": { lines: 10, imports: ["d9.ts"] }, + "d9.ts": { lines: 10, imports: ["d10.ts"] }, + "d10.ts": { lines: 10, imports: ["d11.ts"] }, + "d11.ts": { lines: 10, imports: ["d12.ts"] }, + "d12.ts": { lines: 10, imports: ["d13.ts"] }, + "d13.ts": { lines: 10, imports: ["d14.ts"] }, + "d14.ts": { lines: 10, imports: ["d15.ts"] }, + "d15.ts": { lines: 10, imports: [] }, + }); + const routeRoots = new Map([["/deep", "root.ts"]]); + const result = unifiedTraversal(graph, routeRoots, [], 4); + const branch = result.branches.get("/deep")!; + expect(branch.files.has("d15.ts")).toBe(true); // depth 15, no cap because not mega + expect(branch.maxDepth).toBe(15); + expect(branch.fileCount).toBe(16); + }); + + test("single mega file at root", () => { + const graph = makeGraph({ + "huge.ts": { lines: 3500, imports: ["child.ts"] }, + "child.ts": { lines: 10, imports: [] }, + }); + const routeRoots = new Map([["/huge", "huge.ts"]]); + // Cap at 0 means only depth 0 files kept — child is at depth 1 + // But default cap is 4, so child at depth 1 is fine + const result = unifiedTraversal(graph, routeRoots, [], 4); + const branch = result.branches.get("/huge")!; + expect(branch.files.has("huge.ts")).toBe(true); + expect(branch.files.has("child.ts")).toBe(true); // depth 1, within cap + }); + + test("deferred dynamic import targets are reachable", () => { + const graph = makeGraph({ + "main.ts": { lines: 10, imports: [], dynamic_imports: [ + { expression: "./lazy.ts", resolvable: true, resolved_files: ["lazy.ts"] }, + ] }, + "lazy.ts": { lines: 50, imports: [] }, + }); + const routeRoots = new Map([["/", "main.ts"]]); + const result = unifiedTraversal(graph, routeRoots, []); + expect(result.reachable.has("lazy.ts")).toBe(true); + // lazy.ts should NOT be in the route branch (it's deferred) + const branch = result.branches.get("/")!; + expect(branch.files.has("lazy.ts")).toBe(false); + }); + + test("transitive static imports of dynamic targets are reachable", () => { + // main --(dynamic)--> lazy --(static)--> util + const graph = makeGraph({ + "main.ts": { lines: 10, imports: [], dynamic_imports: [ + { expression: "./lazy.ts", resolvable: true, resolved_files: ["lazy.ts"] }, + ] }, + "lazy.ts": { lines: 50, imports: ["util.ts"] }, + "util.ts": { lines: 20, imports: [] }, + }); + const routeRoots = new Map([["/", "main.ts"]]); + const result = unifiedTraversal(graph, routeRoots, []); + expect(result.reachable.has("lazy.ts")).toBe(true); + expect(result.reachable.has("util.ts")).toBe(true); + }); + + test("transitive dynamic→dynamic chain is reachable", () => { + // main --(dynamic)--> A --(dynamic)--> B + const graph = makeGraph({ + "main.ts": { lines: 10, imports: [], dynamic_imports: [ + { expression: "./A.ts", resolvable: true, resolved_files: ["A.ts"] }, + ] }, + "A.ts": { lines: 30, imports: [], dynamic_imports: [ + { expression: "./B.ts", resolvable: true, resolved_files: ["B.ts"] }, + ] }, + "B.ts": { lines: 20, imports: [] }, + }); + const routeRoots = new Map([["/", "main.ts"]]); + const result = unifiedTraversal(graph, routeRoots, []); + expect(result.reachable.has("A.ts")).toBe(true); + expect(result.reachable.has("B.ts")).toBe(true); + }); + + test("MEGA cap + dynamic reachability: capped files still reachable", () => { + // Mega route: files beyond depth cap are pruned from branch but stay reachable. + // Dynamic imports from pruned files should also be reachable. + const graph = makeGraph({ + "root.ts": { lines: 2500, imports: ["d1.ts"] }, + "d1.ts": { lines: 300, imports: ["d2.ts"] }, + "d2.ts": { lines: 300, imports: [], dynamic_imports: [ + { expression: "./lazy-deep.ts", resolvable: true, resolved_files: ["lazy-deep.ts"] }, + ] }, + "lazy-deep.ts": { lines: 50, imports: [] }, + }); + const routeRoots = new Map([["/mega", "root.ts"]]); + // Cap at 1: d2.ts (depth 2) is pruned from branch + const result = unifiedTraversal(graph, routeRoots, [], 1); + const branch = result.branches.get("/mega")!; + expect(branch.files.has("d2.ts")).toBe(false); // pruned from branch + expect(result.reachable.has("d2.ts")).toBe(true); // but still reachable + expect(result.reachable.has("lazy-deep.ts")).toBe(true); // dynamic target also reachable + }); +}); + +describe("core: findCircularDeps()", () => { + test("detects circular dependency between two files", () => { + const graph = makeGraph({ + "a.ts": { lines: 10, imports: ["b.ts"] }, + "b.ts": { lines: 10, imports: ["a.ts"] }, + }); + const circs = findCircularDeps(graph); + expect(circs.length).toBe(1); + expect(circs[0].cycle_length).toBe(2); + expect(circs[0].severity).toBe("high"); + }); + + test("detects no circular deps in acyclic graph", () => { + const graph = makeGraph({ + "a.ts": { lines: 10, imports: ["b.ts"] }, + "b.ts": { lines: 10, imports: ["c.ts"] }, + "c.ts": { lines: 10, imports: [] }, + }); + const circs = findCircularDeps(graph); + expect(circs.length).toBe(0); + }); + + test("classifies severity by cycle length", () => { + const graph = makeGraph({ + "a.ts": { lines: 10, imports: ["b.ts"] }, + "b.ts": { lines: 10, imports: ["c.ts"] }, + "c.ts": { lines: 10, imports: ["d.ts"] }, + "d.ts": { lines: 10, imports: ["e.ts"] }, + "e.ts": { lines: 10, imports: ["f.ts"] }, + "f.ts": { lines: 10, imports: ["a.ts"] }, + }); + const circs = findCircularDeps(graph); + expect(circs.length).toBe(1); + expect(circs[0].severity).toBe("low"); // 6 files + }); +}); + +describe("core: findTsFiles()", () => { + test("finds TS/TSX files in fixture", () => { + const fixtureRoot = path.join(FIXTURES, "react-router-project"); + if (!fs.existsSync(fixtureRoot)) return; // skip if fixtures not ready + const files = findTsFiles(fixtureRoot); + expect(files.length).toBeGreaterThan(0); + expect(files.some(f => f.endsWith(".tsx"))).toBe(true); + }); + + test("respects max depth", () => { + const fixtureRoot = path.join(FIXTURES, "react-router-project"); + if (!fs.existsSync(fixtureRoot)) return; + const shallow = findTsFiles(fixtureRoot, 0, 0); + // At depth 0, should only find files in root (none expected in react-router fixture) + const deep = findTsFiles(fixtureRoot, 0, 10); + expect(deep.length).toBeGreaterThanOrEqual(shallow.length); + }); + + test("skips node_modules and .git", () => { + const fixtureRoot = path.join(FIXTURES, "react-router-project"); + if (!fs.existsSync(fixtureRoot)) return; + const files = findTsFiles(fixtureRoot); + expect(files.every(f => !f.includes("node_modules"))).toBe(true); + expect(files.every(f => !f.includes(".git/"))).toBe(true); + }); +}); + +describe("core: findEntryPoints()", () => { + test("finds standard entry points", () => { + const graph = makeGraph({ + "src/main.tsx": { lines: 10, imports: [] }, + "src/App.tsx": { lines: 20, imports: [] }, + "src/utils.ts": { lines: 5, imports: [] }, + }); + const entries = findEntryPoints(graph); + expect(entries).toContain("src/main.tsx"); + expect(entries).toContain("src/App.tsx"); + expect(entries).not.toContain("src/utils.ts"); + }); +}); + +describe("core: computeContentHash()", () => { + test("returns consistent hash for same graph", () => { + const graph = makeGraph({ + "a.ts": { lines: 10, imports: [] }, + "b.ts": { lines: 20, imports: [] }, + }); + const h1 = computeContentHash(graph); + const h2 = computeContentHash(graph); + expect(h1).toBe(h2); + expect(h1.length).toBe(16); + }); + + test("returns different hash for different graph", () => { + const g1 = makeGraph({ "a.ts": { lines: 10, imports: [] } }); + const g2 = makeGraph({ "b.ts": { lines: 10, imports: [] } }); + expect(computeContentHash(g1)).not.toBe(computeContentHash(g2)); + }); +}); + +describe("core: constants", () => { + test("constants have expected values", () => { + expect(BASE_BUDGET).toBe(3000); + expect(EASY_THRESHOLD).toBe(800); + expect(MEDIUM_THRESHOLD).toBe(2500); + expect(MEGA_TRACE_DEPTH_CAP).toBe(4); + expect(MAX_FILE_DISCOVERY_DEPTH).toBe(8); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// ALIASES MODULE (#2) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("aliases: parseViteAliases()", () => { + test("returns empty for project with no vite config", () => { + const root = path.join(FIXTURES, "empty-project"); + if (!fs.existsSync(root)) return; + const aliases = parseViteAliases(root); + expect(Object.keys(aliases).length).toBe(0); + }); + + test("parses defineConfig object-style aliases via AST", () => { + const root = path.join(FIXTURES, "vite-aliases"); + if (!fs.existsSync(root)) return; + const result = parseViteAliasesDetailed(root, true); // noEval=true for AST-only + expect(result.aliases["@"]).toBeDefined(); + expect(result.aliases["@components"]).toBeDefined(); + expect(result.method).toBe("ast"); + }); + + test("--no-eval flag forces AST-only mode", () => { + const root = path.join(FIXTURES, "vite-aliases"); + if (!fs.existsSync(root)) return; + const result = parseViteAliasesDetailed(root, true); + expect(result.method).not.toBe("eval"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// ROUTES MODULE (#3) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("routes: detectFramework()", () => { + test("detects React Router from package.json", () => { + const root = path.join(FIXTURES, "react-router-project"); + if (!fs.existsSync(root)) return; + const result = detectFramework(root); + expect(result.framework).toBe("react-router"); + }); + + test("detects Next.js from package.json", () => { + const root = path.join(FIXTURES, "nextjs-project"); + if (!fs.existsSync(root)) return; + const result = detectFramework(root); + expect(["nextjs-pages", "nextjs-app"]).toContain(result.framework); + }); + + test("returns unknown for empty project", () => { + const root = path.join(FIXTURES, "empty-project"); + if (!fs.existsSync(root)) return; + const result = detectFramework(root); + expect(result.framework).toBe("unknown"); + }); +}); + +describe("routes: discoverRoutes()", () => { + test("discovers Next.js file-based routes", () => { + const root = path.join(FIXTURES, "nextjs-project"); + if (!fs.existsSync(root)) return; + const routes = discoverRoutes(root, detectFramework(root)); + expect(routes.length).toBeGreaterThan(0); + // Fixture has app/dashboard/page.tsx → should discover /dashboard/ + const pagePaths = routes.map(r => r.routePath); + expect(pagePaths.some(p => p.includes("dashboard"))).toBe(true); + }); + + test("discovers API routes separately", () => { + const root = path.join(FIXTURES, "nextjs-project"); + if (!fs.existsSync(root)) return; + const routes = discoverRoutes(root, detectFramework(root)); + const apiRoutes = routes.filter(r => r.type === "api"); + expect(apiRoutes.length).toBeGreaterThanOrEqual(0); // may or may not find api routes + }); + + test("returns empty for empty project", () => { + const root = path.join(FIXTURES, "empty-project"); + if (!fs.existsSync(root)) return; + const routes = discoverRoutes(root, detectFramework(root)); + expect(routes.length).toBe(0); + }); +}); + +describe("routes: findPageFileForRoute()", () => { + // findPageFileForRoute(routerContent, routePath, srcDir) reads from filesystem + // We use the react-router-project fixture which has src/pages/{Home,About,Lazy}.tsx + + const fixtureRoot = path.join(FIXTURES, "react-router-project", "src"); + + test("exact case-insensitive match for known route", () => { + if (!fs.existsSync(fixtureRoot)) return; + const match = findPageFileForRoute("", "/home", fixtureRoot); + expect(match).toBeTruthy(); + // Function returns full path — may be case-insensitive on filesystem + expect(match!.toLowerCase()).toContain("home.tsx"); + }); + + test("no substring false positives", () => { + if (!fs.existsSync(fixtureRoot)) return; + // /about should match About.tsx, not AboutExtra.tsx (doesn't exist but tests exact match) + const match = findPageFileForRoute("", "/about", fixtureRoot); + if (match) { + expect(match.toLowerCase()).toContain("about.tsx"); + } + }); + + test("returns null for nonexistent route", () => { + if (!fs.existsSync(fixtureRoot)) return; + const match = findPageFileForRoute("", "/nonexistent-page", fixtureRoot); + expect(match).toBeNull(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// DEAD CODE MODULE (#8) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("dead-code: findDeadFiles()", () => { + test("identifies unreachable files as dead", () => { + const graph = makeGraph({ + "src/main.tsx": { lines: 10, imports: ["src/utils.ts"] }, + "src/utils.ts": { lines: 20, imports: [] }, + "src/orphan.ts": { lines: 30, imports: [] }, + }); + const reachable = new Set(["src/main.tsx", "src/utils.ts"]); + const dead = findDeadFiles(graph, reachable); + expect(dead.length).toBe(1); + expect(dead[0].file).toBe("src/orphan.ts"); + expect(dead[0].lines).toBe(30); + }); + + test("returns empty when all files are reachable", () => { + const graph = makeGraph({ + "src/main.tsx": { lines: 10, imports: [] }, + }); + const reachable = new Set(["src/main.tsx"]); + const dead = findDeadFiles(graph, reachable); + expect(dead.length).toBe(0); + }); + + test("excludes config files from dead detection", () => { + const graph = makeGraph({ + "src/main.tsx": { lines: 10, imports: [] }, + "vite.config.ts": { lines: 50, imports: [] }, + "tailwind.config.ts": { lines: 30, imports: [] }, + }); + const reachable = new Set(["src/main.tsx"]); + const dead = findDeadFiles(graph, reachable); + // Config files should not be reported as dead + const deadFiles = dead.map(d => d.file); + expect(deadFiles).not.toContain("vite.config.ts"); + expect(deadFiles).not.toContain("tailwind.config.ts"); + }); + + test("barrel file exclusion recognizes index.ts with re-exports", () => { + const graph: Record = { + "src/main.tsx": { lines: 10, content_hash: "a", imports: [], unresolved_imports: [] }, + "src/components/index.ts": { + lines: 5, + content_hash: "b", + imports: ["src/components/Button.tsx", "src/components/Card.tsx"], + unresolved_imports: [], + }, + "src/components/Button.tsx": { lines: 50, content_hash: "c", imports: [], unresolved_imports: [] }, + "src/components/Card.tsx": { lines: 40, content_hash: "d", imports: [], unresolved_imports: [] }, + }; + const reachable = new Set(["src/main.tsx"]); + const dead = findDeadFiles(graph, reachable); + const deadFiles = dead.map(d => d.file); + // Barrel index.ts should not be flagged as dead + expect(deadFiles).not.toContain("src/components/index.ts"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CSS MODULE (#9) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("css: buildCssGraph()", () => { + test("discovers CSS files and parses @import", () => { + const root = path.join(FIXTURES, "css-project"); + if (!fs.existsSync(root)) return; + const cssGraph = buildCssGraph(root, {}); + const files = Object.keys(cssGraph); + expect(files.length).toBeGreaterThan(0); + // Should have found main.css and its imports + const mainCss = files.find(f => f.includes("main.css")); + expect(mainCss).toBeDefined(); + if (mainCss) { + expect(cssGraph[mainCss].is_css).toBe(true); + expect(cssGraph[mainCss].imports.length).toBeGreaterThan(0); + } + }); + + test("parses SCSS @use directives", () => { + const root = path.join(FIXTURES, "css-project"); + if (!fs.existsSync(root)) return; + const cssGraph = buildCssGraph(root, {}); + const scssFile = Object.keys(cssGraph).find(f => f.endsWith(".scss")); + if (scssFile) { + expect(cssGraph[scssFile].is_css).toBe(true); + } + }); + + test("CSS nodes have is_css flag", () => { + const root = path.join(FIXTURES, "css-project"); + if (!fs.existsSync(root)) return; + const cssGraph = buildCssGraph(root, {}); + for (const node of Object.values(cssGraph)) { + expect(node.is_css).toBe(true); + } + }); + + test("CSS edges contribute to unified graph", () => { + const root = path.join(FIXTURES, "css-project"); + if (!fs.existsSync(root)) return; + const cssGraph = buildCssGraph(root, {}); + // theme.css imports colors.css + const themeFile = Object.keys(cssGraph).find(f => f.includes("theme.css")); + if (themeFile) { + const colorsImport = cssGraph[themeFile].imports.find(i => i.includes("colors.css")); + expect(colorsImport).toBeDefined(); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// MONOREPO MODULE (#10) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("monorepo: detectMonorepo()", () => { + test("detects npm workspaces from package.json", () => { + const root = path.join(FIXTURES, "monorepo-project"); + if (!fs.existsSync(root)) return; + const info = detectMonorepo(root); + expect(info.detected).toBe(true); + expect(info.type).toBe("npm"); + expect(info.packages.length).toBeGreaterThan(0); + }); + + test("returns detected=false for non-monorepo", () => { + const root = path.join(FIXTURES, "empty-project"); + if (!fs.existsSync(root)) return; + const info = detectMonorepo(root); + expect(info.detected).toBe(false); + expect(info.packages.length).toBe(0); + }); + + test("finds workspace packages", () => { + const root = path.join(FIXTURES, "monorepo-project"); + if (!fs.existsSync(root)) return; + const info = detectMonorepo(root); + if (info.detected) { + // Should find at least 2 packages (ui, app) + expect(info.packages.length).toBeGreaterThanOrEqual(2); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// NON-TS MODULE (#1) +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("non-ts: discoverNonTsFiles()", () => { + test("returns empty for TypeScript-only project", () => { + const root = path.join(FIXTURES, "react-router-project"); + if (!fs.existsSync(root)) return; + const files = discoverNonTsFiles(root); + // React Router fixture has only .tsx files — no non-TS files + expect(files.every(f => f.language !== "typescript")).toBe(true); + }); + + test("counts lines accurately", () => { + const files = discoverNonTsFiles(FIXTURES); + for (const f of files) { + expect(f.lines).toBeGreaterThan(0); + expect(f.language).toBeTruthy(); + expect(f.file).toBeTruthy(); + } + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// INTEGRATION: Full pipeline on fixtures +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("integration: css-project pipeline", () => { + test("CSS files appear in unified graph with TS files", () => { + const root = path.join(FIXTURES, "css-project"); + if (!fs.existsSync(root)) return; + + // Build TS graph + const tsFiles = findTsFiles(root); + // Build CSS graph + const cssGraph = buildCssGraph(root, {}); + + // Both should exist + expect(tsFiles.length).toBeGreaterThan(0); + expect(Object.keys(cssGraph).length).toBeGreaterThan(0); + }); +}); + +// ─── Deferred Dynamic Import Tests ────────────────────────────────────────── +import * as ts from "typescript"; + +/** Helper: parse a TS snippet, find the import() CallExpression, and run isDeferredImport */ +function checkDeferred(code: string): boolean { + const sf = ts.createSourceFile("test.ts", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + let result: boolean | null = null; + + function visit(node: ts.Node) { + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword + ) { + result = isDeferredImport(node); + return; // found it + } + ts.forEachChild(node, visit); + } + visit(sf); + + if (result === null) throw new Error("No import() found in snippet"); + return result; +} + +describe("isDeferredImport", () => { + test("top-level import() is NOT deferred (eager)", () => { + expect(checkDeferred(`import('./foo');`)).toBe(false); + }); + + test("arrow function body import() IS deferred", () => { + expect(checkDeferred(`const fn = () => import('./foo');`)).toBe(true); + }); + + test("function expression body import() IS deferred", () => { + expect(checkDeferred(`const fn = function() { return import('./foo'); };`)).toBe(true); + }); + + test("class method body import() IS deferred", () => { + expect(checkDeferred(`class C { load() { return import('./foo'); } }`)).toBe(true); + }); + + test("IIFE import() is NOT deferred (eager)", () => { + expect(checkDeferred(`(async () => { await import('./foo'); })();`)).toBe(false); + }); + + test("stops at SourceFile boundary — top-level is eager", () => { + expect(checkDeferred(`const x = import('./foo');`)).toBe(false); + }); +}); + +describe("buildImportGraph — deferred dynamic imports", () => { + const DEFERRED_FIXTURE = path.join(FIXTURES, "deferred-imports"); + const tsconfigPath = path.join(DEFERRED_FIXTURE, "tsconfig.json"); + + test("route-map () => import() values NOT in static imports", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + const routeMap = graph["src/route-map.ts"]; + expect(routeMap).toBeDefined(); + // Static imports should NOT contain any pages (they're all in arrow functions) + const pageImports = routeMap.imports.filter(i => i.includes("pages/")); + expect(pageImports).toHaveLength(0); + }); + + test("route-map () => import() values ARE in dynamic_imports", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + const routeMap = graph["src/route-map.ts"]; + expect(routeMap).toBeDefined(); + expect(routeMap.dynamic_imports).toBeDefined(); + const dynamicPages = routeMap.dynamic_imports!.filter(d => d.expression.includes("pages/")); + expect(dynamicPages.length).toBeGreaterThanOrEqual(3); // A, B, C + expect(dynamicPages.every(d => d.resolvable)).toBe(true); + }); + + test("top-level import() still in static imports (eager)", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + const main = graph["src/main.ts"]; + expect(main).toBeDefined(); + // main.ts has top-level import('./pages/A') — should be in static imports + const pageImports = main.imports.filter(i => i.includes("pages/A")); + expect(pageImports.length).toBeGreaterThanOrEqual(1); + }); + + test("IIFE import() still in static imports (eager)", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + const iife = graph["src/iife.ts"]; + expect(iife).toBeDefined(); + // iife.ts has (async () => { await import('./pages/B') })() — IIFE = eager + const pageImports = iife.imports.filter(i => i.includes("pages/B")); + expect(pageImports.length).toBeGreaterThanOrEqual(1); + }); + + test("static import X from '...' is unaffected", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + const main = graph["src/main.ts"]; + expect(main).toBeDefined(); + // main.ts has: import { routes } from './route-map' — always static + const routeMapImport = main.imports.filter(i => i.includes("route-map")); + expect(routeMapImport.length).toBeGreaterThanOrEqual(1); + }); + + test("class method import() NOT in static imports (deferred)", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + const classLoader = graph["src/class-loader.ts"]; + expect(classLoader).toBeDefined(); + // class method body — deferred + const pageImports = classLoader.imports.filter(i => i.includes("pages/")); + expect(pageImports).toHaveLength(0); + // But should be in dynamic_imports + expect(classLoader.dynamic_imports).toBeDefined(); + const dynamicPages = classLoader.dynamic_imports!.filter(d => d.expression.includes("pages/")); + expect(dynamicPages.length).toBeGreaterThanOrEqual(1); + }); + + test("branch_lines for page importing route-map is NOT inflated", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + + // Simulate: main.ts imports route-map.ts. Without the fix, route-map's + // lazy imports would pull in all pages. With the fix, only eager deps count. + const routeRoots = new Map(); + routeRoots.set("/main", "src/main.ts"); + + const entryPoints = ["src/main.ts"]; + const { branches } = unifiedTraversal(graph, routeRoots, entryPoints); + + const mainBranch = branches.get("/main"); + expect(mainBranch).toBeDefined(); + + // Total lines in fixture is small, but the key assertion: + // main → route-map should NOT pull in pages B and C (only A via top-level import) + const files = [...mainBranch!.files]; + const hasPageB = files.some(f => f.includes("pages/B")); + const hasPageC = files.some(f => f.includes("pages/C")); + expect(hasPageB).toBe(false); // B is only in IIFE (separate file) and route-map (deferred) + expect(hasPageC).toBe(false); // C is only in class-loader (deferred) + }); + + test("dynamic_imports metadata preserved for all patterns", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + + // Every file with import() should have dynamic_imports entries + const routeMap = graph["src/route-map.ts"]; + expect(routeMap.dynamic_imports).toBeDefined(); + expect(routeMap.dynamic_imports!.length).toBeGreaterThanOrEqual(3); + + const main = graph["src/main.ts"]; + expect(main.dynamic_imports).toBeDefined(); + expect(main.dynamic_imports!.length).toBeGreaterThanOrEqual(1); + + const iife = graph["src/iife.ts"]; + expect(iife.dynamic_imports).toBeDefined(); + expect(iife.dynamic_imports!.length).toBeGreaterThanOrEqual(1); + + const classLoader = graph["src/class-loader.ts"]; + expect(classLoader.dynamic_imports).toBeDefined(); + expect(classLoader.dynamic_imports!.length).toBeGreaterThanOrEqual(1); + }); + + test("resolved_files populated for deferred imports", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + + // route-map has 3 deferred imports: pages/A, B, C + const routeMap = graph["src/route-map.ts"]; + expect(routeMap.dynamic_imports).toBeDefined(); + const resolvedRouteMapDynImports = routeMap.dynamic_imports!.filter( + d => d.resolved_files && d.resolved_files.length > 0 + ); + // All 3 page imports should have resolved_files populated + expect(resolvedRouteMapDynImports.length).toBe(3); + const resolvedPaths = resolvedRouteMapDynImports.flatMap(d => d.resolved_files!); + expect(resolvedPaths.some(f => f.includes("pages/A"))).toBe(true); + expect(resolvedPaths.some(f => f.includes("pages/B"))).toBe(true); + expect(resolvedPaths.some(f => f.includes("pages/C"))).toBe(true); + + // class-loader has 1 deferred import: pages/C + const classLoader = graph["src/class-loader.ts"]; + const resolvedClassLoaderDynImports = classLoader.dynamic_imports!.filter( + d => d.resolved_files && d.resolved_files.length > 0 + ); + expect(resolvedClassLoaderDynImports.length).toBe(1); + expect(resolvedClassLoaderDynImports[0].resolved_files![0]).toContain("pages/C"); + }); + + test("integration: deferred-imports fixture all pages reachable", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + + // Set up routes: main.ts as the only route root + const routeRoots = new Map(); + routeRoots.set("/main", "src/main.ts"); + + // Entry points include main.ts, iife.ts (top-level side effect file) + const entryPoints = ["src/main.ts", "src/iife.ts"]; + const { reachable } = unifiedTraversal(graph, routeRoots, entryPoints); + + // Page A: reachable via main.ts eager top-level import() + expect(reachable.has("src/pages/A.tsx")).toBe(true); + // Page B: reachable via iife.ts (IIFE = eager) OR via route-map dynamic_imports.resolved_files + expect(reachable.has("src/pages/B.tsx")).toBe(true); + // Page C: reachable via route-map or class-loader dynamic_imports.resolved_files + expect(reachable.has("src/pages/C.tsx")).toBe(true); + + // Core fixture files reachable via entry points and static imports + expect(reachable.has("src/main.ts")).toBe(true); + expect(reachable.has("src/route-map.ts")).toBe(true); + expect(reachable.has("src/iife.ts")).toBe(true); + // class-loader.ts is NOT imported by any entry point — legitimately unreachable + // (in a real app it would be imported somewhere; here it's a standalone fixture file) + }); + + test("resolveAndAddImport refactor parity — static imports produce same graph", () => { + const { graph } = buildImportGraph(DEFERRED_FIXTURE, tsconfigPath, {}); + + // main.ts has a static import of route-map — must be in imports[] + const main = graph["src/main.ts"]; + expect(main.imports).toContain("src/route-map.ts"); + + // main.ts has an eager top-level import() of pages/A — must be in imports[] + expect(main.imports).toContain("src/pages/A.tsx"); + + // route-map.ts has NO static imports (all are deferred arrow functions) + const routeMap = graph["src/route-map.ts"]; + const pageImports = routeMap.imports.filter(i => i.includes("pages/")); + expect(pageImports).toHaveLength(0); + + // iife.ts: IIFE import() is eager — pages/B should be in static imports + const iife = graph["src/iife.ts"]; + expect(iife.imports).toContain("src/pages/B.tsx"); + + // class-loader.ts: class method import() is deferred — pages/C should NOT be in static imports + const classLoader = graph["src/class-loader.ts"]; + const classLoaderPageImports = classLoader.imports.filter(i => i.includes("pages/")); + expect(classLoaderPageImports).toHaveLength(0); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// ADDITIONAL COVERAGE TESTS +// ═══════════════════════════════════════════════════════════════════════════════ + +describe("routes: discoverRoutes() on react-router-project", () => { + test("discovers pages from src/pages/ directory", () => { + const root = path.join(FIXTURES, "react-router-project"); + if (!fs.existsSync(root)) return; + const detection = detectFramework(root); + expect(detection.framework).toBe("react-router"); + const routes = discoverRoutes(root, detection); + expect(routes.length).toBeGreaterThan(0); + const paths = routes.map(r => r.routePath); + expect(paths).toContain("/home"); + expect(paths).toContain("/about"); + expect(paths).toContain("/lazy"); + }); +}); + +describe("routes: Supabase Edge Function detection", () => { + const EDGE_FN_ROOT = path.join(FIXTURES, "supabase-edge-functions"); + + beforeAll(() => { + // Create temp fixture for Edge Functions + fs.mkdirSync(path.join(EDGE_FN_ROOT, "supabase", "functions", "hello-world"), { recursive: true }); + fs.mkdirSync(path.join(EDGE_FN_ROOT, "supabase", "functions", "send-email"), { recursive: true }); + fs.mkdirSync(path.join(EDGE_FN_ROOT, "supabase", "functions", "_shared"), { recursive: true }); + fs.writeFileSync(path.join(EDGE_FN_ROOT, "supabase", "functions", "hello-world", "index.ts"), "export default () => new Response('ok');"); + fs.writeFileSync(path.join(EDGE_FN_ROOT, "supabase", "functions", "send-email", "index.ts"), "export default () => new Response('sent');"); + fs.writeFileSync(path.join(EDGE_FN_ROOT, "package.json"), '{"name":"edge-test"}'); + }); + + afterAll(() => { + fs.rmSync(EDGE_FN_ROOT, { recursive: true, force: true }); + }); + + test("discovers Edge Functions as API routes", () => { + const detection: FrameworkDetectionResult = { framework: "unknown" }; + const routes = discoverRoutes(EDGE_FN_ROOT, detection); + const apiRoutes = routes.filter(r => r.type === "api"); + expect(apiRoutes.length).toBe(2); + expect(apiRoutes.some(r => r.routePath.includes("hello-world"))).toBe(true); + expect(apiRoutes.some(r => r.routePath.includes("send-email"))).toBe(true); + // _shared directory should be skipped (starts with _) + expect(apiRoutes.some(r => r.routePath.includes("_shared"))).toBe(false); + }); +}); + +describe("routes: graceful framework detection for unknown frameworks", () => { + test("Remix project returns unknown (not crash)", () => { + const tmpDir = path.join(os.tmpdir(), "remix-test-" + Date.now()); + fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "package.json"), JSON.stringify({ + dependencies: { "@remix-run/react": "^2.0.0" }, + })); + const result = detectFramework(tmpDir); + expect(result.framework).toBe("remix"); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("SvelteKit project detected as sveltekit", () => { + const tmpDir = path.join(os.tmpdir(), "sveltekit-test-" + Date.now()); + fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "package.json"), JSON.stringify({ + devDependencies: { "@sveltejs/kit": "^2.0.0" }, + })); + const result = detectFramework(tmpDir); + expect(result.framework).toBe("sveltekit"); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); +}); + +describe("aliases: eval fallback", () => { + test("parseViteAliasesDetailed with noEval=false uses AST or eval", () => { + const root = path.join(FIXTURES, "vite-aliases"); + if (!fs.existsSync(root)) return; + const result = parseViteAliasesDetailed(root, false); + // Either AST or eval should work — both should find the aliases + expect(result.aliases["@"]).toBeDefined(); + expect(result.aliases["@components"]).toBeDefined(); + }); +}); + +describe("routes: React Router full pipeline integration", () => { + test("detectFramework → discoverRoutes produces valid route entries", () => { + const root = path.join(FIXTURES, "react-router-project"); + if (!fs.existsSync(root)) return; + + const detection = detectFramework(root); + expect(detection.framework).toBe("react-router"); + + const routes = discoverRoutes(root, detection); + expect(routes.length).toBeGreaterThanOrEqual(3); // Home, About, Lazy + + for (const route of routes) { + expect(route.routePath.startsWith("/")).toBe(true); + expect(route.type).toBe("page"); + expect(route.pageFile).toBeTruthy(); + // Page file should exist on disk + expect(fs.existsSync(path.join(root, route.pageFile))).toBe(true); + } + }); +}); + +// ─── Git Co-Change Complexity Tests ───────────────────────────────────────── +describe("core: getGitCoChangeComplexity()", () => { + let tmpDir: string; + + // Create a temp git repo with controlled commit history + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-cochange-")); + const run = (cmd: string) => { + const result = Bun.spawnSync(["sh", "-c", cmd], { cwd: tmpDir }); + if (result.exitCode !== 0) throw new Error(`Command failed: ${cmd}\n${result.stderr.toString()}`); + }; + + run("git init"); + run("git config user.email test@test.com && git config user.name Test"); + + // Commit 1: Home + useHome (feature-specific pair) + fs.mkdirSync(path.join(tmpDir, "pages"), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, "hooks"), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, "utils"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "pages/Home.tsx"), "// Home page\n".repeat(50)); + fs.writeFileSync(path.join(tmpDir, "hooks/useHome.ts"), "// useHome hook\n".repeat(30)); + run("git add -A && git commit -m 'add Home + useHome'"); + + // Commit 2: Home + shared.ts + fs.writeFileSync(path.join(tmpDir, "utils/shared.ts"), "// shared utils\n".repeat(100)); + fs.writeFileSync(path.join(tmpDir, "pages/Home.tsx"), "// Home page v2\n".repeat(55)); + run("git add -A && git commit -m 'add shared, update Home'"); + + // Commit 3: About + shared.ts (shared now co-changes with 2 pages) + fs.writeFileSync(path.join(tmpDir, "pages/About.tsx"), "// About page\n".repeat(40)); + fs.writeFileSync(path.join(tmpDir, "utils/shared.ts"), "// shared utils v2\n".repeat(110)); + run("git add -A && git commit -m 'add About + update shared'"); + + // Commit 4: About + useAbout (feature-specific pair) + fs.writeFileSync(path.join(tmpDir, "hooks/useAbout.ts"), "// useAbout hook\n".repeat(20)); + fs.writeFileSync(path.join(tmpDir, "pages/About.tsx"), "// About page v2\n".repeat(45)); + run("git add -A && git commit -m 'add useAbout'"); + + // Commit 5: Settings + shared (shared now co-changes with 3 pages) + fs.writeFileSync(path.join(tmpDir, "pages/Settings.tsx"), "// Settings page\n".repeat(35)); + fs.writeFileSync(path.join(tmpDir, "utils/shared.ts"), "// shared utils v3\n".repeat(120)); + run("git add -A && git commit -m 'add Settings + update shared'"); + + // Commit 6: Add a non-source file alongside Home + fs.writeFileSync(path.join(tmpDir, "pages/Home.tsx"), "// Home page v3\n".repeat(60)); + fs.writeFileSync(path.join(tmpDir, "README.md"), "# Readme"); + fs.writeFileSync(path.join(tmpDir, "config.json"), "{}"); + run("git add -A && git commit -m 'update Home + add non-source files'"); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("finds feature-specific co-changed files", () => { + const result = getGitCoChangeComplexity(tmpDir, [ + "pages/Home.tsx", "pages/About.tsx", "pages/Settings.tsx", + ]); + const home = result.get("pages/Home.tsx")!; + expect(home.coChangedFiles).toContain("pages/Home.tsx"); + expect(home.coChangedFiles).toContain("hooks/useHome.ts"); + }); + + test("excludes shared files by breadth threshold", () => { + // shared.ts co-changes with all 3 pages → breadth 3 ≥ threshold max(3, 3*0.25=0) = 3 + const result = getGitCoChangeComplexity(tmpDir, [ + "pages/Home.tsx", "pages/About.tsx", "pages/Settings.tsx", + ]); + const home = result.get("pages/Home.tsx")!; + expect(home.coChangedFiles).not.toContain("utils/shared.ts"); + + const about = result.get("pages/About.tsx")!; + expect(about.coChangedFiles).not.toContain("utils/shared.ts"); + }); + + test("always includes the page file itself", () => { + const result = getGitCoChangeComplexity(tmpDir, ["pages/Settings.tsx"]); + const settings = result.get("pages/Settings.tsx")!; + expect(settings.coChangedFiles).toContain("pages/Settings.tsx"); + expect(settings.lines).toBeGreaterThan(0); + expect(settings.files).toBeGreaterThanOrEqual(1); + }); + + test("handles non-git directory gracefully", () => { + const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-nogit-")); + fs.writeFileSync(path.join(nonGitDir, "page.tsx"), "// page\n".repeat(10)); + try { + const result = getGitCoChangeComplexity(nonGitDir, ["page.tsx"]); + const page = result.get("page.tsx")!; + // "// page\n".repeat(10) → 10 lines + trailing newline → split("\n").length = 11 + expect(page.lines).toBe(11); + expect(page.files).toBe(1); + expect(page.coChangedFiles).toEqual(["page.tsx"]); + } finally { + fs.rmSync(nonGitDir, { recursive: true, force: true }); + } + }); + + test("filters non-source files (images, configs)", () => { + // Home commit 6 includes README.md and config.json — these should not appear + const result = getGitCoChangeComplexity(tmpDir, ["pages/Home.tsx"]); + const home = result.get("pages/Home.tsx")!; + for (const f of home.coChangedFiles) { + expect(f).toMatch(/\.(tsx?|jsx?|vue|svelte|py|rb|go|rs|php|ex|exs)$/); + } + }); +}); + +describe("core: getGitBornDate()", () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-borndate-")); + const run = (cmd: string) => { + const result = Bun.spawnSync(["sh", "-c", cmd], { cwd: tmpDir }); + if (result.exitCode !== 0) throw new Error(`Command failed: ${cmd}`); + }; + run("git init && git config user.email test@test.com && git config user.name Test"); + + fs.writeFileSync(path.join(tmpDir, "first.ts"), "// first"); + run("git add -A && git commit -m 'first commit'"); + + // Wait 1 second for distinct timestamps + Bun.sleepSync(1000); + + fs.writeFileSync(path.join(tmpDir, "second.ts"), "// second"); + run("git add -A && git commit -m 'second commit'"); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns earliest commit timestamp", () => { + const dates = getGitBornDate(tmpDir, ["first.ts", "second.ts"]); + const firstDate = dates.get("first.ts")!; + const secondDate = dates.get("second.ts")!; + expect(firstDate).toBeGreaterThan(0); + expect(secondDate).toBeGreaterThan(0); + expect(firstDate).toBeLessThan(secondDate); + }); + + test("handles non-git directory", () => { + const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-nogit-bd-")); + try { + const dates = getGitBornDate(nonGitDir, ["nonexistent.ts"]); + // Should return epoch 0 for files in non-git dirs + expect(dates.get("nonexistent.ts") ?? 0).toBe(0); + } finally { + fs.rmSync(nonGitDir, { recursive: true, force: true }); + } + }); +}); + +// ─── New Framework Detection Tests ───────────────────────────────────────── + +describe("routes: SvelteKit detection and discovery", () => { + const fixture = path.join(FIXTURES, "sveltekit-project"); + + test("detects SvelteKit framework", () => { + if (!fs.existsSync(fixture)) return; + const result = detectFramework(fixture); + expect(result.framework).toBe("sveltekit"); + }); + + test("discovers SvelteKit page routes", () => { + if (!fs.existsSync(fixture)) return; + const detection = detectFramework(fixture); + const routes = discoverRoutes(fixture, detection); + expect(routes.length).toBeGreaterThan(0); + expect(routes.some(r => r.routePath === "/")).toBe(true); + }); +}); + +describe("routes: Nuxt detection and discovery", () => { + const fixture = path.join(FIXTURES, "nuxt-project"); + + test("detects Nuxt framework", () => { + if (!fs.existsSync(fixture)) return; + const result = detectFramework(fixture); + expect(result.framework).toBe("nuxt"); + }); + + test("discovers Nuxt page routes", () => { + if (!fs.existsSync(fixture)) return; + const detection = detectFramework(fixture); + const routes = discoverRoutes(fixture, detection); + expect(routes.length).toBeGreaterThan(0); + const pageRoutes = routes.filter(r => r.type === "page"); + expect(pageRoutes.length).toBeGreaterThanOrEqual(1); + }); + + test("discovers Nuxt server API routes", () => { + if (!fs.existsSync(fixture)) return; + const detection = detectFramework(fixture); + const routes = discoverRoutes(fixture, detection); + const apiRoutes = routes.filter(r => r.type === "api"); + expect(apiRoutes.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe("routes: Remix detection and discovery", () => { + const fixture = path.join(FIXTURES, "remix-project"); + + test("detects Remix framework", () => { + if (!fs.existsSync(fixture)) return; + const result = detectFramework(fixture); + expect(result.framework).toBe("remix"); + }); + + test("discovers Remix routes", () => { + if (!fs.existsSync(fixture)) return; + const detection = detectFramework(fixture); + const routes = discoverRoutes(fixture, detection); + expect(routes.length).toBeGreaterThan(0); + }); +}); + +describe("routes: Astro detection and discovery", () => { + const fixture = path.join(FIXTURES, "astro-project"); + + test("detects Astro framework", () => { + if (!fs.existsSync(fixture)) return; + const result = detectFramework(fixture); + expect(result.framework).toBe("astro"); + }); + + test("discovers Astro page routes", () => { + if (!fs.existsSync(fixture)) return; + const detection = detectFramework(fixture); + const routes = discoverRoutes(fixture, detection); + expect(routes.length).toBeGreaterThan(0); + expect(routes.some(r => r.routePath === "/")).toBe(true); + }); +}); + +describe("routes: TanStack Router detection and discovery", () => { + const fixture = path.join(FIXTURES, "tanstack-router-project"); + + test("detects TanStack Router framework", () => { + if (!fs.existsSync(fixture)) return; + const result = detectFramework(fixture); + expect(result.framework).toBe("tanstack-router"); + }); + + test("discovers TanStack Router routes from routeTree", () => { + if (!fs.existsSync(fixture)) return; + const detection = detectFramework(fixture); + const routes = discoverRoutes(fixture, detection); + expect(routes.length).toBeGreaterThan(0); + }); +}); + +describe("routes: Vue Router detection and discovery", () => { + const fixture = path.join(FIXTURES, "vue-router-project"); + + test("detects Vue Router framework", () => { + if (!fs.existsSync(fixture)) return; + const result = detectFramework(fixture); + expect(result.framework).toBe("vue-router"); + }); + + test("discovers Vue Router routes from config", () => { + if (!fs.existsSync(fixture)) return; + const detection = detectFramework(fixture); + const routes = discoverRoutes(fixture, detection); + expect(routes.length).toBeGreaterThan(0); + expect(routes.some(r => r.routePath === "/")).toBe(true); + expect(routes.some(r => r.routePath === "/about")).toBe(true); + }); +}); + +// ─── Aliases: tsconfig.json fallback ─────────────────────────────────────── + +import { parseTsconfigPaths, resolveAliases } from "./scanner/aliases"; + +describe("routes: Wouter detection and discovery", () => { + const fixture = path.join(FIXTURES, "wouter-project"); + + test("detects Wouter framework", () => { + if (!fs.existsSync(fixture)) return; + const result = detectFramework(fixture); + expect(result.framework).toBe("wouter"); + }); + + test("discovers Wouter routes from src/pages", () => { + if (!fs.existsSync(fixture)) return; + const detection = detectFramework(fixture); + const routes = discoverRoutes(fixture, detection); + expect(routes.length).toBeGreaterThan(0); + }); +}); + +// ─── Aliases: tsconfig.json fallback ───────────��─────────────────────────── + +describe("aliases: tsconfig.json paths fallback", () => { + test("parseTsconfigPaths returns empty for missing tsconfig", () => { + const result = parseTsconfigPaths("/nonexistent"); + expect(Object.keys(result)).toHaveLength(0); + }); + + test("parseTsconfigPaths parses paths from tsconfig", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-tsconfig-")); + fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { "@/*": ["./src/*"], "@components/*": ["./src/components/*"] }, + }, + })); + const result = parseTsconfigPaths(tmpDir); + expect(result["@"]).toContain("src"); + expect(result["@components"]).toContain("components"); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("resolveAliases falls back to tsconfig when no vite config", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-alias-fb-")); + fs.writeFileSync(path.join(tmpDir, "tsconfig.json"), JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } }, + })); + const result = resolveAliases(tmpDir); + expect(Object.keys(result.aliases).length).toBeGreaterThan(0); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); +}); + +// ─── Dead Code: expanded features ────────────────────────────────────────── + +describe("dead-code: expanded features", () => { + test("respects .oracleignore file", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-ignore-")); + fs.writeFileSync(path.join(tmpDir, ".oracleignore"), "src/ignored.ts\n"); + const graph: Record = { + "src/ignored.ts": { lines: 50, content_hash: "a", imports: [], unresolved_imports: [] }, + "src/dead.ts": { lines: 30, content_hash: "b", imports: [], unresolved_imports: [] }, + }; + const reachable = new Set(); + const dead = findDeadFiles(graph, reachable, tmpDir); + // ignored.ts should be excluded, dead.ts should be found + expect(dead.some(d => d.file === "src/ignored.ts")).toBe(false); + expect(dead.some(d => d.file === "src/dead.ts")).toBe(true); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("detects medium confidence (imported only by dead files)", () => { + const graph: Record = { + "src/entry.ts": { lines: 100, content_hash: "a", imports: [], unresolved_imports: [] }, + "src/dead-parent.ts": { lines: 50, content_hash: "b", imports: ["src/dead-child.ts"], unresolved_imports: [] }, + "src/dead-child.ts": { lines: 30, content_hash: "c", imports: [], unresolved_imports: [] }, + }; + const reachable = new Set(["src/entry.ts"]); + const dead = findDeadFiles(graph, reachable); + const child = dead.find(d => d.file === "src/dead-child.ts"); + expect(child).toBeDefined(); + expect(child!.confidence).toBe("medium"); + }); + + test("detects low confidence (imported only by test files)", () => { + const graph: Record = { + "src/entry.ts": { lines: 100, content_hash: "a", imports: [], unresolved_imports: [] }, + "src/util.ts": { lines: 50, content_hash: "b", imports: [], unresolved_imports: [] }, + "src/util.test.ts": { lines: 80, content_hash: "c", imports: ["src/util.ts"], unresolved_imports: [] }, + }; + const reachable = new Set(["src/entry.ts"]); + const dead = findDeadFiles(graph, reachable); + const util = dead.find(d => d.file === "src/util.ts"); + expect(util).toBeDefined(); + expect(util!.confidence).toBe("low"); + }); + + test("barrel files with expanded names excluded", () => { + const graph: Record = { + "src/mod.ts": { lines: 5, content_hash: "a", imports: ["src/real.ts"], unresolved_imports: [] }, + "src/index.jsx": { lines: 5, content_hash: "b", imports: ["src/real.ts"], unresolved_imports: [] }, + "src/real.ts": { lines: 100, content_hash: "c", imports: [], unresolved_imports: [] }, + }; + const reachable = new Set(); + const dead = findDeadFiles(graph, reachable); + expect(dead.some(d => d.file === "src/mod.ts")).toBe(false); + expect(dead.some(d => d.file === "src/index.jsx")).toBe(false); + }); +}); + +// ─── CSS: expanded features ──────────────────────────────────────────────── + +import { extractCssUrls, mergeCssGraph, detectTailwind } from "./scanner/css"; + +describe("css: expanded features", () => { + test("extractCssUrls finds url() references", () => { + const css = `body { background: url('./images/bg.png'); } .icon { background: url("icons/star.svg"); }`; + const urls = extractCssUrls(css, "/project/src/styles/main.css", "/project"); + expect(urls.length).toBe(2); + }); + + test("extractCssUrls skips data URIs and external URLs", () => { + const css = `body { background: url(data:image/png;base64,abc); } .x { background: url(https://cdn.example.com/img.png); }`; + const urls = extractCssUrls(css, "/project/src/styles/main.css", "/project"); + expect(urls.length).toBe(0); + }); + + test("mergeCssGraph merges two graphs", () => { + const tsGraph = { "a.ts": { lines: 100, content_hash: "a", imports: [], unresolved_imports: [] } as FileNode }; + const cssGraph = { "b.css": { lines: 50, content_hash: "b", imports: [], unresolved_imports: [], is_css: true } as FileNode }; + const merged = mergeCssGraph(tsGraph, cssGraph); + expect(Object.keys(merged)).toHaveLength(2); + expect(merged["b.css"].is_css).toBe(true); + }); + + test("detectTailwind finds tailwind config", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-tw-")); + fs.writeFileSync(path.join(tmpDir, "tailwind.config.js"), "module.exports = {}"); + const result = detectTailwind(tmpDir); + expect(result.detected).toBe(true); + expect(result.configFile).toBe("tailwind.config.js"); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("detectTailwind returns false when no config", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-notw-")); + const result = detectTailwind(tmpDir); + expect(result.detected).toBe(false); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); +}); + +// ─── Monorepo: nx + turbo detection ──────────────────────────────────────── + +describe("monorepo: nx and turbo detection", () => { + test("detects nx.json", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-nx-")); + fs.writeFileSync(path.join(tmpDir, "nx.json"), JSON.stringify({ workspaceLayout: {} })); + const result = detectMonorepo(tmpDir); + expect(result.detected).toBe(true); + expect(result.type).toBe("nx"); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("detects turbo.json", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "oracle-turbo-")); + fs.writeFileSync(path.join(tmpDir, "turbo.json"), JSON.stringify({ pipeline: {} })); + fs.writeFileSync(path.join(tmpDir, "package.json"), JSON.stringify({ name: "test" })); + const result = detectMonorepo(tmpDir); + expect(result.detected).toBe(true); + expect(result.type).toBe("turbo"); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); +}); + +// ─── ScanManifest: head_sha field ────────────────────────────────────────── + +describe("scan manifest: head_sha", () => { + test("ScanManifest type includes head_sha field", () => { + // Type-level check: if this compiles, head_sha is in the type + const manifest: Partial = { + head_sha: "abc123", + }; + expect(manifest.head_sha).toBe("abc123"); + }); +}); diff --git a/oracle/bin/scan-imports.ts b/oracle/bin/scan-imports.ts new file mode 100644 index 000000000..41e9f7dc9 --- /dev/null +++ b/oracle/bin/scan-imports.ts @@ -0,0 +1,378 @@ +#!/usr/bin/env bun +/** + * scan-imports.ts — CLI orchestrator for /oracle scan + * + * Thin entry point that coordinates the scanner modules: + * scanner/core.ts — graph construction, unified traversal, classification + * scanner/routes.ts — framework detection and route discovery + * scanner/aliases.ts — Vite alias resolution + * scanner/dead-code.ts — dead file detection + * scanner/css.ts — CSS/SCSS import tracking (stub) + * scanner/monorepo.ts — workspace detection (stub) + * scanner/non-ts.ts — non-TypeScript file discovery (stub) + * + * Usage: + * bun run ~/.claude/skills/gstack/oracle/bin/scan-imports.ts [options] + * + * Options: + * --project tsconfig.json path (default: tsconfig.json) + * --root Project root directory (default: .) + * --max-depth Max file discovery depth (default: 8) + * --mega-depth MEGA route trace depth cap (default: 4) + * --no-css Disable CSS import tracking + * --no-monorepo Disable monorepo auto-detection + * --no-eval Disable runtime eval fallback (AST-only) + * --no-non-ts Skip non-TypeScript file discovery + * --diff Compare against previous manifest and show changes + * --dry-run Show what would be scanned without writing + * --git-frequency Sort routes by recent commit frequency as tiebreaker + * --visualize Generate HTML visualization (requires visualize-graph.ts) + * + * Output: JSON scan manifest to stdout + */ + +import * as path from "path"; +import * as fs from "fs"; + +import { + type ScanManifest, + type RouteEntry, + type ScanOptions, + BASE_BUDGET, + MEGA_TRACE_DEPTH_CAP, + MAX_FILE_DISCOVERY_DEPTH, + buildImportGraph, + unifiedTraversal, + findCircularDeps, + classify, + estimateSessions, + getGitCoChangeComplexity, + getGitBornDate, + computeContentHash, + findEntryPoints, +} from "./scanner/core"; +import { detectFramework, discoverRoutes } from "./scanner/routes"; +import { parseViteAliases } from "./scanner/aliases"; +import { findDeadFiles } from "./scanner/dead-code"; +import { buildCssGraph } from "./scanner/css"; +import { detectMonorepo } from "./scanner/monorepo"; +import { discoverNonTsFiles } from "./scanner/non-ts"; + +// ─── CLI Args ─────────────────────────────────────────────────────────────── +function parseArgs(): ScanOptions { + const args = process.argv.slice(2); + let tsconfigPath = "tsconfig.json"; + let projectRoot = "."; + let maxDepth = MAX_FILE_DISCOVERY_DEPTH; + let megaDepthCap = MEGA_TRACE_DEPTH_CAP; + let noCss = false; + let noMonorepo = false; + let noEval = true; // eval OFF by default (security: don't execute user's vite config) + let noNonTs = false; + let diff = false; + let dryRun = false; + let gitFrequency = false; + let visualize = false; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case "--project": + tsconfigPath = args[++i]; + break; + case "--root": + projectRoot = args[++i]; + break; + case "--max-depth": { + const v = parseInt(args[++i], 10); + if (!isNaN(v)) maxDepth = v; + break; + } + case "--mega-depth": { + const v = parseInt(args[++i], 10); + if (!isNaN(v)) megaDepthCap = v; + break; + } + case "--no-css": + noCss = true; + break; + case "--no-monorepo": + noMonorepo = true; + break; + case "--no-eval": + noEval = true; + break; + case "--eval": + noEval = false; // opt-in to eval fallback for complex vite configs + break; + case "--no-non-ts": + noNonTs = true; + break; + case "--diff": + diff = true; + break; + case "--dry-run": + dryRun = true; + break; + case "--git-frequency": + gitFrequency = true; + break; + case "--visualize": + visualize = true; + break; + } + } + + projectRoot = path.resolve(projectRoot); + tsconfigPath = path.resolve(projectRoot, tsconfigPath); + + return { + tsconfigPath, projectRoot, maxDepth, megaDepthCap, + noCss, noMonorepo, noEval, noNonTs, + diff, dryRun, gitFrequency, visualize, + }; +} + +// ─── Main ─────────────────────────────────────────────────────────────────── +async function main(): Promise { + const options = parseArgs(); + const { projectRoot, tsconfigPath } = options; + + // Validate prerequisites + if (!fs.existsSync(tsconfigPath)) { + console.error( + `Warning: No tsconfig.json found at ${tsconfigPath} — import aliases won't be resolved.` + ); + } + + const projectName = path.basename(projectRoot); + + // Detect framework (single pass — returns router content for reuse) + const detection = detectFramework(projectRoot); + + // Parse Vite aliases + const viteAliases = parseViteAliases(projectRoot); + + // Build import graph + const { graph, skippedFiles } = buildImportGraph( + projectRoot, + tsconfigPath, + viteAliases + ); + + // Merge CSS graph if enabled + if (!options.noCss) { + const cssGraph = buildCssGraph(projectRoot, graph); + Object.assign(graph, cssGraph); + } + + // Discover routes + const discoveredRoutes = discoverRoutes(projectRoot, detection, viteAliases); + + // Build route map for unified traversal + const routeRoots = new Map(); + for (const dr of discoveredRoutes) { + const routePath = dr.routePath.startsWith("/") ? dr.routePath : `/${dr.routePath}`; + if (graph[dr.pageFile]) { + routeRoots.set(routePath, dr.pageFile); + } + } + + // Find entry points + const entryPoints = findEntryPoints(graph); + + // Unified traversal — single O(N+E) pass replaces buildBranch + findDeadFiles BFS + const traversal = unifiedTraversal(graph, routeRoots, entryPoints, options.megaDepthCap); + + // Git co-change complexity for classification (language-agnostic, no AST) + const pageFiles = discoveredRoutes.map(dr => dr.pageFile); + const complexity = getGitCoChangeComplexity(projectRoot, pageFiles); + const bornDates = getGitBornDate(projectRoot, pageFiles); + + // Build route entries from git co-change results + const routes: RouteEntry[] = []; + for (const dr of discoveredRoutes) { + const routePath = dr.routePath.startsWith("/") ? dr.routePath : `/${dr.routePath}`; + const cx = complexity.get(dr.pageFile) ?? { lines: 0, files: 0, coChangedFiles: [] }; + const classification = cx.lines > 0 ? classify(cx.lines) : "unknown" as const; + + routes.push({ + path: routePath, + type: dr.type, + page_file: dr.pageFile, + branch_lines: cx.lines, + branch_files: cx.files, + classification, + session_slots: Math.round((cx.lines / BASE_BUDGET) * 100) / 100, + status: "not_started", + born_date: bornDates.get(dr.pageFile) ?? 0, + co_changed_files: cx.coChangedFiles, + }); + } + + // Git-frequency secondary sort: count commits in last 30 days per route + if (options.gitFrequency) { + const freqMap = new Map(); + for (const dr of discoveredRoutes) { + try { + const result = Bun.spawnSync(["git", "log", "--since=30 days ago", "--oneline", "--", dr.pageFile], { cwd: projectRoot }); + const count = result.stdout.toString().trim().split("\n").filter(Boolean).length; + freqMap.set(dr.pageFile, count); + } catch { + freqMap.set(dr.pageFile, 0); + } + } + // Attach frequency to routes for sorting + for (const r of routes) { + (r as any)._gitFrequency = freqMap.get(r.page_file) ?? 0; + } + } + + // Sort by born_date (chronological) — foundation first, newest last + // With git-frequency as tiebreaker within same classification + routes.sort((a, b) => { + const dateDiff = (a.born_date ?? 0) - (b.born_date ?? 0); + if (dateDiff !== 0) return dateDiff; + if (options.gitFrequency) { + return ((b as any)._gitFrequency ?? 0) - ((a as any)._gitFrequency ?? 0); + } + return 0; + }); + + // Circular dependency detection + const circularDeps = findCircularDeps(graph); + + // Dead code detection (uses reachable set from unified traversal) + const deadFiles = findDeadFiles(graph, traversal.reachable, projectRoot); + + // Non-TypeScript file discovery + const nonTsFiles = !options.noNonTs ? discoverNonTsFiles(projectRoot) : []; + + // Monorepo detection + const monorepo = !options.noMonorepo ? detectMonorepo(projectRoot) : undefined; + + // Collect unresolved imports + const allUnresolved: ScanManifest["unresolved_imports"] = []; + for (const [file, node] of Object.entries(graph)) { + for (const u of node.unresolved_imports) { + allUnresolved.push({ file, import: u.specifier, reason: u.reason }); + } + } + + // Calculate totals + let totalLines = 0; + for (const node of Object.values(graph)) { + totalLines += node.lines; + } + + // Get HEAD SHA for staleness detection + let headSha = ""; + try { + const result = Bun.spawnSync(["git", "rev-parse", "HEAD"], { cwd: projectRoot }); + headSha = result.stdout.toString().trim(); + } catch { /* not a git repo */ } + + // Build manifest + const manifest: ScanManifest = { + schema_version: 1, + scanned_at: new Date().toISOString(), + head_sha: headSha, + project: projectName, + total_files: Object.keys(graph).length, + total_lines: totalLines, + routes, + circular_deps: circularDeps, + dead_files: deadFiles.filter((d) => d.confidence === "high"), + unresolved_imports: allUnresolved, + skipped_files: skippedFiles, + non_ts_files: nonTsFiles, + import_graph: graph, + estimated_sessions: estimateSessions(routes), + content_hash: computeContentHash(graph), + monorepo: monorepo ? { + detected: monorepo.detected, + type: monorepo.type, + packages: monorepo.packages, + } : undefined, + }; + + // --dry-run: show what would be scanned, don't output full manifest + if (options.dryRun) { + const summary = { + project: projectName, + total_files: manifest.total_files, + total_lines: manifest.total_lines, + routes: manifest.routes.map(r => ({ + path: r.path, + type: r.type, + classification: r.classification, + branch_lines: r.branch_lines, + })), + circular_deps: manifest.circular_deps.length, + dead_files: manifest.dead_files.length, + estimated_sessions: manifest.estimated_sessions, + }; + console.log(JSON.stringify(summary, null, 2)); + return; + } + + // --diff: compare against previous manifest + if (options.diff) { + const slugResult = Bun.spawnSync(["basename", projectRoot], { cwd: projectRoot }); + const slug = slugResult.stdout.toString().trim(); + const prevPath = path.join( + process.env.HOME ?? "~", ".gstack", "projects", slug, ".scan-manifest.prev.json" + ); + let diffOutput: Record = {}; + try { + const prev: ScanManifest = JSON.parse(fs.readFileSync(prevPath, "utf-8")); + const prevRoutes = new Set(prev.routes.map(r => r.path)); + const currRoutes = new Set(manifest.routes.map(r => r.path)); + const newRoutes = manifest.routes.filter(r => !prevRoutes.has(r.path)).map(r => r.path); + const removedRoutes = prev.routes.filter(r => !currRoutes.has(r.path)).map(r => r.path); + const classChanges: Array<{ route: string; from: string; to: string }> = []; + for (const curr of manifest.routes) { + const old = prev.routes.find(r => r.path === curr.path); + if (old && old.classification !== curr.classification) { + classChanges.push({ route: curr.path, from: old.classification, to: curr.classification }); + } + } + diffOutput = { + new_routes: newRoutes, + removed_routes: removedRoutes, + classification_changes: classChanges, + new_circular_deps: manifest.circular_deps.length - prev.circular_deps.length, + new_dead_files: manifest.dead_files.length - prev.dead_files.length, + files_delta: manifest.total_files - prev.total_files, + lines_delta: manifest.total_lines - prev.total_lines, + }; + } catch { + diffOutput = { note: "No previous manifest found. Showing full scan results." }; + } + // Output manifest with diff section + const output = { ...manifest, diff: diffOutput }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + // Output to stdout + console.log(JSON.stringify(manifest, null, 2)); + + // --visualize: generate HTML visualization after outputting manifest + if (options.visualize) { + try { + const { generateHtml } = await import("./visualize-graph"); + const html = generateHtml(manifest as any); + const slug = projectName.replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase(); + const outPath = `/tmp/oracle-scan-${slug}.html`; + fs.writeFileSync(outPath, html); + console.error(`Visualization written to: ${outPath}`); + } catch (err: any) { + console.error(`Visualization failed: ${err.message}`); + } + } +} + +main().catch(err => { + console.error("Error:", err.message); + process.exit(1); +}); diff --git a/oracle/bin/scanner/aliases.ts b/oracle/bin/scanner/aliases.ts new file mode 100644 index 000000000..f9be41bd9 --- /dev/null +++ b/oracle/bin/scanner/aliases.ts @@ -0,0 +1,209 @@ +/** + * scanner/aliases.ts — Vite alias resolution + * + * Parses vite.config.ts to extract path aliases using AST analysis, + * with a stripped-eval fallback for configs that use runtime expressions. + */ + +import * as ts from "typescript"; +import * as fs from "fs"; +import * as path from "path"; + +export interface AliasResult { + aliases: Record; + method: "ast" | "eval"; +} + +function findViteConfig(projectRoot: string): string | null { + for (const name of ["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs"]) { + const p = path.join(projectRoot, name); + if (fs.existsSync(p)) return p; + } + return null; +} + +/** + * Resolve a value expression to a string path. + * Handles: string literals, path.resolve(__dirname, "..."), template literals. + */ +function resolveValueExpr(node: ts.Node, projectRoot: string): string | null { + // String literal: "@": "./src" + if (ts.isStringLiteral(node)) { + return path.resolve(projectRoot, node.text); + } + + // path.resolve(__dirname, "src") or path.resolve(__dirname, "./src") + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "resolve" + ) { + const args = node.arguments; + if (args.length >= 2) { + // Check if first arg is __dirname + const firstArg = args[0]; + if (ts.isIdentifier(firstArg) && firstArg.text === "__dirname") { + // Collect remaining string args + const parts: string[] = [projectRoot]; + for (let i = 1; i < args.length; i++) { + if (ts.isStringLiteral(args[i])) { + parts.push((args[i] as ts.StringLiteral).text); + } else { + return null; // Non-string arg, can't resolve statically + } + } + return path.resolve(...parts); + } + } + } + + return null; +} + +function parseAliasesFromAST(content: string, projectRoot: string): Record | null { + const sourceFile = ts.createSourceFile("vite.config.ts", content, ts.ScriptTarget.Latest, true); + const aliases: Record = {}; + let found = false; + + function visit(node: ts.Node): void { + // Look for: alias: { ... } + if ( + ts.isPropertyAssignment(node) && + ts.isIdentifier(node.name) && + node.name.text === "alias" && + ts.isObjectLiteralExpression(node.initializer) + ) { + for (const prop of node.initializer.properties) { + if (ts.isPropertyAssignment(prop)) { + let key: string | null = null; + if (ts.isIdentifier(prop.name)) key = prop.name.text; + else if (ts.isStringLiteral(prop.name)) key = prop.name.text; + + if (key) { + const value = resolveValueExpr(prop.initializer, projectRoot); + if (value) { + aliases[key] = value; + found = true; + } + } + } + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return found ? aliases : null; +} + +function parseAliasesFromEval(content: string, projectRoot: string): Record | null { + try { + // Strip import/export statements and TypeScript-specific syntax + let stripped = content + .replace(/^import\s+.*$/gm, "") + .replace(/^export\s+default\s+/gm, "const __config__ = ") + .replace(/defineConfig\s*\(/g, "("); + + // Provide __dirname and path.resolve + const __dirname = projectRoot; + const pathResolve = (...args: string[]) => path.resolve(...args); + + const fn = new Function( + "__dirname", + "path", + `${stripped}; return typeof __config__ !== 'undefined' ? __config__ : undefined;`, + ); + + const config = fn(__dirname, { resolve: pathResolve, join: path.join }); + if (config?.resolve?.alias && typeof config.resolve.alias === "object") { + const aliases: Record = {}; + for (const [key, value] of Object.entries(config.resolve.alias)) { + if (typeof value === "string") { + aliases[key] = path.resolve(projectRoot, value); + } + } + return Object.keys(aliases).length > 0 ? aliases : null; + } + } catch { + // Eval failed — expected for complex configs + } + return null; +} + +export function parseViteAliasesDetailed( + projectRoot: string, + noEval = false, +): AliasResult { + const configPath = findViteConfig(projectRoot); + if (!configPath) return { aliases: {}, method: "ast" }; + + const content = fs.readFileSync(configPath, "utf-8"); + + // Try AST first + const astResult = parseAliasesFromAST(content, projectRoot); + if (astResult) return { aliases: astResult, method: "ast" }; + + // Eval fallback (if allowed) + if (!noEval) { + const evalResult = parseAliasesFromEval(content, projectRoot); + if (evalResult) return { aliases: evalResult, method: "eval" }; + } + + return { aliases: {}, method: "ast" }; +} + +export function parseViteAliases(projectRoot: string): Record { + return parseViteAliasesDetailed(projectRoot, false).aliases; +} + +/** + * Parse tsconfig.json compilerOptions.paths as a fallback when no vite config exists. + * Converts { "@/*": ["./src/*"] } to { "@": "/absolute/path/to/src" } + */ +export function parseTsconfigPaths(projectRoot: string): Record { + const tsconfigPath = path.join(projectRoot, "tsconfig.json"); + try { + const raw = fs.readFileSync(tsconfigPath, "utf-8"); + // Strip comments (tsconfig/JSONC allows them). Avoid stripping // inside strings + // by only removing comments that start after whitespace or at line start. + const stripped = raw + .replace(/\/\*[\s\S]*?\*\//g, "") // multi-line comments + .replace(/^\s*\/\/.*$/gm, "") // full-line single-line comments + .replace(/,\s*([}\]])/g, "$1"); // trailing commas + const config = JSON.parse(stripped); + const paths = config?.compilerOptions?.paths; + if (!paths || typeof paths !== "object") return {}; + + const baseUrl = config?.compilerOptions?.baseUrl || "."; + const baseDir = path.resolve(projectRoot, baseUrl); + const aliases: Record = {}; + + for (const [pattern, targets] of Object.entries(paths)) { + if (!Array.isArray(targets) || targets.length === 0) continue; + // Strip trailing /* from pattern and target + const key = pattern.replace(/\/\*$/, ""); + const target = (targets[0] as string).replace(/\/\*$/, ""); + aliases[key] = path.resolve(baseDir, target); + } + + return aliases; + } catch { + return {}; + } +} + +/** + * Resolve aliases: try vite config first, fall back to tsconfig.json paths. + */ +export function resolveAliases(projectRoot: string, noEval = false): AliasResult { + const viteResult = parseViteAliasesDetailed(projectRoot, noEval); + if (Object.keys(viteResult.aliases).length > 0) return viteResult; + + const tsconfigAliases = parseTsconfigPaths(projectRoot); + if (Object.keys(tsconfigAliases).length > 0) { + return { aliases: tsconfigAliases, method: "ast" }; + } + + return { aliases: {}, method: "ast" }; +} diff --git a/oracle/bin/scanner/core.ts b/oracle/bin/scanner/core.ts new file mode 100644 index 000000000..3bceabc67 --- /dev/null +++ b/oracle/bin/scanner/core.ts @@ -0,0 +1,934 @@ +/** + * scanner/core.ts — Core interfaces, graph construction, and unified traversal + * + * This module provides: + * - All shared interfaces (FileNode, RouteEntry, etc.) + * - Import graph construction using TypeScript compiler API + * - Unified graph traversal (replaces buildBranch + findDeadFiles BFS) + * - Classification and session estimation + */ + +import * as ts from "typescript"; +import * as path from "path"; +import * as fs from "fs"; +import * as crypto from "crypto"; + +// ─── Constants ────────────────────────────────────────────────────────────── +export const BASE_BUDGET = 3000; +export const TOKEN_RATIO_MAP_TO_SOURCE = 3; +export const EASY_THRESHOLD = 800; +export const MEDIUM_THRESHOLD = 2500; +export const MEGA_TRACE_DEPTH_CAP = 4; +export const MAX_FILE_DISCOVERY_DEPTH = 8; + +// ─── Interfaces ───────────────────────────────────────────────────────────── +export interface FileNode { + lines: number; + content_hash: string; + imports: string[]; + unresolved_imports: UnresolvedImport[]; + dynamic_imports?: DynamicImport[]; + is_css?: boolean; +} + +export interface UnresolvedImport { + specifier: string; + reason: string; +} + +export interface DynamicImport { + expression: string; + resolvable: boolean; + resolved_files?: string[]; +} + +export interface DiscoveredRoute { + routePath: string; + type: "page" | "api" | "worker"; + pageFile: string; +} + +export interface RouteEntry { + path: string; + type: "page" | "api" | "worker"; + page_file: string; + branch_lines: number; + branch_files: number; + classification: "easy" | "medium" | "hard" | "mega" | "unknown"; + session_slots: number; + status: "not_started" | "partial" | "complete"; + born_date?: number; + co_changed_files?: string[]; +} + +export interface CircularDep { + files: string[]; + severity: "high" | "medium" | "low"; + cycle_length: number; +} + +export interface DeadFile { + file: string; + confidence: "high" | "medium" | "low"; + lines: number; +} + +export interface NonTsFile { + file: string; + language: string; + lines: number; +} + +export interface ScanManifest { + schema_version: number; + scanned_at: string; + head_sha?: string; + project: string; + total_files: number; + total_lines: number; + routes: RouteEntry[]; + circular_deps: CircularDep[]; + dead_files: DeadFile[]; + unresolved_imports: { file: string; import: string; reason: string }[]; + skipped_files: { file: string; reason: string }[]; + non_ts_files: NonTsFile[]; + import_graph: Record; + estimated_sessions: { + easy: number; + medium: number; + hard: number; + mega: number; + total_min: number; + total_max: number; + }; + content_hash: string; + monorepo?: { + detected: boolean; + type?: string; + packages?: string[]; + }; +} + +export interface BranchResult { + totalLines: number; + fileCount: number; + maxDepth: number; + files: Set; +} + +export interface TraversalResult { + branches: Map; + reachable: Set; + routeMembership: Map>; +} + +// ─── CLI Options ──────────────────────────────────────────────────────────── +export interface ScanOptions { + tsconfigPath: string; + projectRoot: string; + maxDepth: number; + megaDepthCap: number; + noCss: boolean; + noMonorepo: boolean; + noEval: boolean; + noNonTs: boolean; + diff?: boolean; + dryRun?: boolean; + gitFrequency?: boolean; + visualize?: boolean; +} + +// ─── Import Graph Construction ────────────────────────────────────────────── +export function buildImportGraph( + root: string, + configPath: string, + viteAliases: Record +): { + graph: Record; + skippedFiles: { file: string; reason: string }[]; +} { + const graph: Record = {}; + const skippedFiles: { file: string; reason: string }[] = []; + + // Parse tsconfig + let compilerOptions: ts.CompilerOptions = {}; + let fileNames: string[] = []; + + if (fs.existsSync(configPath)) { + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + if (!configFile.error) { + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + root + ); + compilerOptions = parsed.options; + fileNames = parsed.fileNames; + } + } + + if (fileNames.length === 0) { + fileNames = findTsFiles(root, 0, MAX_FILE_DISCOVERY_DEPTH); + } + + // Merge Vite aliases into compiler options paths + if (Object.keys(viteAliases).length > 0) { + const existingPaths = compilerOptions.paths || {}; + for (const [alias, target] of Object.entries(viteAliases)) { + const relTarget = path.relative(compilerOptions.baseUrl || root, target); + const key = `${alias}/*`; + if (!existingPaths[key]) { + existingPaths[key] = [`${relTarget}/*`]; + } + const exactKey = alias; + if (!existingPaths[exactKey]) { + existingPaths[exactKey] = [`${relTarget}/index`]; + } + } + compilerOptions.paths = existingPaths; + if (!compilerOptions.baseUrl) { + compilerOptions.baseUrl = root; + } + } + + // Create program and trigger binding (sets parent pointers needed by isDeferredImport) + const program = ts.createProgram(fileNames, compilerOptions); + program.getTypeChecker(); + + for (const sourceFile of program.getSourceFiles()) { + const filePath = sourceFile.fileName; + if (filePath.includes("node_modules") || filePath.endsWith(".d.ts")) + continue; + + const relPath = path.relative(root, filePath); + if (relPath.startsWith("..")) continue; + + const content = sourceFile.getFullText(); + const lines = content.split("\n").length; + const contentHash = crypto + .createHash("sha256") + .update(content) + .digest("hex") + .substring(0, 12); + + const imports: string[] = []; + const unresolvedImports: UnresolvedImport[] = []; + const dynamicImports: DynamicImport[] = []; + + // Walk the AST for import declarations + ts.forEachChild(sourceFile, function visit(node) { + // import ... from "..." + if (ts.isImportDeclaration(node) && node.moduleSpecifier) { + const specifier = (node.moduleSpecifier as ts.StringLiteral).text; + resolveAndAddImport( + specifier, sourceFile, root, program, imports, unresolvedImports + ); + } + + // export ... from "..." + if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + const specifier = (node.moduleSpecifier as ts.StringLiteral).text; + resolveAndAddImport( + specifier, sourceFile, root, program, imports, unresolvedImports + ); + } + + // Dynamic import: import("...") + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 + ) { + const arg = node.arguments[0]; + if (ts.isStringLiteral(arg)) { + // Resolve the import path for ALL dynamic imports (eager and deferred). + // Only eager imports get added to node.imports (static graph edges). + // All dynamic imports get resolved_files for reachability analysis. + const resolvedPath = resolveImportSpecifier(arg.text, sourceFile, root, program); + if (!isDeferredImport(node)) { + if (resolvedPath) { + imports.push(resolvedPath); + } else if (isLocalSpecifier(arg.text)) { + unresolvedImports.push({ specifier: arg.text, reason: "unresolved" }); + } + } + dynamicImports.push({ + expression: arg.text, + resolvable: !!resolvedPath, + resolved_files: resolvedPath ? [resolvedPath] : undefined, + }); + } else { + const text = arg.getText(sourceFile); + unresolvedImports.push({ + specifier: `import(${text})`, + reason: "dynamic_variable_path", + }); + dynamicImports.push({ expression: text, resolvable: false }); + } + } + + // require("...") + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "require" && + node.arguments.length === 1 + ) { + const arg = node.arguments[0]; + if (ts.isStringLiteral(arg)) { + resolveAndAddImport( + arg.text, sourceFile, root, program, imports, unresolvedImports + ); + } else { + unresolvedImports.push({ + specifier: `require(${arg.getText(sourceFile)})`, + reason: "dynamic_variable_path", + }); + } + } + + // import.meta.glob("...") — Vite glob imports (#4) + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "glob" && + ts.isMetaProperty(node.expression.expression) && + node.arguments.length >= 1 + ) { + const arg = node.arguments[0]; + if (ts.isStringLiteral(arg)) { + const globPattern = arg.text; + const resolvedFiles = resolveGlobPattern(globPattern, root, relPath); + dynamicImports.push({ + expression: `import.meta.glob("${globPattern}")`, + resolvable: true, + resolved_files: resolvedFiles, + }); + // Add resolved files as imports + for (const f of resolvedFiles) { + if (!imports.includes(f)) imports.push(f); + } + } + } + + ts.forEachChild(node, visit); + }); + + graph[relPath] = { + lines, + content_hash: contentHash, + imports: [...new Set(imports)], + unresolved_imports: unresolvedImports, + dynamic_imports: dynamicImports.length > 0 ? dynamicImports : undefined, + }; + } + + return { graph, skippedFiles }; +} + +/** Check if an import specifier is local (relative, aliased, or absolute path) */ +function isLocalSpecifier(specifier: string): boolean { + return ( + specifier.startsWith(".") || + specifier.startsWith("/") || + specifier.startsWith("@/") || + specifier.startsWith("~/") + ); +} + +/** + * Resolve an import specifier to a relative file path within the project. + * Returns null if the specifier resolves to an external library, node_modules, + * a path outside the project root, or cannot be resolved at all. + */ +function resolveImportSpecifier( + specifier: string, + sourceFile: ts.SourceFile, + root: string, + program: ts.Program +): string | null { + const resolved = ts.resolveModuleName( + specifier, + sourceFile.fileName, + program.getCompilerOptions(), + ts.sys + ); + if (!resolved.resolvedModule) return null; + if (resolved.resolvedModule.isExternalLibraryImport) return null; + const relPath = path.relative(root, resolved.resolvedModule.resolvedFileName); + if (relPath.startsWith("..") || relPath.includes("node_modules")) return null; + return relPath; +} + +function resolveAndAddImport( + specifier: string, + sourceFile: ts.SourceFile, + root: string, + program: ts.Program, + imports: string[], + unresolvedImports: UnresolvedImport[] +): void { + const resolved = resolveImportSpecifier(specifier, sourceFile, root, program); + if (resolved) { + imports.push(resolved); + } else if (isLocalSpecifier(specifier)) { + unresolvedImports.push({ specifier, reason: "unresolved" }); + } +} + +/** + * Check if an import() call is inside a deferred context (arrow function, + * function expression, method) — meaning it's lazy-loaded at runtime, not + * eagerly loaded at module init. + * + * Handles the IIFE exception: (async () => { await import('...') })() is eager + * because the wrapping function is immediately invoked. + */ +export function isDeferredImport(node: ts.Node): boolean { + let current = node.parent; + while (current) { + if (ts.isSourceFile(current)) break; + + if ( + ts.isArrowFunction(current) || + ts.isFunctionExpression(current) || + ts.isFunctionDeclaration(current) || + ts.isMethodDeclaration(current) + ) { + // IIFE check: if this function is immediately called, it's eager + const parent = current.parent; + if ( + parent && + ts.isCallExpression(parent) && + parent.expression === current + ) { + // The function itself is the callee — it's an IIFE, keep walking up + current = parent.parent; + continue; + } + // Also handle parenthesized IIFEs: (async () => { ... })() + if ( + parent && + ts.isParenthesizedExpression(parent) && + parent.parent && + ts.isCallExpression(parent.parent) && + parent.parent.expression === parent + ) { + current = parent.parent.parent; + continue; + } + return true; // genuinely deferred + } + + current = current.parent; + } + return false; // top-level — eager +} + +/** Resolve a glob pattern to matching files relative to project root */ +function resolveGlobPattern( + pattern: string, + root: string, + sourceRelPath: string +): string[] { + const files: string[] = []; + // Convert glob to a directory + extension filter + // e.g., "./*.ts" → scan current dir for .ts files + // e.g., "./pages/**/*.tsx" → scan pages dir recursively for .tsx files + const sourceDir = path.dirname(path.join(root, sourceRelPath)); + + // Simple glob resolution: handle ./dir/*.ext and ./dir/**/*.ext patterns + const globMatch = pattern.match(/^(\.\/[^*]*?)(?:\*\*\/)?(\*\.(\w+))$/); + if (!globMatch) return files; + + const baseDir = path.resolve(sourceDir, globMatch[1]); + const ext = globMatch[3]; + const recursive = pattern.includes("**"); + + try { + collectFiles(baseDir, ext, recursive, root, files); + } catch { + // glob pattern didn't match any directory + } + + return files; +} + +function collectFiles( + dir: string, + ext: string, + recursive: boolean, + root: string, + files: string[] +): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory() && recursive) { + collectFiles(full, ext, recursive, root, files); + } else if (entry.isFile() && entry.name.endsWith(`.${ext}`)) { + files.push(path.relative(root, full)); + } + } +} + +// ─── File Discovery (fallback when no tsconfig) ──────────────────────────── +const SKIP_DIRS = new Set([ + "node_modules", ".git", "dist", "build", ".next", "coverage", ".turbo", +]); + +export function findFiles( + dir: string, + extensionPattern: RegExp, + depth = 0, + maxDepth = MAX_FILE_DISCOVERY_DEPTH, +): string[] { + if (depth > maxDepth) return []; + const files: string[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return files; + } + + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue; + + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...findFiles(full, extensionPattern, depth + 1, maxDepth)); + } else if (extensionPattern.test(entry.name)) { + files.push(full); + } + } + return files; +} + +export function findTsFiles(dir: string, depth = 0, maxDepth = MAX_FILE_DISCOVERY_DEPTH): string[] { + return findFiles(dir, /\.(tsx?|jsx?)$/, depth, maxDepth) + .filter(f => !f.endsWith(".d.ts")); +} + +// ─── Unified Graph Traversal ──────────────────────────────────────────────── +/** + * Single-pass traversal that computes: + * 1. Per-route branch membership (files, lines, depth) + * 2. Global reachability (for dead code detection) + * 3. Per-file route membership (which routes include each file) + * + * Replaces: buildBranch() + findDeadFiles() BFS + getGitFrequency() branch calls + * Complexity: O(N + E) where N=nodes, E=edges + */ +export function unifiedTraversal( + graph: Record, + routeRoots: Map, // routePath → pageFile + entryPoints: string[], + megaDepthCap: number = MEGA_TRACE_DEPTH_CAP +): TraversalResult { + const branches = new Map(); + const reachable = new Set(); + const routeMembership = new Map>(); + + // Initialize branches for each route + for (const [routePath, pageFile] of routeRoots) { + branches.set(routePath, { + totalLines: 0, + fileCount: 0, + maxDepth: 0, + files: new Set(), + }); + } + + // DFS per route to compute branch membership and depth (uncapped) + for (const [routePath, pageFile] of routeRoots) { + const branch = branches.get(routePath)!; + const visited = new Set(); + const fileDepths = new Map(); + + function dfs(file: string, depth: number): void { + if (visited.has(file)) return; + visited.add(file); + reachable.add(file); + fileDepths.set(file, depth); + + // Track route membership + if (!routeMembership.has(file)) { + routeMembership.set(file, new Set()); + } + routeMembership.get(file)!.add(routePath); + + // Add to branch + branch.files.add(file); + if (depth > branch.maxDepth) branch.maxDepth = depth; + + const node = graph[file]; + if (!node) return; + + for (const imp of node.imports) { + dfs(imp, depth + 1); + } + } + + if (graph[pageFile]) { + dfs(pageFile, 0); + } + + // Compute total lines for the branch + let totalLines = 0; + for (const f of branch.files) { + totalLines += graph[f]?.lines || 0; + } + + // Post-hoc MEGA depth pruning: classify the route from its true total, + // then remove files beyond the depth cap. This is deterministic — unlike + // a running-total approach which depends on DFS traversal order. + const classification = classify(totalLines); + if (classification === "mega") { + for (const f of [...branch.files]) { + if ((fileDepths.get(f) || 0) > megaDepthCap) { + branch.files.delete(f); + } + } + // Recompute after pruning + totalLines = 0; + for (const f of branch.files) { + totalLines += graph[f]?.lines || 0; + } + branch.maxDepth = Math.min(branch.maxDepth, megaDepthCap); + } + + branch.totalLines = totalLines; + branch.fileCount = branch.files.size; + } + + // BFS from entry points for reachability (doesn't add to any route branch) + bfsReachability(graph, reachable, entryPoints, false); + + // Dynamic import reachability: lazy-loaded files (React.lazy, () => import()) + // should be reachable (not flagged as dead) even though they aren't static + // graph edges. Seeds = resolved_files from all reachable files' dynamic_imports. + const dynamicSeeds: string[] = []; + for (const file of reachable) { + const node = graph[file]; + if (!node?.dynamic_imports) continue; + for (const di of node.dynamic_imports) { + if (di.resolved_files) { + for (const rf of di.resolved_files) { + if (!reachable.has(rf)) dynamicSeeds.push(rf); + } + } + } + } + bfsReachability(graph, reachable, dynamicSeeds, true); + + return { branches, reachable, routeMembership }; +} + +/** + * BFS reachability expansion from seed files. + * When followDynamic is true, also follows dynamic_imports.resolved_files + * (for marking lazy-loaded files as reachable). + */ +function bfsReachability( + graph: Record, + reachable: Set, + seeds: string[], + followDynamic: boolean +): void { + const queue = [...seeds]; + while (queue.length > 0) { + const file = queue.shift()!; + if (reachable.has(file)) continue; + reachable.add(file); + const node = graph[file]; + if (!node) continue; + for (const imp of node.imports) { + if (!reachable.has(imp)) queue.push(imp); + } + if (followDynamic && node.dynamic_imports) { + for (const di of node.dynamic_imports) { + if (di.resolved_files) { + for (const rf of di.resolved_files) { + if (!reachable.has(rf)) queue.push(rf); + } + } + } + } + } +} + +// ─── Tarjan's SCC (Circular Dependency Detection) ─────────────────────────── +export function findCircularDeps(graph: Record): CircularDep[] { + const indices = new Map(); + const lowlinks = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const sccs: string[][] = []; + let index = 0; + + function strongconnect(v: string): void { + indices.set(v, index); + lowlinks.set(v, index); + index++; + stack.push(v); + onStack.add(v); + + const node = graph[v]; + if (node) { + for (const w of node.imports) { + if (!graph[w]) continue; + if (!indices.has(w)) { + strongconnect(w); + lowlinks.set(v, Math.min(lowlinks.get(v)!, lowlinks.get(w)!)); + } else if (onStack.has(w)) { + lowlinks.set(v, Math.min(lowlinks.get(v)!, indices.get(w)!)); + } + } + } + + if (lowlinks.get(v) === indices.get(v)) { + const scc: string[] = []; + let w: string; + do { + w = stack.pop()!; + onStack.delete(w); + scc.push(w); + } while (w !== v); + if (scc.length > 1) { + sccs.push(scc); + } + } + } + + for (const v of Object.keys(graph)) { + if (!indices.has(v)) { + strongconnect(v); + } + } + + return sccs.map((scc) => { + const len = scc.length; + let severity: "high" | "medium" | "low"; + if (len <= 2) severity = "high"; + else if (len <= 4) severity = "medium"; + else severity = "low"; + return { files: scc, severity, cycle_length: len }; + }); +} + +// ─── Classification ───────────────────────────────────────────────────────── +export function classify(branchLines: number): "easy" | "medium" | "hard" | "mega" { + if (branchLines < EASY_THRESHOLD) return "easy"; + if (branchLines < MEDIUM_THRESHOLD) return "medium"; + if (branchLines <= BASE_BUDGET) return "hard"; + return "mega"; +} + +// ─── Session Estimation ───────────────────────────────────────────────────── +export function estimateSessions(routes: RouteEntry[]): ScanManifest["estimated_sessions"] { + const tiers = { easy: 0, medium: 0, hard: 0, mega: 0 }; + + for (const r of routes) { + if (r.classification === "unknown") continue; + tiers[r.classification] += r.session_slots; + } + + const easy = Math.ceil(tiers.easy); + const medium = Math.ceil(tiers.medium); + const hard = Math.ceil(tiers.hard); + const mega = Math.ceil(tiers.mega); + const totalMax = easy + medium + hard + mega; + const totalMin = Math.floor(totalMax * 0.7); + + return { easy, medium, hard, mega, total_min: totalMin, total_max: totalMax }; +} + +// ─── Git Co-Change Complexity ─────────────────────────────────────────────── +const SOURCE_RE = /\.(tsx?|jsx?|vue|svelte|py|rb|go|rs|php|ex|exs)$/; + +/** + * For each page file, find files that co-change with it in git history. + * Excludes shared infrastructure (files that co-change with many pages). + * Language-agnostic — works on any git repo. + */ +export function getGitCoChangeComplexity( + root: string, + pageFiles: string[], + opts?: { sharedThresholdPct?: number; minSharedThreshold?: number } +): Map { + const sharedPct = opts?.sharedThresholdPct ?? 0.25; + const minThreshold = opts?.minSharedThreshold ?? 3; + const totalPages = pageFiles.length; + const sharedThreshold = Math.max(minThreshold, Math.floor(totalPages * sharedPct)); + + // Step 1: Get commit hashes per page, deduplicate across pages + const pageCommits = new Map(); // pageFile → [hash, ...] + const allHashes = new Set(); + + for (const pageFile of pageFiles) { + try { + const result = Bun.spawnSync( + ["git", "log", "--format=%H", "--", pageFile], + { cwd: root } + ); + const hashes = result.stdout.toString().trim().split("\n").filter(Boolean); + pageCommits.set(pageFile, hashes); + for (const h of hashes) allHashes.add(h); + } catch { + pageCommits.set(pageFile, []); + } + } + + // Step 2: For each unique commit, get all changed files via diff-tree + const commitFiles = new Map(); + for (const hash of allHashes) { + try { + const result = Bun.spawnSync( + ["git", "diff-tree", "--root", "--no-commit-id", "--name-only", "-r", hash], + { cwd: root } + ); + const files = result.stdout.toString().trim().split("\n").filter(Boolean); + commitFiles.set(hash, files); + } catch { + commitFiles.set(hash, []); + } + } + + // Step 3: Build co-change map per page + const pageCoChanges = new Map>(); + const fileBreadth = new Map>(); + + for (const pageFile of pageFiles) { + const coChanged = new Set(); + const hashes = pageCommits.get(pageFile) ?? []; + for (const hash of hashes) { + const files = commitFiles.get(hash) ?? []; + for (const f of files) { + if (f === pageFile) continue; + if (!SOURCE_RE.test(f)) continue; + coChanged.add(f); + if (!fileBreadth.has(f)) fileBreadth.set(f, new Set()); + fileBreadth.get(f)!.add(pageFile); + } + } + pageCoChanges.set(pageFile, coChanged); + } + + // Cache file line counts — each file read once, reused across routes + const lineCountCache = new Map(); + function getLineCount(filePath: string): number { + if (lineCountCache.has(filePath)) return lineCountCache.get(filePath)!; + try { + const content = fs.readFileSync(path.resolve(root, filePath), "utf-8"); + const count = content.split("\n").length; + lineCountCache.set(filePath, count); + return count; + } catch { + lineCountCache.set(filePath, 0); + return 0; + } + } + + // Filter out shared files and sum lines + const complexity = new Map(); + + for (const pageFile of pageFiles) { + const coChanged = pageCoChanges.get(pageFile) ?? new Set(); + let totalLines = 0; + let totalFiles = 0; + const featureFiles: string[] = []; + + // Always count the page file itself + const pageLines = getLineCount(pageFile); + if (pageLines > 0) { + totalLines += pageLines; + totalFiles++; + featureFiles.push(pageFile); + } + + for (const f of coChanged) { + const breadth = fileBreadth.get(f)?.size ?? 0; + if (breadth >= sharedThreshold) continue; + const lines = getLineCount(f); + if (lines > 0) { + totalLines += lines; + totalFiles++; + featureFiles.push(f); + } + } + + complexity.set(pageFile, { lines: totalLines, files: totalFiles, coChangedFiles: featureFiles }); + } + + return complexity; +} + +// ─── Git Born Date ────────────────────────────────────────────────────────── +/** + * For each file, find the Unix timestamp of its first git commit. + * Used for chronological route ordering (foundation first, newest last). + */ +export function getGitBornDate( + root: string, + files: string[] +): Map { + const bornDates = new Map(); + try { + for (const file of files) { + const result = Bun.spawnSync( + ["git", "log", "--follow", "--diff-filter=A", "--format=%at", "--", file], + { cwd: root } + ); + const output = result.stdout.toString().trim(); + const timestamps = output.split("\n").filter(Boolean); + const earliest = timestamps.length > 0 ? parseInt(timestamps[timestamps.length - 1], 10) : 0; + bornDates.set(file, earliest); + } + } catch { + // Non-git project — all files get epoch 0 + } + return bornDates; +} + +// ─── Content Hash ─────────────────────────────────────────────────────────── +export function computeContentHash(graph: Record): string { + const hashInput = Object.entries(graph) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([f, n]) => `${f}:${n.content_hash}`) + .join("\n"); + return crypto + .createHash("sha256") + .update(hashInput) + .digest("hex") + .substring(0, 16); +} + +// ─── Entry Points ─────────────────────────────────────────────────────────── +export function findEntryPoints(graph: Record): string[] { + const entryPatterns = [ + "src/main.ts", "src/main.tsx", "src/index.ts", "src/index.tsx", + "src/App.ts", "src/App.tsx", "src/app.ts", "src/app.tsx", + ]; + const entries: string[] = []; + for (const p of entryPatterns) { + if (graph[p]) entries.push(p); + } + // Also add config files + for (const f of Object.keys(graph)) { + if ( + f.includes("vite.config") || + f.includes("tailwind.config") || + f.includes("postcss.config") || + f.includes("vitest.config") + ) { + entries.push(f); + } + } + return entries; +} diff --git a/oracle/bin/scanner/css.ts b/oracle/bin/scanner/css.ts new file mode 100644 index 000000000..7e821170c --- /dev/null +++ b/oracle/bin/scanner/css.ts @@ -0,0 +1,131 @@ +/** + * scanner/css.ts — CSS/SCSS import tracking + * + * Discovers .css and .scss files, parses @import and @use directives, + * and returns FileNode entries for the unified graph. + */ + +import * as fs from "fs"; +import * as path from "path"; +import * as crypto from "crypto"; +import { findFiles } from "./core"; +import type { FileNode } from "./core"; + +const IMPORT_REGEX = /@import\s+(?:url\()?\s*['"]([^'"]+)['"]\s*\)?/g; +const USE_REGEX = /@use\s+['"]([^'"]+)['"]/g; + +function resolveImportPath(importStr: string, fromFile: string, projectRoot: string): string { + const dir = path.dirname(fromFile); + const resolved = path.resolve(dir, importStr); + // Return relative to project root + return path.relative(projectRoot, resolved); +} + +export function buildCssGraph( + projectRoot: string, + _existingGraph: Record, +): Record { + const cssFiles = findFiles(projectRoot, /\.(css|scss|sass|less)$/); + const graph: Record = {}; + + for (const fullPath of cssFiles) { + const relPath = path.relative(projectRoot, fullPath); + + try { + const content = fs.readFileSync(fullPath, "utf-8"); + const lines = content.split("\n").length; + const contentHash = crypto + .createHash("sha256") + .update(content) + .digest("hex") + .substring(0, 12); + + const imports: string[] = []; + + // Parse @import directives + let match: RegExpExecArray | null; + IMPORT_REGEX.lastIndex = 0; + while ((match = IMPORT_REGEX.exec(content)) !== null) { + imports.push(resolveImportPath(match[1], fullPath, projectRoot)); + } + + // Parse @use directives (SCSS) + USE_REGEX.lastIndex = 0; + while ((match = USE_REGEX.exec(content)) !== null) { + imports.push(resolveImportPath(match[1], fullPath, projectRoot)); + } + + graph[relPath] = { + lines, + content_hash: contentHash, + imports, + unresolved_imports: [], + is_css: true, + }; + } catch { + // Skip unreadable files + } + } + + return graph; +} + +/** + * Parse CSS url() directives and resolve referenced files. + */ +const URL_REGEX = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g; + +export function extractCssUrls(content: string, fromFile: string, projectRoot: string): string[] { + const urls: string[] = []; + URL_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = URL_REGEX.exec(content)) !== null) { + const ref = match[1]; + // Skip data URIs, external URLs, and CSS variables + if (ref.startsWith("data:") || ref.startsWith("http") || ref.startsWith("#") || ref.startsWith("var(")) continue; + urls.push(resolveImportPath(ref, fromFile, projectRoot)); + } + return urls; +} + +/** + * Merge CSS import graph into the TypeScript import graph. + * CSS files that import TS/JS files (via url()) get cross-graph edges. + */ +export function mergeCssGraph( + tsGraph: Record, + cssGraph: Record, +): Record { + const merged = { ...tsGraph }; + for (const [file, node] of Object.entries(cssGraph)) { + if (merged[file]) { + // File exists in both — merge imports + const existing = merged[file]; + merged[file] = { + ...existing, + imports: [...new Set([...existing.imports, ...node.imports])], + is_css: true, + }; + } else { + merged[file] = node; + } + } + return merged; +} + +/** + * Detect if the project uses Tailwind CSS. + */ +export function detectTailwind(projectRoot: string): { detected: boolean; configFile?: string } { + const configNames = [ + "tailwind.config.js", + "tailwind.config.ts", + "tailwind.config.mjs", + "tailwind.config.cjs", + ]; + for (const name of configNames) { + const p = path.join(projectRoot, name); + if (fs.existsSync(p)) return { detected: true, configFile: name }; + } + return { detected: false }; +} diff --git a/oracle/bin/scanner/dead-code.ts b/oracle/bin/scanner/dead-code.ts new file mode 100644 index 000000000..a414f804d --- /dev/null +++ b/oracle/bin/scanner/dead-code.ts @@ -0,0 +1,204 @@ +/** + * scanner/dead-code.ts — Dead file detection + * + * Identifies files in the import graph that are unreachable from any entry point. + * Supports .oracleignore exclusions, expanded barrel file detection, HTML entry + * points, and multi-level confidence scoring. + */ + +import * as fs from "fs"; +import * as path from "path"; +import type { FileNode, DeadFile } from "./core"; + +const CONFIG_PATTERNS = [ + /^vite\.config/, + /^vitest\.config/, + /^tailwind\.config/, + /^postcss\.config/, + /^tsconfig/, + /^jest\.config/, + /^eslint/, + /^prettier/, + /^next\.config/, + /^\.eslintrc/, + /^babel\.config/, + /^webpack\.config/, +]; + +const BARREL_NAMES = new Set([ + "index.ts", "index.tsx", "index.js", "index.jsx", + "mod.ts", "mod.js", +]); + +function isConfigFile(filePath: string): boolean { + const basename = filePath.split("/").pop() ?? ""; + return CONFIG_PATTERNS.some(p => p.test(basename)); +} + +function isTestFile(filePath: string): boolean { + return ( + filePath.includes(".test.") || + filePath.includes(".spec.") || + filePath.includes("__tests__/") || + filePath.includes(".stories.") + ); +} + +function isBarrelFile(filePath: string, node: FileNode): boolean { + const basename = filePath.split("/").pop() ?? ""; + return BARREL_NAMES.has(basename) && node.imports.length > 0; +} + +/** + * Parse .oracleignore file (gitignore-style patterns). + * Returns a function that checks if a file path should be excluded. + */ +function loadOracleIgnore(projectRoot: string): (file: string) => boolean { + const ignorePath = path.join(projectRoot, ".oracleignore"); + try { + const content = fs.readFileSync(ignorePath, "utf-8"); + const patterns = content + .split("\n") + .map(line => line.trim()) + .filter(line => line && !line.startsWith("#")); + + if (patterns.length === 0) return () => false; + + // Convert simple glob patterns to regex + const regexes = patterns.map(p => { + const escaped = p + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(escaped); + }); + + return (file: string) => regexes.some(r => r.test(file)); + } catch { + return () => false; + } +} + +/** + * Parse index.html for + +`; +} + +// ─── CLI ──────────────────────────────────────────────────────────────────── + +async function main(): Promise { + let input: string; + const args = process.argv.slice(2); + + if (args.length > 0 && !args[0].startsWith("-")) { + input = fs.readFileSync(args[0], "utf-8"); + } else { + // Read from stdin + const chunks: Buffer[] = []; + for await (const chunk of Bun.stdin.stream()) { + chunks.push(Buffer.from(chunk)); + } + input = Buffer.concat(chunks).toString("utf-8"); + } + + const manifest: ScanManifest = JSON.parse(input); + const html = generateHtml(manifest); + + const slug = manifest.project.replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase(); + const outPath = `/tmp/oracle-scan-${slug}.html`; + fs.writeFileSync(outPath, html); + console.error(`Visualization written to: ${outPath}`); +} + +// Only run when executed directly, not when imported +if (import.meta.main) { + main().catch(err => { + console.error("Error:", err.message); + process.exit(1); + }); +} diff --git a/package.json b/package.json index 55f7a9fbb..69d528037 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "browse": "./browse/dist/browse" }, "scripts": { - "build": "bun run gen:skill-docs; bun run gen:skill-docs --host codex; bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && bun build --compile design/src/cli.ts --outfile design/dist/design && bun build --compile bin/gstack-global-discover.ts --outfile bin/gstack-global-discover && bash browse/scripts/build-node-server.sh && git rev-parse HEAD > browse/dist/.version && git rev-parse HEAD > design/dist/.version && rm -f .*.bun-build || true", + "build": "bun run gen:skill-docs; bun run gen:skill-docs --host codex; bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && bun build --compile design/src/cli.ts --outfile design/dist/design && bun build --compile bin/gstack-global-discover.ts --outfile bin/gstack-global-discover && bun build --compile oracle/bin/scan-imports.ts --outfile oracle/bin/dist/scan-imports && bash browse/scripts/build-node-server.sh && git rev-parse HEAD > browse/dist/.version && git rev-parse HEAD > design/dist/.version && rm -f .*.bun-build || true", "dev:design": "bun run design/src/cli.ts", "gen:skill-docs": "bun run scripts/gen-skill-docs.ts", "dev": "bun run browse/src/cli.ts", "server": "bun run browse/src/server.ts", - "test": "bun test browse/test/ test/ --ignore 'test/skill-e2e-*.test.ts' --ignore test/skill-llm-eval.test.ts --ignore test/skill-routing-e2e.test.ts --ignore test/codex-e2e.test.ts --ignore test/gemini-e2e.test.ts", + "test": "bun test browse/test/ test/ oracle/bin/scan-imports.test.ts --ignore 'test/skill-e2e-*.test.ts' --ignore test/skill-llm-eval.test.ts --ignore test/skill-routing-e2e.test.ts --ignore test/codex-e2e.test.ts --ignore test/gemini-e2e.test.ts", "test:evals": "EVALS=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts", "test:evals:all": "EVALS=1 EVALS_ALL=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-llm-eval.test.ts test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts", "test:e2e": "EVALS=1 bun test --retry 2 --concurrent --max-concurrency ${EVALS_CONCURRENCY:-15} test/skill-e2e-*.test.ts test/skill-routing-e2e.test.ts test/codex-e2e.test.ts test/gemini-e2e.test.ts", @@ -38,7 +38,8 @@ "dependencies": { "diff": "^7.0.0", "playwright": "^1.58.2", - "puppeteer-core": "^24.40.0" + "puppeteer-core": "^24.40.0", + "typescript": "^5.0.0" }, "engines": { "bun": ">=1.0.0" diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md index f208894ce..a849d8430 100644 --- a/plan-ceo-review/SKILL.md +++ b/plan-ceo-review/SKILL.md @@ -387,6 +387,24 @@ branch name wherever the instructions say "the base branch" or ``. --- +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # Mega Plan Review Mode ## Philosophy @@ -1535,3 +1553,26 @@ If promoted, copy the CEO plan content to `docs/designs/{FEATURE}.md` (create th │ (Sec 11) │ UI review │ detected │ detected │ │ └─────────────┴──────────────┴──────────────┴──────────────┴────────────────────┘ ``` + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/plan-ceo-review/SKILL.md.tmpl b/plan-ceo-review/SKILL.md.tmpl index 8f6aebe3b..ef66bc83e 100644 --- a/plan-ceo-review/SKILL.md.tmpl +++ b/plan-ceo-review/SKILL.md.tmpl @@ -25,6 +25,8 @@ allowed-tools: {{BASE_BRANCH_DETECT}} +{{PRODUCT_CONSCIENCE_READ}} + # Mega Plan Review Mode ## Philosophy @@ -818,3 +820,5 @@ If promoted, copy the CEO plan content to `docs/designs/{FEATURE}.md` (create th │ (Sec 11) │ UI review │ detected │ detected │ │ └─────────────┴──────────────┴──────────────┴──────────────┴────────────────────┘ ``` + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/plan-design-review/SKILL.md b/plan-design-review/SKILL.md index 902055a0b..af23e8a60 100644 --- a/plan-design-review/SKILL.md +++ b/plan-design-review/SKILL.md @@ -385,6 +385,24 @@ branch name wherever the instructions say "the base branch" or ``. --- +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # /plan-design-review: Designer's Eye Plan Review You are a senior product designer reviewing a PLAN — not a live site. Your job is @@ -1225,3 +1243,26 @@ Use AskUserQuestion to present the next step. Include only applicable options: * One sentence max per option. * After each pass, pause and wait for feedback. * Rate before and after each pass for scannability. + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/plan-design-review/SKILL.md.tmpl b/plan-design-review/SKILL.md.tmpl index cfafa6e6a..5e2cc6914 100644 --- a/plan-design-review/SKILL.md.tmpl +++ b/plan-design-review/SKILL.md.tmpl @@ -23,6 +23,8 @@ allowed-tools: {{BASE_BRANCH_DETECT}} +{{PRODUCT_CONSCIENCE_READ}} + # /plan-design-review: Designer's Eye Plan Review You are a senior product designer reviewing a PLAN — not a live site. Your job is @@ -452,3 +454,5 @@ Use AskUserQuestion to present the next step. Include only applicable options: * One sentence max per option. * After each pass, pause and wait for feedback. * Rate before and after each pass for scannability. + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index c00869315..de3eeb039 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -347,6 +347,24 @@ Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file: file you are allowed to edit in plan mode. The plan file review report is part of the plan's living status. +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # Plan Review Mode Review this plan thoroughly before making any code changes. For every issue or recommendation, explain the concrete tradeoffs, give me an opinionated recommendation, and ask for my input before assuming a direction. @@ -1118,3 +1136,26 @@ Use AskUserQuestion with only the applicable options: ## Unresolved decisions If the user does not respond to an AskUserQuestion or interrupts to move on, note which decisions were left unresolved. At the end of the review, list these as "Unresolved decisions that may bite you later" — never silently default to an option. + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/plan-eng-review/SKILL.md.tmpl b/plan-eng-review/SKILL.md.tmpl index c91e96d78..eee479f13 100644 --- a/plan-eng-review/SKILL.md.tmpl +++ b/plan-eng-review/SKILL.md.tmpl @@ -22,6 +22,8 @@ allowed-tools: {{PREAMBLE}} +{{PRODUCT_CONSCIENCE_READ}} + # Plan Review Mode Review this plan thoroughly before making any code changes. For every issue or recommendation, explain the concrete tradeoffs, give me an opinionated recommendation, and ask for my input before assuming a direction. @@ -302,3 +304,5 @@ Use AskUserQuestion with only the applicable options: ## Unresolved decisions If the user does not respond to an AskUserQuestion or interrupts to move on, note which decisions were left unresolved. At the end of the review, list these as "Unresolved decisions that may bite you later" — never silently default to an option. + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/qa-only/SKILL.md b/qa-only/SKILL.md index 6161dc313..49a0f8a0e 100644 --- a/qa-only/SKILL.md +++ b/qa-only/SKILL.md @@ -343,6 +343,24 @@ Then write a `## GSTACK REVIEW REPORT` section to the end of the plan file: file you are allowed to edit in plan mode. The plan file review report is part of the plan's living status. +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # /qa-only: Report-Only QA Testing You are a QA engineer. Test web applications like a real user — click everything, fill every form, check every state. Produce a structured report with evidence. **NEVER fix anything.** @@ -724,3 +742,26 @@ Report filenames use the domain and date: `qa-report-myapp-com-2026-03-12.md` 11. **Never fix bugs.** Find and document only. Do not read source code, edit files, or suggest fixes in the report. Your job is to report what's broken, not to fix it. Use `/qa` for the test-fix-verify loop. 12. **No test framework detected?** If the project has no test infrastructure (no test config files, no test directories), include in the report summary: "No test framework detected. Run `/qa` to bootstrap one and enable regression test generation." + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/qa-only/SKILL.md.tmpl b/qa-only/SKILL.md.tmpl index 0bb59c0c0..7f8333037 100644 --- a/qa-only/SKILL.md.tmpl +++ b/qa-only/SKILL.md.tmpl @@ -18,6 +18,8 @@ allowed-tools: {{PREAMBLE}} +{{PRODUCT_CONSCIENCE_READ}} + # /qa-only: Report-Only QA Testing You are a QA engineer. Test web applications like a real user — click everything, fill every form, check every state. Produce a structured report with evidence. **NEVER fix anything.** @@ -101,3 +103,5 @@ Report filenames use the domain and date: `qa-report-myapp-com-2026-03-12.md` 11. **Never fix bugs.** Find and document only. Do not read source code, edit files, or suggest fixes in the report. Your job is to report what's broken, not to fix it. Use `/qa` for the test-fix-verify loop. 12. **No test framework detected?** If the project has no test infrastructure (no test config files, no test directories), include in the report summary: "No test framework detected. Run `/qa` to bootstrap one and enable regression test generation." + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/qa/SKILL.md b/qa/SKILL.md index bf532784a..541d5e978 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -388,6 +388,24 @@ branch name wherever the instructions say "the base branch" or ``. --- +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # /qa: Test → Fix → Verify You are a QA engineer AND a bug-fix engineer. Test web applications like a real user — click everything, fill every form, check every state. When you find bugs, fix them in source code with atomic commits, then re-verify. Produce a structured report with before/after evidence. @@ -1134,3 +1152,26 @@ If the repo has a `TODOS.md`: 13. **Only modify tests when generating regression tests in Phase 8e.5.** Never modify CI configuration. Never modify existing tests — only create new test files. 14. **Revert on regression.** If a fix makes things worse, `git revert HEAD` immediately. 15. **Self-regulate.** Follow the WTF-likelihood heuristic. When in doubt, stop and ask. + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/qa/SKILL.md.tmpl b/qa/SKILL.md.tmpl index 0283ffc7c..6c4badd5c 100644 --- a/qa/SKILL.md.tmpl +++ b/qa/SKILL.md.tmpl @@ -26,6 +26,8 @@ allowed-tools: {{BASE_BRANCH_DETECT}} +{{PRODUCT_CONSCIENCE_READ}} + # /qa: Test → Fix → Verify You are a QA engineer AND a bug-fix engineer. Test web applications like a real user — click everything, fill every form, check every state. When you find bugs, fix them in source code with atomic commits, then re-verify. Produce a structured report with before/after evidence. @@ -322,3 +324,5 @@ If the repo has a `TODOS.md`: 13. **Only modify tests when generating regression tests in Phase 8e.5.** Never modify CI configuration. Never modify existing tests — only create new test files. 14. **Revert on regression.** If a fix makes things worse, `git revert HEAD` immediately. 15. **Self-regulate.** Follow the WTF-likelihood heuristic. When in doubt, stop and ask. + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/retro/SKILL.md b/retro/SKILL.md index 3ebc40fec..7b6209453 100644 --- a/retro/SKILL.md +++ b/retro/SKILL.md @@ -1195,3 +1195,26 @@ When the user runs `/retro compare` (or `/retro compare 14d`): - Do not read CLAUDE.md or other docs — this skill is self-contained - On first run (no prior retros), skip comparison sections gracefully - **Global mode:** Does NOT require being inside a git repo. Saves snapshots to `~/.gstack/retros/` (not `.context/retros/`). Gracefully skip AI tools that aren't installed. Only compare against prior global retros with the same window value. If streak hits 365d cap, display as "365+ days". + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/retro/SKILL.md.tmpl b/retro/SKILL.md.tmpl index 5463d07a9..a22d897cf 100644 --- a/retro/SKILL.md.tmpl +++ b/retro/SKILL.md.tmpl @@ -20,6 +20,8 @@ allowed-tools: {{BASE_BRANCH_DETECT}} +{{PRODUCT_CONSCIENCE_READ}} + # /retro — Weekly Engineering Retrospective Generates a comprehensive engineering retrospective analyzing commit history, work patterns, and code quality metrics. Team-aware: identifies the user running the command, then analyzes every contributor with per-person praise and growth opportunities. Designed for a senior IC/CTO-level builder using Claude Code as a force multiplier. @@ -851,3 +853,5 @@ When the user runs `/retro compare` (or `/retro compare 14d`): - Do not read CLAUDE.md or other docs — this skill is self-contained - On first run (no prior retros), skip comparison sections gracefully - **Global mode:** Does NOT require being inside a git repo. Saves snapshots to `~/.gstack/retros/` (not `.context/retros/`). Gracefully skip AI tools that aren't installed. Only compare against prior global retros with the same window value. If streak hits 365d cap, display as "365+ days". + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/review/SKILL.md b/review/SKILL.md index 9b47b6902..b3bd2a460 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -385,6 +385,24 @@ branch name wherever the instructions say "the base branch" or ``. --- +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # Pre-Landing PR Review You are running the `/review` workflow. Analyze the current branch's diff against the base branch for structural issues that tests don't catch. @@ -1136,3 +1154,26 @@ If the review exits early before a real review completes (for example, no diff a - **Be terse.** One line problem, one line fix. No preamble. - **Only flag real problems.** Skip anything that's fine. - **Use Greptile reply templates from greptile-triage.md.** Every reply includes evidence. Never post vague replies. + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/review/SKILL.md.tmpl b/review/SKILL.md.tmpl index bb9a3bc73..276806b28 100644 --- a/review/SKILL.md.tmpl +++ b/review/SKILL.md.tmpl @@ -23,6 +23,8 @@ allowed-tools: {{BASE_BRANCH_DETECT}} +{{PRODUCT_CONSCIENCE_READ}} + # Pre-Landing PR Review You are running the `/review` workflow. Analyze the current branch's diff against the base branch for structural issues that tests don't catch. @@ -282,3 +284,5 @@ If the review exits early before a real review completes (for example, no diff a - **Be terse.** One line problem, one line fix. No preamble. - **Only flag real problems.** Skip anything that's fine. - **Use Greptile reply templates from greptile-triage.md.** Every reply includes evidence. Never post vague replies. + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index a3584bc40..9d39c8bae 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -19,6 +19,7 @@ import { HOST_PATHS } from './resolvers/types'; import { RESOLVERS } from './resolvers/index'; import { codexSkillName, transformFrontmatter, extractHookSafetyProse, extractNameAndDescription, condenseOpenAIShortDescription, generateOpenAIYaml } from './resolvers/codex-helpers'; import { generatePlanCompletionAuditShip, generatePlanCompletionAuditReview, generatePlanVerificationExec } from './resolvers/review'; +import { generateProductConscienceRead, generateProductConscienceWrite } from './resolvers/oracle'; const ROOT = path.resolve(import.meta.dir, '..'); const DRY_RUN = process.argv.includes('--dry-run'); diff --git a/scripts/resolvers/index.ts b/scripts/resolvers/index.ts index 3d2b9dbb0..169250c56 100644 --- a/scripts/resolvers/index.ts +++ b/scripts/resolvers/index.ts @@ -13,6 +13,7 @@ import { generateDesignMethodology, generateDesignHardRules, generateDesignOutsi import { generateTestBootstrap, generateTestCoverageAuditPlan, generateTestCoverageAuditShip, generateTestCoverageAuditReview } from './testing'; import { generateReviewDashboard, generatePlanFileReviewReport, generateSpecReviewLoop, generateBenefitsFrom, generateCodexSecondOpinion, generateAdversarialStep, generateCodexPlanReview, generatePlanCompletionAuditShip, generatePlanCompletionAuditReview, generatePlanVerificationExec } from './review'; import { generateSlugEval, generateSlugSetup, generateBaseBranchDetect, generateDeployBootstrap, generateQAMethodology, generateCoAuthorTrailer } from './utility'; +import { generateProductConscienceRead, generateProductConscienceWrite } from './oracle'; export const RESOLVERS: Record string> = { SLUG_EVAL: generateSlugEval, @@ -48,4 +49,6 @@ export const RESOLVERS: Record string> = { PLAN_COMPLETION_AUDIT_REVIEW: generatePlanCompletionAuditReview, PLAN_VERIFICATION_EXEC: generatePlanVerificationExec, CO_AUTHOR_TRAILER: generateCoAuthorTrailer, + PRODUCT_CONSCIENCE_READ: generateProductConscienceRead, + PRODUCT_CONSCIENCE_WRITE: generateProductConscienceWrite, }; diff --git a/scripts/resolvers/oracle.test.ts b/scripts/resolvers/oracle.test.ts new file mode 100644 index 000000000..a661c55a9 --- /dev/null +++ b/scripts/resolvers/oracle.test.ts @@ -0,0 +1,164 @@ +/** + * oracle.test.ts — Tests for PRODUCT_CONSCIENCE_READ and PRODUCT_CONSCIENCE_WRITE resolvers + */ + +import { describe, test, expect } from "bun:test"; +import { generateProductConscienceRead, generateProductConscienceWrite } from "./oracle"; +import type { TemplateContext } from "./types"; +import { HOST_PATHS } from "./types"; + +function makeCtx(host: "claude" | "codex" = "claude"): TemplateContext { + return { + skillName: "test-skill", + tmplPath: "test/SKILL.md.tmpl", + host, + paths: HOST_PATHS[host], + }; +} + +describe("generateProductConscienceRead", () => { + test("returns non-empty string", () => { + const result = generateProductConscienceRead(makeCtx()); + expect(result.length).toBeGreaterThan(0); + }); + + test("contains product map path check", () => { + const result = generateProductConscienceRead(makeCtx()); + expect(result).toContain("docs/oracle/PRODUCT_MAP.md"); + }); + + test("contains spot-check instruction", () => { + const result = generateProductConscienceRead(makeCtx()); + expect(result).toMatch(/spot.check|grep/i); + }); + + test("contains anti-pattern warning", () => { + const result = generateProductConscienceRead(makeCtx()); + expect(result).toMatch(/anti.pattern/i); + }); + + test("mentions /oracle bootstrap when no map", () => { + const result = generateProductConscienceRead(makeCtx()); + expect(result).toContain("/oracle"); + }); + + test("contains bash block for detection", () => { + const result = generateProductConscienceRead(makeCtx()); + expect(result).toContain("```bash"); + expect(result).toContain("```"); + }); + + test("READ output is host-agnostic (no host-specific paths)", () => { + const claude = generateProductConscienceRead(makeCtx("claude")); + const codex = generateProductConscienceRead(makeCtx("codex")); + // READ just checks for docs/oracle/PRODUCT_MAP.md — no host-specific paths needed + expect(claude).toContain("docs/oracle/PRODUCT_MAP.md"); + expect(codex).toContain("docs/oracle/PRODUCT_MAP.md"); + }); + + test("output is lean (under 30 lines)", () => { + const result = generateProductConscienceRead(makeCtx()); + const lineCount = result.split("\n").length; + expect(lineCount).toBeLessThan(30); + }); +}); + +describe("generateProductConscienceWrite", () => { + test("returns non-empty string", () => { + const result = generateProductConscienceWrite(makeCtx()); + expect(result.length).toBeGreaterThan(0); + }); + + test("contains product map path check", () => { + const result = generateProductConscienceWrite(makeCtx()); + expect(result).toContain("docs/oracle/PRODUCT_MAP.md"); + }); + + test("contains lifecycle status instructions", () => { + const result = generateProductConscienceWrite(makeCtx()); + expect(result).toMatch(/PLANNED.*BUILDING.*SHIPPED/); + }); + + test("contains compression instructions", () => { + const result = generateProductConscienceWrite(makeCtx()); + expect(result).toMatch(/compress|3 months/i); + }); + + test("contains breadcrumb write", () => { + const result = generateProductConscienceWrite(makeCtx()); + expect(result).toContain(".product-map-last-write"); + }); + + test("specifies silent write (no user interaction)", () => { + const result = generateProductConscienceWrite(makeCtx()); + expect(result).toMatch(/silent|do not ask/i); + }); + + test("skips when no map exists", () => { + const result = generateProductConscienceWrite(makeCtx()); + expect(result).toMatch(/skip.*silent|no.*map/i); + }); + + test("claude host uses gstack-slug path", () => { + const result = generateProductConscienceWrite(makeCtx("claude")); + expect(result).toContain("~/.claude/skills/gstack"); + }); + + test("codex host uses GSTACK_BIN path in slug command", () => { + const result = generateProductConscienceWrite(makeCtx("codex")); + expect(result).toContain("$GSTACK_BIN"); + }); + + test("output is lean (under 30 lines)", () => { + const result = generateProductConscienceWrite(makeCtx()); + const lineCount = result.split("\n").length; + expect(lineCount).toBeLessThan(30); + }); + + test("does not contain AskUserQuestion", () => { + const result = generateProductConscienceWrite(makeCtx()); + expect(result).not.toContain("AskUserQuestion"); + }); +}); + +describe("scanner/utils.ts", () => { + // Import utils for testing + const { readPackageJson, hasDependency, fileExists, dirExists, resolveRelative } = + require("../../oracle/bin/scanner/utils"); + + test("readPackageJson returns null for missing file", () => { + expect(readPackageJson("/nonexistent/path")).toBeNull(); + }); + + test("readPackageJson parses valid package.json", () => { + const result = readPackageJson(process.cwd()); + expect(result).not.toBeNull(); + expect(result?.name).toBe("gstack"); + }); + + test("hasDependency finds dependencies", () => { + const pkg = { dependencies: { "playwright": "^1.0" }, devDependencies: {} }; + expect(hasDependency(pkg, "playwright")).toBe(true); + expect(hasDependency(pkg, "nonexistent")).toBe(false); + }); + + test("hasDependency finds devDependencies", () => { + const pkg = { dependencies: {}, devDependencies: { "typescript": "^5.0" } }; + expect(hasDependency(pkg, "typescript")).toBe(true); + }); + + test("fileExists returns true for existing files", () => { + expect(fileExists("package.json")).toBe(true); + expect(fileExists("nonexistent.txt")).toBe(false); + }); + + test("dirExists returns true for existing directories", () => { + expect(dirExists("oracle")).toBe(true); + expect(dirExists("nonexistent-dir")).toBe(false); + }); + + test("resolveRelative produces absolute paths", () => { + const result = resolveRelative("/root", "src", "index.ts"); + expect(result).toBe("/root/src/index.ts"); + }); +}); diff --git a/scripts/resolvers/oracle.ts b/scripts/resolvers/oracle.ts new file mode 100644 index 000000000..0cb376e9e --- /dev/null +++ b/scripts/resolvers/oracle.ts @@ -0,0 +1,62 @@ +/** + * Oracle — Product Conscience resolvers. + * + * PRODUCT_CONSCIENCE_READ: injected into planning/analysis skills. + * Tells the skill to read docs/oracle/PRODUCT_MAP.md and use it for context. + * + * PRODUCT_CONSCIENCE_WRITE: injected into post-work skills. + * Tells the skill to silently update the product map after completing work. + * + * Both are intentionally lean (~10 lines of output each). The product map + * is self-describing — its header contains the schema and instructions. + * Intelligence lives in the data, not in these resolver outputs. + */ + +import type { TemplateContext } from './types'; + +export function generateProductConscienceRead(_ctx: TemplateContext): string { + return `## Product Conscience — Read + +\`\`\`bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +\`\`\` + +If PRODUCT_MAP found: read \`docs/oracle/PRODUCT_MAP.md\`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that \`/oracle\` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +---`; +} + +export function generateProductConscienceWrite(ctx: TemplateContext): string { + const slugCmd = `${ctx.paths.binDir}/gstack-slug 2>/dev/null`; + + return `## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +\`\`\`bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(${slugCmd})" 2>/dev/null || true +\`\`\` + +If PRODUCT_MAP exists: update \`docs/oracle/PRODUCT_MAP.md\` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no \`depends_on\` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +\`\`\`bash +eval "$(${slugCmd})" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +\`\`\` + +If no PRODUCT_MAP: skip silently. Do not create one — that's \`/oracle bootstrap\`.`; +} diff --git a/ship/SKILL.md b/ship/SKILL.md index de2743f83..c461486ca 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -383,6 +383,24 @@ branch name wherever the instructions say "the base branch" or ``. --- +## Product Conscience — Read + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP: found" || echo "PRODUCT_MAP: none" +``` + +If PRODUCT_MAP found: read `docs/oracle/PRODUCT_MAP.md`. The map is self-documenting — +its header explains the schema. Use the Product Arc, Features, Patterns, and Anti-Patterns +to inform your work. Spot-check 2-3 components mentioned in the map by grepping the +codebase to verify accuracy. If an anti-pattern matches what you're about to do, warn +the user. + +If no PRODUCT_MAP: note that `/oracle` can bootstrap one from the codebase. Do not block +on this — proceed with the skill's normal workflow. + +--- + # Ship: Fully Automated Ship Workflow You are running the `/ship` workflow. This is a **non-interactive, fully automated** workflow. Do NOT ask for confirmation at any step. The user said `/ship` which means DO IT. Run straight through and output the PR URL at the end. @@ -1929,3 +1947,26 @@ This step is automatic — never skip it, never ask for confirmation. - **Never push without fresh verification evidence.** If code changed after Step 3 tests, re-run before pushing. - **Step 3.4 generates coverage tests.** They must pass before committing. Never commit failing tests. - **The goal is: user says `/ship`, next thing they see is the review + PR URL + auto-synced docs.** + +## Product Conscience — Write + +After completing the skill's main work, silently update the product map if it exists. + +```bash +_PMAP="docs/oracle/PRODUCT_MAP.md" +[ -f "$_PMAP" ] && echo "PRODUCT_MAP_WRITE: updating" || echo "PRODUCT_MAP_WRITE: skipped (no map)" +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null || true +``` + +If PRODUCT_MAP exists: update `docs/oracle/PRODUCT_MAP.md` following the schema in its +header. Update the feature entry for what changed in this session — adjust lifecycle status +(PLANNED → BUILDING → SHIPPED), add discovered patterns or anti-patterns, update connections. +Compress features shipped >3 months ago with no `depends_on` references to one-liners. +Do NOT ask the user — this is a silent write. + +Then write the breadcrumb: +```bash +eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" 2>/dev/null && mkdir -p ~/.gstack/projects/$SLUG && date -u +%Y-%m-%dT%H:%M:%SZ > ~/.gstack/projects/$SLUG/.product-map-last-write 2>/dev/null || true +``` + +If no PRODUCT_MAP: skip silently. Do not create one — that's `/oracle bootstrap`. diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 62842fc52..2891ca6dc 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -21,6 +21,8 @@ allowed-tools: {{BASE_BRANCH_DETECT}} +{{PRODUCT_CONSCIENCE_READ}} + # Ship: Fully Automated Ship Workflow You are running the `/ship` workflow. This is a **non-interactive, fully automated** workflow. Do NOT ask for confirmation at any step. The user said `/ship` which means DO IT. Run straight through and output the PR URL at the end. @@ -646,3 +648,5 @@ This step is automatic — never skip it, never ask for confirmation. - **Never push without fresh verification evidence.** If code changed after Step 3 tests, re-run before pushing. - **Step 3.4 generates coverage tests.** They must pass before committing. Never commit failing tests. - **The goal is: user says `/ship`, next thing they see is the review + PR URL + auto-synced docs.** + +{{PRODUCT_CONSCIENCE_WRITE}} diff --git a/test/helpers/touchfiles.ts b/test/helpers/touchfiles.ts index 981459b23..da8294f13 100644 --- a/test/helpers/touchfiles.ts +++ b/test/helpers/touchfiles.ts @@ -80,8 +80,6 @@ export const E2E_TOUCHFILES: Record = { 'ship-base-branch': ['ship/**', 'bin/gstack-repo-mode'], 'ship-local-workflow': ['ship/**', 'scripts/gen-skill-docs.ts'], 'review-dashboard-via': ['ship/**', 'scripts/resolvers/review.ts', 'codex/**', 'autoplan/**', 'land-and-deploy/**'], - 'ship-plan-completion': ['ship/**', 'scripts/gen-skill-docs.ts'], - 'ship-plan-verification': ['ship/**', 'scripts/gen-skill-docs.ts'], // Retro 'retro': ['retro/**'], @@ -153,6 +151,10 @@ export const E2E_TOUCHFILES: Record = { // Autoplan 'autoplan-core': ['autoplan/**', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**'], + // Oracle + 'oracle-bootstrap': ['oracle/**', 'scripts/resolvers/oracle.ts', 'scripts/gen-skill-docs.ts'], + 'oracle-scan': ['oracle/bin/**', 'scripts/resolvers/oracle.ts'], + // Skill routing — journey-stage tests (depend on ALL skill descriptions) 'journey-ideation': ['*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts'], 'journey-plan-eng': ['*/SKILL.md.tmpl', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts'], @@ -277,6 +279,10 @@ export const E2E_TIERS: Record = { 'sidebar-navigate': 'periodic', 'sidebar-url-accuracy': 'periodic', + // Oracle — periodic (LLM-driven, non-deterministic) + 'oracle-bootstrap': 'periodic', + 'oracle-scan': 'periodic', + // Autoplan — periodic (not yet implemented) 'autoplan-core': 'periodic', diff --git a/test/skill-e2e-oracle.test.ts b/test/skill-e2e-oracle.test.ts new file mode 100644 index 000000000..f61556cac --- /dev/null +++ b/test/skill-e2e-oracle.test.ts @@ -0,0 +1,146 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { runSkillTest } from './helpers/session-runner'; +import { + ROOT, runId, evalsEnabled, + describeIfSelected, logCost, recordE2E, + createEvalCollector, finalizeEvalCollector, +} from './helpers/e2e-helpers'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const evalCollector = createEvalCollector('e2e-oracle'); + +afterAll(() => { + finalizeEvalCollector(evalCollector); +}); + +// --- Oracle E2E Tests --- + +describeIfSelected('Oracle — bootstrap produces valid PRODUCT_MAP.md', ['oracle-bootstrap'], () => { + let projectDir: string; + + beforeAll(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-oracle-')); + + const run = (cmd: string, args: string[]) => + spawnSync(cmd, args, { cwd: projectDir, stdio: 'pipe', timeout: 5000 }); + + run('git', ['init', '-b', 'main']); + run('git', ['config', 'user.email', 'test@test.com']); + run('git', ['config', 'user.name', 'Test']); + + // Create a minimal app with a few features + fs.mkdirSync(path.join(projectDir, 'src', 'pages'), { recursive: true }); + fs.mkdirSync(path.join(projectDir, 'src', 'components'), { recursive: true }); + + fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify({ + name: 'oracle-test-app', + version: '1.0.0', + dependencies: { 'react': '^18.0.0', 'react-router-dom': '^6.0.0' }, + }, null, 2)); + + fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { target: 'es2020', module: 'esnext', jsx: 'react-jsx' }, + }, null, 2)); + + fs.writeFileSync(path.join(projectDir, 'src', 'pages', 'Dashboard.tsx'), + 'import React from "react";\nexport default function Dashboard() { return
Dashboard
; }\n'); + fs.writeFileSync(path.join(projectDir, 'src', 'pages', 'Login.tsx'), + 'import React from "react";\nexport default function Login() { return
Login
; }\n'); + fs.writeFileSync(path.join(projectDir, 'src', 'components', 'Header.tsx'), + 'import React from "react";\nexport function Header() { return
Header
; }\n'); + + run('git', ['add', '.']); + run('git', ['commit', '-m', 'feat: initial app with dashboard and login']); + }); + + afterAll(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + test('oracle bootstrap generates PRODUCT_MAP.md', async () => { + const result = await runSkillTest({ + testName: 'oracle-bootstrap', + prompt: '/oracle', + cwd: projectDir, + timeout: 120_000, + }); + + logCost(result); + recordE2E(evalCollector, 'oracle-bootstrap', result); + + // Oracle should have created or attempted to create a product map + const output = result.output?.toLowerCase() ?? ''; + const productMapPath = path.join(projectDir, 'docs', 'oracle', 'PRODUCT_MAP.md'); + const mapExists = fs.existsSync(productMapPath); + + // Either the map was created, or the output mentions product map / bootstrap + expect(mapExists || output.includes('product map') || output.includes('bootstrap')).toBe(true); + + if (mapExists) { + const content = fs.readFileSync(productMapPath, 'utf-8'); + // Should have required structural markers + expect(content).toContain('## Product Arc'); + expect(content).toContain('## Features'); + } + }, 180_000); +}); + +describeIfSelected('Oracle — scan produces valid manifest', ['oracle-scan'], () => { + let projectDir: string; + + beforeAll(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-oracle-scan-')); + + const run = (cmd: string, args: string[]) => + spawnSync(cmd, args, { cwd: projectDir, stdio: 'pipe', timeout: 5000 }); + + run('git', ['init', '-b', 'main']); + run('git', ['config', 'user.email', 'test@test.com']); + run('git', ['config', 'user.name', 'Test']); + + fs.mkdirSync(path.join(projectDir, 'src', 'pages'), { recursive: true }); + + fs.writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify({ + name: 'oracle-scan-test', + dependencies: { 'react': '^18.0.0', 'react-router-dom': '^6.0.0' }, + }, null, 2)); + + fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { target: 'es2020', module: 'esnext', jsx: 'react-jsx' }, + }, null, 2)); + + fs.writeFileSync(path.join(projectDir, 'src', 'pages', 'Home.tsx'), + 'import React from "react";\nexport default function Home() { return
Home
; }\n'); + + run('git', ['add', '.']); + run('git', ['commit', '-m', 'initial']); + }); + + afterAll(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + test('scanner produces valid JSON manifest', () => { + const scanBin = path.join(ROOT, 'oracle', 'bin', 'scan-imports.ts'); + const result = spawnSync('bun', ['run', scanBin, '--root', projectDir], { + cwd: ROOT, + stdio: 'pipe', + timeout: 30_000, + }); + + const stdout = result.stdout?.toString() ?? ''; + expect(stdout.length).toBeGreaterThan(0); + + const manifest = JSON.parse(stdout); + expect(manifest.schema_version).toBe(1); + expect(manifest.project).toBeTruthy(); + expect(manifest.total_files).toBeGreaterThanOrEqual(0); + expect(Array.isArray(manifest.routes)).toBe(true); + expect(typeof manifest.content_hash).toBe('string'); + // head_sha should be present (design decision #4) + expect(typeof manifest.head_sha).toBe('string'); + }, 60_000); +});