Skip to content

Architecture

mvoutov edited this page May 12, 2026 · 5 revisions

Architecture

Project Structure

aspens/
  bin/cli.js              # Entry point — Commander CLI setup
  src/
    commands/
      scan.js             # aspens scan — tech stack + graph display
      doc-init.js         # aspens doc init — 3-layer pipeline
      doc-sync.js         # aspens doc sync — diff classifier, deterministic repair, multi-target publish
      doc-graph.js        # aspens doc graph — standalone graph rebuild
      doc-impact.js       # aspens doc impact — context-health report
      add.js              # aspens add — templates + custom skills
      customize.js        # aspens customize — inject project context into agents
      save-tokens.js      # aspens save-tokens — session-optimization settings
    lib/
      scanner.js          # Layer 1 — deterministic repo scanner (languages, frameworks, domains, health)
      graph-builder.js    # Layer 1 — import graph construction, file metrics, clustering
      graph-persistence.js # Graph serialization, code-map (clusters + framework entries), graph-index
      source-exts.js      # Canonical source-extension set (.cjs/.mjs first-class)
      parsers/
        typescript.js     # es-module-lexer wrapper + default-export + `export * from` re-exports
        python.js         # Top-level imports, def/class extraction (strips docstrings)
      frameworks/
        nextjs.js         # App Router / Pages Router / middleware entry-point detection
      path-resolver.js    # tsconfig.json / jsconfig.json `paths:` alias resolution (with `extends` chain)
      diff-classifier.js  # `isNoOpDiff()` — skip the LLM call on lockfile-only / non-code diffs
      target.js           # TARGETS table, allowed paths, multi-target merge
      target-transform.js # collectSkillsForList + sanitizePublishedContent + assertTargetParity chokepoints
      backend.js          # Backend abstraction (claude / codex routing)
      context-builder.js  # Assembles repo context for generation prompts
      runner.js           # Claude/Codex CLI wrappers, stream parsing, output extraction
      skill-writer.js     # Writes skill files, generates skill-rules.json, merges settings
      skill-reader.js     # Parses frontmatter, `triggers:` block, legacy `## Activation` fallback
      impact.js           # Freshness, coverage, drift, hook health checks
      save-tokens.js      # Statusline + handoff + precompact-handoff installers
      git-helpers.js      # Git repo detection, diff, log, changed files (execFileSync)
      diff-helpers.js     # Diff prioritization, truncation, file selection
      git-hook.js         # Post-commit hook install/remove
      timeout.js          # Timeout resolution (flag > env var > fallback)
      errors.js           # CliError class
    prompts/
      discover-domains.md      # Layer 2 — domain discovery agent prompt
      discover-architecture.md # Layer 2 — architecture analysis agent prompt
      doc-init.md              # Layer 3 — skill generation prompt
      doc-init-domain.md       # Layer 3 — per-domain skill generation
      doc-init-claudemd.md     # Layer 3 — orientation doc generation
      doc-sync.md              # Doc sync prompt
      customize-agents.md      # Agent customization prompt
      partials/                # Shared format specs (skill-format.md, examples.md)
    templates/
      agents/             # Bundled AI agents (plan, execute, code-reviewer, etc.)
      commands/           # Slash commands (handoff/resume, dev-docs)
      hooks/              # Hooks (skill-activation, post-tool-use-tracker, save-tokens)
      settings/           # Bundled settings.json fragments
  tests/                   # Vitest test files (439 tests as of v0.8.0)

Data Flow

scanRepo()                   → { languages, frameworks, domains, entryPoints, size, health }
     ↓
buildRepoGraph()             → { files, edges, ranked, hubs, clusters, hotspots,
                                 frameworkEntryPoints, stats }
     ↓
runDiscovery()               → discoveryFindings (architecture + domains as structured text)
     ↓
generateChunked()            → in-memory skill files (base → domains in parallel → CLAUDE.md)
     ↓
publishFilesForTargets()     → Map<targetId, files[]>  (calls transformForTarget per non-source target)
     ↓  assertTargetParity()  ← throws CliError if targets diverge on logical files
     ↓
sanitizePublishedContent()   ← every disk write goes through it (chokepoint sanitizer)
     ↓
writeSkillFiles() / writeTransformedFiles()
                             → .claude/skills/**/*.md, .agents/skills/**/AGENTS.md,
                               CLAUDE.md, AGENTS.md, .claude/code-map.md

generateCodeMap() emits clusters + framework entry points only — no file counts, edge counts, hub rankings, or hotspots (those live in .claude/graph.json for programmatic access).

Key Dependencies

Package Purpose Size
es-module-lexer Parse JS/TS import/export statements (WASM) ~50KB
commander CLI framework ~50KB
@clack/prompts Interactive terminal prompts ~30KB
picocolors Terminal color output ~3KB

Runtime dependency on either claude CLI or codex CLI for LLM operations, depending on the selected backend.

Import Graph Builder

The graph builder (src/lib/graph-builder.js) is the orchestrator. Language-specific parsing lives in src/lib/parsers/ and src/lib/frameworks/; alias resolution lives in src/lib/path-resolver.js.

