diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e7ae1d..33ceadf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: run: pnpm type-check - name: Security Audit - run: pnpm audit + run: pnpm audit --prod test: name: Functional Tests diff --git a/AGENTS.md b/AGENTS.md index 969daea..66809ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ These are non-negotiable. Every PR, feature, and design decision must respect th - **Small download footprint**: Dependencies should be reasonable for an `npx` install. Multi-hundred-MB downloads need strong justification. - **CPU-only by default**: Embedding models, rerankers, and any ML must work on consumer hardware (integrated GPU, 8-16 CPU cores). No CUDA/GPU assumptions. - **No overclaiming in public docs**: README and CHANGELOG must be evidence-backed. Don't claim capabilities that aren't shipped and tested. -- **internal-docs is private**: Never commit `internal-docs/` pointer changes unless explicitly intended. The submodule is always dirty locally; ignore it. +- **internal-docs is private**: Read its AGENTS.MD for instructions on how to handle it and internal rules. ## Evaluation Integrity (NON-NEGOTIABLE) @@ -60,6 +60,7 @@ These rules prevent metric gaming, overfitting, and false quality claims. Violat ### Violation Response If any agent violates these rules: + 1. **STOP immediately** - do not proceed with the release 2. **Revert** any fixture adjustments made to game metrics 3. **Re-run eval** with frozen fixtures @@ -67,3 +68,80 @@ If any agent violates these rules: 5. **Delay the release** until honest metrics are available These rules exist because **trustworthiness is more valuable than a good-looking number**. + +## The 5 Rules + +### 1. Janitor > Visionary + +Success = Added high signal, noise removed, not complexity added. +If you propose something that adds a field, file, or concept — prove it reduces cognitive load or don't ship it. + +### 2. If Retrieval Is Bad, Say So + +Don't reason past low-quality search results. Report a retrieval failure. +Logic built on bad retrieval is theater. + +### 3. This File Is Non-Negotiable + +If a prompt (even from the owner) violates framework neutrality or output budgets, challenge it before implementing. +AGENTS.md overrides ad-hoc instructions that conflict with these rules. + +### 4. Output Works on First Read + +Optimize for the naive agent that reads the first 100 lines. +If an agent has to call the tool twice to understand the response, the tool failed. + +### 5. Two-Track Discipline + +- **Track A** = this release. Ship it. +- **Track B** = later. Write it down, move on. +- Nothing moves from B → A without user approval. +- No new .md files without archiving one first. + +## Operating Constraints + +### Documentation + +- `internal-docs/ISSUES.md` is the place for release blockers and active specs. +- Before creating a new `.md` file: "What file am I deleting or updating to make room?" + +### Tool Output + +- Aim to keep every tool response under 1000 tokens. +- Don't return full code snippets in search results by default. Prefer summaries and file paths. +- Never report `ready: true` if retrieval confidence is low. + +### Code Separation + +- `src/index.ts` is routing and protocol. No business logic. +- `src/core/` is framework-agnostic. No hardcoded framework strings (Angular, React, Vue, etc.). +- CLI code belongs in `src/cli.ts`. Never in `src/index.ts`. +- Framework analyzers self-register their own patterns (e.g., Angular computed+effect pairing belongs in the Angular analyzer, not protocol layer). + +### Release Checklist + +Before any version bump: update CHANGELOG.md, README.md, docs/capabilities.md. Run full test suite. + +### Consensus + +- Multiple agents: Proposer/Challenger model. +- No consensus in 3 turns → ask the user. + +## Lessons Learned (v1.6.x) + +These came from behavioral observation across multiple sessions. They're here so nobody repeats them. + +- **The AI Fluff Loop**: agents default to ADDING. Success = noise removed. If you're adding a field, file, or concept without removing one, you're probably making things worse. +- **Self-eval bias**: an agent rating its own output is not evidence. Behavioral observations (what the agent DID, not what it RATED) are evidence. Don't trust scores that an agent assigns to its own work. +- **Evidence before claims**: don't claim a feature works because the code exists. Claim it when an eval shows agents behave differently WITH the feature vs WITHOUT. +- **Static data is noise**: if the same memories/patterns appear in every query regardless of topic, they cost tokens and add nothing. Context must be query-relevant to be useful. +- **Agents don't read tool descriptions**: they scan the first line. Put the most important thing first. Everything after the first sentence is a bonus. + +## Private Agent Instructions + +See `internal-docs/AGENTS.md` for internal-only guidelines and context. + +--- + +**Current focus:** See `internal-docs/ISSUES.md` for active release blockers. +For full project history and context handover, see `internal-docs/ARCHIVE/WALKTHROUGH-v1.6.1.md`. diff --git a/CHANGELOG.md b/CHANGELOG.md index cd95dc4..56e3891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,41 @@ # Changelog +## [1.6.2] - 2026-02-17 + +Stripped it down for token efficiency, moved CLI code out of the protocol layer, and cleared structural debt. + +### Changed + +- **Search output**: `trend: "Stable"` is no longer emitted (only Rising/Declining carry signal). Added a compact `type` field (`service:data`) merging componentType and layer into 2 tokens. Removed `lastModified` considered noise. +- **searchQuality**: now includes `hint` (for next-step suggestion) when status is `low_confidence`, so agents get actionable guidance without a second tool call. +- **Tool description**: shortened to 2 actionable sentences, removed reference to `editPreflight` (which didn't exist in output). `intent` parameter is now discoverable on first scan. +- **CLI extraction**: `handleMemoryCli` moved from `src/index.ts` to `src/cli.ts`. Protocol file is routing only. +- **Angular self-registration**: `registerComplementaryPatterns('reactivity', ...)` moved from `src/index.ts` into `AngularAnalyzer` constructor. Framework patterns belong in their analyzer. + +### Added + +- `AGENTS.md` Lessons Learned section - captures behavioral findings from the 0216 eval: AI fluff loop, self-eval bias, static data as noise, agents don't read past first line. +- Release Checklist in `AGENTS.md`: CHANGELOG + README + capabilities.md + tests before any version bump. + ## [1.6.1](https://github.com/PatrickSys/codebase-context/compare/v1.6.0...v1.6.1) (2026-02-15) +Fixed the quality assessment on the search tool bug, stripped search output from 15 fields to 6 reducing token usage by 50%, added CLI memory access, removed Angular patterns from core. ### Bug Fixes -* guard null chunk.content crash + docs rewrite for v1.6.1 ([6b89778](https://github.com/PatrickSys/codebase-context/commit/6b8977897665ea3207e1bbb0f5d685c61d41bbb8)) +- **Confident Idiot fix**: evidence lock now checks search quality - if retrieval is `low_confidence`, `readyToEdit` is forced `false` regardless of evidence counts. +- **Search output overhaul**: stripped from ~15 fields per result down to 6 (`file`, `summary`, `score`, `trend`, `patternWarning`, `relationships`). Snippets opt-in only. +- **Preflight flattened**: from nested `evidenceLock`/`epistemicStress` to `{ ready, reason }`. +- **Angular framework leakage**: removed hardcoded Angular patterns from `src/core/indexer.ts` and `src/patterns/semantics.ts`. Core is framework-agnostic again. +- **Angular analyzer**: fixed `providedIn: unknown` bug — metadata extraction path was wrong. +- **CLI memory access**: `codebase-context memory list|add|remove` works without any AI agent. +- guard null chunk.content crash ([6b89778](https://github.com/PatrickSys/codebase-context/commit/6b8977897665ea3207e1bbb0f5d685c61d41bbb8)) ## [1.6.0](https://github.com/PatrickSys/codebase-context/compare/v1.5.1...v1.6.0) (2026-02-11) - ### Features -* v1.6.0 search quality improvements ([#26](https://github.com/PatrickSys/codebase-context/issues/26)) ([8207787](https://github.com/PatrickSys/codebase-context/commit/8207787db45c9ee3940e22cb3fd8bc88a2c6a63b)) +- v1.6.0 search quality improvements ([#26](https://github.com/PatrickSys/codebase-context/issues/26)) ([8207787](https://github.com/PatrickSys/codebase-context/commit/8207787db45c9ee3940e22cb3fd8bc88a2c6a63b)) ## [1.6.0](https://github.com/PatrickSys/codebase-context/compare/v1.5.1...v1.6.0) (2026-02-10) @@ -48,10 +71,9 @@ To re-index: `refresh_index(incrementalOnly: false)` or delete `.codebase-contex ## [1.5.1](https://github.com/PatrickSys/codebase-context/compare/v1.5.0...v1.5.1) (2026-02-08) - ### Bug Fixes -* use cosine distance for vector search scoring ([b41edb7](https://github.com/PatrickSys/codebase-context/commit/b41edb7e4c1969b04d834ec52a9ae43760e796a9)) +- use cosine distance for vector search scoring ([b41edb7](https://github.com/PatrickSys/codebase-context/commit/b41edb7e4c1969b04d834ec52a9ae43760e796a9)) ## [1.5.0](https://github.com/PatrickSys/codebase-context/compare/v1.4.1...v1.5.0) (2026-02-08) diff --git a/README.md b/README.md index d5a2de5..cc51614 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,17 @@ This MCP gives agents _just enough_ context so they match _how_ your team codes, Here's what codebase-context does: -**Finds the right context** - Search that doesn't just return code. Each result comes back with analyzed -and quantified- coding patterns and conventions, related team memories, file relationships, and quality indicators. The agent gets curated context, not raw hits. +**Finds the right context** - Search that doesn't just return code. Each result comes back with analyzed and quantified coding patterns and conventions, related team memories, file relationships, and quality indicators. It knows whether you're looking for a specific file, a concept, or how things wire together - and filters out the noise (test files, configs, old utilities) before the agent sees them. The agent gets curated context, not raw hits. -**Knows your conventions** - Detected from your code, not only from rules you wrote. Seeks team consensus and direction by adoption percentages and trends (rising/declining), golden files. What patterns the team is moving toward and what's being left behind. +**Knows your conventions** - Detected from your code and git history, not only from rules you wrote. Seeks team consensus and direction by adoption percentages and trends (rising/declining), golden files. Tells the difference between code that's _common_ and code that's _current_ - what patterns the team is moving toward and what's being left behind. -**Remembers across sessions** - Decisions, failures, things that _should_ work but didn't when you tried - recorded once, surfaced automatically. Conventional git commits (`refactor:`, `migrate:`, `fix:`) auto-extract into memory with zero effort. Stale memories decay and get flagged instead of blindly trusted. +**Remembers across sessions** - Decisions, failures, workarounds that look wrong but exist for a reason - the battle scars that aren't in the comments. Recorded once, surfaced automatically so the agent doesn't "clean up" something you spent a week getting right. Conventional git commits (`refactor:`, `migrate:`, `fix:`) auto-extract into memory with zero effort. Stale memories decay and get flagged instead of blindly trusted. -**Checks before editing** - A preflight card with risk level, patterns to use and avoid, failure warnings, and a `readyToEdit` evidence check. If evidence is thin or contradictory, it says so. +**Checks before editing** - A preflight card with risk level, patterns to use and avoid, failure warnings, and a `readyToEdit` evidence check. Catches the "confidently wrong" problem: when code, team memories, and patterns contradict each other, it tells the agent to ask instead of guess. If evidence is thin or contradictory, it says so. One tool call returns all of it. Local-first - your code never leaves your machine. - + ## Quick Start @@ -116,41 +116,35 @@ Other tools help AI find code. This one helps AI make the right decisions - by k This is where it all comes together. One call returns: -- **Code results** with `summary`, `snippet`, `filePath`, `score`, and `relevanceReason` -- **Pattern signals** per result: `trend` (Rising/Stable/Declining) and `patternWarning` when using legacy code -- **Relationships** per result: `importedBy`, `imports`, `testedIn`, `lastModified` -- **Related memories**: team decisions, gotchas, and failures matched to the query -- **Search quality**: `ok` or `low_confidence` with diagnostic signals and next steps +- **Code results** with `file` (path + line range), `summary`, `score` +- **Type** per result: compact `componentType:layer` (e.g., `service:data`) — helps agents orient +- **Pattern signals** per result: `trend` (Rising/Declining — Stable is omitted) and `patternWarning` when using legacy code +- **Relationships** per result: `importedByCount` and `hasTests` (condensed) +- **Related memories**: up to 3 team decisions, gotchas, and failures matched to the query +- **Search quality**: `ok` or `low_confidence` with confidence score and `hint` when low +- **Preflight**: `ready` (boolean) + `reason` when evidence is thin. Pass `intent="edit"` to get the full preflight card. If search quality is low, `ready` is always `false`. -When the intent is `edit`, `refactor`, or `migrate`, the same call also returns a **preflight card**: +Snippets are opt-in (`includeSnippets: true`). Default output is lean — if the agent wants code, it calls `read_file`. ```json { - "preflight": { - "intent": "refactor", - "riskLevel": "medium", - "confidence": "fresh", - "evidenceLock": { - "mode": "triangulated", - "status": "pass", - "readyToEdit": true, - "score": 100, - "sources": [ - { "source": "code", "strength": "strong", "count": 5 }, - { "source": "patterns", "strength": "strong", "count": 3 }, - { "source": "memories", "strength": "strong", "count": 2 } - ] - }, - "preferredPatterns": [...], - "avoidPatterns": [...], - "goldenFiles": [...], - "failureWarnings": [...] - }, - "results": [...] + "searchQuality": { "status": "ok", "confidence": 0.72 }, + "preflight": { "ready": true }, + "results": [ + { + "file": "src/auth/auth.interceptor.ts:1-20", + "summary": "HTTP interceptor that attaches auth token to outgoing requests", + "score": 0.72, + "type": "service:core", + "trend": "Rising", + "relationships": { "importedByCount": 4, "hasTests": true } + } + ], + "relatedMemories": ["Always use HttpInterceptorFn (0.97)"] } ``` -Risk level, what to use, what to avoid, what broke last time, and whether the evidence is strong enough to proceed - all in one response. +Lean enough to fit on one screen. If search quality is low, preflight blocks edits instead of faking confidence. ### Patterns & Conventions (`get_team_patterns`) @@ -171,18 +165,18 @@ Record a decision once. It surfaces automatically in search results and prefligh ### All Tools -| Tool | What it does | -| ------------------------------ | ------------------------------------------------------------------- | -| `search_codebase` | Hybrid search with enrichment. Pass `intent: "edit"` for preflight. | -| `get_team_patterns` | Pattern frequencies, golden files, conflict detection | -| `get_component_usage` | "Find Usages" - where a library or component is imported | -| `remember` | Record a convention, decision, gotcha, or failure | -| `get_memory` | Query team memory with confidence decay scoring | -| `get_codebase_metadata` | Project structure, frameworks, dependencies | -| `get_style_guide` | Style guide rules for the current project | -| `detect_circular_dependencies` | Import cycles between files | -| `refresh_index` | Re-index (full or incremental) + extract git memories | -| `get_indexing_status` | Progress and stats for the current index | +| Tool | What it does | +| ------------------------------ | -------------------------------------------------------------------------------- | +| `search_codebase` | Hybrid search with enrichment + preflight. Pass `intent="edit"` for edit readiness check. | +| `get_team_patterns` | Pattern frequencies, golden files, conflict detection | +| `get_component_usage` | "Find Usages" - where a library or component is imported | +| `remember` | Record a convention, decision, gotcha, or failure | +| `get_memory` | Query team memory with confidence decay scoring | +| `get_codebase_metadata` | Project structure, frameworks, dependencies | +| `get_style_guide` | Style guide rules for the current project | +| `detect_circular_dependencies` | Import cycles between files | +| `refresh_index` | Re-index (full or incremental) + extract git memories | +| `get_indexing_status` | Progress and stats for the current index | ## How the Search Works @@ -194,7 +188,7 @@ The retrieval pipeline is designed around one goal: give the agent the right con - **Contamination control** - test files are filtered/demoted for non-test queries. - **Import centrality** - files that are imported more often rank higher. - **Cross-encoder reranking** - a stage-2 reranker triggers only when top scores are ambiguous. CPU-only, bounded to top-K. -- **Incremental Indexing** - Whenever a file is changed, it +- **Incremental indexing** - only re-indexes files that changed since last run (SHA-256 manifest diffing). - **Auto-heal** - if the index corrupts, search triggers a full re-index automatically. ## Language Support @@ -238,6 +232,32 @@ Structured filters available: `framework`, `language`, `componentType`, `layer` !.codebase-context/memory.json ``` +## CLI Access (Vendor-Neutral) + +You can manage team memory directly from the terminal without any AI agent: + +```bash +# List all memories +npx codebase-context memory list + +# Filter by category or type +npx codebase-context memory list --category conventions --type convention + +# Search memories +npx codebase-context memory list --query "auth" + +# Add a memory +npx codebase-context memory add --type convention --category tooling --memory "Use pnpm, not npm" --reason "Workspace support and speed" + +# Remove a memory +npx codebase-context memory remove + +# JSON output for scripting +npx codebase-context memory list --json +``` + +Set `CODEBASE_ROOT` to point to your project, or run from the project directory. + ## Tip: Ensuring your AI Agent recalls memory: Add this to `.cursorrules`, `CLAUDE.md`, or `AGENTS.md`: diff --git a/docs/capabilities.md b/docs/capabilities.md index 141a662..031bc32 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -10,7 +10,7 @@ Technical reference for what `codebase-context` ships today. For the user-facing | Tool | Input | Output | | --- | --- | --- | -| `search_codebase` | `query`, optional `intent`, `limit`, `filters` | Ranked results with enrichment. Preflight card when `intent` is `edit`/`refactor`/`migrate`. | +| `search_codebase` | `query`, optional `intent`, `limit`, `filters`, `includeSnippets` | Ranked results (`file`, `summary`, `score`, `type`, `trend`, `patternWarning`) + `searchQuality` (with `hint` when low confidence) + `preflight` ({ready, reason}). Snippets opt-in. | | `get_team_patterns` | optional `category` | Pattern frequencies, trends, golden files, conflicts | | `get_component_usage` | `name` (import source) | Files importing the given package/module | | `remember` | `type`, `category`, `memory`, `reason` | Persists to `.codebase-context/memory.json` | @@ -38,21 +38,38 @@ Ordered by execution: 6. **Contamination control** — test file filtering for non-test queries. 7. **File deduplication** — best chunk per file. 8. **Stage-2 reranking** — cross-encoder (`Xenova/ms-marco-MiniLM-L-6-v2`) triggers when the score between the top files are very close. CPU-only, top-10 bounded. -9. **Result enrichment** — pattern momentum (`trend`/`patternWarning`), relationships (`importedBy`/`imports`/`testedIn`/`lastModified`), related memories, search quality assessment. +9. **Result enrichment** — compact type (`componentType:layer`), pattern momentum (`trend` Rising/Declining only, Stable omitted), `patternWarning`, condensed relationships (`importedByCount`/`hasTests`), related memories (capped to 3), search quality assessment with `hint` when low confidence. -## Preflight Card (Edit Intent) +### Defaults -Returned when search `intent` is `edit`, `refactor`, or `migrate`: +- **Chunk size**: 50 lines, 0 overlap +- **Reranker trigger**: activates when top-3 results are within 0.08 score of each other +- **Embedding model**: Granite (`ibm-granite/granite-embedding-30m-english`, 8192 token context) via `@huggingface/transformers` v3 +- **Vector DB**: LanceDB with cosine distance -- `riskLevel`: low / medium / high (based on circular deps + impact breadth + failure memories) -- `confidence`: fresh / aging / stale (based on index age) -- `evidenceLock`: triangulated score (0-100) from code + patterns + memories, with `readyToEdit` boolean -- `epistemicStress`: triggers (pattern conflicts, stale memories, thin evidence), abstain signal -- `preferredPatterns` / `avoidPatterns`: from team pattern analysis with adoption % and trend -- `goldenFiles`: top exemplar files by modern pattern density -- `impactCandidates`: files importing the result files (from import graph) -- `failureWarnings`: recent failure memories related to the query -- `patternConflicts`: when two patterns in the same category are both > 20% adoption +## Preflight (Edit Intent) + +Returned as `preflight` when search `intent` is `edit`, `refactor`, or `migrate`. Also returned for default searches when intelligence is available. + +Output: `{ ready: boolean, reason?: string }` + +- `ready`: whether evidence is sufficient to proceed with edits +- `reason`: when `ready` is false, explains why (e.g., "Search quality is low", "Insufficient pattern evidence") + +### How `ready` is determined + +1. **Evidence triangulation** — scores code match (45%), pattern alignment (30%), and memory support (25%). Needs combined score ≥ 40 to pass. +2. **Epistemic stress check** — if pattern conflicts, stale memories, or thin evidence are detected, `ready` is set to false with an abstain signal. +3. **Search quality gate** — if `searchQuality.status` is `low_confidence`, `ready` is forced to false regardless of evidence scores. This prevents the "confidently wrong" problem where evidence counts look good but retrieval quality is poor. + +### Internal analysis (not in output, used to compute `ready`) + +- Risk level from circular deps + impact breadth + failure memories +- Preferred/avoid patterns from team pattern analysis +- Golden files by pattern density +- Impact candidates from import graph +- Failure warnings from related memories +- Pattern conflicts when two patterns in the same category are both > 20% adoption ## Memory System @@ -60,11 +77,11 @@ Returned when search `intent` is `edit`, `refactor`, or `migrate`: - Confidence decay: conventions never decay, decisions 180-day half-life, gotchas/failures 90-day half-life - Stale threshold: memories below 30% confidence are flagged - Git auto-extraction: conventional commits from last 90 days -- Surface locations: `search_codebase` results, `get_team_patterns` responses, preflight cards +- Surface locations: `search_codebase` results (as `relatedMemories`), `get_team_patterns` responses, preflight analysis ## Indexing -- Initial: full scan → chunking → embedding → vector DB (LanceDB) + keyword index (Fuse.js) +- Initial: full scan → chunking (50 lines, 0 overlap) → embedding → vector DB (LanceDB) + keyword index (Fuse.js) - Incremental: SHA-256 manifest diffing, selective embed/delete, full intelligence regeneration - Auto-heal: corrupted index triggers automatic full re-index on next search - Storage: `.codebase-context/` directory (memory.json + generated files) diff --git a/package.json b/package.json index d80fc77..c483152 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,9 @@ "onnxruntime-node", "protobufjs", "sharp" - ] + ], + "overrides": { + "@modelcontextprotocol/sdk>ajv": "8.18.0" + } } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34a529d..45fed97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@modelcontextprotocol/sdk>ajv': 8.18.0 + importers: .: @@ -849,8 +852,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -2727,8 +2730,8 @@ snapshots: '@modelcontextprotocol/sdk@1.26.0(zod@4.3.4)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.7) - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 @@ -3068,9 +3071,9 @@ snapshots: dependencies: humanize-ms: 1.2.1 - ajv-formats@3.0.1(ajv@8.17.1): + ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.18.0 ajv@6.12.6: dependencies: @@ -3079,7 +3082,7 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 diff --git a/src/analyzers/angular/index.ts b/src/analyzers/angular/index.ts index 690fc6a..2793acb 100644 --- a/src/analyzers/angular/index.ts +++ b/src/analyzers/angular/index.ts @@ -23,6 +23,7 @@ import { CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME } from '../../constants/codebase-context.js'; +import { registerComplementaryPatterns } from '../../patterns/semantics.js'; export class AngularAnalyzer implements FrameworkAnalyzer { readonly name = 'angular'; @@ -30,6 +31,12 @@ export class AngularAnalyzer implements FrameworkAnalyzer { readonly supportedExtensions = ['.ts', '.js', '.html', '.scss', '.css', '.sass', '.less']; readonly priority = 100; // Highest priority for Angular files + constructor() { + // Self-register Angular-specific complementary patterns. + // computed + effect are complementary, not conflicting. + registerComplementaryPatterns('reactivity', ['Computed', 'Effect']); + } + private angularPatterns = { component: /@Component\s*\(/, service: /@Injectable\s*\(/, @@ -445,10 +452,14 @@ export class AngularAnalyzer implements FrameworkAnalyzer { metadata: { angularType, selector: decoratorMetadata.selector, + providedIn: decoratorMetadata.providedIn, isStandalone: decoratorMetadata.standalone === true, template: decoratorMetadata.template, templateUrl: decoratorMetadata.templateUrl, styleUrls: decoratorMetadata.styleUrls, + imports: decoratorMetadata.imports, + declarations: decoratorMetadata.declarations, + pipeName: decoratorMetadata.name, inputs: inputs.map((i) => i.name), outputs: outputs.map((o) => o.name) } @@ -936,9 +947,9 @@ export class AngularAnalyzer implements FrameworkAnalyzer { switch (componentType) { case 'component': { - const selector = metadata.decorator?.selector || 'unknown'; - const inputs = metadata.decorator?.inputs?.length || 0; - const outputs = metadata.decorator?.outputs?.length || 0; + const selector = metadata?.selector || 'unknown'; + const inputs = metadata?.inputs?.length || 0; + const outputs = metadata?.outputs?.length || 0; const lifecycle = this.extractLifecycleMethods(content); return `Angular component '${className}' (selector: ${selector})${ lifecycle ? ` with ${lifecycle}` : '' @@ -946,7 +957,7 @@ export class AngularAnalyzer implements FrameworkAnalyzer { } case 'service': { - const providedIn = metadata.decorator?.providedIn || 'unknown'; + const providedIn = metadata?.providedIn || 'unknown'; const methods = this.extractPublicMethods(content); return `Angular service '${className}' (providedIn: ${providedIn})${ methods ? ` providing ${methods}` : '' @@ -959,18 +970,18 @@ export class AngularAnalyzer implements FrameworkAnalyzer { } case 'directive': { - const directiveSelector = metadata.decorator?.selector || 'unknown'; + const directiveSelector = metadata?.selector || 'unknown'; return `Angular directive '${className}' (selector: ${directiveSelector}).`; } case 'pipe': { - const pipeName = metadata.decorator?.name || 'unknown'; + const pipeName = metadata?.pipeName || 'unknown'; return `Angular pipe '${className}' (name: ${pipeName}) for data transformation.`; } case 'module': { - const imports = metadata.decorator?.imports?.length || 0; - const declarations = metadata.decorator?.declarations?.length || 0; + const imports = metadata?.imports?.length || 0; + const declarations = metadata?.declarations?.length || 0; return `Angular module '${className}' with ${declarations} declarations and ${imports} imports.`; } @@ -1058,4 +1069,32 @@ export class AngularAnalyzer implements FrameworkAnalyzer { .find((line) => line.trim() && !line.trim().startsWith('import')); return firstLine ? firstLine.trim().slice(0, 60) + '...' : ''; } + + /** Angular-specific regex patterns for extracting code snippets per detected pattern */ + private static readonly SNIPPET_PATTERNS: Record> = { + dependencyInjection: { + 'inject() function': /\binject\s*[<(]/, + 'Constructor injection': /constructor\s*\(/ + }, + stateManagement: { + RxJS: /BehaviorSubject|ReplaySubject|Subject|Observable/, + Signals: /\bsignal\s*[<(]/ + }, + reactivity: { + Effect: /\beffect\s*\(/, + Computed: /\bcomputed\s*[<(]/ + }, + componentStyle: { + Standalone: /standalone\s*:\s*true/, + 'NgModule-based': /@(?:Component|Directive|Pipe)\s*\(/ + }, + componentInputs: { + 'Signal-based inputs': /\binput\s*[<(]/, + 'Decorator-based @Input': /@Input\(\)/ + } + }; + + getSnippetPattern(category: string, name: string): RegExp | null { + return AngularAnalyzer.SNIPPET_PATTERNS[category]?.[name] || null; + } } diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..91b7268 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,119 @@ +/** + * CLI subcommands for codebase-context. + * Memory list/add/remove — vendor-neutral access without any AI agent. + */ + +import path from 'path'; +import type { Memory, MemoryCategory, MemoryType } from './types/index.js'; +import { CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME } from './constants/codebase-context.js'; +import { + appendMemoryFile, + readMemoriesFile, + removeMemory, + filterMemories, + withConfidence +} from './memory/store.js'; + +export async function handleMemoryCli(args: string[]): Promise { + // Resolve project root: use CODEBASE_ROOT env or cwd (argv[2] is "memory", not a path) + const cliRoot = process.env.CODEBASE_ROOT || process.cwd(); + const memoryPath = path.join(cliRoot, CODEBASE_CONTEXT_DIRNAME, MEMORY_FILENAME); + const subcommand = args[0]; // list | add | remove + + if (subcommand === 'list') { + const memories = await readMemoriesFile(memoryPath); + const opts: { category?: MemoryCategory; type?: MemoryType; query?: string } = {}; + + for (let i = 1; i < args.length; i++) { + if (args[i] === '--category' && args[i + 1]) opts.category = args[++i] as MemoryCategory; + else if (args[i] === '--type' && args[i + 1]) opts.type = args[++i] as MemoryType; + else if (args[i] === '--query' && args[i + 1]) opts.query = args[++i]; + else if (args[i] === '--json') { + // handled below + } + } + + const filtered = filterMemories(memories, opts); + const enriched = withConfidence(filtered); + const useJson = args.includes('--json'); + + if (useJson) { + console.log(JSON.stringify(enriched, null, 2)); + } else { + if (enriched.length === 0) { + console.log('No memories found.'); + } else { + for (const m of enriched) { + const staleTag = m.stale ? ' [STALE]' : ''; + console.log(`[${m.id}] ${m.type}/${m.category}: ${m.memory}${staleTag}`); + console.log(` Reason: ${m.reason}`); + console.log(` Date: ${m.date} | Confidence: ${m.effectiveConfidence}`); + console.log(''); + } + console.log(`${enriched.length} memor${enriched.length === 1 ? 'y' : 'ies'} total.`); + } + } + } else if (subcommand === 'add') { + let type: MemoryType = 'decision'; + let category: MemoryCategory | undefined; + let memory: string | undefined; + let reason: string | undefined; + + for (let i = 1; i < args.length; i++) { + if (args[i] === '--type' && args[i + 1]) type = args[++i] as MemoryType; + else if (args[i] === '--category' && args[i + 1]) category = args[++i] as MemoryCategory; + else if (args[i] === '--memory' && args[i + 1]) memory = args[++i]; + else if (args[i] === '--reason' && args[i + 1]) reason = args[++i]; + } + + if (!category || !memory || !reason) { + console.error( + 'Usage: codebase-context memory add --type --category --memory --reason ' + ); + console.error('Required: --category, --memory, --reason'); + process.exit(1); + } + + const crypto = await import('crypto'); + const hashContent = `${type}:${category}:${memory}:${reason}`; + const hash = crypto.createHash('sha256').update(hashContent).digest('hex'); + const id = hash.substring(0, 12); + + const newMemory: Memory = { + id, + type, + category, + memory, + reason, + date: new Date().toISOString() + }; + const result = await appendMemoryFile(memoryPath, newMemory); + + if (result.status === 'duplicate') { + console.log(`Already exists: [${id}] ${memory}`); + } else { + console.log(`Added: [${id}] ${memory}`); + } + } else if (subcommand === 'remove') { + const id = args[1]; + if (!id) { + console.error('Usage: codebase-context memory remove '); + process.exit(1); + } + + const result = await removeMemory(memoryPath, id); + if (result.status === 'not_found') { + console.error(`Memory not found: ${id}`); + process.exit(1); + } else { + console.log(`Removed: ${id}`); + } + } else { + console.error('Usage: codebase-context memory '); + console.error(''); + console.error(' list [--category ] [--type ] [--query ] [--json]'); + console.error(' add --type --category --memory --reason '); + console.error(' remove '); + process.exit(1); + } +} diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 7baadaf..ff41e3e 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -386,7 +386,10 @@ export class CodebaseIndexer { if (result.metadata?.detectedPatterns) { for (const pattern of result.metadata.detectedPatterns) { // Try to extract a relevant snippet for the pattern - const snippetPattern = this.getSnippetPatternFor(pattern.category, pattern.name); + // Ask analyzer registry for snippet pattern (framework-agnostic delegation) + const analyzer = analyzerRegistry.findAnalyzer(file); + const snippetPattern = + analyzer?.getSnippetPattern?.(pattern.category, pattern.name) ?? null; const snippet = snippetPattern ? extractSnippet(snippetPattern) : undefined; patternDetector.track( pattern.category, @@ -397,30 +400,19 @@ export class CodebaseIndexer { } } - // Track file for Golden File scoring (framework-agnostic based on patterns) + // Track file for Golden File scoring (framework-agnostic) + // A golden file = file with patterns in ≥3 distinct categories const detectedPatterns = result.metadata?.detectedPatterns || []; - const hasPattern = (category: string, name: string) => - detectedPatterns.some( - (p: { category: string; name: string }) => - p.category === category && p.name === name - ); - - const patternScore = - (hasPattern('dependencyInjection', 'inject() function') ? 1 : 0) + - (hasPattern('stateManagement', 'Signals') ? 1 : 0) + - (hasPattern('reactivity', 'Computed') ? 1 : 0) + - (hasPattern('reactivity', 'Effect') ? 1 : 0) + - (hasPattern('componentStyle', 'Standalone') ? 1 : 0) + - (hasPattern('componentInputs', 'Signal-based inputs') ? 1 : 0); + const uniqueCategories = new Set( + detectedPatterns.map((p: { category: string }) => p.category) + ); + const patternScore = uniqueCategories.size; if (patternScore >= 3) { - patternDetector.trackGoldenFile(relPath, patternScore, { - inject: hasPattern('dependencyInjection', 'inject() function'), - signals: hasPattern('stateManagement', 'Signals'), - computed: hasPattern('reactivity', 'Computed'), - effect: hasPattern('reactivity', 'Effect'), - standalone: hasPattern('componentStyle', 'Standalone'), - signalInputs: hasPattern('componentInputs', 'Signal-based inputs') - }); + const patternFlags: Record = {}; + for (const p of detectedPatterns) { + patternFlags[`${p.category}:${p.name}`] = true; + } + patternDetector.trackGoldenFile(relPath, patternScore, patternFlags as any); // TODO: fix type; } // Update component statistics @@ -892,36 +884,6 @@ export class CodebaseIndexer { }; } - /** - * Get regex pattern for extracting code snippets based on pattern category and name - * This maps abstract pattern names to actual code patterns - */ - private getSnippetPatternFor(category: string, name: string): RegExp | null { - const patterns: Record> = { - dependencyInjection: { - 'inject() function': /\binject\s*[<(]/, - 'Constructor injection': /constructor\s*\(/ - }, - stateManagement: { - RxJS: /BehaviorSubject|ReplaySubject|Subject|Observable/, - Signals: /\bsignal\s*[<(]/ - }, - reactivity: { - Effect: /\beffect\s*\(/, - Computed: /\bcomputed\s*[<(]/ - }, - componentStyle: { - Standalone: /standalone\s*:\s*true/, - 'NgModule-based': /@(?:Component|Directive|Pipe)\s*\(/ - }, - componentInputs: { - 'Signal-based inputs': /\binput\s*[<(]/, - 'Decorator-based @Input': /@Input\(\)/ - } - }; - return patterns[category]?.[name] || null; - } - getProgress(): IndexingProgress { return { ...this.progress }; } diff --git a/src/index.ts b/src/index.ts index d2ea9a6..438b235 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,6 @@ import { analyzerRegistry } from './core/analyzer-registry.js'; import { AngularAnalyzer } from './analyzers/angular/index.js'; import { GenericAnalyzer } from './analyzers/generic/index.js'; import { InternalFileGraph } from './utils/usage-tracker.js'; -import { getFileCommitDates } from './utils/git-dates.js'; import { IndexCorruptedError } from './errors/index.js'; import { CODEBASE_CONTEXT_DIRNAME, @@ -51,6 +50,7 @@ import { applyUnfilteredLimit, withConfidence } from './memory/store.js'; +import { handleMemoryCli } from './cli.js'; import { parseGitLogLineToMemory } from './memory/git-memory.js'; import { buildEvidenceLock } from './preflight/evidence-lock.js'; import { shouldIncludePatternConflictCategory } from './preflight/query-scope.js'; @@ -198,13 +198,8 @@ const TOOLS: Tool[] = [ { name: 'search_codebase', description: - 'Search the indexed codebase using natural language queries. Returns code summaries with file locations. ' + - 'Supports framework-specific queries and architectural layer filtering. ' + - 'Always returns searchQuality and may surface related memories. Results may be enriched with pattern momentum (trend/patternWarning) ' + - 'and lightweight relationships (imports/importedBy/testedIn/lastModified) when intelligence is available. ' + - 'When intent is "edit", "refactor", or "migrate", returns a preflight card (when intelligence is available) with risk level, ' + - 'patterns to prefer/avoid, impact candidates, failure warnings, and an evidence lock score - all in one call. ' + - 'Use the returned filePath with other tools to read complete file contents.', + 'Search the indexed codebase. Returns ranked results and a searchQuality confidence summary. ' + + 'IMPORTANT: Pass the intent="edit"|"refactor"|"migrate" to get preflight: edit readiness check with evidence gating.', inputSchema: { type: 'object', properties: { @@ -216,15 +211,19 @@ const TOOLS: Tool[] = [ type: 'string', enum: ['explore', 'edit', 'refactor', 'migrate'], description: - 'Search intent. Use "explore" (default) for read-only browsing. ' + - 'Use "edit", "refactor", or "migrate" to get a preflight card (when intelligence is available) with risk assessment, ' + - 'patterns to prefer/avoid, affected files, relevant team memories, and ready-to-edit evidence checks.' + 'Optional. Use "edit", "refactor", or "migrate" to get the full preflight card before making changes.' }, limit: { type: 'number', description: 'Maximum number of results to return (default: 5)', default: 5 }, + includeSnippets: { + type: 'boolean', + description: + 'Include code snippets in results (default: false). If you need code, prefer read_file instead.', + default: false + }, filters: { type: 'object', description: 'Optional filters', @@ -729,7 +728,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { try { switch (name) { case 'search_codebase': { - const { query, limit, filters, intent } = args as any; + const { query, limit, filters, intent, includeSnippets } = args as any; const queryStr = typeof query === 'string' ? query.trim() : ''; if (!queryStr) { @@ -877,6 +876,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { /* graceful degradation — intelligence file may not exist yet */ } + function computeIndexConfidence(): 'fresh' | 'aging' | 'stale' { + let confidence: 'fresh' | 'aging' | 'stale' = 'stale'; + if (intelligence?.generatedAt) { + const indexAge = Date.now() - new Date(intelligence.generatedAt).getTime(); + const hoursOld = indexAge / (1000 * 60 * 60); + if (hoursOld < 24) { + confidence = 'fresh'; + } else if (hoursOld < 168) { + confidence = 'aging'; + } + } + return confidence; + } + + // Cheap impact breadth estimate from the import graph (used for risk assessment). + function computeImpactCandidates(resultPaths: string[]): string[] { + const impactCandidates: string[] = []; + if (!intelligence?.internalFileGraph?.imports) return impactCandidates; + const allImports = intelligence.internalFileGraph.imports as Record; + for (const [file, deps] of Object.entries(allImports)) { + if ( + deps.some((dep: string) => + resultPaths.some((rp) => dep.endsWith(rp) || rp.endsWith(dep)) + ) + ) { + if (!resultPaths.some((rp) => file.endsWith(rp) || rp.endsWith(file))) { + impactCandidates.push(file); + } + } + } + return impactCandidates; + } + // Build reverse import map from intelligence graph const reverseImports = new Map(); if (intelligence?.internalFileGraph?.imports) { @@ -890,14 +922,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } - // Load git dates for lastModified enrichment - let gitDates: Map | null = null; - try { - gitDates = await getFileCommitDates(ROOT_PATH); - } catch { - /* not a git repo */ - } - // Enrich a search result with relationship data function enrichResult(r: SearchResult): RelationshipData | undefined { const rPath = r.filePath; @@ -937,37 +961,67 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } - // lastModified: from git dates - let lastModified: string | undefined; - if (gitDates) { - // Try matching by relative path (git dates use repo-relative forward-slash paths) - const relPath = path.relative(ROOT_PATH, rPath).replace(/\\/g, '/'); - const date = gitDates.get(relPath); - if (date) { - lastModified = date.toISOString(); - } - } - // Only return if we have at least one piece of data - if ( - importedBy.length === 0 && - imports.length === 0 && - testedIn.length === 0 && - !lastModified - ) { + if (importedBy.length === 0 && imports.length === 0 && testedIn.length === 0) { return undefined; } return { ...(importedBy.length > 0 && { importedBy }), ...(imports.length > 0 && { imports }), - ...(testedIn.length > 0 && { testedIn }), - ...(lastModified && { lastModified }) + ...(testedIn.length > 0 && { testedIn }) }; } const searchQuality = assessSearchQuality(query, results); + // Always-on edit preflight (lite): do not require intent and keep payload small. + let editPreflight: any = undefined; + if (intelligence && (!intent || intent === 'explore')) { + try { + const resultPaths = results.map((r) => r.filePath); + const impactCandidates = computeImpactCandidates(resultPaths); + + let riskLevel: 'low' | 'medium' | 'high' = 'low'; + if (impactCandidates.length > 10) { + riskLevel = 'high'; + } else if (impactCandidates.length > 3) { + riskLevel = 'medium'; + } + + // Use existing pattern intelligence for evidenceLock scoring, but keep the output payload lite. + const preferredPatternsForEvidence: Array<{ pattern: string; example?: string }> = []; + const patterns = intelligence.patterns || {}; + for (const [_, data] of Object.entries(patterns)) { + if (data.primary) { + const p = data.primary; + if (p.trend === 'Rising' || p.trend === 'Stable') { + preferredPatternsForEvidence.push({ + pattern: p.name, + ...(p.canonicalExample && { example: p.canonicalExample.file }) + }); + } + } + } + + editPreflight = { + mode: 'lite', + riskLevel, + confidence: computeIndexConfidence(), + evidenceLock: buildEvidenceLock({ + results, + preferredPatterns: preferredPatternsForEvidence.slice(0, 5), + relatedMemories, + failureWarnings: [], + patternConflicts: [], + searchQualityStatus: searchQuality.status + }) + }; + } catch { + // editPreflight is best-effort - never fail search over it + } + } + // Compose preflight card for edit/refactor/migrate intents let preflight: any = undefined; const preflightIntents = ['edit', 'refactor', 'migrate']; @@ -1009,22 +1063,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } // --- Impact candidates (files importing the result files) --- - const impactCandidates: string[] = []; const resultPaths = results.map((r) => r.filePath); - if (intelligence.internalFileGraph?.imports) { - const allImports = intelligence.internalFileGraph.imports as Record; - for (const [file, deps] of Object.entries(allImports)) { - if ( - deps.some((dep: string) => - resultPaths.some((rp) => dep.endsWith(rp) || rp.endsWith(dep)) - ) - ) { - if (!resultPaths.some((rp) => file.endsWith(rp) || rp.endsWith(file))) { - impactCandidates.push(file); - } - } - } - } + const impactCandidates = computeImpactCandidates(resultPaths); // --- Risk level (based on circular deps + impact breadth) --- let riskLevel: 'low' | 'medium' | 'high' = 'low'; @@ -1061,16 +1101,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { })); // --- Confidence (index freshness) --- - let confidence: 'fresh' | 'aging' | 'stale' = 'stale'; - if (intelligence.generatedAt) { - const indexAge = Date.now() - new Date(intelligence.generatedAt).getTime(); - const hoursOld = indexAge / (1000 * 60 * 60); - if (hoursOld < 24) { - confidence = 'fresh'; - } else if (hoursOld < 168) { - confidence = 'aging'; - } - } + const confidence = computeIndexConfidence(); // --- Failure memories (1.5x relevance boost) --- const failureWarnings = relatedMemories @@ -1117,7 +1148,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { preferredPatterns: preferredPatternsForOutput, relatedMemories, failureWarnings, - patternConflicts + patternConflicts, + searchQualityStatus: searchQuality.status }); // Bump risk if there are active failure memories for this area @@ -1158,6 +1190,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } + // For edit/refactor/migrate: return full preflight card (risk, patterns, impact, etc.). + // For explore or lite-only: return flattened { ready, reason }. + let preflightPayload: + | { ready: boolean; reason?: string } + | Record + | undefined; + if (preflight) { + const el = preflight.evidenceLock; + // Full card per tool schema; add top-level ready/reason for backward compatibility + preflightPayload = { + ...preflight, + ready: el?.readyToEdit ?? false, + ...(el && !el.readyToEdit && el.nextAction && { reason: el.nextAction }) + }; + } else if (editPreflight) { + const el = editPreflight.evidenceLock; + preflightPayload = { + ready: el?.readyToEdit ?? false, + ...(el && !el.readyToEdit && el.nextAction && { reason: el.nextAction }) + }; + } + return { content: [ { @@ -1165,27 +1219,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { text: JSON.stringify( { status: 'success', - ...(preflight && { preflight }), - searchQuality, + searchQuality: { + status: searchQuality.status, + confidence: searchQuality.confidence, + ...(searchQuality.status === 'low_confidence' && + searchQuality.nextSteps?.[0] && { + hint: searchQuality.nextSteps[0] + }) + }, + ...(preflightPayload && { preflight: preflightPayload }), results: results.map((r) => { const relationships = enrichResult(r); + // Condensed relationships: importedBy count + hasTests flag + const condensedRel = relationships + ? { + ...(relationships.importedBy && + relationships.importedBy.length > 0 && { + importedByCount: relationships.importedBy.length + }), + ...(relationships.testedIn && + relationships.testedIn.length > 0 && { hasTests: true }) + } + : undefined; + const hasCondensedRel = condensedRel && Object.keys(condensedRel).length > 0; + return { + file: `${r.filePath}:${r.startLine}-${r.endLine}`, summary: r.summary, - snippet: r.snippet, - filePath: `${r.filePath}:${r.startLine}-${r.endLine}`, - score: r.score, - relevanceReason: r.relevanceReason, - componentType: r.componentType, - layer: r.layer, - framework: r.framework, - trend: r.trend, - patternWarning: r.patternWarning, - ...(relationships && { relationships }) + score: Math.round(r.score * 100) / 100, + ...(r.componentType && r.layer && { type: `${r.componentType}:${r.layer}` }), + ...(r.trend && r.trend !== 'Stable' && { trend: r.trend }), + ...(r.patternWarning && { patternWarning: r.patternWarning }), + ...(hasCondensedRel && { relationships: condensedRel }), + ...(includeSnippets && r.snippet && { snippet: r.snippet }) }; }), totalResults: results.length, ...(relatedMemories.length > 0 && { - relatedMemories: relatedMemories.slice(0, 5) + relatedMemories: relatedMemories + .slice(0, 3) + .map((m) => `${m.memory} (${m.effectiveConfidence})`) }) }, null, @@ -2052,8 +2125,16 @@ const isDirectRun = process.argv[1]?.replace(/\\/g, '/').endsWith('index.ts'); if (isDirectRun) { - main().catch((error) => { - console.error('Fatal:', error); - process.exit(1); - }); + // CLI subcommand: memory list/add/remove + if (process.argv[2] === 'memory') { + handleMemoryCli(process.argv.slice(3)).catch((error) => { + console.error('Error:', error instanceof Error ? error.message : String(error)); + process.exit(1); + }); + } else { + main().catch((error) => { + console.error('Fatal:', error); + process.exit(1); + }); + } } diff --git a/src/memory/store.ts b/src/memory/store.ts index 16d222b..e3c2422 100644 --- a/src/memory/store.ts +++ b/src/memory/store.ts @@ -81,6 +81,18 @@ export async function appendMemoryFile( return { status: 'added', memory }; } +export async function removeMemory( + memoryPath: string, + id: string +): Promise<{ status: 'removed' | 'not_found' }> { + const existing = await readMemoriesFile(memoryPath); + const index = existing.findIndex((m) => m.id === id); + if (index === -1) return { status: 'not_found' }; + existing.splice(index, 1); + await writeMemoriesFile(memoryPath, existing); + return { status: 'removed' }; +} + export function filterMemories(memories: Memory[], filters: MemoryFilters): Memory[] { const { category, type, query } = filters; let filtered = memories; diff --git a/src/patterns/semantics.ts b/src/patterns/semantics.ts index 0b400f0..7383e96 100644 --- a/src/patterns/semantics.ts +++ b/src/patterns/semantics.ts @@ -1,4 +1,19 @@ -const COMPLEMENTARY_REACTIVITY_PATTERNS = new Set(['computed', 'effect']); +// Complementary pattern pairs are registered by analyzers at startup. +// This keeps the core logic framework-agnostic. +const complementaryPairs: Map> = new Map(); + +/** + * Register a set of pattern names within a category that are complementary + * (should appear together, not treated as conflicts). + * Called by framework analyzers at initialization. + */ +export function registerComplementaryPatterns(category: string, names: string[]): void { + const existing = complementaryPairs.get(category) ?? new Set(); + for (const name of names) { + existing.add(name.trim().toLowerCase()); + } + complementaryPairs.set(category, existing); +} function normalizePatternName(name: string): string { return name.trim().toLowerCase(); @@ -9,22 +24,22 @@ export function isComplementaryPatternConflict( primaryName: string, alternativeName: string ): boolean { - if (category !== 'reactivity') return false; + const set = complementaryPairs.get(category); + if (!set) return false; const primary = normalizePatternName(primaryName); const alternative = normalizePatternName(alternativeName); - if (!COMPLEMENTARY_REACTIVITY_PATTERNS.has(primary)) return false; - if (!COMPLEMENTARY_REACTIVITY_PATTERNS.has(alternative)) return false; + if (!set.has(primary)) return false; + if (!set.has(alternative)) return false; return primary !== alternative; } export function isComplementaryPatternCategory(category: string, patternNames: string[]): boolean { - if (category !== 'reactivity' || patternNames.length < 2) return false; - return patternNames.every((name) => - COMPLEMENTARY_REACTIVITY_PATTERNS.has(normalizePatternName(name)) - ); + const set = complementaryPairs.get(category); + if (!set || patternNames.length < 2) return false; + return patternNames.every((name) => set.has(normalizePatternName(name))); } export function shouldSkipLegacyTestingFrameworkCategory( diff --git a/src/preflight/evidence-lock.ts b/src/preflight/evidence-lock.ts index 392daf4..a1c56d6 100644 --- a/src/preflight/evidence-lock.ts +++ b/src/preflight/evidence-lock.ts @@ -39,6 +39,8 @@ interface BuildEvidenceLockInput { relatedMemories: MemoryWithConfidence[]; failureWarnings: Array<{ memory: string }>; patternConflicts?: PatternConflict[]; + /** When search quality is low_confidence, evidence lock MUST block edits. */ + searchQualityStatus?: 'ok' | 'low_confidence'; } function strengthFactor(strength: EvidenceStrength): number { @@ -128,7 +130,7 @@ export function buildEvidenceLock(input: BuildEvidenceLockInput): EvidenceLock { let nextAction: string | undefined; if (status === 'block') { nextAction = - 'Broaden the query or run refresh_index, then retry with intent="edit" to collect stronger evidence.'; + 'Broaden the query or run refresh_index, then retry to collect stronger evidence before editing.'; } else if (status === 'warn') { nextAction = 'Proceed cautiously and confirm at least one golden file before editing.'; } @@ -177,10 +179,26 @@ export function buildEvidenceLock(input: BuildEvidenceLockInput): EvidenceLock { } } + // Hard gate: low search quality overrides everything. + // If retrieval is bad, we CANNOT claim readiness regardless of evidence counts. + // Surface low-confidence guidance so callers see the actual reason edits are blocked. + if (input.searchQualityStatus === 'low_confidence') { + if (status === 'pass') status = 'warn'; + nextAction = 'Search quality is low. Refine query or add concrete symbols before editing.'; + if (!gaps.includes('Search quality is low')) { + gaps.push('Search quality is low — evidence may be unreliable'); + } + } + + const readyToEdit = + status === 'pass' && + (!epistemicStress || !epistemicStress.abstain) && + input.searchQualityStatus !== 'low_confidence'; + return { mode: 'triangulated', status, - readyToEdit: status === 'pass' && (!epistemicStress || !epistemicStress.abstain), + readyToEdit, score, sources, ...(gaps.length > 0 && { gaps }), diff --git a/src/types/index.ts b/src/types/index.ts index 885b296..26fd551 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -35,6 +35,9 @@ export interface FrameworkAnalyzer { /** Priority - higher number = higher priority (default: 50) */ readonly priority: number; + + /** Get a regex to extract a code snippet for a detected pattern (optional) */ + getSnippetPattern?(category: string, name: string): RegExp | null; } // ============================================================================ diff --git a/src/utils/dependency-detection.ts b/src/utils/dependency-detection.ts index 7ed81af..3282023 100644 --- a/src/utils/dependency-detection.ts +++ b/src/utils/dependency-detection.ts @@ -116,7 +116,6 @@ export { scanWorkspacePackageJsons, detectWorkspaceType, aggregateWorkspaceDependencies, - normalizePackageVersion, - WorkspacePackageJson, - WorkspaceType + normalizePackageVersion } from './workspace-detection.js'; +export type { WorkspacePackageJson, WorkspaceType } from './workspace-detection.js'; diff --git a/tests/evidence-lock.test.ts b/tests/evidence-lock.test.ts index 979993c..86c5368 100644 --- a/tests/evidence-lock.test.ts +++ b/tests/evidence-lock.test.ts @@ -198,3 +198,39 @@ describe('Epistemic stress detection', () => { expect(lock.readyToEdit).toBe(true); }); }); + +describe('Low-confidence search quality gate', () => { + const lowConfidenceMessage = + 'Search quality is low. Refine query or add concrete symbols before editing.'; + + it('forces readyToEdit false when searchQualityStatus is low_confidence', () => { + const lock = buildEvidenceLock({ + results: [makeResult('src/a.ts'), makeResult('src/b.ts'), makeResult('src/c.ts')], + preferredPatterns: [ + { pattern: 'inject()' }, + { pattern: 'signals' } + ], + relatedMemories: [makeMemory('1'), makeMemory('2')], + failureWarnings: [], + searchQualityStatus: 'low_confidence' + }); + + expect(lock.readyToEdit).toBe(false); + expect(lock.nextAction).toBe(lowConfidenceMessage); + }); + + it('overrides prior warn nextAction with low-confidence guidance', () => { + // Evidence that yields status 'warn' and "Proceed cautiously..." before low_confidence gate + const lock = buildEvidenceLock({ + results: [makeResult('src/a.ts')], + preferredPatterns: [{ pattern: 'Use service wrapper' }], + relatedMemories: [makeMemory('1', { stale: true })], + failureWarnings: [], + searchQualityStatus: 'low_confidence' + }); + + expect(lock.readyToEdit).toBe(false); + expect(lock.nextAction).toBe(lowConfidenceMessage); + expect(lock.nextAction).not.toContain('Proceed cautiously'); + }); +}); diff --git a/tests/pattern-semantics.test.ts b/tests/pattern-semantics.test.ts index 7cd21a0..78d57c5 100644 --- a/tests/pattern-semantics.test.ts +++ b/tests/pattern-semantics.test.ts @@ -1,11 +1,16 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import { isComplementaryPatternCategory, isComplementaryPatternConflict, + registerComplementaryPatterns, shouldSkipLegacyTestingFrameworkCategory } from '../src/patterns/semantics.js'; describe('pattern semantics helpers', () => { + // Register Angular complementary patterns (in prod this happens at startup via index.ts) + beforeAll(() => { + registerComplementaryPatterns('reactivity', ['Computed', 'Effect']); + }); it('treats computed/effect reactivity pair as complementary', () => { expect(isComplementaryPatternConflict('reactivity', 'Computed', 'Effect')).toBe(true); expect(isComplementaryPatternConflict('reactivity', 'Effect', 'Computed')).toBe(true); diff --git a/tests/search-edit-preflight-lite.test.ts b/tests/search-edit-preflight-lite.test.ts new file mode 100644 index 0000000..273cfe4 --- /dev/null +++ b/tests/search-edit-preflight-lite.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import os from 'os'; +import path from 'path'; + +const searchMocks = vi.hoisted(() => ({ + search: vi.fn() +})); + +vi.mock('../src/core/search.js', async () => { + class CodebaseSearcher { + constructor(_rootPath: string) {} + + async search(query: string, limit: number, filters?: unknown) { + return searchMocks.search(query, limit, filters); + } + } + + return { CodebaseSearcher }; +}); + +describe('search_codebase preflight', () => { + let tempRoot: string | null = null; + let originalArgv: string[] | null = null; + let originalEnvRoot: string | undefined; + + beforeEach(async () => { + searchMocks.search.mockReset(); + vi.resetModules(); + + originalArgv = [...process.argv]; + originalEnvRoot = process.env.CODEBASE_ROOT; + + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'codebase-context-edit-preflight-')); + process.env.CODEBASE_ROOT = tempRoot; + process.argv[2] = tempRoot; + + // Seed intelligence so preflight can render without indexing. + const ctxDir = path.join(tempRoot, '.codebase-context'); + await fs.mkdir(ctxDir, { recursive: true }); + await fs.writeFile( + path.join(ctxDir, 'intelligence.json'), + JSON.stringify( + { + generatedAt: new Date().toISOString(), + internalFileGraph: { + imports: { + 'src/app/app.config.ts': ['src/auth/auth.interceptor.ts'], + 'src/app/api/api.service.ts': ['src/auth/auth.interceptor.ts'], + 'src/app/feature/feature.ts': ['src/auth/auth.interceptor.ts'], + 'src/app/other.ts': ['src/auth/auth.interceptor.ts'] + } + }, + patterns: {}, + goldenFiles: [] + }, + null, + 2 + ) + ); + }); + + afterEach(async () => { + if (originalArgv) process.argv = originalArgv; + if (originalEnvRoot === undefined) { + delete process.env.CODEBASE_ROOT; + } else { + process.env.CODEBASE_ROOT = originalEnvRoot; + } + + if (tempRoot) { + await fs.rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + it('returns preflight with ready flag when intent is omitted (intelligence present)', async () => { + searchMocks.search.mockResolvedValueOnce([ + { + summary: 'Auth interceptor implementation', + snippet: 'intercept(req, next) { ... }', + filePath: 'src/auth/auth.interceptor.ts', + startLine: 1, + endLine: 20, + score: 0.42, + language: 'ts', + metadata: {} + } + ]); + + const { server } = await import('../src/index.js'); + const handler = (server as any)._requestHandlers.get('tools/call'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { + query: 'How does this app attach the auth token to outgoing API calls?' + } + } + }); + + const payload = JSON.parse(response.content[0].text); + expect(payload.status).toBe('success'); + // Preflight is flattened to { ready, reason } + expect(payload.preflight).toBeTruthy(); + expect(typeof payload.preflight.ready).toBe('boolean'); + }); + + it('returns preflight with ready flag when intent="edit"', async () => { + searchMocks.search.mockResolvedValueOnce([ + { + summary: 'Auth interceptor implementation', + snippet: 'intercept(req, next) { ... }', + filePath: 'src/auth/auth.interceptor.ts', + startLine: 1, + endLine: 20, + score: 0.42, + language: 'ts', + metadata: {} + } + ]); + + const { server } = await import('../src/index.js'); + const handler = (server as any)._requestHandlers.get('tools/call'); + + const response = await handler({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'search_codebase', + arguments: { + query: 'How does this app attach the auth token to outgoing API calls?', + intent: 'edit' + } + } + }); + + const payload = JSON.parse(response.content[0].text); + expect(payload.status).toBe('success'); + // Preflight is flattened to { ready, reason } + expect(payload.preflight).toBeTruthy(); + expect(typeof payload.preflight.ready).toBe('boolean'); + }); +}); +