Implement layered maps and context retrieval
This commit is contained in:
@@ -5,6 +5,7 @@ coverage/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
.pi-map.md
|
.pi-map.md
|
||||||
|
.pi-map.index.md
|
||||||
# Local Pi runtime state
|
# Local Pi runtime state
|
||||||
.atl/
|
.atl/
|
||||||
.pi
|
.pi
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ Pi skill for hierarchical project analysis.
|
|||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
Generates `.pi-map.md` files throughout your project — one per directory — containing a dense, machine-readable summary of that directory's files, exports, dependencies, and architecture. This gives Pi agents instant project comprehension without reading every source file.
|
Generates **paired** project-analysis artifacts throughout your project:
|
||||||
|
|
||||||
|
- `.pi-map.index.md` — routing-first index for deciding what to open next
|
||||||
|
- `.pi-map.md` — orientation-first rich map for understanding a directory
|
||||||
|
|
||||||
|
This gives Pi agents fast navigation plus deeper architectural context without reading every source file up front.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -13,6 +18,56 @@ npm install -g pi-project-map
|
|||||||
project-map init
|
project-map init
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Agent operating model
|
||||||
|
|
||||||
|
### Tier 0
|
||||||
|
Always start with:
|
||||||
|
- `Project Map Protocol`
|
||||||
|
- root `.pi-map.index.md`
|
||||||
|
|
||||||
|
### Tier 1
|
||||||
|
Load likely relevant directory indexes first, then open the strongest-match rich maps.
|
||||||
|
|
||||||
|
### Tier 2
|
||||||
|
Read actual source, tests, config, and docs before editing or making exact runtime claims.
|
||||||
|
|
||||||
|
**Trust boundary:** index routes, map orients, source decides.
|
||||||
|
|
||||||
|
### Retrieval
|
||||||
|
When you have a specific query (e.g. "authentication logic" or "routing metadata"):
|
||||||
|
1. Run `project-map context <query>` or use the Pi tool `project_map_context`
|
||||||
|
2. Read the returned **Context bundle** — it contains relevant indexes, maps, likely files, and symbols
|
||||||
|
3. Always verify critical behavior from source before editing
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
project-map init
|
||||||
|
project-map patch <file>
|
||||||
|
project-map validate [--fix]
|
||||||
|
project-map reinit
|
||||||
|
project-map context <query>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context retrieval
|
||||||
|
|
||||||
|
`project-map context <query>` searches the paired map/index artifacts and returns a compact markdown bundle with the most relevant directories, files, and symbols. No LLM call is needed — it uses deterministic metadata scoring.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create `.pi-project-map.json` in the project root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ignorePatterns": ["node_modules", ".git"],
|
||||||
|
"smallPackageThreshold": 10,
|
||||||
|
"contextBudget": 4000,
|
||||||
|
"autoInjectPrompt": true,
|
||||||
|
"tagCap": 8,
|
||||||
|
"workflowHintCap": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
See [design-doc.md](design-doc.md) for the full specification.
|
See [design-doc.md](design-doc.md) for the full specification.
|
||||||
|
|||||||
@@ -1,108 +1,45 @@
|
|||||||
---
|
---
|
||||||
name: pi-map
|
name: pi-map
|
||||||
description: Generates and maintains hierarchical, machine-readable project analysis files (.pi-map.md) for instant codebase comprehension. Use when working with medium-to-large codebases where understanding architecture, file relationships, and exports without reading every file is valuable. Automatically extracts symbols via AST and LLM heuristics.
|
description: Generates and maintains hierarchical, machine-readable paired project analysis artifacts (.pi-map.index.md and .pi-map.md) for fast codebase navigation and orientation.
|
||||||
---
|
---
|
||||||
|
|
||||||
# pi-project-map
|
# pi-project-map
|
||||||
|
|
||||||
A Pi skill that generates and maintains a hierarchical, machine-readable analysis of a software project. Each directory gets a `.pi-map.md` file containing architectural context, exported symbols, and dependencies.
|
A Pi skill that generates and maintains a paired analysis for each non-ignored directory:
|
||||||
|
|
||||||
|
- `.pi-map.index.md` for routing
|
||||||
|
- `.pi-map.md` for orientation
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
- **Scans** your entire project and creates one `.pi-map.md` per directory
|
- **Scans** your project and creates one paired map/index artifact set per directory
|
||||||
- **Extracts** exports, imports, and dependencies via AST parsing (TypeScript, Python, Go) and LLM heuristics
|
- **Extracts** exports, imports, and dependencies via AST parsing and LLM heuristics
|
||||||
- **Updates** incrementally when files change (full rewrite for small packages, section-level patch for large)
|
- **Updates** generated artifacts after source edits
|
||||||
- **Validates** detects stale entries, missing files, orphaned entries, and changed signatures
|
- **Validates** stale, missing, broken, or inconsistent paired artifacts
|
||||||
|
- **Retrieves** relevant context on demand via deterministic metadata scoring over the paired artifacts
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install globally
|
|
||||||
npm install -g pi-project-map
|
|
||||||
|
|
||||||
# Generate analysis files for the entire project
|
|
||||||
project-map init
|
project-map init
|
||||||
|
|
||||||
# After editing a file, update its directory's analysis
|
|
||||||
project-map patch src/components/Button.tsx
|
project-map patch src/components/Button.tsx
|
||||||
|
|
||||||
# Check for staleness
|
|
||||||
project-map validate
|
project-map validate
|
||||||
|
|
||||||
# Force full regeneration
|
|
||||||
project-map reinit
|
project-map reinit
|
||||||
|
project-map context "authentication logic"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Format
|
## Operating Model
|
||||||
|
|
||||||
Each `.pi-map.md` uses dense markdown optimized for LLM consumption:
|
### Tier 0
|
||||||
|
Read the root `.pi-map.index.md` and the `Project Map Protocol` first.
|
||||||
|
|
||||||
```markdown
|
### Tier 1
|
||||||
# pkg/auth
|
Use indexes first for routing. Open the strongest-match `.pi-map.md` files next.
|
||||||
## role
|
|
||||||
Auth layer: JWT issuance, validation, refresh. Stateless. Dep: pkg/crypto, pkg/db.
|
|
||||||
## files
|
|
||||||
- tokens.ts | JWT gen/val | exp: issueToken, verifyToken, refreshToken | dep: crypto/hmac, db/sessions
|
|
||||||
- middleware.ts | HTTP auth guard | exp: requireAuth, requireRole | dep: tokens/verifyToken
|
|
||||||
## arch
|
|
||||||
Guard pattern on routes. Tokens short-lived (15m), refresh long-lived (7d). Rotation on every use.
|
|
||||||
## dirty
|
|
||||||
-
|
|
||||||
```
|
|
||||||
|
|
||||||
### Abbreviations
|
### Tier 2
|
||||||
|
Read actual source before editing or asserting exact behavior.
|
||||||
|
|
||||||
| Abbreviation | Meaning |
|
**Trust boundary:** index routes, map orients, source decides.
|
||||||
|-------------|---------|
|
|
||||||
| `exp:` | Exported symbols |
|
|
||||||
| `dep:` | Dependencies |
|
|
||||||
| `pkg/` | Internal package reference |
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
### `project-map:init [root]`
|
|
||||||
Runs a full project scan and generates `.pi-map.md` files in every directory.
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```bash
|
|
||||||
project-map init
|
|
||||||
project-map init ~/my-project
|
|
||||||
```
|
|
||||||
|
|
||||||
### `project-map:patch <file-path>`
|
|
||||||
Updates the `.pi-map.md` for the directory containing the given file.
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- Small packages (< 10 files): full rewrite
|
|
||||||
- Large packages (>= 10 files): section-level patch
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```bash
|
|
||||||
project-map patch src/components/Button.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### `project-map:validate [root]`
|
|
||||||
Checks all `.pi-map.md` files for staleness.
|
|
||||||
|
|
||||||
**Detects:**
|
|
||||||
- Missing files (new files not yet in `.pi-map.md`)
|
|
||||||
- Orphaned entries (files listed but deleted)
|
|
||||||
- Stale signatures (exports changed since last scan)
|
|
||||||
- Dirty markers (packages flagged for reconciliation)
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```bash
|
|
||||||
project-map validate
|
|
||||||
```
|
|
||||||
|
|
||||||
### `project-map:reinit [path]`
|
|
||||||
Force full re-initialization. Clears all dirty markers.
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```bash
|
|
||||||
project-map reinit
|
|
||||||
project-map reinit src/components
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -110,43 +47,33 @@ Create `.pi-project-map.json` in the project root:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ignorePatterns": ["node_modules", ".git"],
|
"tagCap": 8,
|
||||||
"smallPackageThreshold": 10,
|
"workflowHintCap": 5
|
||||||
"contextBudget": 4000,
|
|
||||||
"autoInjectPrompt": true
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
|
||||||
|--------|---------|-------------|
|
|
||||||
| `ignorePatterns` | `node_modules`, `.git`, `dist`, etc. | Additional ignore patterns |
|
|
||||||
| `smallPackageThreshold` | `10` | File count threshold for full rewrite vs patch |
|
|
||||||
| `contextBudget` | `4000` | Max tokens to spend on analysis files |
|
|
||||||
| `autoInjectPrompt` | `true` | Auto-inject maintenance instructions |
|
|
||||||
|
|
||||||
## Agent Instructions
|
## Agent Instructions
|
||||||
|
|
||||||
When `.pi-map.md` files exist in the project:
|
When project map artifacts exist in the repo:
|
||||||
|
|
||||||
1. **Read them at session start** to build project understanding without scanning every file
|
1. Start with the root `.pi-map.index.md`
|
||||||
2. **Run `project-map:patch <path>`** after editing any source file
|
2. Use indexes first to route into the right directory
|
||||||
3. **Run `project-map:validate`** if you suspect staleness before making architectural decisions
|
3. Read the local `.pi-map.md` plus source before editing
|
||||||
4. **Trust the analysis** for orientation, but verify critical details by reading source when needed
|
4. Run `project-map patch <path>` after editing source
|
||||||
|
5. Run `project-map validate` before freshness-sensitive architectural decisions
|
||||||
|
6. For **targeted navigation**, use `project_map_context` (Pi tool) or `project-map context` (CLI) with a natural-language query. It returns a compact markdown bundle with the strongest-match indexes, maps, likely files, and symbols.
|
||||||
|
|
||||||
## Best Practices
|
## Retrieval Model
|
||||||
|
|
||||||
- Run `project-map:init` after cloning a new repository
|
`project_map_context` and `project-map context` implement **index-first retrieval**:
|
||||||
- Run `project-map:reinit` periodically (daily/weekly) to catch changes made outside the agent
|
|
||||||
- Add `.pi-map.md` to `.gitignore` — they are derived artifacts
|
|
||||||
- For very large projects (> 1000 directories), consider running `init` on subdirectories
|
|
||||||
|
|
||||||
## Supported Languages
|
1. Score every directory's paired map/index metadata against the query
|
||||||
|
2. Keep the top 3 strongest matches
|
||||||
|
3. Expand those matches into:
|
||||||
|
- Relevant indexes (routing-first)
|
||||||
|
- Relevant maps (orientation-first)
|
||||||
|
- Likely files
|
||||||
|
- Relevant symbols (only when useful)
|
||||||
|
4. Return a stable markdown bundle titled `# Context bundle: <query>`
|
||||||
|
|
||||||
| Language | AST Parsing | Heuristic Extraction |
|
**Trust boundary still applies:** the bundle routes and orients, but source decides. Always read actual source before editing.
|
||||||
|----------|------------|---------------------|
|
|
||||||
| TypeScript / TSX | Full | Full |
|
|
||||||
| JavaScript / JSX | Full | Full |
|
|
||||||
| Python | Partial | Full |
|
|
||||||
| Go | Partial | Full |
|
|
||||||
| Rust | Partial | Full |
|
|
||||||
| Other | - | Full (filename + regex patterns) |
|
|
||||||
|
|||||||
+56
-25
@@ -19,19 +19,49 @@ Enable a Pi coding agent to understand a software project's architecture and cod
|
|||||||
- **Dense markdown**: Hierarchical headings, bullet points, and abbreviations are natively understood by LLMs and extremely token-efficient.
|
- **Dense markdown**: Hierarchical headings, bullet points, and abbreviations are natively understood by LLMs and extremely token-efficient.
|
||||||
|
|
||||||
### Structure
|
### Structure
|
||||||
Each directory in the project gets one analysis file named `.pi-map.md` (hidden by default, excluded from git via `.gitignore`).
|
Each non-ignored directory in the project gets **two** hidden analysis files:
|
||||||
|
|
||||||
|
- `.pi-map.index.md` — routing-first index
|
||||||
|
- `.pi-map.md` — orientation-first rich map
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# <relative-path> (index)
|
||||||
|
dir: <relative-path>
|
||||||
|
## role
|
||||||
|
<short routing summary>
|
||||||
|
## parent
|
||||||
|
<parent links or ->
|
||||||
|
## children
|
||||||
|
<child links or ->
|
||||||
|
## files
|
||||||
|
<likely files>
|
||||||
|
## links
|
||||||
|
index: <self-index>
|
||||||
|
map: <self-map>
|
||||||
|
## workflows
|
||||||
|
<compact task -> route hints>
|
||||||
|
## dirty
|
||||||
|
<timestamp or ->
|
||||||
|
```
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# <relative-path>
|
# <relative-path>
|
||||||
|
dir: <relative-path>
|
||||||
|
index: <sibling-index>
|
||||||
## role
|
## role
|
||||||
<one-line package role> | Dep: <comma-separated upstream deps>
|
<one-line package role>
|
||||||
## files
|
## files
|
||||||
- <filename> | <one-line purpose> | exp: <exported symbols> | dep: <internal/external deps>
|
- <filename> | <one-line purpose> | exp: <exported symbols> | dep: <internal/external deps>
|
||||||
- <filename> | <one-line purpose> | exp: <exported symbols> | dep: <internal/external deps>
|
|
||||||
## arch
|
## arch
|
||||||
<free-form architectural notes: patterns, data flow, invariants, design decisions>
|
<free-form architectural notes>
|
||||||
|
## tags
|
||||||
|
<compact tags>
|
||||||
|
## symbols
|
||||||
|
<prioritized symbols>
|
||||||
|
## workflows
|
||||||
|
<compact workflow hints>
|
||||||
## dirty
|
## dirty
|
||||||
<timestamp or flag indicating staleness>
|
<timestamp or ->
|
||||||
```
|
```
|
||||||
|
|
||||||
### Abbreviation Conventions
|
### Abbreviation Conventions
|
||||||
@@ -152,16 +182,14 @@ For each directory (depth-first):
|
|||||||
|
|
||||||
### Patch Pipeline
|
### Patch Pipeline
|
||||||
```
|
```
|
||||||
When agent edits file(s) in directory:
|
When agent edits file(s):
|
||||||
1. Determine patch strategy:
|
1. Classify patch mode: auto, small, or structural.
|
||||||
- If directory has < 10 files: full rewrite.
|
2. Always regenerate the changed directory pair:
|
||||||
- Else: section-level patch for changed file(s) only.
|
- `.pi-map.md`
|
||||||
2. For each changed file:
|
- `.pi-map.index.md`
|
||||||
a. Recompute SHA-256.
|
3. For small changes: refresh ancestor indexes.
|
||||||
b. Check cache. If miss or stale, call LLM with retries/backoff.
|
4. For structural changes: refresh ancestor map/index pairs.
|
||||||
3. Re-run AST extraction on changed file(s) if applicable.
|
5. Use `validate --fix` to repair affected chains when paired artifacts are stale or missing.
|
||||||
4. Update `## files` section (rewrite or patch).
|
|
||||||
5. Update `## dirty` flag if full regeneration is deferred.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. LLM Prompt Design
|
## 4. LLM Prompt Design
|
||||||
@@ -220,18 +248,20 @@ The LLM client's response is parsed to extract `PURPOSE`, `DEPS`, `CONCEPTS`, `R
|
|||||||
## 5. Consumption Model
|
## 5. Consumption Model
|
||||||
|
|
||||||
### Session Start
|
### Session Start
|
||||||
1. Agent discovers all `.pi-map.md` files (e.g., via `find . -name ".pi-map.md"`).
|
1. Agent reads the root `Project Map Protocol` and root `.pi-map.index.md`.
|
||||||
2. Agent reads **all** files into context. This is a one-time cost at session start.
|
2. For architecture/system or ambiguous tasks, agent also reads the root `.pi-map.md`.
|
||||||
3. Agent constructs an internal mental model of the project hierarchy.
|
3. Agent does **not** preload every directory map by default.
|
||||||
|
|
||||||
### During Session
|
### During Session
|
||||||
- An **auto-injected summary** stays in context (e.g., a condensed top-level `.pi-map.md` or a synthesized project overview).
|
- Use directory indexes first to decide what to open next.
|
||||||
- When the agent needs deeper detail about a specific package, it already has the full `.pi-map.md` in memory from step 2.
|
- For **targeted queries**, run `project_map_context` (tool) or `project-map context` (CLI). The retrieval engine scores all paired metadata and returns a compact markdown bundle with the top-3 strongest matches: indexes, maps, likely files, and symbols.
|
||||||
- If the agent enters a new package not yet loaded, it reads that package's `.pi-map.md` on demand.
|
- Open the strongest-match `.pi-map.md` files for richer orientation.
|
||||||
|
- Read actual source before editing or making exact runtime claims.
|
||||||
|
|
||||||
### Context Management
|
### Context Management
|
||||||
- For very large projects, the agent may summarize or prune the initial read, keeping only the top N levels of the hierarchy in active context.
|
- Tier 0 stays tiny and stable.
|
||||||
- The skill can provide a "context budget" parameter: max tokens to spend on analysis files.
|
- Tier 1 loads only likely relevant indexes/maps.
|
||||||
|
- Tier 2 is real source, tests, config, and docs.
|
||||||
|
|
||||||
## 6. Stale Data Mitigation
|
## 6. Stale Data Mitigation
|
||||||
|
|
||||||
@@ -297,9 +327,10 @@ pi-project-map/
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Custom Tools
|
### Custom Tools
|
||||||
- `project-map:init` — Run full project scan. Creates all `.pi-map.md` files.
|
- `project-map:init` — Run full project scan. Creates all paired map/index artifacts.
|
||||||
- `project-map:patch <file-path>` — Update analysis for a specific file/directory.
|
- `project-map:patch <file-path>` — Update analysis for a specific file/directory.
|
||||||
- `project-map:validate` — Run consistency check across all `.pi-map.md` files.
|
- `project-map:validate` — Run consistency check across all paired artifacts.
|
||||||
|
- `project-map:context <query>` — Retrieve a compact markdown bundle of the most relevant directories, files, and symbols for a natural-language query.
|
||||||
- `project-map:reinit [path]` — Force re-initialization of entire project or subtree.
|
- `project-map:reinit [path]` — Force re-initialization of entire project or subtree.
|
||||||
|
|
||||||
### Prompt Hook
|
### Prompt Hook
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Design: Layered Map Protocol
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Phase | **Design** |
|
||||||
|
| Based on | [Spec](spec.md) |
|
||||||
|
| Next | Tasks |
|
||||||
|
|
||||||
|
## Design summary
|
||||||
|
|
||||||
|
This change introduces a paired, navigation-first artifact model without changing the core mission of `pi-project-map`. The implementation should keep the existing discover → analyze → merge → render pipeline, but route both outputs through a shared intermediate directory model:
|
||||||
|
|
||||||
|
- `.pi-map.index.md` = routing-first view
|
||||||
|
- `.pi-map.md` = orientation-first view
|
||||||
|
|
||||||
|
Source remains the final authority.
|
||||||
|
|
||||||
|
## Affected areas
|
||||||
|
|
||||||
|
### Source files likely to change
|
||||||
|
- `src/format.ts`
|
||||||
|
- `src/init.ts`
|
||||||
|
- `src/patch.ts`
|
||||||
|
- `src/validate.ts`
|
||||||
|
- `pi-extension.ts`
|
||||||
|
- `README.md`
|
||||||
|
- `SKILL.md`
|
||||||
|
- `design-doc.md`
|
||||||
|
- CLI argument parsing for `validate --fix` if not already supported
|
||||||
|
|
||||||
|
### New modules likely to appear
|
||||||
|
- `src/root-index.ts` or equivalent shared index generation helper
|
||||||
|
- `src/directory-model.ts` or equivalent shared intermediate model helper
|
||||||
|
- optional config support for workflow/tag caps if not already present in config handling
|
||||||
|
|
||||||
|
## Architecture changes
|
||||||
|
|
||||||
|
### 1. Shared intermediate model
|
||||||
|
Build one structured directory model per mapped directory, then render two views from it.
|
||||||
|
|
||||||
|
The model should carry at least:
|
||||||
|
- directory identity (`dir`)
|
||||||
|
- sibling/parent/child relationships
|
||||||
|
- likely files
|
||||||
|
- role/arch summaries
|
||||||
|
- tags
|
||||||
|
- prioritized symbols
|
||||||
|
- workflow candidates in normalized schema
|
||||||
|
- stale/freshness markers
|
||||||
|
|
||||||
|
This shared model is the main guard against map/index drift.
|
||||||
|
|
||||||
|
### 2. Universal paired artifacts
|
||||||
|
During init/reinit, generate both `.pi-map.md` and `.pi-map.index.md` for every non-ignored directory.
|
||||||
|
|
||||||
|
#### Index rendering target
|
||||||
|
Indexes should be small and role-first, using this fixed order:
|
||||||
|
- `# <relative-path> (index)`
|
||||||
|
- `## role`
|
||||||
|
- `## parent`
|
||||||
|
- `## children`
|
||||||
|
- `## files`
|
||||||
|
- `## links`
|
||||||
|
- `## workflows`
|
||||||
|
- `## dirty`
|
||||||
|
|
||||||
|
Index content should include:
|
||||||
|
- `dir`
|
||||||
|
- short role summary
|
||||||
|
- parent link
|
||||||
|
- child directory links
|
||||||
|
- likely files
|
||||||
|
- explicit sibling/child `index:` / `map:` links
|
||||||
|
- up to configured workflow hints
|
||||||
|
- dirty marker
|
||||||
|
|
||||||
|
Indexes must omit dependency edges and keep architectural prose minimal.
|
||||||
|
|
||||||
|
#### Rich-map rendering target
|
||||||
|
Maps should remain dense but richer, using this fixed order:
|
||||||
|
- `# <relative-path>`
|
||||||
|
- `## role`
|
||||||
|
- `## files`
|
||||||
|
- `## arch`
|
||||||
|
- `## tags`
|
||||||
|
- `## symbols`
|
||||||
|
- `## workflows`
|
||||||
|
- `## dirty`
|
||||||
|
|
||||||
|
Map content should include:
|
||||||
|
- `dir`
|
||||||
|
- sibling `index:` link near the top
|
||||||
|
- slightly richer file lines than today when useful
|
||||||
|
- prioritized symbol lists in large directories
|
||||||
|
|
||||||
|
### 3. Root artifact behavior
|
||||||
|
Root `.pi-map.index.md` is the Tier 0 routing artifact.
|
||||||
|
|
||||||
|
Root `.pi-map.md` remains rich and must:
|
||||||
|
- point to root index,
|
||||||
|
- restate the trust boundary,
|
||||||
|
- be suitable for automatic loading on architecture/system or ambiguous tasks.
|
||||||
|
|
||||||
|
Both root artifacts should explicitly encode the Tier 0 behavior.
|
||||||
|
|
||||||
|
### 4. Workflow synthesis
|
||||||
|
Workflow hints may be LLM-heavy, but they must be normalized into a deterministic shape before rendering.
|
||||||
|
|
||||||
|
Suggested normalized fields:
|
||||||
|
- `task`
|
||||||
|
- `read`
|
||||||
|
- `index`
|
||||||
|
- `map`
|
||||||
|
- optional `files`
|
||||||
|
|
||||||
|
Indexes should render workflow hints in compact task→route form.
|
||||||
|
|
||||||
|
Because the user wants cross-repo workflow routing allowed, workflow synthesis may target other directories outside the local subtree when the hints are strong.
|
||||||
|
|
||||||
|
### 5. Configuration
|
||||||
|
Paired mode is the only mode for now, but these knobs should be configurable:
|
||||||
|
- workflow-hint cap (default 5)
|
||||||
|
- rich-map tag cap (default 8)
|
||||||
|
|
||||||
|
Configuration belongs in shared config handling rather than ad hoc generator constants.
|
||||||
|
|
||||||
|
Suggested config shape for v1:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
workflowHintCap: 5
|
||||||
|
tagCap: 8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Patch behavior
|
||||||
|
Patch should always regenerate, never hand-edit, generated artifacts.
|
||||||
|
|
||||||
|
#### Changed directory
|
||||||
|
Always regenerate both the local `.pi-map.md` and `.pi-map.index.md`.
|
||||||
|
|
||||||
|
#### Ancestor strategy
|
||||||
|
Use auto-detected patch sizing with explicit override available from both CLI and tool surfaces.
|
||||||
|
|
||||||
|
- **Small default:** up to 3 related files, no structure/routing shift
|
||||||
|
- refresh ancestor indexes only
|
||||||
|
- **Large/structural:** export changes, directory shape changes, routing metadata impact, or wider cross-area effect
|
||||||
|
- refresh both ancestor indexes and ancestor maps
|
||||||
|
|
||||||
|
This suggests a helper that computes the affected chain and the required artifact depth for each ancestor.
|
||||||
|
|
||||||
|
### 7. Validation behavior
|
||||||
|
Validation should become pair-aware, not just file-list-aware.
|
||||||
|
|
||||||
|
Checks should include:
|
||||||
|
- missing sibling artifact
|
||||||
|
- sibling `dir` mismatch
|
||||||
|
- broken parent/child/sibling references
|
||||||
|
- stale `dirty` markers
|
||||||
|
- stale or missing likely-file references
|
||||||
|
- disagreement between map/index routing references
|
||||||
|
|
||||||
|
Validation failures for missing/stale indexes are hard failures.
|
||||||
|
|
||||||
|
When validation classifies a patch as structural, the output should explain the main reason concisely (for example export change, directory-shape change, or routing impact).
|
||||||
|
|
||||||
|
Repair should be exposed through `validate --fix`, which regenerates the affected chain by default.
|
||||||
|
|
||||||
|
Validation and repair messaging should stay concise and inline, with the main structural reason plus the chosen `patchMode` when repair runs.
|
||||||
|
|
||||||
|
### 8. Prompt and doc integration
|
||||||
|
`pi-extension.ts`, `SKILL.md`, and docs should explicitly teach:
|
||||||
|
- Tier 0 = protocol + root index
|
||||||
|
- indexes first, maps next
|
||||||
|
- local rich map + source before edits
|
||||||
|
- validate before freshness-sensitive handoff
|
||||||
|
- `index routes, map orients, source decides`
|
||||||
|
|
||||||
|
### 9. External dependency boundary
|
||||||
|
This design should stay self-contained.
|
||||||
|
|
||||||
|
- No vector store is required.
|
||||||
|
- No Engram dependency is required.
|
||||||
|
- Retrieval remains local, deterministic, and based on generated paired artifacts.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Pair generation increases implementation size | Keep a shared intermediate model and split delivery into slices |
|
||||||
|
| Workflow hints become noisy | Omit uncertain hints and normalize schema before rendering |
|
||||||
|
| Ancestor refresh logic becomes brittle | Centralize affected-chain computation and test small vs structural cases |
|
||||||
|
| Pair drift creates false confidence | Use common `dir` identity and pair-aware validation |
|
||||||
|
| Config knobs spread inconsistently | Keep workflow/tag caps in shared config loading |
|
||||||
|
|
||||||
|
## Settled v1 defaults
|
||||||
|
|
||||||
|
- Root `.pi-map.md` points to root `.pi-map.index.md` rather than repeating route summaries.
|
||||||
|
- The shared intermediate directory model remains internal; behavior is tested through rendered outputs.
|
||||||
|
- Validation surfaces structural reasons as concise inline messages.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Proposal: Layered Map Protocol
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Phase | **Proposal** |
|
||||||
|
| Based on | User interview + current design doc |
|
||||||
|
| Next | Spec |
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The current design centers consumption on discovering all `.pi-map.md` files and loading them at session start, with pruning for larger repositories. That scales poorly and frames maps as bulk context instead of as a navigation system.
|
||||||
|
|
||||||
|
The repo currently lacks:
|
||||||
|
|
||||||
|
1. a clear root routing artifact,
|
||||||
|
2. an explicit trust/use protocol,
|
||||||
|
3. a layered loading model,
|
||||||
|
4. fast per-directory navigation artifacts,
|
||||||
|
5. a clean split between routing metadata and richer orientation metadata.
|
||||||
|
|
||||||
|
## Proposed change
|
||||||
|
|
||||||
|
Adopt a layered navigation model built around a **paired artifact** for every non-ignored directory:
|
||||||
|
|
||||||
|
- `.pi-map.index.md` → fast routing
|
||||||
|
- `.pi-map.md` → richer orientation
|
||||||
|
|
||||||
|
### Tier model
|
||||||
|
- **Tier 0 — always injected:** Project Map Protocol + root `.pi-map.index.md`
|
||||||
|
- **Tier 1 — task-start load:** likely relevant directory indexes first, then rich maps for strongest matches
|
||||||
|
- **Tier 2 — exact verification:** source files, tests, configs, and docs
|
||||||
|
|
||||||
|
### Paired artifacts per directory
|
||||||
|
Every non-ignored directory should generate both:
|
||||||
|
- a rich `.pi-map.md`
|
||||||
|
- a lightweight `.pi-map.index.md`
|
||||||
|
|
||||||
|
The index is for movement. The map is for understanding. Source remains the final authority.
|
||||||
|
|
||||||
|
This change stays self-contained: it does not add a vector store, Engram dependency, or other external memory/retrieval backend.
|
||||||
|
|
||||||
|
### Operating model
|
||||||
|
- Root `.pi-map.md` remains rich, but points to root `.pi-map.index.md` for traversal.
|
||||||
|
- Each index points to its sibling rich map.
|
||||||
|
- Each rich map points back to its sibling index.
|
||||||
|
- If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||||
|
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- [ ] Generate `.pi-map.index.md` for every non-ignored directory
|
||||||
|
- [ ] Keep `.pi-map.md` as the richer sibling artifact for every non-ignored directory
|
||||||
|
- [ ] Emit a Project Map Protocol in docs/prompt guidance and in generated root artifacts
|
||||||
|
- [ ] Replace eager "read all maps" guidance with layered retrieval guidance
|
||||||
|
- [ ] Define fixed index/map responsibilities and section order
|
||||||
|
- [ ] Cascade freshness and repair logic across changed directory pairs and affected ancestors
|
||||||
|
- [ ] Add strict validation for missing/stale/broken index-map pairs
|
||||||
|
- [ ] Update docs and skill guidance to reflect the new model
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- [ ] Add a query-driven retrieval command such as `project-map context <query>`
|
||||||
|
- [ ] Preserve backward compatibility for agents that only understand the old preload-only model
|
||||||
|
- [ ] Replace source verification with map-based authority
|
||||||
|
- [ ] Redesign the core AST/LLM extraction pipeline beyond what is needed to feed the new artifacts
|
||||||
|
|
||||||
|
## Decisions from grilling
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Change split | Two specs |
|
||||||
|
| This change slug | `layered-map-protocol` |
|
||||||
|
| Follow-up change slug | `map-context-retrieval` |
|
||||||
|
| Backward compatibility required | No |
|
||||||
|
| Artifact model | Every non-ignored directory gets both `.pi-map.md` and `.pi-map.index.md` |
|
||||||
|
| Root Tier 0 | Project Map Protocol + root `.pi-map.index.md` |
|
||||||
|
| Root rich map | Auto-read for architecture/system questions and ambiguous tasks |
|
||||||
|
| Root relationship | Root map points to root index |
|
||||||
|
| Retrieval mode | Hybrid auto-load, indexes first |
|
||||||
|
| Patch strategy | Changed dir updates both; ancestors refresh by size/structure rules |
|
||||||
|
| Validation | Hard-fail on missing/stale indexes, repair via `validate --fix` |
|
||||||
|
| Prompt trust boundary | index routes, map orients, source decides |
|
||||||
|
| Extra retrieval backends | None in this change |
|
||||||
|
| SDD mode | Interactive |
|
||||||
|
| Artifact store | OpenSpec |
|
||||||
|
| PR strategy | Auto-forecast |
|
||||||
|
| Review budget | 400 lines |
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- [ ] Every non-ignored directory has both a fast index and a richer map
|
||||||
|
- [ ] The root artifacts clearly tell an agent how to navigate and when to read source
|
||||||
|
- [ ] The documented consumption model no longer requires reading every `.pi-map.md` up front
|
||||||
|
- [ ] Validation fails on stale or missing paired artifacts and offers a repair path
|
||||||
|
- [ ] Patch/refresh behavior keeps routing trustworthy without requiring full reinit for small changes
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Spec: Layered Map Protocol
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Phase | **Spec** |
|
||||||
|
| Based on | [Proposal](proposal.md) |
|
||||||
|
| Next | Design |
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`pi-project-map` must move from a bulk-preload model to a layered navigation model. The system should always provide a small root protocol plus root index, selectively load likely relevant directory indexes first, load rich maps next for the strongest matches, and require source verification before edits or precise runtime claims.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| # | Question | Answer |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Per-directory artifact model | Every non-ignored directory gets both `.pi-map.md` and `.pi-map.index.md` |
|
||||||
|
| 2 | Tier 0 default | Project Map Protocol + root `.pi-map.index.md` |
|
||||||
|
| 3 | Root rich map auto-read | Yes for architecture/system questions and ambiguous tasks |
|
||||||
|
| 4 | Retrieval style before query tool exists | Hybrid auto-load, indexes first |
|
||||||
|
| 5 | Backward compatibility with old preload-only model | Not required |
|
||||||
|
| 6 | Query-driven context bundling | Deferred to follow-up spec `map-context-retrieval` |
|
||||||
|
| 7 | Mode shape | Paired artifact mode is the only mode for now |
|
||||||
|
| 8 | Index section naming | Simple fixed names: `role`, `parent`, `children`, `files`, `links`, `workflows`, `dirty` |
|
||||||
|
| 9 | Rich map section naming | Current-plus fixed names: `role`, `files`, `arch`, `tags`, `symbols`, `workflows`, `dirty` |
|
||||||
|
| 10 | Patch-size override surface | Available from both CLI and tool surfaces |
|
||||||
|
| 11 | Structural classification output | Validation explains the main structural reason concisely |
|
||||||
|
| 12 | Extra retrieval backends | No vector store or Engram dependency in this change |
|
||||||
|
|
||||||
|
## Functional requirements
|
||||||
|
|
||||||
|
### 1. Paired artifacts per directory
|
||||||
|
Every non-ignored directory must generate two artifacts:
|
||||||
|
- `.pi-map.index.md` — quick-routing index
|
||||||
|
- `.pi-map.md` — rich orientation document
|
||||||
|
|
||||||
|
Both artifacts must share a common identity marker such as `dir: <relative-path>`.
|
||||||
|
|
||||||
|
### 2. Root entrypoint
|
||||||
|
The tool must generate a root navigation layer in both root artifacts.
|
||||||
|
|
||||||
|
#### Root Tier 0 behavior
|
||||||
|
Tier 0 must always include:
|
||||||
|
- Project Map Protocol
|
||||||
|
- root `.pi-map.index.md`
|
||||||
|
|
||||||
|
The generated root artifacts must explicitly state this behavior.
|
||||||
|
|
||||||
|
#### Root rich map behavior
|
||||||
|
Root `.pi-map.md` must:
|
||||||
|
- point to root `.pi-map.index.md` for traversal,
|
||||||
|
- restate the trust boundary,
|
||||||
|
- be suitable for automatic loading on architecture/system questions and ambiguous tasks.
|
||||||
|
|
||||||
|
### 3. Project Map Protocol
|
||||||
|
The generated system must define an explicit protocol with at least these rules:
|
||||||
|
1. Read the Project Map Protocol and root `.pi-map.index.md` first.
|
||||||
|
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||||
|
3. Load indexes before rich maps during task-start navigation.
|
||||||
|
4. Read the local rich map and actual source before editing.
|
||||||
|
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||||
|
6. If source and generated artifacts disagree, trust source.
|
||||||
|
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||||
|
8. After editing source, run `project_map_patch` for each changed file.
|
||||||
|
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||||
|
|
||||||
|
### 4. Index contract
|
||||||
|
Every `.pi-map.index.md` must use a fixed section order and remain optimized for fast routing.
|
||||||
|
|
||||||
|
#### Required behavior
|
||||||
|
- Keep architectural prose minimal.
|
||||||
|
- Omit uncertain workflow/file hints rather than labeling confidence.
|
||||||
|
- Allow up to 5 workflow hints by default, with configuration support.
|
||||||
|
- Do not include dependency edges in indexes.
|
||||||
|
- Include explicit parent link when a parent mapped directory exists.
|
||||||
|
- For child directory entries, point to both child `index:` and child `map:`.
|
||||||
|
- Leaf-directory indexes remain tiny but still follow the same contract.
|
||||||
|
|
||||||
|
#### Fixed section order
|
||||||
|
Each index must use this fixed order:
|
||||||
|
1. `## role`
|
||||||
|
2. `## parent`
|
||||||
|
3. `## children`
|
||||||
|
4. `## files`
|
||||||
|
5. `## links`
|
||||||
|
6. `## workflows`
|
||||||
|
7. `## dirty`
|
||||||
|
|
||||||
|
#### Minimum contents
|
||||||
|
Each index must minimally support:
|
||||||
|
- title header in the form `# <relative-path> (index)`
|
||||||
|
- `dir` identity marker
|
||||||
|
- short role summary
|
||||||
|
- parent link where applicable
|
||||||
|
- child directories where applicable
|
||||||
|
- likely files
|
||||||
|
- explicit sibling/child `index:` / `map:` links
|
||||||
|
- short workflow hints in hybrid task→route form
|
||||||
|
- `## dirty`
|
||||||
|
|
||||||
|
### 5. Rich map contract
|
||||||
|
Every `.pi-map.md` must use a fixed section order and remain optimized for dense orientation.
|
||||||
|
|
||||||
|
#### Required behavior
|
||||||
|
- Rich maps must explicitly link to their sibling `.pi-map.index.md`.
|
||||||
|
- Rich maps may keep some routing overlap, but indexes remain primary for navigation.
|
||||||
|
- `files` entries should be slightly richer than the current dense line format when useful.
|
||||||
|
- Tags should default to up to 8, with configuration support.
|
||||||
|
- Symbols should be prioritized for density in large directories using structural importance plus LLM refinement.
|
||||||
|
- Cross-repo workflow routing should live mostly in indexes, not maps.
|
||||||
|
|
||||||
|
#### Fixed section order
|
||||||
|
Each rich map must use this fixed order:
|
||||||
|
1. `## role`
|
||||||
|
2. `## files`
|
||||||
|
3. `## arch`
|
||||||
|
4. `## tags`
|
||||||
|
5. `## symbols`
|
||||||
|
6. `## workflows`
|
||||||
|
7. `## dirty`
|
||||||
|
|
||||||
|
#### Minimum contents
|
||||||
|
Each rich map must minimally support:
|
||||||
|
- title header in the form `# <relative-path>`
|
||||||
|
- `dir` identity marker
|
||||||
|
- sibling `index:` link near the top
|
||||||
|
- richer but still compact workflow guidance
|
||||||
|
- `## dirty`
|
||||||
|
|
||||||
|
### 6. Workflow generation
|
||||||
|
Workflow hints may be LLM-heavy, but they must be normalized into a deterministic schema before rendering.
|
||||||
|
|
||||||
|
#### Index workflow rules
|
||||||
|
- Primary form is hybrid task→route, for example: `change CLI behavior -> read: src/.pi-map.index.md, src/cli.ts`
|
||||||
|
- Cross-repo routing is allowed.
|
||||||
|
- Uncertain hints should be omitted.
|
||||||
|
- Indexes should optimize for fast routing, not standalone completeness.
|
||||||
|
|
||||||
|
### 7. Shared generation model
|
||||||
|
Map and index generation must come from a shared intermediate directory model rather than unrelated passes.
|
||||||
|
|
||||||
|
The renderer may present different views, but the underlying directory identity and source facts must stay aligned.
|
||||||
|
|
||||||
|
### 8. Patch behavior
|
||||||
|
`project_map_patch` must never manually edit generated artifacts; it must regenerate them.
|
||||||
|
|
||||||
|
#### Changed directory
|
||||||
|
For every changed source file, patch must update both artifacts for the changed directory.
|
||||||
|
|
||||||
|
#### Ancestor refresh behavior
|
||||||
|
Patch sizing must be auto-detected with an explicit override path available from both CLI and tool surfaces.
|
||||||
|
|
||||||
|
- **Small change default:** up to 3 closely related files and no structural/routing shift
|
||||||
|
- changed directory: refresh map + index
|
||||||
|
- ancestors: refresh indexes only
|
||||||
|
- **Large/structural change:** exports, directory shape, routing metadata, or broader cross-area impact
|
||||||
|
- changed directory: refresh map + index
|
||||||
|
- ancestors: refresh both map + index
|
||||||
|
|
||||||
|
### 9. Validation behavior
|
||||||
|
`project_map_validate` must hard-fail when paired artifacts are missing or structurally stale.
|
||||||
|
|
||||||
|
Validation output should prefer concise inline wording, for example:
|
||||||
|
- `[structural] src/foo.ts: export change`
|
||||||
|
- `[stale-index] src/.pi-map.index.md: likely files out of date`
|
||||||
|
|
||||||
|
Validation must check at least:
|
||||||
|
- missing `.pi-map.index.md` or `.pi-map.md`
|
||||||
|
- stale `## dirty` sections in either artifact
|
||||||
|
- broken `index:` / `map:` references
|
||||||
|
- stale `files:` pointers
|
||||||
|
- map/index pair disagreement on routing hints or referenced areas
|
||||||
|
- sibling pair identity mismatches
|
||||||
|
|
||||||
|
When validation classifies a patch as structural, it should report the main reason concisely, such as export change, directory-shape change, or routing impact.
|
||||||
|
|
||||||
|
#### Repair
|
||||||
|
Repair must be exposed through `project-map validate --fix`.
|
||||||
|
|
||||||
|
Default repair scope is the affected directory pair plus the necessary ancestor chain.
|
||||||
|
Repair summaries should also report the chosen `patchMode` concisely, for example: `repaired affected chain (patchMode: structural)`.
|
||||||
|
|
||||||
|
### 10. Visibility and policy
|
||||||
|
`.pi-map.index.md` must follow the same hidden/ignored policy as `.pi-map.md`.
|
||||||
|
|
||||||
|
Agents should never manually edit either artifact directly.
|
||||||
|
|
||||||
|
## Non-functional requirements
|
||||||
|
|
||||||
|
- Prefer density over completeness.
|
||||||
|
- Keep indexes routing-first and maps understanding-first.
|
||||||
|
- Keep the protocol simple enough to emit in root artifacts and prompt guidance.
|
||||||
|
- Support configuration for top-level `workflowHintCap` and `tagCap` keys.
|
||||||
|
- Do not require a vector store, Engram, or other extra retrieval backend for this design.
|
||||||
|
|
||||||
|
## User flows
|
||||||
|
|
||||||
|
### Flow 1: Agent starts work on a repo
|
||||||
|
1. Agent reads the Project Map Protocol and root `.pi-map.index.md`
|
||||||
|
2. For architecture/system or ambiguous tasks, agent also reads root `.pi-map.md`
|
||||||
|
3. Agent opens likely relevant directory indexes first
|
||||||
|
4. Agent opens the strongest-match rich maps
|
||||||
|
5. Agent reads source only when exact behavior or editing is involved
|
||||||
|
|
||||||
|
### Flow 2: Agent edits a file
|
||||||
|
1. Agent routes with index data
|
||||||
|
2. Agent reads the local rich map and actual source
|
||||||
|
3. Agent edits source
|
||||||
|
4. Agent runs `project_map_patch` for changed files
|
||||||
|
5. Patch refreshes the changed directory pair and the required ancestor chain
|
||||||
|
|
||||||
|
### Flow 3: Validation and repair
|
||||||
|
1. Agent runs `project_map_validate`
|
||||||
|
2. Missing/stale pair failures are reported as hard errors
|
||||||
|
3. Agent runs `project-map validate --fix` when repair is desired
|
||||||
|
4. The affected chain is regenerated
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Every non-ignored directory produces both `.pi-map.md` and `.pi-map.index.md`
|
||||||
|
- [ ] Tier 0 behavior is both documented and emitted in generated root artifacts
|
||||||
|
- [ ] Indexes have a fixed routing-first contract with parent/child/sibling links
|
||||||
|
- [ ] Rich maps have a fixed orientation-first contract with sibling index links
|
||||||
|
- [ ] `project_map_patch` refreshes both changed-directory artifacts and size-appropriate ancestor artifacts
|
||||||
|
- [ ] `project_map_validate` hard-fails on missing/stale paired artifacts and supports `--fix`
|
||||||
|
- [ ] Workflow-hint count and tag cap are configurable
|
||||||
|
- [ ] Prompt guidance expresses: index routes, map orients, source decides
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Tasks: Layered Map Protocol
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Phase | **Tasks** |
|
||||||
|
| Based on | [Design](design.md) |
|
||||||
|
| Next | Apply |
|
||||||
|
|
||||||
|
## Delivery slices
|
||||||
|
|
||||||
|
### Slice 1: Shared model and paired rendering
|
||||||
|
**Scope**: shared intermediate directory model, universal paired artifacts, fixed section names/order, root Tier 0 entrypoint
|
||||||
|
**Review goal**: establish the core paired architecture without retrieval-command work
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. [ ] Add a shared intermediate directory model for paired map/index generation
|
||||||
|
2. [ ] Extend render/parse support for paired `.pi-map.md` and `.pi-map.index.md` outputs with the agreed fixed section names/order
|
||||||
|
3. [ ] Add generation support for `.pi-map.index.md` in every non-ignored directory
|
||||||
|
4. [ ] Enrich root artifacts with explicit Tier 0 behavior and sibling links
|
||||||
|
5. [ ] Add/update tests for map/index rendering, fixed order, and sibling identity
|
||||||
|
|
||||||
|
### Slice 2: Routing metadata and workflow normalization
|
||||||
|
**Scope**: likely files, parent/child/sibling links, workflow schema, rich-map metadata density rules
|
||||||
|
**Review goal**: make the navigation model operational on real output
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. [ ] Add index-first routing metadata generation from AST/structure + LLM judgment
|
||||||
|
2. [ ] Normalize workflow hints into a deterministic schema before rendering
|
||||||
|
3. [ ] Add parent/child/sibling `index:` / `map:` references
|
||||||
|
4. [ ] Implement rich-map density rules for slightly richer file lines, tag cap, and prioritized symbols
|
||||||
|
5. [ ] Add/update tests for routing hints, leaf indexes, and workflow omission on low confidence
|
||||||
|
|
||||||
|
### Slice 3: Patch sizing, cascade refresh, and repair
|
||||||
|
**Scope**: changed-directory pair refresh, ancestor-chain refresh rules, validation hard failures, `validate --fix`
|
||||||
|
**Review goal**: make freshness and repair trustworthy
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. [ ] Make `project_map_patch` always regenerate both artifacts for the changed directory
|
||||||
|
2. [ ] Add auto-detected small vs structural patch sizing with explicit override in both CLI and tool surfaces
|
||||||
|
3. [ ] Refresh ancestor indexes for small changes and ancestor map+index pairs for structural changes
|
||||||
|
4. [ ] Expand validation for pair-aware checks, hard failures on missing/stale indexes, and concise structural-reason output
|
||||||
|
5. [ ] Add/confirm `project-map validate --fix` repair flow for affected-chain regeneration
|
||||||
|
6. [ ] Add/update tests for patch classification, ancestor refresh, and repair behavior
|
||||||
|
|
||||||
|
### Slice 4: Prompt guidance, config knobs, and docs
|
||||||
|
**Scope**: Project Map Protocol, Tier 0 runtime guidance, workflow/tag config, docs rewrite
|
||||||
|
**Review goal**: align runtime behavior and documentation with the final operating model
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. [ ] Update `pi-extension.ts` prompt guidance to teach the layered paired protocol
|
||||||
|
2. [ ] Update `SKILL.md` to describe indexes as routing and maps as orientation
|
||||||
|
3. [ ] Update `README.md` and `design-doc.md` consumption guidance
|
||||||
|
4. [ ] Add config support for workflow-hint cap and rich-map tag cap
|
||||||
|
5. [ ] Keep the paired-artifact model self-contained with no vector-store or Engram dependency
|
||||||
|
6. [ ] Add/update tests for config-driven caps and root Tier 0 guidance
|
||||||
|
|
||||||
|
## Acceptance checklist
|
||||||
|
|
||||||
|
- [ ] Every non-ignored directory has both `.pi-map.md` and `.pi-map.index.md`
|
||||||
|
- [ ] Root Tier 0 behavior is emitted in generated root artifacts
|
||||||
|
- [ ] Indexes are routing-first, role-first, and include parent/child/sibling links
|
||||||
|
- [ ] Rich maps are orientation-first and include sibling index links
|
||||||
|
- [ ] Changed-directory patch always regenerates both artifacts
|
||||||
|
- [ ] Ancestor refresh follows small vs structural rules
|
||||||
|
- [ ] Validation hard-fails on missing/stale paired artifacts and supports `validate --fix`
|
||||||
|
- [ ] Workflow-hint count and tag cap are configurable
|
||||||
|
- [ ] No vector-store or Engram dependency is introduced
|
||||||
|
- [ ] `npm run typecheck` passes
|
||||||
|
- [ ] `npm test` passes
|
||||||
|
- [ ] `npm run lint` passes
|
||||||
|
|
||||||
|
## Review workload note
|
||||||
|
This change crosses format, generation, patching, validation, config, docs, and prompt guidance. Keep implementation in narrow, reviewable slices and avoid one oversized PR.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Design: Map Context Retrieval
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Phase | **Design** |
|
||||||
|
| Based on | [Spec](spec.md) |
|
||||||
|
| Next | Tasks |
|
||||||
|
|
||||||
|
## Design summary
|
||||||
|
|
||||||
|
This change adds a lightweight retrieval layer on top of paired project-map metadata. It should not require a new external storage system, vector store, or Engram dependency. Instead, it should scan root and directory indexes first, expand to rich maps and files from the strongest candidates, and emit an agent-friendly bundle.
|
||||||
|
|
||||||
|
## Likely implementation areas
|
||||||
|
|
||||||
|
- `pi-extension.ts` for the first-class tool surface
|
||||||
|
- `src/index.ts` for exports
|
||||||
|
- new retrieval module, e.g. `src/context.ts` or `src/retrieve.ts`
|
||||||
|
- shared parsing/model code from the layered protocol
|
||||||
|
- `src/cli/*` for follow-up CLI wiring
|
||||||
|
- docs and skill guidance
|
||||||
|
|
||||||
|
## Retrieval pipeline
|
||||||
|
|
||||||
|
1. Accept a natural-language `query`
|
||||||
|
2. Read root `.pi-map.index.md` and, when needed, root `.pi-map.md`
|
||||||
|
3. Parse paired index/map metadata through the shared format/model layer
|
||||||
|
4. Score candidate directories primarily from indexes
|
||||||
|
5. Keep the top 3 candidates by default
|
||||||
|
6. Expand strongest candidates to rich maps, likely files, and symbols
|
||||||
|
7. Emit a compact markdown bundle with stable section order and retrieval-specific title `# Context bundle: <query>`
|
||||||
|
|
||||||
|
## Candidate scoring inputs
|
||||||
|
|
||||||
|
- direct term matches in workflow hints
|
||||||
|
- direct term matches in likely files
|
||||||
|
- parent/child/sibling link context
|
||||||
|
- direct term matches in tags
|
||||||
|
- direct term matches in symbols
|
||||||
|
- path/name similarity
|
||||||
|
- root workflow routing hits
|
||||||
|
|
||||||
|
A first implementation can use deterministic weighted lexical scoring.
|
||||||
|
|
||||||
|
Candidate-selection reasoning does not need to be exposed by default.
|
||||||
|
|
||||||
|
## Parsing strategy
|
||||||
|
|
||||||
|
Reuse the paired-artifact parser/model from the layered protocol. Avoid retrieval-specific ad hoc parsing.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Retrieval becomes too fuzzy to trust | Keep output advisory and always direct agent back to source |
|
||||||
|
| Pair parser complexity grows | Extend shared format/model logic instead of command-local parsing |
|
||||||
|
| Command output becomes too large | Limit result count and keep instructions terse |
|
||||||
|
|
||||||
|
## Open choices
|
||||||
|
|
||||||
|
1. Exact scoring weights for workflow hints vs files vs tags vs symbols
|
||||||
|
2. Whether the tool should support optional structured output later
|
||||||
|
3. Whether broad architecture queries should be allowed to exceed the usual top-3 default in a later version
|
||||||
|
4. Whether later versions should support optional root-rich-map inclusion as a flag
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Proposal: Map Context Retrieval
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Phase | **Proposal** |
|
||||||
|
| Based on | Follow-up to `layered-map-protocol` |
|
||||||
|
| Next | Spec |
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
After the layered map protocol lands, the agent will know it should load root protocol + root index first, then route through per-directory indexes and rich maps. But the repo will still lack an ergonomic retrieval primitive that can turn a natural-language task into a compact context bundle.
|
||||||
|
|
||||||
|
Without that helper, the agent still has to manually inspect directory indexes, rank candidate areas, and expand to rich maps/files. That weakens the value of the new routing layer.
|
||||||
|
|
||||||
|
## Proposed change
|
||||||
|
|
||||||
|
Add a retrieval tool and command shape:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
project-map context "<user task>"
|
||||||
|
```
|
||||||
|
|
||||||
|
The first-class surface should be a Pi tool. CLI support can follow the same shape.
|
||||||
|
|
||||||
|
The retrieval result should return a compact context bundle containing:
|
||||||
|
- relevant indexes,
|
||||||
|
- strongest-match rich maps,
|
||||||
|
- likely source files,
|
||||||
|
- relevant symbols when useful,
|
||||||
|
- short instructions on what to read next.
|
||||||
|
|
||||||
|
This remains a local metadata-driven retrieval feature, not a vector-store or Engram-backed memory system.
|
||||||
|
## In scope
|
||||||
|
|
||||||
|
- [ ] Add Pi tool support for `project-map context <query>`
|
||||||
|
- [ ] Add CLI support using the same shape after the tool contract is stable
|
||||||
|
- [ ] Rank results using paired index/map metadata from the layered protocol
|
||||||
|
- [ ] Return a compact, markdown-first, agent-friendly context bundle
|
||||||
|
- [ ] Document recommended usage from prompts and docs
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- [ ] Full semantic search across arbitrary source contents
|
||||||
|
- [ ] Replacing source verification with map-based answers
|
||||||
|
- [ ] Building a heavyweight external indexer or vector database
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- [ ] A natural-language task can resolve to likely indexes/maps/files/symbols without manual repo scanning
|
||||||
|
- [ ] Output is compact enough to inject directly into the next step
|
||||||
|
- [ ] Retrieval builds on generated paired metadata rather than bypassing it
|
||||||
|
- [ ] The first version is tool-first and markdown-first
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Spec: Map Context Retrieval
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Phase | **Spec** |
|
||||||
|
| Based on | [Proposal](proposal.md) |
|
||||||
|
| Next | Design |
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a retrieval-oriented `context` command that turns a user task into a compact project-map routing bundle. This change depends on the paired root/per-directory index+map metadata introduced by `layered-map-protocol`.
|
||||||
|
|
||||||
|
## Dependency
|
||||||
|
|
||||||
|
This change should not begin implementation before `layered-map-protocol` has landed enough metadata to support routing and ranking.
|
||||||
|
|
||||||
|
## Functional requirements
|
||||||
|
|
||||||
|
### 1. Tool-first surface
|
||||||
|
Pi should expose `project-map context` as a first-class tool action so agents can request a context bundle directly.
|
||||||
|
|
||||||
|
For v1, the tool input should be minimal:
|
||||||
|
- `query: string`
|
||||||
|
|
||||||
|
### 2. CLI follow-up surface
|
||||||
|
The CLI should support the same conceptual shape:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
project-map context "<query>"
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI support may follow after the tool contract is stable.
|
||||||
|
The CLI should mirror the same main input name: `query`.
|
||||||
|
### 3. Bundle contents
|
||||||
|
A context bundle must contain, at minimum:
|
||||||
|
- relevant indexes
|
||||||
|
- strongest-match relevant maps
|
||||||
|
- likely files
|
||||||
|
- relevant symbols when useful
|
||||||
|
- short next-step instructions
|
||||||
|
### 4. Ranking behavior
|
||||||
|
The command must rank candidates using generated metadata such as:
|
||||||
|
- root workflow hints
|
||||||
|
- directory index routing hints
|
||||||
|
- parent/child/sibling link structure
|
||||||
|
- package tags
|
||||||
|
- symbol references
|
||||||
|
- task/workflow entries
|
||||||
|
- `files:` / `index:` / `map:` targets
|
||||||
|
|
||||||
|
Indexes should be the first routing layer. Rich maps and symbols should expand from the strongest routed candidates.
|
||||||
|
|
||||||
|
By default, retrieval should usually surface the top 3 candidate directories, with adaptive omission of weaker rich maps when confidence drops.
|
||||||
|
### 5. Trust boundary
|
||||||
|
The bundle must instruct the agent to:
|
||||||
|
- read indexes first for orientation,
|
||||||
|
- read rich maps next when deeper context is needed,
|
||||||
|
- read source before editing or asserting exact behavior.
|
||||||
|
|
||||||
|
## Output shape
|
||||||
|
|
||||||
|
The first version should be markdown-first, with stable, schema-like sections optimized for LLM consumption rather than human prose.
|
||||||
|
|
||||||
|
Default section order:
|
||||||
|
1. `query`
|
||||||
|
2. `relevant indexes`
|
||||||
|
3. `relevant maps`
|
||||||
|
4. `likely files`
|
||||||
|
5. `relevant symbols`
|
||||||
|
6. `instructions`
|
||||||
|
|
||||||
|
The retrieval title format should remain retrieval-specific:
|
||||||
|
- `# Context bundle: <query>`
|
||||||
|
|
||||||
|
A representative response shape:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Context bundle: validation stale signatures
|
||||||
|
|
||||||
|
## query
|
||||||
|
validation stale signatures
|
||||||
|
|
||||||
|
## relevant indexes
|
||||||
|
- .pi-map.index.md
|
||||||
|
- src/.pi-map.index.md
|
||||||
|
- tests/.pi-map.index.md
|
||||||
|
|
||||||
|
## relevant maps
|
||||||
|
- src/.pi-map.md
|
||||||
|
- tests/.pi-map.md
|
||||||
|
|
||||||
|
## likely files
|
||||||
|
- src/validate.ts
|
||||||
|
- src/ast/ast-extract.ts
|
||||||
|
- tests/validate.test.ts
|
||||||
|
|
||||||
|
## relevant symbols
|
||||||
|
- validateMaps
|
||||||
|
- generateDirectoryMap
|
||||||
|
- extractFileAST
|
||||||
|
|
||||||
|
## instructions
|
||||||
|
Read the indexes first, then the strongest-match rich maps, then verify behavior from source before editing.
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool should omit weak/unhelpful symbol output rather than forcing a noisy symbol section.
|
||||||
|
## Non-functional requirements
|
||||||
|
|
||||||
|
- Output must be compact enough for prompt injection.
|
||||||
|
- Ranking should be deterministic enough to test.
|
||||||
|
- The implementation should prefer lightweight heuristics over heavyweight indexing.
|
||||||
|
- Retrieval should reuse the paired-artifact parsing layer instead of ad hoc string slicing.
|
||||||
|
- Retrieval should not require a vector store, Engram, or another external memory backend.
|
||||||
|
- The first version should accept natural language queries first; structured filters can come later.
|
||||||
|
- Candidate explanations are not required by default.
|
||||||
|
- Result-count override and root-rich-map inclusion can come later if needed, but are not part of v1.
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Pi exposes `project-map context <query>` as a tool-first surface
|
||||||
|
- [ ] CLI support follows the same shape after the tool contract is stable
|
||||||
|
- [ ] Retrieval output includes indexes, strongest-match maps, files, and instructions
|
||||||
|
- [ ] Symbol hints appear only when paired metadata makes them useful
|
||||||
|
- [ ] Ranking respects the index-first routing model and usually returns top 3 candidates
|
||||||
|
- [ ] Docs show when to use the retrieval command
|
||||||
|
- [ ] Trust-boundary guidance remains explicit
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Tasks: Map Context Retrieval
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Phase | **Tasks** |
|
||||||
|
| Based on | [Design](design.md) |
|
||||||
|
| Next | Apply |
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. [ ] Add Pi tool support for `project-map context` with `query` as the main input
|
||||||
|
2. [ ] Add retrieval module for scoring paired index/map metadata
|
||||||
|
3. [ ] Reuse or extend shared paired-artifact parsing/model code
|
||||||
|
4. [ ] Rank candidate directories from indexes first, then expand to maps/files/symbols
|
||||||
|
5. [ ] Emit compact markdown-first context bundles with stable section order and retrieval-specific `Context bundle` title
|
||||||
|
6. [ ] Add tests for retrieval ranking, top-3 default behavior, and output shape
|
||||||
|
7. [ ] Add CLI support after the tool contract is stable
|
||||||
|
8. [ ] Update docs and skill guidance for retrieval usage
|
||||||
|
## Acceptance checklist
|
||||||
|
|
||||||
|
- [ ] Tool works for natural-language queries via `query`
|
||||||
|
- [ ] Output includes relevant indexes, strongest-match maps, likely files, symbols when useful, and instructions
|
||||||
|
- [ ] Retrieval depends on paired metadata rather than raw source scanning
|
||||||
|
- [ ] Ranking follows the index-first routing model with a top-3 default
|
||||||
|
- [ ] `npm run typecheck` passes
|
||||||
|
- [ ] `npm test` passes
|
||||||
|
- [ ] `npm run lint` passes
|
||||||
|
|
||||||
|
## Sequencing note
|
||||||
|
This change is intentionally follow-up work. It should start after the layered map protocol produces reliable paired routing metadata.
|
||||||
|
|
||||||
|
The first delivery surface is the Pi tool. CLI support follows the stabilized tool contract.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
project:
|
||||||
|
name: pi-project-map
|
||||||
|
description: Pi skill and CLI for generating hierarchical .pi-map.md files for AI-oriented codebase comprehension
|
||||||
|
|
||||||
|
stack:
|
||||||
|
runtime:
|
||||||
|
language: TypeScript
|
||||||
|
platform: Node.js
|
||||||
|
cli:
|
||||||
|
entrypoint: dist/cli.js
|
||||||
|
extension:
|
||||||
|
entrypoint: pi-extension.ts
|
||||||
|
testing:
|
||||||
|
framework: Vitest
|
||||||
|
commands:
|
||||||
|
- npm test
|
||||||
|
- npm run typecheck
|
||||||
|
- npm run lint
|
||||||
|
|
||||||
|
sdd:
|
||||||
|
execution_mode: interactive
|
||||||
|
artifact_store: openspec
|
||||||
|
chained_pr_strategy: auto-forecast
|
||||||
|
review_budget_lines: 400
|
||||||
|
|
||||||
|
phase_rules:
|
||||||
|
explore_before_proposal: true
|
||||||
|
spec_before_design: true
|
||||||
|
design_before_tasks: true
|
||||||
|
verify_before_archive: true
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# OpenSpec Project Context: pi-project-map
|
||||||
|
|
||||||
|
## Product
|
||||||
|
`pi-project-map` is a Pi skill plus CLI that generates hierarchical `.pi-map.md` files throughout a repository so coding agents can orient quickly without reading every source file.
|
||||||
|
|
||||||
|
## Current architecture
|
||||||
|
- `src/init.ts`: full project discovery and map generation
|
||||||
|
- `src/patch.ts`: incremental map updates after file edits
|
||||||
|
- `src/validate.ts`: map freshness and discrepancy checks
|
||||||
|
- `src/format.ts`: markdown map rendering/parsing
|
||||||
|
- `pi-extension.ts`: Pi tool registration and prompt guidance
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- `.pi-map.md` files are orientation aids, not runtime truth.
|
||||||
|
- Exact behavior must still be verified from source before editing or making precise claims.
|
||||||
|
- Tooling changes should preserve the core `init` / `patch` / `validate` / `reinit` workflow unless a spec explicitly changes it.
|
||||||
|
- Keep review slices small; prefer staged, reviewable changes over one large refactor.
|
||||||
|
|
||||||
|
## Active planning theme
|
||||||
|
Move from "load all maps up front" toward a layered retrieval model where the root artifacts teach the agent how to navigate, then package maps and source files are loaded on demand or by focused auto-selection.
|
||||||
+82
-15
@@ -7,6 +7,7 @@ import {
|
|||||||
patchFile,
|
patchFile,
|
||||||
validateMaps,
|
validateMaps,
|
||||||
reinitPath,
|
reinitPath,
|
||||||
|
retrieveContext,
|
||||||
} from "./src/index.js";
|
} from "./src/index.js";
|
||||||
import { createLLMClient } from "./src/llm/llm-client.js";
|
import { createLLMClient } from "./src/llm/llm-client.js";
|
||||||
import { LLMError } from "./src/llm/llm-error.js";
|
import { LLMError } from "./src/llm/llm-error.js";
|
||||||
@@ -55,7 +56,12 @@ function isDirty(content: string): boolean {
|
|||||||
return content.includes("## dirty") && !content.includes("## dirty\n-");
|
return content.includes("## dirty") && !content.includes("## dirty\n-");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProgressBar(completed: number, total: number, currentFile?: string, width = 20): string {
|
function renderProgressBar(
|
||||||
|
completed: number,
|
||||||
|
total: number,
|
||||||
|
currentFile?: string,
|
||||||
|
width = 20,
|
||||||
|
): string {
|
||||||
const pct = total > 0 ? completed / total : 0;
|
const pct = total > 0 ? completed / total : 0;
|
||||||
const filled = Math.round(width * pct);
|
const filled = Math.round(width * pct);
|
||||||
const bar = "█".repeat(filled) + "░".repeat(width - filled);
|
const bar = "█".repeat(filled) + "░".repeat(width - filled);
|
||||||
@@ -68,12 +74,12 @@ export default function (pi: ExtensionAPI) {
|
|||||||
name: "project_map_init",
|
name: "project_map_init",
|
||||||
label: "Project Map Init",
|
label: "Project Map Init",
|
||||||
description:
|
description:
|
||||||
"Generate .pi-map.md analysis files for the entire project or a subdirectory",
|
"Generate paired .pi-map.md and .pi-map.index.md analysis files for the entire project or a subdirectory",
|
||||||
promptSnippet:
|
promptSnippet:
|
||||||
"Initialize project analysis files for codebase understanding",
|
"Initialize paired project map/index artifacts for codebase understanding",
|
||||||
promptGuidelines: [
|
promptGuidelines: [
|
||||||
"Use project_map_init when starting work on a new project or after significant restructuring",
|
"Use project_map_init when starting work on a new project or after significant restructuring",
|
||||||
"Run project_map_init when .pi-map.md files are missing or severely outdated",
|
"Run project_map_init when .pi-map.md / .pi-map.index.md files are missing or severely outdated",
|
||||||
],
|
],
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
path: Type.Optional(
|
path: Type.Optional(
|
||||||
@@ -91,10 +97,21 @@ export default function (pi: ExtensionAPI) {
|
|||||||
llmClient: client,
|
llmClient: client,
|
||||||
cacheDir: ctx.cwd,
|
cacheDir: ctx.cwd,
|
||||||
onProgress: (info) => {
|
onProgress: (info) => {
|
||||||
const bar = renderProgressBar(info.completed, info.total, info.currentFile);
|
const bar = renderProgressBar(
|
||||||
|
info.completed,
|
||||||
|
info.total,
|
||||||
|
info.currentFile,
|
||||||
|
);
|
||||||
_onUpdate?.({
|
_onUpdate?.({
|
||||||
content: [{ type: "text", text: bar }],
|
content: [{ type: "text", text: bar }],
|
||||||
details: { progress: info.total > 0 ? Math.round((info.completed / info.total) * 100) : 0, file: info.currentFile, dir: info.dir },
|
details: {
|
||||||
|
progress:
|
||||||
|
info.total > 0
|
||||||
|
? Math.round((info.completed / info.total) * 100)
|
||||||
|
: 0,
|
||||||
|
file: info.currentFile,
|
||||||
|
dir: info.dir,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -121,8 +138,9 @@ export default function (pi: ExtensionAPI) {
|
|||||||
name: "project_map_patch",
|
name: "project_map_patch",
|
||||||
label: "Project Map Patch",
|
label: "Project Map Patch",
|
||||||
description:
|
description:
|
||||||
"Update .pi-map.md for the directory containing a changed file",
|
"Update the paired .pi-map.md / .pi-map.index.md artifacts for the directory containing a changed file",
|
||||||
promptSnippet: "Update project analysis after editing a source file",
|
promptSnippet:
|
||||||
|
"Update paired project map/index artifacts after editing a source file",
|
||||||
promptGuidelines: [
|
promptGuidelines: [
|
||||||
"Use project_map_patch immediately after editing any source file",
|
"Use project_map_patch immediately after editing any source file",
|
||||||
"Pass the absolute or relative path of the modified file",
|
"Pass the absolute or relative path of the modified file",
|
||||||
@@ -158,8 +176,9 @@ export default function (pi: ExtensionAPI) {
|
|||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "project_map_validate",
|
name: "project_map_validate",
|
||||||
label: "Project Map Validate",
|
label: "Project Map Validate",
|
||||||
description: "Check all .pi-map.md files for staleness and discrepancies",
|
description:
|
||||||
promptSnippet: "Validate project analysis files for accuracy",
|
"Check all .pi-map.md / .pi-map.index.md files for staleness and discrepancies",
|
||||||
|
promptSnippet: "Validate paired project map/index artifacts for accuracy",
|
||||||
promptGuidelines: [
|
promptGuidelines: [
|
||||||
"Use project_map_validate before making architectural decisions if you suspect stale data",
|
"Use project_map_validate before making architectural decisions if you suspect stale data",
|
||||||
"Use project_map_validate to detect files that were deleted or added outside the agent",
|
"Use project_map_validate to detect files that were deleted or added outside the agent",
|
||||||
@@ -201,8 +220,10 @@ export default function (pi: ExtensionAPI) {
|
|||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "project_map_reinit",
|
name: "project_map_reinit",
|
||||||
label: "Project Map Reinit",
|
label: "Project Map Reinit",
|
||||||
description: "Force full regeneration of all .pi-map.md files",
|
description:
|
||||||
promptSnippet: "Force full regeneration of project analysis files",
|
"Force full regeneration of all .pi-map.md / .pi-map.index.md artifacts",
|
||||||
|
promptSnippet:
|
||||||
|
"Force full regeneration of paired project map/index artifacts",
|
||||||
promptGuidelines: [
|
promptGuidelines: [
|
||||||
"Use project_map_reinit when validation shows widespread staleness",
|
"Use project_map_reinit when validation shows widespread staleness",
|
||||||
"Use project_map_reinit after pulling major changes from version control",
|
"Use project_map_reinit after pulling major changes from version control",
|
||||||
@@ -223,10 +244,21 @@ export default function (pi: ExtensionAPI) {
|
|||||||
llmClient: client,
|
llmClient: client,
|
||||||
cacheDir: ctx.cwd,
|
cacheDir: ctx.cwd,
|
||||||
onProgress: (info) => {
|
onProgress: (info) => {
|
||||||
const bar = renderProgressBar(info.completed, info.total, info.currentFile);
|
const bar = renderProgressBar(
|
||||||
|
info.completed,
|
||||||
|
info.total,
|
||||||
|
info.currentFile,
|
||||||
|
);
|
||||||
_onUpdate?.({
|
_onUpdate?.({
|
||||||
content: [{ type: "text", text: bar }],
|
content: [{ type: "text", text: bar }],
|
||||||
details: { progress: info.total > 0 ? Math.round((info.completed / info.total) * 100) : 0, file: info.currentFile, dir: info.dir },
|
details: {
|
||||||
|
progress:
|
||||||
|
info.total > 0
|
||||||
|
? Math.round((info.completed / info.total) * 100)
|
||||||
|
: 0,
|
||||||
|
file: info.currentFile,
|
||||||
|
dir: info.dir,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -249,6 +281,41 @@ export default function (pi: ExtensionAPI) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "project_map_context",
|
||||||
|
label: "Project Map Context",
|
||||||
|
description:
|
||||||
|
"Retrieve a compact markdown context bundle for a natural-language query using paired project map/index metadata",
|
||||||
|
promptSnippet:
|
||||||
|
"Get relevant project context for a task without reading every source file",
|
||||||
|
promptGuidelines: [
|
||||||
|
"Use project_map_context when you need to understand a task area before diving into source",
|
||||||
|
"Pass a concise query describing the feature, bug, or area you want to explore",
|
||||||
|
"Always read the suggested indexes first, then maps, then verify from source",
|
||||||
|
],
|
||||||
|
parameters: Type.Object({
|
||||||
|
query: Type.String({
|
||||||
|
description:
|
||||||
|
"Natural-language query describing the task or area to explore",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
try {
|
||||||
|
const bundle = retrieveContext(params.query, ctx.cwd);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: bundle }],
|
||||||
|
details: { success: true },
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err instanceof LLMError ? err.message : String(err);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Error: ${msg}` }],
|
||||||
|
details: { success: false, error: msg },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-load .pi-map.md files on session start
|
// Auto-load .pi-map.md files on session start
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
const mapFiles = findPiMapFiles(ctx.cwd);
|
const mapFiles = findPiMapFiles(ctx.cwd);
|
||||||
@@ -280,7 +347,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
message: {
|
message: {
|
||||||
customType: "pi-project-map-hint",
|
customType: "pi-project-map-hint",
|
||||||
content:
|
content:
|
||||||
"📋 Project map active: If you modify any source file, run `project_map_patch` with the file path. If you suspect staleness, run `project_map_validate`.",
|
"📋 Project map active: Start with the root `.pi-map.index.md`, use indexes first for routing, read the local `.pi-map.md` plus source before edits, run `project_map_patch` after source edits, and run `project_map_validate` before freshness-sensitive architectural handoff.",
|
||||||
display: false,
|
display: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ function extractCallChain(node: SyntaxNode): string | null {
|
|||||||
node.type === "property_identifier" ||
|
node.type === "property_identifier" ||
|
||||||
node.type === "type_identifier"
|
node.type === "type_identifier"
|
||||||
) {
|
) {
|
||||||
return node.text;
|
return node.text.replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
if (node.type === "call" || node.type === "call_expression") {
|
if (node.type === "call" || node.type === "call_expression") {
|
||||||
const func = node.childForFieldName?.("function");
|
const func = node.childForFieldName?.("function");
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import "./cli/cli.js";
|
||||||
+67
-13
@@ -4,6 +4,7 @@ import { patchFile } from "../patch.js";
|
|||||||
import { validateMaps } from "../validate.js";
|
import { validateMaps } from "../validate.js";
|
||||||
import { reinitPath } from "../init.js";
|
import { reinitPath } from "../init.js";
|
||||||
import { discoverProject } from "../discover.js";
|
import { discoverProject } from "../discover.js";
|
||||||
|
import { retrieveContext } from "../retrieve.js";
|
||||||
import { createLLMClient, LLMError } from "../llm/llm-client.js";
|
import { createLLMClient, LLMError } from "../llm/llm-client.js";
|
||||||
import { loadConfig } from "../config.js";
|
import { loadConfig } from "../config.js";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
@@ -11,6 +12,8 @@ import pc from "picocolors";
|
|||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const command = args[0];
|
const command = args[0];
|
||||||
|
|
||||||
|
type PatchMode = "auto" | "small" | "structural";
|
||||||
|
|
||||||
function printUsage() {
|
function printUsage() {
|
||||||
console.log(`${pc.bold("project-map")} — hierarchical project analysis for Pi agents
|
console.log(`${pc.bold("project-map")} — hierarchical project analysis for Pi agents
|
||||||
`);
|
`);
|
||||||
@@ -30,6 +33,9 @@ function printUsage() {
|
|||||||
console.log(
|
console.log(
|
||||||
` project-map ${pc.cyan("--help")} Show this help message`,
|
` project-map ${pc.cyan("--help")} Show this help message`,
|
||||||
);
|
);
|
||||||
|
console.log(
|
||||||
|
` project-map ${pc.cyan("context")} <query> Retrieve relevant project context for a query`,
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` project-map ${pc.cyan("--version")} Show version\n`,
|
` project-map ${pc.cyan("--version")} Show version\n`,
|
||||||
);
|
);
|
||||||
@@ -40,19 +46,21 @@ function printUsage() {
|
|||||||
console.log(
|
console.log(
|
||||||
` --llm-model=<model> LLM model name (or set LLM_MODEL env var)`,
|
` --llm-model=<model> LLM model name (or set LLM_MODEL env var)`,
|
||||||
);
|
);
|
||||||
console.log(` --llm-base-url=<url> Custom base URL for LLM API\n`);
|
console.log(` --llm-base-url=<url> Custom base URL for LLM API`);
|
||||||
|
console.log(
|
||||||
|
` --patch-mode=<mode> Patch/repair mode: auto|small|structural\n`,
|
||||||
|
);
|
||||||
console.log(`${pc.bold("Examples:")}`);
|
console.log(`${pc.bold("Examples:")}`);
|
||||||
console.log(` project-map init`);
|
console.log(` project-map init`);
|
||||||
console.log(` project-map patch src/components/Button.tsx`);
|
console.log(` project-map patch src/components/Button.tsx`);
|
||||||
console.log(` project-map validate --fix`);
|
console.log(` project-map validate --fix`);
|
||||||
console.log(` project-map reinit`);
|
console.log(` project-map reinit`);
|
||||||
console.log(
|
console.log(` project-map context "authentication logic"`);
|
||||||
` project-map init --llm-provider=kimi --llm-model=kimi-k2-6`,
|
console.log(` project-map init --llm-provider=kimi --llm-model=kimi-k2-6`);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function printVersion() {
|
function printVersion() {
|
||||||
const pkg = require("../package.json");
|
const pkg = require("../../package.json");
|
||||||
console.log(pkg.version);
|
console.log(pkg.version);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +71,12 @@ function formatCount(count: number, label: string): string {
|
|||||||
return `${pc.bold(String(count))} ${count === 1 ? label : plural}`;
|
return `${pc.bold(String(count))} ${count === 1 ? label : plural}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProgressBar(completed: number, total: number, currentFile?: string, width = 30): string {
|
function renderProgressBar(
|
||||||
|
completed: number,
|
||||||
|
total: number,
|
||||||
|
currentFile?: string,
|
||||||
|
width = 30,
|
||||||
|
): string {
|
||||||
const pct = total > 0 ? completed / total : 0;
|
const pct = total > 0 ? completed / total : 0;
|
||||||
const filled = Math.round(width * pct);
|
const filled = Math.round(width * pct);
|
||||||
const bar = "█".repeat(filled) + "░".repeat(width - filled);
|
const bar = "█".repeat(filled) + "░".repeat(width - filled);
|
||||||
@@ -77,6 +90,7 @@ function parseArgs(args: string[]): {
|
|||||||
llmProvider?: string;
|
llmProvider?: string;
|
||||||
llmModel?: string;
|
llmModel?: string;
|
||||||
llmBaseUrl?: string;
|
llmBaseUrl?: string;
|
||||||
|
patchMode?: PatchMode;
|
||||||
positional: string[];
|
positional: string[];
|
||||||
} {
|
} {
|
||||||
let path = ".";
|
let path = ".";
|
||||||
@@ -84,6 +98,7 @@ function parseArgs(args: string[]): {
|
|||||||
let llmProvider: string | undefined;
|
let llmProvider: string | undefined;
|
||||||
let llmModel: string | undefined;
|
let llmModel: string | undefined;
|
||||||
let llmBaseUrl: string | undefined;
|
let llmBaseUrl: string | undefined;
|
||||||
|
let patchMode: PatchMode | undefined;
|
||||||
const positional: string[] = [];
|
const positional: string[] = [];
|
||||||
|
|
||||||
for (const arg of args.slice(1)) {
|
for (const arg of args.slice(1)) {
|
||||||
@@ -95,13 +110,23 @@ function parseArgs(args: string[]): {
|
|||||||
llmModel = arg.slice("--llm-model=".length);
|
llmModel = arg.slice("--llm-model=".length);
|
||||||
} else if (arg.startsWith("--llm-base-url=")) {
|
} else if (arg.startsWith("--llm-base-url=")) {
|
||||||
llmBaseUrl = arg.slice("--llm-base-url=".length);
|
llmBaseUrl = arg.slice("--llm-base-url=".length);
|
||||||
|
} else if (arg.startsWith("--patch-mode=")) {
|
||||||
|
patchMode = arg.slice("--patch-mode=".length) as PatchMode;
|
||||||
} else if (!arg.startsWith("-")) {
|
} else if (!arg.startsWith("-")) {
|
||||||
positional.push(arg);
|
positional.push(arg);
|
||||||
path = arg;
|
path = arg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { path, fix, llmProvider, llmModel, llmBaseUrl, positional };
|
return {
|
||||||
|
path,
|
||||||
|
fix,
|
||||||
|
llmProvider,
|
||||||
|
llmModel,
|
||||||
|
llmBaseUrl,
|
||||||
|
patchMode,
|
||||||
|
positional,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createClientFromArgs(args: ReturnType<typeof parseArgs>) {
|
function createClientFromArgs(args: ReturnType<typeof parseArgs>) {
|
||||||
@@ -142,8 +167,12 @@ async function main() {
|
|||||||
llmClient: client,
|
llmClient: client,
|
||||||
cacheDir: targetPath,
|
cacheDir: targetPath,
|
||||||
onProgress: (info) => {
|
onProgress: (info) => {
|
||||||
const line = renderProgressBar(info.completed, info.total, info.currentFile);
|
const line = renderProgressBar(
|
||||||
process.stdout.write("\r" + line.padEnd(lastLine.length));
|
info.completed,
|
||||||
|
info.total,
|
||||||
|
info.currentFile,
|
||||||
|
);
|
||||||
|
process.stdout.write(`\r${line.padEnd(lastLine.length)}`);
|
||||||
lastLine = line;
|
lastLine = line;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -162,13 +191,22 @@ async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const client = createClientFromArgs(parsed);
|
const client = createClientFromArgs(parsed);
|
||||||
await patchFile(parsed.positional[0], client, process.cwd());
|
await patchFile(parsed.positional[0], client, process.cwd(), {
|
||||||
|
patchMode: parsed.patchMode,
|
||||||
|
rootPath: process.cwd(),
|
||||||
|
});
|
||||||
console.log(`${pc.green("✓")} Patched`);
|
console.log(`${pc.green("✓")} Patched`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "validate": {
|
case "validate": {
|
||||||
const { path, fix } = parsed;
|
const { path, fix } = parsed;
|
||||||
const result = await validateMaps(path, { fix, verbose: true });
|
const result = await validateMaps(path, {
|
||||||
|
fix,
|
||||||
|
verbose: true,
|
||||||
|
llmClient: fix ? createClientFromArgs(parsed) : undefined,
|
||||||
|
cacheDir: process.cwd(),
|
||||||
|
patchMode: parsed.patchMode,
|
||||||
|
});
|
||||||
if (result.clean) {
|
if (result.clean) {
|
||||||
console.log(`${pc.green("✓")} All .pi-map.md files are clean.`);
|
console.log(`${pc.green("✓")} All .pi-map.md files are clean.`);
|
||||||
} else {
|
} else {
|
||||||
@@ -190,6 +228,18 @@ async function main() {
|
|||||||
process.exit(result.clean ? 0 : 1);
|
process.exit(result.clean ? 0 : 1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "context": {
|
||||||
|
if (!parsed.positional[0]) {
|
||||||
|
console.error(
|
||||||
|
`${pc.red("Error:")} Missing query. Usage: project-map context <query>`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const query = parsed.positional[0];
|
||||||
|
const bundle = retrieveContext(query, process.cwd());
|
||||||
|
console.log(bundle);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "reinit": {
|
case "reinit": {
|
||||||
const targetPath = parsed.positional[0] || ".";
|
const targetPath = parsed.positional[0] || ".";
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@@ -204,8 +254,12 @@ async function main() {
|
|||||||
llmClient: client,
|
llmClient: client,
|
||||||
cacheDir: targetPath,
|
cacheDir: targetPath,
|
||||||
onProgress: (info) => {
|
onProgress: (info) => {
|
||||||
const line = renderProgressBar(info.completed, info.total, info.currentFile);
|
const line = renderProgressBar(
|
||||||
process.stdout.write("\r" + line.padEnd(lastLine.length));
|
info.completed,
|
||||||
|
info.total,
|
||||||
|
info.currentFile,
|
||||||
|
);
|
||||||
|
process.stdout.write(`\r${line.padEnd(lastLine.length)}`);
|
||||||
lastLine = line;
|
lastLine = line;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface SkillConfig {
|
|||||||
llmBaseUrl?: string;
|
llmBaseUrl?: string;
|
||||||
contextBudget: number;
|
contextBudget: number;
|
||||||
autoInjectPrompt: boolean;
|
autoInjectPrompt: boolean;
|
||||||
|
tagCap: number;
|
||||||
|
workflowHintCap: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG: SkillConfig = {
|
export const DEFAULT_CONFIG: SkillConfig = {
|
||||||
@@ -24,6 +26,7 @@ export const DEFAULT_CONFIG: SkillConfig = {
|
|||||||
".DS_Store",
|
".DS_Store",
|
||||||
"*.log",
|
"*.log",
|
||||||
".pi-map.md",
|
".pi-map.md",
|
||||||
|
".pi-map.index.md",
|
||||||
".cache",
|
".cache",
|
||||||
"tmp",
|
"tmp",
|
||||||
"temp",
|
"temp",
|
||||||
@@ -38,6 +41,8 @@ export const DEFAULT_CONFIG: SkillConfig = {
|
|||||||
llmModel: "gpt-4o-mini",
|
llmModel: "gpt-4o-mini",
|
||||||
contextBudget: 4000,
|
contextBudget: 4000,
|
||||||
autoInjectPrompt: true,
|
autoInjectPrompt: true,
|
||||||
|
tagCap: 8,
|
||||||
|
workflowHintCap: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function loadConfig(cwd: string = process.cwd()): SkillConfig {
|
export function loadConfig(cwd: string = process.cwd()): SkillConfig {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
purpose: string;
|
||||||
|
exports: string[];
|
||||||
|
deps: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryArtifactModel {
|
||||||
|
dir: string;
|
||||||
|
role: string;
|
||||||
|
files: FileEntry[];
|
||||||
|
arch: string;
|
||||||
|
dirty?: string;
|
||||||
|
isRoot: boolean;
|
||||||
|
parent?: string;
|
||||||
|
children: string[];
|
||||||
|
tags: string[];
|
||||||
|
symbols: string[];
|
||||||
|
workflows: WorkflowHint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowHint {
|
||||||
|
task: string;
|
||||||
|
read?: string[];
|
||||||
|
index?: string[];
|
||||||
|
map?: string[];
|
||||||
|
files?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROJECT_MAP_PROTOCOL_LINES = [
|
||||||
|
"## Project Map Protocol",
|
||||||
|
"",
|
||||||
|
"1. Read this protocol and the root `.pi-map.index.md` first.",
|
||||||
|
"2. Use `index:` / `map:` references to open relevant directory indexes and maps.",
|
||||||
|
"3. Load indexes before rich maps during task-start navigation.",
|
||||||
|
"4. Read the local rich map and actual source before editing.",
|
||||||
|
"5. Treat non-empty `## dirty` sections in either artifact as stale.",
|
||||||
|
"6. If source and generated artifacts disagree, trust source.",
|
||||||
|
"7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.",
|
||||||
|
"8. After editing source, run `project_map_patch` for each changed file.",
|
||||||
|
"9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.",
|
||||||
|
"",
|
||||||
|
"Trust boundary: index routes, map orients, source decides.",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface RoutingMetadataOptions {
|
||||||
|
tagCap?: number;
|
||||||
|
workflowHintCap?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDirectoryModel(opts: {
|
||||||
|
dir: string;
|
||||||
|
role: string;
|
||||||
|
files: FileEntry[];
|
||||||
|
arch: string;
|
||||||
|
parent?: string;
|
||||||
|
children?: string[];
|
||||||
|
isRoot?: boolean;
|
||||||
|
dirty?: string;
|
||||||
|
}): DirectoryArtifactModel {
|
||||||
|
return {
|
||||||
|
dir: opts.dir,
|
||||||
|
role: opts.role,
|
||||||
|
files: opts.files,
|
||||||
|
arch: opts.arch,
|
||||||
|
parent: opts.parent,
|
||||||
|
children: opts.children ?? [],
|
||||||
|
isRoot: opts.isRoot ?? false,
|
||||||
|
dirty: opts.dirty,
|
||||||
|
tags: [],
|
||||||
|
symbols: [],
|
||||||
|
workflows: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const DEFAULT_IGNORE = [
|
|||||||
".DS_Store",
|
".DS_Store",
|
||||||
"*.log",
|
"*.log",
|
||||||
".pi-map.md",
|
".pi-map.md",
|
||||||
|
".pi-map.index.md",
|
||||||
".cache",
|
".cache",
|
||||||
"tmp",
|
"tmp",
|
||||||
"temp",
|
"temp",
|
||||||
|
|||||||
+531
-31
@@ -1,3 +1,6 @@
|
|||||||
|
import type { DirectoryArtifactModel, FileEntry } from "./directory-model.js";
|
||||||
|
import { PROJECT_MAP_PROTOCOL_LINES } from "./directory-model.js";
|
||||||
|
|
||||||
export interface PackageMapData {
|
export interface PackageMapData {
|
||||||
path: string;
|
path: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -6,20 +9,64 @@ export interface PackageMapData {
|
|||||||
dirty?: string;
|
dirty?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileEntry {
|
export function renderPackageMap(data: PackageMapData): string {
|
||||||
name: string;
|
const model = convertPackageMapToModel(data);
|
||||||
purpose: string;
|
return renderDirectoryMap(model);
|
||||||
exports: string[];
|
|
||||||
deps: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderPackageMap(data: PackageMapData): string {
|
export function parsePackageMap(markdown: string): PackageMapData {
|
||||||
|
const model = parseDirectoryMap(markdown);
|
||||||
|
return convertModelToPackageMap(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertPackageMapToModel(
|
||||||
|
data: PackageMapData,
|
||||||
|
): DirectoryArtifactModel {
|
||||||
|
return {
|
||||||
|
dir: data.path,
|
||||||
|
role: data.role,
|
||||||
|
files: data.files,
|
||||||
|
arch: data.arch,
|
||||||
|
dirty: data.dirty,
|
||||||
|
isRoot: data.path === ".",
|
||||||
|
parent: undefined,
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
symbols: [],
|
||||||
|
workflows: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertModelToPackageMap(
|
||||||
|
model: DirectoryArtifactModel,
|
||||||
|
): PackageMapData {
|
||||||
|
return {
|
||||||
|
path: model.dir,
|
||||||
|
role: model.role,
|
||||||
|
files: model.files,
|
||||||
|
arch: model.arch,
|
||||||
|
dirty: model.dirty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderDirectoryMap(model: DirectoryArtifactModel): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`# ${data.path}`);
|
lines.push(`# ${model.dir}`);
|
||||||
|
lines.push(`dir: ${model.dir}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`index: ${model.dir}/.pi-map.index.md`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (model.isRoot) {
|
||||||
|
lines.push(...PROJECT_MAP_PROTOCOL_LINES);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
lines.push(`## role`);
|
lines.push(`## role`);
|
||||||
lines.push(data.role);
|
lines.push(model.role);
|
||||||
|
|
||||||
lines.push(`## files`);
|
lines.push(`## files`);
|
||||||
for (const file of data.files) {
|
for (const file of model.files) {
|
||||||
const exp =
|
const exp =
|
||||||
file.exports.length > 0 ? `exp: ${file.exports.join(", ")}` : "";
|
file.exports.length > 0 ? `exp: ${file.exports.join(", ")}` : "";
|
||||||
const dep = file.deps.length > 0 ? `dep: ${file.deps.join(", ")}` : "";
|
const dep = file.deps.length > 0 ? `dep: ${file.deps.join(", ")}` : "";
|
||||||
@@ -28,28 +75,88 @@ export function renderPackageMap(data: PackageMapData): string {
|
|||||||
if (dep) parts.push(dep);
|
if (dep) parts.push(dep);
|
||||||
lines.push(parts.join(" | "));
|
lines.push(parts.join(" | "));
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(`## arch`);
|
lines.push(`## arch`);
|
||||||
lines.push(data.arch);
|
lines.push(model.arch);
|
||||||
|
|
||||||
|
// Slice 2: tags, symbols, workflows — scaffold empty sections for now
|
||||||
|
lines.push(`## tags`);
|
||||||
|
if (model.tags.length > 0) {
|
||||||
|
lines.push(model.tags.join(", "));
|
||||||
|
} else {
|
||||||
|
lines.push("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`## symbols`);
|
||||||
|
if (model.symbols.length > 0) {
|
||||||
|
for (const sym of model.symbols) {
|
||||||
|
lines.push(`- ${sym}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`## workflows`);
|
||||||
|
if (model.workflows.length > 0) {
|
||||||
|
for (const wf of model.workflows) {
|
||||||
|
lines.push(`- ${wf.task}`);
|
||||||
|
if (wf.read && wf.read.length > 0) {
|
||||||
|
lines.push(` read: ${wf.read.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (wf.index && wf.index.length > 0) {
|
||||||
|
lines.push(` index: ${wf.index.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (wf.map && wf.map.length > 0) {
|
||||||
|
lines.push(` map: ${wf.map.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (wf.files && wf.files.length > 0) {
|
||||||
|
lines.push(` files: ${wf.files.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push("-");
|
||||||
|
}
|
||||||
|
|
||||||
lines.push(`## dirty`);
|
lines.push(`## dirty`);
|
||||||
lines.push(data.dirty || "-");
|
lines.push(model.dirty || "-");
|
||||||
return `${lines.join("\n")}\n`;
|
return `${lines.join("\n")}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePackageMap(markdown: string): PackageMapData {
|
export function parseDirectoryMap(markdown: string): DirectoryArtifactModel {
|
||||||
const lines = markdown.split("\n").map((l) => l.trimEnd());
|
const lines = markdown.split("\n").map((l) => l.trimEnd());
|
||||||
const result: PackageMapData = {
|
const result: DirectoryArtifactModel = {
|
||||||
path: "",
|
dir: "",
|
||||||
role: "",
|
role: "",
|
||||||
files: [],
|
files: [],
|
||||||
arch: "",
|
arch: "",
|
||||||
dirty: "-",
|
dirty: "-",
|
||||||
|
isRoot: false,
|
||||||
|
parent: undefined,
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
symbols: [],
|
||||||
|
workflows: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
let section: "none" | "role" | "files" | "arch" | "dirty" = "none";
|
let section:
|
||||||
|
| "none"
|
||||||
|
| "role"
|
||||||
|
| "files"
|
||||||
|
| "arch"
|
||||||
|
| "tags"
|
||||||
|
| "symbols"
|
||||||
|
| "workflows"
|
||||||
|
| "dirty" = "none";
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("# ")) {
|
if (line.startsWith("# ")) {
|
||||||
result.path = line.slice(2).trim();
|
result.dir = line.slice(2).trim();
|
||||||
|
result.isRoot = result.dir === ".";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith("dir: ")) {
|
||||||
|
result.dir = line.slice(5).trim();
|
||||||
|
result.isRoot = result.dir === ".";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (line === "## role") {
|
if (line === "## role") {
|
||||||
@@ -64,6 +171,18 @@ export function parsePackageMap(markdown: string): PackageMapData {
|
|||||||
section = "arch";
|
section = "arch";
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (line === "## tags") {
|
||||||
|
section = "tags";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "## symbols") {
|
||||||
|
section = "symbols";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "## workflows") {
|
||||||
|
section = "workflows";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (line === "## dirty") {
|
if (line === "## dirty") {
|
||||||
section = "dirty";
|
section = "dirty";
|
||||||
continue;
|
continue;
|
||||||
@@ -83,6 +202,70 @@ export function parsePackageMap(markdown: string): PackageMapData {
|
|||||||
case "arch":
|
case "arch":
|
||||||
result.arch = result.arch ? `${result.arch}\n${line}` : line;
|
result.arch = result.arch ? `${result.arch}\n${line}` : line;
|
||||||
break;
|
break;
|
||||||
|
case "tags":
|
||||||
|
if (line !== "-") {
|
||||||
|
result.tags.push(
|
||||||
|
...line
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "symbols":
|
||||||
|
if (line !== "-" && line.startsWith("- ")) {
|
||||||
|
result.symbols.push(line.slice(2).trim());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "workflows": {
|
||||||
|
if (line !== "-" && line.startsWith("- ")) {
|
||||||
|
const task = line.slice(2).trim();
|
||||||
|
result.workflows.push({ task });
|
||||||
|
} else if (line.startsWith(" read: ") && result.workflows.length > 0) {
|
||||||
|
const reads = line
|
||||||
|
.slice(8)
|
||||||
|
.trim()
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const last = result.workflows[result.workflows.length - 1];
|
||||||
|
if (last) last.read = reads;
|
||||||
|
} else if (
|
||||||
|
line.startsWith(" index: ") &&
|
||||||
|
result.workflows.length > 0
|
||||||
|
) {
|
||||||
|
const indices = line
|
||||||
|
.slice(9)
|
||||||
|
.trim()
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const last = result.workflows[result.workflows.length - 1];
|
||||||
|
if (last) last.index = indices;
|
||||||
|
} else if (line.startsWith(" map: ") && result.workflows.length > 0) {
|
||||||
|
const maps = line
|
||||||
|
.slice(7)
|
||||||
|
.trim()
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const last = result.workflows[result.workflows.length - 1];
|
||||||
|
if (last) last.map = maps;
|
||||||
|
} else if (
|
||||||
|
line.startsWith(" files: ") &&
|
||||||
|
result.workflows.length > 0
|
||||||
|
) {
|
||||||
|
const files = line
|
||||||
|
.slice(9)
|
||||||
|
.trim()
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const last = result.workflows[result.workflows.length - 1];
|
||||||
|
if (last) last.files = files;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "dirty":
|
case "dirty":
|
||||||
result.dirty = line === "-" ? undefined : line;
|
result.dirty = line === "-" ? undefined : line;
|
||||||
break;
|
break;
|
||||||
@@ -92,10 +275,115 @@ export function parsePackageMap(markdown: string): PackageMapData {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitTopLevel(str: string, delimiter: string): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let depthParen = 0;
|
||||||
|
let depthBracket = 0;
|
||||||
|
let depthBrace = 0;
|
||||||
|
let depthAngle = 0;
|
||||||
|
let inString: '"' | "'" | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const ch = str[i];
|
||||||
|
const prev = str[i - 1];
|
||||||
|
const next = str[i + 1];
|
||||||
|
|
||||||
|
if (inString) {
|
||||||
|
if (ch === inString && prev !== "\\") {
|
||||||
|
inString = null;
|
||||||
|
}
|
||||||
|
current += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
const isWordApostrophe =
|
||||||
|
ch === "'" &&
|
||||||
|
/[A-Za-z0-9_$]/.test(prev ?? "") &&
|
||||||
|
/[A-Za-z0-9_$]/.test(next ?? "");
|
||||||
|
if (isWordApostrophe) {
|
||||||
|
current += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inString = ch;
|
||||||
|
current += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === "(") depthParen++;
|
||||||
|
if (ch === ")") depthParen = Math.max(0, depthParen - 1);
|
||||||
|
if (ch === "[") depthBracket++;
|
||||||
|
if (ch === "]") depthBracket = Math.max(0, depthBracket - 1);
|
||||||
|
if (ch === "{") depthBrace++;
|
||||||
|
if (ch === "}") depthBrace = Math.max(0, depthBrace - 1);
|
||||||
|
if (ch === "<") depthAngle++;
|
||||||
|
if (ch === ">") depthAngle = Math.max(0, depthAngle - 1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
delimiter.length === 1 &&
|
||||||
|
ch === delimiter &&
|
||||||
|
depthParen === 0 &&
|
||||||
|
depthBracket === 0 &&
|
||||||
|
depthBrace === 0 &&
|
||||||
|
depthAngle === 0
|
||||||
|
) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
delimiter.length > 1 &&
|
||||||
|
str.startsWith(delimiter, i) &&
|
||||||
|
depthParen === 0 &&
|
||||||
|
depthBracket === 0 &&
|
||||||
|
depthBrace === 0 &&
|
||||||
|
depthAngle === 0
|
||||||
|
) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = "";
|
||||||
|
i += delimiter.length - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.trim()) result.push(current.trim());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRespectingNesting(str: string): string[] {
|
||||||
|
return splitTopLevel(str, ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFieldsRespectingNesting(str: string): string[] {
|
||||||
|
const rawParts = splitTopLevel(str, " | ");
|
||||||
|
const fields: string[] = [];
|
||||||
|
|
||||||
|
for (const part of rawParts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (
|
||||||
|
fields.length < 2 ||
|
||||||
|
trimmed.startsWith("exp: ") ||
|
||||||
|
trimmed.startsWith("dep: ")
|
||||||
|
) {
|
||||||
|
fields.push(trimmed);
|
||||||
|
} else if (fields.length > 0) {
|
||||||
|
fields[fields.length - 1] = `${fields[fields.length - 1]} | ${trimmed}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
function parseFileLine(line: string): FileEntry | null {
|
function parseFileLine(line: string): FileEntry | null {
|
||||||
// Format: - filename | purpose | exp: ... | dep: ...
|
// Format: - filename | purpose | exp: ... | dep: ...
|
||||||
const withoutPrefix = line.slice(2).trim();
|
const withoutPrefix = line.slice(2).trim();
|
||||||
const parts = withoutPrefix.split(" | ").map((p) => p.trim());
|
const parts = splitFieldsRespectingNesting(withoutPrefix).map((p) =>
|
||||||
|
p.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
if (parts.length < 2) return null;
|
if (parts.length < 2) return null;
|
||||||
|
|
||||||
@@ -107,23 +395,235 @@ function parseFileLine(line: string): FileEntry | null {
|
|||||||
for (let i = 2; i < parts.length; i++) {
|
for (let i = 2; i < parts.length; i++) {
|
||||||
const part = parts[i];
|
const part = parts[i];
|
||||||
if (part.startsWith("exp: ")) {
|
if (part.startsWith("exp: ")) {
|
||||||
exports.push(
|
exports.push(...splitRespectingNesting(part.slice(5)).filter(Boolean));
|
||||||
...part
|
|
||||||
.slice(5)
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
} else if (part.startsWith("dep: ")) {
|
} else if (part.startsWith("dep: ")) {
|
||||||
deps.push(
|
deps.push(...splitRespectingNesting(part.slice(5)).filter(Boolean));
|
||||||
...part
|
|
||||||
.slice(5)
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name, purpose, exports, deps };
|
return { name, purpose, exports, deps };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderDirectoryIndex(model: DirectoryArtifactModel): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`# ${model.dir} (index)`);
|
||||||
|
lines.push(`dir: ${model.dir}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (model.isRoot) {
|
||||||
|
lines.push(...PROJECT_MAP_PROTOCOL_LINES);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`## role`);
|
||||||
|
lines.push(model.role);
|
||||||
|
|
||||||
|
lines.push(`## parent`);
|
||||||
|
if (model.parent) {
|
||||||
|
lines.push(`index: ${model.parent}/.pi-map.index.md`);
|
||||||
|
lines.push(`map: ${model.parent}/.pi-map.md`);
|
||||||
|
} else {
|
||||||
|
lines.push("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`## children`);
|
||||||
|
if (model.children.length > 0) {
|
||||||
|
for (const child of model.children) {
|
||||||
|
lines.push(`- ${child}`);
|
||||||
|
lines.push(` index: ${child}/.pi-map.index.md`);
|
||||||
|
lines.push(` map: ${child}/.pi-map.md`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`## files`);
|
||||||
|
for (const file of model.files) {
|
||||||
|
lines.push(`- ${file.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`## links`);
|
||||||
|
lines.push(`index: ${model.dir}/.pi-map.index.md`);
|
||||||
|
lines.push(`map: ${model.dir}/.pi-map.md`);
|
||||||
|
|
||||||
|
lines.push(`## workflows`);
|
||||||
|
if (model.workflows.length > 0) {
|
||||||
|
for (const wf of model.workflows) {
|
||||||
|
lines.push(`- ${wf.task}`);
|
||||||
|
if (wf.read && wf.read.length > 0) {
|
||||||
|
lines.push(` read: ${wf.read.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (wf.index && wf.index.length > 0) {
|
||||||
|
lines.push(` index: ${wf.index.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (wf.map && wf.map.length > 0) {
|
||||||
|
lines.push(` map: ${wf.map.join(", ")}`);
|
||||||
|
}
|
||||||
|
if (wf.files && wf.files.length > 0) {
|
||||||
|
lines.push(` files: ${wf.files.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`## dirty`);
|
||||||
|
lines.push(model.dirty || "-");
|
||||||
|
|
||||||
|
return `${lines.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDirectoryIndex(markdown: string): DirectoryArtifactModel {
|
||||||
|
const lines = markdown.split("\n").map((l) => l.trimEnd());
|
||||||
|
const result: DirectoryArtifactModel = {
|
||||||
|
dir: "",
|
||||||
|
role: "",
|
||||||
|
files: [],
|
||||||
|
arch: "",
|
||||||
|
dirty: "-",
|
||||||
|
isRoot: false,
|
||||||
|
parent: undefined,
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
symbols: [],
|
||||||
|
workflows: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let section:
|
||||||
|
| "none"
|
||||||
|
| "role"
|
||||||
|
| "parent"
|
||||||
|
| "children"
|
||||||
|
| "files"
|
||||||
|
| "links"
|
||||||
|
| "workflows"
|
||||||
|
| "dirty" = "none";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("# ")) {
|
||||||
|
const title = line.slice(2).trim();
|
||||||
|
// Strip " (index)" suffix if present
|
||||||
|
result.dir = title.replace(/ \(index\)$/, "");
|
||||||
|
result.isRoot = result.dir === ".";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith("dir: ")) {
|
||||||
|
result.dir = line.slice(5).trim();
|
||||||
|
result.isRoot = result.dir === ".";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "## role") {
|
||||||
|
section = "role";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "## parent") {
|
||||||
|
section = "parent";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "## children") {
|
||||||
|
section = "children";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "## files") {
|
||||||
|
section = "files";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "## links") {
|
||||||
|
section = "links";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "## workflows") {
|
||||||
|
section = "workflows";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "## dirty") {
|
||||||
|
section = "dirty";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "") continue;
|
||||||
|
|
||||||
|
switch (section) {
|
||||||
|
case "role":
|
||||||
|
result.role = line;
|
||||||
|
break;
|
||||||
|
case "parent":
|
||||||
|
if (line !== "-" && line.startsWith("index: ")) {
|
||||||
|
result.parent = line.slice(7).trim().replace("/.pi-map.index.md", "");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "children":
|
||||||
|
if (line !== "-" && line.startsWith("- ")) {
|
||||||
|
const childName = line.slice(2).trim();
|
||||||
|
result.children.push(childName);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "files":
|
||||||
|
if (line !== "-" && line.startsWith("- ")) {
|
||||||
|
result.files.push({
|
||||||
|
name: line.slice(2).trim(),
|
||||||
|
purpose: "",
|
||||||
|
exports: [],
|
||||||
|
deps: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "links":
|
||||||
|
// Parse sibling links; no-op for now
|
||||||
|
break;
|
||||||
|
case "workflows": {
|
||||||
|
if (line !== "-" && line.startsWith("- ")) {
|
||||||
|
const task = line.slice(2).trim();
|
||||||
|
result.workflows.push({ task });
|
||||||
|
} else if (line.startsWith(" read: ") && result.workflows.length > 0) {
|
||||||
|
const reads = line
|
||||||
|
.slice(8)
|
||||||
|
.trim()
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const last = result.workflows[result.workflows.length - 1];
|
||||||
|
if (last) last.read = reads;
|
||||||
|
} else if (
|
||||||
|
line.startsWith(" index: ") &&
|
||||||
|
result.workflows.length > 0
|
||||||
|
) {
|
||||||
|
const indices = line
|
||||||
|
.slice(9)
|
||||||
|
.trim()
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const last = result.workflows[result.workflows.length - 1];
|
||||||
|
if (last) last.index = indices;
|
||||||
|
} else if (line.startsWith(" map: ") && result.workflows.length > 0) {
|
||||||
|
const maps = line
|
||||||
|
.slice(7)
|
||||||
|
.trim()
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const last = result.workflows[result.workflows.length - 1];
|
||||||
|
if (last) last.map = maps;
|
||||||
|
} else if (
|
||||||
|
line.startsWith(" files: ") &&
|
||||||
|
result.workflows.length > 0
|
||||||
|
) {
|
||||||
|
const files = line
|
||||||
|
.slice(9)
|
||||||
|
.trim()
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const last = result.workflows[result.workflows.length - 1];
|
||||||
|
if (last) last.files = files;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "dirty":
|
||||||
|
result.dirty = line === "-" ? undefined : line;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
+24
-4
@@ -1,5 +1,25 @@
|
|||||||
// Main entry point for pi-project-map skill
|
// Main entry point for pi-project-map skill
|
||||||
export { initProject, reinitPath } from "./init.js";
|
export { initProject, reinitPath, generateDirectoryArtifacts } from "./init.js";
|
||||||
export { patchFile } from "./patch.js";
|
export { patchFile, type PatchMode, type PatchOptions } from "./patch.js";
|
||||||
export { validateMaps } from "./validate.js";
|
export {
|
||||||
export { renderPackageMap, parsePackageMap } from "./format.js";
|
validateMaps,
|
||||||
|
type ValidationOptions,
|
||||||
|
type ValidationResult,
|
||||||
|
type Discrepancy,
|
||||||
|
} from "./validate.js";
|
||||||
|
export {
|
||||||
|
renderPackageMap,
|
||||||
|
parsePackageMap,
|
||||||
|
renderDirectoryMap,
|
||||||
|
parseDirectoryMap,
|
||||||
|
renderDirectoryIndex,
|
||||||
|
parseDirectoryIndex,
|
||||||
|
} from "./format.js";
|
||||||
|
export type { PackageMapData } from "./format.js";
|
||||||
|
export {
|
||||||
|
createDirectoryModel,
|
||||||
|
type DirectoryArtifactModel,
|
||||||
|
type FileEntry,
|
||||||
|
} from "./directory-model.js";
|
||||||
|
export { populateRoutingMetadata } from "./routing-metadata.js";
|
||||||
|
export { retrieveContext } from "./retrieve.js";
|
||||||
|
|||||||
+166
-21
@@ -1,9 +1,8 @@
|
|||||||
import { discoverProject, type DirectoryEntry } from "./discover.js";
|
import { discoverProject, type DirectoryEntry } from "./discover.js";
|
||||||
import {
|
|
||||||
renderPackageMap,
|
export { discoverProject };
|
||||||
type PackageMapData,
|
import type { FileEntry } from "./directory-model.js";
|
||||||
type FileEntry,
|
import { renderDirectoryIndex, renderDirectoryMap } from "./format.js";
|
||||||
} from "./format.js";
|
|
||||||
import { extractFileLLM, extractPackageLLM } from "./llm/llm-extract.js";
|
import { extractFileLLM, extractPackageLLM } from "./llm/llm-extract.js";
|
||||||
import { extractFileAST } from "./ast/ast-extract.js";
|
import { extractFileAST } from "./ast/ast-extract.js";
|
||||||
import { mergeFileData } from "./merge.js";
|
import { mergeFileData } from "./merge.js";
|
||||||
@@ -11,6 +10,13 @@ import { processFiles } from "./llm/llm-batch.js";
|
|||||||
import { writeFileSync } from "fs";
|
import { writeFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import type { LLMClient } from "./llm/llm-client.js";
|
import type { LLMClient } from "./llm/llm-client.js";
|
||||||
|
import {
|
||||||
|
createDirectoryModel,
|
||||||
|
type DirectoryArtifactModel,
|
||||||
|
type RoutingMetadataOptions,
|
||||||
|
} from "./directory-model.js";
|
||||||
|
import { populateRoutingMetadata } from "./routing-metadata.js";
|
||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
|
||||||
export interface ProgressInfo {
|
export interface ProgressInfo {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -25,6 +31,8 @@ export interface InitOptions {
|
|||||||
llmClient?: LLMClient;
|
llmClient?: LLMClient;
|
||||||
cacheDir?: string;
|
cacheDir?: string;
|
||||||
onProgress?: (info: ProgressInfo) => void;
|
onProgress?: (info: ProgressInfo) => void;
|
||||||
|
tagCap?: number;
|
||||||
|
workflowHintCap?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initProject(
|
export async function initProject(
|
||||||
@@ -35,22 +43,40 @@ export async function initProject(
|
|||||||
const totalFiles = entries.reduce((sum, e) => sum + e.files.length, 0);
|
const totalFiles = entries.reduce((sum, e) => sum + e.files.length, 0);
|
||||||
let globalCompleted = 0;
|
let globalCompleted = 0;
|
||||||
|
|
||||||
|
// Load project config and apply defaults when not overridden in options
|
||||||
|
const config = loadConfig(rootPath);
|
||||||
|
const routingOpts: RoutingMetadataOptions = {
|
||||||
|
tagCap: options.tagCap ?? config.tagCap,
|
||||||
|
workflowHintCap: options.workflowHintCap ?? config.workflowHintCap,
|
||||||
|
};
|
||||||
|
|
||||||
options.onProgress?.({
|
options.onProgress?.({
|
||||||
message: `Scanning ${entries.length} directories (${totalFiles} files)...`,
|
message: `Scanning ${entries.length} directories (${totalFiles} files)...`,
|
||||||
completed: 0,
|
completed: 0,
|
||||||
total: totalFiles,
|
total: totalFiles,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build directory relationships
|
||||||
|
const dirSet = new Set(entries.map((e) => e.relativePath));
|
||||||
|
const parentMap = buildParentMap(entries);
|
||||||
|
const childrenMap = buildChildrenMap(entries);
|
||||||
|
|
||||||
for (let i = 0; i < entries.length; i++) {
|
for (let i = 0; i < entries.length; i++) {
|
||||||
const entry = entries[i];
|
const entry = entries[i];
|
||||||
await generateDirectoryMap(
|
await generateDirectoryArtifacts(
|
||||||
entry,
|
entry,
|
||||||
|
{
|
||||||
|
dirSet,
|
||||||
|
parentMap,
|
||||||
|
childrenMap,
|
||||||
|
isRoot: entry.relativePath === ".",
|
||||||
|
},
|
||||||
options.llmClient,
|
options.llmClient,
|
||||||
options.cacheDir,
|
options.cacheDir,
|
||||||
(info) => {
|
(info) => {
|
||||||
globalCompleted = info.completed + entries
|
globalCompleted =
|
||||||
.slice(0, i)
|
info.completed +
|
||||||
.reduce((sum, e) => sum + e.files.length, 0);
|
entries.slice(0, i).reduce((sum, e) => sum + e.files.length, 0);
|
||||||
options.onProgress?.({
|
options.onProgress?.({
|
||||||
...info,
|
...info,
|
||||||
completed: globalCompleted,
|
completed: globalCompleted,
|
||||||
@@ -58,26 +84,83 @@ export async function initProject(
|
|||||||
dir: entry.relativePath,
|
dir: entry.relativePath,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
routingOpts,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
options.onProgress?.({
|
options.onProgress?.({
|
||||||
message: `Generated ${entries.length} .pi-map.md files`,
|
message: `Generated ${entries.length} directory map/index pairs`,
|
||||||
completed: totalFiles,
|
completed: totalFiles,
|
||||||
total: totalFiles,
|
total: totalFiles,
|
||||||
});
|
});
|
||||||
if (options.verbose !== false) {
|
if (options.verbose !== false) {
|
||||||
console.log(`Generated ${entries.length} .pi-map.md files`);
|
console.log(`Generated ${entries.length} directory map/index pairs`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateDirectoryMap(
|
export interface DirectoryContext {
|
||||||
|
dirSet: Set<string>;
|
||||||
|
parentMap: Map<string, string | undefined>;
|
||||||
|
childrenMap: Map<string, string[]>;
|
||||||
|
isRoot: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArtifactWriteMode = "both" | "map" | "index";
|
||||||
|
|
||||||
|
export function buildDirectoryContext(
|
||||||
|
entries: DirectoryEntry[],
|
||||||
|
targetEntry: DirectoryEntry,
|
||||||
|
): DirectoryContext {
|
||||||
|
return {
|
||||||
|
dirSet: new Set(entries.map((e) => e.relativePath)),
|
||||||
|
parentMap: buildParentMap(entries),
|
||||||
|
childrenMap: buildChildrenMap(entries),
|
||||||
|
isRoot: targetEntry.relativePath === ".",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParentMap(
|
||||||
|
entries: DirectoryEntry[],
|
||||||
|
): Map<string, string | undefined> {
|
||||||
|
const map = new Map<string, string | undefined>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const rel = entry.relativePath;
|
||||||
|
if (rel === ".") {
|
||||||
|
map.set(rel, undefined);
|
||||||
|
} else {
|
||||||
|
const lastSep = rel.lastIndexOf("/");
|
||||||
|
map.set(rel, lastSep >= 0 ? rel.slice(0, lastSep) : ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChildrenMap(entries: DirectoryEntry[]): Map<string, string[]> {
|
||||||
|
const map = new Map<string, string[]>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
map.set(entry.relativePath, []);
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const rel = entry.relativePath;
|
||||||
|
if (rel === ".") continue;
|
||||||
|
const lastSep = rel.lastIndexOf("/");
|
||||||
|
const parent = lastSep >= 0 ? rel.slice(0, lastSep) : ".";
|
||||||
|
const siblings = map.get(parent);
|
||||||
|
if (siblings) {
|
||||||
|
siblings.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildDirectoryArtifactModel(
|
||||||
entry: DirectoryEntry,
|
entry: DirectoryEntry,
|
||||||
|
ctx: DirectoryContext,
|
||||||
llmClient?: LLMClient,
|
llmClient?: LLMClient,
|
||||||
cacheDir?: string,
|
cacheDir?: string,
|
||||||
onProgress?: (info: ProgressInfo) => void,
|
onProgress?: (info: ProgressInfo) => void,
|
||||||
): Promise<FileEntry[]> {
|
routingOpts?: RoutingMetadataOptions,
|
||||||
// Process files in parallel (4 concurrent) with retry logic
|
): Promise<DirectoryArtifactModel> {
|
||||||
const fileData = await processFiles(
|
const fileData = await processFiles(
|
||||||
entry.files,
|
entry.files,
|
||||||
async (file) => {
|
async (file) => {
|
||||||
@@ -105,23 +188,85 @@ export async function generateDirectoryMap(
|
|||||||
cacheDir,
|
cacheDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mapData: PackageMapData = {
|
const model: DirectoryArtifactModel = createDirectoryModel({
|
||||||
path: entry.relativePath,
|
dir: entry.relativePath,
|
||||||
role: packageData.role,
|
role: packageData.role,
|
||||||
files: fileData,
|
files: fileData,
|
||||||
arch: packageData.arch,
|
arch: packageData.arch,
|
||||||
|
parent: ctx.parentMap.get(entry.relativePath),
|
||||||
|
children: ctx.childrenMap.get(entry.relativePath) ?? [],
|
||||||
|
isRoot: ctx.isRoot,
|
||||||
dirty: "-",
|
dirty: "-",
|
||||||
};
|
});
|
||||||
|
|
||||||
const outPath = join(entry.dirPath, ".pi-map.md");
|
populateRoutingMetadata(model, routingOpts);
|
||||||
writeFileSync(outPath, renderPackageMap(mapData));
|
return model;
|
||||||
return fileData;
|
}
|
||||||
|
|
||||||
|
export function writeDirectoryArtifacts(
|
||||||
|
entry: DirectoryEntry,
|
||||||
|
model: DirectoryArtifactModel,
|
||||||
|
writeMode: ArtifactWriteMode = "both",
|
||||||
|
): void {
|
||||||
|
const mapPath = join(entry.dirPath, ".pi-map.md");
|
||||||
|
const indexPath = join(entry.dirPath, ".pi-map.index.md");
|
||||||
|
|
||||||
|
if (writeMode === "both" || writeMode === "map") {
|
||||||
|
writeFileSync(mapPath, renderDirectoryMap(model));
|
||||||
|
}
|
||||||
|
if (writeMode === "both" || writeMode === "index") {
|
||||||
|
writeFileSync(indexPath, renderDirectoryIndex(model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateDirectoryArtifacts(
|
||||||
|
entry: DirectoryEntry,
|
||||||
|
ctx: DirectoryContext,
|
||||||
|
llmClient?: LLMClient,
|
||||||
|
cacheDir?: string,
|
||||||
|
onProgress?: (info: ProgressInfo) => void,
|
||||||
|
routingOpts?: RoutingMetadataOptions,
|
||||||
|
writeMode: ArtifactWriteMode = "both",
|
||||||
|
): Promise<FileEntry[]> {
|
||||||
|
const model = await buildDirectoryArtifactModel(
|
||||||
|
entry,
|
||||||
|
ctx,
|
||||||
|
llmClient,
|
||||||
|
cacheDir,
|
||||||
|
onProgress,
|
||||||
|
routingOpts,
|
||||||
|
);
|
||||||
|
writeDirectoryArtifacts(entry, model, writeMode);
|
||||||
|
return model.files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reinitPath(
|
export async function reinitPath(
|
||||||
path: string,
|
path: string,
|
||||||
options: InitOptions = {},
|
options: InitOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Full regeneration clears all dirty markers by overwriting every .pi-map.md
|
// Full regeneration clears all dirty markers by overwriting every map/index pair
|
||||||
await initProject(path, options);
|
await initProject(path, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backward-compatible wrapper for patch/validate compatibility
|
||||||
|
export async function generateDirectoryMap(
|
||||||
|
entry: DirectoryEntry,
|
||||||
|
llmClient?: LLMClient,
|
||||||
|
cacheDir?: string,
|
||||||
|
): Promise<FileEntry[]> {
|
||||||
|
const entries = [entry];
|
||||||
|
const ctx = buildDirectoryContext(entries, entry);
|
||||||
|
const config = loadConfig(entry.dirPath);
|
||||||
|
const routingOpts: RoutingMetadataOptions = {
|
||||||
|
tagCap: config.tagCap,
|
||||||
|
workflowHintCap: config.workflowHintCap,
|
||||||
|
};
|
||||||
|
return generateDirectoryArtifacts(
|
||||||
|
entry,
|
||||||
|
ctx,
|
||||||
|
llmClient,
|
||||||
|
cacheDir,
|
||||||
|
undefined,
|
||||||
|
routingOpts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+9
-9
@@ -1,4 +1,4 @@
|
|||||||
import type { FileEntry } from "./format.js";
|
import type { FileEntry } from "./directory-model.js";
|
||||||
|
|
||||||
interface LLMFileData {
|
interface LLMFileData {
|
||||||
purpose: string;
|
purpose: string;
|
||||||
@@ -51,11 +51,11 @@ export function mergeFileData(
|
|||||||
const paramStr = method.params.join(", ");
|
const paramStr = method.params.join(", ");
|
||||||
const returnStr = method.returns ? ` → ${method.returns}` : "";
|
const returnStr = method.returns ? ` → ${method.returns}` : "";
|
||||||
classExports.push(`method:${method.name}(${paramStr})${returnStr}`);
|
classExports.push(`method:${method.name}(${paramStr})${returnStr}`);
|
||||||
if (method.calls.length > 0) {
|
for (const call of method.calls) {
|
||||||
classExports.push(`call:${method.calls.join(", ")}`);
|
classExports.push(`call:${call}`);
|
||||||
}
|
}
|
||||||
if (method.raises.length > 0) {
|
for (const raise of method.raises) {
|
||||||
classExports.push(`raise:${method.raises.join(", ")}`);
|
classExports.push(`raise:${raise}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dedupedExports.push(...classExports);
|
dedupedExports.push(...classExports);
|
||||||
@@ -68,11 +68,11 @@ export function mergeFileData(
|
|||||||
const paramStr = func.params.join(", ");
|
const paramStr = func.params.join(", ");
|
||||||
const returnStr = func.returns ? ` → ${func.returns}` : "";
|
const returnStr = func.returns ? ` → ${func.returns}` : "";
|
||||||
dedupedExports.push(`func:${func.name}(${paramStr})${returnStr}`);
|
dedupedExports.push(`func:${func.name}(${paramStr})${returnStr}`);
|
||||||
if (func.calls.length > 0) {
|
for (const call of func.calls) {
|
||||||
dedupedExports.push(`call:${func.calls.join(", ")}`);
|
dedupedExports.push(`call:${call}`);
|
||||||
}
|
}
|
||||||
if (func.raises.length > 0) {
|
for (const raise of func.raises) {
|
||||||
dedupedExports.push(`raise:${func.raises.join(", ")}`);
|
dedupedExports.push(`raise:${raise}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+143
-52
@@ -1,71 +1,162 @@
|
|||||||
import { dirname, join, basename, relative } from "path";
|
import { basename, dirname, relative, resolve } from "path";
|
||||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
import { existsSync, readFileSync } from "fs";
|
||||||
import { parsePackageMap, renderPackageMap } from "./format.js";
|
import { parsePackageMap } from "./format.js";
|
||||||
import { extractFileLLM } from "./llm/llm-extract.js";
|
import {
|
||||||
import { extractFileAST } from "./ast/ast-extract.js";
|
discoverProject,
|
||||||
import { mergeFileData } from "./merge.js";
|
generateDirectoryArtifacts,
|
||||||
import { generateDirectoryMap } from "./init.js";
|
buildDirectoryContext,
|
||||||
import { readdirSync, statSync } from "fs";
|
} from "./init.js";
|
||||||
|
import type { DirectoryEntry } from "./discover.js";
|
||||||
import type { LLMClient } from "./llm/llm-client.js";
|
import type { LLMClient } from "./llm/llm-client.js";
|
||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
|
||||||
const SMALL_PACKAGE_THRESHOLD = 10;
|
export type PatchMode = "auto" | "small" | "structural";
|
||||||
|
|
||||||
|
export interface PatchOptions {
|
||||||
|
patchMode?: PatchMode;
|
||||||
|
rootPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SMALL_CHANGE_FILE_WINDOW = 3;
|
||||||
|
const STRUCTURAL_FILE_HINT =
|
||||||
|
/(^|\b)(index|config|cli|command|init|format|discover|patch|validate)(\b|\.)/i;
|
||||||
|
|
||||||
export async function patchFile(
|
export async function patchFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
llmClient?: LLMClient,
|
llmClient?: LLMClient,
|
||||||
cacheDir?: string,
|
cacheDir?: string,
|
||||||
|
options: PatchOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const dirPath = dirname(filePath);
|
const rootPath = resolve(options.rootPath ?? cacheDir ?? process.cwd());
|
||||||
const mapPath = join(dirPath, ".pi-map.md");
|
const absFilePath = resolve(rootPath, filePath);
|
||||||
|
const dirPath = dirname(absFilePath);
|
||||||
|
const relDir = normalizeRelativePath(relative(rootPath, dirPath));
|
||||||
|
const entries = discoverProject(rootPath);
|
||||||
|
const entry = entries.find((candidate) => candidate.relativePath === relDir);
|
||||||
|
|
||||||
if (!existsSync(mapPath)) {
|
if (!entry) {
|
||||||
// No map exists yet — would need to generate from scratch
|
console.warn(`No discovered directory entry found for ${relDir}`);
|
||||||
console.warn(`No .pi-map.md found in ${dirPath}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allFiles = readdirSync(dirPath).filter(
|
const config = loadConfig(rootPath);
|
||||||
(f: string) => !f.startsWith(".") && !f.endsWith(".md"),
|
const routingOpts = {
|
||||||
|
tagCap: config.tagCap,
|
||||||
|
workflowHintCap: config.workflowHintCap,
|
||||||
|
};
|
||||||
|
const mode = determinePatchMode(
|
||||||
|
entry,
|
||||||
|
absFilePath,
|
||||||
|
rootPath,
|
||||||
|
options.patchMode,
|
||||||
);
|
);
|
||||||
const isSmallPackage = allFiles.length < SMALL_PACKAGE_THRESHOLD;
|
const changedCtx = buildDirectoryContext(entries, entry);
|
||||||
|
await generateDirectoryArtifacts(
|
||||||
if (isSmallPackage) {
|
entry,
|
||||||
// Full rewrite for small packages
|
changedCtx,
|
||||||
const files = allFiles.filter((f) => {
|
|
||||||
const st = statSync(join(dirPath, f));
|
|
||||||
return st.isFile();
|
|
||||||
});
|
|
||||||
const relDir = relative(process.cwd(), dirPath) || ".";
|
|
||||||
await generateDirectoryMap(
|
|
||||||
{
|
|
||||||
dirPath,
|
|
||||||
relativePath: relDir,
|
|
||||||
files,
|
|
||||||
},
|
|
||||||
llmClient,
|
llmClient,
|
||||||
cacheDir,
|
cacheDir,
|
||||||
|
undefined,
|
||||||
|
routingOpts,
|
||||||
|
"both",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const ancestor of getAncestorEntries(entries, changedCtx, entry)) {
|
||||||
|
const ancestorCtx = buildDirectoryContext(entries, ancestor);
|
||||||
|
await generateDirectoryArtifacts(
|
||||||
|
ancestor,
|
||||||
|
ancestorCtx,
|
||||||
|
llmClient,
|
||||||
|
cacheDir,
|
||||||
|
undefined,
|
||||||
|
routingOpts,
|
||||||
|
mode === "small" ? "index" : "both",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Full rewrite of ${mapPath} (small package: ${allFiles.length} files)`,
|
`Patched ${joinArtifactPath(relDir, ".pi-map.md")} (patchMode: ${mode})`,
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
// Section-level patch
|
|
||||||
const existing = parsePackageMap(readFileSync(mapPath, "utf8"));
|
function determinePatchMode(
|
||||||
const llmData = await extractFileLLM(filePath, llmClient, cacheDir);
|
entry: DirectoryEntry,
|
||||||
const astData = await extractFileAST(filePath);
|
absFilePath: string,
|
||||||
const fileName = basename(filePath);
|
rootPath: string,
|
||||||
const updatedFile = mergeFileData(fileName, llmData, astData);
|
explicitMode: PatchMode = "auto",
|
||||||
|
): Exclude<PatchMode, "auto"> {
|
||||||
// Replace the matching file entry
|
if (explicitMode !== "auto") {
|
||||||
const idx = existing.files.findIndex((f) => f.name === updatedFile.name);
|
return explicitMode;
|
||||||
if (idx >= 0) {
|
}
|
||||||
existing.files[idx] = updatedFile;
|
|
||||||
} else {
|
const existingMapPath = resolve(entry.dirPath, ".pi-map.md");
|
||||||
existing.files.push(updatedFile);
|
const fileName = basename(absFilePath);
|
||||||
}
|
const childCount =
|
||||||
|
entry.relativePath === "."
|
||||||
existing.dirty = `${new Date().toISOString()}: ${fileName} patched (section-level)`;
|
? 0
|
||||||
writeFileSync(mapPath, renderPackageMap(existing));
|
: countDirectChildren(rootPath, entry.relativePath);
|
||||||
console.log(`Patched ${mapPath}`);
|
|
||||||
}
|
if (childCount > 0) {
|
||||||
|
return "structural";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.files.length > SMALL_CHANGE_FILE_WINDOW) {
|
||||||
|
return "structural";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (STRUCTURAL_FILE_HINT.test(fileName)) {
|
||||||
|
return "structural";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(existingMapPath)) {
|
||||||
|
const existing = parsePackageMap(readFileSync(existingMapPath, "utf8"));
|
||||||
|
const existingFile = existing.files.find(
|
||||||
|
(candidate) => candidate.name === fileName,
|
||||||
|
);
|
||||||
|
if (!existingFile) {
|
||||||
|
return "structural";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "small";
|
||||||
|
}
|
||||||
|
|
||||||
|
function countDirectChildren(rootPath: string, relDir: string): number {
|
||||||
|
const entries = discoverProject(rootPath);
|
||||||
|
return entries.filter((entry) => {
|
||||||
|
if (entry.relativePath === "." || entry.relativePath === relDir)
|
||||||
|
return false;
|
||||||
|
const parent = normalizeRelativePath(
|
||||||
|
entry.relativePath.split("/").slice(0, -1).join("/"),
|
||||||
|
);
|
||||||
|
return parent === relDir;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAncestorEntries(
|
||||||
|
entries: DirectoryEntry[],
|
||||||
|
ctx: ReturnType<typeof buildDirectoryContext>,
|
||||||
|
entry: DirectoryEntry,
|
||||||
|
): DirectoryEntry[] {
|
||||||
|
const result: DirectoryEntry[] = [];
|
||||||
|
let parent = ctx.parentMap.get(entry.relativePath);
|
||||||
|
while (parent) {
|
||||||
|
const ancestor = entries.find(
|
||||||
|
(candidate) => candidate.relativePath === parent,
|
||||||
|
);
|
||||||
|
if (ancestor) {
|
||||||
|
result.push(ancestor);
|
||||||
|
}
|
||||||
|
parent = ctx.parentMap.get(parent);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(relPath: string): string {
|
||||||
|
if (!relPath || relPath === ".") return ".";
|
||||||
|
return relPath.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinArtifactPath(relDir: string, artifactName: string): string {
|
||||||
|
return relDir === "." ? artifactName : `${relDir}/${artifactName}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
import { readdirSync, readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { parseDirectoryMap, parseDirectoryIndex } from "./format.js";
|
||||||
|
import type { DirectoryArtifactModel } from "./directory-model.js";
|
||||||
|
|
||||||
|
const TOP_K = 3;
|
||||||
|
|
||||||
|
export interface RetrieveContextOptions {
|
||||||
|
topK?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retrieveContext(
|
||||||
|
query: string,
|
||||||
|
rootPath: string,
|
||||||
|
opts: RetrieveContextOptions = {},
|
||||||
|
): string {
|
||||||
|
const topK = opts.topK ?? TOP_K;
|
||||||
|
const candidates = collectCandidates(rootPath);
|
||||||
|
const queryTerms = normalizeTerms(query);
|
||||||
|
|
||||||
|
const scored = candidates
|
||||||
|
.map((model) => ({
|
||||||
|
model,
|
||||||
|
score: scoreDirectory(model, queryTerms),
|
||||||
|
}))
|
||||||
|
.filter((c) => c.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, topK);
|
||||||
|
|
||||||
|
if (scored.length === 0) {
|
||||||
|
return renderNoResultsBundle(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderContextBundle(
|
||||||
|
query,
|
||||||
|
scored.map((s) => s.model),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCandidates(rootPath: string): DirectoryArtifactModel[] {
|
||||||
|
const results: DirectoryArtifactModel[] = [];
|
||||||
|
function walk(dir: string) {
|
||||||
|
let entries: import("fs").Dirent[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasIndex = entries.some(
|
||||||
|
(e) => e.isFile() && e.name === ".pi-map.index.md",
|
||||||
|
);
|
||||||
|
const hasMap = entries.some((e) => e.isFile() && e.name === ".pi-map.md");
|
||||||
|
|
||||||
|
if (hasIndex || hasMap) {
|
||||||
|
// Prefer index first, fall back to map, merge if both exist
|
||||||
|
let model: DirectoryArtifactModel | null = null;
|
||||||
|
|
||||||
|
if (hasIndex) {
|
||||||
|
try {
|
||||||
|
const text = readFileSync(join(dir, ".pi-map.index.md"), "utf8");
|
||||||
|
model = parseDirectoryIndex(text);
|
||||||
|
} catch {
|
||||||
|
// ignore parse error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMap) {
|
||||||
|
try {
|
||||||
|
const text = readFileSync(join(dir, ".pi-map.md"), "utf8");
|
||||||
|
const mapModel = parseDirectoryMap(text);
|
||||||
|
if (model) {
|
||||||
|
// Merge: index provides routing, map provides richer metadata
|
||||||
|
mergeModels(model, mapModel);
|
||||||
|
} else {
|
||||||
|
model = mapModel;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
results.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (
|
||||||
|
entry.isDirectory() &&
|
||||||
|
!entry.name.startsWith(".") &&
|
||||||
|
entry.name !== "node_modules"
|
||||||
|
) {
|
||||||
|
walk(join(dir, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(rootPath);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeModels(
|
||||||
|
index: DirectoryArtifactModel,
|
||||||
|
map: DirectoryArtifactModel,
|
||||||
|
): void {
|
||||||
|
// Use map's richer fields when index lacks them
|
||||||
|
if (!index.arch && map.arch) index.arch = map.arch;
|
||||||
|
if (index.tags.length === 0 && map.tags.length > 0) index.tags = map.tags;
|
||||||
|
if (index.symbols.length === 0 && map.symbols.length > 0)
|
||||||
|
index.symbols = map.symbols;
|
||||||
|
if (index.files.length === 0 && map.files.length > 0) index.files = map.files;
|
||||||
|
// Merge workflows, preferring index's workflow list but keeping map's extras
|
||||||
|
const indexTasks = new Set(index.workflows.map((w) => w.task));
|
||||||
|
for (const wf of map.workflows) {
|
||||||
|
if (!indexTasks.has(wf.task)) {
|
||||||
|
index.workflows.push(wf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTerms(query: string): string[] {
|
||||||
|
const terms = new Set<string>();
|
||||||
|
for (const word of query.toLowerCase().split(/[^a-z0-9]+/)) {
|
||||||
|
if (word.length > 1) {
|
||||||
|
terms.add(word);
|
||||||
|
for (const sub of splitCamelCase(word)) {
|
||||||
|
if (sub.length > 1) terms.add(sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...terms];
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCamelCase(str: string): string[] {
|
||||||
|
return str
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/[_-]+/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => w.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreDirectory(
|
||||||
|
model: DirectoryArtifactModel,
|
||||||
|
queryTerms: string[],
|
||||||
|
): number {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Role matches
|
||||||
|
const roleWords = extractWords(model.role);
|
||||||
|
for (const term of queryTerms) {
|
||||||
|
if (roleWords.includes(term)) score += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag matches
|
||||||
|
for (const tag of model.tags) {
|
||||||
|
for (const term of queryTerms) {
|
||||||
|
if (tag.toLowerCase().includes(term)) score += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbol matches
|
||||||
|
for (const sym of model.symbols) {
|
||||||
|
for (const term of queryTerms) {
|
||||||
|
if (sym.toLowerCase().includes(term)) score += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workflow task matches
|
||||||
|
for (const wf of model.workflows) {
|
||||||
|
for (const term of queryTerms) {
|
||||||
|
if (wf.task.toLowerCase().includes(term)) score += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File name and purpose matches
|
||||||
|
for (const file of model.files) {
|
||||||
|
const fileWords = extractWords(`${file.name} ${file.purpose}`);
|
||||||
|
const camelWords = splitCamelCase(file.name);
|
||||||
|
for (const term of queryTerms) {
|
||||||
|
if (fileWords.includes(term) || camelWords.includes(term)) score += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory path matches
|
||||||
|
const pathWords = extractWords(model.dir);
|
||||||
|
for (const term of queryTerms) {
|
||||||
|
if (pathWords.includes(term)) score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractWords(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/)
|
||||||
|
.filter((w) => w.length > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContextBundle(
|
||||||
|
query: string,
|
||||||
|
candidates: DirectoryArtifactModel[],
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`# Context bundle: ${query}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("## query");
|
||||||
|
lines.push(query);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("## relevant indexes");
|
||||||
|
for (const c of candidates) {
|
||||||
|
lines.push(`- ${c.dir}/.pi-map.index.md`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("## relevant maps");
|
||||||
|
for (const c of candidates) {
|
||||||
|
lines.push(`- ${c.dir}/.pi-map.md`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("## likely files");
|
||||||
|
const seenFiles = new Set<string>();
|
||||||
|
for (const c of candidates) {
|
||||||
|
for (const f of c.files) {
|
||||||
|
const path = `${c.dir}/${f.name}`;
|
||||||
|
if (!seenFiles.has(path)) {
|
||||||
|
seenFiles.add(path);
|
||||||
|
lines.push(`- ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seenFiles.size === 0) {
|
||||||
|
lines.push("-");
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
// Symbols: only include if at least one candidate has scored symbols above threshold
|
||||||
|
const relevantSymbols = collectRelevantSymbols(candidates);
|
||||||
|
if (relevantSymbols.length > 0) {
|
||||||
|
lines.push("## relevant symbols");
|
||||||
|
for (const sym of relevantSymbols) {
|
||||||
|
lines.push(`- ${sym}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("## instructions");
|
||||||
|
lines.push(
|
||||||
|
"Read the indexes first, then the strongest-match rich maps, then verify behavior from source before editing.",
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRelevantSymbols(
|
||||||
|
candidates: DirectoryArtifactModel[],
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const c of candidates) {
|
||||||
|
for (const sym of c.symbols) {
|
||||||
|
if (!seen.has(sym)) {
|
||||||
|
seen.add(sym);
|
||||||
|
result.push(sym);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cap at a reasonable number to keep output compact
|
||||||
|
return result.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNoResultsBundle(query: string): string {
|
||||||
|
return [
|
||||||
|
`# Context bundle: ${query}`,
|
||||||
|
"",
|
||||||
|
"## query",
|
||||||
|
query,
|
||||||
|
"",
|
||||||
|
"## relevant indexes",
|
||||||
|
"-",
|
||||||
|
"",
|
||||||
|
"## relevant maps",
|
||||||
|
"-",
|
||||||
|
"",
|
||||||
|
"## likely files",
|
||||||
|
"-",
|
||||||
|
"",
|
||||||
|
"## instructions",
|
||||||
|
"No relevant directories found. Try rephrasing the query or run `project_map_reinit` if artifacts are stale.",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import type {
|
||||||
|
DirectoryArtifactModel,
|
||||||
|
FileEntry,
|
||||||
|
RoutingMetadataOptions,
|
||||||
|
WorkflowHint,
|
||||||
|
} from "./directory-model.js";
|
||||||
|
|
||||||
|
const DEFAULT_TAG_CAP = 8;
|
||||||
|
const DEFAULT_WORKFLOW_HINT_CAP = 5;
|
||||||
|
|
||||||
|
const STOP_WORDS = new Set([
|
||||||
|
"the",
|
||||||
|
"and",
|
||||||
|
"for",
|
||||||
|
"with",
|
||||||
|
"from",
|
||||||
|
"into",
|
||||||
|
"onto",
|
||||||
|
"this",
|
||||||
|
"that",
|
||||||
|
"then",
|
||||||
|
"than",
|
||||||
|
"when",
|
||||||
|
"where",
|
||||||
|
"what",
|
||||||
|
"how",
|
||||||
|
"why",
|
||||||
|
"who",
|
||||||
|
"which",
|
||||||
|
"while",
|
||||||
|
"during",
|
||||||
|
"before",
|
||||||
|
"after",
|
||||||
|
"above",
|
||||||
|
"below",
|
||||||
|
"between",
|
||||||
|
"among",
|
||||||
|
"through",
|
||||||
|
"over",
|
||||||
|
"under",
|
||||||
|
"again",
|
||||||
|
"further",
|
||||||
|
"once",
|
||||||
|
"here",
|
||||||
|
"there",
|
||||||
|
"all",
|
||||||
|
"any",
|
||||||
|
"both",
|
||||||
|
"each",
|
||||||
|
"few",
|
||||||
|
"more",
|
||||||
|
"most",
|
||||||
|
"other",
|
||||||
|
"some",
|
||||||
|
"such",
|
||||||
|
"only",
|
||||||
|
"own",
|
||||||
|
"same",
|
||||||
|
"so",
|
||||||
|
"too",
|
||||||
|
"very",
|
||||||
|
"can",
|
||||||
|
"will",
|
||||||
|
"just",
|
||||||
|
"should",
|
||||||
|
"now",
|
||||||
|
"use",
|
||||||
|
"using",
|
||||||
|
"used",
|
||||||
|
"via",
|
||||||
|
"based",
|
||||||
|
"build",
|
||||||
|
"built",
|
||||||
|
"used",
|
||||||
|
"file",
|
||||||
|
"files",
|
||||||
|
"module",
|
||||||
|
"modules",
|
||||||
|
"function",
|
||||||
|
"functions",
|
||||||
|
"class",
|
||||||
|
"classes",
|
||||||
|
"export",
|
||||||
|
"exports",
|
||||||
|
"import",
|
||||||
|
"imports",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function populateRoutingMetadata(
|
||||||
|
model: DirectoryArtifactModel,
|
||||||
|
opts: RoutingMetadataOptions = {},
|
||||||
|
): void {
|
||||||
|
const tagCap = opts.tagCap ?? DEFAULT_TAG_CAP;
|
||||||
|
const workflowHintCap = opts.workflowHintCap ?? DEFAULT_WORKFLOW_HINT_CAP;
|
||||||
|
|
||||||
|
model.tags = generateTags(model.files, tagCap);
|
||||||
|
model.symbols = generateSymbols(model.files, tagCap);
|
||||||
|
model.workflows = generateWorkflows(model, workflowHintCap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTags(files: FileEntry[], cap: number): string[] {
|
||||||
|
const scores = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Score words from file purpose
|
||||||
|
for (const word of extractWords(file.purpose)) {
|
||||||
|
scores.set(word, (scores.get(word) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score words from export names (camelCase split)
|
||||||
|
for (const exp of file.exports) {
|
||||||
|
const cleanExp = exp.replace(/^(class|func|method):/, "").split("(")[0];
|
||||||
|
for (const word of splitCamelCase(cleanExp)) {
|
||||||
|
if (word.length > 1) {
|
||||||
|
scores.set(word, (scores.get(word) ?? 0) + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score dep path segments
|
||||||
|
for (const dep of file.deps) {
|
||||||
|
for (const segment of dep.split(/[/\-.]/)) {
|
||||||
|
const word = segment.toLowerCase();
|
||||||
|
if (word.length > 1 && !STOP_WORDS.has(word)) {
|
||||||
|
scores.set(word, (scores.get(word) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score from file name (stem, no extension)
|
||||||
|
const stem = file.name.replace(/\.[^.]+$/, "");
|
||||||
|
for (const word of splitCamelCase(stem)) {
|
||||||
|
if (word.length > 1) {
|
||||||
|
scores.set(word, (scores.get(word) ?? 0) + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(scores.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([word]) => word)
|
||||||
|
.slice(0, cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSymbols(files: FileEntry[], cap: number): string[] {
|
||||||
|
const scores = new Map<string, number>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
for (const exp of file.exports) {
|
||||||
|
// Prefer clean identifiers over encoded DSL entries when possible
|
||||||
|
let symbol = exp;
|
||||||
|
let score = 1;
|
||||||
|
|
||||||
|
if (exp.startsWith("class:")) {
|
||||||
|
symbol = exp.slice(6).split(" ")[0];
|
||||||
|
score = 3;
|
||||||
|
} else if (exp.startsWith("func:")) {
|
||||||
|
symbol = exp.slice(5).split("(")[0];
|
||||||
|
score = 2;
|
||||||
|
} else if (exp.startsWith("method:")) {
|
||||||
|
symbol = exp.slice(7).split("(")[0];
|
||||||
|
score = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seen.has(symbol)) {
|
||||||
|
seen.add(symbol);
|
||||||
|
scores.set(symbol, (scores.get(symbol) ?? 0) + score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(scores.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([name]) => name)
|
||||||
|
.slice(0, cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateWorkflows(
|
||||||
|
model: DirectoryArtifactModel,
|
||||||
|
cap: number,
|
||||||
|
): WorkflowHint[] {
|
||||||
|
const workflows: WorkflowHint[] = [];
|
||||||
|
const dirName = model.dir.split("/").pop() || model.dir;
|
||||||
|
const baseName = dirName === "." ? "project" : dirName;
|
||||||
|
|
||||||
|
// Only generate workflows when we have reasonable structural confidence
|
||||||
|
const hasSourceFiles = model.files.some((f) =>
|
||||||
|
/\.(ts|tsx|js|jsx|py|go|rs|java)$/.test(f.name),
|
||||||
|
);
|
||||||
|
if (!hasSourceFiles || model.files.length === 0) {
|
||||||
|
return workflows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFiles = model.files.filter(
|
||||||
|
(f) =>
|
||||||
|
!/\.(test|spec)\./.test(f.name) && !/\.(md|json|yaml|yml)$/.test(f.name),
|
||||||
|
);
|
||||||
|
const testFiles = model.files.filter((f) => /\.(test|spec)\./.test(f.name));
|
||||||
|
const configFiles = model.files.filter(
|
||||||
|
(f) =>
|
||||||
|
f.name.includes("config") ||
|
||||||
|
/\.(json|yaml|yml|toml)$/.test(f.name) ||
|
||||||
|
f.name === ".env",
|
||||||
|
);
|
||||||
|
const cliFiles = model.files.filter(
|
||||||
|
(f) => f.name.includes("cli") || f.name.includes("command"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourceFiles.length > 0) {
|
||||||
|
workflows.push({
|
||||||
|
task: `change ${baseName} behavior`,
|
||||||
|
read: sourceFiles.slice(0, 3).map((f) => f.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testFiles.length > 0) {
|
||||||
|
workflows.push({
|
||||||
|
task: `update ${baseName} tests`,
|
||||||
|
read: testFiles.slice(0, 3).map((f) => f.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliFiles.length > 0) {
|
||||||
|
workflows.push({
|
||||||
|
task: `change ${baseName} CLI`,
|
||||||
|
read: cliFiles.slice(0, 3).map((f) => f.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configFiles.length > 0) {
|
||||||
|
workflows.push({
|
||||||
|
task: `change ${baseName} config`,
|
||||||
|
read: configFiles.slice(0, 3).map((f) => f.name),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a directory-navigation workflow for non-leaf directories
|
||||||
|
if (model.children.length > 0) {
|
||||||
|
workflows.push({
|
||||||
|
task: `explore ${baseName} subdirectories`,
|
||||||
|
index: model.children
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((child) => `${child}/.pi-map.index.md`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflows.slice(0, cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractWords(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[^a-z0-9]+/)
|
||||||
|
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCamelCase(str: string): string[] {
|
||||||
|
return str
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/[_-]+/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => w.length > 1 && !STOP_WORDS.has(w));
|
||||||
|
}
|
||||||
+370
-57
@@ -1,9 +1,17 @@
|
|||||||
import { discoverProject } from "./discover.js";
|
|
||||||
import { parsePackageMap } from "./format.js";
|
|
||||||
import { existsSync, readFileSync } from "fs";
|
import { existsSync, readFileSync } from "fs";
|
||||||
import { join } from "path";
|
import { join, resolve } from "path";
|
||||||
|
import { discoverProject } from "./discover.js";
|
||||||
|
import type { DirectoryEntry } from "./discover.js";
|
||||||
|
import { parseDirectoryIndex, parseDirectoryMap } from "./format.js";
|
||||||
import { extractFileAST } from "./ast/ast-extract.js";
|
import { extractFileAST } from "./ast/ast-extract.js";
|
||||||
import { generateDirectoryMap } from "./init.js";
|
import {
|
||||||
|
generateDirectoryArtifacts,
|
||||||
|
buildDirectoryContext,
|
||||||
|
type ArtifactWriteMode,
|
||||||
|
} from "./init.js";
|
||||||
|
import type { LLMClient } from "./llm/llm-client.js";
|
||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
import type { PatchMode } from "./patch.js";
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
clean: boolean;
|
clean: boolean;
|
||||||
@@ -11,81 +19,219 @@ export interface ValidationResult {
|
|||||||
fixed?: number;
|
fixed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ValidationOptions {
|
||||||
|
fix?: boolean;
|
||||||
|
verbose?: boolean;
|
||||||
|
llmClient?: LLMClient;
|
||||||
|
cacheDir?: string;
|
||||||
|
patchMode?: PatchMode;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Discrepancy {
|
export interface Discrepancy {
|
||||||
type: "missing" | "orphaned" | "stale-signature" | "dirty";
|
type:
|
||||||
|
| "missing"
|
||||||
|
| "orphaned"
|
||||||
|
| "stale-signature"
|
||||||
|
| "dirty"
|
||||||
|
| "stale-index"
|
||||||
|
| "stale-map"
|
||||||
|
| "broken-link"
|
||||||
|
| "structural";
|
||||||
path: string;
|
path: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RepairMode = Exclude<PatchMode, "auto">;
|
||||||
|
|
||||||
export async function validateMaps(
|
export async function validateMaps(
|
||||||
rootPath: string,
|
rootPath: string,
|
||||||
options?: { fix?: boolean; verbose?: boolean },
|
options: ValidationOptions = {},
|
||||||
): Promise<ValidationResult> {
|
): Promise<ValidationResult> {
|
||||||
const { fix = false, verbose = true } = options || {};
|
const root = resolve(rootPath);
|
||||||
|
const {
|
||||||
|
fix = false,
|
||||||
|
verbose = true,
|
||||||
|
llmClient,
|
||||||
|
cacheDir,
|
||||||
|
patchMode = "auto",
|
||||||
|
} = options;
|
||||||
const discrepancies: Discrepancy[] = [];
|
const discrepancies: Discrepancy[] = [];
|
||||||
const entries = discoverProject(rootPath);
|
const entries = discoverProject(root);
|
||||||
const dirsToFix = new Set<string>();
|
const config = loadConfig(root);
|
||||||
|
const routingOpts = {
|
||||||
|
tagCap: config.tagCap,
|
||||||
|
workflowHintCap: config.workflowHintCap,
|
||||||
|
};
|
||||||
|
const repairModes = new Map<string, RepairMode>();
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
const ctx = buildDirectoryContext(entries, entry);
|
||||||
const mapPath = join(entry.dirPath, ".pi-map.md");
|
const mapPath = join(entry.dirPath, ".pi-map.md");
|
||||||
|
const indexPath = join(entry.dirPath, ".pi-map.index.md");
|
||||||
|
const expectedParent = ctx.parentMap.get(entry.relativePath);
|
||||||
|
const expectedChildren = [
|
||||||
|
...(ctx.childrenMap.get(entry.relativePath) ?? []),
|
||||||
|
].sort();
|
||||||
|
const structuralFlag = (reason: string, path = entry.relativePath) => {
|
||||||
|
discrepancies.push({ type: "structural", path, message: reason });
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
|
};
|
||||||
|
|
||||||
if (!existsSync(mapPath)) {
|
if (!existsSync(mapPath)) {
|
||||||
discrepancies.push({
|
discrepancies.push({
|
||||||
type: "missing",
|
type: "stale-map",
|
||||||
path: entry.relativePath,
|
path: mapPath,
|
||||||
message: "No .pi-map.md found",
|
message: "missing .pi-map.md",
|
||||||
});
|
});
|
||||||
if (fix) dirsToFix.add(entry.dirPath);
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!existsSync(indexPath)) {
|
||||||
|
discrepancies.push({
|
||||||
|
type: "stale-index",
|
||||||
|
path: indexPath,
|
||||||
|
message: "missing .pi-map.index.md",
|
||||||
|
});
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapData = parsePackageMap(readFileSync(mapPath, "utf8"));
|
const mapText = readFileSync(mapPath, "utf8");
|
||||||
let mapNeedsRewrite = false;
|
const indexText = readFileSync(indexPath, "utf8");
|
||||||
|
const mapData = parseDirectoryMap(mapText);
|
||||||
|
const indexData = parseDirectoryIndex(indexText);
|
||||||
|
|
||||||
// Check for dirty markers
|
|
||||||
if (mapData.dirty && mapData.dirty !== "-") {
|
if (mapData.dirty && mapData.dirty !== "-") {
|
||||||
discrepancies.push({
|
discrepancies.push({
|
||||||
type: "dirty",
|
type: "dirty",
|
||||||
path: mapPath,
|
path: mapPath,
|
||||||
message: `Dirty: ${mapData.dirty}`,
|
message: `Dirty: ${mapData.dirty}`,
|
||||||
});
|
});
|
||||||
if (fix) mapNeedsRewrite = true;
|
markRepairMode(repairModes, entry.dirPath, "small");
|
||||||
}
|
}
|
||||||
|
if (indexData.dirty && indexData.dirty !== "-") {
|
||||||
// Check for orphaned entries
|
|
||||||
for (const fileEntry of mapData.files) {
|
|
||||||
const filePath = join(entry.dirPath, fileEntry.name);
|
|
||||||
if (!existsSync(filePath)) {
|
|
||||||
discrepancies.push({
|
discrepancies.push({
|
||||||
type: "orphaned",
|
type: "stale-index",
|
||||||
path: filePath,
|
path: indexPath,
|
||||||
message: `File listed but deleted: ${fileEntry.name}`,
|
message: `dirty index: ${indexData.dirty}`,
|
||||||
});
|
});
|
||||||
if (fix) mapNeedsRewrite = true;
|
markRepairMode(repairModes, entry.dirPath, "small");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for new files not in map
|
if (mapData.dir !== entry.relativePath) {
|
||||||
|
structuralFlag(`dir mismatch in map (${mapData.dir})`, mapPath);
|
||||||
|
}
|
||||||
|
if (indexData.dir !== entry.relativePath) {
|
||||||
|
structuralFlag(`dir mismatch in index (${indexData.dir})`, indexPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedIndexLink = `index: ${entry.relativePath}/.pi-map.index.md`;
|
||||||
|
const expectedMapLink = `map: ${entry.relativePath}/.pi-map.md`;
|
||||||
|
if (!mapText.includes(expectedIndexLink)) {
|
||||||
|
discrepancies.push({
|
||||||
|
type: "broken-link",
|
||||||
|
path: mapPath,
|
||||||
|
message: `missing sibling index link (${expectedIndexLink})`,
|
||||||
|
});
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
|
}
|
||||||
|
if (!indexText.includes(expectedMapLink)) {
|
||||||
|
discrepancies.push({
|
||||||
|
type: "broken-link",
|
||||||
|
path: indexPath,
|
||||||
|
message: `missing sibling map link (${expectedMapLink})`,
|
||||||
|
});
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapFiles = new Set(mapData.files.map((file) => file.name));
|
||||||
|
const indexFiles = new Set(indexData.files.map((file) => file.name));
|
||||||
|
const actualFiles = new Set(entry.files);
|
||||||
for (const file of entry.files) {
|
for (const file of entry.files) {
|
||||||
if (!mapData.files.find((f) => f.name === file)) {
|
if (!mapFiles.has(file)) {
|
||||||
discrepancies.push({
|
discrepancies.push({
|
||||||
type: "missing",
|
type: "missing",
|
||||||
path: join(entry.dirPath, file),
|
path: join(entry.dirPath, file),
|
||||||
message: `File not in .pi-map.md: ${file}`,
|
message: `File not in .pi-map.md: ${file}`,
|
||||||
});
|
});
|
||||||
if (fix) mapNeedsRewrite = true;
|
markRepairMode(repairModes, entry.dirPath, "small");
|
||||||
|
}
|
||||||
|
if (!indexFiles.has(file)) {
|
||||||
|
discrepancies.push({
|
||||||
|
type: "stale-index",
|
||||||
|
path: indexPath,
|
||||||
|
message: `file missing from index: ${file}`,
|
||||||
|
});
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "small");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const file of mapFiles) {
|
||||||
|
if (!actualFiles.has(file)) {
|
||||||
|
discrepancies.push({
|
||||||
|
type: "orphaned",
|
||||||
|
path: join(entry.dirPath, file),
|
||||||
|
message: `File listed but deleted: ${file}`,
|
||||||
|
});
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "small");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const file of indexFiles) {
|
||||||
|
if (!actualFiles.has(file)) {
|
||||||
|
discrepancies.push({
|
||||||
|
type: "stale-index",
|
||||||
|
path: indexPath,
|
||||||
|
message: `orphaned file entry in index: ${file}`,
|
||||||
|
});
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "small");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((indexData.parent ?? undefined) !== expectedParent) {
|
||||||
|
discrepancies.push({
|
||||||
|
type: "broken-link",
|
||||||
|
path: indexPath,
|
||||||
|
message: `parent mismatch: expected ${expectedParent ?? "-"}`,
|
||||||
|
});
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
|
}
|
||||||
|
if (!sameStringArrays(indexData.children, expectedChildren)) {
|
||||||
|
discrepancies.push({
|
||||||
|
type: "broken-link",
|
||||||
|
path: indexPath,
|
||||||
|
message: `children mismatch: expected ${expectedChildren.join(", ") || "-"}`,
|
||||||
|
});
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sameWorkflowShapes(mapData.workflows, indexData.workflows)) {
|
||||||
|
discrepancies.push({
|
||||||
|
type: "structural",
|
||||||
|
path: entry.relativePath,
|
||||||
|
message: "map/index workflow disagreement",
|
||||||
|
});
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const target of collectWorkflowTargets(indexData)) {
|
||||||
|
if (!targetExists(root, target)) {
|
||||||
|
discrepancies.push({
|
||||||
|
type: "broken-link",
|
||||||
|
path: indexPath,
|
||||||
|
message: `workflow target missing: ${target}`,
|
||||||
|
});
|
||||||
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check signatures for code files
|
|
||||||
for (const fileEntry of mapData.files) {
|
for (const fileEntry of mapData.files) {
|
||||||
const filePath = join(entry.dirPath, fileEntry.name);
|
const filePath = join(entry.dirPath, fileEntry.name);
|
||||||
if (!existsSync(filePath)) continue;
|
if (!existsSync(filePath)) continue;
|
||||||
|
|
||||||
const astData = await extractFileAST(filePath);
|
const astData = await extractFileAST(filePath);
|
||||||
if (astData) {
|
if (!astData) continue;
|
||||||
const listedExports = new Set(fileEntry.exports);
|
|
||||||
const actualExports = new Set(astData.exports);
|
const actualExports = new Set(astData.exports);
|
||||||
|
const listedExports = new Set(
|
||||||
|
normalizeListedExports(fileEntry.exports, actualExports),
|
||||||
|
);
|
||||||
for (const exp of listedExports) {
|
for (const exp of listedExports) {
|
||||||
if (!actualExports.has(exp)) {
|
if (!actualExports.has(exp)) {
|
||||||
discrepancies.push({
|
discrepancies.push({
|
||||||
@@ -93,7 +239,7 @@ export async function validateMaps(
|
|||||||
path: filePath,
|
path: filePath,
|
||||||
message: `Missing export: ${exp}`,
|
message: `Missing export: ${exp}`,
|
||||||
});
|
});
|
||||||
if (fix) mapNeedsRewrite = true;
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const exp of actualExports) {
|
for (const exp of actualExports) {
|
||||||
@@ -103,26 +249,41 @@ export async function validateMaps(
|
|||||||
path: filePath,
|
path: filePath,
|
||||||
message: `New export: ${exp}`,
|
message: `New export: ${exp}`,
|
||||||
});
|
});
|
||||||
if (fix) mapNeedsRewrite = true;
|
markRepairMode(repairModes, entry.dirPath, "structural");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fix && mapNeedsRewrite) {
|
|
||||||
dirsToFix.add(entry.dirPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply fixes
|
|
||||||
let fixed = 0;
|
let fixed = 0;
|
||||||
if (fix && dirsToFix.size > 0) {
|
if (fix && repairModes.size > 0) {
|
||||||
for (const dirPath of dirsToFix) {
|
if (!llmClient) {
|
||||||
const entry = entries.find((e) => e.dirPath === dirPath);
|
throw new Error("validate --fix requires an LLM client");
|
||||||
if (entry) {
|
|
||||||
await generateDirectoryMap(entry);
|
|
||||||
fixed++;
|
|
||||||
}
|
}
|
||||||
|
const repairPlan = new Map<string, ArtifactWriteMode>();
|
||||||
|
for (const [dirPath, inferredMode] of repairModes) {
|
||||||
|
const entry = entries.find((candidate) => candidate.dirPath === dirPath);
|
||||||
|
if (!entry) continue;
|
||||||
|
const effectiveMode = patchMode === "auto" ? inferredMode : patchMode;
|
||||||
|
addRepairChain(entries, entry, effectiveMode, repairPlan);
|
||||||
|
}
|
||||||
|
const orderedEntries = Array.from(repairPlan.entries()).sort(
|
||||||
|
(a, b) => depthOfPath(b[0]) - depthOfPath(a[0]),
|
||||||
|
);
|
||||||
|
for (const [dirPath, writeMode] of orderedEntries) {
|
||||||
|
const entry = entries.find((candidate) => candidate.dirPath === dirPath);
|
||||||
|
if (!entry) continue;
|
||||||
|
const ctx = buildDirectoryContext(entries, entry);
|
||||||
|
await generateDirectoryArtifacts(
|
||||||
|
entry,
|
||||||
|
ctx,
|
||||||
|
llmClient,
|
||||||
|
cacheDir,
|
||||||
|
undefined,
|
||||||
|
routingOpts,
|
||||||
|
writeMode,
|
||||||
|
);
|
||||||
|
fixed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,24 +291,176 @@ export async function validateMaps(
|
|||||||
clean: discrepancies.length === 0,
|
clean: discrepancies.length === 0,
|
||||||
discrepancies,
|
discrepancies,
|
||||||
};
|
};
|
||||||
|
if (fix) result.fixed = fixed;
|
||||||
if (fix) {
|
|
||||||
result.fixed = fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
if (result.clean) {
|
if (result.clean) {
|
||||||
console.log("All .pi-map.md files are clean.");
|
console.log("All .pi-map.md/.pi-map.index.md files are clean.");
|
||||||
} else {
|
} else {
|
||||||
console.log(`Found ${discrepancies.length} discrepancies:`);
|
console.log(`Found ${discrepancies.length} discrepancies:`);
|
||||||
for (const d of discrepancies) {
|
for (const discrepancy of discrepancies) {
|
||||||
console.log(` [${d.type}] ${d.path}: ${d.message}`);
|
console.log(
|
||||||
|
` [${discrepancy.type}] ${discrepancy.path}: ${discrepancy.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fix && fixed > 0) {
|
if (fix && fixed > 0) {
|
||||||
console.log(`Fixed ${fixed} director${fixed === 1 ? "y" : "ies"}.`);
|
const fixModeLabel = patchMode === "auto" ? "auto" : patchMode;
|
||||||
|
console.log(
|
||||||
|
`Repaired affected chain (patchMode: ${fixModeLabel}) across ${fixed} directories.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameStringArrays(a: string[], b: string[]): boolean {
|
||||||
|
const left = [...a].sort();
|
||||||
|
const right = [...b].sort();
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameWorkflowShapes(
|
||||||
|
a: ReturnType<typeof parseDirectoryMap>["workflows"],
|
||||||
|
b: ReturnType<typeof parseDirectoryIndex>["workflows"],
|
||||||
|
): boolean {
|
||||||
|
const normalize = (workflow: (typeof a)[number]) => ({
|
||||||
|
task: workflow.task,
|
||||||
|
read: [...(workflow.read ?? [])].sort(),
|
||||||
|
index: [...(workflow.index ?? [])].sort(),
|
||||||
|
map: [...(workflow.map ?? [])].sort(),
|
||||||
|
files: [...(workflow.files ?? [])].sort(),
|
||||||
|
});
|
||||||
|
return JSON.stringify(a.map(normalize)) === JSON.stringify(b.map(normalize));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWorkflowTargets(
|
||||||
|
indexData: ReturnType<typeof parseDirectoryIndex>,
|
||||||
|
): string[] {
|
||||||
|
const targets: string[] = [];
|
||||||
|
for (const workflow of indexData.workflows) {
|
||||||
|
targets.push(...(workflow.index ?? []));
|
||||||
|
targets.push(...(workflow.map ?? []));
|
||||||
|
targets.push(...(workflow.files ?? []));
|
||||||
|
}
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetExists(rootPath: string, target: string): boolean {
|
||||||
|
return existsSync(resolve(rootPath, target));
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILT_IN_GLOBALS = new Set([
|
||||||
|
"Array",
|
||||||
|
"Date",
|
||||||
|
"JSON",
|
||||||
|
"Math",
|
||||||
|
"Object",
|
||||||
|
"Promise",
|
||||||
|
"RegExp",
|
||||||
|
"String",
|
||||||
|
"Number",
|
||||||
|
"Boolean",
|
||||||
|
"Error",
|
||||||
|
"Map",
|
||||||
|
"Set",
|
||||||
|
"Symbol",
|
||||||
|
"WeakMap",
|
||||||
|
"WeakSet",
|
||||||
|
"ArrayBuffer",
|
||||||
|
"DataView",
|
||||||
|
"Float32Array",
|
||||||
|
"Float64Array",
|
||||||
|
"Int8Array",
|
||||||
|
"Int16Array",
|
||||||
|
"Int32Array",
|
||||||
|
"Uint8Array",
|
||||||
|
"Uint8ClampedArray",
|
||||||
|
"Uint16Array",
|
||||||
|
"Uint32Array",
|
||||||
|
"console",
|
||||||
|
"process",
|
||||||
|
"Buffer",
|
||||||
|
"undefined",
|
||||||
|
"null",
|
||||||
|
"Infinity",
|
||||||
|
"NaN",
|
||||||
|
"parseInt",
|
||||||
|
"parseFloat",
|
||||||
|
"isNaN",
|
||||||
|
"isFinite",
|
||||||
|
"encodeURI",
|
||||||
|
"encodeURIComponent",
|
||||||
|
"decodeURI",
|
||||||
|
"decodeURIComponent",
|
||||||
|
"eval",
|
||||||
|
"Function",
|
||||||
|
"Proxy",
|
||||||
|
"Reflect",
|
||||||
|
"Intl",
|
||||||
|
"BigInt",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeListedExports(
|
||||||
|
exports: string[],
|
||||||
|
actualExports: Set<string>,
|
||||||
|
): string[] {
|
||||||
|
const normalized = new Set<string>();
|
||||||
|
for (const exp of exports) {
|
||||||
|
let candidate = exp.trim();
|
||||||
|
if (candidate.startsWith("call:") || candidate.startsWith("raise:"))
|
||||||
|
continue;
|
||||||
|
candidate = candidate.replace(/^(class|func|method):/, "");
|
||||||
|
candidate = candidate.split("(")[0]?.split(" ")[0] ?? candidate;
|
||||||
|
const match = candidate.match(/[A-Za-z_$][\w$]*/);
|
||||||
|
if (!match) continue;
|
||||||
|
const symbol = match[0];
|
||||||
|
if (
|
||||||
|
actualExports.has(symbol) ||
|
||||||
|
(/^[A-Z][A-Za-z0-9_$]*$/.test(symbol) && !BUILT_IN_GLOBALS.has(symbol)) ||
|
||||||
|
/^[A-Z0-9_]+$/.test(symbol)
|
||||||
|
) {
|
||||||
|
normalized.add(symbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRepairMode(
|
||||||
|
repairModes: Map<string, RepairMode>,
|
||||||
|
dirPath: string,
|
||||||
|
mode: RepairMode,
|
||||||
|
): void {
|
||||||
|
const existing = repairModes.get(dirPath);
|
||||||
|
if (!existing || existing === "small") {
|
||||||
|
repairModes.set(dirPath, mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRepairChain(
|
||||||
|
entries: DirectoryEntry[],
|
||||||
|
entry: DirectoryEntry,
|
||||||
|
mode: RepairMode,
|
||||||
|
plan: Map<string, ArtifactWriteMode>,
|
||||||
|
): void {
|
||||||
|
plan.set(entry.dirPath, "both");
|
||||||
|
const ctx = buildDirectoryContext(entries, entry);
|
||||||
|
let parent = ctx.parentMap.get(entry.relativePath);
|
||||||
|
while (parent) {
|
||||||
|
const ancestor = entries.find(
|
||||||
|
(candidate) => candidate.relativePath === parent,
|
||||||
|
);
|
||||||
|
if (!ancestor) break;
|
||||||
|
const desiredMode: ArtifactWriteMode = mode === "small" ? "index" : "both";
|
||||||
|
const existingMode = plan.get(ancestor.dirPath);
|
||||||
|
if (existingMode !== "both") {
|
||||||
|
plan.set(ancestor.dirPath, desiredMode);
|
||||||
|
}
|
||||||
|
parent = ctx.parentMap.get(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function depthOfPath(dirPath: string): number {
|
||||||
|
return dirPath.split(/[\\/]/).filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
|
||||||
|
function runCli(
|
||||||
|
args: string,
|
||||||
|
cwd: string,
|
||||||
|
): { stdout: string; stderr: string; exitCode: number } {
|
||||||
|
try {
|
||||||
|
const stdout = execSync(
|
||||||
|
`npx tsx ${join(projectRoot, "src/cli/cli.ts")} ${args}`,
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { stdout: stdout.trim(), stderr: "", exitCode: 0 };
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
stdout: err.stdout?.toString().trim() || "",
|
||||||
|
stderr: err.stderr?.toString().trim() || "",
|
||||||
|
exitCode: err.status || 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("cli context", () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "pi-map-cli-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("context command returns a bundle for a matching query", () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
mkdirSync(join(dir, "src", "auth"));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", "auth", "tokens.ts"),
|
||||||
|
`export function validateToken() {}\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", "auth", ".pi-map.md"),
|
||||||
|
`# src/auth
|
||||||
|
dir: src/auth
|
||||||
|
index: src/auth/.pi-map.index.md
|
||||||
|
## role
|
||||||
|
Authentication and token validation.
|
||||||
|
## files
|
||||||
|
- tokens.ts | Token validation | exp: validateToken | dep: -
|
||||||
|
## arch
|
||||||
|
Guard pattern.
|
||||||
|
## tags
|
||||||
|
auth, token, validate
|
||||||
|
## symbols
|
||||||
|
validateToken
|
||||||
|
## workflows
|
||||||
|
- validate token
|
||||||
|
files: tokens.ts
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", "auth", ".pi-map.index.md"),
|
||||||
|
`# src/auth (index)
|
||||||
|
dir: src/auth
|
||||||
|
## role
|
||||||
|
Auth layer.
|
||||||
|
## parent
|
||||||
|
index: ./.pi-map.index.md
|
||||||
|
map: ./.pi-map.md
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- tokens.ts
|
||||||
|
## links
|
||||||
|
index: src/auth/.pi-map.index.md
|
||||||
|
map: src/auth/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
- validate token
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = runCli('context "token validation"', dir);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("# Context bundle: token validation");
|
||||||
|
expect(stdout).toContain("## relevant indexes");
|
||||||
|
expect(stdout).toContain("src/auth/.pi-map.index.md");
|
||||||
|
expect(stdout).toContain("## relevant maps");
|
||||||
|
expect(stdout).toContain("src/auth/.pi-map.md");
|
||||||
|
expect(stdout).toContain("## likely files");
|
||||||
|
expect(stdout).toContain("src/auth/tokens.ts");
|
||||||
|
expect(stdout).toContain("## instructions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("context command omits symbols section when no symbols exist", () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", ".pi-map.md"),
|
||||||
|
`# src
|
||||||
|
dir: src
|
||||||
|
index: src/.pi-map.index.md
|
||||||
|
## role
|
||||||
|
Core source.
|
||||||
|
## files
|
||||||
|
- index.ts | Entry | exp: main | dep: -
|
||||||
|
## arch
|
||||||
|
Entrypoint.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", ".pi-map.index.md"),
|
||||||
|
`# src (index)
|
||||||
|
dir: src
|
||||||
|
## role
|
||||||
|
Core source.
|
||||||
|
## parent
|
||||||
|
-
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- index.ts
|
||||||
|
## links
|
||||||
|
index: src/.pi-map.index.md
|
||||||
|
map: src/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = runCli('context "core source"', dir);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("# Context bundle: core source");
|
||||||
|
expect(stdout).not.toContain("## relevant symbols");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("context command returns no-results bundle when nothing matches", () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", ".pi-map.md"),
|
||||||
|
`# src
|
||||||
|
dir: src
|
||||||
|
index: src/.pi-map.index.md
|
||||||
|
## role
|
||||||
|
Core source.
|
||||||
|
## files
|
||||||
|
- index.ts | Entry | exp: main | dep: -
|
||||||
|
## arch
|
||||||
|
Entrypoint.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", ".pi-map.index.md"),
|
||||||
|
`# src (index)
|
||||||
|
dir: src
|
||||||
|
## role
|
||||||
|
Core source.
|
||||||
|
## parent
|
||||||
|
-
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
## files
|
||||||
|
- index.ts
|
||||||
|
## links
|
||||||
|
index: src/.pi-map.index.md
|
||||||
|
map: src/.pi-map.md
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdout, exitCode } = runCli('context "zzzzzzzz"', dir);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
expect(stdout).toContain("# Context bundle: zzzzzzzz");
|
||||||
|
expect(stdout).toContain("No relevant directories found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("context command errors when query is missing", () => {
|
||||||
|
const { stderr, exitCode } = runCli("context", dir);
|
||||||
|
expect(exitCode).toBe(1);
|
||||||
|
expect(stderr).toContain("Missing query");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,13 @@ import { describe, it, expect } from "vitest";
|
|||||||
import {
|
import {
|
||||||
renderPackageMap,
|
renderPackageMap,
|
||||||
parsePackageMap,
|
parsePackageMap,
|
||||||
|
renderDirectoryMap,
|
||||||
|
parseDirectoryMap,
|
||||||
|
renderDirectoryIndex,
|
||||||
|
parseDirectoryIndex,
|
||||||
type PackageMapData,
|
type PackageMapData,
|
||||||
} from "../src/format.js";
|
} from "../src/format.js";
|
||||||
|
import { createDirectoryModel } from "../src/directory-model.js";
|
||||||
|
|
||||||
const sampleData: PackageMapData = {
|
const sampleData: PackageMapData = {
|
||||||
path: "pkg/auth",
|
path: "pkg/auth",
|
||||||
@@ -42,6 +47,9 @@ describe("format", () => {
|
|||||||
"- tokens.ts | JWT gen/val | exp: issueToken, verifyToken, refreshToken | dep: crypto/hmac, db/sessions",
|
"- tokens.ts | JWT gen/val | exp: issueToken, verifyToken, refreshToken | dep: crypto/hmac, db/sessions",
|
||||||
);
|
);
|
||||||
expect(output).toContain("## arch");
|
expect(output).toContain("## arch");
|
||||||
|
expect(output).toContain("## tags");
|
||||||
|
expect(output).toContain("## symbols");
|
||||||
|
expect(output).toContain("## workflows");
|
||||||
expect(output).toContain("## dirty");
|
expect(output).toContain("## dirty");
|
||||||
expect(output).toContain("-");
|
expect(output).toContain("-");
|
||||||
});
|
});
|
||||||
@@ -94,6 +102,12 @@ Test package
|
|||||||
Line one.
|
Line one.
|
||||||
Line two.
|
Line two.
|
||||||
Line three.
|
Line three.
|
||||||
|
## tags
|
||||||
|
-
|
||||||
|
## symbols
|
||||||
|
-
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
## dirty
|
## dirty
|
||||||
-
|
-
|
||||||
`;
|
`;
|
||||||
@@ -101,3 +115,278 @@ Line three.
|
|||||||
expect(parsed.arch).toBe("Line one.\nLine two.\nLine three.");
|
expect(parsed.arch).toBe("Line one.\nLine two.\nLine three.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("paired artifacts", () => {
|
||||||
|
it("renders and parses a directory map with tags/symbols/workflows", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/cli",
|
||||||
|
role: "CLI entrypoint and command parsing",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "cli.ts",
|
||||||
|
purpose: "CLI entrypoint",
|
||||||
|
exports: ["main"],
|
||||||
|
deps: ["commander"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
arch: "Command-line interface built on commander.js",
|
||||||
|
});
|
||||||
|
model.tags = ["cli", "entrypoint"];
|
||||||
|
model.symbols = ["main"];
|
||||||
|
model.workflows = [{ task: "add command", read: ["cli.ts"] }];
|
||||||
|
|
||||||
|
const rendered = renderDirectoryMap(model);
|
||||||
|
expect(rendered).toContain("# src/cli");
|
||||||
|
expect(rendered).toContain("## tags");
|
||||||
|
expect(rendered).toContain("cli, entrypoint");
|
||||||
|
expect(rendered).toContain("## symbols");
|
||||||
|
expect(rendered).toContain("- main");
|
||||||
|
expect(rendered).toContain("## workflows");
|
||||||
|
expect(rendered).toContain("- add command");
|
||||||
|
|
||||||
|
const parsed = parseDirectoryMap(rendered);
|
||||||
|
expect(parsed.dir).toBe("src/cli");
|
||||||
|
expect(parsed.tags).toEqual(["cli", "entrypoint"]);
|
||||||
|
expect(parsed.symbols).toEqual(["main"]);
|
||||||
|
expect(parsed.workflows).toHaveLength(1);
|
||||||
|
expect(parsed.workflows[0].task).toBe("add command");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders and parses a directory index with parent/children/links", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src",
|
||||||
|
role: "Core source code",
|
||||||
|
files: [
|
||||||
|
{ name: "index.ts", purpose: "Main exports", exports: [], deps: [] },
|
||||||
|
],
|
||||||
|
arch: "Source code root",
|
||||||
|
parent: ".",
|
||||||
|
children: ["src/cli", "src/lib"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = renderDirectoryIndex(model);
|
||||||
|
expect(rendered).toContain("# src (index)");
|
||||||
|
expect(rendered).toContain("dir: src");
|
||||||
|
expect(rendered).toContain("## parent");
|
||||||
|
expect(rendered).toContain("index: ./.pi-map.index.md");
|
||||||
|
expect(rendered).toContain("map: ./.pi-map.md");
|
||||||
|
expect(rendered).toContain("## children");
|
||||||
|
expect(rendered).toContain("- src/cli");
|
||||||
|
expect(rendered).toContain("index: src/cli/.pi-map.index.md");
|
||||||
|
expect(rendered).toContain("map: src/cli/.pi-map.md");
|
||||||
|
expect(rendered).toContain("## links");
|
||||||
|
expect(rendered).toContain("index: src/.pi-map.index.md");
|
||||||
|
expect(rendered).toContain("map: src/.pi-map.md");
|
||||||
|
|
||||||
|
const parsed = parseDirectoryIndex(rendered);
|
||||||
|
expect(parsed.dir).toBe("src");
|
||||||
|
expect(parsed.parent).toBe(".");
|
||||||
|
expect(parsed.children).toEqual(["src/cli", "src/lib"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a root index with Project Map Protocol", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: ".",
|
||||||
|
role: "Project root",
|
||||||
|
files: [],
|
||||||
|
arch: "Root architecture",
|
||||||
|
isRoot: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = renderDirectoryIndex(model);
|
||||||
|
expect(rendered).toContain("# . (index)");
|
||||||
|
expect(rendered).toContain("dir: .");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips an empty leaf index", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/utils",
|
||||||
|
role: "Utilities",
|
||||||
|
files: [
|
||||||
|
{ name: "helpers.ts", purpose: "Helpers", exports: [], deps: [] },
|
||||||
|
],
|
||||||
|
arch: "Shared helpers",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = renderDirectoryIndex(model);
|
||||||
|
expect(rendered).toContain("# src/utils (index)");
|
||||||
|
expect(rendered).toContain("## children");
|
||||||
|
expect(rendered).toContain("-");
|
||||||
|
|
||||||
|
const parsed = parseDirectoryIndex(rendered);
|
||||||
|
expect(parsed.dir).toBe("src/utils");
|
||||||
|
expect(parsed.children).toEqual([]);
|
||||||
|
expect(parsed.files).toHaveLength(1);
|
||||||
|
expect(parsed.files[0].name).toBe("helpers.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips workflows with read continuations in map", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/cli",
|
||||||
|
role: "CLI entrypoint",
|
||||||
|
files: [{ name: "cli.ts", purpose: "CLI", exports: ["main"], deps: [] }],
|
||||||
|
arch: "CLI",
|
||||||
|
});
|
||||||
|
model.workflows = [
|
||||||
|
{ task: "add command", read: ["cli.ts", "commands.ts"] },
|
||||||
|
{ task: "update flags", read: ["cli.ts"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rendered = renderDirectoryMap(model);
|
||||||
|
expect(rendered).toContain("- add command");
|
||||||
|
expect(rendered).toContain(" read: cli.ts, commands.ts");
|
||||||
|
expect(rendered).toContain("- update flags");
|
||||||
|
expect(rendered).toContain(" read: cli.ts");
|
||||||
|
|
||||||
|
const parsed = parseDirectoryMap(rendered);
|
||||||
|
expect(parsed.workflows).toHaveLength(2);
|
||||||
|
expect(parsed.workflows[0].task).toBe("add command");
|
||||||
|
expect(parsed.workflows[0].read).toEqual(["cli.ts", "commands.ts"]);
|
||||||
|
expect(parsed.workflows[1].task).toBe("update flags");
|
||||||
|
expect(parsed.workflows[1].read).toEqual(["cli.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips workflows with read continuations in index", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/cli",
|
||||||
|
role: "CLI entrypoint",
|
||||||
|
files: [{ name: "cli.ts", purpose: "CLI", exports: ["main"], deps: [] }],
|
||||||
|
arch: "CLI",
|
||||||
|
});
|
||||||
|
model.workflows = [
|
||||||
|
{ task: "add command", read: ["cli.ts", "commands.ts"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rendered = renderDirectoryIndex(model);
|
||||||
|
expect(rendered).toContain("- add command");
|
||||||
|
expect(rendered).toContain(" read: cli.ts, commands.ts");
|
||||||
|
|
||||||
|
const parsed = parseDirectoryIndex(rendered);
|
||||||
|
expect(parsed.workflows).toHaveLength(1);
|
||||||
|
expect(parsed.workflows[0].task).toBe("add command");
|
||||||
|
expect(parsed.workflows[0].read).toEqual(["cli.ts", "commands.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips workflows with index, map, and files continuations", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src",
|
||||||
|
role: "Source root",
|
||||||
|
files: [{ name: "index.ts", purpose: "Exports", exports: [], deps: [] }],
|
||||||
|
arch: "Source",
|
||||||
|
children: ["src/auth", "src/cli"],
|
||||||
|
});
|
||||||
|
model.workflows = [
|
||||||
|
{
|
||||||
|
task: "explore subdirectories",
|
||||||
|
index: ["src/auth/.pi-map.index.md", "src/cli/.pi-map.index.md"],
|
||||||
|
map: ["src/auth/.pi-map.md"],
|
||||||
|
files: ["src/index.ts"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mapRendered = renderDirectoryMap(model);
|
||||||
|
expect(mapRendered).toContain("- explore subdirectories");
|
||||||
|
expect(mapRendered).toContain(
|
||||||
|
" index: src/auth/.pi-map.index.md, src/cli/.pi-map.index.md",
|
||||||
|
);
|
||||||
|
expect(mapRendered).toContain(" map: src/auth/.pi-map.md");
|
||||||
|
expect(mapRendered).toContain(" files: src/index.ts");
|
||||||
|
|
||||||
|
const mapParsed = parseDirectoryMap(mapRendered);
|
||||||
|
expect(mapParsed.workflows).toHaveLength(1);
|
||||||
|
expect(mapParsed.workflows[0].index).toEqual([
|
||||||
|
"src/auth/.pi-map.index.md",
|
||||||
|
"src/cli/.pi-map.index.md",
|
||||||
|
]);
|
||||||
|
expect(mapParsed.workflows[0].map).toEqual(["src/auth/.pi-map.md"]);
|
||||||
|
expect(mapParsed.workflows[0].files).toEqual(["src/index.ts"]);
|
||||||
|
|
||||||
|
const indexRendered = renderDirectoryIndex(model);
|
||||||
|
expect(indexRendered).toContain("- explore subdirectories");
|
||||||
|
expect(indexRendered).toContain(
|
||||||
|
" index: src/auth/.pi-map.index.md, src/cli/.pi-map.index.md",
|
||||||
|
);
|
||||||
|
|
||||||
|
const indexParsed = parseDirectoryIndex(indexRendered);
|
||||||
|
expect(indexParsed.workflows).toHaveLength(1);
|
||||||
|
expect(indexParsed.workflows[0].index).toEqual([
|
||||||
|
"src/auth/.pi-map.index.md",
|
||||||
|
"src/cli/.pi-map.index.md",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renderer includes dir and index preamble directly", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/core",
|
||||||
|
role: "Core logic",
|
||||||
|
files: [],
|
||||||
|
arch: "Core",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = renderDirectoryMap(model);
|
||||||
|
expect(rendered).toContain("dir: src/core");
|
||||||
|
expect(rendered).toContain("index: src/core/.pi-map.index.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("root renderer includes Project Map Protocol directly", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: ".",
|
||||||
|
role: "Project root",
|
||||||
|
files: [],
|
||||||
|
arch: "Root",
|
||||||
|
isRoot: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapRendered = renderDirectoryMap(model);
|
||||||
|
expect(mapRendered).toContain("## Project Map Protocol");
|
||||||
|
expect(mapRendered).toContain(
|
||||||
|
"Trust boundary: index routes, map orients, source decides.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const indexRendered = renderDirectoryIndex(model);
|
||||||
|
expect(indexRendered).toContain("## Project Map Protocol");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses file lines with commas inside signatures", () => {
|
||||||
|
const mapText = `# src
|
||||||
|
## files
|
||||||
|
- format.ts | Renders maps | exp: PackageMapData, func:renderPackageMap(data: PackageMapData) → string, call:convertPackageMapToModel, func:renderDirectoryIndex(model: DirectoryArtifactModel) → string | dep: ./model.js
|
||||||
|
## arch
|
||||||
|
Test
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
|
`;
|
||||||
|
const parsed = parseDirectoryMap(mapText);
|
||||||
|
expect(parsed.files).toHaveLength(1);
|
||||||
|
expect(parsed.files[0].exports).toEqual([
|
||||||
|
"PackageMapData",
|
||||||
|
"func:renderPackageMap(data: PackageMapData) → string",
|
||||||
|
"call:convertPackageMapToModel",
|
||||||
|
"func:renderDirectoryIndex(model: DirectoryArtifactModel) → string",
|
||||||
|
]);
|
||||||
|
expect(parsed.files[0].deps).toEqual(["./model.js"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses file lines with pipes and commas inside type signatures", () => {
|
||||||
|
const mapText = `# src
|
||||||
|
## files
|
||||||
|
- llm-cache.ts | Cache helpers | exp: func:getCached(hash: string, cacheDir: string) → string | undefined, func:setCached(hash: string, result: string, cacheDir: string) → void | dep: fs, path
|
||||||
|
- user.ts | User helpers | exp: User, func:createUser(data: Omit<User, "id" | "createdAt">) → User, func:serializeUser(user: User) → string | dep: ../utils/validation.js
|
||||||
|
## arch
|
||||||
|
Test
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
|
`;
|
||||||
|
const parsed = parseDirectoryMap(mapText);
|
||||||
|
expect(parsed.files).toHaveLength(2);
|
||||||
|
expect(parsed.files[0].exports).toEqual([
|
||||||
|
"func:getCached(hash: string, cacheDir: string) → string | undefined",
|
||||||
|
"func:setCached(hash: string, result: string, cacheDir: string) → void",
|
||||||
|
]);
|
||||||
|
expect(parsed.files[1].exports).toEqual([
|
||||||
|
"User",
|
||||||
|
'func:createUser(data: Omit<User, "id" | "createdAt">) → User',
|
||||||
|
"func:serializeUser(user: User) → string",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+250
-18
@@ -11,7 +11,7 @@ import { tmpdir } from "os";
|
|||||||
import { initProject } from "../src/init.js";
|
import { initProject } from "../src/init.js";
|
||||||
import { patchFile } from "../src/patch.js";
|
import { patchFile } from "../src/patch.js";
|
||||||
import { validateMaps } from "../src/validate.js";
|
import { validateMaps } from "../src/validate.js";
|
||||||
import { createMockFileClient, createMockPackageClient } from "./mock-llm.js";
|
import { createMockFileClient } from "./mock-llm.js";
|
||||||
|
|
||||||
describe("integration", () => {
|
describe("integration", () => {
|
||||||
let dir: string;
|
let dir: string;
|
||||||
@@ -24,14 +24,68 @@ describe("integration", () => {
|
|||||||
rmSync(dir, { recursive: true });
|
rmSync(dir, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("init creates .pi-map.md files", async () => {
|
it("init creates both .pi-map.md and .pi-map.index.md files", async () => {
|
||||||
mkdirSync(join(dir, "src"));
|
mkdirSync(join(dir, "src"));
|
||||||
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
|
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
|
||||||
const client = createMockFileClient();
|
const client = createMockFileClient();
|
||||||
await initProject(dir, { llmClient: client, verbose: false });
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
|
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
|
||||||
|
const index = readFileSync(join(dir, "src", ".pi-map.index.md"), "utf8");
|
||||||
expect(map).toContain("# src");
|
expect(map).toContain("# src");
|
||||||
|
expect(index).toContain("# src (index)");
|
||||||
|
expect(index).toContain("dir: src");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("root artifacts contain Project Map Protocol", async () => {
|
||||||
|
writeFileSync(join(dir, "package.json"), `{}\n`);
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
const rootMap = readFileSync(join(dir, ".pi-map.md"), "utf8");
|
||||||
|
const rootIndex = readFileSync(join(dir, ".pi-map.index.md"), "utf8");
|
||||||
|
|
||||||
|
expect(rootMap).toContain("## Project Map Protocol");
|
||||||
|
expect(rootMap).toContain("index: ./.pi-map.index.md");
|
||||||
|
expect(rootIndex).toContain("## Project Map Protocol");
|
||||||
|
expect(rootIndex).toContain("map: ./.pi-map.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-root map contains index link", async () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
|
||||||
|
expect(map).toContain("index: src/.pi-map.index.md");
|
||||||
|
expect(map).toContain("dir: src");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("index contains parent and children when applicable", async () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
mkdirSync(join(dir, "src", "utils"));
|
||||||
|
writeFileSync(join(dir, "src", "index.ts"), `export function foo() {}\n`);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", "utils", "helpers.ts"),
|
||||||
|
`export const h = 1;\n`,
|
||||||
|
);
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
const srcIndex = readFileSync(join(dir, "src", ".pi-map.index.md"), "utf8");
|
||||||
|
expect(srcIndex).toContain("## parent");
|
||||||
|
expect(srcIndex).toContain("index: ./.pi-map.index.md");
|
||||||
|
|
||||||
|
expect(srcIndex).toContain("## children");
|
||||||
|
expect(srcIndex).toContain("- src/utils");
|
||||||
|
|
||||||
|
const utilsIndex = readFileSync(
|
||||||
|
join(dir, "src", "utils", ".pi-map.index.md"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(utilsIndex).toContain("## parent");
|
||||||
|
expect(utilsIndex).toContain("index: src/.pi-map.index.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("patch updates a file entry", async () => {
|
it("patch updates a file entry", async () => {
|
||||||
@@ -52,29 +106,68 @@ describe("integration", () => {
|
|||||||
expect(map).toContain("baz");
|
expect(map).toContain("baz");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("patch adds dirty marker for large packages", async () => {
|
it("patch with small mode refreshes ancestor indexes but preserves ancestor maps", async () => {
|
||||||
mkdirSync(join(dir, "src"));
|
mkdirSync(join(dir, "src"));
|
||||||
// Create 11 files so it's a "large" package
|
writeFileSync(join(dir, "package.json"), `{}\n`);
|
||||||
for (let i = 0; i < 11; i++) {
|
writeFileSync(join(dir, "src", "index.ts"), `export const a = 1;\n`);
|
||||||
writeFileSync(
|
writeFileSync(join(dir, "src", "helper.ts"), `export const b = 2;\n`);
|
||||||
join(dir, "src", `file${i}.ts`),
|
|
||||||
`export const x${i} = ${i};\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const client = createMockFileClient();
|
const client = createMockFileClient();
|
||||||
await initProject(dir, { llmClient: client, verbose: false });
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
// Modify a file
|
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(dir, "src", "file0.ts"),
|
join(dir, ".pi-map.md"),
|
||||||
`export const x0 = 0;\nexport const y = 99;\n`,
|
`${readFileSync(join(dir, ".pi-map.md"), "utf8")}\nSENTINEL_ROOT_MAP\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".pi-map.index.md"),
|
||||||
|
`${readFileSync(join(dir, ".pi-map.index.md"), "utf8")}\nSENTINEL_ROOT_INDEX\n`,
|
||||||
);
|
);
|
||||||
await patchFile(join(dir, "src", "file0.ts"), client, dir);
|
|
||||||
|
|
||||||
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
|
writeFileSync(
|
||||||
expect(map).toContain("y");
|
join(dir, "src", "index.ts"),
|
||||||
expect(map).toContain("dirty");
|
`export const a = 1;\nexport const c = 3;\n`,
|
||||||
expect(map).toContain("patched");
|
);
|
||||||
|
await patchFile(join(dir, "src", "index.ts"), client, dir, {
|
||||||
|
rootPath: dir,
|
||||||
|
patchMode: "small",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootMap = readFileSync(join(dir, ".pi-map.md"), "utf8");
|
||||||
|
const rootIndex = readFileSync(join(dir, ".pi-map.index.md"), "utf8");
|
||||||
|
expect(rootMap).toContain("SENTINEL_ROOT_MAP");
|
||||||
|
expect(rootIndex).not.toContain("SENTINEL_ROOT_INDEX");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patch with structural mode refreshes ancestor maps and indexes", async () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
writeFileSync(join(dir, "package.json"), `{}\n`);
|
||||||
|
writeFileSync(join(dir, "src", "index.ts"), `export const a = 1;\n`);
|
||||||
|
writeFileSync(join(dir, "src", "helper.ts"), `export const b = 2;\n`);
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".pi-map.md"),
|
||||||
|
`${readFileSync(join(dir, ".pi-map.md"), "utf8")}\nSENTINEL_ROOT_MAP\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".pi-map.index.md"),
|
||||||
|
`${readFileSync(join(dir, ".pi-map.index.md"), "utf8")}\nSENTINEL_ROOT_INDEX\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", "index.ts"),
|
||||||
|
`export const a = 1;\nexport const c = 3;\n`,
|
||||||
|
);
|
||||||
|
await patchFile(join(dir, "src", "index.ts"), client, dir, {
|
||||||
|
rootPath: dir,
|
||||||
|
patchMode: "structural",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootMap = readFileSync(join(dir, ".pi-map.md"), "utf8");
|
||||||
|
const rootIndex = readFileSync(join(dir, ".pi-map.index.md"), "utf8");
|
||||||
|
expect(rootMap).not.toContain("SENTINEL_ROOT_MAP");
|
||||||
|
expect(rootIndex).not.toContain("SENTINEL_ROOT_INDEX");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validate detects new files", async () => {
|
it("validate detects new files", async () => {
|
||||||
@@ -124,4 +217,143 @@ describe("integration", () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("init populates routing metadata in rich maps", async () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", "index.ts"),
|
||||||
|
`export function init() {}\nexport function run() {}\n`,
|
||||||
|
);
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
|
||||||
|
// Tags and symbols should be scaffolded or populated
|
||||||
|
expect(map).toContain("## tags");
|
||||||
|
expect(map).toContain("## symbols");
|
||||||
|
expect(map).toContain("## workflows");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaf directory index is tiny but present", async () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
mkdirSync(join(dir, "src", "utils"));
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", "utils", "helpers.ts"),
|
||||||
|
`export const h = 1;\n`,
|
||||||
|
);
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
const index = readFileSync(
|
||||||
|
join(dir, "src", "utils", ".pi-map.index.md"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
// Leaf index should have empty children but still follow contract
|
||||||
|
expect(index).toContain("## children");
|
||||||
|
expect(index).toContain("-");
|
||||||
|
expect(index).toContain("## parent");
|
||||||
|
expect(index).toContain("index: src/.pi-map.index.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-source directories omit uncertain workflows", async () => {
|
||||||
|
mkdirSync(join(dir, "docs"));
|
||||||
|
writeFileSync(join(dir, "docs", "README.md"), `# README\n`);
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
const map = readFileSync(join(dir, "docs", ".pi-map.md"), "utf8");
|
||||||
|
// No source files means workflows section should be empty/omitted
|
||||||
|
const workflowsMatch = map.match(/## workflows\n([^#]*)/);
|
||||||
|
if (workflowsMatch) {
|
||||||
|
expect(workflowsMatch[1].trim()).toBe("-");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("init loads config caps and applies them to routing metadata", async () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "src", `file${i}.ts`),
|
||||||
|
`export function fn${i}() {}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".pi-project-map.json"),
|
||||||
|
JSON.stringify({ tagCap: 3, workflowHintCap: 2 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
const map = readFileSync(join(dir, "src", ".pi-map.md"), "utf8");
|
||||||
|
const parsedMap = (await import("../src/format.js")).parseDirectoryMap(map);
|
||||||
|
expect(parsedMap.tags.length).toBeLessThanOrEqual(3);
|
||||||
|
expect(parsedMap.symbols.length).toBeLessThanOrEqual(3);
|
||||||
|
|
||||||
|
const index = readFileSync(join(dir, "src", ".pi-map.index.md"), "utf8");
|
||||||
|
const parsedIndex = (await import("../src/format.js")).parseDirectoryIndex(
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
expect(parsedIndex.workflows.length).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validate hard-fails when an index is missing", async () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
rmSync(join(dir, "src", ".pi-map.index.md"));
|
||||||
|
|
||||||
|
const result = await validateMaps(dir, { verbose: false });
|
||||||
|
expect(result.clean).toBe(false);
|
||||||
|
expect(result.discrepancies.some((d) => d.type === "stale-index")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validate detects broken sibling links", async () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
const mapPath = join(dir, "src", ".pi-map.md");
|
||||||
|
writeFileSync(
|
||||||
|
mapPath,
|
||||||
|
readFileSync(mapPath, "utf8").replace(
|
||||||
|
"index: src/.pi-map.index.md",
|
||||||
|
"index: src/bad-index.md",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await validateMaps(dir, { verbose: false });
|
||||||
|
expect(result.clean).toBe(false);
|
||||||
|
expect(result.discrepancies.some((d) => d.type === "broken-link")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validate --fix repairs the affected chain", async () => {
|
||||||
|
mkdirSync(join(dir, "src"));
|
||||||
|
writeFileSync(join(dir, "package.json"), `{}\n`);
|
||||||
|
writeFileSync(join(dir, "src", "a.ts"), `export const a = 1;\n`);
|
||||||
|
const client = createMockFileClient();
|
||||||
|
await initProject(dir, { llmClient: client, verbose: false });
|
||||||
|
|
||||||
|
rmSync(join(dir, "src", ".pi-map.index.md"));
|
||||||
|
const result = await validateMaps(dir, {
|
||||||
|
fix: true,
|
||||||
|
verbose: false,
|
||||||
|
llmClient: client,
|
||||||
|
cacheDir: dir,
|
||||||
|
patchMode: "small",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.clean).toBe(false);
|
||||||
|
expect(result.fixed).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
readFileSync(join(dir, "src", ".pi-map.index.md"), "utf8"),
|
||||||
|
).toContain("# src (index)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,12 +81,13 @@ describe("pi-extension", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("tool registration", () => {
|
describe("tool registration", () => {
|
||||||
it("registers 4 tools", () => {
|
it("registers 5 tools", () => {
|
||||||
expect(Object.keys(registeredTools)).toHaveLength(4);
|
expect(Object.keys(registeredTools)).toHaveLength(5);
|
||||||
expect(registeredTools).toHaveProperty("project_map_init");
|
expect(registeredTools).toHaveProperty("project_map_init");
|
||||||
expect(registeredTools).toHaveProperty("project_map_patch");
|
expect(registeredTools).toHaveProperty("project_map_patch");
|
||||||
expect(registeredTools).toHaveProperty("project_map_validate");
|
expect(registeredTools).toHaveProperty("project_map_validate");
|
||||||
expect(registeredTools).toHaveProperty("project_map_reinit");
|
expect(registeredTools).toHaveProperty("project_map_reinit");
|
||||||
|
expect(registeredTools).toHaveProperty("project_map_context");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers session_start and before_agent_start events", () => {
|
it("registers session_start and before_agent_start events", () => {
|
||||||
@@ -141,11 +142,15 @@ describe("pi-extension", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("project_map_validate tool", () => {
|
describe("project_map_validate tool", () => {
|
||||||
it("reports clean when map exists and is up to date", async () => {
|
it("reports clean when map and index exist and are up to date", async () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
|
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(dir, ".pi-map.md"),
|
join(dir, ".pi-map.md"),
|
||||||
"# .\n## role\nTest\n## files\n## arch\n## dirty\n-\n",
|
"# .\ndir: .\n\nindex: ./.pi-map.index.md\n\n## Project Map Protocol\n\n1. Read this protocol and the root `.pi-map.index.md` first.\n\nTrust boundary: index routes, map orients, source decides.\n\n## role\nTest\n## files\n-\n## arch\nTest arch\n## tags\n-\n## symbols\n-\n## workflows\n-\n## dirty\n-\n",
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".pi-map.index.md"),
|
||||||
|
"# . (index)\ndir: .\n\n## Project Map Protocol\n\n1. Read this protocol and the root `.pi-map.index.md` first.\n\nTrust boundary: index routes, map orients, source decides.\n\n## role\nTest\n## parent\n-\n## children\n-\n## files\n-\n## links\nindex: ./.pi-map.index.md\nmap: ./.pi-map.md\n## workflows\n-\n## dirty\n-\n",
|
||||||
);
|
);
|
||||||
mockCtx.cwd = dir;
|
mockCtx.cwd = dir;
|
||||||
|
|
||||||
@@ -180,6 +185,51 @@ describe("pi-extension", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("project_map_context tool", () => {
|
||||||
|
it("returns a context bundle for a query", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
|
||||||
|
// Create a minimal map + index
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".pi-map.md"),
|
||||||
|
"# .\ndir: .\n\nindex: ./.pi-map.index.md\n\n## Project Map Protocol\n\nTrust boundary: index routes, map orients, source decides.\n\n## role\nTest project\n## files\n- test.ts | Test file\n## arch\nTest\n## tags\ntest\n## symbols\n- main\n## workflows\n-\n## dirty\n-\n",
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, ".pi-map.index.md"),
|
||||||
|
"# . (index)\ndir: .\n\n## role\nTest project\n## parent\n-\n## children\n-\n## files\n- test.ts\n## links\nindex: ./.pi-map.index.md\nmap: ./.pi-map.md\n## workflows\n-\n## dirty\n-\n",
|
||||||
|
);
|
||||||
|
mockCtx.cwd = dir;
|
||||||
|
|
||||||
|
const tool = registeredTools.project_map_context;
|
||||||
|
const result = await tool.execute(
|
||||||
|
"tool-1",
|
||||||
|
{ query: "test" },
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
mockCtx,
|
||||||
|
);
|
||||||
|
expect(result.details.success).toBe(true);
|
||||||
|
expect(result.content[0].text).toContain("# Context bundle: test");
|
||||||
|
expect(result.content[0].text).toContain("## relevant indexes");
|
||||||
|
expect(result.content[0].text).toContain("## instructions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no-results bundle when no maps match", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
|
||||||
|
mockCtx.cwd = dir;
|
||||||
|
|
||||||
|
const tool = registeredTools.project_map_context;
|
||||||
|
const result = await tool.execute(
|
||||||
|
"tool-1",
|
||||||
|
{ query: "nonexistent" },
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
mockCtx,
|
||||||
|
);
|
||||||
|
expect(result.details.success).toBe(true);
|
||||||
|
expect(result.content[0].text).toContain("No relevant directories found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("session_start event", () => {
|
describe("session_start event", () => {
|
||||||
it("notifies when dirty .pi-map.md files exist", async () => {
|
it("notifies when dirty .pi-map.md files exist", async () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
|
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
|
||||||
@@ -221,7 +271,7 @@ describe("pi-extension", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("before_agent_start event", () => {
|
describe("before_agent_start event", () => {
|
||||||
it("injects hint when .pi-map.md files exist", async () => {
|
it("injects layered protocol hint when project map files exist", async () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
|
const dir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
|
||||||
writeFileSync(join(dir, ".pi-map.md"), "# .\n## role\nTest\n");
|
writeFileSync(join(dir, ".pi-map.md"), "# .\n## role\nTest\n");
|
||||||
mockCtx.cwd = dir;
|
mockCtx.cwd = dir;
|
||||||
@@ -230,7 +280,9 @@ describe("pi-extension", () => {
|
|||||||
const result = await handler(null, mockCtx);
|
const result = await handler(null, mockCtx);
|
||||||
|
|
||||||
expect(result).toHaveProperty("message");
|
expect(result).toHaveProperty("message");
|
||||||
|
expect(result.message.content).toContain("root `.pi-map.index.md`");
|
||||||
expect(result.message.content).toContain("project_map_patch");
|
expect(result.message.content).toContain("project_map_patch");
|
||||||
|
expect(result.message.content).toContain("project_map_validate");
|
||||||
expect(result.message.display).toBe(false);
|
expect(result.message.display).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { retrieveContext } from "../src/retrieve.js";
|
||||||
|
|
||||||
|
describe("retrieve", () => {
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "pi-ret-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeMap(
|
||||||
|
relDir: string,
|
||||||
|
role: string,
|
||||||
|
files: { name: string; purpose: string; exports?: string[] }[],
|
||||||
|
tags?: string[],
|
||||||
|
symbols?: string[],
|
||||||
|
) {
|
||||||
|
const d = join(dir, relDir);
|
||||||
|
if (!d.startsWith(dir)) throw new Error("invalid path");
|
||||||
|
mkdirSync(d, { recursive: true });
|
||||||
|
const fileLines = files
|
||||||
|
.map((f) => {
|
||||||
|
const exp = f.exports?.length ? ` | exp: ${f.exports.join(", ")}` : "";
|
||||||
|
return `- ${f.name} | ${f.purpose}${exp}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
const tagLine = tags?.length ? tags.join(", ") : "-";
|
||||||
|
const symLines = symbols?.length
|
||||||
|
? symbols.map((s) => `- ${s}`).join("\n")
|
||||||
|
: "-";
|
||||||
|
writeFileSync(
|
||||||
|
join(d, ".pi-map.md"),
|
||||||
|
`# ${relDir}
|
||||||
|
dir: ${relDir}
|
||||||
|
|
||||||
|
index: ${relDir}/.pi-map.index.md
|
||||||
|
|
||||||
|
## role
|
||||||
|
${role}
|
||||||
|
|
||||||
|
## files
|
||||||
|
${fileLines}
|
||||||
|
|
||||||
|
## arch
|
||||||
|
Test arch
|
||||||
|
|
||||||
|
## tags
|
||||||
|
${tagLine}
|
||||||
|
|
||||||
|
## symbols
|
||||||
|
${symLines}
|
||||||
|
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(d, ".pi-map.index.md"),
|
||||||
|
`# ${relDir} (index)
|
||||||
|
dir: ${relDir}
|
||||||
|
|
||||||
|
## role
|
||||||
|
${role}
|
||||||
|
|
||||||
|
## parent
|
||||||
|
-
|
||||||
|
|
||||||
|
## children
|
||||||
|
-
|
||||||
|
|
||||||
|
## files
|
||||||
|
${files.map((f) => `- ${f.name}`).join("\n")}
|
||||||
|
|
||||||
|
## links
|
||||||
|
index: ${relDir}/.pi-map.index.md
|
||||||
|
map: ${relDir}/.pi-map.md
|
||||||
|
|
||||||
|
## workflows
|
||||||
|
-
|
||||||
|
|
||||||
|
## dirty
|
||||||
|
-
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns empty bundle when no maps exist", () => {
|
||||||
|
const bundle = retrieveContext("auth", dir);
|
||||||
|
expect(bundle).toContain("# Context bundle: auth");
|
||||||
|
expect(bundle).toContain("No relevant directories found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks directories by query relevance", () => {
|
||||||
|
writeMap(
|
||||||
|
"src/auth",
|
||||||
|
"Auth layer: JWT issuance, validation, refresh.",
|
||||||
|
[{ name: "tokens.ts", purpose: "JWT gen/val", exports: ["issueToken"] }],
|
||||||
|
["auth", "jwt"],
|
||||||
|
["issueToken"],
|
||||||
|
);
|
||||||
|
writeMap(
|
||||||
|
"src/utils",
|
||||||
|
"Shared utilities and helpers.",
|
||||||
|
[{ name: "helpers.ts", purpose: "Helpers" }],
|
||||||
|
["utils"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundle = retrieveContext("jwt validation", dir);
|
||||||
|
expect(bundle).toContain("src/auth/.pi-map.index.md");
|
||||||
|
expect(bundle).toContain("src/auth/.pi-map.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits results to top 3 by default", () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
writeMap(
|
||||||
|
`src/pkg${i}`,
|
||||||
|
`Package ${i} logic`,
|
||||||
|
[{ name: `file${i}.ts`, purpose: `Feature ${i}` }],
|
||||||
|
[`pkg${i}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query matching all
|
||||||
|
const bundle = retrieveContext("pkg", dir);
|
||||||
|
const indexMatches = bundle.match(/\.pi-map\.index\.md/g) || [];
|
||||||
|
expect(indexMatches.length).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes likely files from matched directories", () => {
|
||||||
|
writeMap(
|
||||||
|
"src/auth",
|
||||||
|
"Auth layer",
|
||||||
|
[
|
||||||
|
{ name: "tokens.ts", purpose: "JWT tokens", exports: ["issueToken"] },
|
||||||
|
{
|
||||||
|
name: "middleware.ts",
|
||||||
|
purpose: "Auth middleware",
|
||||||
|
exports: ["requireAuth"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["auth"],
|
||||||
|
["issueToken", "requireAuth"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundle = retrieveContext("auth middleware", dir);
|
||||||
|
expect(bundle).toContain("src/auth/tokens.ts");
|
||||||
|
expect(bundle).toContain("src/auth/middleware.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes relevant symbols when present", () => {
|
||||||
|
writeMap(
|
||||||
|
"src/auth",
|
||||||
|
"Auth layer",
|
||||||
|
[{ name: "tokens.ts", purpose: "JWT tokens" }],
|
||||||
|
["auth"],
|
||||||
|
["issueToken", "verifyToken"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundle = retrieveContext("auth", dir);
|
||||||
|
expect(bundle).toContain("## relevant symbols");
|
||||||
|
expect(bundle).toContain("issueToken");
|
||||||
|
expect(bundle).toContain("verifyToken");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits symbol section when no symbols exist", () => {
|
||||||
|
writeMap(
|
||||||
|
"src/utils",
|
||||||
|
"Utilities",
|
||||||
|
[{ name: "helpers.ts", purpose: "Helpers" }],
|
||||||
|
["utils"],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundle = retrieveContext("utils", dir);
|
||||||
|
expect(bundle).not.toContain("## relevant symbols");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes instructions in every bundle", () => {
|
||||||
|
writeMap(
|
||||||
|
"src/cli",
|
||||||
|
"CLI entrypoint",
|
||||||
|
[{ name: "cli.ts", purpose: "CLI" }],
|
||||||
|
["cli"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundle = retrieveContext("cli", dir);
|
||||||
|
expect(bundle).toContain("## instructions");
|
||||||
|
expect(bundle).toContain("Read the indexes first");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses stable section order", () => {
|
||||||
|
writeMap(
|
||||||
|
"src/auth",
|
||||||
|
"Auth layer",
|
||||||
|
[{ name: "tokens.ts", purpose: "JWT tokens" }],
|
||||||
|
["auth"],
|
||||||
|
["issueToken"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundle = retrieveContext("auth", dir);
|
||||||
|
const queryIdx = bundle.indexOf("## query");
|
||||||
|
const indexIdx = bundle.indexOf("## relevant indexes");
|
||||||
|
const mapIdx = bundle.indexOf("## relevant maps");
|
||||||
|
const fileIdx = bundle.indexOf("## likely files");
|
||||||
|
const symIdx = bundle.indexOf("## relevant symbols");
|
||||||
|
const instIdx = bundle.indexOf("## instructions");
|
||||||
|
|
||||||
|
expect(queryIdx).toBeGreaterThan(-1);
|
||||||
|
expect(indexIdx).toBeGreaterThan(queryIdx);
|
||||||
|
expect(mapIdx).toBeGreaterThan(indexIdx);
|
||||||
|
expect(fileIdx).toBeGreaterThan(mapIdx);
|
||||||
|
expect(symIdx).toBeGreaterThan(fileIdx);
|
||||||
|
expect(instIdx).toBeGreaterThan(symIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scores file names and purposes", () => {
|
||||||
|
writeMap(
|
||||||
|
"src/auth",
|
||||||
|
"Auth layer",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "validateToken.ts",
|
||||||
|
purpose: "Token validation logic",
|
||||||
|
exports: ["validateToken"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundle = retrieveContext("validate token", dir);
|
||||||
|
expect(bundle).toContain("src/auth/.pi-map.index.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createDirectoryModel } from "../src/directory-model.js";
|
||||||
|
import { populateRoutingMetadata } from "../src/routing-metadata.js";
|
||||||
|
|
||||||
|
describe("routing metadata", () => {
|
||||||
|
it("generates tags from file purposes and exports", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/auth",
|
||||||
|
role: "Auth layer",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "tokens.ts",
|
||||||
|
purpose: "JWT generation and validation",
|
||||||
|
exports: ["issueToken", "verifyToken"],
|
||||||
|
deps: ["crypto"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
arch: "Auth",
|
||||||
|
});
|
||||||
|
|
||||||
|
populateRoutingMetadata(model);
|
||||||
|
expect(model.tags.length).toBeGreaterThan(0);
|
||||||
|
expect(model.tags).toContain("jwt");
|
||||||
|
expect(model.tags).toContain("token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates symbols from exports", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/utils",
|
||||||
|
role: "Utilities",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "helpers.ts",
|
||||||
|
purpose: "Helpers",
|
||||||
|
exports: ["formatDate", "parseUrl", "formatDate"],
|
||||||
|
deps: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
arch: "Utils",
|
||||||
|
});
|
||||||
|
|
||||||
|
populateRoutingMetadata(model);
|
||||||
|
expect(model.symbols.length).toBeGreaterThan(0);
|
||||||
|
expect(model.symbols).toContain("formatDate");
|
||||||
|
expect(model.symbols).toContain("parseUrl");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps tags at default limit", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/big",
|
||||||
|
role: "Big module",
|
||||||
|
files: Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
name: `file${i}.ts`,
|
||||||
|
purpose: `Purpose ${i} with many unique words ${i}`,
|
||||||
|
exports: [`export${i}A`, `export${i}B`],
|
||||||
|
deps: [`dep${i}`],
|
||||||
|
})),
|
||||||
|
arch: "Big",
|
||||||
|
});
|
||||||
|
|
||||||
|
populateRoutingMetadata(model);
|
||||||
|
expect(model.tags.length).toBeLessThanOrEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps symbols at default limit", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/big",
|
||||||
|
role: "Big module",
|
||||||
|
files: Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
name: `file${i}.ts`,
|
||||||
|
purpose: `Purpose ${i}`,
|
||||||
|
exports: [`export${i}A`, `export${i}B`],
|
||||||
|
deps: [],
|
||||||
|
})),
|
||||||
|
arch: "Big",
|
||||||
|
});
|
||||||
|
|
||||||
|
populateRoutingMetadata(model);
|
||||||
|
expect(model.symbols.length).toBeLessThanOrEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates workflow hints for source directories", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/cli",
|
||||||
|
role: "CLI",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "cli.ts",
|
||||||
|
purpose: "CLI entrypoint",
|
||||||
|
exports: ["main"],
|
||||||
|
deps: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cli.test.ts",
|
||||||
|
purpose: "CLI tests",
|
||||||
|
exports: [],
|
||||||
|
deps: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
arch: "CLI",
|
||||||
|
});
|
||||||
|
|
||||||
|
populateRoutingMetadata(model);
|
||||||
|
expect(model.workflows.length).toBeGreaterThan(0);
|
||||||
|
const changeBehavior = model.workflows.find((w) =>
|
||||||
|
w.task.includes("change"),
|
||||||
|
);
|
||||||
|
expect(changeBehavior).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits workflows for non-source directories", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "docs",
|
||||||
|
role: "Documentation",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "README.md",
|
||||||
|
purpose: "Readme",
|
||||||
|
exports: [],
|
||||||
|
deps: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
arch: "Docs",
|
||||||
|
});
|
||||||
|
|
||||||
|
populateRoutingMetadata(model);
|
||||||
|
expect(model.workflows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps workflow hints at default limit", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/cli",
|
||||||
|
role: "CLI",
|
||||||
|
files: [
|
||||||
|
{ name: "cli.ts", purpose: "CLI", exports: ["main"], deps: [] },
|
||||||
|
{ name: "config.ts", purpose: "Config", exports: [], deps: [] },
|
||||||
|
{ name: "cli.test.ts", purpose: "Tests", exports: [], deps: [] },
|
||||||
|
{ name: "commands.ts", purpose: "Commands", exports: [], deps: [] },
|
||||||
|
],
|
||||||
|
arch: "CLI",
|
||||||
|
});
|
||||||
|
model.children = ["src/cli/sub"];
|
||||||
|
|
||||||
|
populateRoutingMetadata(model);
|
||||||
|
expect(model.workflows.length).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("workflow hints include read targets for relevant files", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/auth",
|
||||||
|
role: "Auth",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "tokens.ts",
|
||||||
|
purpose: "Tokens",
|
||||||
|
exports: ["issueToken"],
|
||||||
|
deps: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tokens.test.ts",
|
||||||
|
purpose: "Token tests",
|
||||||
|
exports: [],
|
||||||
|
deps: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
arch: "Auth",
|
||||||
|
});
|
||||||
|
|
||||||
|
populateRoutingMetadata(model);
|
||||||
|
const testWorkflow = model.workflows.find((w) => w.task.includes("test"));
|
||||||
|
expect(testWorkflow).toBeDefined();
|
||||||
|
expect(testWorkflow!.read).toContain("tokens.test.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("workflow hints include index targets for directories with children", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src",
|
||||||
|
role: "Source",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "index.ts",
|
||||||
|
purpose: "Exports",
|
||||||
|
exports: [],
|
||||||
|
deps: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
arch: "Source",
|
||||||
|
children: ["src/auth", "src/cli"],
|
||||||
|
});
|
||||||
|
|
||||||
|
populateRoutingMetadata(model);
|
||||||
|
const exploreWorkflow = model.workflows.find((w) =>
|
||||||
|
w.task.includes("explore"),
|
||||||
|
);
|
||||||
|
expect(exploreWorkflow).toBeDefined();
|
||||||
|
expect(exploreWorkflow!.index).toContain("src/auth/.pi-map.index.md");
|
||||||
|
expect(exploreWorkflow!.index).toContain("src/cli/.pi-map.index.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects explicit tagCap and workflowHintCap options", () => {
|
||||||
|
const model = createDirectoryModel({
|
||||||
|
dir: "src/big",
|
||||||
|
role: "Big module",
|
||||||
|
files: Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
name: `file${i}.ts`,
|
||||||
|
purpose: `Purpose ${i} with many unique words ${i}`,
|
||||||
|
exports: [`export${i}A`, `export${i}B`],
|
||||||
|
deps: [`dep${i}`],
|
||||||
|
})),
|
||||||
|
arch: "Big",
|
||||||
|
children: ["src/big/sub"],
|
||||||
|
});
|
||||||
|
|
||||||
|
populateRoutingMetadata(model, { tagCap: 3, workflowHintCap: 2 });
|
||||||
|
expect(model.tags.length).toBeLessThanOrEqual(3);
|
||||||
|
expect(model.symbols.length).toBeLessThanOrEqual(3);
|
||||||
|
expect(model.workflows.length).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user