What it parses

  • JS/TSsrc/lib/parsers/typescript.js wraps es-module-lexer and adds post-pass handling for inline default exports (export default function Foo, export default class Bar, export default Baz) and export * from '<spec>' re-exports. Handles .js, .ts, .tsx, .jsx, .mjs, .cjs.
  • Pythonsrc/lib/parsers/python.js. Top-level only (line-anchored regex). Parses import X, from X import Y, from .X import Y, from ..X import Y. Strips triple-quoted strings to avoid matching imports in docstrings. Also extracts top-level def / class for export names; SCREAMING_SNAKE constants are deliberately excluded.

Framework entry points

src/lib/frameworks/nextjs.js surfaces files Next.js runs implicitly — they have no static importer, so the priority ranker would otherwise rank them too low. Detected entry points:

  • App Routerpage, layout, route, loading, error, not-found, template, default, global-error (under app/ or src/app/)
  • Pages Router — every file under pages/ or src/pages/
  • Special top-level filesmiddleware, instrumentation

Entry points are tagged in the graph and rendered in code-map.md under Framework entry points.

Import resolution

  1. Relative imports (./foo, ../bar) — tries extensions (.ts, .tsx, .js, .jsx, .mjs, .cjs), then index files (./foo/index.ts).
  2. Path aliases (@/components/Button) — src/lib/path-resolver.js reads tsconfig.json / jsconfig.json compilerOptions.paths + baseUrl, follows extends chains (capped at 5 levels), and resolves relative to the tsconfig's directory. Searches the repo root, common subdirectories (frontend/, web/, client/, app/, src/), and monorepo apps/* / packages/*. Any resolution that escapes the repo root via ../../../ is rejected (isInsideRepo() bound-check).
  3. Python relative (.utils, ..models) — dot-counting for parent traversal, module path to filesystem path.
  4. Python absolute (app.services.db) — tries from multiple roots: repo root + detected Python package roots (directories containing pyproject.toml, setup.py, __init__.py, or app/).

File priority formula

priority = fanIn * 3.0          # how many files depend on this one
         + exportCount * 1.5    # how many things it exports
         + isEntryPoint * 10.0  # main.ts, index.ts, etc.
         + gitChurn * 2.0       # changes in last 6 months
         + 1/(depth+1) * 1.0    # shallower = more architectural

Domain clustering

Connected components via BFS on the undirected import graph. Files that import each other (directly or transitively) land in the same cluster. Each cluster is labeled by its primary directory.

For display in code-map.md, single-file clusters are dropped and clusters that share a label (emitted by the connected-component builder when components are isolated) are merged so the output doesn't have duplicate cluster headings. Per-cluster files are sorted by fanIn descending, then path ascending, and capped at 5 files per cluster.

Inter-cluster coupling is computed as the count of cross-cluster import edges.

Target Transform Chokepoints

src/lib/target-transform.js is the v0.8.0 publishing pipeline. Every multi-target write flows through these three exports:

  • collectSkillsForList(files, pendingBaseSkill, instructionsFile, sourceTarget, repoPath) — merges on-disk skills with the in-flight subset before building the root instructions file's ## Skills section. Without this, a partial doc sync that only re-generates one skill would silently drop every unchanged skill from the AGENTS.md / CLAUDE.md skills list.
  • sanitizePublishedContent(content, filePath) — defense-in-depth sanitizer that strips forbidden blocks (## Activation, ## Key Files, hub/cluster/hotspot tables, framework-entry blocks) from skill and instructions files before they hit disk. code-map.md is whitelisted because cluster + framework-entry blocks are legitimate there.
  • assertTargetParity(perTargetMap) — throws CliError if two configured targets publish a different logical file set (root instructions + per-domain skills). Codex-only synthetic architecture skills are carved out by logicalKeyForFile.

The no-op repair path (repairDeterministicSections() in src/commands/doc-sync.js) runs on every doc sync invocation where the diff is empty or non-code-bearing — it re-injects the deterministic ## Skills and ## Behavior sections from on-disk state so missing-section drift is fixed without an LLM call.

Parallel Execution

Layer 2 — Discovery (2 agents)

const [domainsResult, archResult] = await Promise.all([
  runClaude(discoverDomainsPrompt, ...),
  runClaude(discoverArchitecturePrompt, ...),
]);

Both discovery runs receive the same graph context. Domain discovery is faster (focused task). Architecture analysis is deeper (reads hub files). Results are merged.

Layer 3 — Skill Generation (batches of 3)

const PARALLEL_LIMIT = 3;
for (let i = 0; i < domains.length; i += PARALLEL_LIMIT) {
  const batch = domains.slice(i, i + PARALLEL_LIMIT);
  const results = await Promise.all(batch.map(domain => runClaude(...)));
}

Base skill is always sequential (generated first, feeds into domain prompts). The target orientation doc (CLAUDE.md or AGENTS.md) is always last.

Runner Architecture

runner.js wraps the selected backend CLI:

claude -p "prompt" --allowedTools Read,Glob,Grep --output-format stream-json
codex exec --json --sandbox read-only --ask-for-approval never --ephemeral
  • Streams/parses structured output for token usage and content extraction
  • Auto-scales timeout based on repo size (small: 120s, medium: 300s, large: 600s, very-large: 900s)
  • Normalizes backend output into a shared { text, usage } interface
  • Enforces target-aware path safety for .claude/, .agents/skills/, .codex/, CLAUDE.md, and AGENTS.md

Clone this wiki locally