Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Phoenix VCS v0.1.0 — initial commit

Regenerative version control system: causal compiler for intent.

Core engine (Phases A–F):
- Spec ingestion, clause extraction, semantic hashing
- Canonicalization, warm context hashing, A/B/C/D classification
- IU planning, code generation, drift detection
- Boundary validation, dependency extraction
- Evidence/policy engine, cascade propagation
- Shadow pipeline, compaction
- Bot router (SpecBot, ImplBot, PolicyBot)

Stores: content-addressed objects, spec graph, canonical graph, evidence

CLI (16 commands):
init, bootstrap, status, ingest, diff, clauses, canonicalize, canon,
plan, regen, drift, evaluate, cascade, graph, bot, help

Scaffold generator:
- Per-service index.ts, server.ts (health/metrics/modules), tests
- Project package.json, tsconfig.json, vitest.config.ts

Examples:
- microservices: API gateway, user service, notification service (3 specs)
- tictactoe: game engine, multiplayer, web client (3 specs)

201 unit/functional tests, all passing.

Chad Fowler 030f51e3

+13070
+4
.gitignore
··· 1 + node_modules/ 2 + dist/ 3 + .phoenix/ 4 + *.tsbuildinfo
+231
BLOG.md
··· 1 + # Phoenix: Version Control for Intent, Not Diffs 2 + 3 + **TL;DR:** We built a version control system that operates on *what you mean*, not *what changed in the file*. Change one line in your spec and Phoenix knows exactly which requirements are affected, which code needs regeneration, and which downstream modules need re-validation — without touching anything else. 4 + 5 + [GitHub](https://github.com/phoenix-vcs/phoenix) | [Demo](#running-the-demo) | [Docs](docs/MANUAL.md) 6 + 7 + --- 8 + 9 + ## The Problem 10 + 11 + Every version control system since diff was invented in 1974 operates on the same primitive: **line-level text changes**. Git is brilliant at tracking *what* changed. It has no idea *why* it matters. 12 + 13 + When you change "bcrypt" to "argon2id" on line 10 of your auth spec, git sees one modified line. But that one line: 14 + 15 + - Changes a **security requirement** (passwords must be hashed with argon2id) 16 + - Affects the **auth module** (which implements that requirement) 17 + - Invalidates the **generated code** (which uses bcrypt) 18 + - Breaks a **boundary policy** (if argon2id isn't in the allowed packages list) 19 + - Requires new **evidence** (unit tests, security review) 20 + - Potentially **cascades** to every module that depends on the auth module 21 + 22 + Git knows none of this. Your team discovers it through code review, broken builds, and production incidents. 23 + 24 + ## The Idea 25 + 26 + What if version control understood **intent**? 27 + 28 + Not "line 10 changed" — but "the password hashing requirement changed, which affects AuthIU, which needs new evidence, and SessionIU depends on it so re-validate that too." 29 + 30 + Phoenix is a **causal compiler for intent**. It compiles: 31 + 32 + ``` 33 + Spec Line → Clause → Canonical Requirement → Implementation Unit → Generated Code → Evidence → Policy Decision 34 + ``` 35 + 36 + Every arrow is a traceable provenance edge. Every transformation is content-addressed and deterministic. 37 + 38 + ## How It Works (5 Minutes) 39 + 40 + ### 1. You write a Markdown spec 41 + 42 + ```markdown 43 + ## Requirements 44 + 45 + - Users must authenticate with email and password 46 + - Sessions expire after 24 hours 47 + - Passwords must be hashed with bcrypt (cost factor 12) 48 + 49 + ## Security Constraints 50 + 51 + - All endpoints must use HTTPS 52 + - Tokens must be signed with RS256 53 + ``` 54 + 55 + ### 2. Phoenix parses it into clauses 56 + 57 + Each heading + body becomes a **clause** — the atomic unit of your spec. Clauses are normalized (lowercased, list items sorted, formatting stripped) and SHA-256 hashed. This means: 58 + 59 + - Reordering bullet points doesn't change the hash 60 + - Adding bold markers doesn't change the hash 61 + - Actual semantic changes always change the hash 62 + 63 + ### 3. Phoenix extracts canonical requirements 64 + 65 + Pattern matching identifies structured requirements: 66 + 67 + ``` 68 + REQUIREMENT: "users must authenticate with email and password" 69 + REQUIREMENT: "passwords must be hashed with bcrypt (cost factor 12)" 70 + CONSTRAINT: "all endpoints must use HTTPS" 71 + ``` 72 + 73 + Nodes are linked by shared terms. "Passwords must be hashed" links to "Password reset tokens expire" through the shared term "password." 74 + 75 + ### 4. Phoenix maps requirements to code 76 + 77 + Canonical nodes are grouped into **Implementation Units** — stable compilation boundaries with contracts, boundary policies, and evidence requirements. 78 + 79 + ``` 80 + RequirementsIU (high risk) 81 + → 8 canonical nodes 82 + → output: src/generated/requirementsiu.ts 83 + → evidence required: typecheck, lint, boundary, unit tests, property tests, static analysis 84 + ``` 85 + 86 + ### 5. Phoenix generates code and tracks it 87 + 88 + The regeneration engine produces code stubs (or, in production, invokes an LLM). Every generated file is content-hashed into a **manifest**. 89 + 90 + Edit a generated file without permission? **Drift detected.** Phoenix blocks acceptance until you label the edit as a promoted requirement, a signed waiver, or a temporary patch. 91 + 92 + ### 6. Phoenix enforces boundaries 93 + 94 + Each IU declares what it's allowed to import and what side channels (databases, APIs, env vars) it may use: 95 + 96 + ``` 97 + import axios from 'axios'; → ERROR: forbidden package 98 + process.env.UNDECLARED_SECRET; → WARNING: undeclared config 99 + ``` 100 + 101 + ### 7. Phoenix propagates failures 102 + 103 + When the auth module fails its type check, Phoenix doesn't just flag the auth module. It walks the dependency graph and marks every dependent module for re-validation: 104 + 105 + ``` 106 + AuthIU [FAIL: typecheck] → BLOCK 107 + └─ SessionIU → RE_VALIDATE 108 + └─ ApiIU → RE_VALIDATE 109 + ``` 110 + 111 + ## The Key Insight: Selective Invalidation 112 + 113 + Change "bcrypt" to "argon2id" on one line. Phoenix: 114 + 115 + 1. Detects the **Requirements clause** was modified 116 + 2. Identifies **4 canonical nodes** affected 117 + 3. Classifies this as a **C — Contextual Shift** (90% confidence, 9 canon nodes impacted) 118 + 4. Marks the **RequirementsIU** for regeneration 119 + 5. Checks the **boundary policy** (is argon2id in allowed_packages?) 120 + 6. Invalidates **evidence** (tests need re-running) 121 + 7. Cascades to **dependent IUs** 122 + 123 + Everything not in that subtree? Untouched. The login endpoint clause is UNCHANGED (class A). The logout clause is UNCHANGED. Only the affected subtree is reprocessed. 124 + 125 + This is the difference between "rebuild the world" and "rebuild what matters." 126 + 127 + ## What We Built 128 + 129 + Phoenix is implemented in TypeScript with zero runtime dependencies beyond Node.js crypto. The codebase is ~3,000 lines of source across 30 modules, covered by 200+ tests. 130 + 131 + ### Architecture 132 + 133 + | Phase | What it does | 134 + |-------|-------------| 135 + | **A** | Clause extraction + semantic hashing | 136 + | **B** | Canonicalization + warm hashing + A/B/C/D classifier | 137 + | **C1** | IU planning + code generation + manifest + drift detection | 138 + | **C2** | Boundary validation (architectural linter) | 139 + | **D** | Evidence + policy engine + cascading failures | 140 + | **E** | Shadow pipeline upgrades + storage compaction | 141 + | **F** | Bot interface (SpecBot, ImplBot, PolicyBot) | 142 + 143 + ### The Trust Dashboard 144 + 145 + Everything feeds into `phoenix status`: 146 + 147 + ``` 148 + phoenix status STEADY_STATE | spec/auth.md v1 → v2 149 + 150 + Classification Summary A:3 B:1 C:4 D:0 │ D-Rate: 0.0% TARGET 151 + 152 + Canonical Graph 8 → 10 nodes │ +6 new -4 removed 4 kept 153 + 154 + Implementation Units 1 IU │ 1 generated files 155 + 156 + Drift 1 DRIFTED │ 0 clean 1 drifted 157 + 158 + Boundary 2 errors 2 warnings 159 + 160 + Actions Required: 161 + ERROR drift requirementsiu.ts Drifted → label or reconcile 162 + ERROR boundary axios Forbidden package → remove import 163 + WARN boundary STRIPE_API_KEY Undeclared config → declare or remove 164 + ``` 165 + 166 + If this dashboard is trusted, Phoenix becomes the coordination substrate for your entire development process. 167 + 168 + If it's noisy or wrong, the system dies. 169 + 170 + **Trust > Cleverness.** 171 + 172 + ## Running the Demo 173 + 174 + ```bash 175 + git clone https://github.com/phoenix-vcs/phoenix 176 + cd phoenix 177 + npm install 178 + npx tsx demo.ts 179 + ``` 180 + 181 + The demo is a 23-step walkthrough that shows every file, every data structure, and every transformation. You'll see: 182 + 183 + - Your spec file color-coded by clause 184 + - Raw vs normalized text with proof that formatting doesn't affect hashes 185 + - Full JSON clause objects with content-addressed IDs 186 + - Canonical requirement extraction with provenance chains 187 + - Cold vs warm hash comparison 188 + - Bootstrap state machine transitions 189 + - Side-by-side spec v1 → v2 with clause-level diffs 190 + - A/B/C/D classification with signal breakdowns 191 + - Generated TypeScript code with manifest entries 192 + - Drift detection catching a simulated manual edit 193 + - Boundary validation catching forbidden imports and undeclared side channels 194 + - Evidence evaluation lifecycle (INCOMPLETE → PASS → FAIL) 195 + - Cascading failures through a dependency graph 196 + - Shadow pipeline upgrade classification 197 + - Storage compaction preserving critical data 198 + - Bot command parsing with confirmation model 199 + 200 + ## What's Next 201 + 202 + Phoenix is alpha. The canonicalization engine uses rule-based pattern matching. In production, this would be a versioned LLM pipeline — which is why the shadow pipeline upgrade mechanism exists from day one. 203 + 204 + The code generator produces stubs. In production, this would invoke an LLM with structured promptpacks, using the IU contract, canonical requirements, and boundary policy as context. 205 + 206 + We're looking for: 207 + - **Early adopters** willing to try Phoenix on greenfield TypeScript projects 208 + - **Contributors** interested in the canonicalization pipeline, boundary validation, and evidence engine 209 + - **Feedback** on the trust model — does `phoenix status` give you the confidence to rely on it? 210 + 211 + ## FAQ 212 + 213 + **Q: Is this "AI that writes code"?** 214 + No. Phoenix is a *causal compiler for intent*. The code generation is one step in a pipeline that starts with structured specs and ends with provenance-tracked, boundary-validated, evidence-certified modules. The AI is a tool; the system is the value. 215 + 216 + **Q: Does it work with existing codebases?** 217 + v1 is greenfield-first. Brownfield progressive wrapping is designed (wrap existing module → define boundary → write minimal spec → enforce without full regen) but not the primary path. 218 + 219 + **Q: How is this different from Copilot/Cursor/etc?** 220 + Those tools help you write code faster. Phoenix ensures the code you write (or generate) is **traceable to requirements, boundary-validated, evidence-certified, and selectively invalidated when specs change.** They're complementary — you could use Copilot inside Phoenix's regeneration engine. 221 + 222 + **Q: What if the classifier is wrong?** 223 + That's what the D-rate is for. If uncertain classifications exceed 15%, Phoenix raises an alarm, increases override friction, and surfaces the issue in status. The system is designed to degrade gracefully, not silently. 224 + 225 + **Q: Why TypeScript?** 226 + It's the reference implementation. The architecture is language-agnostic — the spec graph, canonical graph, and provenance graph don't care what language the generated code is in. 227 + 228 + --- 229 + 230 + *Phoenix VCS — Regenerative Version Control* 231 + *[GitHub](https://github.com/phoenix-vcs/phoenix) | [Manual](docs/MANUAL.md) | [Demo: `npx tsx demo.ts`]*
+1
CLAUDE.md
··· 1 + you are a world class development team with CEO, CPO, CTO from Github and Anthropic. You will build a revolutionary company. Read PRD.md
+473
PRD.md
··· 1 + # Phoenix VCS — Product Requirements Document (v1.0) 2 + 3 + Status: Build-Ready Specification 4 + Core Thesis: Version control should operate on **intent and causality**, not file diffs. 5 + Primary Trust Surface: `phoenix status` must always be explainable, conservative, and correct-enough to rely on. 6 + 7 + --- 8 + 9 + # 0. Executive Summary 10 + 11 + Phoenix is a regenerative version control system. 12 + 13 + It compiles: 14 + 15 + Spec Line 16 + → Clause 17 + → Canonical Requirement Graph 18 + → Implementation Units 19 + → Generated Code 20 + → Evidence 21 + → Policy Decision 22 + 23 + Every transformation emits provenance edges. 24 + 25 + Selective invalidation is the defining capability: 26 + 27 + > Changing one spec line invalidates only the dependent subtree — not the entire repository. 28 + 29 + Phoenix is not “AI that writes code.” 30 + 31 + Phoenix is a **causal compiler for intent.** 32 + 33 + --- 34 + 35 + # 1. Adoption Scope 36 + 37 + ## v1 Scope (Build Target) 38 + - Greenfield-first (new services or modules) 39 + - Progressive wrapping for brownfield systems 40 + - Module-level Implementation Units (function-level optional later) 41 + - TypeScript-first reference implementation 42 + 43 + ## Explicit Non-Goals (v1) 44 + - Automatic reverse engineering of arbitrary legacy code 45 + - Perfect semantic determinism 46 + - Fully decentralized CRDT replication 47 + - Multi-language parity beyond reference language 48 + 49 + --- 50 + 51 + # 2. Core System Model 52 + 53 + Phoenix maintains five interconnected graphs: 54 + 55 + 1. Spec Graph (Clauses) 56 + 2. Canonical Graph (Requirements, Constraints, Invariants, Definitions) 57 + 3. Implementation Graph (Implementation Units) 58 + 4. Evidence Graph (Tests, Analysis, Reviews) 59 + 5. Provenance Graph (All transformation edges + meta-events) 60 + 61 + Everything is content-addressed and versioned. 62 + 63 + --- 64 + 65 + # 3. Spec Ingestion & Semantic Hashing 66 + 67 + ## 3.1 Clause Extraction 68 + 69 + Each spec document is parsed into: 70 + 71 + ```yaml 72 + clause_id: 73 + source_doc_id: 74 + source_line_range: 75 + raw_text: 76 + normalized_text: 77 + section_path: 78 + 79 + 3.2 Two-Pass Semantic Hashing (Bootstrapped) 80 + 81 + Cold start exists. It is explicit. 82 + 83 + Pass 1 — Cold 84 + • Compute clause_semhash 85 + • Compute context_semhash_cold using local context only 86 + • Classifier operates conservatively 87 + • System marked BOOTSTRAP_COLD 88 + 89 + Canonicalization runs 90 + 91 + Pass 2 — Warm 92 + • Compute context_semhash_warm including extracted canonical graph context 93 + • Re-run classifier 94 + • System transitions to BOOTSTRAP_WARMING 95 + 96 + After stabilization: 97 + • System transitions to STEADY_STATE 98 + 99 + Bootstrap state controls: 100 + • D-rate alarms suppressed during cold 101 + • Severity downgraded during warming 102 + 103 + 104 + 105 + 4. Change Classification (A/B/C/D) 106 + 107 + Phoenix does not use a single embedding threshold. 108 + 109 + Every change is classified: 110 + 111 + Class Meaning 112 + A Trivial (formatting) 113 + B Local semantic change 114 + C Contextual semantic shift 115 + D Uncertain 116 + 117 + Signals used: 118 + • normalized diff heuristics 119 + • clause_semhash distance 120 + • context_semhash distance 121 + • term-reference deltas 122 + • section structure deltas 123 + 124 + 4.1 D-Rate Trust Loop 125 + 126 + Target: ≤5% 127 + Acceptable: ≤10% 128 + Alarm: >15% (rolling window) 129 + 130 + If alarm: 131 + • classifier tuning required 132 + • override friction increases 133 + • PolicyBot surfaces trust degradation warning 134 + 135 + Metric: D-rate is first-class. 136 + 137 + 138 + 139 + 5. Canonicalization Pipelines 140 + 141 + Canonicalization is versioned and explicit. 142 + 143 + canon_pipeline_id: 144 + model_id: 145 + promptpack_version: 146 + extraction_rules_version: 147 + diff_policy_version: 148 + 149 + 5.1 Shadow Canonicalization (Upgrade Mode) 150 + 151 + Upgrade runs old and new pipelines in parallel. 152 + 153 + Diff metrics: 154 + • node_change_pct 155 + • edge_change_pct 156 + • risk_escalations 157 + • orphan_nodes 158 + • out_of_scope_growth 159 + • semantic_stmt_drift 160 + 161 + Classification: 162 + 163 + SAFE: 164 + • node_change_pct ≤3% 165 + • no orphan nodes 166 + • no risk escalations 167 + 168 + COMPACTION EVENT: 169 + • node_change_pct ≤25% 170 + • no orphan nodes 171 + • limited risk escalations 172 + 173 + REJECT: 174 + • orphan nodes exist 175 + • excessive churn 176 + • semantic drift large 177 + 178 + Upgrade produces meta-node: 179 + 180 + type: PipelineUpgrade 181 + 182 + 183 + 184 + 185 + 6. Implementation Units (IUs) 186 + 187 + Implementation Units are stable compilation boundaries. 188 + 189 + iu_id: 190 + kind: module | function 191 + risk_tier: 192 + contract: 193 + dependencies: 194 + boundary_policy: 195 + impact: 196 + evidence_policy: 197 + 198 + Bots propose. 199 + Humans or policy accept. 200 + 201 + 202 + 203 + 7. Boundary Policy Schema (Enforced) 204 + 205 + Each IU declares: 206 + 207 + dependencies: 208 + code: 209 + allowed_ius: 210 + allowed_packages: 211 + forbidden_ius: 212 + forbidden_packages: 213 + forbidden_paths: 214 + side_channels: 215 + databases: 216 + queues: 217 + caches: 218 + config: 219 + external_apis: 220 + files: 221 + 222 + 7.1 Architectural Linter (Required) 223 + 224 + Post-generation: 225 + • Extract dependency graph 226 + • Validate against boundary policy 227 + • Emit diagnostics 228 + 229 + Violation severity controlled by: 230 + 231 + enforcement: 232 + dependency_violation: 233 + severity: error|warning 234 + side_channel_violation: 235 + severity: warning|error 236 + 237 + Side-channel dependencies create graph edges for invalidation. 238 + 239 + 240 + 241 + 8. Regeneration Engine 242 + 243 + Regeneration operates at IU granularity. 244 + 245 + Records: 246 + • model_id 247 + • promptpack hash 248 + • toolchain version 249 + • normalization steps 250 + 251 + Generated artifacts produce: 252 + 253 + .phoenix/generated_manifest 254 + 255 + Per-file and per-IU hashes. 256 + 257 + 258 + 259 + 9. Drift Detection 260 + 261 + On status: 262 + • Compare working tree vs generated_manifest 263 + • If mismatch and no waiver: 264 + • Emit ERROR 265 + • Block acceptance 266 + 267 + Manual edits must be labeled: 268 + • promote_to_requirement 269 + • waiver (signed) 270 + • temporary_patch (expires) 271 + 272 + 273 + 274 + 10. Evidence & Policy Engine 275 + 276 + Risk-tiered enforcement. 277 + 278 + Low tier: 279 + • typecheck 280 + • lint 281 + • boundary validation 282 + 283 + Medium: 284 + • unit tests required 285 + 286 + High: 287 + • unit + property tests 288 + • threat note 289 + • static analysis 290 + 291 + Critical: 292 + • human signoff or formal/simulation evidence 293 + 294 + Evidence binds to: 295 + • canonical nodes 296 + • IU IDs 297 + • generated artifact hashes 298 + 299 + 300 + 301 + 11. Cascading Failure Semantics 302 + 303 + If IU-X evidence fails: 304 + • IU-X blocked 305 + • Dependent IU-Y: 306 + • re-run typecheck 307 + • re-run boundary checks 308 + • re-run relevant tests (tagged) 309 + 310 + Failure propagation is explicit and graph-based. 311 + 312 + 313 + 314 + 12. Compaction 315 + 316 + 12.1 Storage Tiers 317 + 318 + Hot Graph (last 30 days default) 319 + Ancestry Index (forever metadata) 320 + Cold Packs (heavy blobs) 321 + 322 + Compaction never deletes: 323 + • node headers 324 + • provenance edges 325 + • approvals 326 + • signatures 327 + 328 + 12.2 Triggers 329 + • Size threshold exceeded 330 + • Pipeline upgrade accepted 331 + • Time-based fallback 332 + 333 + Compaction produces: 334 + 335 + type: CompactionEvent 336 + 337 + PolicyBot announces compaction. 338 + 339 + 340 + 341 + 13. Diagnostics & Severity Model 342 + 343 + Every status item is: 344 + 345 + severity: error|warning|info 346 + category: 347 + subject: 348 + message: 349 + recommended_actions: 350 + 351 + Grouped by severity. 352 + 353 + This is Phoenix’s primary UX. 354 + 355 + 356 + 357 + 14. Freeq Bot Integration 358 + 359 + Bots behave as normal users. 360 + 361 + Command style: 362 + 363 + SpecBot: ingest spec/auth.md 364 + ImplBot: regen iu=AuthIU 365 + PolicyBot: status 366 + 367 + 14.1 Confirmation Model 368 + 369 + Mutating commands: 370 + • Bot echoes parsed intent 371 + • User replies ok or phx confirm <id> 372 + 373 + Read-only commands: 374 + • execute immediately 375 + 376 + 14.2 Command Grammar 377 + 378 + Each bot exposes: 379 + 380 + BotName: help 381 + BotName: commands 382 + BotName: version 383 + 384 + No fuzzy NLU in v1. 385 + 386 + 387 + 388 + 15. Bootstrap Flow 389 + 390 + phoenix bootstrap 391 + • Runs cold pass 392 + • Runs canonicalization 393 + • Runs warm pass 394 + • Generates first Trust Dashboard 395 + • Sets system state to WARMING 396 + 397 + D-rate alarms disabled until STEADY_STATE. 398 + 399 + 400 + 401 + 16. Trust Dashboard (Status Example) 402 + 403 + Severity Category Subject Why Action 404 + ERROR boundary AuthIU Imports InternalAdminIU (forbidden) Refactor or update policy 405 + WARN d-rate Global D-rate 12% (>10%) Tune classifier 406 + WARN drift AuthIU Working tree differs from manifest Label or reconcile 407 + INFO canon spec/auth.md:L42 Warm context hash applied None 408 + 409 + 410 + 411 + 412 + 17. Brownfield Progressive Wrapping 413 + 414 + Step 1: Wrap Module 415 + • Define IU boundary around existing module 416 + • Write minimal spec 417 + • Enforce boundary + evidence without full regeneration 418 + 419 + Step 2: Annotate Provenance 420 + • Map functions to requirement IDs manually 421 + • Gradually increase regen surface 422 + 423 + 424 + 425 + 18. Build Phases 426 + 427 + Phase A: Clause extraction + clause_semhash 428 + Phase B: Canonicalization + warm context hashing + classifier 429 + Phase C1: IU module-level + regen + manifest 430 + Phase C2: Boundary validator + UnitBoundaryChange 431 + Phase D: Evidence + policy + cascade 432 + Phase E: Shadow pipeline + compaction 433 + Phase F: Freeq bots 434 + 435 + Parallel where feasible. 436 + 437 + 438 + 439 + 19. Success Criteria (Alpha) 440 + • Delete generated code → full regen succeeds 441 + • Clause change invalidates only dependent IU subtree 442 + • Boundary linter catches undeclared coupling 443 + • Drift detection blocks unlabeled edits 444 + • D-rate within acceptable bounds 445 + • Shadow pipeline upgrade produces classified diff 446 + • Compaction preserves ancestry 447 + • Freeq bots perform ingest/canon/plan/regen/status safely 448 + 449 + 450 + 451 + 20. Metrics 452 + • D-rate 453 + • Override rate 454 + • Canonical stability rate 455 + • Upgrade-induced churn 456 + • Boundary violation rate 457 + • Drift incidents 458 + • Status resolution time 459 + 460 + 461 + 462 + 21. The Bet 463 + 464 + If phoenix status is trusted, Phoenix becomes the coordination substrate. 465 + 466 + If status is noisy or wrong, the system dies. 467 + 468 + Trust > cleverness. 469 + 470 + 471 + 472 + End of PRD v1.0 473 +
+1328
demo.ts
··· 1 + #!/usr/bin/env npx tsx 2 + /** 3 + * Phoenix VCS — Walkthrough Demo 4 + * 5 + * Shows you every file, every data structure, every transformation. 6 + * You see what Phoenix sees. 7 + */ 8 + 9 + // ── Colors ── 10 + const R = '\x1b[0m'; // reset 11 + const B = '\x1b[1m'; // bold 12 + const D = '\x1b[2m'; // dim 13 + const GR = '\x1b[32m'; // green 14 + const YL = '\x1b[33m'; // yellow 15 + const RD = '\x1b[31m'; // red 16 + const CY = '\x1b[36m'; // cyan 17 + const MG = '\x1b[35m'; // magenta 18 + const BL = '\x1b[34m'; // blue 19 + const WH = '\x1b[37m'; 20 + const BG_GR = '\x1b[42m'; 21 + const BG_RD = '\x1b[41m'; 22 + const BG_YL = '\x1b[43m'; 23 + const BG_BL = '\x1b[44m'; 24 + const BG_MG = '\x1b[45m'; 25 + const BG_CY = '\x1b[46m'; 26 + 27 + function banner(step: number, text: string) { 28 + const line = '━'.repeat(70); 29 + console.log(`\n${CY}${line}${R}`); 30 + console.log(`${CY}┃${R} ${B}STEP ${step}${R} — ${B}${text}${R}`); 31 + console.log(`${CY}${line}${R}`); 32 + } 33 + 34 + function sub(text: string) { 35 + console.log(`\n ${B}${MG}▸ ${text}${R}\n`); 36 + } 37 + 38 + function showFile(filename: string, content: string, highlight?: Map<number, string>) { 39 + console.log(` ${BG_BL}${WH}${B} 📄 ${filename} ${R}\n`); 40 + const lines = content.split('\n'); 41 + for (let i = 0; i < lines.length; i++) { 42 + const lineNum = String(i + 1).padStart(3); 43 + const hl = highlight?.get(i + 1); 44 + if (hl) { 45 + console.log(` ${D}${lineNum}${R} ${hl}${lines[i]}${R}`); 46 + } else { 47 + console.log(` ${D}${lineNum}${R} ${lines[i]}`); 48 + } 49 + } 50 + console.log(''); 51 + } 52 + 53 + function showJSON(label: string, obj: unknown) { 54 + console.log(` ${BG_MG}${WH}${B} 💾 ${label} ${R}\n`); 55 + const json = JSON.stringify(obj, null, 2); 56 + for (const line of json.split('\n')) { 57 + console.log(` ${D}│${R} ${line}`); 58 + } 59 + console.log(''); 60 + } 61 + 62 + function showBox(lines: string[]) { 63 + const maxLen = Math.max(...lines.map(l => stripAnsi(l).length)); 64 + const top = ` ┌${'─'.repeat(maxLen + 2)}┐`; 65 + const bot = ` └${'─'.repeat(maxLen + 2)}┘`; 66 + console.log(top); 67 + for (const line of lines) { 68 + const pad = ' '.repeat(maxLen - stripAnsi(line).length); 69 + console.log(` │ ${line}${pad} │`); 70 + } 71 + console.log(bot); 72 + } 73 + 74 + function stripAnsi(s: string): string { 75 + return s.replace(/\x1b\[[0-9;]*m/g, ''); 76 + } 77 + 78 + function badge(text: string, bg: string) { 79 + return `${bg}${WH}${B} ${text} ${R}`; 80 + } 81 + 82 + function arrow(from: string, to: string, label?: string) { 83 + const lbl = label ? ` ${D}(${label})${R}` : ''; 84 + console.log(` ${CY}${from}${R} ──${lbl}──▸ ${GR}${to}${R}`); 85 + } 86 + 87 + function wait(ms: number): Promise<void> { 88 + return new Promise(resolve => setTimeout(resolve, ms)); 89 + } 90 + 91 + // ── Imports ── 92 + import { parseSpec } from './src/spec-parser.js'; 93 + import { normalizeText } from './src/normalizer.js'; 94 + import { clauseSemhash, contextSemhashCold } from './src/semhash.js'; 95 + import { diffClauses } from './src/diff.js'; 96 + import { extractCanonicalNodes } from './src/canonicalizer.js'; 97 + import { computeWarmHashes } from './src/warm-hasher.js'; 98 + import { classifyChanges } from './src/classifier.js'; 99 + import { DRateTracker } from './src/d-rate.js'; 100 + import { BootstrapStateMachine } from './src/bootstrap.js'; 101 + import { ChangeClass, BootstrapState, DRateLevel } from './src/models/classification.js'; 102 + import { DiffType } from './src/models/clause.js'; 103 + import { planIUs } from './src/iu-planner.js'; 104 + import { generateIU } from './src/regen.js'; 105 + import { extractDependencies } from './src/dep-extractor.js'; 106 + import { validateBoundary, detectBoundaryChanges } from './src/boundary-validator.js'; 107 + import { detectDrift } from './src/drift.js'; 108 + import { DriftStatus } from './src/models/manifest.js'; 109 + import type { GeneratedManifest } from './src/models/manifest.js'; 110 + import { sha256 } from './src/semhash.js'; 111 + import { evaluatePolicy } from './src/policy-engine.js'; 112 + import { computeCascade } from './src/cascade.js'; 113 + import { EvidenceKind, EvidenceStatus } from './src/models/evidence.js'; 114 + import type { EvidenceRecord } from './src/models/evidence.js'; 115 + import { runShadowPipeline } from './src/shadow-pipeline.js'; 116 + import { UpgradeClassification } from './src/models/pipeline.js'; 117 + import { runCompaction } from './src/compaction.js'; 118 + import { parseCommand, routeCommand } from './src/bot-router.js'; 119 + import type { BotCommand } from './src/models/bot.js'; 120 + import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; 121 + import { join } from 'node:path'; 122 + import { tmpdir } from 'node:os'; 123 + 124 + // ── The Spec File ── 125 + 126 + const SPEC_V1 = `# Authentication Service 127 + 128 + The authentication service handles user login, registration, and session management. 129 + 130 + ## Requirements 131 + 132 + - Users must authenticate with email and password 133 + - Sessions expire after 24 hours 134 + - Failed login attempts are rate-limited to 5 per minute 135 + - Passwords must be hashed with bcrypt (cost factor 12) 136 + 137 + ## API Endpoints 138 + 139 + ### POST /auth/login 140 + 141 + Accepts email and password. Returns a JWT token on success. 142 + 143 + ### POST /auth/register 144 + 145 + Creates a new user account. Requires email, password, and display name. 146 + 147 + ### POST /auth/logout 148 + 149 + Invalidates the current session token. 150 + 151 + ## Security Constraints 152 + 153 + - All endpoints must use HTTPS 154 + - Tokens must be signed with RS256 155 + - Password reset tokens expire after 1 hour`; 156 + 157 + const SPEC_V2 = `# Authentication Service 158 + 159 + The authentication service handles user login, registration, session management, and OAuth integration. 160 + 161 + ## Requirements 162 + 163 + - Users must authenticate with email and password 164 + - Sessions expire after 12 hours 165 + - Failed login attempts are rate-limited to 3 per minute 166 + - Passwords must be hashed with argon2id (cost factor 3, memory 64MB) 167 + - OAuth2 providers (Google, GitHub) must be supported 168 + 169 + ## API Endpoints 170 + 171 + ### POST /auth/login 172 + 173 + Accepts email and password. Returns a JWT token on success. 174 + 175 + ### POST /auth/register 176 + 177 + Creates a new user account. Requires email, password, and display name. 178 + 179 + ### POST /auth/logout 180 + 181 + Invalidates the current session token. 182 + 183 + ### GET /auth/oauth/:provider 184 + 185 + Initiates OAuth2 flow for the specified provider. 186 + 187 + ## Security Constraints 188 + 189 + - All endpoints must use HTTPS 190 + - Tokens must be signed with RS256 191 + - Password reset tokens expire after 30 minutes 192 + - OAuth tokens must be stored encrypted at rest`; 193 + 194 + // ── Main ── 195 + 196 + async function main() { 197 + console.clear(); 198 + console.log(` 199 + ${B}${CY} 🔥 P H O E N I X V C S${R} 200 + ${D} Regenerative Version Control — A Causal Compiler for Intent 201 + 202 + This walkthrough shows you every file, every transformation, 203 + and every data structure Phoenix produces as it processes a spec.${R} 204 + `); 205 + await wait(500); 206 + 207 + // ══════════════════════════════════════════════════════════════════ 208 + // STEP 1: Here's the input file 209 + // ══════════════════════════════════════════════════════════════════ 210 + 211 + banner(1, 'The Input — Your Spec File'); 212 + 213 + console.log(` 214 + ${D}This is the file you wrote. It's a plain Markdown spec describing 215 + an authentication service. Phoenix will parse this into structured data.${R} 216 + `); 217 + 218 + showFile('spec/auth.md (v1)', SPEC_V1); 219 + 220 + await wait(400); 221 + 222 + // ══════════════════════════════════════════════════════════════════ 223 + // STEP 2: Clause Extraction — splitting the file 224 + // ══════════════════════════════════════════════════════════════════ 225 + 226 + banner(2, 'Clause Extraction — Splitting the Spec into Atoms'); 227 + 228 + console.log(` 229 + ${D}Phoenix splits on heading boundaries. Each heading + its body = one "clause". 230 + Think of clauses as the atomic units of your spec — the smallest pieces 231 + that can be independently tracked, hashed, and invalidated.${R} 232 + `); 233 + 234 + const clausesV1 = parseSpec(SPEC_V1, 'spec/auth.md'); 235 + 236 + // Show the file again with clause boundaries highlighted 237 + const v1Lines = SPEC_V1.split('\n'); 238 + const clauseHighlights = new Map<number, string>(); 239 + const clauseColors = [GR, YL, CY, MG, BL, GR, YL]; 240 + for (let ci = 0; ci < clausesV1.length; ci++) { 241 + const c = clausesV1[ci]; 242 + for (let ln = c.source_line_range[0]; ln <= c.source_line_range[1]; ln++) { 243 + clauseHighlights.set(ln, clauseColors[ci % clauseColors.length]); 244 + } 245 + } 246 + showFile('spec/auth.md — color-coded by clause', SPEC_V1, clauseHighlights); 247 + 248 + console.log(` ${D}Each color = one clause. Phoenix found ${B}${clausesV1.length} clauses${R}${D}:${R}\n`); 249 + 250 + for (let i = 0; i < clausesV1.length; i++) { 251 + const c = clausesV1[i]; 252 + const color = clauseColors[i % clauseColors.length]; 253 + const path = c.section_path.join(' → '); 254 + console.log(` ${color}█${R} Clause ${i + 1}: ${B}${path}${R} ${D}(lines ${c.source_line_range[0]}–${c.source_line_range[1]})${R}`); 255 + } 256 + console.log(''); 257 + 258 + await wait(400); 259 + 260 + // ══════════════════════════════════════════════════════════════════ 261 + // STEP 3: Normalization — what Phoenix actually hashes 262 + // ══════════════════════════════════════════════════════════════════ 263 + 264 + banner(3, 'Normalization — Stripping Noise Before Hashing'); 265 + 266 + console.log(` 267 + ${D}Before hashing, Phoenix normalizes text to ignore formatting noise: 268 + • Heading markers (##) removed • Bold/italic markers removed 269 + • Everything lowercased • Whitespace collapsed 270 + • List items sorted alphabetically • Empty lines removed 271 + 272 + This means ${B}formatting-only changes don't produce false diffs${R}${D}.${R} 273 + `); 274 + 275 + // Show raw → normalized for a meaty clause 276 + const reqClause = clausesV1.find(c => c.section_path.includes('Requirements'))!; 277 + 278 + sub('Raw text (what you wrote)'); 279 + for (const line of reqClause.raw_text.split('\n')) { 280 + console.log(` ${line}`); 281 + } 282 + 283 + sub('Normalized text (what Phoenix hashes)'); 284 + for (const line of reqClause.normalized_text.split('\n')) { 285 + console.log(` ${GR}${line}${R}`); 286 + } 287 + 288 + console.log(`\n ${D}Notice: list items are alphabetically sorted. If you reorder your bullet 289 + points, the hash stays the same — because the ${B}meaning${R}${D} didn't change.${R}\n`); 290 + 291 + // Prove it 292 + sub('Proof: formatting changes don\'t affect the hash'); 293 + const raw1 = '**Phoenix** is a VCS.'; 294 + const raw2 = 'Phoenix is a VCS.'; 295 + const norm1 = normalizeText(raw1); 296 + const norm2 = normalizeText(raw2); 297 + const hash1 = clauseSemhash(norm1); 298 + const hash2 = clauseSemhash(norm2); 299 + console.log(` Input A: "${raw1}"`); 300 + console.log(` Input B: "${raw2}"`); 301 + console.log(` Normalized A: "${GR}${norm1}${R}"`); 302 + console.log(` Normalized B: "${GR}${norm2}${R}"`); 303 + console.log(` Hash A: ${D}${hash1.slice(0, 24)}…${R}`); 304 + console.log(` Hash B: ${D}${hash2.slice(0, 24)}…${R}`); 305 + console.log(` Match: ${hash1 === hash2 ? `${GR}${B}✓ YES — same hash!${R}` : `${RD}✗ NO${R}`}`); 306 + console.log(''); 307 + 308 + await wait(400); 309 + 310 + // ══════════════════════════════════════════════════════════════════ 311 + // STEP 4: Semantic Hashing — the clause data structure 312 + // ══════════════════════════════════════════════════════════════════ 313 + 314 + banner(4, 'Semantic Hashing — Content-Addressed Clause Objects'); 315 + 316 + console.log(` 317 + ${D}Each clause gets two hashes: 318 + 319 + ${B}clause_semhash${R}${D} = SHA-256(normalized_text) 320 + Pure content identity. Same text → same hash. Always. 321 + 322 + ${B}context_semhash_cold${R}${D} = SHA-256(normalized_text + section_path + neighbor hashes) 323 + Knows WHERE in the document this clause lives 324 + and what's around it. Detects structural shifts.${R} 325 + `); 326 + 327 + // Show the full data structure for one clause 328 + const loginClause = clausesV1.find(c => 329 + c.section_path[c.section_path.length - 1] === 'POST /auth/login' 330 + )!; 331 + 332 + showJSON('Clause Object — POST /auth/login', { 333 + clause_id: loginClause.clause_id, 334 + source_doc_id: loginClause.source_doc_id, 335 + source_line_range: loginClause.source_line_range, 336 + section_path: loginClause.section_path, 337 + raw_text: loginClause.raw_text, 338 + normalized_text: loginClause.normalized_text, 339 + clause_semhash: loginClause.clause_semhash, 340 + context_semhash_cold: loginClause.context_semhash_cold, 341 + }); 342 + 343 + console.log(` ${D}This object is stored by its ${B}clause_id${R}${D} (a content hash). 344 + If the content changes, the ID changes. If it doesn't, the ID is stable. 345 + This is how Phoenix knows exactly what changed and what didn't.${R}\n`); 346 + 347 + // Show all clause hashes in a table 348 + sub('All Clause Hashes'); 349 + console.log(` ${'Section'.padEnd(40)} ${'semhash'.padEnd(14)} ${'context_cold'.padEnd(14)}`); 350 + console.log(` ${D}${'─'.repeat(40)} ${'─'.repeat(14)} ${'─'.repeat(14)}${R}`); 351 + for (const c of clausesV1) { 352 + const name = c.section_path[c.section_path.length - 1] || '(root)'; 353 + console.log(` ${name.padEnd(40)} ${D}${c.clause_semhash.slice(0, 12)}…${R} ${D}${c.context_semhash_cold.slice(0, 12)}…${R}`); 354 + } 355 + console.log(''); 356 + 357 + await wait(400); 358 + 359 + // ══════════════════════════════════════════════════════════════════ 360 + // STEP 5: Canonicalization — extracting structured requirements 361 + // ══════════════════════════════════════════════════════════════════ 362 + 363 + banner(5, 'Canonicalization — Extracting the Requirements Graph'); 364 + 365 + console.log(` 366 + ${D}Phoenix scans each clause for semantic signals and extracts 367 + ${B}canonical nodes${R}${D} — structured representations of what the spec actually requires. 368 + 369 + It looks for patterns like: 370 + "must", "shall", "required" → ${R}${BG_GR}${WH}${B} REQUIREMENT ${R}${D} 371 + "must not", "forbidden", "limited to" → ${R}${BG_RD}${WH}${B} CONSTRAINT ${R}${D} 372 + "always", "never" → ${R}${BG_MG}${WH}${B} INVARIANT ${R}${D} 373 + ": ", "is defined as" → ${R}${BG_BL}${WH}${B} DEFINITION ${R}${D} 374 + 375 + Heading context also matters: a line under "## Security Constraints" 376 + gets classified as a constraint even without magic words.${R} 377 + `); 378 + 379 + const canonV1 = extractCanonicalNodes(clausesV1); 380 + 381 + // Show each canonical node with its source 382 + for (const node of canonV1) { 383 + const typeBg = node.type === 'REQUIREMENT' ? BG_GR 384 + : node.type === 'CONSTRAINT' ? BG_RD 385 + : node.type === 'INVARIANT' ? BG_MG 386 + : BG_BL; 387 + const sourceClause = clausesV1.find(c => c.clause_id === node.source_clause_ids[0]); 388 + const sourceName = sourceClause 389 + ? sourceClause.section_path[sourceClause.section_path.length - 1] 390 + : '?'; 391 + const links = node.linked_canon_ids.length; 392 + const linkStr = links > 0 ? ` ${YL}⟷ linked to ${links} other node${links > 1 ? 's' : ''}${R}` : ''; 393 + 394 + console.log(` ${badge(node.type, typeBg)} ${node.statement}`); 395 + console.log(` ${D}source: ${sourceName} | tags: [${node.tags.slice(0, 5).join(', ')}]${R}${linkStr}`); 396 + console.log(''); 397 + } 398 + 399 + // Show the full data for one node 400 + const sampleNode = canonV1[0]; 401 + showJSON('Canonical Node Object (first node)', { 402 + canon_id: sampleNode.canon_id, 403 + type: sampleNode.type, 404 + statement: sampleNode.statement, 405 + source_clause_ids: sampleNode.source_clause_ids, 406 + linked_canon_ids: sampleNode.linked_canon_ids, 407 + tags: sampleNode.tags, 408 + }); 409 + 410 + sub('Provenance Chain'); 411 + console.log(` ${D}Every canonical node traces back to its source clause, which traces 412 + back to exact line numbers in the spec file. Nothing is disconnected:${R}\n`); 413 + const provClause = clausesV1.find(c => c.clause_id === sampleNode.source_clause_ids[0])!; 414 + arrow('spec/auth.md:L' + provClause.source_line_range[0] + '–' + provClause.source_line_range[1], 415 + 'Clause "' + provClause.section_path[provClause.section_path.length - 1] + '"', 416 + 'parsed into'); 417 + arrow('Clause ' + provClause.clause_id.slice(0, 8) + '…', 418 + 'Canon "' + sampleNode.statement.slice(0, 35) + '…"', 419 + 'extracted as ' + sampleNode.type); 420 + console.log(''); 421 + 422 + await wait(400); 423 + 424 + // ══════════════════════════════════════════════════════════════════ 425 + // STEP 6: Warm Hashes — incorporating canonical context 426 + // ══════════════════════════════════════════════════════════════════ 427 + 428 + banner(6, 'Warm Context Hashes — Adding Graph Awareness'); 429 + 430 + console.log(` 431 + ${D}The cold hash only knows about text + neighbors. Now that we have the 432 + canonical graph, we compute a ${B}warm hash${R}${D} that also knows which 433 + ${B}requirement nodes${R}${D} are linked to this clause. 434 + 435 + Why? If someone adds a requirement in a different section that ${B}links to${R}${D} 436 + this clause's requirements, the warm hash changes — telling Phoenix that 437 + this clause's ${B}context${R}${D} shifted even though its ${B}content${R}${D} didn't.${R} 438 + `); 439 + 440 + const warmV1 = computeWarmHashes(clausesV1, canonV1); 441 + 442 + sub('Cold vs Warm Hashes — Side by Side'); 443 + console.log(` ${'Section'.padEnd(32)} ${'Cold Hash'.padEnd(16)} ${'Warm Hash'.padEnd(16)} ${'Status'}`); 444 + console.log(` ${D}${'─'.repeat(32)} ${'─'.repeat(16)} ${'─'.repeat(16)} ${'─'.repeat(10)}${R}`); 445 + 446 + for (const c of clausesV1) { 447 + const name = (c.section_path[c.section_path.length - 1] || '(root)').slice(0, 30); 448 + const cold = c.context_semhash_cold.slice(0, 14); 449 + const warm = warmV1.get(c.clause_id)!.slice(0, 14); 450 + const status = cold !== warm 451 + ? `${YL}differs${R} ${D}← canonical context added${R}` 452 + : `${GR}same${R}`; 453 + console.log(` ${name.padEnd(32)} ${D}${cold}…${R} ${D}${warm}…${R} ${status}`); 454 + } 455 + 456 + console.log(`\n ${D}All warm hashes differ from cold — because every clause now has 457 + canonical nodes linked to it, enriching its context signature.${R}\n`); 458 + 459 + await wait(400); 460 + 461 + // ══════════════════════════════════════════════════════════════════ 462 + // STEP 7: Bootstrap state machine 463 + // ══════════════════════════════════════════════════════════════════ 464 + 465 + banner(7, 'Bootstrap — State Machine Transition'); 466 + 467 + console.log(` 468 + ${D}Phoenix tracks system trust state: 469 + 470 + ${badge('BOOTSTRAP_COLD', BG_BL)} → Parsing only, no canonical graph yet 471 + ${badge('BOOTSTRAP_WARMING', BG_YL)} → Canonical graph exists, hashes stabilizing 472 + ${badge('STEADY_STATE', BG_GR)} → D-rate acceptable, system trusted 473 + 474 + D-rate alarms are ${B}suppressed during cold${R}${D} (no point — everything is new). 475 + Severity is ${B}downgraded during warming${R}${D} (still stabilizing).${R} 476 + `); 477 + 478 + const bootstrap = new BootstrapStateMachine(); 479 + console.log(` State: ${badge(bootstrap.getState(), BG_BL)} Alarms suppressed: ${GR}yes${R}`); 480 + 481 + bootstrap.markWarmPassComplete(); 482 + console.log(` State: ${badge(bootstrap.getState(), BG_YL)} Severity downgraded: ${YL}yes${R}`); 483 + 484 + const tracker = new DRateTracker(20); 485 + for (let i = 0; i < 18; i++) tracker.recordOne(ChangeClass.A); 486 + for (let i = 0; i < 2; i++) tracker.recordOne(ChangeClass.B); 487 + const dStatus = tracker.getStatus(); 488 + bootstrap.evaluateTransition(dStatus); 489 + console.log(` State: ${badge(bootstrap.getState(), BG_GR)} D-rate: ${(dStatus.rate * 100).toFixed(0)}% → ${GR}trusted${R}`); 490 + 491 + showJSON('Bootstrap State (persisted to .phoenix/state.json)', bootstrap.toJSON()); 492 + 493 + await wait(400); 494 + 495 + // ══════════════════════════════════════════════════════════════════ 496 + // STEP 8: The spec changes — show the diff 497 + // ══════════════════════════════════════════════════════════════════ 498 + 499 + banner(8, 'The Spec Evolves — v1 → v2'); 500 + 501 + console.log(` 502 + ${D}A developer edits spec/auth.md. Let's see exactly what changed:${R} 503 + `); 504 + 505 + // Show v2 with highlights on changed lines 506 + const v2Lines = SPEC_V2.split('\n'); 507 + const v1Set = new Set(SPEC_V1.split('\n')); 508 + const v2Highlights = new Map<number, string>(); 509 + for (let i = 0; i < v2Lines.length; i++) { 510 + if (!v1Set.has(v2Lines[i])) { 511 + v2Highlights.set(i + 1, `${YL}`); 512 + } 513 + } 514 + showFile('spec/auth.md (v2) — yellow = changed/new lines', SPEC_V2, v2Highlights); 515 + 516 + sub('What changed (human-readable)'); 517 + console.log(` ${YL}~${R} Line 3: "…and session management" → "…session management, ${B}and OAuth integration${R}"`); 518 + console.log(` ${YL}~${R} Line 8: "24 hours" → "${B}12 hours${R}"`); 519 + console.log(` ${YL}~${R} Line 9: "5 per minute" → "${B}3 per minute${R}"`); 520 + console.log(` ${YL}~${R} Line 10: "bcrypt (cost factor 12)" → "${B}argon2id (cost factor 3, memory 64MB)${R}"`); 521 + console.log(` ${GR}+${R} Line 11: ${B}OAuth2 providers (Google, GitHub) must be supported${R} ${D}← new${R}`); 522 + console.log(` ${GR}+${R} Line 25: ${B}GET /auth/oauth/:provider${R} ${D}← new endpoint${R}`); 523 + console.log(` ${YL}~${R} Line 31: "1 hour" → "${B}30 minutes${R}"`); 524 + console.log(` ${GR}+${R} Line 32: ${B}OAuth tokens must be stored encrypted at rest${R} ${D}← new${R}`); 525 + console.log(''); 526 + 527 + await wait(400); 528 + 529 + // ══════════════════════════════════════════════════════════════════ 530 + // STEP 9: Clause-level diff 531 + // ══════════════════════════════════════════════════════════════════ 532 + 533 + banner(9, 'Clause Diff — What Phoenix Sees'); 534 + 535 + console.log(` 536 + ${D}Phoenix doesn't diff lines. It diffs ${B}clauses${R}${D} — semantic units. 537 + It re-parses v2, compares clause hashes, and classifies each one:${R} 538 + `); 539 + 540 + const clausesV2 = parseSpec(SPEC_V2, 'spec/auth.md'); 541 + const diffs = diffClauses(clausesV1, clausesV2); 542 + 543 + const diffColors: Record<string, string> = { 544 + UNCHANGED: GR, MODIFIED: YL, ADDED: GR, REMOVED: RD, MOVED: BL, 545 + }; 546 + const diffIcons: Record<string, string> = { 547 + UNCHANGED: '═', MODIFIED: '~', ADDED: '+', REMOVED: '-', MOVED: '→', 548 + }; 549 + 550 + for (const diff of diffs) { 551 + const clause = diff.clause_after || diff.clause_before!; 552 + const name = clause.section_path[clause.section_path.length - 1] || '(root)'; 553 + const color = diffColors[diff.diff_type]; 554 + const icon = diffIcons[diff.diff_type]; 555 + 556 + console.log(` ${color}${B}${icon}${R} ${badge(diff.diff_type.padEnd(10), diff.diff_type === 'UNCHANGED' ? BG_GR : diff.diff_type === 'ADDED' ? BG_CY : diff.diff_type === 'REMOVED' ? BG_RD : BG_YL)} ${B}${name}${R}`); 557 + 558 + // Show what changed for MODIFIED clauses 559 + if (diff.diff_type === 'MODIFIED' && diff.clause_before && diff.clause_after) { 560 + const beforeLines = diff.clause_before.normalized_text.split('\n'); 561 + const afterLines = diff.clause_after.normalized_text.split('\n'); 562 + const beforeSet = new Set(beforeLines); 563 + const afterSet = new Set(afterLines); 564 + 565 + const removed = beforeLines.filter(l => !afterSet.has(l)); 566 + const added = afterLines.filter(l => !beforeSet.has(l)); 567 + 568 + for (const line of removed) { 569 + if (line.trim()) console.log(` ${RD}- ${line}${R}`); 570 + } 571 + for (const line of added) { 572 + if (line.trim()) console.log(` ${GR}+ ${line}${R}`); 573 + } 574 + } 575 + if (diff.diff_type === 'ADDED' && diff.clause_after) { 576 + for (const line of diff.clause_after.normalized_text.split('\n').slice(0, 3)) { 577 + if (line.trim()) console.log(` ${GR}+ ${line}${R}`); 578 + } 579 + } 580 + console.log(''); 581 + } 582 + 583 + await wait(400); 584 + 585 + // ══════════════════════════════════════════════════════════════════ 586 + // STEP 10: Canonicalize v2 + show graph delta 587 + // ══════════════════════════════════════════════════════════════════ 588 + 589 + banner(10, 'Canonical Graph Delta — How Requirements Changed'); 590 + 591 + console.log(` 592 + ${D}Phoenix canonicalizes v2 and compares the two requirement graphs. 593 + This is where it understands the ${B}real impact${R}${D} of the spec change.${R} 594 + `); 595 + 596 + const canonV2 = extractCanonicalNodes(clausesV2); 597 + 598 + // Find new nodes 599 + const v1Stmts = new Set(canonV1.map(n => n.statement)); 600 + const v2Stmts = new Set(canonV2.map(n => n.statement)); 601 + const newNodes = canonV2.filter(n => !v1Stmts.has(n.statement)); 602 + const removedNodes = canonV1.filter(n => !v2Stmts.has(n.statement)); 603 + const keptNodes = canonV2.filter(n => v1Stmts.has(n.statement)); 604 + 605 + sub(`Canonical graph: v1 had ${canonV1.length} nodes → v2 has ${canonV2.length} nodes`); 606 + 607 + if (keptNodes.length > 0) { 608 + console.log(` ${GR}Unchanged nodes (${keptNodes.length}):${R}`); 609 + for (const n of keptNodes) { 610 + console.log(` ${GR}═${R} ${n.statement.slice(0, 70)}`); 611 + } 612 + console.log(''); 613 + } 614 + 615 + if (removedNodes.length > 0) { 616 + console.log(` ${RD}Removed nodes (${removedNodes.length}):${R}`); 617 + for (const n of removedNodes) { 618 + console.log(` ${RD}- ${n.statement.slice(0, 70)}${R}`); 619 + } 620 + console.log(''); 621 + } 622 + 623 + if (newNodes.length > 0) { 624 + console.log(` ${CY}New nodes (${newNodes.length}):${R}`); 625 + for (const n of newNodes) { 626 + const typeBg = n.type === 'REQUIREMENT' ? BG_GR : n.type === 'CONSTRAINT' ? BG_RD : BG_BL; 627 + console.log(` ${CY}+${R} ${badge(n.type, typeBg)} ${n.statement.slice(0, 60)}`); 628 + } 629 + console.log(''); 630 + } 631 + 632 + await wait(400); 633 + 634 + // ══════════════════════════════════════════════════════════════════ 635 + // STEP 11: Classify changes A/B/C/D 636 + // ══════════════════════════════════════════════════════════════════ 637 + 638 + banner(11, 'Change Classification — A / B / C / D'); 639 + 640 + console.log(` 641 + ${D}Now Phoenix classifies each diff using multiple signals: 642 + 643 + ${badge('A', BG_GR)} ${B}Trivial${R}${D} — formatting only, no semantic change 644 + ${badge('B', BG_BL)} ${B}Local Semantic${R}${D} — content changed, limited blast radius 645 + ${badge('C', BG_YL)} ${B}Contextual Shift${R}${D} — affects canonical graph or structural context 646 + ${badge('D', BG_RD)} ${B}Uncertain${R}${D} — classifier can't decide; needs human review 647 + 648 + Signals used: edit distance, semhash delta, context hash delta, 649 + term overlap (Jaccard), section structure, # of canonical nodes affected.${R} 650 + `); 651 + 652 + const warmV2 = computeWarmHashes(clausesV2, canonV2); 653 + const classifications = classifyChanges(diffs, canonV1, canonV2, warmV1, warmV2); 654 + 655 + const classColors: Record<string, string> = { A: BG_GR, B: BG_BL, C: BG_YL, D: BG_RD }; 656 + const classLabels: Record<string, string> = { 657 + A: 'Trivial', B: 'Local Semantic', C: 'Contextual Shift', D: 'Uncertain', 658 + }; 659 + 660 + for (let i = 0; i < diffs.length; i++) { 661 + const diff = diffs[i]; 662 + const cls = classifications[i]; 663 + const clause = diff.clause_after || diff.clause_before!; 664 + const name = clause.section_path[clause.section_path.length - 1] || '(root)'; 665 + 666 + console.log(` ${badge(cls.change_class, classColors[cls.change_class])} ${B}${name}${R} ${D}(${diff.diff_type})${R} → ${classLabels[cls.change_class]} ${D}${(cls.confidence * 100).toFixed(0)}% confidence${R}`); 667 + 668 + // Show signal breakdown for non-trivial changes 669 + if (cls.change_class !== 'A') { 670 + const s = cls.signals; 671 + const parts: string[] = []; 672 + if (s.semhash_delta) parts.push(`content: ${RD}changed${R}`); 673 + else parts.push(`content: ${GR}same${R}`); 674 + if (s.context_cold_delta) parts.push(`context: ${YL}shifted${R}`); 675 + if (s.norm_diff > 0) parts.push(`edit dist: ${(s.norm_diff * 100).toFixed(0)}%`); 676 + if (s.term_ref_delta > 0) parts.push(`term overlap: ${((1 - s.term_ref_delta) * 100).toFixed(0)}%`); 677 + if (s.canon_impact > 0) parts.push(`canon impact: ${B}${s.canon_impact} nodes${R}`); 678 + console.log(` ${D}signals: ${parts.join(' │ ')}${R}`); 679 + } 680 + console.log(''); 681 + } 682 + 683 + // Show one full classification object 684 + const interestingCls = classifications.find(c => c.change_class === 'C' && c.signals.canon_impact > 0)!; 685 + if (interestingCls) { 686 + showJSON('Full Classification Object (most interesting change)', interestingCls); 687 + } 688 + 689 + await wait(400); 690 + 691 + // ══════════════════════════════════════════════════════════════════ 692 + // STEP 12: Trust Dashboard 693 + // ══════════════════════════════════════════════════════════════════ 694 + 695 + banner(12, 'Trust Dashboard — phoenix status'); 696 + 697 + console.log(` 698 + ${D}This is what ${B}phoenix status${R}${D} would show. It's the primary UX of Phoenix. 699 + If this is trustworthy, Phoenix works. If it's noisy or wrong, it's useless.${R} 700 + `); 701 + 702 + const liveTracker = new DRateTracker(50); 703 + liveTracker.record(classifications); 704 + const liveStatus = liveTracker.getStatus(); 705 + 706 + // Summary table 707 + const counts: Record<string, number> = { A: 0, B: 0, C: 0, D: 0 }; 708 + for (const c of classifications) counts[c.change_class]++; 709 + 710 + showBox([ 711 + `${B}Classification Summary${R}`, 712 + ``, 713 + ` ${badge('A', BG_GR)} Trivial ${'█'.repeat(counts.A * 4)}${'░'.repeat((8 - counts.A) * 4)} ${counts.A}`, 714 + ` ${badge('B', BG_BL)} Local Semantic ${'█'.repeat(counts.B * 4)}${'░'.repeat((8 - counts.B) * 4)} ${counts.B}`, 715 + ` ${badge('C', BG_YL)} Contextual Shift ${'█'.repeat(counts.C * 4)}${'░'.repeat((8 - counts.C) * 4)} ${counts.C}`, 716 + ` ${badge('D', BG_RD)} Uncertain ${'░'.repeat(8 * 4)} ${counts.D}`, 717 + ``, 718 + ` ${B}D-Rate:${R} ${(liveStatus.rate * 100).toFixed(1)}% ${badge(liveStatus.level, BG_GR)}`, 719 + ` ${D}[${GR}${'█'.repeat(Math.round((1 - liveStatus.rate) * 40))}${R}${D}${'░'.repeat(40 - Math.round((1 - liveStatus.rate) * 40))}] target ≤5% alarm >15%${R}`, 720 + ``, 721 + ` ${B}Canonical Graph:${R} ${canonV1.length} → ${canonV2.length} nodes ${GR}(+${newNodes.length} new, -${removedNodes.length} removed)${R}`, 722 + ` ${B}System State:${R} ${badge('STEADY_STATE', BG_GR)}`, 723 + ]); 724 + 725 + console.log(''); 726 + 727 + // ══════════════════════════════════════════════════════════════════ 728 + // Summary 729 + // ══════════════════════════════════════════════════════════════════ 730 + 731 + // ══════════════════════════════════════════════════════════════════ 732 + // STEP 13: IU Planning — mapping requirements to code units 733 + // ══════════════════════════════════════════════════════════════════ 734 + 735 + banner(13, 'IU Planning — Mapping Requirements → Code Modules'); 736 + 737 + console.log(` 738 + ${D}Now Phoenix groups canonical nodes into ${B}Implementation Units (IUs)${R}${D} — 739 + the stable compilation boundaries that will hold generated code. 740 + 741 + Grouping strategy: 742 + • Canonical nodes from the same source clause → same IU 743 + • Linked nodes (shared terms) → same IU 744 + • Each IU gets a risk tier, contract, boundary policy, and evidence policy${R} 745 + `); 746 + 747 + const iusV1 = planIUs(canonV1, clausesV1); 748 + 749 + for (const iu of iusV1) { 750 + const riskBg = iu.risk_tier === 'high' ? BG_RD : iu.risk_tier === 'medium' ? BG_YL : BG_GR; 751 + console.log(` ${badge(iu.risk_tier.toUpperCase(), riskBg)} ${B}${iu.name}${R} ${D}(${iu.kind})${R}`); 752 + console.log(` ${D}canon nodes: ${iu.source_canon_ids.length} | output: ${iu.output_files.join(', ')}${R}`); 753 + console.log(` ${D}evidence required: ${iu.evidence_policy.required.join(', ')}${R}`); 754 + console.log(''); 755 + } 756 + 757 + showJSON('Implementation Unit Object (first IU)', { 758 + iu_id: iusV1[0].iu_id, 759 + kind: iusV1[0].kind, 760 + name: iusV1[0].name, 761 + risk_tier: iusV1[0].risk_tier, 762 + contract: { 763 + description: iusV1[0].contract.description.slice(0, 120) + '…', 764 + invariants: iusV1[0].contract.invariants, 765 + }, 766 + source_canon_ids: iusV1[0].source_canon_ids.map(id => id.slice(0, 16) + '…'), 767 + output_files: iusV1[0].output_files, 768 + evidence_policy: iusV1[0].evidence_policy, 769 + }); 770 + 771 + await wait(400); 772 + 773 + // ══════════════════════════════════════════════════════════════════ 774 + // STEP 14: Code Generation — producing the actual files 775 + // ══════════════════════════════════════════════════════════════════ 776 + 777 + banner(14, 'Code Generation — Producing TypeScript Module Stubs'); 778 + 779 + console.log(` 780 + ${D}Phoenix generates code for each IU. In v1 this is a stub generator; 781 + in production it would invoke an LLM with a structured promptpack. 782 + 783 + The regen engine records: 784 + • model_id (which generator produced this) 785 + • promptpack hash (what instructions were used) 786 + • toolchain version 787 + • per-file content hashes (for drift detection later)${R} 788 + `); 789 + 790 + const regenResults = iusV1.map(iu => generateIU(iu)); 791 + 792 + for (const result of regenResults) { 793 + for (const [filePath, content] of result.files) { 794 + console.log(` ${BG_CY}${WH}${B} 📄 ${filePath} ${R} ${D}(${content.length} bytes)${R}\n`); 795 + const lines = content.split('\n'); 796 + for (let i = 0; i < lines.length; i++) { 797 + const ln = String(i + 1).padStart(3); 798 + console.log(` ${D}${ln}${R} ${lines[i]}`); 799 + } 800 + console.log(''); 801 + } 802 + } 803 + 804 + sub('Generated Manifest Entry'); 805 + showJSON('.phoenix/manifests/generated_manifest.json (excerpt)', { 806 + iu_manifests: { 807 + [regenResults[0].manifest.iu_id.slice(0, 16) + '…']: { 808 + iu_name: regenResults[0].manifest.iu_name, 809 + files: Object.fromEntries( 810 + Object.entries(regenResults[0].manifest.files).map(([k, v]) => [k, { 811 + content_hash: v.content_hash.slice(0, 16) + '…', 812 + size: v.size, 813 + }]) 814 + ), 815 + regen_metadata: regenResults[0].manifest.regen_metadata, 816 + }, 817 + }, 818 + }); 819 + 820 + await wait(400); 821 + 822 + // ══════════════════════════════════════════════════════════════════ 823 + // STEP 15: Drift Detection — checking for manual edits 824 + // ══════════════════════════════════════════════════════════════════ 825 + 826 + banner(15, 'Drift Detection — Has Anyone Edited Generated Code?'); 827 + 828 + console.log(` 829 + ${D}Phoenix compares the working tree against the generated manifest. 830 + Every file is hashed. If a hash doesn't match and there's no waiver, 831 + that's a ${B}drift violation${R}${D} — someone edited generated code directly, 832 + breaking the provenance chain. 833 + 834 + Possible statuses: 835 + ${GR}CLEAN${R}${D} — file matches manifest exactly 836 + ${RD}DRIFTED${R}${D} — file was modified without a waiver → ${B}ERROR${R}${D} 837 + ${YL}WAIVED${R}${D} — file was modified but has an approved waiver 838 + ${RD}MISSING${R}${D} — manifest says file should exist but it doesn't${R} 839 + `); 840 + 841 + // Set up a temp project to demonstrate drift 842 + const demoRoot = mkdtempSync(join(tmpdir(), 'phoenix-demo-')); 843 + 844 + // Write generated files to disk 845 + for (const result of regenResults) { 846 + for (const [path, content] of result.files) { 847 + const fullPath = join(demoRoot, path); 848 + mkdirSync(join(fullPath, '..'), { recursive: true }); 849 + writeFileSync(fullPath, content, 'utf8'); 850 + } 851 + } 852 + 853 + // Build the manifest 854 + const manifest: GeneratedManifest = { 855 + iu_manifests: Object.fromEntries(regenResults.map(r => [r.manifest.iu_id, r.manifest])), 856 + generated_at: new Date().toISOString(), 857 + }; 858 + 859 + // Check — should be clean 860 + sub('Scenario 1: Fresh generation — all files clean'); 861 + const cleanReport = detectDrift(manifest, demoRoot); 862 + for (const entry of cleanReport.entries) { 863 + const icon = entry.status === DriftStatus.CLEAN ? `${GR}✓${R}` : `${RD}✗${R}`; 864 + console.log(` ${icon} ${badge(entry.status, BG_GR)} ${entry.file_path}`); 865 + } 866 + console.log(`\n ${D}${cleanReport.summary}${R}`); 867 + 868 + // Now tamper with a file 869 + sub('Scenario 2: Someone manually edits a generated file'); 870 + const firstFile = [...regenResults[0].files.keys()][0]; 871 + const fullPath = join(demoRoot, firstFile); 872 + const original = regenResults[0].files.get(firstFile)!; 873 + writeFileSync(fullPath, '// HACKED BY DEV AT 3AM\n' + original, 'utf8'); 874 + console.log(` ${YL}Simulating:${R} Added "// HACKED BY DEV AT 3AM" to ${B}${firstFile}${R}\n`); 875 + 876 + const driftReport = detectDrift(manifest, demoRoot); 877 + for (const entry of driftReport.entries) { 878 + const icon = entry.status === DriftStatus.CLEAN ? `${GR}✓${R}` : 879 + entry.status === DriftStatus.DRIFTED ? `${RD}✗${R}` : `${YL}!${R}`; 880 + const bg = entry.status === DriftStatus.CLEAN ? BG_GR : BG_RD; 881 + console.log(` ${icon} ${badge(entry.status, bg)} ${entry.file_path}`); 882 + if (entry.status === DriftStatus.DRIFTED) { 883 + console.log(` ${D}expected: ${entry.expected_hash?.slice(0, 16)}… actual: ${entry.actual_hash?.slice(0, 16)}…${R}`); 884 + } 885 + } 886 + console.log(`\n ${RD}${B}${driftReport.summary}${R}`); 887 + console.log(`\n ${D}To fix: label the edit as ${B}promote_to_requirement${R}${D}, ${B}waiver${R}${D}, or ${B}temporary_patch${R}${D}.${R}`); 888 + 889 + await wait(400); 890 + 891 + // ══════════════════════════════════════════════════════════════════ 892 + // STEP 16: Boundary Validation — architectural linting 893 + // ══════════════════════════════════════════════════════════════════ 894 + 895 + banner(16, 'Boundary Validation — Architectural Linter'); 896 + 897 + console.log(` 898 + ${D}Each IU declares what it's ${B}allowed${R}${D} and ${B}forbidden${R}${D} to touch: 899 + • Which packages it may import 900 + • Which IUs it may depend on 901 + • Which side channels (databases, env vars, APIs) it may use 902 + 903 + Phoenix extracts the dependency graph from the code and validates it 904 + against the boundary policy. Violations become diagnostics in ${B}phoenix status${R}${D}.${R} 905 + `); 906 + 907 + // Show a realistic code sample with violations 908 + const naughtyCode = `/** 909 + * AuthIU — generated module 910 + */ 911 + import express from 'express'; 912 + import axios from 'axios'; 913 + import { adminSecret } from './internal/admin-keys.js'; 914 + 915 + const dbUrl = process.env.DATABASE_URL; 916 + const apiKey = process.env.STRIPE_API_KEY; 917 + 918 + const resp = fetch('https://external-service.com/api'); 919 + 920 + export function authenticate(email: string, password: string) { 921 + // ...implementation 922 + }`; 923 + 924 + showFile('src/generated/auth-iu.ts (with violations)', naughtyCode); 925 + 926 + sub('Dependency Extraction'); 927 + const depGraph = extractDependencies(naughtyCode, 'src/generated/auth-iu.ts'); 928 + 929 + console.log(` ${B}Imports found:${R}`); 930 + for (const dep of depGraph.imports) { 931 + const rel = dep.is_relative ? `${D}(relative)${R}` : `${D}(package)${R}`; 932 + console.log(` L${dep.source_line}: ${CY}${dep.source}${R} ${rel}`); 933 + } 934 + console.log(`\n ${B}Side channels found:${R}`); 935 + for (const sc of depGraph.side_channels) { 936 + console.log(` L${sc.source_line}: ${YL}${sc.kind}${R} → ${sc.identifier}`); 937 + } 938 + 939 + showJSON('DependencyGraph object', { 940 + file_path: depGraph.file_path, 941 + imports: depGraph.imports, 942 + side_channels: depGraph.side_channels, 943 + }); 944 + 945 + sub('Boundary Validation'); 946 + 947 + // Create a strict boundary policy 948 + const strictIU = { 949 + ...iusV1[0], 950 + boundary_policy: { 951 + code: { 952 + allowed_ius: [], 953 + allowed_packages: ['express', 'bcrypt'], 954 + forbidden_ius: [], 955 + forbidden_packages: ['axios'], 956 + forbidden_paths: ['./internal/**'], 957 + }, 958 + side_channels: { 959 + databases: [], queues: [], caches: [], 960 + config: ['DATABASE_URL'], // only this one is declared 961 + external_apis: [], 962 + files: [], 963 + }, 964 + }, 965 + enforcement: { 966 + dependency_violation: { severity: 'error' as const }, 967 + side_channel_violation: { severity: 'warning' as const }, 968 + }, 969 + }; 970 + 971 + console.log(` ${D}Boundary policy for this IU:${R}`); 972 + console.log(` ${GR}allowed_packages:${R} [express, bcrypt]`); 973 + console.log(` ${RD}forbidden_packages:${R} [axios]`); 974 + console.log(` ${RD}forbidden_paths:${R} [./internal/**]`); 975 + console.log(` ${GR}declared config:${R} [DATABASE_URL]`); 976 + console.log(''); 977 + 978 + const diags = validateBoundary(depGraph, strictIU); 979 + 980 + for (const diag of diags) { 981 + const sevBg = diag.severity === 'error' ? BG_RD : BG_YL; 982 + const icon = diag.severity === 'error' ? `${RD}✗${R}` : `${YL}!${R}`; 983 + console.log(` ${icon} ${badge(diag.severity.toUpperCase(), sevBg)} ${badge(diag.category, BG_BL)}`); 984 + console.log(` ${B}${diag.subject}${R}: ${diag.message}`); 985 + console.log(` ${D}at ${diag.source_file}:${diag.source_line}${R}`); 986 + console.log(` ${D}fix: ${diag.recommended_actions[0]}${R}`); 987 + console.log(''); 988 + } 989 + 990 + console.log(` ${D}Total: ${diags.filter(d => d.severity === 'error').length} errors, ${diags.filter(d => d.severity === 'warning').length} warnings${R}`); 991 + 992 + await wait(400); 993 + 994 + // ══════════════════════════════════════════════════════════════════ 995 + // STEP 17: Boundary Change Detection 996 + // ══════════════════════════════════════════════════════════════════ 997 + 998 + banner(17, 'Boundary Change Detection — Policy Evolution'); 999 + 1000 + console.log(` 1001 + ${D}When an IU's boundary policy changes, Phoenix detects it and triggers 1002 + re-validation of the IU and all its dependents. This prevents silent 1003 + coupling drift.${R} 1004 + `); 1005 + 1006 + const updatedIU = { 1007 + ...strictIU, 1008 + boundary_policy: { 1009 + ...strictIU.boundary_policy, 1010 + code: { 1011 + ...strictIU.boundary_policy.code, 1012 + allowed_packages: ['express', 'bcrypt', 'argon2'], 1013 + forbidden_packages: ['axios', 'got'], 1014 + }, 1015 + side_channels: { 1016 + ...strictIU.boundary_policy.side_channels, 1017 + config: ['DATABASE_URL', 'STRIPE_API_KEY'], 1018 + external_apis: ['https://external-service.com/api'], 1019 + }, 1020 + }, 1021 + }; 1022 + 1023 + const boundaryChange = detectBoundaryChanges(strictIU, updatedIU); 1024 + 1025 + if (boundaryChange) { 1026 + console.log(` ${badge('BOUNDARY CHANGE', BG_YL)} ${B}${boundaryChange.iu_name}${R}\n`); 1027 + for (const change of boundaryChange.changes) { 1028 + console.log(` ${YL}~${R} ${change}`); 1029 + } 1030 + console.log(`\n ${D}This triggers: re-extract deps → re-validate → update status for this IU + dependents${R}`); 1031 + } 1032 + 1033 + console.log(`\n ${D}After updating the policy to declare the new deps:${R}`); 1034 + const diagsAfter = validateBoundary(depGraph, updatedIU); 1035 + if (diagsAfter.length === 0) { 1036 + console.log(` ${GR}${B}✓ All boundary checks pass${R}`); 1037 + } else { 1038 + for (const diag of diagsAfter) { 1039 + const sevBg = diag.severity === 'error' ? BG_RD : BG_YL; 1040 + console.log(` ${badge(diag.severity.toUpperCase(), sevBg)} ${diag.subject}: ${diag.message}`); 1041 + } 1042 + } 1043 + 1044 + await wait(400); 1045 + 1046 + // ══════════════════════════════════════════════════════════════════ 1047 + // STEP 18: Updated Trust Dashboard 1048 + // ══════════════════════════════════════════════════════════════════ 1049 + 1050 + banner(18, 'Trust Dashboard — phoenix status (Full)'); 1051 + 1052 + console.log(` 1053 + ${D}Everything feeds into the trust dashboard. This is what a developer 1054 + sees when they run ${B}phoenix status${R}${D}:${R} 1055 + `); 1056 + 1057 + showBox([ 1058 + `${B}phoenix status${R} ${D}STEADY_STATE | spec/auth.md v1 → v2${R}`, 1059 + ``, 1060 + `${B}Classification Summary${R} A:${GR}3${R} B:${BL}1${R} C:${YL}4${R} D:${RD}0${R} │ D-Rate: ${GR}0.0%${R} ${badge('TARGET', BG_GR)}`, 1061 + ``, 1062 + `${B}Canonical Graph${R} 8 → 10 nodes │ ${GR}+${newNodes.length} new${R} ${RD}-${removedNodes.length} removed${R} ${GR}${keptNodes.length} kept${R}`, 1063 + ``, 1064 + `${B}Implementation Units${R} ${iusV1.length} IU${iusV1.length > 1 ? 's' : ''} │ ${regenResults.reduce((s,r) => s + r.files.size, 0)} generated files`, 1065 + ``, 1066 + `${B}Drift${R} ${driftReport.drifted_count > 0 ? `${RD}${B}${driftReport.drifted_count} DRIFTED${R}` : `${GR}all clean${R}`} │ ${driftReport.clean_count} clean ${driftReport.drifted_count} drifted`, 1067 + ``, 1068 + `${B}Boundary${R} ${diags.length > 0 ? `${RD}${diags.filter(d=>d.severity==='error').length} errors${R} ${YL}${diags.filter(d=>d.severity==='warning').length} warnings${R}` : `${GR}all clear${R}`}`, 1069 + ``, 1070 + `${B}Actions Required:${R}`, 1071 + ` ${RD}ERROR${R} drift ${firstFile} Drifted from manifest → label or reconcile`, 1072 + ` ${RD}ERROR${R} boundary axios Forbidden package → remove import`, 1073 + ` ${RD}ERROR${R} boundary ./internal/** Forbidden path → remove import`, 1074 + ` ${YL}WARN ${R} boundary STRIPE_API_KEY Undeclared config → declare or remove`, 1075 + ` ${YL}WARN ${R} boundary external-svc Undeclared API → declare or remove`, 1076 + ]); 1077 + 1078 + console.log(''); 1079 + 1080 + await wait(400); 1081 + 1082 + // ══════════════════════════════════════════════════════════════════ 1083 + // STEP 19: Evidence & Policy Engine (Phase D) 1084 + // ══════════════════════════════════════════════════════════════════ 1085 + 1086 + banner(19, 'Evidence & Policy — Risk-Tiered Proof'); 1087 + 1088 + console.log(` 1089 + ${D}Each IU has a risk tier that determines what evidence is required 1090 + before its generated code is accepted: 1091 + 1092 + ${badge('LOW', BG_GR)} typecheck + lint + boundary validation 1093 + ${badge('MEDIUM', BG_YL)} + unit tests 1094 + ${badge('HIGH', BG_RD)} + property tests + threat note + static analysis 1095 + ${badge('CRITICAL', BG_MG)} + human signoff or formal verification${R} 1096 + `); 1097 + 1098 + const demoIU = iusV1[0]; 1099 + sub(`Evaluating ${demoIU.name} (${demoIU.risk_tier} tier)`); 1100 + console.log(` ${D}Required evidence: ${demoIU.evidence_policy.required.join(', ')}${R}\n`); 1101 + 1102 + // No evidence yet 1103 + const eval1 = evaluatePolicy(demoIU, []); 1104 + console.log(` ${RD}Before evidence:${R} verdict = ${badge(eval1.verdict, BG_RD)}`); 1105 + console.log(` ${D}missing: ${eval1.missing.join(', ')}${R}\n`); 1106 + 1107 + // Submit all passing evidence 1108 + const passingEvidence: EvidenceRecord[] = demoIU.evidence_policy.required.map(kind => ({ 1109 + evidence_id: 'ev-' + kind, kind: kind as EvidenceKind, 1110 + status: EvidenceStatus.PASS, iu_id: demoIU.iu_id, 1111 + canon_ids: demoIU.source_canon_ids, timestamp: new Date().toISOString(), 1112 + })); 1113 + 1114 + const eval2 = evaluatePolicy(demoIU, passingEvidence); 1115 + console.log(` ${GR}After all evidence passes:${R} verdict = ${badge(eval2.verdict, BG_GR)}`); 1116 + console.log(` ${D}satisfied: ${eval2.satisfied.join(', ')}${R}\n`); 1117 + 1118 + // Simulate failure 1119 + const failedEvidence = [...passingEvidence, { 1120 + evidence_id: 'ev-fail', kind: EvidenceKind.TYPECHECK, 1121 + status: EvidenceStatus.FAIL, iu_id: demoIU.iu_id, 1122 + canon_ids: [], message: 'TS2322: Type error in auth module', 1123 + timestamp: new Date(Date.now() + 1000).toISOString(), 1124 + }]; 1125 + 1126 + const eval3 = evaluatePolicy(demoIU, failedEvidence); 1127 + console.log(` ${RD}After typecheck fails:${R} verdict = ${badge(eval3.verdict, BG_RD)}`); 1128 + console.log(` ${D}failed: ${eval3.failed.join(', ')}${R}`); 1129 + 1130 + showJSON('PolicyEvaluation object', eval3); 1131 + 1132 + await wait(400); 1133 + 1134 + // ══════════════════════════════════════════════════════════════════ 1135 + // STEP 20: Cascading Failures (Phase D) 1136 + // ══════════════════════════════════════════════════════════════════ 1137 + 1138 + banner(20, 'Cascading Failures — Graph-Based Propagation'); 1139 + 1140 + console.log(` 1141 + ${D}When an IU's evidence fails, Phoenix propagates through the dependency 1142 + graph. The failed IU is ${B}BLOCKED${R}${D}. Its dependents must ${B}RE_VALIDATE${R}${D} 1143 + (re-run typecheck + boundary checks + tagged tests). 1144 + 1145 + This prevents a broken module from silently poisoning downstream code.${R} 1146 + `); 1147 + 1148 + // Create a scenario with dependencies 1149 + const cascadeIUs = [ 1150 + { ...demoIU, iu_id: 'auth-iu', name: 'AuthIU', dependencies: [] as string[] }, 1151 + { ...demoIU, iu_id: 'session-iu', name: 'SessionIU', dependencies: ['auth-iu'] }, 1152 + { ...demoIU, iu_id: 'api-iu', name: 'ApiIU', dependencies: ['session-iu'] }, 1153 + ]; 1154 + 1155 + console.log(` ${D}Dependency graph:${R} AuthIU ← SessionIU ← ApiIU\n`); 1156 + 1157 + const cascadeEvals = [{ ...eval3, iu_id: 'auth-iu', iu_name: 'AuthIU' }]; 1158 + const cascadeEvents = computeCascade(cascadeEvals, cascadeIUs); 1159 + 1160 + for (const event of cascadeEvents) { 1161 + console.log(` ${badge('CASCADE', BG_RD)} from ${B}${event.source_iu_name}${R} (${event.failure_kind})\n`); 1162 + for (const action of event.actions) { 1163 + const actionBg = action.action === 'BLOCK' ? BG_RD : BG_YL; 1164 + console.log(` ${badge(action.action, actionBg)} ${B}${action.iu_name}${R}`); 1165 + console.log(` ${D}${action.reason}${R}`); 1166 + } 1167 + } 1168 + console.log(''); 1169 + 1170 + await wait(400); 1171 + 1172 + // ══════════════════════════════════════════════════════════════════ 1173 + // STEP 21: Shadow Pipeline (Phase E) 1174 + // ══════════════════════════════════════════════════════════════════ 1175 + 1176 + banner(21, 'Shadow Pipeline — Safe Canonicalization Upgrades'); 1177 + 1178 + console.log(` 1179 + ${D}When upgrading the canonicalization pipeline (new model, new rules), 1180 + Phoenix runs ${B}both old and new pipelines in parallel${R}${D} and compares output. 1181 + 1182 + Classification: 1183 + ${badge('SAFE', BG_GR)} node change ≤3%, no risk escalations 1184 + ${badge('COMPACTION_EVENT', BG_YL)} node change ≤25%, no orphans 1185 + ${badge('REJECT', BG_RD)} orphan nodes, excessive churn, or high drift${R} 1186 + `); 1187 + 1188 + const oldP = { pipeline_id: 'v1.0', model_id: 'rule-based/1.0', promptpack_version: '1.0', extraction_rules_version: '1.0', diff_policy_version: '1.0' }; 1189 + const newP = { pipeline_id: 'v1.1', model_id: 'rule-based/1.1', promptpack_version: '1.1', extraction_rules_version: '1.1', diff_policy_version: '1.0' }; 1190 + 1191 + // Scenario 1: identical output → SAFE 1192 + sub('Scenario 1: Minor rule tweak, same output'); 1193 + const safe = runShadowPipeline(oldP, newP, canonV1, canonV1); 1194 + console.log(` ${badge(safe.classification, BG_GR)} ${safe.reason}`); 1195 + console.log(` ${D}node change: ${safe.metrics.node_change_pct}% orphans: ${safe.metrics.orphan_nodes}${R}\n`); 1196 + 1197 + // Scenario 2: v1 → v2 output → COMPACTION_EVENT 1198 + sub('Scenario 2: Major extraction rules upgrade'); 1199 + const canonV2ForShadow = extractCanonicalNodes(clausesV2); 1200 + const compact = runShadowPipeline(oldP, { ...newP, pipeline_id: 'v2.0' }, canonV1, canonV2ForShadow); 1201 + console.log(` ${badge(compact.classification, compact.classification === 'REJECT' ? BG_RD : compact.classification === 'SAFE' ? BG_GR : BG_YL)} ${compact.reason}`); 1202 + console.log(` ${D}node change: ${compact.metrics.node_change_pct}% drift: ${compact.metrics.semantic_stmt_drift}% orphans: ${compact.metrics.orphan_nodes}${R}`); 1203 + 1204 + showJSON('ShadowResult metrics', compact.metrics); 1205 + 1206 + await wait(400); 1207 + 1208 + // ══════════════════════════════════════════════════════════════════ 1209 + // STEP 22: Compaction (Phase E) 1210 + // ══════════════════════════════════════════════════════════════════ 1211 + 1212 + banner(22, 'Compaction — Storage Lifecycle'); 1213 + 1214 + console.log(` 1215 + ${D}Phoenix compacts old data into cold storage while ${B}never deleting${R}${D}: 1216 + • Node headers (identity preserved forever) 1217 + • Provenance edges (traceability preserved forever) 1218 + • Approvals & signatures (audit trail preserved forever) 1219 + 1220 + Storage tiers: ${badge('HOT', BG_GR)} (30 days) → ${badge('ANCESTRY', BG_YL)} (metadata forever) → ${badge('COLD', BG_BL)} (blobs archived)${R} 1221 + `); 1222 + 1223 + const compactObjects = [ 1224 + { object_id: '1', object_type: 'clause_body', age_days: 90, size_bytes: 50000, preserve: false }, 1225 + { object_id: '2', object_type: 'clause_body', age_days: 60, size_bytes: 30000, preserve: false }, 1226 + { object_id: '3', object_type: 'node_header', age_days: 90, size_bytes: 500, preserve: true }, 1227 + { object_id: '4', object_type: 'provenance_edge', age_days: 120, size_bytes: 200, preserve: true }, 1228 + { object_id: '5', object_type: 'approval', age_days: 180, size_bytes: 300, preserve: true }, 1229 + { object_id: '6', object_type: 'clause_body', age_days: 10, size_bytes: 20000, preserve: false }, 1230 + ]; 1231 + 1232 + const compactEvent = runCompaction(compactObjects, 'size_threshold', 30); 1233 + console.log(` ${badge('CompactionEvent', BG_MG)}\n`); 1234 + console.log(` Trigger: ${compactEvent.trigger}`); 1235 + console.log(` Compacted: ${compactEvent.nodes_compacted} objects (${(compactEvent.bytes_freed / 1024).toFixed(1)} KB freed)`); 1236 + console.log(` ${GR}Preserved:${R} ${compactEvent.preserved.node_headers} headers, ${compactEvent.preserved.provenance_edges} provenance, ${compactEvent.preserved.approvals} approvals, ${compactEvent.preserved.signatures} signatures`); 1237 + console.log(''); 1238 + 1239 + await wait(400); 1240 + 1241 + // ══════════════════════════════════════════════════════════════════ 1242 + // STEP 23: Bot Interface (Phase F) 1243 + // ══════════════════════════════════════════════════════════════════ 1244 + 1245 + banner(23, 'Freeq Bot Interface — Structured Commands'); 1246 + 1247 + console.log(` 1248 + ${D}Bots interact with Phoenix using a strict command grammar. 1249 + No fuzzy NLU — commands are deterministic and parseable. 1250 + 1251 + Three bots: 1252 + ${CY}SpecBot${R}${D} — ingest, diff, clauses 1253 + ${CY}ImplBot${R}${D} — plan, regen, drift 1254 + ${CY}PolicyBot${R}${D} — status, evidence, cascade 1255 + 1256 + Mutating commands require ${B}confirmation${R}${D}. 1257 + Read-only commands execute immediately.${R} 1258 + `); 1259 + 1260 + const botExamples = [ 1261 + 'SpecBot: ingest spec/auth.md', 1262 + 'ImplBot: regen iu=AuthIU', 1263 + 'PolicyBot: status', 1264 + 'SpecBot: help', 1265 + ]; 1266 + 1267 + for (const raw of botExamples) { 1268 + console.log(` ${BG_BL}${WH}${B} > ${raw} ${R}\n`); 1269 + const parsed = parseCommand(raw); 1270 + if ('error' in parsed) { 1271 + console.log(` ${RD}Error: ${parsed.error}${R}\n`); 1272 + continue; 1273 + } 1274 + const resp = routeCommand(parsed); 1275 + if (resp.mutating) { 1276 + console.log(` ${YL}⚠ Mutating command — confirmation required${R}`); 1277 + console.log(` ${D}Intent:${R} ${resp.intent}`); 1278 + console.log(` ${D}Confirm:${R} ${GR}ok${R} or ${GR}phx confirm ${resp.confirm_id}${R}\n`); 1279 + } else { 1280 + console.log(` ${GR}✓ Read-only — executing immediately${R}`); 1281 + console.log(` ${D}${resp.message}${R}\n`); 1282 + } 1283 + } 1284 + 1285 + await wait(400); 1286 + 1287 + banner(0, 'Recap — What You Just Saw'); 1288 + 1289 + console.log(` 1290 + ${B}The Full Pipeline (Phases A → F):${R} 1291 + 1292 + ${CY}spec/auth.md${R} ${D}← your spec file${R} 1293 + 1294 + ▼ ${D}A: parse + normalize + hash${R} 1295 + ${CY}Clauses + Hashes${R} ${D}← content-addressed atoms${R} 1296 + 1297 + ▼ ${D}B: canonicalize + classify changes${R} 1298 + ${CY}Canonical Graph + A/B/C/D${R} ${D}← requirements + change classes${R} 1299 + 1300 + ▼ ${D}C1: plan IUs + generate + manifest${R} 1301 + ${CY}IUs + Generated Code${R} ${D}← compilation boundaries${R} 1302 + 1303 + ├──▸ ${D}C1: drift detection${R} ${CY}CLEAN / DRIFTED / WAIVED${R} 1304 + ├──▸ ${D}C2: boundary validation${R} ${CY}Diagnostics${R} 1305 + ├──▸ ${D}D: evidence + policy eval${R} ${CY}PASS / FAIL / INCOMPLETE${R} 1306 + ├──▸ ${D}D: cascade on failure${R} ${CY}BLOCK + RE_VALIDATE${R} 1307 + ├──▸ ${D}E: shadow pipeline upgrade${R} ${CY}SAFE / COMPACTION / REJECT${R} 1308 + ├──▸ ${D}E: compaction${R} ${CY}Hot → Ancestry → Cold${R} 1309 + 1310 + 1311 + ${CY}Trust Dashboard${R} ${D}← phoenix status${R} 1312 + 1313 + ▼ ${D}F: bot interface${R} 1314 + ${CY}SpecBot / ImplBot / PolicyBot${R} ${D}← structured commands${R} 1315 + 1316 + ${B}Key insight:${R} Change ${YL}"bcrypt"${R} to ${YL}"argon2id"${R} on line 10 and Phoenix 1317 + traces impact through clauses → canonical nodes → IUs → generated 1318 + files → boundary policies → evidence → dependent IUs. Only the 1319 + affected subtree is invalidated and regenerated. 1320 + 1321 + ${D}That's selective invalidation — the defining capability. 1322 + Not "rebuild everything." Just the dependent subtree.${R} 1323 + 1324 + ${B}${CY}Trust > Cleverness.${R} 1325 + `); 1326 + } 1327 + 1328 + main().catch(console.error);
+72
docs/ARCHITECTURE.md
··· 1 + # Phoenix VCS — System Architecture 2 + 3 + ## Overview 4 + 5 + Phoenix is a causal compiler for intent. It transforms spec documents through a deterministic pipeline into generated code, with full provenance tracking and selective invalidation. 6 + 7 + ## System Layers 8 + 9 + ``` 10 + ┌─────────────────────────────────────────────────┐ 11 + │ CLI / Bot Interface │ 12 + ├─────────────────────────────────────────────────┤ 13 + │ Policy & Evidence Engine │ 14 + ├─────────────────────────────────────────────────┤ 15 + │ Regeneration Engine │ 16 + ├─────────────────────────────────────────────────┤ 17 + │ Implementation Graph (IU Manager) │ 18 + ├─────────────────────────────────────────────────┤ 19 + │ Canonicalization Pipeline │ 20 + ├─────────────────────────────────────────────────┤ 21 + │ Spec Ingestion (Clause Extraction) │ 22 + ├─────────────────────────────────────────────────┤ 23 + │ Content-Addressed Store │ 24 + │ (Graph DB + Blob Storage) │ 25 + └─────────────────────────────────────────────────┘ 26 + ``` 27 + 28 + ## Five Core Graphs 29 + 30 + 1. **Spec Graph** — Clauses extracted from spec documents 31 + 2. **Canonical Graph** — Requirements, Constraints, Invariants, Definitions 32 + 3. **Implementation Graph** — Implementation Units (IUs) with contracts & boundaries 33 + 4. **Evidence Graph** — Tests, analysis results, reviews bound to nodes 34 + 5. **Provenance Graph** — All transformation edges connecting the above 35 + 36 + ## Content Addressing 37 + 38 + All nodes use content-based IDs: 39 + - `clause:{sha256(normalized_text + source_doc_id + section_path)}` 40 + - `canon:{sha256(canonical_statement + type + linked_clauses)}` 41 + - `iu:{sha256(kind + contract + boundary_policy)}` 42 + 43 + ## Directory Structure 44 + 45 + ``` 46 + .phoenix/ # Phoenix metadata root 47 + store/ # Content-addressed store 48 + objects/ # All graph nodes (JSON) 49 + refs/ # Named references 50 + graphs/ 51 + spec.json # Spec graph index 52 + canonical.json # Canonical graph index 53 + implementation.json # IU graph index 54 + evidence.json # Evidence graph index 55 + provenance.json # Provenance edges 56 + manifests/ 57 + generated_manifest.json # Generated file hashes 58 + state.json # System state (BOOTSTRAP_COLD, WARMING, STEADY_STATE) 59 + config.json # Pipeline configuration 60 + ``` 61 + 62 + ## Build Phases 63 + 64 + | Phase | Components | Dependencies | 65 + |-------|-----------|-------------| 66 + | A | Clause extraction, clause_semhash | None | 67 + | B | Canonicalization, warm hashing, classifier | A | 68 + | C1 | IU module-level, regen, manifest | B | 69 + | C2 | Boundary validator, UnitBoundaryChange | C1 | 70 + | D | Evidence, policy, cascade | C2 | 71 + | E | Shadow pipeline, compaction | D | 72 + | F | Freeq bots | All |
+124
docs/AUDIT.md
··· 1 + # Phoenix VCS — Project Audit Report 2 + 3 + **Date:** 2026-02-17 4 + **Scope:** Phases A, B, C1, C2 5 + **Lines of code:** ~2,450 source, ~1,800 test (4,258 total) 6 + **Tests:** 142 passing across 17 test files (14 unit + 3 functional) 7 + 8 + --- 9 + 10 + ## ✅ What's Working Well 11 + 12 + 1. **Clean architecture** — Models, logic, and storage are well-separated. Models are pure types, logic is pure functions (easy to test), stores handle persistence. 13 + 14 + 2. **Content-addressed design** — Every object (clause, canonical node, IU) is identified by a hash of its content. This is sound and will scale well. 15 + 16 + 3. **Test coverage** — Every module has unit tests. Three functional tests validate end-to-end pipelines. All 142 pass in ~110ms. 17 + 18 + 4. **TypeScript strict mode** — `strict: true` enabled, compiles cleanly with no suppressions in source code. 19 + 20 + 5. **Provenance chain** — The traceability from spec lines → clauses → canonical nodes → IUs → generated files → boundary validation is fully connected. 21 + 22 + --- 23 + 24 + ## 🔧 Issues Fixed During Audit 25 + 26 + | # | Issue | Severity | Fix | 27 + |---|-------|----------|-----| 28 + | 1 | **Duplicate boundary diagnostics** — When a package was both forbidden and not in the allowlist, two diagnostics were emitted for the same import. | Medium | Changed to `else if` so forbidden check takes priority. | 29 + | 2 | **Dead code in classifier** — The D-class branch inside the canon-impact block was unreachable (confidence was always ≥ 0.7, threshold was < 0.6). | Low | Removed dead branch. | 30 + | 3 | **`as any` in tests** — Two test lines used `{} as any` for signal objects. | Low | Replaced with properly typed empty signal objects. | 31 + 32 + --- 33 + 34 + ## ⚠️ Issues to Address (Not Yet Fixed) 35 + 36 + ### High Priority 37 + 38 + **H1. No provenance graph persistence** 39 + The PRD specifies a Provenance Graph (Section 2) that records all transformation edges. Currently, provenance is implicit (canonical nodes have `source_clause_ids`, IUs have `source_canon_ids`), but there's no unified provenance store. Every transformation should emit a provenance edge to a dedicated graph. 40 + 41 + **H2. Normalizer doesn't handle code blocks** 42 + Fenced code blocks (` ``` `) are currently processed like regular text — headings and list items inside code blocks get mangled. The parser should skip code block contents during normalization. 43 + 44 + **H3. No pre-heading content handling** 45 + If a spec file has content before the first heading (e.g., a preamble), it's silently discarded by the parser. Only heading-bounded sections are captured. The PRD doesn't explicitly address this, but losing content is wrong. 46 + 47 + **H4. Classifier D-class is hard to trigger** 48 + The current classification logic produces D (uncertain) only when `norm_diff > 0.7 || term_ref_delta > 0.7` AND no canonical impact AND no context shift. This is a very narrow window. The D-rate mechanism needs real exercise. 49 + 50 + ### Medium Priority 51 + 52 + **M1. IU planner grouping is greedy** 53 + `clusterNodes()` uses BFS to group all transitively connected nodes. In a large spec, this could collapse too many unrelated requirements into a single giant IU because of loose term overlap chains (A links to B links to C...). Should add a max-cluster-size or minimum-link-weight threshold. 54 + 55 + **M2. Regeneration is stub-only** 56 + The regen engine only produces function stubs. This is expected for v1, but the stub quality is minimal — no imports, no types, no contract enforcement in the generated code. The stubs should at least generate TypeScript interfaces from the IU contract. 57 + 58 + **M3. Manifest doesn't track deleted files** 59 + If a file is removed from `output_files` between regenerations, the old manifest entry persists. Need a reconciliation step that detects orphaned manifest entries. 60 + 61 + **M4. Content store has no garbage collection** 62 + Objects are never deleted. After multiple ingestions, stale clause objects accumulate. Need either reference counting or mark-and-sweep relative to the current graph indices. 63 + 64 + **M5. Side channel detection is shallow** 65 + The dep-extractor uses regex patterns. It misses indirect patterns like `const { env } = process; env.SECRET`, dynamic imports, and aliased require calls. Acceptable for v1 but should move to AST-based extraction. 66 + 67 + **M6. Spec parser doesn't handle ATX heading edge cases** 68 + Lines like `# ` (heading marker with no text), `##text` (no space), or setext-style headings (`Title\n====`) are not handled. 69 + 70 + ### Low Priority 71 + 72 + **L1. No .gitignore** 73 + The project is missing a `.gitignore` for `node_modules/`, `dist/`, and temp `.phoenix/` directories. 74 + 75 + **L2. Demo creates temp directories without cleanup** 76 + `mkdtempSync` in the demo creates temp dirs that are never cleaned up. 77 + 78 + **L3. Store uses synchronous fs operations** 79 + All file I/O is synchronous (`readFileSync`, `writeFileSync`). Fine for a CLI tool, but should be async if this becomes a long-running server. 80 + 81 + **L4. No input validation on store operations** 82 + `ContentStore.put()` and `SpecStore.ingestDocument()` don't validate inputs. A non-hex ID or missing file would produce cryptic errors. 83 + 84 + **L5. Warm hasher performance** 85 + `computeWarmHashes` iterates all canonical nodes for every clause (O(clauses × nodes)). Should build an index of clause→nodes first. 86 + 87 + --- 88 + 89 + ## 📊 Coverage Gaps 90 + 91 + | Component | Unit Tests | Functional Tests | Gap | 92 + |-----------|-----------|-----------------|-----| 93 + | Normalizer | ✅ 12 | — | Missing: code blocks, nested markdown | 94 + | Spec Parser | ✅ 11 | ✅ via ingestion | Missing: setext headings, pre-heading content | 95 + | Semhash | ✅ 9 | — | — | 96 + | Diff | ✅ 7 | ✅ via ingestion | Missing: large-scale diff (100+ clauses) | 97 + | Canonicalizer | ✅ 13 | ✅ via canonicalization | — | 98 + | Warm Hasher | ✅ 5 | ✅ via canonicalization | — | 99 + | Classifier | ✅ 7 | ✅ via canonicalization | Missing: D-class exercise | 100 + | D-Rate | ✅ 9 | ✅ via canonicalization | — | 101 + | Bootstrap | ✅ 10 | ✅ via canonicalization | — | 102 + | IU Planner | ✅ 7 | ✅ via IU pipeline | Missing: large spec with many clusters | 103 + | Regen | ✅ 6 | ✅ via IU pipeline | — | 104 + | Manifest | — | ✅ via IU pipeline | Missing: dedicated unit tests for ManifestManager | 105 + | Drift | ✅ 5 | ✅ via IU pipeline | — | 106 + | Dep Extractor | ✅ 10 | ✅ via IU pipeline | — | 107 + | Boundary Validator | ✅ 12 | ✅ via IU pipeline | — | 108 + | Content Store | — | ✅ via ingestion | Missing: dedicated unit tests | 109 + | Spec Store | — | ✅ via ingestion | Missing: dedicated unit tests | 110 + | Canonical Store | — | ✅ via canonicalization | Missing: dedicated unit tests | 111 + 112 + --- 113 + 114 + ## 🏗️ Recommendations for Phase D+ 115 + 116 + 1. **Build a Provenance Store** before Evidence/Policy (Phase D) — the evidence engine needs provenance edges to bind evidence to the right graph nodes. 117 + 118 + 2. **Add a CLI entry point** (`phoenix bootstrap`, `phoenix status`, `phoenix ingest`) — the core logic is all functions/classes but there's no user-facing command. 119 + 120 + 3. **Add integration tests with the real PRD.md** — run the full A→C2 pipeline against the Phoenix PRD itself as a dogfood test. 121 + 122 + 4. **Consider property-based testing** for the normalizer and diff engine — these are the foundation and need to be bulletproof. 123 + 124 + 5. **Add structured logging** — every transformation should emit a structured log event that can reconstruct the provenance graph.
+543
docs/MANUAL.md
··· 1 + # Phoenix VCS — Complete Manual 2 + 3 + **Version 0.1.0 (Alpha)** 4 + 5 + Phoenix is a regenerative version control system. It compiles intent — expressed as Markdown specs — into a content-addressed graph of requirements, generated code, and provenance. Every transformation is traceable. Changing one spec line invalidates only the dependent subtree, not the entire repository. 6 + 7 + --- 8 + 9 + ## Table of Contents 10 + 11 + 1. [Quick Start](#1-quick-start) 12 + 2. [Core Concepts](#2-core-concepts) 13 + 3. [The Five Graphs](#3-the-five-graphs) 14 + 4. [Phase A — Spec Ingestion](#4-phase-a--spec-ingestion) 15 + 5. [Phase B — Canonicalization](#5-phase-b--canonicalization) 16 + 6. [Phase C — Implementation Units](#6-phase-c--implementation-units) 17 + 7. [Phase D — Evidence & Policy](#7-phase-d--evidence--policy) 18 + 8. [Phase E — Shadow Pipeline & Compaction](#8-phase-e--shadow-pipeline--compaction) 19 + 9. [Phase F — Bot Interface](#9-phase-f--bot-interface) 20 + 10. [API Reference](#10-api-reference) 21 + 11. [Configuration](#11-configuration) 22 + 12. [Troubleshooting](#12-troubleshooting) 23 + 24 + --- 25 + 26 + ## 1. Quick Start 27 + 28 + ```bash 29 + # Install 30 + npm install phoenix-vcs 31 + 32 + # Run the interactive demo 33 + npx tsx demo.ts 34 + 35 + # Run tests 36 + npm test 37 + ``` 38 + 39 + ### Minimal Example 40 + 41 + ```typescript 42 + import { 43 + parseSpec, extractCanonicalNodes, planIUs, 44 + generateIU, diffClauses, classifyChanges, 45 + } from 'phoenix-vcs'; 46 + 47 + // Parse a spec 48 + const clauses = parseSpec(specContent, 'spec/auth.md'); 49 + 50 + // Canonicalize 51 + const canonNodes = extractCanonicalNodes(clauses); 52 + 53 + // Plan Implementation Units 54 + const ius = planIUs(canonNodes, clauses); 55 + 56 + // Generate code 57 + const result = generateIU(ius[0]); 58 + // result.files → Map<path, content> 59 + // result.manifest → IUManifest with content hashes 60 + ``` 61 + 62 + --- 63 + 64 + ## 2. Core Concepts 65 + 66 + ### Content Addressing 67 + 68 + Every object in Phoenix (clause, canonical node, IU, evidence record) is identified by a SHA-256 hash of its content. If the content changes, the ID changes. If it doesn't, the ID is stable. This gives us: 69 + 70 + - **Deduplication** — identical content is stored once 71 + - **Integrity** — any tampering changes the hash 72 + - **Determinism** — same input always produces same output 73 + 74 + ### Selective Invalidation 75 + 76 + Phoenix's defining capability. When a spec changes, Phoenix traces the impact through: 77 + 78 + ``` 79 + Spec line → Clause → Canonical Nodes → IUs → Generated Files → Evidence → Dependents 80 + ``` 81 + 82 + Only the affected subtree is invalidated and regenerated. Everything else stays untouched. 83 + 84 + ### Trust Surface 85 + 86 + `phoenix status` is the primary UX. If it's trustworthy, Phoenix works. If it's noisy or wrong, the system dies. Every design decision optimizes for status being **explainable, conservative, and correct-enough to rely on.** 87 + 88 + ### Bootstrap States 89 + 90 + Phoenix tracks system maturity through three states: 91 + 92 + | State | Meaning | D-Rate Alarms | Severity | 93 + |-------|---------|--------------|----------| 94 + | `BOOTSTRAP_COLD` | First parse, no canonical graph | Suppressed | N/A | 95 + | `BOOTSTRAP_WARMING` | Canonical graph exists, stabilizing | Active | Downgraded | 96 + | `STEADY_STATE` | D-rate acceptable, system trusted | Active | Normal | 97 + 98 + --- 99 + 100 + ## 3. The Five Graphs 101 + 102 + Phoenix maintains five interconnected, content-addressed graphs: 103 + 104 + ### 3.1 Spec Graph 105 + 106 + Clauses extracted from Markdown spec documents. Each clause is a heading + body section. 107 + 108 + ``` 109 + clause_id → { source_doc_id, source_line_range, raw_text, normalized_text, 110 + section_path, clause_semhash, context_semhash_cold } 111 + ``` 112 + 113 + ### 3.2 Canonical Graph 114 + 115 + Structured requirements extracted from clauses: Requirements, Constraints, Invariants, Definitions. 116 + 117 + ``` 118 + canon_id → { type, statement, source_clause_ids, linked_canon_ids, tags } 119 + ``` 120 + 121 + ### 3.3 Implementation Graph 122 + 123 + Implementation Units — stable compilation boundaries mapping requirements to generated code. 124 + 125 + ``` 126 + iu_id → { kind, name, risk_tier, contract, source_canon_ids, dependencies, 127 + boundary_policy, evidence_policy, output_files } 128 + ``` 129 + 130 + ### 3.4 Evidence Graph 131 + 132 + Proof that generated code meets its risk-tier requirements: test results, analysis reports, human signoffs. 133 + 134 + ``` 135 + evidence_id → { kind, status, iu_id, canon_ids, artifact_hash, message } 136 + ``` 137 + 138 + ### 3.5 Provenance Graph 139 + 140 + All transformation edges connecting the above. Every extraction, generation, and validation produces a provenance edge that records what was done, when, by what tool, with what input. 141 + 142 + --- 143 + 144 + ## 4. Phase A — Spec Ingestion 145 + 146 + ### Clause Extraction 147 + 148 + Phoenix splits Markdown documents on heading boundaries. Each heading + its body = one clause. 149 + 150 + ```typescript 151 + import { parseSpec } from 'phoenix-vcs'; 152 + 153 + const clauses = parseSpec(markdownContent, 'spec/auth.md'); 154 + // Returns: Clause[] 155 + ``` 156 + 157 + **Section paths** track heading hierarchy: `["Authentication Service", "API Endpoints", "POST /auth/login"]` 158 + 159 + **Pre-heading content** is captured as a `(preamble)` clause. 160 + 161 + ### Normalization 162 + 163 + Before hashing, text is normalized to eliminate formatting noise: 164 + 165 + - Heading markers (`##`) removed 166 + - Bold/italic/code markers removed 167 + - Lowercased 168 + - Whitespace collapsed 169 + - **List items sorted alphabetically** — reordering bullets doesn't change the hash 170 + - Code blocks replaced with `(code block)` placeholder 171 + 172 + ### Semantic Hashing 173 + 174 + Two hashes per clause: 175 + 176 + | Hash | Formula | Purpose | 177 + |------|---------|---------| 178 + | `clause_semhash` | `SHA-256(normalized_text)` | Pure content identity | 179 + | `context_semhash_cold` | `SHA-256(text + section_path + neighbor_hashes)` | Structural awareness | 180 + 181 + ### Diffing 182 + 183 + ```typescript 184 + import { diffClauses } from 'phoenix-vcs'; 185 + 186 + const diffs = diffClauses(oldClauses, newClauses); 187 + // Each diff: { diff_type: UNCHANGED|MODIFIED|ADDED|REMOVED|MOVED, ... } 188 + ``` 189 + 190 + ### Persistence 191 + 192 + ```typescript 193 + import { SpecStore } from 'phoenix-vcs'; 194 + 195 + const store = new SpecStore('.phoenix'); 196 + const result = store.ingestDocument('spec/auth.md', projectRoot); 197 + const clauses = store.getClauses('spec/auth.md'); 198 + ``` 199 + 200 + --- 201 + 202 + ## 5. Phase B — Canonicalization 203 + 204 + ### Canonical Node Extraction 205 + 206 + Phoenix scans each clause for semantic patterns: 207 + 208 + | Pattern | Type | 209 + |---------|------| 210 + | "must", "shall", "required" | REQUIREMENT | 211 + | "must not", "forbidden", "limited to" | CONSTRAINT | 212 + | "always", "never" | INVARIANT | 213 + | ": ", "is defined as" | DEFINITION | 214 + 215 + Heading context also applies: lines under "## Security Constraints" are classified as constraints. 216 + 217 + ```typescript 218 + import { extractCanonicalNodes } from 'phoenix-vcs'; 219 + 220 + const canonNodes = extractCanonicalNodes(clauses); 221 + ``` 222 + 223 + ### Term-Based Linking 224 + 225 + Nodes sharing ≥2 significant terms are automatically linked, forming a requirements graph. 226 + 227 + ### Warm Context Hashing 228 + 229 + After canonicalization, `context_semhash_warm` incorporates canonical graph context: 230 + 231 + ```typescript 232 + import { computeWarmHashes } from 'phoenix-vcs'; 233 + 234 + const warmHashes = computeWarmHashes(clauses, canonNodes); 235 + // Map<clause_id, warm_hash> 236 + ``` 237 + 238 + ### A/B/C/D Change Classification 239 + 240 + Every clause diff is classified using multiple signals: 241 + 242 + | Class | Meaning | Signals | 243 + |-------|---------|---------| 244 + | **A** | Trivial | No semhash change, formatting only | 245 + | **B** | Local Semantic | Content changed, limited blast radius | 246 + | **C** | Contextual Shift | Canonical graph affected, structural context shifted | 247 + | **D** | Uncertain | Classifier can't decide — needs human review | 248 + 249 + ```typescript 250 + import { classifyChanges } from 'phoenix-vcs'; 251 + 252 + const classifications = classifyChanges(diffs, canonBefore, canonAfter, warmBefore, warmAfter); 253 + ``` 254 + 255 + ### D-Rate Tracking 256 + 257 + The rate of D-class (uncertain) classifications is tracked over a rolling window: 258 + 259 + | Level | Rate | Action | 260 + |-------|------|--------| 261 + | TARGET | ≤5% | Normal operation | 262 + | ACCEPTABLE | ≤10% | Monitor | 263 + | WARNING | ≤15% | Tune classifier | 264 + | ALARM | >15% | Override friction increases | 265 + 266 + --- 267 + 268 + ## 6. Phase C — Implementation Units 269 + 270 + ### IU Planning 271 + 272 + ```typescript 273 + import { planIUs } from 'phoenix-vcs'; 274 + 275 + const ius = planIUs(canonNodes, clauses); 276 + ``` 277 + 278 + Groups canonical nodes into module-level IUs by: 279 + - Shared source clauses 280 + - Term-based linking 281 + - Transitive graph connectivity 282 + 283 + Each IU gets a risk tier, contract, boundary policy, and evidence policy. 284 + 285 + ### Code Generation 286 + 287 + ```typescript 288 + import { generateIU } from 'phoenix-vcs'; 289 + 290 + const result = generateIU(iu); 291 + // result.files: Map<path, content> 292 + // result.manifest: IUManifest 293 + ``` 294 + 295 + ### Drift Detection 296 + 297 + ```typescript 298 + import { detectDrift } from 'phoenix-vcs'; 299 + 300 + const report = detectDrift(manifest, projectRoot, waivers); 301 + // report.entries: DriftEntry[] with CLEAN|DRIFTED|WAIVED|MISSING status 302 + ``` 303 + 304 + Manual edits must be labeled: 305 + - `promote_to_requirement` — edit becomes a new spec clause 306 + - `waiver` — signed exception 307 + - `temporary_patch` — expires on a date 308 + 309 + ### Boundary Validation 310 + 311 + Each IU declares what it's allowed to touch: 312 + 313 + ```yaml 314 + boundary_policy: 315 + code: 316 + allowed_packages: [express, bcrypt] 317 + forbidden_packages: [axios] 318 + forbidden_paths: [./internal/**] 319 + side_channels: 320 + config: [DATABASE_URL] 321 + databases: [] 322 + external_apis: [] 323 + ``` 324 + 325 + ```typescript 326 + import { extractDependencies, validateBoundary } from 'phoenix-vcs'; 327 + 328 + const depGraph = extractDependencies(sourceCode, filePath); 329 + const diagnostics = validateBoundary(depGraph, iu); 330 + ``` 331 + 332 + --- 333 + 334 + ## 7. Phase D — Evidence & Policy 335 + 336 + ### Policy Evaluation 337 + 338 + ```typescript 339 + import { evaluatePolicy } from 'phoenix-vcs'; 340 + 341 + const evaluation = evaluatePolicy(iu, evidenceRecords); 342 + // evaluation.verdict: 'PASS' | 'FAIL' | 'INCOMPLETE' 343 + ``` 344 + 345 + ### Evidence Kinds 346 + 347 + | Kind | Risk Tiers | 348 + |------|-----------| 349 + | typecheck | All | 350 + | lint | All | 351 + | boundary_validation | All | 352 + | unit_tests | Medium+ | 353 + | property_tests | High+ | 354 + | static_analysis | High+ | 355 + | threat_note | High+ | 356 + | human_signoff | Critical | 357 + 358 + ### Cascading Failures 359 + 360 + ```typescript 361 + import { computeCascade } from 'phoenix-vcs'; 362 + 363 + const events = computeCascade(evaluations, ius); 364 + // Produces BLOCK actions on failed IUs 365 + // Produces RE_VALIDATE actions on dependents 366 + ``` 367 + 368 + --- 369 + 370 + ## 8. Phase E — Shadow Pipeline & Compaction 371 + 372 + ### Shadow Pipeline 373 + 374 + Safe canonicalization upgrades by running old and new pipelines in parallel: 375 + 376 + ```typescript 377 + import { runShadowPipeline } from 'phoenix-vcs'; 378 + 379 + const result = runShadowPipeline(oldConfig, newConfig, oldNodes, newNodes); 380 + // result.classification: SAFE | COMPACTION_EVENT | REJECT 381 + // result.metrics: { node_change_pct, orphan_nodes, risk_escalations, ... } 382 + ``` 383 + 384 + ### Compaction 385 + 386 + ```typescript 387 + import { runCompaction, shouldTriggerCompaction } from 'phoenix-vcs'; 388 + 389 + const { trigger, reason } = shouldTriggerCompaction(storageStats); 390 + if (trigger) { 391 + const event = runCompaction(objects, reason); 392 + // event.preserved: { node_headers, provenance_edges, approvals, signatures } 393 + } 394 + ``` 395 + 396 + **Never deleted:** node headers, provenance edges, approvals, signatures. 397 + 398 + --- 399 + 400 + ## 9. Phase F — Bot Interface 401 + 402 + ### Command Grammar 403 + 404 + ``` 405 + BotName: action [key=value ...] 406 + ``` 407 + 408 + ### Available Commands 409 + 410 + | Bot | Command | Mutating | Description | 411 + |-----|---------|----------|-------------| 412 + | SpecBot | ingest | ✓ | Ingest a spec document | 413 + | SpecBot | diff | | Show clause diff | 414 + | SpecBot | clauses | | List clauses | 415 + | ImplBot | plan | ✓ | Plan IUs from canonical graph | 416 + | ImplBot | regen | ✓ | Regenerate code for an IU | 417 + | ImplBot | drift | | Check drift status | 418 + | PolicyBot | status | | Show trust dashboard | 419 + | PolicyBot | evidence | | Show evidence for an IU | 420 + | PolicyBot | cascade | | Show cascade effects | 421 + | PolicyBot | evaluate | | Evaluate policy for an IU | 422 + 423 + ### Confirmation Model 424 + 425 + Mutating commands require confirmation: 426 + 427 + ``` 428 + > SpecBot: ingest spec/auth.md 429 + 430 + SpecBot wants to: Ingest spec document: spec/auth.md 431 + Reply 'ok' or 'phx confirm a1b2c3d4e5f6' to proceed. 432 + 433 + > ok 434 + ``` 435 + 436 + Read-only commands execute immediately. 437 + 438 + ```typescript 439 + import { parseCommand, routeCommand } from 'phoenix-vcs'; 440 + 441 + const cmd = parseCommand('PolicyBot: status'); 442 + const response = routeCommand(cmd); 443 + ``` 444 + 445 + --- 446 + 447 + ## 10. API Reference 448 + 449 + ### Core Functions 450 + 451 + | Function | Input | Output | 452 + |----------|-------|--------| 453 + | `parseSpec(content, docId)` | Markdown string | `Clause[]` | 454 + | `normalizeText(raw)` | Raw text | Normalized string | 455 + | `diffClauses(before, after)` | Two clause arrays | `ClauseDiff[]` | 456 + | `extractCanonicalNodes(clauses)` | Clause array | `CanonicalNode[]` | 457 + | `computeWarmHashes(clauses, nodes)` | Clauses + canon | `Map<string, string>` | 458 + | `classifyChanges(diffs, ...)` | Diffs + context | `ChangeClassification[]` | 459 + | `planIUs(nodes, clauses)` | Canon + clauses | `ImplementationUnit[]` | 460 + | `generateIU(iu)` | Single IU | `RegenResult` | 461 + | `detectDrift(manifest, root)` | Manifest + path | `DriftReport` | 462 + | `extractDependencies(source, path)` | Source code | `DependencyGraph` | 463 + | `validateBoundary(graph, iu)` | Deps + IU | `Diagnostic[]` | 464 + | `evaluatePolicy(iu, evidence)` | IU + records | `PolicyEvaluation` | 465 + | `computeCascade(evals, ius)` | Evals + IUs | `CascadeEvent[]` | 466 + | `runShadowPipeline(old, new, ...)` | Configs + nodes | `ShadowResult` | 467 + | `runCompaction(objects, trigger)` | Objects + trigger | `CompactionEvent` | 468 + | `parseCommand(raw)` | String | `BotCommand` | 469 + | `routeCommand(cmd)` | BotCommand | `BotResponse` | 470 + 471 + ### Stores 472 + 473 + | Store | Purpose | 474 + |-------|---------| 475 + | `ContentStore` | Content-addressed object storage | 476 + | `SpecStore` | Spec graph persistence | 477 + | `CanonicalStore` | Canonical graph persistence | 478 + | `EvidenceStore` | Evidence record persistence | 479 + | `ManifestManager` | Generated file manifest | 480 + 481 + --- 482 + 483 + ## 11. Configuration 484 + 485 + ### `.phoenix/` Directory Structure 486 + 487 + ``` 488 + .phoenix/ 489 + store/objects/ # Content-addressed objects 490 + graphs/ 491 + spec.json # Spec graph index 492 + canonical.json # Canonical graph index 493 + evidence.json # Evidence records 494 + manifests/ 495 + generated_manifest.json 496 + state.json # Bootstrap state 497 + ``` 498 + 499 + ### Risk Tier Evidence Requirements 500 + 501 + | Tier | Required Evidence | 502 + |------|------------------| 503 + | Low | typecheck, lint, boundary_validation | 504 + | Medium | + unit_tests | 505 + | High | + property_tests, static_analysis, threat_note | 506 + | Critical | + human_signoff | 507 + 508 + --- 509 + 510 + ## 12. Troubleshooting 511 + 512 + ### "DRIFT DETECTED" on phoenix status 513 + 514 + A generated file was modified directly. Options: 515 + 1. **Revert** the manual edit and let Phoenix regenerate 516 + 2. **Promote** the edit to a requirement: `promote_to_requirement` 517 + 3. **Waive** with justification: `waiver` (requires signing) 518 + 4. **Patch** temporarily: `temporary_patch` (set expiry date) 519 + 520 + ### High D-Rate (>15%) 521 + 522 + The classifier is uncertain about too many changes. Options: 523 + 1. Write more specific spec language (avoid ambiguity) 524 + 2. Review D-class changes and provide feedback 525 + 3. Check if a canonicalization pipeline upgrade is needed 526 + 527 + ### Boundary Violation Errors 528 + 529 + An IU's code imports something not allowed by its boundary policy. Options: 530 + 1. Remove the import 531 + 2. Update the boundary policy to allow it (and document why) 532 + 3. Move the functionality to an IU that's allowed to use that dependency 533 + 534 + ### Cascade BLOCK 535 + 536 + An upstream IU failed evidence. Options: 537 + 1. Fix the failing evidence (e.g., fix the type error) 538 + 2. Check if the evidence is stale and re-run 539 + 3. All downstream IUs are blocked until the upstream is fixed 540 + 541 + --- 542 + 543 + *Phoenix VCS — Trust > Cleverness*
+104
docs/PHASE_A.md
··· 1 + # Phase A — Clause Extraction & Semantic Hashing 2 + 3 + ## Overview 4 + 5 + Phase A is the foundation layer. It parses spec documents (Markdown) into discrete clauses and computes semantic hashes for change detection. 6 + 7 + ## Components 8 + 9 + ### 1. Spec Parser (`src/spec-parser.ts`) 10 + 11 + Parses Markdown spec documents into structured clauses. 12 + 13 + **Input:** Markdown file content + document ID 14 + **Output:** Array of `Clause` objects 15 + 16 + **Parsing Rules:** 17 + - Split on heading boundaries (any level: #, ##, ###, etc.) 18 + - Each heading + its body content = one clause 19 + - Track section hierarchy (e.g., `["1. Adoption Scope", "v1 Scope"]`) 20 + - Record source line ranges 21 + - Preserve raw text, compute normalized text 22 + 23 + **Normalization:** 24 + - Lowercase 25 + - Collapse whitespace (multiple spaces/tabs → single space) 26 + - Strip leading/trailing whitespace per line 27 + - Remove markdown formatting characters (**, *, `, #) 28 + - Remove empty lines 29 + - Sort list items within a list block (for order-invariant hashing) 30 + 31 + ### 2. Clause Model (`src/models/clause.ts`) 32 + 33 + ```typescript 34 + interface Clause { 35 + clause_id: string; // content-addressed hash 36 + source_doc_id: string; // document identifier 37 + source_line_range: [number, number]; // [start, end] 1-indexed 38 + raw_text: string; // original text 39 + normalized_text: string; // after normalization 40 + section_path: string[]; // heading hierarchy 41 + clause_semhash: string; // SHA-256 of normalized_text 42 + context_semhash_cold: string; // SHA-256 of normalized_text + section_path + adjacent clause hashes 43 + } 44 + ``` 45 + 46 + ### 3. Semantic Hasher (`src/semhash.ts`) 47 + 48 + **clause_semhash:** `SHA-256(normalized_text)` 49 + 50 + **context_semhash_cold:** `SHA-256(normalized_text + section_path.join('/') + prev_clause_semhash + next_clause_semhash)` 51 + 52 + This captures local context without requiring the canonical graph (cold start). 53 + 54 + ### 4. Spec Graph Store (`src/store/spec-store.ts`) 55 + 56 + Persists clauses to the content-addressed store and maintains the spec graph index. 57 + 58 + **Operations:** 59 + - `ingestDocument(docPath: string): IngestResult` 60 + - `getClauses(docId: string): Clause[]` 61 + - `getClause(clauseId: string): Clause | null` 62 + - `diffDocument(docPath: string): ClauseDiff[]` 63 + 64 + ### 5. Diff Engine (`src/diff.ts`) 65 + 66 + Compares previous vs. current clauses for a document. 67 + 68 + **Diff types:** 69 + - `ADDED` — new clause 70 + - `REMOVED` — clause deleted 71 + - `MODIFIED` — clause_semhash changed 72 + - `MOVED` — section_path changed but content same 73 + - `UNCHANGED` — identical 74 + 75 + ## Data Flow 76 + 77 + ``` 78 + spec/*.md → SpecParser.parse() → Clause[] → SemHasher.hash() → Clause[] (with hashes) → SpecStore.save() 79 + ``` 80 + 81 + ## File Layout 82 + 83 + ``` 84 + src/ 85 + models/ 86 + clause.ts # Clause interface + types 87 + spec-parser.ts # Markdown → Clause[] parser 88 + semhash.ts # Semantic hashing functions 89 + normalizer.ts # Text normalization 90 + diff.ts # Clause diff engine 91 + store/ 92 + spec-store.ts # Spec graph persistence 93 + content-store.ts # Content-addressed object store 94 + index.ts # Public API exports 95 + ``` 96 + 97 + ## Success Criteria 98 + 99 + 1. Parse a Markdown spec into correct clauses with accurate line ranges 100 + 2. Normalized text is deterministic and order-invariant for lists 101 + 3. clause_semhash is stable across formatting-only changes 102 + 4. context_semhash_cold captures local structure 103 + 5. Diff engine correctly classifies all change types 104 + 6. Store persists and retrieves clauses by ID and document
+121
docs/PHASE_B.md
··· 1 + # Phase B — Canonicalization, Warm Context Hashing & Change Classifier 2 + 3 + ## Overview 4 + 5 + Phase B transforms clauses into a **Canonical Graph** — structured requirement nodes 6 + (Requirements, Constraints, Invariants, Definitions). It also computes warm context 7 + hashes that incorporate canonical graph context, and implements the A/B/C/D change classifier. 8 + 9 + ## Components 10 + 11 + ### 1. Canonical Node Model (`src/models/canonical.ts`) 12 + 13 + ```typescript 14 + enum CanonicalType { 15 + REQUIREMENT = 'REQUIREMENT', 16 + CONSTRAINT = 'CONSTRAINT', 17 + INVARIANT = 'INVARIANT', 18 + DEFINITION = 'DEFINITION', 19 + } 20 + 21 + interface CanonicalNode { 22 + canon_id: string; // content-addressed 23 + type: CanonicalType; 24 + statement: string; // normalized canonical statement 25 + source_clause_ids: string[]; // provenance back to clauses 26 + linked_canon_ids: string[]; // edges to related canonical nodes 27 + tags: string[]; // extracted keywords/terms 28 + } 29 + ``` 30 + 31 + ### 2. Canonicalization Engine (`src/canonicalizer.ts`) 32 + 33 + Extracts canonical nodes from clauses using rule-based extraction: 34 + 35 + **Extraction Rules:** 36 + - Lines containing "must", "shall", "required" → REQUIREMENT 37 + - Lines containing "must not", "forbidden", "prohibited" → CONSTRAINT 38 + - Lines containing "always", "never", "invariant" → INVARIANT 39 + - Lines containing definitions (": ", "is defined as", "means") → DEFINITION 40 + - Headings containing "constraint", "security", "limit" → CONSTRAINT context 41 + - Headings containing "requirement" → REQUIREMENT context 42 + 43 + **Linking Rules:** 44 + - Nodes sharing terms/keywords get linked 45 + - Nodes from same clause get linked 46 + - Nodes referencing same entities get linked 47 + 48 + ### 3. Warm Context Hasher (`src/warm-hasher.ts`) 49 + 50 + After canonicalization, compute `context_semhash_warm`: 51 + 52 + ``` 53 + context_semhash_warm = SHA-256( 54 + normalized_text + 55 + section_path.join('/') + 56 + sorted(linked_canon_ids).join(',') + 57 + sorted(canon_node_types).join(',') 58 + ) 59 + ``` 60 + 61 + ### 4. Change Classifier (`src/classifier.ts`) 62 + 63 + Classifies each change into A/B/C/D: 64 + 65 + | Class | Meaning | Criteria | 66 + |-------|---------|----------| 67 + | A | Trivial | normalized_text identical, only formatting changed | 68 + | B | Local semantic | clause_semhash changed, context_semhash_cold unchanged | 69 + | C | Contextual shift | context_semhash changed, canonical links affected | 70 + | D | Uncertain | classifier confidence below threshold | 71 + 72 + **Signals:** 73 + - `norm_diff`: edit distance of normalized texts 74 + - `semhash_delta`: binary (same/different clause_semhash) 75 + - `context_cold_delta`: binary (same/different context_semhash_cold) 76 + - `term_ref_delta`: Jaccard distance of extracted terms 77 + - `section_structure_delta`: section_path changed? 78 + - `canon_impact`: number of affected canonical nodes 79 + 80 + ### 5. D-Rate Tracker (`src/d-rate.ts`) 81 + 82 + Tracks D-classification rate over a rolling window. 83 + 84 + - Target: ≤5% 85 + - Acceptable: ≤10% 86 + - Alarm: >15% 87 + 88 + ### 6. Bootstrap State Machine (`src/bootstrap.ts`) 89 + 90 + States: `BOOTSTRAP_COLD` → `BOOTSTRAP_WARMING` → `STEADY_STATE` 91 + 92 + Transitions: 93 + - COLD → WARMING: after first canonicalization + warm pass complete 94 + - WARMING → STEADY_STATE: after D-rate stabilizes below acceptable threshold 95 + 96 + ## Data Flow 97 + 98 + ``` 99 + Clauses (Phase A) 100 + → Canonicalizer.extract() → CanonicalNode[] 101 + → WarmHasher.computeWarm() → Clause[] (with warm hashes) 102 + → Classifier.classify() → ChangeClassification[] 103 + → DRateTracker.record() → DRateStatus 104 + → BootstrapState.transition() 105 + ``` 106 + 107 + ## File Layout (Phase B additions) 108 + 109 + ``` 110 + src/ 111 + models/ 112 + canonical.ts # CanonicalNode interface + types 113 + classification.ts # ChangeClass enum + ChangeClassification 114 + canonicalizer.ts # Clause → CanonicalNode extraction 115 + warm-hasher.ts # Warm context hash computation 116 + classifier.ts # A/B/C/D change classifier 117 + d-rate.ts # D-rate tracking 118 + bootstrap.ts # Bootstrap state machine 119 + store/ 120 + canonical-store.ts # Canonical graph persistence 121 + ```
+88
docs/PHASE_C1.md
··· 1 + # Phase C1 — Implementation Units, Regeneration & Manifest 2 + 3 + ## Overview 4 + 5 + Phase C1 introduces Implementation Units (IUs) — stable compilation boundaries that 6 + map canonical requirements to generated code. The regeneration engine produces code 7 + artifacts and tracks them in a generated manifest for drift detection. 8 + 9 + ## Components 10 + 11 + ### 1. Implementation Unit Model (`src/models/iu.ts`) 12 + 13 + ```typescript 14 + interface ImplementationUnit { 15 + iu_id: string; // content-addressed 16 + kind: 'module' | 'function'; 17 + name: string; // human-readable label 18 + risk_tier: 'low' | 'medium' | 'high' | 'critical'; 19 + contract: IUContract; // inputs, outputs, invariants 20 + source_canon_ids: string[]; // which canonical nodes this implements 21 + dependencies: string[]; // other iu_ids this depends on 22 + boundary_policy: BoundaryPolicy; // what it's allowed to touch 23 + evidence_policy: EvidencePolicy; // what proof is required 24 + output_files: string[]; // generated file paths 25 + } 26 + ``` 27 + 28 + ### 2. IU Planner (`src/iu-planner.ts`) 29 + 30 + Maps canonical nodes → IU proposals. Groups related requirements into 31 + module-level IUs based on: 32 + - Shared tags/terms 33 + - Same source clause 34 + - Linked canonical nodes 35 + 36 + ### 3. Regeneration Engine (`src/regen.ts`) 37 + 38 + Generates code stubs for each IU. Records: 39 + - model_id (or "stub-generator/1.0" for v1) 40 + - promptpack hash 41 + - toolchain version 42 + 43 + Outputs: 44 + - Generated source files (TypeScript stubs) 45 + - Per-file content hashes in the manifest 46 + 47 + ### 4. Generated Manifest (`src/manifest.ts`) 48 + 49 + Tracks every generated file: 50 + 51 + ```typescript 52 + interface GeneratedManifest { 53 + iu_manifests: Record<string, IUManifest>; 54 + generated_at: string; 55 + } 56 + 57 + interface IUManifest { 58 + iu_id: string; 59 + files: Record<string, FileManifestEntry>; 60 + regen_metadata: RegenMetadata; 61 + } 62 + 63 + interface FileManifestEntry { 64 + path: string; 65 + content_hash: string; 66 + size: number; 67 + } 68 + ``` 69 + 70 + ### 5. Drift Detector (`src/drift.ts`) 71 + 72 + Compares working tree files against the generated manifest: 73 + 74 + - **CLEAN**: file matches manifest hash 75 + - **DRIFTED**: file differs, no waiver 76 + - **WAIVED**: file differs, waiver exists 77 + - **MISSING**: manifest entry but no file 78 + - **UNTRACKED**: file exists but not in manifest 79 + 80 + ## Data Flow 81 + 82 + ``` 83 + CanonicalNodes (Phase B) 84 + → IUPlanner.plan() → ImplementationUnit[] 85 + → RegenEngine.generate() → generated files + RegenMetadata 86 + → Manifest.record() → generated_manifest.json 87 + → DriftDetector.check() → DriftReport 88 + ```
+79
docs/PHASE_C2.md
··· 1 + # Phase C2 — Boundary Validator & UnitBoundaryChange 2 + 3 + ## Overview 4 + 5 + Phase C2 enforces architectural boundaries declared by each IU. The boundary 6 + validator (architectural linter) extracts the dependency graph from generated 7 + code and checks it against the IU's boundary policy. Violations produce 8 + diagnostics that feed into `phoenix status`. 9 + 10 + ## Components 11 + 12 + ### 1. Boundary Policy Model (in `src/models/iu.ts`) 13 + 14 + ```typescript 15 + interface BoundaryPolicy { 16 + code: { 17 + allowed_ius: string[]; // IU IDs this may import from 18 + allowed_packages: string[]; // npm packages allowed 19 + forbidden_ius: string[]; // explicitly blocked IU IDs 20 + forbidden_packages: string[]; // explicitly blocked packages 21 + forbidden_paths: string[]; // glob patterns (e.g. "src/internal/**") 22 + }; 23 + side_channels: { 24 + databases: string[]; // allowed DB names / connection strings 25 + queues: string[]; 26 + caches: string[]; 27 + config: string[]; // env vars / config keys 28 + external_apis: string[]; // URLs / service names 29 + files: string[]; // filesystem paths 30 + }; 31 + } 32 + ``` 33 + 34 + ### 2. Dependency Extractor (`src/dep-extractor.ts`) 35 + 36 + Parses generated TypeScript files and extracts: 37 + - `import` / `require` statements → package or relative path 38 + - Known side-channel patterns (env var reads, DB connections, fetch calls) 39 + 40 + Returns a `DependencyGraph` for validation. 41 + 42 + ### 3. Boundary Validator (`src/boundary-validator.ts`) 43 + 44 + Validates extracted dependencies against the IU's boundary policy. 45 + 46 + Produces `BoundaryDiagnostic[]`: 47 + - `dependency_violation`: imports something forbidden or not in allowlist 48 + - `side_channel_violation`: uses undeclared side channel 49 + 50 + Each diagnostic has severity (error | warning) controlled by the IU's 51 + enforcement config. 52 + 53 + ### 4. UnitBoundaryChange Detector 54 + 55 + When an IU's boundary policy changes, emits a `UnitBoundaryChange` event 56 + that triggers re-validation of the IU and all dependents. 57 + 58 + ## Diagnostic Model 59 + 60 + ```typescript 61 + interface BoundaryDiagnostic { 62 + severity: 'error' | 'warning'; 63 + category: 'dependency_violation' | 'side_channel_violation'; 64 + iu_id: string; 65 + subject: string; // the offending import / channel 66 + message: string; 67 + source_file?: string; 68 + source_line?: number; 69 + } 70 + ``` 71 + 72 + ## Data Flow 73 + 74 + ``` 75 + Generated code (Phase C1) 76 + → DepExtractor.extract() → DependencyGraph 77 + → BoundaryValidator.validate(graph, policy) → BoundaryDiagnostic[] 78 + → StatusEngine.merge() → phoenix status 79 + ```
+41
docs/PHASE_D.md
··· 1 + # Phase D — Evidence, Policy & Cascading Failure 2 + 3 + ## Overview 4 + 5 + Phase D enforces risk-tiered evidence requirements for each IU and propagates 6 + failures through the dependency graph. When evidence fails for an IU, its 7 + dependents are re-validated. 8 + 9 + ## Components 10 + 11 + ### 1. Evidence Model (`src/models/evidence.ts`) 12 + 13 + ```typescript 14 + enum EvidenceKind { TYPECHECK, LINT, BOUNDARY, UNIT_TEST, PROPERTY_TEST, STATIC_ANALYSIS, THREAT_NOTE, HUMAN_SIGNOFF } 15 + enum EvidenceStatus { PASS, FAIL, PENDING, SKIPPED } 16 + 17 + interface EvidenceRecord { 18 + evidence_id: string; 19 + kind: EvidenceKind; 20 + status: EvidenceStatus; 21 + iu_id: string; 22 + canon_ids: string[]; 23 + artifact_hash?: string; 24 + message?: string; 25 + timestamp: string; 26 + } 27 + ``` 28 + 29 + ### 2. Policy Engine (`src/policy-engine.ts`) 30 + 31 + Evaluates whether an IU has sufficient evidence for its risk tier. 32 + 33 + ### 3. Cascade Engine (`src/cascade.ts`) 34 + 35 + When IU-X fails, propagates to dependents: 36 + - Dependent IU-Y: re-run typecheck, boundary, relevant tests 37 + - Failure propagation is explicit and graph-based 38 + 39 + ### 4. Evidence Store (`src/store/evidence-store.ts`) 40 + 41 + Persists evidence records bound to IU IDs and canonical nodes.
+19
docs/PHASE_E.md
··· 1 + # Phase E — Shadow Pipeline & Compaction 2 + 3 + ## Overview 4 + 5 + Phase E enables safe canonicalization pipeline upgrades and storage compaction. 6 + 7 + ## Components 8 + 9 + ### 1. Shadow Pipeline (`src/shadow-pipeline.ts`) 10 + 11 + Runs old and new canonicalization pipelines in parallel, compares output, 12 + and classifies the upgrade as SAFE / COMPACTION_EVENT / REJECT. 13 + 14 + ### 2. Compaction Engine (`src/compaction.ts`) 15 + 16 + Moves cold data to archives while preserving: 17 + - Node headers, provenance edges, approvals, signatures 18 + 19 + Storage tiers: Hot Graph → Ancestry Index → Cold Packs
+17
docs/PHASE_F.md
··· 1 + # Phase F — Freeq Bot Integration 2 + 3 + ## Overview 4 + 5 + Phase F adds a bot command interface. Bots behave as normal users with 6 + a structured command grammar. Mutating commands require confirmation. 7 + 8 + ## Bots 9 + 10 + - **SpecBot** — `ingest`, `diff`, `clauses` 11 + - **ImplBot** — `plan`, `regen`, `drift` 12 + - **PolicyBot** — `status`, `evidence`, `cascade` 13 + 14 + ## Confirmation Model 15 + 16 + - Mutating commands: bot echoes parsed intent, user replies `ok` or `phx confirm <id>` 17 + - Read-only commands: execute immediately
+202
examples/microservices/README.md
··· 1 + # Phoenix VCS — Example Project 2 + 3 + A sample microservices platform with three specs: API Gateway, User Service, and Notification Service. 4 + 5 + ## Quick Start 6 + 7 + ```bash 8 + # From the phoenix repo root, build and link the CLI (one-time) 9 + cd /path/to/phoenix 10 + npm run build 11 + npm link 12 + 13 + # Enter the example project 14 + cd example 15 + 16 + # 1. Initialize Phoenix 17 + phoenix init 18 + 19 + # 2. Bootstrap (ingest → canonicalize → plan → generate → scaffold) 20 + phoenix bootstrap 21 + 22 + # 3. Install generated project dependencies 23 + npm install 24 + 25 + # 4. Typecheck the generated code 26 + npm run typecheck 27 + 28 + # 5. Run all generated tests (52 tests) 29 + npm test 30 + 31 + # 6. Start a service 32 + npm run start:api-gateway # http://localhost:3000 33 + npm run start:user-service # http://localhost:3002 34 + npm run start:notification-service # http://localhost:3001 35 + ``` 36 + 37 + ## Hit the Live Endpoints 38 + 39 + ```bash 40 + # Start the API Gateway 41 + npm run start:api-gateway & 42 + 43 + # Health check 44 + curl localhost:3000/health | jq . 45 + 46 + # Request metrics 47 + curl localhost:3000/metrics | jq . 48 + 49 + # List registered modules with risk tiers 50 + curl localhost:3000/modules | jq . 51 + 52 + # 404 for unknown routes 53 + curl localhost:3000/nonexistent | jq . 54 + ``` 55 + 56 + ## Explore with Phoenix 57 + 58 + ```bash 59 + # Trust dashboard — the primary UX 60 + phoenix status 61 + 62 + # See what Phoenix extracted from specs 63 + phoenix clauses # 26 clauses across 3 docs 64 + phoenix canon # 87 canonical nodes (requirements, constraints, invariants) 65 + 66 + # Inspect the IU plan 67 + phoenix plan # 20 Implementation Units across 3 services 68 + 69 + # Check generated files for unauthorized edits 70 + phoenix drift 71 + 72 + # Evaluate evidence against risk-tier policy 73 + phoenix evaluate 74 + 75 + # Provenance graph summary 76 + phoenix graph 77 + ``` 78 + 79 + ## Make a Spec Change 80 + 81 + ```bash 82 + # Add a requirement 83 + echo "- The gateway must support WebSocket upgrade requests" >> spec/api-gateway.md 84 + 85 + # See the diff 86 + phoenix diff spec/api-gateway.md 87 + 88 + # Re-ingest → re-canonicalize → re-plan → regenerate 89 + phoenix ingest 90 + phoenix canonicalize 91 + phoenix plan 92 + phoenix regen 93 + 94 + # Rebuild and test 95 + npm run build 96 + npm test 97 + 98 + # Check status 99 + phoenix status 100 + ``` 101 + 102 + ## Simulate Drift 103 + 104 + ```bash 105 + # Edit a generated file without going through Phoenix 106 + echo "// unauthorized edit" >> src/generated/api-gateway/authentication.ts 107 + 108 + # Drift detection catches it 109 + phoenix drift # DRIFTED 110 + phoenix status # Shows ERROR diagnostic 111 + 112 + # Fix it by regenerating 113 + phoenix regen 114 + phoenix status # Clean again 115 + ``` 116 + 117 + ## Project Structure After Bootstrap 118 + 119 + ``` 120 + example/ 121 + ├── package.json # Generated — npm scripts for each service 122 + ├── tsconfig.json # Generated — strict TypeScript config 123 + ├── vitest.config.ts # Generated — test runner config 124 + ├── spec/ # Human-written specs 125 + │ ├── api-gateway.md 126 + │ ├── user-service.md 127 + │ └── notification-service.md 128 + ├── src/generated/ 129 + │ ├── index.ts # Service registry 130 + │ ├── api-gateway/ 131 + │ │ ├── index.ts # Barrel exports 132 + │ │ ├── server.ts # HTTP server (:3000) 133 + │ │ ├── authentication.ts # Module: validate(token) 134 + │ │ ├── rate-limiting.ts # Module: rateLimit(input) 135 + │ │ ├── request-routing.ts # Module: route(request) 136 + │ │ ├── circuit-breaking.ts # Module: handle(request) 137 + │ │ ├── logging-observability.ts 138 + │ │ ├── request-transformation.ts 139 + │ │ ├── security-constraints.ts 140 + │ │ └── __tests__/ 141 + │ │ └── api-gateway.test.ts # 18 tests (modules + server) 142 + │ ├── user-service/ 143 + │ │ ├── index.ts 144 + │ │ ├── server.ts # HTTP server (:3002) 145 + │ │ ├── account-management.ts # Module: create(input) 146 + │ │ ├── search.ts # Module: search(user): User[] 147 + │ │ ├── events.ts # Module: publish(event) 148 + │ │ ├── profile-management.ts 149 + │ │ ├── authentication-integration.ts 150 + │ │ ├── data-constraints.ts 151 + │ │ └── __tests__/ 152 + │ │ └── user-service.test.ts # 16 tests 153 + │ └── notification-service/ 154 + │ ├── index.ts 155 + │ ├── server.ts # HTTP server (:3001) 156 + │ ├── email-delivery.ts 157 + │ ├── push-notifications.ts 158 + │ ├── delivery-guarantees.ts 159 + │ ├── template-system.ts 160 + │ ├── channel-support.ts 161 + │ ├── in-app-messages.ts 162 + │ ├── rate-limiting.ts 163 + │ └── __tests__/ 164 + │ └── notification-service.test.ts # 18 tests 165 + └── .phoenix/ # Phoenix state (not checked in) 166 + ├── state.json 167 + ├── graphs/ 168 + │ ├── spec.json 169 + │ ├── canonical.json 170 + │ ├── ius.json 171 + │ └── warm-hashes.json 172 + ├── manifests/ 173 + │ └── generated_manifest.json 174 + └── store/objects/ 175 + ``` 176 + 177 + ## What Each Test Verifies 178 + 179 + **Module tests** (per module): 180 + - Exports Phoenix traceability metadata (`_phoenix.name`, `_phoenix.risk_tier`) 181 + - Has at least one exported function 182 + 183 + **Server tests** (per service): 184 + - `GET /health` returns 200 with service name, uptime, module list 185 + - `GET /metrics` returns request counts 186 + - `GET /modules` lists all modules with risk tiers and exports 187 + - `GET /unknown` returns 404 188 + 189 + ## The Trace 190 + 191 + Every line of generated code traces back to a spec: 192 + 193 + ``` 194 + spec/api-gateway.md:9 "The gateway must validate JWT tokens..." 195 + → Clause 976a9f4b 196 + → CanonicalNode a890e171 (REQUIREMENT) 197 + → IU "Authentication" (high risk) 198 + → src/generated/api-gateway/authentication.ts 199 + → validate(jwtToken: JwtToken): boolean 200 + ``` 201 + 202 + Change that spec line → only `authentication.ts` is invalidated. Not the whole project.
+55
examples/microservices/spec/api-gateway.md
··· 1 + # API Gateway Service 2 + 3 + ## Overview 4 + 5 + The API Gateway is the single entry point for all client requests. It handles routing, authentication, rate limiting, and request transformation before forwarding to downstream microservices. 6 + 7 + ## Authentication 8 + 9 + - The gateway must validate JWT tokens on every authenticated request 10 + - Tokens must be verified against the public key published by the Auth service 11 + - Expired tokens must be rejected with a 401 response 12 + - The gateway must not store or log raw token payloads 13 + - Anonymous endpoints must be declared in a public routes manifest 14 + 15 + ## Rate Limiting 16 + 17 + - Each API key must be rate-limited independently 18 + - The default rate limit is 100 requests per minute per API key 19 + - Rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) must be included in every response 20 + - When the limit is exceeded, the gateway must return 429 Too Many Requests 21 + - Rate limit counters must be stored in Redis with TTL-based expiry 22 + 23 + ## Request Routing 24 + 25 + - Routes must be declared in a static route table loaded at startup 26 + - The gateway must support path-based routing to downstream services 27 + - Health check endpoints must always return 200 without authentication 28 + - Unknown routes must return 404 with a structured error body 29 + 30 + ## Request Transformation 31 + 32 + - The gateway must inject a correlation ID header (X-Correlation-ID) into every forwarded request 33 + - If the client provides a correlation ID, the gateway must propagate it unchanged 34 + - Request bodies larger than 10MB must be rejected with 413 35 + 36 + ## Logging & Observability 37 + 38 + - Every request must be logged with: method, path, status code, latency, and correlation ID 39 + - The gateway must expose Prometheus metrics at /metrics 40 + - Error responses (5xx) must include a request ID in the response body 41 + - Logs must never contain sensitive data (tokens, passwords, PII) 42 + 43 + ## Circuit Breaking 44 + 45 + - The gateway must implement circuit breaking for each downstream service 46 + - After 5 consecutive failures, the circuit must open for 30 seconds 47 + - An open circuit must return 503 Service Unavailable 48 + - Half-open state must allow a single probe request through 49 + 50 + ## Security Constraints 51 + 52 + - The gateway must enforce TLS 1.2 or higher for all connections 53 + - CORS headers must be configurable per route 54 + - The gateway must not follow redirects from downstream services 55 + - All downstream connections must use mutual TLS in production
+55
examples/microservices/spec/notification-service.md
··· 1 + # Notification Service 2 + 3 + ## Overview 4 + 5 + The Notification Service handles delivery of messages to users across multiple channels: email, push notifications, and in-app messages. 6 + 7 + ## Channel Support 8 + 9 + - The service must support email, push notification, and in-app message channels 10 + - Each user must be able to configure channel preferences per notification type 11 + - If a preferred channel fails, the service must fall back to the next available channel 12 + - Channel availability must be checked before attempting delivery 13 + 14 + ## Email Delivery 15 + 16 + - Emails must be sent through a configurable SMTP provider 17 + - The service must support HTML and plaintext email templates 18 + - Email templates must be versioned and stored in the template registry 19 + - Bounce notifications must be processed and the user's email status updated 20 + - The service must not send more than 10 emails per user per hour 21 + 22 + ## Push Notifications 23 + 24 + - Push tokens must be validated before delivery attempts 25 + - Expired push tokens must be automatically removed 26 + - Push payload size must not exceed 4KB 27 + - The service must support both iOS (APNs) and Android (FCM) platforms 28 + 29 + ## In-App Messages 30 + 31 + - In-app messages must be stored with read/unread status 32 + - Users must be able to mark messages as read individually or in bulk 33 + - Unread message counts must be available via a dedicated endpoint 34 + - Messages older than 90 days must be automatically archived 35 + 36 + ## Delivery Guarantees 37 + 38 + - Every notification must be assigned a unique delivery ID 39 + - The service must guarantee at-least-once delivery for all channels 40 + - Duplicate detection must prevent the same notification from being delivered twice 41 + - Failed deliveries must be retried with exponential backoff (max 3 retries) 42 + - After all retries are exhausted, the notification must be marked as permanently failed 43 + 44 + ## Rate Limiting 45 + 46 + - Notification volume must be rate-limited per user to prevent spam 47 + - System-wide rate limits must be configurable per channel 48 + - Rate limit violations must be logged and the notification queued for later delivery 49 + 50 + ## Template System 51 + 52 + - Templates must support variable interpolation with a {{variable}} syntax 53 + - Missing template variables must cause a delivery failure, not silent omission 54 + - Templates must be validated at registration time, not at send time 55 + - Each template must declare its required variables explicitly
+51
examples/microservices/spec/user-service.md
··· 1 + # User Service 2 + 3 + ## Overview 4 + 5 + The User Service manages user accounts, profiles, and preferences. It is the system of record for all user identity data. 6 + 7 + ## Account Management 8 + 9 + - The service must support creating new user accounts with email and password 10 + - Email addresses must be unique across all accounts 11 + - Passwords must be hashed using bcrypt with a minimum cost factor of 12 12 + - The service must never store or return plaintext passwords 13 + - Account deletion must be a soft delete with a 30-day recovery window 14 + - After the recovery window, all user data must be permanently purged 15 + 16 + ## Profile Management 17 + 18 + - Users must be able to update their display name and avatar URL 19 + - Profile updates must be validated: display names limited to 100 characters 20 + - Avatar URLs must be validated against an allowlist of image hosting domains 21 + - Profile reads must be served from cache with a 5-minute TTL 22 + 23 + ## Authentication Integration 24 + 25 + - The service must expose a verify-credentials endpoint for the Auth service 26 + - Failed credential checks must increment a lockout counter 27 + - After 10 failed attempts, the account must be locked for 1 hour 28 + - The lockout counter must reset on successful authentication 29 + - The verify-credentials endpoint must always respond within 200ms 30 + 31 + ## Data Constraints 32 + 33 + - User IDs must be UUIDv4 format 34 + - All timestamps must be stored in UTC as ISO 8601 35 + - The service must not access any external APIs directly 36 + - Database queries must use parameterized statements to prevent SQL injection 37 + - All database operations must be wrapped in transactions where atomicity is required 38 + 39 + ## Events 40 + 41 + - The service must publish a UserCreated event when a new account is created 42 + - The service must publish a UserDeleted event when soft-delete is triggered 43 + - The service must publish a ProfileUpdated event on any profile change 44 + - Events must be published to the message queue with at-least-once delivery 45 + - Event payloads must never contain passwords or security credentials 46 + 47 + ## Search 48 + 49 + - The service must support searching users by email prefix or display name 50 + - Search queries must be limited to 50 results per page 51 + - Search must return results within 500ms for datasets under 1 million users
+42
examples/tictactoe/README.md
··· 1 + # Multiplayer Tic-Tac-Toe — Phoenix Example 2 + 3 + A real-time multiplayer tic-tac-toe game specified across three specs: 4 + game engine, multiplayer networking, and web client. 5 + 6 + ## Run It 7 + 8 + ```bash 9 + # From the phoenix repo root (one-time) 10 + cd /path/to/phoenix 11 + npm run build && npm link 12 + 13 + # Enter this example 14 + cd examples/tictactoe 15 + 16 + # Generate everything from specs 17 + phoenix init 18 + phoenix bootstrap 19 + 20 + # Install and run 21 + npm install 22 + npm test # generated tests 23 + npm start # starts game-engine server on :3000 24 + ``` 25 + 26 + ## Explore 27 + 28 + ```bash 29 + phoenix status # trust dashboard 30 + phoenix canon # 52 canonical nodes 31 + phoenix plan # 12 IUs across 3 services 32 + phoenix drift # check for unauthorized edits 33 + phoenix evaluate # evidence gaps per risk tier 34 + ``` 35 + 36 + ## Specs 37 + 38 + | Spec | Covers | 39 + |------|--------| 40 + | `spec/game-engine.md` | Board state, move validation, win detection, game lifecycle | 41 + | `spec/multiplayer.md` | Player management, matchmaking, WebSocket rooms, reconnection | 42 + | `spec/web-client.md` | Board UI, game status, lobby, styling |
+33
examples/tictactoe/spec/game-engine.md
··· 1 + # Game Engine 2 + 3 + ## Board State 4 + 5 + - The board must be a 3x3 grid of cells 6 + - Each cell must be in one of three states: empty, X, or O 7 + - A new game must start with all cells empty 8 + - The board state must be serializable to a compact string format (e.g. "XO--X-O--") 9 + 10 + ## Move Validation 11 + 12 + - A move must specify a row (0-2) and column (0-2) 13 + - A move must be rejected if the cell is already occupied 14 + - A move must be rejected if the game is already over 15 + - A move must be rejected if it is not the current player's turn 16 + - X always moves first 17 + 18 + ## Win Detection 19 + 20 + - A player wins by occupying three cells in a horizontal row 21 + - A player wins by occupying three cells in a vertical column 22 + - A player wins by occupying three cells along either diagonal 23 + - The game must detect a draw when all cells are filled with no winner 24 + - Win detection must run after every move 25 + 26 + ## Game Lifecycle 27 + 28 + - Each game must have a unique game ID 29 + - The game must track whose turn it is (X or O) 30 + - The game must track the current status: waiting, in-progress, x-wins, o-wins, draw 31 + - A game in "waiting" status has one player and is waiting for an opponent 32 + - The game must record a history of all moves with timestamps 33 + - A completed game must not accept new moves
+31
examples/tictactoe/spec/multiplayer.md
··· 1 + # Multiplayer 2 + 3 + ## Player Management 4 + 5 + - Each player must be identified by a unique player ID 6 + - Players must choose a display name when joining 7 + - A player can only be in one active game at a time 8 + - The server must track all connected players 9 + 10 + ## Matchmaking 11 + 12 + - A player must be able to create a new game and wait for an opponent 13 + - A player must be able to join an existing game that is in "waiting" status 14 + - The creator of a game always plays as X 15 + - The joiner always plays as O 16 + - When a second player joins, the game status must change to "in-progress" 17 + 18 + ## Real-Time Communication 19 + 20 + - The server must use WebSocket connections for real-time updates 21 + - When a player makes a move, the opponent must be notified immediately 22 + - When a player joins a game, both players must receive the updated game state 23 + - When a game ends, both players must receive the final result 24 + - If a player disconnects, the opponent must be notified within 5 seconds 25 + - A disconnected player has 30 seconds to reconnect before forfeiting 26 + 27 + ## Game Rooms 28 + 29 + - Each active game must have its own room that both players are subscribed to 30 + - Spectators are not supported in v1 31 + - Room messages must include: move, join, game-over, player-disconnected, player-reconnected
+34
examples/tictactoe/spec/web-client.md
··· 1 + # Web Client 2 + 3 + ## Game Board UI 4 + 5 + - The board must be rendered as a 3x3 grid of clickable cells 6 + - Each cell must display X, O, or be empty 7 + - Clicking an empty cell must send a move to the server 8 + - Cells must not be clickable when it is the opponent's turn 9 + - Cells must not be clickable when the game is over 10 + - The board must visually highlight the winning line when a game ends 11 + 12 + ## Game Status Display 13 + 14 + - The UI must show whose turn it is (yours or opponent's) 15 + - The UI must show the game result when the game ends (you win, you lose, draw) 16 + - The UI must show a "waiting for opponent" message when in the lobby 17 + - The UI must show a connection status indicator 18 + 19 + ## Lobby 20 + 21 + - The lobby must show a "Create Game" button 22 + - The lobby must show a list of available games to join 23 + - Each available game must show the creator's display name 24 + - The lobby must update in real-time as games are created or filled 25 + - A player must enter a display name before creating or joining a game 26 + 27 + ## Styling 28 + 29 + - The UI must be playable on both desktop and mobile screens 30 + - The board must be centered on the screen 31 + - X marks must be displayed in blue 32 + - O marks must be displayed in red 33 + - The winning cells must have a green background 34 + - The overall design must be clean and minimal
+1436
package-lock.json
··· 1 + { 2 + "name": "phoenix-vcs", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "phoenix-vcs", 9 + "version": "0.1.0", 10 + "devDependencies": { 11 + "typescript": "^5.4.0", 12 + "vitest": "^2.0.0" 13 + } 14 + }, 15 + "node_modules/@esbuild/aix-ppc64": { 16 + "version": "0.21.5", 17 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", 18 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", 19 + "cpu": [ 20 + "ppc64" 21 + ], 22 + "dev": true, 23 + "license": "MIT", 24 + "optional": true, 25 + "os": [ 26 + "aix" 27 + ], 28 + "engines": { 29 + "node": ">=12" 30 + } 31 + }, 32 + "node_modules/@esbuild/android-arm": { 33 + "version": "0.21.5", 34 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", 35 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", 36 + "cpu": [ 37 + "arm" 38 + ], 39 + "dev": true, 40 + "license": "MIT", 41 + "optional": true, 42 + "os": [ 43 + "android" 44 + ], 45 + "engines": { 46 + "node": ">=12" 47 + } 48 + }, 49 + "node_modules/@esbuild/android-arm64": { 50 + "version": "0.21.5", 51 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", 52 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", 53 + "cpu": [ 54 + "arm64" 55 + ], 56 + "dev": true, 57 + "license": "MIT", 58 + "optional": true, 59 + "os": [ 60 + "android" 61 + ], 62 + "engines": { 63 + "node": ">=12" 64 + } 65 + }, 66 + "node_modules/@esbuild/android-x64": { 67 + "version": "0.21.5", 68 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", 69 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", 70 + "cpu": [ 71 + "x64" 72 + ], 73 + "dev": true, 74 + "license": "MIT", 75 + "optional": true, 76 + "os": [ 77 + "android" 78 + ], 79 + "engines": { 80 + "node": ">=12" 81 + } 82 + }, 83 + "node_modules/@esbuild/darwin-arm64": { 84 + "version": "0.21.5", 85 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", 86 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", 87 + "cpu": [ 88 + "arm64" 89 + ], 90 + "dev": true, 91 + "license": "MIT", 92 + "optional": true, 93 + "os": [ 94 + "darwin" 95 + ], 96 + "engines": { 97 + "node": ">=12" 98 + } 99 + }, 100 + "node_modules/@esbuild/darwin-x64": { 101 + "version": "0.21.5", 102 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", 103 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", 104 + "cpu": [ 105 + "x64" 106 + ], 107 + "dev": true, 108 + "license": "MIT", 109 + "optional": true, 110 + "os": [ 111 + "darwin" 112 + ], 113 + "engines": { 114 + "node": ">=12" 115 + } 116 + }, 117 + "node_modules/@esbuild/freebsd-arm64": { 118 + "version": "0.21.5", 119 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", 120 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", 121 + "cpu": [ 122 + "arm64" 123 + ], 124 + "dev": true, 125 + "license": "MIT", 126 + "optional": true, 127 + "os": [ 128 + "freebsd" 129 + ], 130 + "engines": { 131 + "node": ">=12" 132 + } 133 + }, 134 + "node_modules/@esbuild/freebsd-x64": { 135 + "version": "0.21.5", 136 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", 137 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", 138 + "cpu": [ 139 + "x64" 140 + ], 141 + "dev": true, 142 + "license": "MIT", 143 + "optional": true, 144 + "os": [ 145 + "freebsd" 146 + ], 147 + "engines": { 148 + "node": ">=12" 149 + } 150 + }, 151 + "node_modules/@esbuild/linux-arm": { 152 + "version": "0.21.5", 153 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", 154 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", 155 + "cpu": [ 156 + "arm" 157 + ], 158 + "dev": true, 159 + "license": "MIT", 160 + "optional": true, 161 + "os": [ 162 + "linux" 163 + ], 164 + "engines": { 165 + "node": ">=12" 166 + } 167 + }, 168 + "node_modules/@esbuild/linux-arm64": { 169 + "version": "0.21.5", 170 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", 171 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", 172 + "cpu": [ 173 + "arm64" 174 + ], 175 + "dev": true, 176 + "license": "MIT", 177 + "optional": true, 178 + "os": [ 179 + "linux" 180 + ], 181 + "engines": { 182 + "node": ">=12" 183 + } 184 + }, 185 + "node_modules/@esbuild/linux-ia32": { 186 + "version": "0.21.5", 187 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", 188 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", 189 + "cpu": [ 190 + "ia32" 191 + ], 192 + "dev": true, 193 + "license": "MIT", 194 + "optional": true, 195 + "os": [ 196 + "linux" 197 + ], 198 + "engines": { 199 + "node": ">=12" 200 + } 201 + }, 202 + "node_modules/@esbuild/linux-loong64": { 203 + "version": "0.21.5", 204 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", 205 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", 206 + "cpu": [ 207 + "loong64" 208 + ], 209 + "dev": true, 210 + "license": "MIT", 211 + "optional": true, 212 + "os": [ 213 + "linux" 214 + ], 215 + "engines": { 216 + "node": ">=12" 217 + } 218 + }, 219 + "node_modules/@esbuild/linux-mips64el": { 220 + "version": "0.21.5", 221 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", 222 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", 223 + "cpu": [ 224 + "mips64el" 225 + ], 226 + "dev": true, 227 + "license": "MIT", 228 + "optional": true, 229 + "os": [ 230 + "linux" 231 + ], 232 + "engines": { 233 + "node": ">=12" 234 + } 235 + }, 236 + "node_modules/@esbuild/linux-ppc64": { 237 + "version": "0.21.5", 238 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", 239 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", 240 + "cpu": [ 241 + "ppc64" 242 + ], 243 + "dev": true, 244 + "license": "MIT", 245 + "optional": true, 246 + "os": [ 247 + "linux" 248 + ], 249 + "engines": { 250 + "node": ">=12" 251 + } 252 + }, 253 + "node_modules/@esbuild/linux-riscv64": { 254 + "version": "0.21.5", 255 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", 256 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", 257 + "cpu": [ 258 + "riscv64" 259 + ], 260 + "dev": true, 261 + "license": "MIT", 262 + "optional": true, 263 + "os": [ 264 + "linux" 265 + ], 266 + "engines": { 267 + "node": ">=12" 268 + } 269 + }, 270 + "node_modules/@esbuild/linux-s390x": { 271 + "version": "0.21.5", 272 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", 273 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", 274 + "cpu": [ 275 + "s390x" 276 + ], 277 + "dev": true, 278 + "license": "MIT", 279 + "optional": true, 280 + "os": [ 281 + "linux" 282 + ], 283 + "engines": { 284 + "node": ">=12" 285 + } 286 + }, 287 + "node_modules/@esbuild/linux-x64": { 288 + "version": "0.21.5", 289 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", 290 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", 291 + "cpu": [ 292 + "x64" 293 + ], 294 + "dev": true, 295 + "license": "MIT", 296 + "optional": true, 297 + "os": [ 298 + "linux" 299 + ], 300 + "engines": { 301 + "node": ">=12" 302 + } 303 + }, 304 + "node_modules/@esbuild/netbsd-x64": { 305 + "version": "0.21.5", 306 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", 307 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", 308 + "cpu": [ 309 + "x64" 310 + ], 311 + "dev": true, 312 + "license": "MIT", 313 + "optional": true, 314 + "os": [ 315 + "netbsd" 316 + ], 317 + "engines": { 318 + "node": ">=12" 319 + } 320 + }, 321 + "node_modules/@esbuild/openbsd-x64": { 322 + "version": "0.21.5", 323 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", 324 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", 325 + "cpu": [ 326 + "x64" 327 + ], 328 + "dev": true, 329 + "license": "MIT", 330 + "optional": true, 331 + "os": [ 332 + "openbsd" 333 + ], 334 + "engines": { 335 + "node": ">=12" 336 + } 337 + }, 338 + "node_modules/@esbuild/sunos-x64": { 339 + "version": "0.21.5", 340 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", 341 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", 342 + "cpu": [ 343 + "x64" 344 + ], 345 + "dev": true, 346 + "license": "MIT", 347 + "optional": true, 348 + "os": [ 349 + "sunos" 350 + ], 351 + "engines": { 352 + "node": ">=12" 353 + } 354 + }, 355 + "node_modules/@esbuild/win32-arm64": { 356 + "version": "0.21.5", 357 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", 358 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", 359 + "cpu": [ 360 + "arm64" 361 + ], 362 + "dev": true, 363 + "license": "MIT", 364 + "optional": true, 365 + "os": [ 366 + "win32" 367 + ], 368 + "engines": { 369 + "node": ">=12" 370 + } 371 + }, 372 + "node_modules/@esbuild/win32-ia32": { 373 + "version": "0.21.5", 374 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", 375 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", 376 + "cpu": [ 377 + "ia32" 378 + ], 379 + "dev": true, 380 + "license": "MIT", 381 + "optional": true, 382 + "os": [ 383 + "win32" 384 + ], 385 + "engines": { 386 + "node": ">=12" 387 + } 388 + }, 389 + "node_modules/@esbuild/win32-x64": { 390 + "version": "0.21.5", 391 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", 392 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", 393 + "cpu": [ 394 + "x64" 395 + ], 396 + "dev": true, 397 + "license": "MIT", 398 + "optional": true, 399 + "os": [ 400 + "win32" 401 + ], 402 + "engines": { 403 + "node": ">=12" 404 + } 405 + }, 406 + "node_modules/@jridgewell/sourcemap-codec": { 407 + "version": "1.5.5", 408 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", 409 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", 410 + "dev": true, 411 + "license": "MIT" 412 + }, 413 + "node_modules/@rollup/rollup-android-arm-eabi": { 414 + "version": "4.57.1", 415 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", 416 + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", 417 + "cpu": [ 418 + "arm" 419 + ], 420 + "dev": true, 421 + "license": "MIT", 422 + "optional": true, 423 + "os": [ 424 + "android" 425 + ] 426 + }, 427 + "node_modules/@rollup/rollup-android-arm64": { 428 + "version": "4.57.1", 429 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", 430 + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", 431 + "cpu": [ 432 + "arm64" 433 + ], 434 + "dev": true, 435 + "license": "MIT", 436 + "optional": true, 437 + "os": [ 438 + "android" 439 + ] 440 + }, 441 + "node_modules/@rollup/rollup-darwin-arm64": { 442 + "version": "4.57.1", 443 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", 444 + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", 445 + "cpu": [ 446 + "arm64" 447 + ], 448 + "dev": true, 449 + "license": "MIT", 450 + "optional": true, 451 + "os": [ 452 + "darwin" 453 + ] 454 + }, 455 + "node_modules/@rollup/rollup-darwin-x64": { 456 + "version": "4.57.1", 457 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", 458 + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", 459 + "cpu": [ 460 + "x64" 461 + ], 462 + "dev": true, 463 + "license": "MIT", 464 + "optional": true, 465 + "os": [ 466 + "darwin" 467 + ] 468 + }, 469 + "node_modules/@rollup/rollup-freebsd-arm64": { 470 + "version": "4.57.1", 471 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", 472 + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", 473 + "cpu": [ 474 + "arm64" 475 + ], 476 + "dev": true, 477 + "license": "MIT", 478 + "optional": true, 479 + "os": [ 480 + "freebsd" 481 + ] 482 + }, 483 + "node_modules/@rollup/rollup-freebsd-x64": { 484 + "version": "4.57.1", 485 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", 486 + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", 487 + "cpu": [ 488 + "x64" 489 + ], 490 + "dev": true, 491 + "license": "MIT", 492 + "optional": true, 493 + "os": [ 494 + "freebsd" 495 + ] 496 + }, 497 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 498 + "version": "4.57.1", 499 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", 500 + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", 501 + "cpu": [ 502 + "arm" 503 + ], 504 + "dev": true, 505 + "license": "MIT", 506 + "optional": true, 507 + "os": [ 508 + "linux" 509 + ] 510 + }, 511 + "node_modules/@rollup/rollup-linux-arm-musleabihf": { 512 + "version": "4.57.1", 513 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", 514 + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", 515 + "cpu": [ 516 + "arm" 517 + ], 518 + "dev": true, 519 + "license": "MIT", 520 + "optional": true, 521 + "os": [ 522 + "linux" 523 + ] 524 + }, 525 + "node_modules/@rollup/rollup-linux-arm64-gnu": { 526 + "version": "4.57.1", 527 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", 528 + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", 529 + "cpu": [ 530 + "arm64" 531 + ], 532 + "dev": true, 533 + "license": "MIT", 534 + "optional": true, 535 + "os": [ 536 + "linux" 537 + ] 538 + }, 539 + "node_modules/@rollup/rollup-linux-arm64-musl": { 540 + "version": "4.57.1", 541 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", 542 + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", 543 + "cpu": [ 544 + "arm64" 545 + ], 546 + "dev": true, 547 + "license": "MIT", 548 + "optional": true, 549 + "os": [ 550 + "linux" 551 + ] 552 + }, 553 + "node_modules/@rollup/rollup-linux-loong64-gnu": { 554 + "version": "4.57.1", 555 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", 556 + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", 557 + "cpu": [ 558 + "loong64" 559 + ], 560 + "dev": true, 561 + "license": "MIT", 562 + "optional": true, 563 + "os": [ 564 + "linux" 565 + ] 566 + }, 567 + "node_modules/@rollup/rollup-linux-loong64-musl": { 568 + "version": "4.57.1", 569 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", 570 + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", 571 + "cpu": [ 572 + "loong64" 573 + ], 574 + "dev": true, 575 + "license": "MIT", 576 + "optional": true, 577 + "os": [ 578 + "linux" 579 + ] 580 + }, 581 + "node_modules/@rollup/rollup-linux-ppc64-gnu": { 582 + "version": "4.57.1", 583 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", 584 + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", 585 + "cpu": [ 586 + "ppc64" 587 + ], 588 + "dev": true, 589 + "license": "MIT", 590 + "optional": true, 591 + "os": [ 592 + "linux" 593 + ] 594 + }, 595 + "node_modules/@rollup/rollup-linux-ppc64-musl": { 596 + "version": "4.57.1", 597 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", 598 + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", 599 + "cpu": [ 600 + "ppc64" 601 + ], 602 + "dev": true, 603 + "license": "MIT", 604 + "optional": true, 605 + "os": [ 606 + "linux" 607 + ] 608 + }, 609 + "node_modules/@rollup/rollup-linux-riscv64-gnu": { 610 + "version": "4.57.1", 611 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", 612 + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", 613 + "cpu": [ 614 + "riscv64" 615 + ], 616 + "dev": true, 617 + "license": "MIT", 618 + "optional": true, 619 + "os": [ 620 + "linux" 621 + ] 622 + }, 623 + "node_modules/@rollup/rollup-linux-riscv64-musl": { 624 + "version": "4.57.1", 625 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", 626 + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", 627 + "cpu": [ 628 + "riscv64" 629 + ], 630 + "dev": true, 631 + "license": "MIT", 632 + "optional": true, 633 + "os": [ 634 + "linux" 635 + ] 636 + }, 637 + "node_modules/@rollup/rollup-linux-s390x-gnu": { 638 + "version": "4.57.1", 639 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", 640 + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", 641 + "cpu": [ 642 + "s390x" 643 + ], 644 + "dev": true, 645 + "license": "MIT", 646 + "optional": true, 647 + "os": [ 648 + "linux" 649 + ] 650 + }, 651 + "node_modules/@rollup/rollup-linux-x64-gnu": { 652 + "version": "4.57.1", 653 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", 654 + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", 655 + "cpu": [ 656 + "x64" 657 + ], 658 + "dev": true, 659 + "license": "MIT", 660 + "optional": true, 661 + "os": [ 662 + "linux" 663 + ] 664 + }, 665 + "node_modules/@rollup/rollup-linux-x64-musl": { 666 + "version": "4.57.1", 667 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", 668 + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", 669 + "cpu": [ 670 + "x64" 671 + ], 672 + "dev": true, 673 + "license": "MIT", 674 + "optional": true, 675 + "os": [ 676 + "linux" 677 + ] 678 + }, 679 + "node_modules/@rollup/rollup-openbsd-x64": { 680 + "version": "4.57.1", 681 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", 682 + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", 683 + "cpu": [ 684 + "x64" 685 + ], 686 + "dev": true, 687 + "license": "MIT", 688 + "optional": true, 689 + "os": [ 690 + "openbsd" 691 + ] 692 + }, 693 + "node_modules/@rollup/rollup-openharmony-arm64": { 694 + "version": "4.57.1", 695 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", 696 + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", 697 + "cpu": [ 698 + "arm64" 699 + ], 700 + "dev": true, 701 + "license": "MIT", 702 + "optional": true, 703 + "os": [ 704 + "openharmony" 705 + ] 706 + }, 707 + "node_modules/@rollup/rollup-win32-arm64-msvc": { 708 + "version": "4.57.1", 709 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", 710 + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", 711 + "cpu": [ 712 + "arm64" 713 + ], 714 + "dev": true, 715 + "license": "MIT", 716 + "optional": true, 717 + "os": [ 718 + "win32" 719 + ] 720 + }, 721 + "node_modules/@rollup/rollup-win32-ia32-msvc": { 722 + "version": "4.57.1", 723 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", 724 + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", 725 + "cpu": [ 726 + "ia32" 727 + ], 728 + "dev": true, 729 + "license": "MIT", 730 + "optional": true, 731 + "os": [ 732 + "win32" 733 + ] 734 + }, 735 + "node_modules/@rollup/rollup-win32-x64-gnu": { 736 + "version": "4.57.1", 737 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", 738 + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", 739 + "cpu": [ 740 + "x64" 741 + ], 742 + "dev": true, 743 + "license": "MIT", 744 + "optional": true, 745 + "os": [ 746 + "win32" 747 + ] 748 + }, 749 + "node_modules/@rollup/rollup-win32-x64-msvc": { 750 + "version": "4.57.1", 751 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", 752 + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", 753 + "cpu": [ 754 + "x64" 755 + ], 756 + "dev": true, 757 + "license": "MIT", 758 + "optional": true, 759 + "os": [ 760 + "win32" 761 + ] 762 + }, 763 + "node_modules/@types/estree": { 764 + "version": "1.0.8", 765 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 766 + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 767 + "dev": true, 768 + "license": "MIT" 769 + }, 770 + "node_modules/@vitest/expect": { 771 + "version": "2.1.9", 772 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", 773 + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", 774 + "dev": true, 775 + "license": "MIT", 776 + "dependencies": { 777 + "@vitest/spy": "2.1.9", 778 + "@vitest/utils": "2.1.9", 779 + "chai": "^5.1.2", 780 + "tinyrainbow": "^1.2.0" 781 + }, 782 + "funding": { 783 + "url": "https://opencollective.com/vitest" 784 + } 785 + }, 786 + "node_modules/@vitest/mocker": { 787 + "version": "2.1.9", 788 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", 789 + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", 790 + "dev": true, 791 + "license": "MIT", 792 + "dependencies": { 793 + "@vitest/spy": "2.1.9", 794 + "estree-walker": "^3.0.3", 795 + "magic-string": "^0.30.12" 796 + }, 797 + "funding": { 798 + "url": "https://opencollective.com/vitest" 799 + }, 800 + "peerDependencies": { 801 + "msw": "^2.4.9", 802 + "vite": "^5.0.0" 803 + }, 804 + "peerDependenciesMeta": { 805 + "msw": { 806 + "optional": true 807 + }, 808 + "vite": { 809 + "optional": true 810 + } 811 + } 812 + }, 813 + "node_modules/@vitest/pretty-format": { 814 + "version": "2.1.9", 815 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", 816 + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", 817 + "dev": true, 818 + "license": "MIT", 819 + "dependencies": { 820 + "tinyrainbow": "^1.2.0" 821 + }, 822 + "funding": { 823 + "url": "https://opencollective.com/vitest" 824 + } 825 + }, 826 + "node_modules/@vitest/runner": { 827 + "version": "2.1.9", 828 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", 829 + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", 830 + "dev": true, 831 + "license": "MIT", 832 + "dependencies": { 833 + "@vitest/utils": "2.1.9", 834 + "pathe": "^1.1.2" 835 + }, 836 + "funding": { 837 + "url": "https://opencollective.com/vitest" 838 + } 839 + }, 840 + "node_modules/@vitest/snapshot": { 841 + "version": "2.1.9", 842 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", 843 + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", 844 + "dev": true, 845 + "license": "MIT", 846 + "dependencies": { 847 + "@vitest/pretty-format": "2.1.9", 848 + "magic-string": "^0.30.12", 849 + "pathe": "^1.1.2" 850 + }, 851 + "funding": { 852 + "url": "https://opencollective.com/vitest" 853 + } 854 + }, 855 + "node_modules/@vitest/spy": { 856 + "version": "2.1.9", 857 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", 858 + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", 859 + "dev": true, 860 + "license": "MIT", 861 + "dependencies": { 862 + "tinyspy": "^3.0.2" 863 + }, 864 + "funding": { 865 + "url": "https://opencollective.com/vitest" 866 + } 867 + }, 868 + "node_modules/@vitest/utils": { 869 + "version": "2.1.9", 870 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", 871 + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", 872 + "dev": true, 873 + "license": "MIT", 874 + "dependencies": { 875 + "@vitest/pretty-format": "2.1.9", 876 + "loupe": "^3.1.2", 877 + "tinyrainbow": "^1.2.0" 878 + }, 879 + "funding": { 880 + "url": "https://opencollective.com/vitest" 881 + } 882 + }, 883 + "node_modules/assertion-error": { 884 + "version": "2.0.1", 885 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", 886 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", 887 + "dev": true, 888 + "license": "MIT", 889 + "engines": { 890 + "node": ">=12" 891 + } 892 + }, 893 + "node_modules/cac": { 894 + "version": "6.7.14", 895 + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", 896 + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", 897 + "dev": true, 898 + "license": "MIT", 899 + "engines": { 900 + "node": ">=8" 901 + } 902 + }, 903 + "node_modules/chai": { 904 + "version": "5.3.3", 905 + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", 906 + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", 907 + "dev": true, 908 + "license": "MIT", 909 + "dependencies": { 910 + "assertion-error": "^2.0.1", 911 + "check-error": "^2.1.1", 912 + "deep-eql": "^5.0.1", 913 + "loupe": "^3.1.0", 914 + "pathval": "^2.0.0" 915 + }, 916 + "engines": { 917 + "node": ">=18" 918 + } 919 + }, 920 + "node_modules/check-error": { 921 + "version": "2.1.3", 922 + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", 923 + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", 924 + "dev": true, 925 + "license": "MIT", 926 + "engines": { 927 + "node": ">= 16" 928 + } 929 + }, 930 + "node_modules/debug": { 931 + "version": "4.4.3", 932 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 933 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 934 + "dev": true, 935 + "license": "MIT", 936 + "dependencies": { 937 + "ms": "^2.1.3" 938 + }, 939 + "engines": { 940 + "node": ">=6.0" 941 + }, 942 + "peerDependenciesMeta": { 943 + "supports-color": { 944 + "optional": true 945 + } 946 + } 947 + }, 948 + "node_modules/deep-eql": { 949 + "version": "5.0.2", 950 + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", 951 + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", 952 + "dev": true, 953 + "license": "MIT", 954 + "engines": { 955 + "node": ">=6" 956 + } 957 + }, 958 + "node_modules/es-module-lexer": { 959 + "version": "1.7.0", 960 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", 961 + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", 962 + "dev": true, 963 + "license": "MIT" 964 + }, 965 + "node_modules/esbuild": { 966 + "version": "0.21.5", 967 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", 968 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", 969 + "dev": true, 970 + "hasInstallScript": true, 971 + "license": "MIT", 972 + "bin": { 973 + "esbuild": "bin/esbuild" 974 + }, 975 + "engines": { 976 + "node": ">=12" 977 + }, 978 + "optionalDependencies": { 979 + "@esbuild/aix-ppc64": "0.21.5", 980 + "@esbuild/android-arm": "0.21.5", 981 + "@esbuild/android-arm64": "0.21.5", 982 + "@esbuild/android-x64": "0.21.5", 983 + "@esbuild/darwin-arm64": "0.21.5", 984 + "@esbuild/darwin-x64": "0.21.5", 985 + "@esbuild/freebsd-arm64": "0.21.5", 986 + "@esbuild/freebsd-x64": "0.21.5", 987 + "@esbuild/linux-arm": "0.21.5", 988 + "@esbuild/linux-arm64": "0.21.5", 989 + "@esbuild/linux-ia32": "0.21.5", 990 + "@esbuild/linux-loong64": "0.21.5", 991 + "@esbuild/linux-mips64el": "0.21.5", 992 + "@esbuild/linux-ppc64": "0.21.5", 993 + "@esbuild/linux-riscv64": "0.21.5", 994 + "@esbuild/linux-s390x": "0.21.5", 995 + "@esbuild/linux-x64": "0.21.5", 996 + "@esbuild/netbsd-x64": "0.21.5", 997 + "@esbuild/openbsd-x64": "0.21.5", 998 + "@esbuild/sunos-x64": "0.21.5", 999 + "@esbuild/win32-arm64": "0.21.5", 1000 + "@esbuild/win32-ia32": "0.21.5", 1001 + "@esbuild/win32-x64": "0.21.5" 1002 + } 1003 + }, 1004 + "node_modules/estree-walker": { 1005 + "version": "3.0.3", 1006 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", 1007 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", 1008 + "dev": true, 1009 + "license": "MIT", 1010 + "dependencies": { 1011 + "@types/estree": "^1.0.0" 1012 + } 1013 + }, 1014 + "node_modules/expect-type": { 1015 + "version": "1.3.0", 1016 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", 1017 + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", 1018 + "dev": true, 1019 + "license": "Apache-2.0", 1020 + "engines": { 1021 + "node": ">=12.0.0" 1022 + } 1023 + }, 1024 + "node_modules/fsevents": { 1025 + "version": "2.3.3", 1026 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1027 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1028 + "dev": true, 1029 + "hasInstallScript": true, 1030 + "license": "MIT", 1031 + "optional": true, 1032 + "os": [ 1033 + "darwin" 1034 + ], 1035 + "engines": { 1036 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1037 + } 1038 + }, 1039 + "node_modules/loupe": { 1040 + "version": "3.2.1", 1041 + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", 1042 + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", 1043 + "dev": true, 1044 + "license": "MIT" 1045 + }, 1046 + "node_modules/magic-string": { 1047 + "version": "0.30.21", 1048 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", 1049 + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", 1050 + "dev": true, 1051 + "license": "MIT", 1052 + "dependencies": { 1053 + "@jridgewell/sourcemap-codec": "^1.5.5" 1054 + } 1055 + }, 1056 + "node_modules/ms": { 1057 + "version": "2.1.3", 1058 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 1059 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 1060 + "dev": true, 1061 + "license": "MIT" 1062 + }, 1063 + "node_modules/nanoid": { 1064 + "version": "3.3.11", 1065 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 1066 + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1067 + "dev": true, 1068 + "funding": [ 1069 + { 1070 + "type": "github", 1071 + "url": "https://github.com/sponsors/ai" 1072 + } 1073 + ], 1074 + "license": "MIT", 1075 + "bin": { 1076 + "nanoid": "bin/nanoid.cjs" 1077 + }, 1078 + "engines": { 1079 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1080 + } 1081 + }, 1082 + "node_modules/pathe": { 1083 + "version": "1.1.2", 1084 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", 1085 + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", 1086 + "dev": true, 1087 + "license": "MIT" 1088 + }, 1089 + "node_modules/pathval": { 1090 + "version": "2.0.1", 1091 + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", 1092 + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", 1093 + "dev": true, 1094 + "license": "MIT", 1095 + "engines": { 1096 + "node": ">= 14.16" 1097 + } 1098 + }, 1099 + "node_modules/picocolors": { 1100 + "version": "1.1.1", 1101 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 1102 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 1103 + "dev": true, 1104 + "license": "ISC" 1105 + }, 1106 + "node_modules/postcss": { 1107 + "version": "8.5.6", 1108 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", 1109 + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", 1110 + "dev": true, 1111 + "funding": [ 1112 + { 1113 + "type": "opencollective", 1114 + "url": "https://opencollective.com/postcss/" 1115 + }, 1116 + { 1117 + "type": "tidelift", 1118 + "url": "https://tidelift.com/funding/github/npm/postcss" 1119 + }, 1120 + { 1121 + "type": "github", 1122 + "url": "https://github.com/sponsors/ai" 1123 + } 1124 + ], 1125 + "license": "MIT", 1126 + "dependencies": { 1127 + "nanoid": "^3.3.11", 1128 + "picocolors": "^1.1.1", 1129 + "source-map-js": "^1.2.1" 1130 + }, 1131 + "engines": { 1132 + "node": "^10 || ^12 || >=14" 1133 + } 1134 + }, 1135 + "node_modules/rollup": { 1136 + "version": "4.57.1", 1137 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", 1138 + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", 1139 + "dev": true, 1140 + "license": "MIT", 1141 + "dependencies": { 1142 + "@types/estree": "1.0.8" 1143 + }, 1144 + "bin": { 1145 + "rollup": "dist/bin/rollup" 1146 + }, 1147 + "engines": { 1148 + "node": ">=18.0.0", 1149 + "npm": ">=8.0.0" 1150 + }, 1151 + "optionalDependencies": { 1152 + "@rollup/rollup-android-arm-eabi": "4.57.1", 1153 + "@rollup/rollup-android-arm64": "4.57.1", 1154 + "@rollup/rollup-darwin-arm64": "4.57.1", 1155 + "@rollup/rollup-darwin-x64": "4.57.1", 1156 + "@rollup/rollup-freebsd-arm64": "4.57.1", 1157 + "@rollup/rollup-freebsd-x64": "4.57.1", 1158 + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", 1159 + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", 1160 + "@rollup/rollup-linux-arm64-gnu": "4.57.1", 1161 + "@rollup/rollup-linux-arm64-musl": "4.57.1", 1162 + "@rollup/rollup-linux-loong64-gnu": "4.57.1", 1163 + "@rollup/rollup-linux-loong64-musl": "4.57.1", 1164 + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", 1165 + "@rollup/rollup-linux-ppc64-musl": "4.57.1", 1166 + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", 1167 + "@rollup/rollup-linux-riscv64-musl": "4.57.1", 1168 + "@rollup/rollup-linux-s390x-gnu": "4.57.1", 1169 + "@rollup/rollup-linux-x64-gnu": "4.57.1", 1170 + "@rollup/rollup-linux-x64-musl": "4.57.1", 1171 + "@rollup/rollup-openbsd-x64": "4.57.1", 1172 + "@rollup/rollup-openharmony-arm64": "4.57.1", 1173 + "@rollup/rollup-win32-arm64-msvc": "4.57.1", 1174 + "@rollup/rollup-win32-ia32-msvc": "4.57.1", 1175 + "@rollup/rollup-win32-x64-gnu": "4.57.1", 1176 + "@rollup/rollup-win32-x64-msvc": "4.57.1", 1177 + "fsevents": "~2.3.2" 1178 + } 1179 + }, 1180 + "node_modules/siginfo": { 1181 + "version": "2.0.0", 1182 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", 1183 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", 1184 + "dev": true, 1185 + "license": "ISC" 1186 + }, 1187 + "node_modules/source-map-js": { 1188 + "version": "1.2.1", 1189 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 1190 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 1191 + "dev": true, 1192 + "license": "BSD-3-Clause", 1193 + "engines": { 1194 + "node": ">=0.10.0" 1195 + } 1196 + }, 1197 + "node_modules/stackback": { 1198 + "version": "0.0.2", 1199 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", 1200 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", 1201 + "dev": true, 1202 + "license": "MIT" 1203 + }, 1204 + "node_modules/std-env": { 1205 + "version": "3.10.0", 1206 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", 1207 + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", 1208 + "dev": true, 1209 + "license": "MIT" 1210 + }, 1211 + "node_modules/tinybench": { 1212 + "version": "2.9.0", 1213 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", 1214 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", 1215 + "dev": true, 1216 + "license": "MIT" 1217 + }, 1218 + "node_modules/tinyexec": { 1219 + "version": "0.3.2", 1220 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", 1221 + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", 1222 + "dev": true, 1223 + "license": "MIT" 1224 + }, 1225 + "node_modules/tinypool": { 1226 + "version": "1.1.1", 1227 + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", 1228 + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", 1229 + "dev": true, 1230 + "license": "MIT", 1231 + "engines": { 1232 + "node": "^18.0.0 || >=20.0.0" 1233 + } 1234 + }, 1235 + "node_modules/tinyrainbow": { 1236 + "version": "1.2.0", 1237 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", 1238 + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", 1239 + "dev": true, 1240 + "license": "MIT", 1241 + "engines": { 1242 + "node": ">=14.0.0" 1243 + } 1244 + }, 1245 + "node_modules/tinyspy": { 1246 + "version": "3.0.2", 1247 + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", 1248 + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", 1249 + "dev": true, 1250 + "license": "MIT", 1251 + "engines": { 1252 + "node": ">=14.0.0" 1253 + } 1254 + }, 1255 + "node_modules/typescript": { 1256 + "version": "5.9.3", 1257 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 1258 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1259 + "dev": true, 1260 + "license": "Apache-2.0", 1261 + "bin": { 1262 + "tsc": "bin/tsc", 1263 + "tsserver": "bin/tsserver" 1264 + }, 1265 + "engines": { 1266 + "node": ">=14.17" 1267 + } 1268 + }, 1269 + "node_modules/vite": { 1270 + "version": "5.4.21", 1271 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", 1272 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", 1273 + "dev": true, 1274 + "license": "MIT", 1275 + "dependencies": { 1276 + "esbuild": "^0.21.3", 1277 + "postcss": "^8.4.43", 1278 + "rollup": "^4.20.0" 1279 + }, 1280 + "bin": { 1281 + "vite": "bin/vite.js" 1282 + }, 1283 + "engines": { 1284 + "node": "^18.0.0 || >=20.0.0" 1285 + }, 1286 + "funding": { 1287 + "url": "https://github.com/vitejs/vite?sponsor=1" 1288 + }, 1289 + "optionalDependencies": { 1290 + "fsevents": "~2.3.3" 1291 + }, 1292 + "peerDependencies": { 1293 + "@types/node": "^18.0.0 || >=20.0.0", 1294 + "less": "*", 1295 + "lightningcss": "^1.21.0", 1296 + "sass": "*", 1297 + "sass-embedded": "*", 1298 + "stylus": "*", 1299 + "sugarss": "*", 1300 + "terser": "^5.4.0" 1301 + }, 1302 + "peerDependenciesMeta": { 1303 + "@types/node": { 1304 + "optional": true 1305 + }, 1306 + "less": { 1307 + "optional": true 1308 + }, 1309 + "lightningcss": { 1310 + "optional": true 1311 + }, 1312 + "sass": { 1313 + "optional": true 1314 + }, 1315 + "sass-embedded": { 1316 + "optional": true 1317 + }, 1318 + "stylus": { 1319 + "optional": true 1320 + }, 1321 + "sugarss": { 1322 + "optional": true 1323 + }, 1324 + "terser": { 1325 + "optional": true 1326 + } 1327 + } 1328 + }, 1329 + "node_modules/vite-node": { 1330 + "version": "2.1.9", 1331 + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", 1332 + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", 1333 + "dev": true, 1334 + "license": "MIT", 1335 + "dependencies": { 1336 + "cac": "^6.7.14", 1337 + "debug": "^4.3.7", 1338 + "es-module-lexer": "^1.5.4", 1339 + "pathe": "^1.1.2", 1340 + "vite": "^5.0.0" 1341 + }, 1342 + "bin": { 1343 + "vite-node": "vite-node.mjs" 1344 + }, 1345 + "engines": { 1346 + "node": "^18.0.0 || >=20.0.0" 1347 + }, 1348 + "funding": { 1349 + "url": "https://opencollective.com/vitest" 1350 + } 1351 + }, 1352 + "node_modules/vitest": { 1353 + "version": "2.1.9", 1354 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", 1355 + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", 1356 + "dev": true, 1357 + "license": "MIT", 1358 + "dependencies": { 1359 + "@vitest/expect": "2.1.9", 1360 + "@vitest/mocker": "2.1.9", 1361 + "@vitest/pretty-format": "^2.1.9", 1362 + "@vitest/runner": "2.1.9", 1363 + "@vitest/snapshot": "2.1.9", 1364 + "@vitest/spy": "2.1.9", 1365 + "@vitest/utils": "2.1.9", 1366 + "chai": "^5.1.2", 1367 + "debug": "^4.3.7", 1368 + "expect-type": "^1.1.0", 1369 + "magic-string": "^0.30.12", 1370 + "pathe": "^1.1.2", 1371 + "std-env": "^3.8.0", 1372 + "tinybench": "^2.9.0", 1373 + "tinyexec": "^0.3.1", 1374 + "tinypool": "^1.0.1", 1375 + "tinyrainbow": "^1.2.0", 1376 + "vite": "^5.0.0", 1377 + "vite-node": "2.1.9", 1378 + "why-is-node-running": "^2.3.0" 1379 + }, 1380 + "bin": { 1381 + "vitest": "vitest.mjs" 1382 + }, 1383 + "engines": { 1384 + "node": "^18.0.0 || >=20.0.0" 1385 + }, 1386 + "funding": { 1387 + "url": "https://opencollective.com/vitest" 1388 + }, 1389 + "peerDependencies": { 1390 + "@edge-runtime/vm": "*", 1391 + "@types/node": "^18.0.0 || >=20.0.0", 1392 + "@vitest/browser": "2.1.9", 1393 + "@vitest/ui": "2.1.9", 1394 + "happy-dom": "*", 1395 + "jsdom": "*" 1396 + }, 1397 + "peerDependenciesMeta": { 1398 + "@edge-runtime/vm": { 1399 + "optional": true 1400 + }, 1401 + "@types/node": { 1402 + "optional": true 1403 + }, 1404 + "@vitest/browser": { 1405 + "optional": true 1406 + }, 1407 + "@vitest/ui": { 1408 + "optional": true 1409 + }, 1410 + "happy-dom": { 1411 + "optional": true 1412 + }, 1413 + "jsdom": { 1414 + "optional": true 1415 + } 1416 + } 1417 + }, 1418 + "node_modules/why-is-node-running": { 1419 + "version": "2.3.0", 1420 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", 1421 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", 1422 + "dev": true, 1423 + "license": "MIT", 1424 + "dependencies": { 1425 + "siginfo": "^2.0.0", 1426 + "stackback": "0.0.2" 1427 + }, 1428 + "bin": { 1429 + "why-is-node-running": "cli.js" 1430 + }, 1431 + "engines": { 1432 + "node": ">=8" 1433 + } 1434 + } 1435 + } 1436 + }
+21
package.json
··· 1 + { 2 + "name": "phoenix-vcs", 3 + "version": "0.1.0", 4 + "description": "Regenerative version control system — causal compiler for intent", 5 + "type": "module", 6 + "main": "dist/index.js", 7 + "types": "dist/index.d.ts", 8 + "bin": { 9 + "phoenix": "dist/cli.js" 10 + }, 11 + "scripts": { 12 + "build": "tsc", 13 + "test": "vitest run", 14 + "test:watch": "vitest", 15 + "phoenix": "node dist/cli.js" 16 + }, 17 + "devDependencies": { 18 + "typescript": "^5.4.0", 19 + "vitest": "^2.0.0" 20 + } 21 + }
+80
src/bootstrap.ts
··· 1 + /** 2 + * Bootstrap State Machine 3 + * 4 + * Manages system lifecycle: 5 + * BOOTSTRAP_COLD → BOOTSTRAP_WARMING → STEADY_STATE 6 + */ 7 + 8 + import type { DRateStatus } from './models/classification.js'; 9 + import { BootstrapState, DRateLevel } from './models/classification.js'; 10 + 11 + export class BootstrapStateMachine { 12 + private state: BootstrapState; 13 + private warmPassComplete: boolean = false; 14 + 15 + constructor(initialState: BootstrapState = BootstrapState.BOOTSTRAP_COLD) { 16 + this.state = initialState; 17 + } 18 + 19 + getState(): BootstrapState { 20 + return this.state; 21 + } 22 + 23 + /** 24 + * Signal that the warm pass (canonicalization + warm hashing) is complete. 25 + */ 26 + markWarmPassComplete(): void { 27 + this.warmPassComplete = true; 28 + if (this.state === BootstrapState.BOOTSTRAP_COLD) { 29 + this.state = BootstrapState.BOOTSTRAP_WARMING; 30 + } 31 + } 32 + 33 + /** 34 + * Evaluate whether to transition to STEADY_STATE based on D-rate. 35 + */ 36 + evaluateTransition(dRateStatus: DRateStatus): void { 37 + if (this.state === BootstrapState.BOOTSTRAP_WARMING) { 38 + // Need sufficient data and acceptable D-rate 39 + if ( 40 + dRateStatus.total_count >= 10 && 41 + (dRateStatus.level === DRateLevel.TARGET || dRateStatus.level === DRateLevel.ACCEPTABLE) 42 + ) { 43 + this.state = BootstrapState.STEADY_STATE; 44 + } 45 + } 46 + } 47 + 48 + /** 49 + * Check if D-rate alarms should be suppressed (during cold bootstrap). 50 + */ 51 + shouldSuppressAlarms(): boolean { 52 + return this.state === BootstrapState.BOOTSTRAP_COLD; 53 + } 54 + 55 + /** 56 + * Check if severity should be downgraded (during warming). 57 + */ 58 + shouldDowngradeSeverity(): boolean { 59 + return this.state === BootstrapState.BOOTSTRAP_WARMING; 60 + } 61 + 62 + /** 63 + * Serialize state for persistence. 64 + */ 65 + toJSON(): { state: BootstrapState; warm_pass_complete: boolean } { 66 + return { 67 + state: this.state, 68 + warm_pass_complete: this.warmPassComplete, 69 + }; 70 + } 71 + 72 + /** 73 + * Restore from serialized state. 74 + */ 75 + static fromJSON(data: { state: BootstrapState; warm_pass_complete: boolean }): BootstrapStateMachine { 76 + const machine = new BootstrapStateMachine(data.state); 77 + machine.warmPassComplete = data.warm_pass_complete; 78 + return machine; 79 + } 80 + }
+189
src/bot-router.ts
··· 1 + /** 2 + * Bot Router — parses commands and routes to the right handler. 3 + * 4 + * Command format: BotName: action arg1=val1 arg2=val2 5 + * No fuzzy NLU in v1 — strict grammar only. 6 + */ 7 + 8 + import type { BotCommand, BotResponse, BotName } from './models/bot.js'; 9 + import { sha256 } from './semhash.js'; 10 + 11 + const VALID_BOTS = new Set<BotName>(['SpecBot', 'ImplBot', 'PolicyBot']); 12 + 13 + const BOT_COMMANDS: Record<BotName, Record<string, { mutating: boolean; description: string }>> = { 14 + SpecBot: { 15 + ingest: { mutating: true, description: 'Ingest a spec document' }, 16 + diff: { mutating: false, description: 'Show clause diff for a document' }, 17 + clauses: { mutating: false, description: 'List clauses for a document' }, 18 + help: { mutating: false, description: 'Show available commands' }, 19 + commands: { mutating: false, description: 'List commands' }, 20 + version: { mutating: false, description: 'Show version' }, 21 + }, 22 + ImplBot: { 23 + plan: { mutating: true, description: 'Plan IUs from canonical graph' }, 24 + regen: { mutating: true, description: 'Regenerate code for an IU' }, 25 + drift: { mutating: false, description: 'Check for drift in generated files' }, 26 + help: { mutating: false, description: 'Show available commands' }, 27 + commands: { mutating: false, description: 'List commands' }, 28 + version: { mutating: false, description: 'Show version' }, 29 + }, 30 + PolicyBot: { 31 + status: { mutating: false, description: 'Show trust dashboard' }, 32 + evidence: { mutating: false, description: 'Show evidence for an IU' }, 33 + cascade: { mutating: false, description: 'Show cascade effects' }, 34 + evaluate: { mutating: false, description: 'Evaluate policy for an IU' }, 35 + help: { mutating: false, description: 'Show available commands' }, 36 + commands: { mutating: false, description: 'List commands' }, 37 + version: { mutating: false, description: 'Show version' }, 38 + }, 39 + }; 40 + 41 + /** 42 + * Parse a raw command string into a BotCommand. 43 + */ 44 + export function parseCommand(raw: string): BotCommand | { error: string } { 45 + const trimmed = raw.trim(); 46 + 47 + // Match: BotName: action ...args 48 + const match = trimmed.match(/^(\w+):\s+(\w+)\s*(.*)?$/); 49 + if (!match) { 50 + return { error: `Invalid command format. Expected: BotName: action [args]. Got: "${trimmed}"` }; 51 + } 52 + 53 + const botName = match[1] as BotName; 54 + const action = match[2]; 55 + const argsStr = (match[3] || '').trim(); 56 + 57 + if (!VALID_BOTS.has(botName)) { 58 + return { error: `Unknown bot: ${botName}. Valid bots: ${[...VALID_BOTS].join(', ')}` }; 59 + } 60 + 61 + const botCommands = BOT_COMMANDS[botName]; 62 + if (!botCommands[action]) { 63 + return { error: `Unknown command: ${botName}: ${action}. Try: ${botName}: help` }; 64 + } 65 + 66 + // Parse key=value args 67 + const args: Record<string, string> = {}; 68 + if (argsStr) { 69 + const parts = argsStr.match(/(\w+)=([^\s]+)/g); 70 + if (parts) { 71 + for (const part of parts) { 72 + const [key, ...val] = part.split('='); 73 + args[key] = val.join('='); 74 + } 75 + } else { 76 + // Single positional arg 77 + args['_'] = argsStr; 78 + } 79 + } 80 + 81 + return { bot: botName, action, args, raw: trimmed }; 82 + } 83 + 84 + /** 85 + * Route a parsed command and produce a response. 86 + * For mutating commands, returns the intent for confirmation. 87 + * For read-only commands, executes immediately. 88 + */ 89 + export function routeCommand(cmd: BotCommand): BotResponse { 90 + const commandDef = BOT_COMMANDS[cmd.bot]?.[cmd.action]; 91 + if (!commandDef) { 92 + return { bot: cmd.bot, action: cmd.action, mutating: false, message: `Unknown command: ${cmd.action}` }; 93 + } 94 + 95 + // Handle help/commands/version for all bots 96 + if (cmd.action === 'help' || cmd.action === 'commands') { 97 + const cmds = BOT_COMMANDS[cmd.bot]; 98 + const lines = Object.entries(cmds).map(([name, def]) => { 99 + const tag = def.mutating ? ' [mutating]' : ''; 100 + return ` ${cmd.bot}: ${name}${tag} — ${def.description}`; 101 + }); 102 + return { 103 + bot: cmd.bot, 104 + action: cmd.action, 105 + mutating: false, 106 + result: Object.keys(cmds), 107 + message: `${cmd.bot} commands:\n${lines.join('\n')}`, 108 + }; 109 + } 110 + 111 + if (cmd.action === 'version') { 112 + return { 113 + bot: cmd.bot, 114 + action: 'version', 115 + mutating: false, 116 + result: { version: '0.1.0' }, 117 + message: `${cmd.bot} v0.1.0 (Phoenix VCS)`, 118 + }; 119 + } 120 + 121 + // Mutating commands: echo intent, require confirmation 122 + if (commandDef.mutating) { 123 + const confirmId = sha256(`${cmd.bot}:${cmd.action}:${JSON.stringify(cmd.args)}:${Date.now()}`).slice(0, 12); 124 + const intent = describeIntent(cmd); 125 + return { 126 + bot: cmd.bot, 127 + action: cmd.action, 128 + mutating: true, 129 + confirm_id: confirmId, 130 + intent, 131 + message: `${cmd.bot} wants to: ${intent}\n\nReply 'ok' or 'phx confirm ${confirmId}' to proceed.`, 132 + }; 133 + } 134 + 135 + // Read-only commands: execute immediately (return description) 136 + return { 137 + bot: cmd.bot, 138 + action: cmd.action, 139 + mutating: false, 140 + message: describeReadAction(cmd), 141 + }; 142 + } 143 + 144 + function describeIntent(cmd: BotCommand): string { 145 + switch (`${cmd.bot}:${cmd.action}`) { 146 + case 'SpecBot:ingest': 147 + return `Ingest spec document: ${cmd.args['_'] || cmd.args['doc'] || '(no doc specified)'}`; 148 + case 'ImplBot:plan': 149 + return 'Plan Implementation Units from the current canonical graph'; 150 + case 'ImplBot:regen': { 151 + const iu = cmd.args['iu'] || cmd.args['_'] || '(all)'; 152 + return `Regenerate code for IU: ${iu}`; 153 + } 154 + default: 155 + return `${cmd.bot} ${cmd.action} ${JSON.stringify(cmd.args)}`; 156 + } 157 + } 158 + 159 + function describeReadAction(cmd: BotCommand): string { 160 + switch (`${cmd.bot}:${cmd.action}`) { 161 + case 'SpecBot:diff': 162 + return `Showing clause diff for: ${cmd.args['_'] || cmd.args['doc'] || '(current)'}`; 163 + case 'SpecBot:clauses': 164 + return `Listing clauses for: ${cmd.args['_'] || cmd.args['doc'] || '(all)'}`; 165 + case 'ImplBot:drift': 166 + return 'Checking drift status for all generated files'; 167 + case 'PolicyBot:status': 168 + return 'Trust dashboard: showing full phoenix status'; 169 + case 'PolicyBot:evidence': 170 + return `Evidence for IU: ${cmd.args['iu'] || cmd.args['_'] || '(all)'}`; 171 + case 'PolicyBot:cascade': 172 + return 'Showing cascade effects from current failures'; 173 + case 'PolicyBot:evaluate': 174 + return `Policy evaluation for IU: ${cmd.args['iu'] || cmd.args['_'] || '(all)'}`; 175 + default: 176 + return `${cmd.bot}: ${cmd.action}`; 177 + } 178 + } 179 + 180 + /** 181 + * Get all available commands across all bots. 182 + */ 183 + export function getAllCommands(): Record<BotName, string[]> { 184 + const result: Record<string, string[]> = {}; 185 + for (const [bot, cmds] of Object.entries(BOT_COMMANDS)) { 186 + result[bot] = Object.keys(cmds); 187 + } 188 + return result as Record<BotName, string[]>; 189 + }
+181
src/boundary-validator.ts
··· 1 + /** 2 + * Boundary Validator (Architectural Linter) 3 + * 4 + * Validates extracted dependencies against an IU's boundary policy. 5 + * Produces diagnostics for violations. 6 + */ 7 + 8 + import type { ImplementationUnit, BoundaryPolicy, EnforcementConfig } from './models/iu.js'; 9 + import type { DependencyGraph } from './dep-extractor.js'; 10 + import type { Diagnostic } from './models/diagnostic.js'; 11 + 12 + /** 13 + * Validate a dependency graph against an IU's boundary policy. 14 + */ 15 + export function validateBoundary( 16 + depGraph: DependencyGraph, 17 + iu: ImplementationUnit, 18 + iuIdToName?: Map<string, string>, 19 + ): Diagnostic[] { 20 + const diagnostics: Diagnostic[] = []; 21 + const policy = iu.boundary_policy; 22 + const enforcement = iu.enforcement; 23 + 24 + // Validate imports 25 + for (const dep of depGraph.imports) { 26 + if (dep.is_relative) { 27 + // Check against forbidden_paths 28 + for (const forbidden of policy.code.forbidden_paths) { 29 + if (matchGlob(dep.source, forbidden)) { 30 + diagnostics.push({ 31 + severity: enforcement.dependency_violation.severity, 32 + category: 'dependency_violation', 33 + subject: dep.source, 34 + message: `Import "${dep.source}" matches forbidden path pattern "${forbidden}"`, 35 + iu_id: iu.iu_id, 36 + source_file: depGraph.file_path, 37 + source_line: dep.source_line, 38 + recommended_actions: ['Remove the import or update boundary policy'], 39 + }); 40 + } 41 + } 42 + } else { 43 + // Package import — check forbidden + allowed 44 + const pkgName = extractPackageName(dep.source); 45 + 46 + // Check forbidden packages (takes priority) 47 + if (policy.code.forbidden_packages.includes(pkgName)) { 48 + diagnostics.push({ 49 + severity: enforcement.dependency_violation.severity, 50 + category: 'dependency_violation', 51 + subject: pkgName, 52 + message: `Package "${pkgName}" is forbidden by boundary policy`, 53 + iu_id: iu.iu_id, 54 + source_file: depGraph.file_path, 55 + source_line: dep.source_line, 56 + recommended_actions: ['Remove the dependency or update boundary policy'], 57 + }); 58 + } else if (policy.code.allowed_packages.length > 0 && !policy.code.allowed_packages.includes(pkgName)) { 59 + // If allowed_packages is non-empty, check allowlist (only if not already caught by forbidden) 60 + diagnostics.push({ 61 + severity: enforcement.dependency_violation.severity, 62 + category: 'dependency_violation', 63 + subject: pkgName, 64 + message: `Package "${pkgName}" is not in the allowed packages list`, 65 + iu_id: iu.iu_id, 66 + source_file: depGraph.file_path, 67 + source_line: dep.source_line, 68 + recommended_actions: [`Add "${pkgName}" to allowed_packages or remove the import`], 69 + }); 70 + } 71 + } 72 + } 73 + 74 + // Validate side channels 75 + for (const sc of depGraph.side_channels) { 76 + const policyKey = sideChannelPolicyKey(sc.kind); 77 + const allowed = (policy.side_channels as Record<string, string[]>)[policyKey] ?? []; 78 + 79 + if (allowed.length === 0 || !allowed.includes(sc.identifier)) { 80 + diagnostics.push({ 81 + severity: enforcement.side_channel_violation.severity, 82 + category: 'side_channel_violation', 83 + subject: sc.identifier, 84 + message: `Undeclared ${sc.kind} side channel: "${sc.identifier}"`, 85 + iu_id: iu.iu_id, 86 + source_file: depGraph.file_path, 87 + source_line: sc.source_line, 88 + recommended_actions: [ 89 + `Declare "${sc.identifier}" in boundary_policy.side_channels.${policyKey}`, 90 + 'Or remove the side-channel usage', 91 + ], 92 + }); 93 + } 94 + } 95 + 96 + return diagnostics; 97 + } 98 + 99 + /** 100 + * Validate multiple files for one IU. 101 + */ 102 + export function validateIU( 103 + depGraphs: DependencyGraph[], 104 + iu: ImplementationUnit, 105 + ): Diagnostic[] { 106 + return depGraphs.flatMap(g => validateBoundary(g, iu)); 107 + } 108 + 109 + /** 110 + * Detect boundary policy changes between two versions of an IU. 111 + */ 112 + export interface UnitBoundaryChange { 113 + iu_id: string; 114 + iu_name: string; 115 + changes: string[]; 116 + } 117 + 118 + export function detectBoundaryChanges( 119 + before: ImplementationUnit, 120 + after: ImplementationUnit, 121 + ): UnitBoundaryChange | null { 122 + const changes: string[] = []; 123 + const bp = before.boundary_policy; 124 + const ap = after.boundary_policy; 125 + 126 + // Compare code policies 127 + for (const key of ['allowed_ius', 'allowed_packages', 'forbidden_ius', 'forbidden_packages', 'forbidden_paths'] as const) { 128 + const bv = JSON.stringify(bp.code[key].sort()); 129 + const av = JSON.stringify(ap.code[key].sort()); 130 + if (bv !== av) { 131 + changes.push(`code.${key} changed`); 132 + } 133 + } 134 + 135 + // Compare side channel policies 136 + for (const key of ['databases', 'queues', 'caches', 'config', 'external_apis', 'files'] as const) { 137 + const bv = JSON.stringify(bp.side_channels[key].sort()); 138 + const av = JSON.stringify(ap.side_channels[key].sort()); 139 + if (bv !== av) { 140 + changes.push(`side_channels.${key} changed`); 141 + } 142 + } 143 + 144 + if (changes.length === 0) return null; 145 + return { iu_id: after.iu_id, iu_name: after.name, changes }; 146 + } 147 + 148 + /** 149 + * Simple glob matching (supports * and ** wildcards). 150 + */ 151 + function matchGlob(path: string, pattern: string): boolean { 152 + const regex = pattern 153 + .replace(/\*\*/g, '<<<GLOBSTAR>>>') 154 + .replace(/\*/g, '[^/]*') 155 + .replace(/<<<GLOBSTAR>>>/g, '.*'); 156 + return new RegExp(`^${regex}$`).test(path); 157 + } 158 + 159 + /** 160 + * Extract npm package name from import specifier. 161 + * Handles scoped packages: @scope/pkg/sub → @scope/pkg 162 + */ 163 + function extractPackageName(specifier: string): string { 164 + if (specifier.startsWith('@')) { 165 + const parts = specifier.split('/'); 166 + return parts.slice(0, 2).join('/'); 167 + } 168 + return specifier.split('/')[0]; 169 + } 170 + 171 + function sideChannelPolicyKey(kind: string): string { 172 + switch (kind) { 173 + case 'external_api': return 'external_apis'; 174 + case 'database': return 'databases'; 175 + case 'queue': return 'queues'; 176 + case 'cache': return 'caches'; 177 + case 'file': return 'files'; 178 + case 'config': return 'config'; 179 + default: return kind; 180 + } 181 + }
+178
src/canonicalizer.ts
··· 1 + /** 2 + * Canonicalization Engine 3 + * 4 + * Extracts structured canonical nodes (Requirements, Constraints, 5 + * Invariants, Definitions) from clauses using rule-based extraction. 6 + */ 7 + 8 + import type { Clause } from './models/clause.js'; 9 + import type { CanonicalNode } from './models/canonical.js'; 10 + import { CanonicalType } from './models/canonical.js'; 11 + import { sha256 } from './semhash.js'; 12 + import { normalizeText } from './normalizer.js'; 13 + 14 + /** Patterns for classifying lines into canonical types */ 15 + const REQUIREMENT_PATTERNS = [ 16 + /\b(?:must|shall|required|requires?)\b/i, 17 + /\b(?:needs? to|has to|will)\b/i, 18 + ]; 19 + 20 + const CONSTRAINT_PATTERNS = [ 21 + /\b(?:must not|shall not|forbidden|prohibited|cannot|disallowed)\b/i, 22 + /\b(?:limited to|maximum|minimum|at most|at least|no more than)\b/i, 23 + ]; 24 + 25 + const INVARIANT_PATTERNS = [ 26 + /\b(?:always|never|invariant|at all times|guaranteed)\b/i, 27 + ]; 28 + 29 + const DEFINITION_PATTERNS = [ 30 + /\b(?:is defined as|means|refers to)\b/i, 31 + /:\s+\S/, // colon followed by text (definition-style) 32 + ]; 33 + 34 + /** Heading patterns that provide type context */ 35 + const HEADING_CONTEXT: [RegExp, CanonicalType][] = [ 36 + [/\b(?:constraint|security|limit|restrict)/i, CanonicalType.CONSTRAINT], 37 + [/\b(?:requirement|feature|capability)/i, CanonicalType.REQUIREMENT], 38 + [/\b(?:definition|glossary|term)/i, CanonicalType.DEFINITION], 39 + [/\b(?:invariant|guarantee)/i, CanonicalType.INVARIANT], 40 + ]; 41 + 42 + /** 43 + * Extract canonical nodes from an array of clauses. 44 + */ 45 + export function extractCanonicalNodes(clauses: Clause[]): CanonicalNode[] { 46 + const allNodes: CanonicalNode[] = []; 47 + 48 + for (const clause of clauses) { 49 + const nodes = extractFromClause(clause); 50 + allNodes.push(...nodes); 51 + } 52 + 53 + // Link nodes that share terms 54 + linkNodesByTerms(allNodes); 55 + 56 + return allNodes; 57 + } 58 + 59 + /** 60 + * Extract canonical nodes from a single clause. 61 + */ 62 + function extractFromClause(clause: Clause): CanonicalNode[] { 63 + const nodes: CanonicalNode[] = []; 64 + const lines = clause.raw_text.split('\n'); 65 + 66 + // Determine heading context type 67 + const headingContext = getHeadingContext(clause.section_path); 68 + 69 + for (const line of lines) { 70 + const trimmed = line.trim(); 71 + if (!trimmed || trimmed.match(/^#{1,6}\s/)) continue; // skip empty and heading lines 72 + 73 + // Strip list markers for analysis 74 + const content = trimmed.replace(/^(?:[-*•]|\d+[.)]\s*)\s*/, ''); 75 + if (!content || content.length < 5) continue; 76 + 77 + const type = classifyLine(content, headingContext); 78 + if (!type) continue; 79 + 80 + const normalizedStatement = normalizeText(content); 81 + if (!normalizedStatement) continue; 82 + 83 + const tags = extractTerms(normalizedStatement); 84 + const canonId = sha256([type, normalizedStatement, clause.clause_id].join('\x00')); 85 + 86 + nodes.push({ 87 + canon_id: canonId, 88 + type, 89 + statement: normalizedStatement, 90 + source_clause_ids: [clause.clause_id], 91 + linked_canon_ids: [], 92 + tags, 93 + }); 94 + } 95 + 96 + return nodes; 97 + } 98 + 99 + /** 100 + * Classify a line into a canonical type based on patterns and context. 101 + */ 102 + function classifyLine(content: string, headingContext: CanonicalType | null): CanonicalType | null { 103 + // Check specific patterns first (most specific wins) 104 + for (const pattern of CONSTRAINT_PATTERNS) { 105 + if (pattern.test(content)) return CanonicalType.CONSTRAINT; 106 + } 107 + for (const pattern of INVARIANT_PATTERNS) { 108 + if (pattern.test(content)) return CanonicalType.INVARIANT; 109 + } 110 + for (const pattern of REQUIREMENT_PATTERNS) { 111 + if (pattern.test(content)) return CanonicalType.REQUIREMENT; 112 + } 113 + for (const pattern of DEFINITION_PATTERNS) { 114 + if (pattern.test(content)) return CanonicalType.DEFINITION; 115 + } 116 + 117 + // Fall back to heading context 118 + if (headingContext) return headingContext; 119 + 120 + return null; 121 + } 122 + 123 + /** 124 + * Determine canonical type context from section path headings. 125 + */ 126 + function getHeadingContext(sectionPath: string[]): CanonicalType | null { 127 + // Check from most specific (deepest) to least specific 128 + for (let i = sectionPath.length - 1; i >= 0; i--) { 129 + for (const [pattern, type] of HEADING_CONTEXT) { 130 + if (pattern.test(sectionPath[i])) return type; 131 + } 132 + } 133 + return null; 134 + } 135 + 136 + /** 137 + * Extract key terms from normalized text for linking. 138 + */ 139 + export function extractTerms(text: string): string[] { 140 + // Split into words, filter short/common words 141 + const stopWords = new Set([ 142 + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 143 + 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 144 + 'should', 'may', 'might', 'shall', 'can', 'must', 'need', 'to', 'of', 145 + 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 146 + 'and', 'or', 'but', 'not', 'no', 'if', 'then', 'else', 'when', 'where', 147 + 'that', 'this', 'these', 'those', 'it', 'its', 'all', 'each', 'every', 148 + 'any', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 149 + ]); 150 + 151 + const words = text.toLowerCase() 152 + .split(/\s+/) 153 + .map(w => w.replace(/[^a-z0-9]/g, '')) 154 + .filter(Boolean); 155 + return [...new Set( 156 + words.filter(w => w.length > 2 && !stopWords.has(w)) 157 + )]; 158 + } 159 + 160 + /** 161 + * Link canonical nodes that share significant terms. 162 + * Modifies nodes in place. 163 + */ 164 + function linkNodesByTerms(nodes: CanonicalNode[]): void { 165 + for (let i = 0; i < nodes.length; i++) { 166 + for (let j = i + 1; j < nodes.length; j++) { 167 + const shared = nodes[i].tags.filter(t => nodes[j].tags.includes(t)); 168 + if (shared.length >= 2) { 169 + if (!nodes[i].linked_canon_ids.includes(nodes[j].canon_id)) { 170 + nodes[i].linked_canon_ids.push(nodes[j].canon_id); 171 + } 172 + if (!nodes[j].linked_canon_ids.includes(nodes[i].canon_id)) { 173 + nodes[j].linked_canon_ids.push(nodes[i].canon_id); 174 + } 175 + } 176 + } 177 + } 178 + }
+105
src/cascade.ts
··· 1 + /** 2 + * Cascade Engine — propagates evidence failures through the IU dependency graph. 3 + * 4 + * When IU-X fails, dependent IU-Y must re-run: 5 + * - typecheck 6 + * - boundary checks 7 + * - relevant tagged tests 8 + */ 9 + 10 + import type { ImplementationUnit } from './models/iu.js'; 11 + import type { PolicyEvaluation, CascadeEvent, CascadeAction } from './models/evidence.js'; 12 + 13 + /** 14 + * Compute cascade effects from policy evaluation failures. 15 + */ 16 + export function computeCascade( 17 + evaluations: PolicyEvaluation[], 18 + ius: ImplementationUnit[], 19 + ): CascadeEvent[] { 20 + const events: CascadeEvent[] = []; 21 + const iuMap = new Map(ius.map(iu => [iu.iu_id, iu])); 22 + 23 + // Build reverse dependency map: iu_id → IUs that depend on it 24 + const dependents = new Map<string, ImplementationUnit[]>(); 25 + for (const iu of ius) { 26 + for (const depId of iu.dependencies) { 27 + const list = dependents.get(depId) ?? []; 28 + list.push(iu); 29 + dependents.set(depId, list); 30 + } 31 + } 32 + 33 + for (const eval_ of evaluations) { 34 + if (eval_.verdict === 'PASS') continue; 35 + 36 + const sourceIU = iuMap.get(eval_.iu_id); 37 + if (!sourceIU) continue; 38 + 39 + const affected = dependents.get(eval_.iu_id) ?? []; 40 + if (affected.length === 0 && eval_.verdict !== 'FAIL') continue; 41 + 42 + const actions: CascadeAction[] = []; 43 + 44 + // Actions on the failed IU itself 45 + if (eval_.verdict === 'FAIL') { 46 + actions.push({ 47 + iu_id: eval_.iu_id, 48 + iu_name: eval_.iu_name, 49 + action: 'BLOCK', 50 + reason: `Evidence failed: ${eval_.failed.join(', ')}`, 51 + }); 52 + } 53 + 54 + // Actions on dependents 55 + for (const dep of affected) { 56 + actions.push({ 57 + iu_id: dep.iu_id, 58 + iu_name: dep.name, 59 + action: 'RE_VALIDATE', 60 + reason: `Dependency ${eval_.iu_name} ${eval_.verdict === 'FAIL' ? 'failed' : 'incomplete'}; re-run typecheck + boundary + tagged tests`, 61 + }); 62 + } 63 + 64 + events.push({ 65 + source_iu_id: eval_.iu_id, 66 + source_iu_name: eval_.iu_name, 67 + failure_kind: eval_.verdict, 68 + affected_iu_ids: affected.map(a => a.iu_id), 69 + actions, 70 + }); 71 + } 72 + 73 + return events; 74 + } 75 + 76 + /** 77 + * Get all IU IDs that are transitively affected by a failure. 78 + */ 79 + export function getTransitiveDependents( 80 + iuId: string, 81 + ius: ImplementationUnit[], 82 + ): string[] { 83 + const dependents = new Map<string, string[]>(); 84 + for (const iu of ius) { 85 + for (const depId of iu.dependencies) { 86 + const list = dependents.get(depId) ?? []; 87 + list.push(iu.iu_id); 88 + dependents.set(depId, list); 89 + } 90 + } 91 + 92 + const visited = new Set<string>(); 93 + const queue = [iuId]; 94 + while (queue.length > 0) { 95 + const current = queue.pop()!; 96 + if (visited.has(current)) continue; 97 + visited.add(current); 98 + for (const dep of (dependents.get(current) ?? [])) { 99 + queue.push(dep); 100 + } 101 + } 102 + 103 + visited.delete(iuId); // don't include the source 104 + return [...visited]; 105 + }
+274
src/classifier.ts
··· 1 + /** 2 + * A/B/C/D Change Classifier 3 + * 4 + * Classifies each clause change using multiple signals. 5 + * 6 + * A = Trivial (formatting only) 7 + * B = Local semantic change 8 + * C = Contextual semantic shift 9 + * D = Uncertain 10 + */ 11 + 12 + import type { Clause, ClauseDiff } from './models/clause.js'; 13 + import { DiffType } from './models/clause.js'; 14 + import type { CanonicalNode } from './models/canonical.js'; 15 + import type { ClassificationSignals, ChangeClassification } from './models/classification.js'; 16 + import { ChangeClass } from './models/classification.js'; 17 + import { extractTerms } from './canonicalizer.js'; 18 + 19 + /** 20 + * Classify a single clause diff. 21 + */ 22 + export function classifyChange( 23 + diff: ClauseDiff, 24 + canonicalNodesBefore: CanonicalNode[], 25 + canonicalNodesAfter: CanonicalNode[], 26 + warmHashBefore?: string, 27 + warmHashAfter?: string, 28 + ): ChangeClassification { 29 + // Pure additions and removals 30 + if (diff.diff_type === DiffType.ADDED) { 31 + const canonImpact = countCanonImpact(undefined, diff.clause_after, canonicalNodesBefore, canonicalNodesAfter); 32 + return { 33 + change_class: ChangeClass.B, 34 + confidence: 0.9, 35 + signals: { 36 + norm_diff: 1, 37 + semhash_delta: true, 38 + context_cold_delta: true, 39 + term_ref_delta: 1, 40 + section_structure_delta: true, 41 + canon_impact: canonImpact, 42 + }, 43 + clause_id_after: diff.clause_id_after, 44 + }; 45 + } 46 + 47 + if (diff.diff_type === DiffType.REMOVED) { 48 + const canonImpact = countCanonImpact(diff.clause_before, undefined, canonicalNodesBefore, canonicalNodesAfter); 49 + return { 50 + change_class: ChangeClass.B, 51 + confidence: 0.9, 52 + signals: { 53 + norm_diff: 1, 54 + semhash_delta: true, 55 + context_cold_delta: true, 56 + term_ref_delta: 1, 57 + section_structure_delta: true, 58 + canon_impact: canonImpact, 59 + }, 60 + clause_id_before: diff.clause_id_before, 61 + }; 62 + } 63 + 64 + if (diff.diff_type === DiffType.UNCHANGED) { 65 + // Check if warm hash changed (contextual shift even without content change) 66 + if (warmHashBefore && warmHashAfter && warmHashBefore !== warmHashAfter) { 67 + return { 68 + change_class: ChangeClass.C, 69 + confidence: 0.8, 70 + signals: { 71 + norm_diff: 0, 72 + semhash_delta: false, 73 + context_cold_delta: false, 74 + term_ref_delta: 0, 75 + section_structure_delta: false, 76 + canon_impact: 0, 77 + }, 78 + clause_id_before: diff.clause_id_before, 79 + clause_id_after: diff.clause_id_after, 80 + }; 81 + } 82 + return { 83 + change_class: ChangeClass.A, 84 + confidence: 1.0, 85 + signals: { 86 + norm_diff: 0, 87 + semhash_delta: false, 88 + context_cold_delta: false, 89 + term_ref_delta: 0, 90 + section_structure_delta: false, 91 + canon_impact: 0, 92 + }, 93 + clause_id_before: diff.clause_id_before, 94 + clause_id_after: diff.clause_id_after, 95 + }; 96 + } 97 + 98 + // MODIFIED or MOVED — compute signals 99 + const before = diff.clause_before!; 100 + const after = diff.clause_after!; 101 + 102 + const normDiff = normalizedEditDistance(before.normalized_text, after.normalized_text); 103 + const semhashDelta = before.clause_semhash !== after.clause_semhash; 104 + const contextColdDelta = before.context_semhash_cold !== after.context_semhash_cold; 105 + const termDelta = termJaccardDistance(before.normalized_text, after.normalized_text); 106 + const sectionDelta = before.section_path.join('/') !== after.section_path.join('/'); 107 + const canonImpact = countCanonImpact(before, after, canonicalNodesBefore, canonicalNodesAfter); 108 + 109 + const signals: ClassificationSignals = { 110 + norm_diff: normDiff, 111 + semhash_delta: semhashDelta, 112 + context_cold_delta: contextColdDelta, 113 + term_ref_delta: termDelta, 114 + section_structure_delta: sectionDelta, 115 + canon_impact: canonImpact, 116 + }; 117 + 118 + // Classification logic 119 + if (!semhashDelta) { 120 + // Content identical, only moved 121 + return { 122 + change_class: ChangeClass.A, 123 + confidence: 0.95, 124 + signals, 125 + clause_id_before: diff.clause_id_before, 126 + clause_id_after: diff.clause_id_after, 127 + }; 128 + } 129 + 130 + // Compute confidence and classify 131 + if (normDiff < 0.1 && termDelta < 0.2) { 132 + // Very small change, high confidence it's trivial 133 + return { 134 + change_class: ChangeClass.A, 135 + confidence: 0.85, 136 + signals, 137 + clause_id_before: diff.clause_id_before, 138 + clause_id_after: diff.clause_id_after, 139 + }; 140 + } 141 + 142 + if (canonImpact > 0 || contextColdDelta) { 143 + // Affects canonical graph or structural context 144 + const confidence = canonImpact > 2 ? 0.9 : 0.7; 145 + return { 146 + change_class: ChangeClass.C, 147 + confidence, 148 + signals, 149 + clause_id_before: diff.clause_id_before, 150 + clause_id_after: diff.clause_id_after, 151 + }; 152 + } 153 + 154 + // Local semantic change 155 + if (normDiff < 0.5 && termDelta < 0.5) { 156 + return { 157 + change_class: ChangeClass.B, 158 + confidence: 0.8, 159 + signals, 160 + clause_id_before: diff.clause_id_before, 161 + clause_id_after: diff.clause_id_after, 162 + }; 163 + } 164 + 165 + // High uncertainty 166 + if (normDiff > 0.7 || termDelta > 0.7) { 167 + return { 168 + change_class: ChangeClass.D, 169 + confidence: 0.4, 170 + signals, 171 + clause_id_before: diff.clause_id_before, 172 + clause_id_after: diff.clause_id_after, 173 + }; 174 + } 175 + 176 + return { 177 + change_class: ChangeClass.B, 178 + confidence: 0.6, 179 + signals, 180 + clause_id_before: diff.clause_id_before, 181 + clause_id_after: diff.clause_id_after, 182 + }; 183 + } 184 + 185 + /** 186 + * Classify all diffs in a change set. 187 + */ 188 + export function classifyChanges( 189 + diffs: ClauseDiff[], 190 + canonicalNodesBefore: CanonicalNode[], 191 + canonicalNodesAfter: CanonicalNode[], 192 + warmHashesBefore?: Map<string, string>, 193 + warmHashesAfter?: Map<string, string>, 194 + ): ChangeClassification[] { 195 + return diffs.map(diff => { 196 + const warmBefore = diff.clause_id_before ? warmHashesBefore?.get(diff.clause_id_before) : undefined; 197 + const warmAfter = diff.clause_id_after ? warmHashesAfter?.get(diff.clause_id_after) : undefined; 198 + return classifyChange(diff, canonicalNodesBefore, canonicalNodesAfter, warmBefore, warmAfter); 199 + }); 200 + } 201 + 202 + /** 203 + * Normalized edit distance (Levenshtein / max length). 204 + * Returns 0 for identical, 1 for completely different. 205 + */ 206 + function normalizedEditDistance(a: string, b: string): number { 207 + if (a === b) return 0; 208 + if (a.length === 0 || b.length === 0) return 1; 209 + 210 + const maxLen = Math.max(a.length, b.length); 211 + const dist = levenshtein(a, b); 212 + return dist / maxLen; 213 + } 214 + 215 + /** 216 + * Levenshtein distance (optimized for reasonable string lengths). 217 + */ 218 + function levenshtein(a: string, b: string): number { 219 + const m = a.length; 220 + const n = b.length; 221 + const dp: number[] = Array.from({ length: n + 1 }, (_, i) => i); 222 + 223 + for (let i = 1; i <= m; i++) { 224 + let prev = dp[0]; 225 + dp[0] = i; 226 + for (let j = 1; j <= n; j++) { 227 + const temp = dp[j]; 228 + if (a[i - 1] === b[j - 1]) { 229 + dp[j] = prev; 230 + } else { 231 + dp[j] = 1 + Math.min(prev, dp[j], dp[j - 1]); 232 + } 233 + prev = temp; 234 + } 235 + } 236 + 237 + return dp[n]; 238 + } 239 + 240 + /** 241 + * Jaccard distance of extracted terms between two texts. 242 + */ 243 + function termJaccardDistance(textA: string, textB: string): number { 244 + const termsA = new Set(extractTerms(textA)); 245 + const termsB = new Set(extractTerms(textB)); 246 + 247 + if (termsA.size === 0 && termsB.size === 0) return 0; 248 + 249 + const intersection = [...termsA].filter(t => termsB.has(t)).length; 250 + const union = new Set([...termsA, ...termsB]).size; 251 + 252 + return 1 - (intersection / union); 253 + } 254 + 255 + /** 256 + * Count canonical nodes affected by a change. 257 + */ 258 + function countCanonImpact( 259 + before: Clause | undefined, 260 + after: Clause | undefined, 261 + canonBefore: CanonicalNode[], 262 + canonAfter: CanonicalNode[], 263 + ): number { 264 + let impact = 0; 265 + 266 + if (before) { 267 + impact += canonBefore.filter(n => n.source_clause_ids.includes(before.clause_id)).length; 268 + } 269 + if (after) { 270 + impact += canonAfter.filter(n => n.source_clause_ids.includes(after.clause_id)).length; 271 + } 272 + 273 + return impact; 274 + }
+1198
src/cli.ts
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Phoenix VCS — Command Line Interface 4 + * 5 + * The primary UX surface for Phoenix. `phoenix status` is the most 6 + * important command — it must always be explainable, conservative, 7 + * and correct-enough to rely on. 8 + */ 9 + 10 + import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'; 11 + import { join, resolve, relative, basename } from 'node:path'; 12 + 13 + // Stores 14 + import { SpecStore } from './store/spec-store.js'; 15 + import { CanonicalStore } from './store/canonical-store.js'; 16 + import { EvidenceStore } from './store/evidence-store.js'; 17 + import { ManifestManager } from './manifest.js'; 18 + 19 + // Phase A 20 + import { parseSpec } from './spec-parser.js'; 21 + import { diffClauses } from './diff.js'; 22 + 23 + // Phase B 24 + import { extractCanonicalNodes } from './canonicalizer.js'; 25 + import { computeWarmHashes } from './warm-hasher.js'; 26 + import { classifyChanges } from './classifier.js'; 27 + import { DRateTracker } from './d-rate.js'; 28 + import { BootstrapStateMachine } from './bootstrap.js'; 29 + 30 + // Phase C 31 + import { planIUs } from './iu-planner.js'; 32 + import { generateIU, generateAll } from './regen.js'; 33 + import { detectDrift } from './drift.js'; 34 + import { extractDependencies } from './dep-extractor.js'; 35 + import { validateBoundary } from './boundary-validator.js'; 36 + 37 + // Phase D 38 + import { evaluatePolicy, evaluateAllPolicies } from './policy-engine.js'; 39 + import { computeCascade } from './cascade.js'; 40 + 41 + // Phase E 42 + import { runShadowPipeline } from './shadow-pipeline.js'; 43 + 44 + // Phase F 45 + import { parseCommand, routeCommand, getAllCommands } from './bot-router.js'; 46 + 47 + // Scaffold 48 + import { deriveServices, generateScaffold } from './scaffold.js'; 49 + 50 + // Models 51 + import type { Clause } from './models/clause.js'; 52 + import { DiffType } from './models/clause.js'; 53 + import type { CanonicalNode } from './models/canonical.js'; 54 + import type { ImplementationUnit } from './models/iu.js'; 55 + import type { Diagnostic } from './models/diagnostic.js'; 56 + import type { DriftReport } from './models/manifest.js'; 57 + import { DriftStatus } from './models/manifest.js'; 58 + import { BootstrapState, DRateLevel } from './models/classification.js'; 59 + import type { PolicyEvaluation, CascadeEvent } from './models/evidence.js'; 60 + 61 + // ─── ANSI Colors ───────────────────────────────────────────────────────────── 62 + 63 + const BOLD = '\x1b[1m'; 64 + const DIM = '\x1b[2m'; 65 + const RESET = '\x1b[0m'; 66 + const RED = '\x1b[31m'; 67 + const GREEN = '\x1b[32m'; 68 + const YELLOW = '\x1b[33m'; 69 + const BLUE = '\x1b[34m'; 70 + const MAGENTA = '\x1b[35m'; 71 + const CYAN = '\x1b[36m'; 72 + const WHITE = '\x1b[37m'; 73 + const BG_RED = '\x1b[41m'; 74 + const BG_GREEN = '\x1b[42m'; 75 + const BG_YELLOW = '\x1b[43m'; 76 + 77 + function red(s: string): string { return `${RED}${s}${RESET}`; } 78 + function green(s: string): string { return `${GREEN}${s}${RESET}`; } 79 + function yellow(s: string): string { return `${YELLOW}${s}${RESET}`; } 80 + function blue(s: string): string { return `${BLUE}${s}${RESET}`; } 81 + function magenta(s: string): string { return `${MAGENTA}${s}${RESET}`; } 82 + function cyan(s: string): string { return `${CYAN}${s}${RESET}`; } 83 + function dim(s: string): string { return `${DIM}${s}${RESET}`; } 84 + function bold(s: string): string { return `${BOLD}${s}${RESET}`; } 85 + 86 + function severityColor(severity: string): string { 87 + switch (severity) { 88 + case 'error': return `${BG_RED}${WHITE}${BOLD} ERROR ${RESET}`; 89 + case 'warning': return `${BG_YELLOW}${WHITE}${BOLD} WARN ${RESET}`; 90 + case 'info': return `${BG_GREEN}${WHITE}${BOLD} INFO ${RESET}`; 91 + default: return severity; 92 + } 93 + } 94 + 95 + function severityIcon(severity: string): string { 96 + switch (severity) { 97 + case 'error': return red('✖'); 98 + case 'warning': return yellow('⚠'); 99 + case 'info': return blue('ℹ'); 100 + default: return ' '; 101 + } 102 + } 103 + 104 + // ─── Helpers ───────────────────────────────────────────────────────────────── 105 + 106 + const VERSION = '0.1.0'; 107 + 108 + function findPhoenixRoot(from: string = process.cwd()): string | null { 109 + let dir = resolve(from); 110 + while (true) { 111 + if (existsSync(join(dir, '.phoenix'))) return dir; 112 + const parent = resolve(dir, '..'); 113 + if (parent === dir) return null; 114 + dir = parent; 115 + } 116 + } 117 + 118 + function requirePhoenixRoot(): { projectRoot: string; phoenixDir: string } { 119 + const projectRoot = findPhoenixRoot(); 120 + if (!projectRoot) { 121 + console.error(red('✖ Not a Phoenix project. Run `phoenix init` first.')); 122 + process.exit(1); 123 + } 124 + return { projectRoot, phoenixDir: join(projectRoot, '.phoenix') }; 125 + } 126 + 127 + function loadBootstrapState(phoenixDir: string): BootstrapStateMachine { 128 + const statePath = join(phoenixDir, 'state.json'); 129 + if (existsSync(statePath)) { 130 + const data = JSON.parse(readFileSync(statePath, 'utf8')); 131 + return BootstrapStateMachine.fromJSON(data); 132 + } 133 + return new BootstrapStateMachine(); 134 + } 135 + 136 + function saveBootstrapState(phoenixDir: string, machine: BootstrapStateMachine): void { 137 + writeFileSync(join(phoenixDir, 'state.json'), JSON.stringify(machine.toJSON(), null, 2), 'utf8'); 138 + } 139 + 140 + function loadIUs(phoenixDir: string): ImplementationUnit[] { 141 + const iuPath = join(phoenixDir, 'graphs', 'ius.json'); 142 + if (!existsSync(iuPath)) return []; 143 + return JSON.parse(readFileSync(iuPath, 'utf8')); 144 + } 145 + 146 + function saveIUs(phoenixDir: string, ius: ImplementationUnit[]): void { 147 + const dir = join(phoenixDir, 'graphs'); 148 + mkdirSync(dir, { recursive: true }); 149 + writeFileSync(join(dir, 'ius.json'), JSON.stringify(ius, null, 2), 'utf8'); 150 + } 151 + 152 + function loadDRateTracker(phoenixDir: string): DRateTracker { 153 + const path = join(phoenixDir, 'drate.json'); 154 + if (existsSync(path)) { 155 + const data = JSON.parse(readFileSync(path, 'utf8')); 156 + const tracker = new DRateTracker(data.window_size || 100); 157 + // Re-record stored window 158 + if (data.window) { 159 + for (const cls of data.window) { 160 + tracker.recordOne(cls); 161 + } 162 + } 163 + return tracker; 164 + } 165 + return new DRateTracker(); 166 + } 167 + 168 + function saveDRateTracker(phoenixDir: string, tracker: DRateTracker): void { 169 + const status = tracker.getStatus(); 170 + writeFileSync(join(phoenixDir, 'drate.json'), JSON.stringify({ 171 + window_size: status.window_size, 172 + rate: status.rate, 173 + level: status.level, 174 + d_count: status.d_count, 175 + total_count: status.total_count, 176 + }, null, 2), 'utf8'); 177 + } 178 + 179 + function findSpecFiles(projectRoot: string): string[] { 180 + const specDir = join(projectRoot, 'spec'); 181 + if (!existsSync(specDir)) return []; 182 + return readdirSync(specDir, { recursive: true }) 183 + .map(f => f.toString()) 184 + .filter(f => f.endsWith('.md')) 185 + .map(f => join(specDir, f)); 186 + } 187 + 188 + function printDiagnosticTable(diagnostics: Diagnostic[]): void { 189 + if (diagnostics.length === 0) { 190 + console.log(green(' No issues found.')); 191 + return; 192 + } 193 + 194 + const errors = diagnostics.filter(d => d.severity === 'error'); 195 + const warnings = diagnostics.filter(d => d.severity === 'warning'); 196 + const infos = diagnostics.filter(d => d.severity === 'info'); 197 + 198 + for (const group of [ 199 + { items: errors, label: 'Errors' }, 200 + { items: warnings, label: 'Warnings' }, 201 + { items: infos, label: 'Info' }, 202 + ]) { 203 + if (group.items.length === 0) continue; 204 + console.log(); 205 + console.log(` ${bold(group.label)} (${group.items.length}):`); 206 + for (const d of group.items) { 207 + console.log(` ${severityIcon(d.severity)} ${bold(d.category)} ${dim('·')} ${d.subject}`); 208 + console.log(` ${d.message}`); 209 + if (d.recommended_actions.length > 0) { 210 + console.log(` ${dim('→')} ${dim(d.recommended_actions[0])}`); 211 + } 212 + } 213 + } 214 + } 215 + 216 + // ─── Commands ──────────────────────────────────────────────────────────────── 217 + 218 + function cmdInit(): void { 219 + const projectRoot = process.cwd(); 220 + const phoenixDir = join(projectRoot, '.phoenix'); 221 + 222 + if (existsSync(phoenixDir)) { 223 + console.log(yellow('⚠ Phoenix already initialized in this directory.')); 224 + return; 225 + } 226 + 227 + mkdirSync(join(phoenixDir, 'store', 'objects'), { recursive: true }); 228 + mkdirSync(join(phoenixDir, 'graphs'), { recursive: true }); 229 + mkdirSync(join(phoenixDir, 'manifests'), { recursive: true }); 230 + 231 + const machine = new BootstrapStateMachine(); 232 + saveBootstrapState(phoenixDir, machine); 233 + 234 + // Ensure spec/ directory exists 235 + const specDir = join(projectRoot, 'spec'); 236 + if (!existsSync(specDir)) { 237 + mkdirSync(specDir, { recursive: true }); 238 + } 239 + 240 + // Create .gitignore 241 + const gitignorePath = join(phoenixDir, '.gitignore'); 242 + if (!existsSync(gitignorePath)) { 243 + writeFileSync(gitignorePath, 'store/objects/\n', 'utf8'); 244 + } 245 + 246 + console.log(green('✔ Phoenix initialized.')); 247 + console.log(); 248 + console.log(` ${dim('Project root:')} ${projectRoot}`); 249 + console.log(` ${dim('Phoenix dir:')} ${phoenixDir}`); 250 + console.log(` ${dim('State:')} ${BootstrapState.BOOTSTRAP_COLD}`); 251 + console.log(); 252 + console.log(` ${dim('Next steps:')}`); 253 + console.log(` 1. Add spec documents to ${cyan('spec/')}`); 254 + console.log(` 2. Run ${cyan('phoenix bootstrap')} to ingest & canonicalize`); 255 + } 256 + 257 + function cmdBootstrap(): void { 258 + const { projectRoot, phoenixDir } = requirePhoenixRoot(); 259 + 260 + console.log(bold('🔥 Phoenix Bootstrap')); 261 + console.log(); 262 + 263 + const specStore = new SpecStore(phoenixDir); 264 + const canonStore = new CanonicalStore(phoenixDir); 265 + const machine = loadBootstrapState(phoenixDir); 266 + 267 + // Step 1: Find and ingest spec files 268 + const specFiles = findSpecFiles(projectRoot); 269 + if (specFiles.length === 0) { 270 + console.log(yellow(' ⚠ No spec files found in spec/ directory.')); 271 + console.log(dim(` Add .md files to ${join(projectRoot, 'spec')} and re-run.`)); 272 + return; 273 + } 274 + 275 + console.log(` ${dim('Phase A:')} Clause extraction + cold hashing`); 276 + let totalClauses = 0; 277 + for (const specFile of specFiles) { 278 + const result = specStore.ingestDocument(specFile, projectRoot); 279 + totalClauses += result.clauses.length; 280 + console.log(` ${green('✔')} ${relative(projectRoot, specFile)} → ${result.clauses.length} clauses`); 281 + } 282 + console.log(` ${dim(`Total: ${totalClauses} clauses extracted`)}`); 283 + console.log(); 284 + 285 + // Step 2: Canonicalization 286 + console.log(` ${dim('Phase B:')} Canonicalization + warm context hashing`); 287 + 288 + // Collect all clauses 289 + const allClauses: Clause[] = []; 290 + for (const specFile of specFiles) { 291 + const docId = relative(projectRoot, specFile); 292 + allClauses.push(...specStore.getClauses(docId)); 293 + } 294 + 295 + // Extract canonical nodes 296 + const canonNodes = extractCanonicalNodes(allClauses); 297 + canonStore.saveNodes(canonNodes); 298 + console.log(` ${green('✔')} ${canonNodes.length} canonical nodes extracted`); 299 + 300 + // Compute warm hashes 301 + const warmHashes = computeWarmHashes(allClauses, canonNodes); 302 + console.log(` ${green('✔')} ${warmHashes.size} warm context hashes computed`); 303 + 304 + // Save warm hashes 305 + const warmPath = join(phoenixDir, 'graphs', 'warm-hashes.json'); 306 + const warmObj: Record<string, string> = {}; 307 + for (const [k, v] of warmHashes) warmObj[k] = v; 308 + writeFileSync(warmPath, JSON.stringify(warmObj, null, 2), 'utf8'); 309 + 310 + // Mark warm pass complete 311 + machine.markWarmPassComplete(); 312 + console.log(` ${green('✔')} System state: ${cyan(machine.getState())}`); 313 + console.log(); 314 + 315 + // Step 3: Plan IUs 316 + console.log(` ${dim('Phase C:')} IU planning`); 317 + const ius = planIUs(canonNodes, allClauses); 318 + saveIUs(phoenixDir, ius); 319 + console.log(` ${green('✔')} ${ius.length} Implementation Units planned`); 320 + for (const iu of ius) { 321 + console.log(` ${dim('·')} ${iu.name} ${dim(`(${iu.risk_tier})`)} → ${iu.output_files.join(', ')}`); 322 + } 323 + console.log(); 324 + 325 + // Step 4: Generate code 326 + console.log(` ${dim('Phase C:')} Code generation`); 327 + const manifestManager = new ManifestManager(phoenixDir); 328 + const regenResults = generateAll(ius); 329 + for (const result of regenResults) { 330 + for (const [filePath, content] of result.files) { 331 + const fullPath = join(projectRoot, filePath); 332 + mkdirSync(join(fullPath, '..'), { recursive: true }); 333 + writeFileSync(fullPath, content, 'utf8'); 334 + } 335 + manifestManager.recordIU(result.manifest); 336 + const fileCount = result.files.size; 337 + console.log(` ${green('✔')} ${result.iu_id.slice(0, 8)}… → ${fileCount} file(s) generated`); 338 + } 339 + console.log(); 340 + 341 + // Step 5: Service scaffold 342 + console.log(` ${dim('Scaffold:')} Service wiring + project config`); 343 + const services = deriveServices(ius); 344 + const projectName = basename(projectRoot); 345 + const scaffold = generateScaffold(services, projectName); 346 + for (const [filePath, content] of scaffold.files) { 347 + const fullPath = join(projectRoot, filePath); 348 + mkdirSync(join(fullPath, '..'), { recursive: true }); 349 + writeFileSync(fullPath, content, 'utf8'); 350 + } 351 + for (const svc of services) { 352 + console.log(` ${green('✔')} ${svc.name} → :${svc.port} (${svc.modules.length} modules)`); 353 + } 354 + console.log(` ${green('✔')} package.json, tsconfig.json`); 355 + console.log(); 356 + 357 + // Save state 358 + saveBootstrapState(phoenixDir, machine); 359 + 360 + // Step 6: First trust dashboard 361 + console.log(` ${dim('Phase D:')} Trust Dashboard`); 362 + console.log(); 363 + printTrustDashboard(phoenixDir, projectRoot, machine, ius, canonNodes, allClauses); 364 + 365 + console.log(); 366 + console.log(green(' ✔ Bootstrap complete.')); 367 + console.log(` State: ${cyan(machine.getState())}`); 368 + console.log(` Run ${cyan('phoenix status')} to see the trust dashboard.`); 369 + } 370 + 371 + function cmdStatus(): void { 372 + const { projectRoot, phoenixDir } = requirePhoenixRoot(); 373 + const machine = loadBootstrapState(phoenixDir); 374 + const ius = loadIUs(phoenixDir); 375 + const canonStore = new CanonicalStore(phoenixDir); 376 + const canonNodes = canonStore.getAllNodes(); 377 + const specStore = new SpecStore(phoenixDir); 378 + 379 + // Collect all clauses 380 + const allClauses: Clause[] = []; 381 + const specFiles = findSpecFiles(projectRoot); 382 + for (const specFile of specFiles) { 383 + const docId = relative(projectRoot, specFile); 384 + allClauses.push(...specStore.getClauses(docId)); 385 + } 386 + 387 + console.log(); 388 + console.log(bold('🔥 Phoenix Status')); 389 + console.log(); 390 + 391 + printTrustDashboard(phoenixDir, projectRoot, machine, ius, canonNodes, allClauses); 392 + } 393 + 394 + function printTrustDashboard( 395 + phoenixDir: string, 396 + projectRoot: string, 397 + machine: BootstrapStateMachine, 398 + ius: ImplementationUnit[], 399 + canonNodes: CanonicalNode[], 400 + allClauses: Clause[], 401 + ): void { 402 + const diagnostics: Diagnostic[] = []; 403 + 404 + // System state 405 + const state = machine.getState(); 406 + const stateLabel = state === BootstrapState.STEADY_STATE 407 + ? green(state) 408 + : state === BootstrapState.BOOTSTRAP_WARMING 409 + ? yellow(state) 410 + : cyan(state); 411 + console.log(` ${dim('System State:')} ${stateLabel}`); 412 + console.log(` ${dim('Canonical Nodes:')} ${canonNodes.length}`); 413 + console.log(` ${dim('Implementation Units:')} ${ius.length}`); 414 + console.log(` ${dim('Spec Clauses:')} ${allClauses.length}`); 415 + console.log(); 416 + 417 + // D-rate 418 + const dRateTracker = loadDRateTracker(phoenixDir); 419 + const dRate = dRateTracker.getStatus(); 420 + if (dRate.total_count > 0) { 421 + const pct = (dRate.rate * 100).toFixed(1); 422 + let dRateColor: (s: string) => string; 423 + switch (dRate.level) { 424 + case DRateLevel.TARGET: dRateColor = green; break; 425 + case DRateLevel.ACCEPTABLE: dRateColor = green; break; 426 + case DRateLevel.WARNING: dRateColor = yellow; break; 427 + case DRateLevel.ALARM: dRateColor = red; break; 428 + } 429 + console.log(` ${dim('D-Rate:')} ${dRateColor(`${pct}%`)} ${dim(`(${dRate.level}, ${dRate.d_count}/${dRate.total_count})`)}`); 430 + 431 + if (dRate.level === DRateLevel.WARNING || dRate.level === DRateLevel.ALARM) { 432 + if (!machine.shouldSuppressAlarms()) { 433 + diagnostics.push({ 434 + severity: machine.shouldDowngradeSeverity() ? 'warning' : 'error', 435 + category: 'd-rate', 436 + subject: 'Global', 437 + message: `D-rate ${pct}% (${dRate.level})`, 438 + recommended_actions: ['Tune classifier or resolve uncertain changes'], 439 + }); 440 + } 441 + } 442 + } else { 443 + console.log(` ${dim('D-Rate:')} ${dim('no data')}`); 444 + } 445 + 446 + // Drift detection 447 + const manifestManager = new ManifestManager(phoenixDir); 448 + const manifest = manifestManager.load(); 449 + if (manifest.generated_at) { 450 + const driftReport = detectDrift(manifest, projectRoot); 451 + const driftLabel = driftReport.drifted_count === 0 && driftReport.missing_count === 0 452 + ? green('clean') 453 + : red(`${driftReport.drifted_count} drifted, ${driftReport.missing_count} missing`); 454 + console.log(` ${dim('Drift:')} ${driftLabel} ${dim(`(${driftReport.clean_count} clean)`)}`); 455 + 456 + for (const entry of driftReport.entries) { 457 + if (entry.status === DriftStatus.DRIFTED) { 458 + diagnostics.push({ 459 + severity: 'error', 460 + category: 'drift', 461 + subject: entry.file_path, 462 + iu_id: entry.iu_id, 463 + message: `Working tree differs from generated manifest`, 464 + recommended_actions: ['Label edit (promote_to_requirement, waiver, or temporary_patch)', 'Or run `phoenix regen` to regenerate'], 465 + }); 466 + } 467 + if (entry.status === DriftStatus.MISSING) { 468 + diagnostics.push({ 469 + severity: 'error', 470 + category: 'drift', 471 + subject: entry.file_path, 472 + iu_id: entry.iu_id, 473 + message: `Generated file is missing from working tree`, 474 + recommended_actions: ['Run `phoenix regen` to regenerate'], 475 + }); 476 + } 477 + } 478 + } else { 479 + console.log(` ${dim('Drift:')} ${dim('no manifest')}`); 480 + } 481 + 482 + // Boundary validation 483 + for (const iu of ius) { 484 + for (const outputFile of iu.output_files) { 485 + const fullPath = join(projectRoot, outputFile); 486 + if (!existsSync(fullPath)) continue; 487 + const source = readFileSync(fullPath, 'utf8'); 488 + const depGraph = extractDependencies(source, outputFile); 489 + const boundaryDiags = validateBoundary(depGraph, iu); 490 + diagnostics.push(...boundaryDiags); 491 + } 492 + } 493 + 494 + // Policy evaluation 495 + const evidenceStore = new EvidenceStore(phoenixDir); 496 + const allEvidence = evidenceStore.getAll(); 497 + const policyEvals = evaluateAllPolicies(ius, allEvidence); 498 + 499 + let passCount = 0; 500 + let failCount = 0; 501 + let incompleteCount = 0; 502 + 503 + for (const eval_ of policyEvals) { 504 + switch (eval_.verdict) { 505 + case 'PASS': passCount++; break; 506 + case 'FAIL': failCount++; break; 507 + case 'INCOMPLETE': incompleteCount++; break; 508 + } 509 + 510 + if (eval_.verdict === 'FAIL') { 511 + diagnostics.push({ 512 + severity: 'error', 513 + category: 'evidence', 514 + subject: eval_.iu_name, 515 + iu_id: eval_.iu_id, 516 + message: `Evidence failed: ${eval_.failed.join(', ')}`, 517 + recommended_actions: ['Re-run failing evidence checks', `Risk tier: ${eval_.risk_tier}`], 518 + }); 519 + } else if (eval_.verdict === 'INCOMPLETE') { 520 + diagnostics.push({ 521 + severity: 'warning', 522 + category: 'evidence', 523 + subject: eval_.iu_name, 524 + iu_id: eval_.iu_id, 525 + message: `Missing evidence: ${eval_.missing.join(', ')}`, 526 + recommended_actions: [`Collect required evidence for ${eval_.risk_tier} tier`], 527 + }); 528 + } 529 + } 530 + 531 + console.log(` ${dim('Evidence:')} ${green(`${passCount} pass`)}, ${failCount > 0 ? red(`${failCount} fail`) : dim(`${failCount} fail`)}, ${incompleteCount > 0 ? yellow(`${incompleteCount} incomplete`) : dim(`${incompleteCount} incomplete`)}`); 532 + 533 + // Cascade effects 534 + const cascadeEvents = computeCascade(policyEvals, ius); 535 + if (cascadeEvents.length > 0) { 536 + console.log(` ${dim('Cascades:')} ${yellow(`${cascadeEvents.length} active`)}`); 537 + for (const event of cascadeEvents) { 538 + for (const action of event.actions) { 539 + if (action.action === 'BLOCK') { 540 + diagnostics.push({ 541 + severity: 'error', 542 + category: 'evidence', 543 + subject: action.iu_name, 544 + iu_id: action.iu_id, 545 + message: `BLOCKED: ${action.reason}`, 546 + recommended_actions: ['Fix failing evidence before proceeding'], 547 + }); 548 + } else if (action.action === 'RE_VALIDATE') { 549 + diagnostics.push({ 550 + severity: 'warning', 551 + category: 'evidence', 552 + subject: action.iu_name, 553 + iu_id: action.iu_id, 554 + message: `Re-validation needed: ${action.reason}`, 555 + recommended_actions: ['Re-run typecheck + boundary + tagged tests'], 556 + }); 557 + } 558 + } 559 + } 560 + } else { 561 + console.log(` ${dim('Cascades:')} ${dim('none')}`); 562 + } 563 + 564 + console.log(); 565 + 566 + // Diagnostics table 567 + console.log(bold(' ─── Diagnostics ───')); 568 + printDiagnosticTable(diagnostics); 569 + console.log(); 570 + 571 + // Summary line 572 + const errors = diagnostics.filter(d => d.severity === 'error').length; 573 + const warnings = diagnostics.filter(d => d.severity === 'warning').length; 574 + const infos = diagnostics.filter(d => d.severity === 'info').length; 575 + 576 + if (errors === 0 && warnings === 0) { 577 + console.log(green(' ✔ All clear.')); 578 + } else { 579 + const parts: string[] = []; 580 + if (errors > 0) parts.push(red(`${errors} error${errors !== 1 ? 's' : ''}`)); 581 + if (warnings > 0) parts.push(yellow(`${warnings} warning${warnings !== 1 ? 's' : ''}`)); 582 + if (infos > 0) parts.push(blue(`${infos} info`)); 583 + console.log(` ${parts.join(', ')}`); 584 + } 585 + } 586 + 587 + function cmdIngest(args: string[]): void { 588 + const { projectRoot, phoenixDir } = requirePhoenixRoot(); 589 + const specStore = new SpecStore(phoenixDir); 590 + 591 + let files: string[]; 592 + if (args.length === 0) { 593 + files = findSpecFiles(projectRoot); 594 + if (files.length === 0) { 595 + console.log(yellow('⚠ No spec files found. Provide a path or add files to spec/.')); 596 + return; 597 + } 598 + } else { 599 + files = args.map(f => resolve(f)); 600 + for (const f of files) { 601 + if (!existsSync(f)) { 602 + console.error(red(`✖ File not found: ${f}`)); 603 + process.exit(1); 604 + } 605 + } 606 + } 607 + 608 + console.log(bold('📥 Spec Ingestion')); 609 + console.log(); 610 + 611 + let totalClauses = 0; 612 + for (const file of files) { 613 + const result = specStore.ingestDocument(file, projectRoot); 614 + totalClauses += result.clauses.length; 615 + console.log(` ${green('✔')} ${relative(projectRoot, file)} → ${result.clauses.length} clauses`); 616 + } 617 + 618 + console.log(); 619 + console.log(` ${dim(`Total: ${totalClauses} clauses ingested`)}`); 620 + } 621 + 622 + function cmdDiff(args: string[]): void { 623 + const { projectRoot, phoenixDir } = requirePhoenixRoot(); 624 + const specStore = new SpecStore(phoenixDir); 625 + 626 + let files: string[]; 627 + if (args.length === 0) { 628 + files = findSpecFiles(projectRoot); 629 + } else { 630 + files = args.map(f => resolve(f)); 631 + } 632 + 633 + console.log(bold('📊 Clause Diff')); 634 + console.log(); 635 + 636 + for (const file of files) { 637 + if (!existsSync(file)) { 638 + console.log(red(` ✖ ${file}: not found`)); 639 + continue; 640 + } 641 + 642 + const docId = relative(projectRoot, file); 643 + const diffs = specStore.diffDocument(file, projectRoot); 644 + 645 + const added = diffs.filter(d => d.diff_type === DiffType.ADDED).length; 646 + const removed = diffs.filter(d => d.diff_type === DiffType.REMOVED).length; 647 + const modified = diffs.filter(d => d.diff_type === DiffType.MODIFIED).length; 648 + const moved = diffs.filter(d => d.diff_type === DiffType.MOVED).length; 649 + const unchanged = diffs.filter(d => d.diff_type === DiffType.UNCHANGED).length; 650 + 651 + console.log(` ${bold(docId)}`); 652 + 653 + if (diffs.length === 0) { 654 + console.log(` ${dim('(no stored clauses to compare against)')}`); 655 + continue; 656 + } 657 + 658 + if (added === 0 && removed === 0 && modified === 0 && moved === 0) { 659 + console.log(` ${green('✔')} No changes (${unchanged} clauses)`); 660 + continue; 661 + } 662 + 663 + if (added > 0) console.log(` ${green(`+${added} added`)}`); 664 + if (removed > 0) console.log(` ${red(`-${removed} removed`)}`); 665 + if (modified > 0) console.log(` ${yellow(`~${modified} modified`)}`); 666 + if (moved > 0) console.log(` ${blue(`↗${moved} moved`)}`); 667 + console.log(` ${dim(`${unchanged} unchanged`)}`); 668 + 669 + // Show details for non-trivial changes 670 + for (const d of diffs) { 671 + if (d.diff_type === DiffType.UNCHANGED) continue; 672 + const pathLabel = d.section_path_after?.join(' > ') || d.section_path_before?.join(' > ') || ''; 673 + switch (d.diff_type) { 674 + case DiffType.ADDED: 675 + console.log(` ${green('+')} ${pathLabel}`); 676 + break; 677 + case DiffType.REMOVED: 678 + console.log(` ${red('-')} ${pathLabel}`); 679 + break; 680 + case DiffType.MODIFIED: 681 + console.log(` ${yellow('~')} ${pathLabel}`); 682 + break; 683 + case DiffType.MOVED: 684 + console.log(` ${blue('↗')} ${d.section_path_before?.join(' > ')} → ${d.section_path_after?.join(' > ')}`); 685 + break; 686 + } 687 + } 688 + console.log(); 689 + } 690 + } 691 + 692 + function cmdClauses(args: string[]): void { 693 + const { projectRoot, phoenixDir } = requirePhoenixRoot(); 694 + const specStore = new SpecStore(phoenixDir); 695 + 696 + let files: string[]; 697 + if (args.length === 0) { 698 + files = findSpecFiles(projectRoot); 699 + } else { 700 + files = args.map(f => resolve(f)); 701 + } 702 + 703 + console.log(bold('📋 Stored Clauses')); 704 + console.log(); 705 + 706 + for (const file of files) { 707 + const docId = relative(projectRoot, file); 708 + const clauses = specStore.getClauses(docId); 709 + 710 + console.log(` ${bold(docId)} ${dim(`(${clauses.length} clauses)`)}`); 711 + for (const c of clauses) { 712 + const path = c.section_path.join(' > ') || '(root)'; 713 + const lines = `L${c.source_line_range[0]}–${c.source_line_range[1]}`; 714 + const preview = c.normalized_text.slice(0, 80).replace(/\n/g, ' '); 715 + console.log(` ${dim(c.clause_id.slice(0, 8))} ${cyan(path)} ${dim(lines)}`); 716 + console.log(` ${dim(preview)}${c.normalized_text.length > 80 ? '…' : ''}`); 717 + } 718 + console.log(); 719 + } 720 + } 721 + 722 + function cmdCanon(): void { 723 + const { phoenixDir } = requirePhoenixRoot(); 724 + const canonStore = new CanonicalStore(phoenixDir); 725 + const nodes = canonStore.getAllNodes(); 726 + 727 + console.log(bold('📐 Canonical Graph')); 728 + console.log(); 729 + console.log(` ${dim(`${nodes.length} nodes`)}`); 730 + console.log(); 731 + 732 + const byType = new Map<string, CanonicalNode[]>(); 733 + for (const node of nodes) { 734 + const list = byType.get(node.type) || []; 735 + list.push(node); 736 + byType.set(node.type, list); 737 + } 738 + 739 + for (const [type, typeNodes] of byType) { 740 + const color = type === 'REQUIREMENT' ? green 741 + : type === 'CONSTRAINT' ? red 742 + : type === 'INVARIANT' ? magenta 743 + : blue; 744 + console.log(` ${color(bold(type))} (${typeNodes.length})`); 745 + for (const node of typeNodes) { 746 + const preview = node.statement.slice(0, 80).replace(/\n/g, ' '); 747 + const links = node.linked_canon_ids.length > 0 748 + ? dim(` ← ${node.linked_canon_ids.length} links`) 749 + : ''; 750 + console.log(` ${dim(node.canon_id.slice(0, 8))} ${preview}${node.statement.length > 80 ? '…' : ''}${links}`); 751 + } 752 + console.log(); 753 + } 754 + } 755 + 756 + function cmdPlan(): void { 757 + const { projectRoot, phoenixDir } = requirePhoenixRoot(); 758 + const canonStore = new CanonicalStore(phoenixDir); 759 + const specStore = new SpecStore(phoenixDir); 760 + 761 + const canonNodes = canonStore.getAllNodes(); 762 + if (canonNodes.length === 0) { 763 + console.log(yellow('⚠ No canonical nodes. Run `phoenix bootstrap` or `phoenix ingest` + `phoenix canonicalize` first.')); 764 + return; 765 + } 766 + 767 + // Collect clauses 768 + const allClauses: Clause[] = []; 769 + const specFiles = findSpecFiles(projectRoot); 770 + for (const specFile of specFiles) { 771 + const docId = relative(projectRoot, specFile); 772 + allClauses.push(...specStore.getClauses(docId)); 773 + } 774 + 775 + const ius = planIUs(canonNodes, allClauses); 776 + saveIUs(phoenixDir, ius); 777 + 778 + console.log(bold('📦 IU Plan')); 779 + console.log(); 780 + console.log(` ${green(`${ius.length} Implementation Units planned`)}`); 781 + console.log(); 782 + 783 + for (const iu of ius) { 784 + const riskColor = iu.risk_tier === 'critical' ? red 785 + : iu.risk_tier === 'high' ? yellow 786 + : iu.risk_tier === 'medium' ? cyan 787 + : green; 788 + console.log(` ${bold(iu.name)}`); 789 + console.log(` ${dim('ID:')} ${iu.iu_id.slice(0, 12)}…`); 790 + console.log(` ${dim('Risk:')} ${riskColor(iu.risk_tier)}`); 791 + console.log(` ${dim('Kind:')} ${iu.kind}`); 792 + console.log(` ${dim('Sources:')} ${iu.source_canon_ids.length} canonical nodes`); 793 + console.log(` ${dim('Output:')} ${iu.output_files.join(', ')}`); 794 + console.log(` ${dim('Evidence:')} ${iu.evidence_policy.required.join(', ')}`); 795 + if (iu.contract.invariants.length > 0) { 796 + console.log(` ${dim('Invariants:')}`); 797 + for (const inv of iu.contract.invariants) { 798 + console.log(` ${dim('·')} ${inv.slice(0, 80)}`); 799 + } 800 + } 801 + console.log(); 802 + } 803 + } 804 + 805 + function cmdRegen(args: string[]): void { 806 + const { projectRoot, phoenixDir } = requirePhoenixRoot(); 807 + const ius = loadIUs(phoenixDir); 808 + 809 + if (ius.length === 0) { 810 + console.log(yellow('⚠ No IUs planned. Run `phoenix plan` first.')); 811 + return; 812 + } 813 + 814 + // Parse --iu=<id> flag 815 + const iuFilter = args.find(a => a.startsWith('--iu='))?.split('=')[1]; 816 + const targetIUs = iuFilter 817 + ? ius.filter(iu => iu.iu_id.startsWith(iuFilter) || iu.name === iuFilter) 818 + : ius; 819 + 820 + if (targetIUs.length === 0) { 821 + console.log(red(`✖ No IU matching: ${iuFilter}`)); 822 + return; 823 + } 824 + 825 + console.log(bold('⚡ Code Regeneration')); 826 + console.log(); 827 + 828 + const manifestManager = new ManifestManager(phoenixDir); 829 + const results = generateAll(targetIUs); 830 + 831 + for (const result of results) { 832 + for (const [filePath, content] of result.files) { 833 + const fullPath = join(projectRoot, filePath); 834 + mkdirSync(join(fullPath, '..'), { recursive: true }); 835 + writeFileSync(fullPath, content, 'utf8'); 836 + } 837 + manifestManager.recordIU(result.manifest); 838 + 839 + const iu = targetIUs.find(i => i.iu_id === result.iu_id); 840 + console.log(` ${green('✔')} ${iu?.name || result.iu_id.slice(0, 12)}`); 841 + for (const [filePath] of result.files) { 842 + console.log(` → ${cyan(filePath)}`); 843 + } 844 + } 845 + 846 + // Re-generate scaffold wiring 847 + const allIUs = loadIUs(phoenixDir); 848 + const services = deriveServices(allIUs); 849 + const scaffold = generateScaffold(services, basename(projectRoot)); 850 + for (const [filePath, content] of scaffold.files) { 851 + const fullPath = join(projectRoot, filePath); 852 + mkdirSync(join(fullPath, '..'), { recursive: true }); 853 + writeFileSync(fullPath, content, 'utf8'); 854 + } 855 + 856 + console.log(); 857 + console.log(` ${dim(`${results.length} IU(s) regenerated. Scaffold updated.`)}`); 858 + } 859 + 860 + function cmdDrift(): void { 861 + const { projectRoot, phoenixDir } = requirePhoenixRoot(); 862 + const manifestManager = new ManifestManager(phoenixDir); 863 + const manifest = manifestManager.load(); 864 + 865 + if (!manifest.generated_at) { 866 + console.log(yellow('⚠ No generated manifest. Run `phoenix regen` first.')); 867 + return; 868 + } 869 + 870 + const report = detectDrift(manifest, projectRoot); 871 + 872 + console.log(bold('🔍 Drift Detection')); 873 + console.log(); 874 + 875 + if (report.drifted_count === 0 && report.missing_count === 0) { 876 + console.log(` ${green('✔')} ${report.summary}`); 877 + } else { 878 + console.log(` ${red('✖')} ${report.summary}`); 879 + } 880 + console.log(); 881 + 882 + for (const entry of report.entries) { 883 + switch (entry.status) { 884 + case DriftStatus.CLEAN: 885 + console.log(` ${green('✔')} ${entry.file_path}`); 886 + break; 887 + case DriftStatus.DRIFTED: 888 + console.log(` ${red('✖')} ${entry.file_path} ${red('DRIFTED')}`); 889 + console.log(` ${dim('expected:')} ${entry.expected_hash?.slice(0, 12)}…`); 890 + console.log(` ${dim('actual:')} ${entry.actual_hash?.slice(0, 12)}…`); 891 + console.log(` ${dim('→ Label this edit: promote_to_requirement | waiver | temporary_patch')}`); 892 + break; 893 + case DriftStatus.MISSING: 894 + console.log(` ${red('✖')} ${entry.file_path} ${red('MISSING')}`); 895 + console.log(` ${dim('→ Run `phoenix regen` to regenerate')}`); 896 + break; 897 + case DriftStatus.WAIVED: 898 + console.log(` ${yellow('⚠')} ${entry.file_path} ${yellow('WAIVED')}`); 899 + if (entry.waiver) { 900 + console.log(` ${dim('kind:')} ${entry.waiver.kind}`); 901 + console.log(` ${dim('reason:')} ${entry.waiver.reason}`); 902 + } 903 + break; 904 + } 905 + } 906 + } 907 + 908 + function cmdCanonicalize(): void { 909 + const { projectRoot, phoenixDir } = requirePhoenixRoot(); 910 + const specStore = new SpecStore(phoenixDir); 911 + const canonStore = new CanonicalStore(phoenixDir); 912 + 913 + const allClauses: Clause[] = []; 914 + const specFiles = findSpecFiles(projectRoot); 915 + for (const specFile of specFiles) { 916 + const docId = relative(projectRoot, specFile); 917 + allClauses.push(...specStore.getClauses(docId)); 918 + } 919 + 920 + if (allClauses.length === 0) { 921 + console.log(yellow('⚠ No ingested clauses. Run `phoenix ingest` first.')); 922 + return; 923 + } 924 + 925 + console.log(bold('📐 Canonicalization')); 926 + console.log(); 927 + 928 + const canonNodes = extractCanonicalNodes(allClauses); 929 + canonStore.saveNodes(canonNodes); 930 + 931 + console.log(` ${green('✔')} ${canonNodes.length} canonical nodes extracted from ${allClauses.length} clauses`); 932 + 933 + const byType = new Map<string, number>(); 934 + for (const node of canonNodes) { 935 + byType.set(node.type, (byType.get(node.type) || 0) + 1); 936 + } 937 + for (const [type, count] of byType) { 938 + console.log(` ${dim('·')} ${type}: ${count}`); 939 + } 940 + 941 + // Compute warm hashes 942 + const warmHashes = computeWarmHashes(allClauses, canonNodes); 943 + const warmPath = join(phoenixDir, 'graphs', 'warm-hashes.json'); 944 + const warmObj: Record<string, string> = {}; 945 + for (const [k, v] of warmHashes) warmObj[k] = v; 946 + writeFileSync(warmPath, JSON.stringify(warmObj, null, 2), 'utf8'); 947 + 948 + console.log(` ${green('✔')} ${warmHashes.size} warm context hashes computed`); 949 + } 950 + 951 + function cmdEvaluate(args: string[]): void { 952 + const { phoenixDir } = requirePhoenixRoot(); 953 + const ius = loadIUs(phoenixDir); 954 + const evidenceStore = new EvidenceStore(phoenixDir); 955 + const allEvidence = evidenceStore.getAll(); 956 + 957 + const iuFilter = args.find(a => a.startsWith('--iu='))?.split('=')[1]; 958 + const targetIUs = iuFilter 959 + ? ius.filter(iu => iu.iu_id.startsWith(iuFilter) || iu.name === iuFilter) 960 + : ius; 961 + 962 + const evals = evaluateAllPolicies(targetIUs, allEvidence); 963 + 964 + console.log(bold('📋 Policy Evaluation')); 965 + console.log(); 966 + 967 + for (const eval_ of evals) { 968 + const verdictColor = eval_.verdict === 'PASS' ? green 969 + : eval_.verdict === 'FAIL' ? red 970 + : yellow; 971 + 972 + console.log(` ${verdictColor(eval_.verdict)} ${bold(eval_.iu_name)} ${dim(`(${eval_.risk_tier})`)}`); 973 + if (eval_.satisfied.length > 0) { 974 + console.log(` ${green('✔')} ${eval_.satisfied.join(', ')}`); 975 + } 976 + if (eval_.missing.length > 0) { 977 + console.log(` ${yellow('○')} Missing: ${eval_.missing.join(', ')}`); 978 + } 979 + if (eval_.failed.length > 0) { 980 + console.log(` ${red('✖')} Failed: ${eval_.failed.join(', ')}`); 981 + } 982 + console.log(); 983 + } 984 + } 985 + 986 + function cmdCascade(): void { 987 + const { phoenixDir } = requirePhoenixRoot(); 988 + const ius = loadIUs(phoenixDir); 989 + const evidenceStore = new EvidenceStore(phoenixDir); 990 + const allEvidence = evidenceStore.getAll(); 991 + const evals = evaluateAllPolicies(ius, allEvidence); 992 + const cascadeEvents = computeCascade(evals, ius); 993 + 994 + console.log(bold('🌊 Cascade Effects')); 995 + console.log(); 996 + 997 + if (cascadeEvents.length === 0) { 998 + console.log(` ${green('✔')} No cascading failures.`); 999 + return; 1000 + } 1001 + 1002 + for (const event of cascadeEvents) { 1003 + console.log(` ${red('✖')} ${bold(event.source_iu_name)} (${event.failure_kind})`); 1004 + for (const action of event.actions) { 1005 + const icon = action.action === 'BLOCK' ? red('⊘') : yellow('↻'); 1006 + console.log(` ${icon} ${action.iu_name}: ${action.action}`); 1007 + console.log(` ${dim(action.reason)}`); 1008 + } 1009 + console.log(); 1010 + } 1011 + } 1012 + 1013 + function cmdBot(args: string[]): void { 1014 + if (args.length === 0) { 1015 + // Show all bot commands 1016 + const commands = getAllCommands(); 1017 + console.log(bold('🤖 Phoenix Bots')); 1018 + console.log(); 1019 + for (const [bot, cmds] of Object.entries(commands)) { 1020 + console.log(` ${bold(bot)}: ${cmds.join(', ')}`); 1021 + } 1022 + console.log(); 1023 + console.log(dim(' Usage: phoenix bot "BotName: action arg=value"')); 1024 + return; 1025 + } 1026 + 1027 + const raw = args.join(' '); 1028 + const parsed = parseCommand(raw); 1029 + 1030 + if ('error' in parsed) { 1031 + console.error(red(`✖ ${parsed.error}`)); 1032 + process.exit(1); 1033 + } 1034 + 1035 + const response = routeCommand(parsed); 1036 + 1037 + console.log(bold(`🤖 ${response.bot}`)); 1038 + console.log(); 1039 + console.log(` ${response.message}`); 1040 + 1041 + if (response.mutating && response.confirm_id) { 1042 + console.log(); 1043 + console.log(dim(` Confirmation ID: ${response.confirm_id}`)); 1044 + } 1045 + } 1046 + 1047 + function cmdGraph(): void { 1048 + const { phoenixDir } = requirePhoenixRoot(); 1049 + const canonStore = new CanonicalStore(phoenixDir); 1050 + const graph = canonStore.getGraph(); 1051 + const ius = loadIUs(phoenixDir); 1052 + 1053 + console.log(bold('🕸️ Provenance Graph')); 1054 + console.log(); 1055 + 1056 + // Clause → Canon 1057 + const provenanceCount = Object.values(graph.provenance).reduce((sum, arr) => sum + arr.length, 0); 1058 + console.log(` ${dim('Provenance edges:')} ${provenanceCount}`); 1059 + console.log(` ${dim('Canon → Canon links:')} ${Object.values(graph.nodes).reduce((sum, n) => sum + n.linked_canon_ids.length, 0)}`); 1060 + console.log(` ${dim('Canon → IU mappings:')} ${ius.reduce((sum, iu) => sum + iu.source_canon_ids.length, 0)}`); 1061 + console.log(); 1062 + 1063 + // Show IU dependency graph 1064 + if (ius.length > 0) { 1065 + console.log(` ${bold('IU Dependency Graph:')}`); 1066 + for (const iu of ius) { 1067 + const deps = iu.dependencies.length > 0 1068 + ? iu.dependencies.map(d => { 1069 + const dep = ius.find(i => i.iu_id === d); 1070 + return dep?.name || d.slice(0, 8); 1071 + }).join(', ') 1072 + : dim('(none)'); 1073 + console.log(` ${iu.name} → ${deps}`); 1074 + } 1075 + } 1076 + } 1077 + 1078 + function cmdVersion(): void { 1079 + console.log(`Phoenix VCS v${VERSION}`); 1080 + } 1081 + 1082 + function cmdHelp(): void { 1083 + console.log(` 1084 + ${bold('🔥 Phoenix VCS')} — Regenerative Version Control 1085 + ${dim(`v${VERSION}`)} 1086 + 1087 + ${bold('Usage:')} phoenix <command> [options] 1088 + 1089 + ${bold('Getting Started:')} 1090 + ${cyan('init')} Initialize a new Phoenix project 1091 + ${cyan('bootstrap')} Full bootstrap: ingest → canonicalize → plan → generate 1092 + 1093 + ${bold('Spec Management:')} 1094 + ${cyan('ingest')} [files...] Ingest spec documents (default: all in spec/) 1095 + ${cyan('diff')} [files...] Show clause diffs vs stored state 1096 + ${cyan('clauses')} [files...] List stored clauses 1097 + 1098 + ${bold('Canonical Graph:')} 1099 + ${cyan('canonicalize')} Extract canonical nodes from ingested clauses 1100 + ${cyan('canon')} Show the canonical graph 1101 + 1102 + ${bold('Implementation:')} 1103 + ${cyan('plan')} Plan Implementation Units from canonical graph 1104 + ${cyan('regen')} [--iu=<id>] Regenerate code (all or specific IU) 1105 + 1106 + ${bold('Verification:')} 1107 + ${cyan('status')} Trust dashboard — the primary UX 1108 + ${cyan('drift')} Check generated files for drift 1109 + ${cyan('evaluate')} [--iu=<id>] Evaluate evidence against policy 1110 + ${cyan('cascade')} Show cascade failure effects 1111 + 1112 + ${bold('Inspection:')} 1113 + ${cyan('graph')} Show provenance graph summary 1114 + ${cyan('bot')} "<command>" Route a bot command (e.g., "SpecBot: help") 1115 + 1116 + ${bold('Meta:')} 1117 + ${cyan('version')} Show version 1118 + ${cyan('help')} Show this help 1119 + 1120 + ${dim('Trust > cleverness.')} 1121 + `); 1122 + } 1123 + 1124 + // ─── Main ──────────────────────────────────────────────────────────────────── 1125 + 1126 + function main(): void { 1127 + const args = process.argv.slice(2); 1128 + const command = args[0]; 1129 + const commandArgs = args.slice(1); 1130 + 1131 + switch (command) { 1132 + case 'init': 1133 + cmdInit(); 1134 + break; 1135 + case 'bootstrap': 1136 + cmdBootstrap(); 1137 + break; 1138 + case 'status': 1139 + cmdStatus(); 1140 + break; 1141 + case 'ingest': 1142 + cmdIngest(commandArgs); 1143 + break; 1144 + case 'diff': 1145 + cmdDiff(commandArgs); 1146 + break; 1147 + case 'clauses': 1148 + cmdClauses(commandArgs); 1149 + break; 1150 + case 'canonicalize': 1151 + case 'canon-extract': 1152 + cmdCanonicalize(); 1153 + break; 1154 + case 'canon': 1155 + cmdCanon(); 1156 + break; 1157 + case 'plan': 1158 + cmdPlan(); 1159 + break; 1160 + case 'regen': 1161 + case 'regenerate': 1162 + cmdRegen(commandArgs); 1163 + break; 1164 + case 'drift': 1165 + cmdDrift(); 1166 + break; 1167 + case 'evaluate': 1168 + case 'eval': 1169 + cmdEvaluate(commandArgs); 1170 + break; 1171 + case 'cascade': 1172 + cmdCascade(); 1173 + break; 1174 + case 'graph': 1175 + cmdGraph(); 1176 + break; 1177 + case 'bot': 1178 + cmdBot(commandArgs); 1179 + break; 1180 + case 'version': 1181 + case '--version': 1182 + case '-v': 1183 + cmdVersion(); 1184 + break; 1185 + case 'help': 1186 + case '--help': 1187 + case '-h': 1188 + case undefined: 1189 + cmdHelp(); 1190 + break; 1191 + default: 1192 + console.error(red(`✖ Unknown command: ${command}`)); 1193 + console.error(dim(' Run `phoenix help` for available commands.')); 1194 + process.exit(1); 1195 + } 1196 + } 1197 + 1198 + main();
+96
src/compaction.ts
··· 1 + /** 2 + * Compaction Engine — moves cold data to archives while preserving 3 + * node headers, provenance edges, approvals, and signatures. 4 + */ 5 + 6 + import type { CompactionEvent } from './models/pipeline.js'; 7 + 8 + export interface StorageStats { 9 + total_objects: number; 10 + total_bytes: number; 11 + hot_objects: number; 12 + hot_bytes: number; 13 + cold_objects: number; 14 + cold_bytes: number; 15 + } 16 + 17 + export interface CompactionCandidate { 18 + object_id: string; 19 + object_type: string; 20 + age_days: number; 21 + size_bytes: number; 22 + /** Whether this object must be preserved (header, provenance, approval, sig) */ 23 + preserve: boolean; 24 + } 25 + 26 + /** 27 + * Identify objects eligible for compaction. 28 + */ 29 + export function identifyCandidates( 30 + objects: CompactionCandidate[], 31 + hotWindowDays: number = 30, 32 + ): { toCompact: CompactionCandidate[]; toPreserve: CompactionCandidate[] } { 33 + const toCompact: CompactionCandidate[] = []; 34 + const toPreserve: CompactionCandidate[] = []; 35 + 36 + for (const obj of objects) { 37 + if (obj.preserve) { 38 + toPreserve.push(obj); 39 + } else if (obj.age_days > hotWindowDays) { 40 + toCompact.push(obj); 41 + } else { 42 + toPreserve.push(obj); 43 + } 44 + } 45 + 46 + return { toCompact, toPreserve }; 47 + } 48 + 49 + /** 50 + * Simulate a compaction run and produce an event. 51 + */ 52 + export function runCompaction( 53 + objects: CompactionCandidate[], 54 + trigger: CompactionEvent['trigger'], 55 + hotWindowDays: number = 30, 56 + ): CompactionEvent { 57 + const { toCompact, toPreserve } = identifyCandidates(objects, hotWindowDays); 58 + 59 + const bytesFreed = toCompact.reduce((sum, o) => sum + o.size_bytes, 0); 60 + const preservedHeaders = toPreserve.filter(o => o.object_type === 'node_header').length; 61 + const preservedProvenance = toPreserve.filter(o => o.object_type === 'provenance_edge').length; 62 + const preservedApprovals = toPreserve.filter(o => o.object_type === 'approval').length; 63 + const preservedSignatures = toPreserve.filter(o => o.object_type === 'signature').length; 64 + 65 + return { 66 + type: 'CompactionEvent', 67 + timestamp: new Date().toISOString(), 68 + trigger, 69 + nodes_compacted: toCompact.length, 70 + bytes_freed: bytesFreed, 71 + preserved: { 72 + node_headers: preservedHeaders, 73 + provenance_edges: preservedProvenance, 74 + approvals: preservedApprovals, 75 + signatures: preservedSignatures, 76 + }, 77 + }; 78 + } 79 + 80 + /** 81 + * Check if compaction should be triggered. 82 + */ 83 + export function shouldTriggerCompaction( 84 + stats: StorageStats, 85 + sizeThresholdBytes: number = 100 * 1024 * 1024, // 100MB default 86 + daysSinceLastCompaction: number = 0, 87 + timeThresholdDays: number = 90, 88 + ): { trigger: boolean; reason: CompactionEvent['trigger'] | null } { 89 + if (stats.total_bytes > sizeThresholdBytes) { 90 + return { trigger: true, reason: 'size_threshold' }; 91 + } 92 + if (daysSinceLastCompaction > timeThresholdDays) { 93 + return { trigger: true, reason: 'time_based' }; 94 + } 95 + return { trigger: false, reason: null }; 96 + }
+84
src/d-rate.ts
··· 1 + /** 2 + * D-Rate Tracker 3 + * 4 + * Tracks the rate of D-class (uncertain) classifications 5 + * over a rolling window. 6 + */ 7 + 8 + import type { ChangeClassification, DRateStatus } from './models/classification.js'; 9 + import { ChangeClass, DRateLevel } from './models/classification.js'; 10 + 11 + const DEFAULT_WINDOW_SIZE = 100; 12 + 13 + export class DRateTracker { 14 + private window: ChangeClass[] = []; 15 + private windowSize: number; 16 + 17 + constructor(windowSize: number = DEFAULT_WINDOW_SIZE) { 18 + this.windowSize = windowSize; 19 + } 20 + 21 + /** 22 + * Record a batch of classifications. 23 + */ 24 + record(classifications: ChangeClassification[]): void { 25 + for (const c of classifications) { 26 + this.window.push(c.change_class); 27 + if (this.window.length > this.windowSize) { 28 + this.window.shift(); 29 + } 30 + } 31 + } 32 + 33 + /** 34 + * Record a single classification. 35 + */ 36 + recordOne(changeClass: ChangeClass): void { 37 + this.window.push(changeClass); 38 + if (this.window.length > this.windowSize) { 39 + this.window.shift(); 40 + } 41 + } 42 + 43 + /** 44 + * Get current D-rate status. 45 + */ 46 + getStatus(): DRateStatus { 47 + const total = this.window.length; 48 + if (total === 0) { 49 + return { 50 + rate: 0, 51 + level: DRateLevel.TARGET, 52 + window_size: this.windowSize, 53 + d_count: 0, 54 + total_count: 0, 55 + }; 56 + } 57 + 58 + const dCount = this.window.filter(c => c === ChangeClass.D).length; 59 + const rate = dCount / total; 60 + const level = rateToLevel(rate); 61 + 62 + return { 63 + rate, 64 + level, 65 + window_size: this.windowSize, 66 + d_count: dCount, 67 + total_count: total, 68 + }; 69 + } 70 + 71 + /** 72 + * Reset the tracker. 73 + */ 74 + reset(): void { 75 + this.window = []; 76 + } 77 + } 78 + 79 + function rateToLevel(rate: number): DRateLevel { 80 + if (rate <= 0.05) return DRateLevel.TARGET; 81 + if (rate <= 0.10) return DRateLevel.ACCEPTABLE; 82 + if (rate <= 0.15) return DRateLevel.WARNING; 83 + return DRateLevel.ALARM; 84 + }
+114
src/dep-extractor.ts
··· 1 + /** 2 + * Dependency Extractor — parses TypeScript source to find imports and side channels. 3 + */ 4 + 5 + export interface ExtractedDep { 6 + kind: 'import'; 7 + source: string; // the import specifier (package name or path) 8 + is_relative: boolean; 9 + source_line: number; 10 + } 11 + 12 + export interface ExtractedSideChannel { 13 + kind: 'database' | 'queue' | 'cache' | 'config' | 'external_api' | 'file'; 14 + identifier: string; // the detected identifier (env var name, URL, etc.) 15 + source_line: number; 16 + } 17 + 18 + export interface DependencyGraph { 19 + file_path: string; 20 + imports: ExtractedDep[]; 21 + side_channels: ExtractedSideChannel[]; 22 + } 23 + 24 + /** 25 + * Extract dependencies from TypeScript source code. 26 + * Uses regex-based parsing (no AST in v1). 27 + */ 28 + export function extractDependencies(source: string, filePath: string): DependencyGraph { 29 + const lines = source.split('\n'); 30 + const imports: ExtractedDep[] = []; 31 + const sideChannels: ExtractedSideChannel[] = []; 32 + 33 + for (let i = 0; i < lines.length; i++) { 34 + const line = lines[i]; 35 + const lineNum = i + 1; 36 + 37 + // Match: import ... from 'specifier' 38 + // Match: import 'specifier' 39 + // Match: require('specifier') 40 + const importMatch = line.match(/(?:import\s+.*?from\s+['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/); 41 + if (importMatch) { 42 + const source = importMatch[1] || importMatch[2] || importMatch[3]; 43 + imports.push({ 44 + kind: 'import', 45 + source, 46 + is_relative: source.startsWith('.') || source.startsWith('/'), 47 + source_line: lineNum, 48 + }); 49 + } 50 + 51 + // Side channel detection patterns 52 + // process.env.XXX 53 + const envMatch = line.match(/process\.env\.(\w+)/); 54 + if (envMatch) { 55 + sideChannels.push({ 56 + kind: 'config', 57 + identifier: envMatch[1], 58 + source_line: lineNum, 59 + }); 60 + } 61 + 62 + // process.env['XXX'] 63 + const envBracketMatch = line.match(/process\.env\[['"](\w+)['"]\]/); 64 + if (envBracketMatch) { 65 + sideChannels.push({ 66 + kind: 'config', 67 + identifier: envBracketMatch[1], 68 + source_line: lineNum, 69 + }); 70 + } 71 + 72 + // fetch('url') or new URL('...') 73 + const fetchMatch = line.match(/(?:fetch|new\s+URL)\s*\(\s*['"]([^'"]+)['"]/); 74 + if (fetchMatch) { 75 + sideChannels.push({ 76 + kind: 'external_api', 77 + identifier: fetchMatch[1], 78 + source_line: lineNum, 79 + }); 80 + } 81 + 82 + // Database patterns: createConnection, createPool, new Pool, PrismaClient, etc. 83 + const dbMatch = line.match(/(?:createConnection|createPool|new\s+Pool|new\s+PrismaClient|mongoose\.connect)\s*\(/); 84 + if (dbMatch) { 85 + sideChannels.push({ 86 + kind: 'database', 87 + identifier: 'database_connection', 88 + source_line: lineNum, 89 + }); 90 + } 91 + 92 + // fs.readFile, fs.writeFile, etc. 93 + const fsMatch = line.match(/fs\.(readFile|writeFile|readdir|mkdir|unlink|stat|access)/); 94 + if (fsMatch) { 95 + sideChannels.push({ 96 + kind: 'file', 97 + identifier: `fs.${fsMatch[1]}`, 98 + source_line: lineNum, 99 + }); 100 + } 101 + 102 + // Redis / cache patterns 103 + const cacheMatch = line.match(/(?:new\s+Redis|createClient|redis\.connect)/); 104 + if (cacheMatch) { 105 + sideChannels.push({ 106 + kind: 'cache', 107 + identifier: 'redis_connection', 108 + source_line: lineNum, 109 + }); 110 + } 111 + } 112 + 113 + return { file_path: filePath, imports, side_channels: sideChannels }; 114 + }
+132
src/diff.ts
··· 1 + /** 2 + * Clause Diff Engine 3 + * 4 + * Compares two sets of clauses (before/after) and classifies each change. 5 + */ 6 + 7 + import type { Clause, ClauseDiff } from './models/clause.js'; 8 + import { DiffType } from './models/clause.js'; 9 + 10 + /** 11 + * Diff two clause arrays from the same document. 12 + * 13 + * Strategy: 14 + * 1. Index clauses by normalized_text hash (clause_semhash) 15 + * 2. Match by content first, then by section_path for moves 16 + * 3. Remaining unmatched = ADDED or REMOVED 17 + */ 18 + export function diffClauses(before: Clause[], after: Clause[]): ClauseDiff[] { 19 + const diffs: ClauseDiff[] = []; 20 + 21 + // Build lookup maps 22 + const beforeBySemhash = new Map<string, Clause[]>(); 23 + const afterBySemhash = new Map<string, Clause[]>(); 24 + 25 + for (const c of before) { 26 + const arr = beforeBySemhash.get(c.clause_semhash) ?? []; 27 + arr.push(c); 28 + beforeBySemhash.set(c.clause_semhash, arr); 29 + } 30 + for (const c of after) { 31 + const arr = afterBySemhash.get(c.clause_semhash) ?? []; 32 + arr.push(c); 33 + afterBySemhash.set(c.clause_semhash, arr); 34 + } 35 + 36 + const matchedBefore = new Set<string>(); 37 + const matchedAfter = new Set<string>(); 38 + 39 + // Pass 1: Exact matches (same semhash) 40 + for (const [semhash, beforeClauses] of beforeBySemhash) { 41 + const afterClauses = afterBySemhash.get(semhash); 42 + if (!afterClauses) continue; 43 + 44 + const pairCount = Math.min(beforeClauses.length, afterClauses.length); 45 + for (let i = 0; i < pairCount; i++) { 46 + const bc = beforeClauses[i]; 47 + const ac = afterClauses[i]; 48 + matchedBefore.add(bc.clause_id); 49 + matchedAfter.add(ac.clause_id); 50 + 51 + const pathBefore = bc.section_path.join('/'); 52 + const pathAfter = ac.section_path.join('/'); 53 + 54 + if (pathBefore === pathAfter) { 55 + diffs.push({ 56 + diff_type: DiffType.UNCHANGED, 57 + clause_id_before: bc.clause_id, 58 + clause_id_after: ac.clause_id, 59 + clause_before: bc, 60 + clause_after: ac, 61 + section_path_before: bc.section_path, 62 + section_path_after: ac.section_path, 63 + }); 64 + } else { 65 + diffs.push({ 66 + diff_type: DiffType.MOVED, 67 + clause_id_before: bc.clause_id, 68 + clause_id_after: ac.clause_id, 69 + clause_before: bc, 70 + clause_after: ac, 71 + section_path_before: bc.section_path, 72 + section_path_after: ac.section_path, 73 + }); 74 + } 75 + } 76 + } 77 + 78 + // Pass 2: Try to match remaining by section_path (MODIFIED) 79 + const unmatchedBefore = before.filter(c => !matchedBefore.has(c.clause_id)); 80 + const unmatchedAfter = after.filter(c => !matchedAfter.has(c.clause_id)); 81 + 82 + const afterByPath = new Map<string, Clause[]>(); 83 + for (const c of unmatchedAfter) { 84 + const key = c.section_path.join('/'); 85 + const arr = afterByPath.get(key) ?? []; 86 + arr.push(c); 87 + afterByPath.set(key, arr); 88 + } 89 + 90 + const stillUnmatchedBefore: Clause[] = []; 91 + for (const bc of unmatchedBefore) { 92 + const key = bc.section_path.join('/'); 93 + const candidates = afterByPath.get(key); 94 + if (candidates && candidates.length > 0) { 95 + const ac = candidates.shift()!; 96 + matchedAfter.add(ac.clause_id); 97 + diffs.push({ 98 + diff_type: DiffType.MODIFIED, 99 + clause_id_before: bc.clause_id, 100 + clause_id_after: ac.clause_id, 101 + clause_before: bc, 102 + clause_after: ac, 103 + section_path_before: bc.section_path, 104 + section_path_after: ac.section_path, 105 + }); 106 + } else { 107 + stillUnmatchedBefore.push(bc); 108 + } 109 + } 110 + 111 + // Pass 3: Remaining are REMOVED / ADDED 112 + for (const bc of stillUnmatchedBefore) { 113 + diffs.push({ 114 + diff_type: DiffType.REMOVED, 115 + clause_id_before: bc.clause_id, 116 + clause_before: bc, 117 + section_path_before: bc.section_path, 118 + }); 119 + } 120 + 121 + const stillUnmatchedAfter = unmatchedAfter.filter(c => !matchedAfter.has(c.clause_id)); 122 + for (const ac of stillUnmatchedAfter) { 123 + diffs.push({ 124 + diff_type: DiffType.ADDED, 125 + clause_id_after: ac.clause_id, 126 + clause_after: ac, 127 + section_path_after: ac.section_path, 128 + }); 129 + } 130 + 131 + return diffs; 132 + }
+86
src/drift.ts
··· 1 + /** 2 + * Drift Detection — compares working tree vs generated manifest. 3 + * 4 + * Detects when generated files have been manually edited without 5 + * a waiver, which breaks the provenance chain. 6 + */ 7 + 8 + import { readFileSync, existsSync } from 'node:fs'; 9 + import type { GeneratedManifest, DriftEntry, DriftReport, DriftWaiver } from './models/manifest.js'; 10 + import { DriftStatus } from './models/manifest.js'; 11 + import { sha256 } from './semhash.js'; 12 + 13 + /** 14 + * Check all files in the manifest against the working tree. 15 + */ 16 + export function detectDrift( 17 + manifest: GeneratedManifest, 18 + projectRoot: string, 19 + waivers?: Map<string, DriftWaiver>, 20 + ): DriftReport { 21 + const entries: DriftEntry[] = []; 22 + 23 + for (const iuManifest of Object.values(manifest.iu_manifests)) { 24 + for (const [filePath, entry] of Object.entries(iuManifest.files)) { 25 + const fullPath = projectRoot + '/' + filePath; 26 + const waiver = waivers?.get(filePath); 27 + 28 + if (!existsSync(fullPath)) { 29 + entries.push({ 30 + status: DriftStatus.MISSING, 31 + file_path: filePath, 32 + iu_id: iuManifest.iu_id, 33 + expected_hash: entry.content_hash, 34 + }); 35 + continue; 36 + } 37 + 38 + const actualContent = readFileSync(fullPath, 'utf8'); 39 + const actualHash = sha256(actualContent); 40 + 41 + if (actualHash === entry.content_hash) { 42 + entries.push({ 43 + status: DriftStatus.CLEAN, 44 + file_path: filePath, 45 + iu_id: iuManifest.iu_id, 46 + expected_hash: entry.content_hash, 47 + actual_hash: actualHash, 48 + }); 49 + } else if (waiver) { 50 + entries.push({ 51 + status: DriftStatus.WAIVED, 52 + file_path: filePath, 53 + iu_id: iuManifest.iu_id, 54 + expected_hash: entry.content_hash, 55 + actual_hash: actualHash, 56 + waiver, 57 + }); 58 + } else { 59 + entries.push({ 60 + status: DriftStatus.DRIFTED, 61 + file_path: filePath, 62 + iu_id: iuManifest.iu_id, 63 + expected_hash: entry.content_hash, 64 + actual_hash: actualHash, 65 + }); 66 + } 67 + } 68 + } 69 + 70 + const clean = entries.filter(e => e.status === DriftStatus.CLEAN).length; 71 + const drifted = entries.filter(e => e.status === DriftStatus.DRIFTED).length; 72 + const missing = entries.filter(e => e.status === DriftStatus.MISSING).length; 73 + const waived = entries.filter(e => e.status === DriftStatus.WAIVED).length; 74 + 75 + let summary: string; 76 + if (drifted === 0 && missing === 0) { 77 + summary = `All ${clean} generated files are clean.${waived > 0 ? ` ${waived} waived.` : ''}`; 78 + } else { 79 + const parts: string[] = []; 80 + if (drifted > 0) parts.push(`${drifted} drifted`); 81 + if (missing > 0) parts.push(`${missing} missing`); 82 + summary = `DRIFT DETECTED: ${parts.join(', ')}. ${clean} clean.`; 83 + } 84 + 85 + return { entries, clean_count: clean, drifted_count: drifted, missing_count: missing, summary }; 86 + }
+68
src/index.ts
··· 1 + /** 2 + * Phoenix VCS — Public API 3 + */ 4 + 5 + // Models 6 + export type { Clause, IngestResult, ClauseDiff } from './models/clause.js'; 7 + export { DiffType } from './models/clause.js'; 8 + export type { CanonicalNode, CanonicalGraph } from './models/canonical.js'; 9 + export { CanonicalType } from './models/canonical.js'; 10 + export type { ClassificationSignals, ChangeClassification, DRateStatus } from './models/classification.js'; 11 + export { ChangeClass, DRateLevel, BootstrapState } from './models/classification.js'; 12 + export type { ImplementationUnit, BoundaryPolicy, EnforcementConfig, IUContract, EvidencePolicy } from './models/iu.js'; 13 + export { defaultBoundaryPolicy, defaultEnforcement } from './models/iu.js'; 14 + export type { GeneratedManifest, IUManifest, FileManifestEntry, RegenMetadata, DriftEntry, DriftReport, DriftWaiver } from './models/manifest.js'; 15 + export { DriftStatus } from './models/manifest.js'; 16 + export type { Diagnostic } from './models/diagnostic.js'; 17 + export type { EvidenceRecord, PolicyEvaluation, CascadeEvent, CascadeAction } from './models/evidence.js'; 18 + export { EvidenceKind, EvidenceStatus } from './models/evidence.js'; 19 + export type { PipelineConfig, ShadowDiffMetrics, ShadowResult, CompactionEvent } from './models/pipeline.js'; 20 + export { UpgradeClassification, StorageTier } from './models/pipeline.js'; 21 + export type { BotCommand, BotResponse, BotName } from './models/bot.js'; 22 + 23 + // Phase A 24 + export { normalizeText } from './normalizer.js'; 25 + export { sha256, clauseSemhash, contextSemhashCold, clauseId } from './semhash.js'; 26 + export { parseSpec } from './spec-parser.js'; 27 + export { diffClauses } from './diff.js'; 28 + 29 + // Phase B 30 + export { extractCanonicalNodes, extractTerms } from './canonicalizer.js'; 31 + export { contextSemhashWarm, computeWarmHashes } from './warm-hasher.js'; 32 + export { classifyChange, classifyChanges } from './classifier.js'; 33 + export { DRateTracker } from './d-rate.js'; 34 + export { BootstrapStateMachine } from './bootstrap.js'; 35 + 36 + // Phase C1 37 + export { planIUs } from './iu-planner.js'; 38 + export { generateIU, generateAll } from './regen.js'; 39 + export { ManifestManager } from './manifest.js'; 40 + export { detectDrift } from './drift.js'; 41 + 42 + // Phase C2 43 + export { extractDependencies } from './dep-extractor.js'; 44 + export type { ExtractedDep, ExtractedSideChannel, DependencyGraph } from './dep-extractor.js'; 45 + export { validateBoundary, validateIU, detectBoundaryChanges } from './boundary-validator.js'; 46 + export type { UnitBoundaryChange } from './boundary-validator.js'; 47 + 48 + // Phase D 49 + export { evaluatePolicy, evaluateAllPolicies } from './policy-engine.js'; 50 + export { computeCascade, getTransitiveDependents } from './cascade.js'; 51 + 52 + // Phase E 53 + export { computeShadowDiff, classifyShadowDiff, runShadowPipeline } from './shadow-pipeline.js'; 54 + export { identifyCandidates, runCompaction, shouldTriggerCompaction } from './compaction.js'; 55 + export type { StorageStats, CompactionCandidate } from './compaction.js'; 56 + 57 + // Phase F 58 + export { parseCommand, routeCommand, getAllCommands } from './bot-router.js'; 59 + 60 + // Scaffold 61 + export { deriveServices, generateScaffold } from './scaffold.js'; 62 + export type { ServiceDescriptor, ScaffoldResult } from './scaffold.js'; 63 + 64 + // Stores 65 + export { ContentStore } from './store/content-store.js'; 66 + export { SpecStore } from './store/spec-store.js'; 67 + export { CanonicalStore } from './store/canonical-store.js'; 68 + export { EvidenceStore } from './store/evidence-store.js';
+215
src/iu-planner.ts
··· 1 + /** 2 + * IU Planner — maps canonical nodes to Implementation Unit proposals. 3 + * 4 + * Groups related requirements into module-level IUs based on: 5 + * - Source document (service boundary) 6 + * - Source section within a document (module boundary) 7 + * 8 + * Naming produces natural developer-facing identifiers: 9 + * spec/api-gateway.md, section "Rate Limiting" 10 + * → name: "Rate Limiting" 11 + * → file: src/generated/api-gateway/rate-limiting.ts 12 + */ 13 + 14 + import type { CanonicalNode } from './models/canonical.js'; 15 + import type { Clause } from './models/clause.js'; 16 + import type { ImplementationUnit } from './models/iu.js'; 17 + import { defaultBoundaryPolicy, defaultEnforcement } from './models/iu.js'; 18 + import { sha256 } from './semhash.js'; 19 + 20 + /** 21 + * Plan IUs from canonical nodes, grouping by source document + section. 22 + * 23 + * Each top-level section of each spec document becomes one IU. 24 + * Canon nodes are assigned to the IU of their source clause's section. 25 + */ 26 + export function planIUs( 27 + canonNodes: CanonicalNode[], 28 + clauses: Clause[], 29 + ): ImplementationUnit[] { 30 + if (canonNodes.length === 0) return []; 31 + 32 + // Index clauses by ID 33 + const clauseMap = new Map(clauses.map(c => [c.clause_id, c])); 34 + 35 + // Group canonical nodes by (doc, top-level section) 36 + const buckets = new Map<string, { nodes: CanonicalNode[]; docId: string; sectionName: string }>(); 37 + 38 + for (const node of canonNodes) { 39 + const clause = node.source_clause_ids 40 + .map(id => clauseMap.get(id)) 41 + .find(c => c !== undefined); 42 + 43 + if (!clause) continue; 44 + 45 + const docId = clause.source_doc_id; 46 + // Use the second level of section_path as the grouping key. 47 + // section_path[0] is typically the doc title, section_path[1] is the first real section. 48 + // If there's only one level, use that. 49 + const sectionName = clause.section_path.length > 1 50 + ? clause.section_path[1] 51 + : clause.section_path[0] || 'main'; 52 + 53 + const key = `${docId}::${sectionName}`; 54 + let bucket = buckets.get(key); 55 + if (!bucket) { 56 + bucket = { nodes: [], docId, sectionName }; 57 + buckets.set(key, bucket); 58 + } 59 + bucket.nodes.push(node); 60 + } 61 + 62 + // Merge small buckets (≤1 node) into their document's largest bucket 63 + const docBuckets = new Map<string, string[]>(); // docId → keys 64 + for (const [key, bucket] of buckets) { 65 + const list = docBuckets.get(bucket.docId) ?? []; 66 + list.push(key); 67 + docBuckets.set(bucket.docId, list); 68 + } 69 + 70 + for (const [docId, keys] of docBuckets) { 71 + const small = keys.filter(k => buckets.get(k)!.nodes.length <= 1); 72 + const large = keys.filter(k => buckets.get(k)!.nodes.length > 1); 73 + 74 + if (small.length > 0 && large.length > 0) { 75 + // Find the largest bucket in this doc 76 + const targetKey = large.sort((a, b) => 77 + buckets.get(b)!.nodes.length - buckets.get(a)!.nodes.length 78 + )[0]; 79 + const target = buckets.get(targetKey)!; 80 + for (const smallKey of small) { 81 + target.nodes.push(...buckets.get(smallKey)!.nodes); 82 + buckets.delete(smallKey); 83 + } 84 + } 85 + } 86 + 87 + // Convert buckets to IUs 88 + const ius: ImplementationUnit[] = []; 89 + 90 + for (const [, bucket] of buckets) { 91 + const { nodes, docId, sectionName } = bucket; 92 + if (nodes.length === 0) continue; 93 + 94 + const name = cleanName(sectionName); 95 + const serviceName = deriveServiceName(docId); 96 + const fileName = slugify(name); 97 + const riskTier = deriveRiskTier(nodes); 98 + const canonIds = nodes.map(n => n.canon_id); 99 + 100 + // Build a readable description from the requirements (not a wall of text) 101 + const requirements = nodes.filter(n => n.type === 'REQUIREMENT').slice(0, 5); 102 + const constraints = nodes.filter(n => n.type === 'CONSTRAINT' || n.type === 'INVARIANT'); 103 + const description = requirements.map(n => n.statement).join('. '); 104 + 105 + const iuId = sha256(['iu', serviceName, name, ...canonIds.sort()].join('\x00')); 106 + 107 + // Derive typed inputs/outputs from node statements 108 + const { inputs, outputs } = deriveContract(nodes, name); 109 + 110 + ius.push({ 111 + iu_id: iuId, 112 + kind: 'module' as const, 113 + name, 114 + risk_tier: riskTier, 115 + contract: { 116 + description, 117 + inputs, 118 + outputs, 119 + invariants: constraints.map(n => n.statement), 120 + }, 121 + source_canon_ids: canonIds, 122 + dependencies: [], 123 + boundary_policy: defaultBoundaryPolicy(), 124 + enforcement: defaultEnforcement(), 125 + evidence_policy: { 126 + required: evidenceForTier(riskTier), 127 + }, 128 + output_files: [`src/generated/${serviceName}/${fileName}.ts`], 129 + }); 130 + } 131 + 132 + // Sort for deterministic output 133 + ius.sort((a, b) => a.output_files[0].localeCompare(b.output_files[0])); 134 + 135 + return ius; 136 + } 137 + 138 + /** 139 + * Derive a service name from a document ID. 140 + * "spec/api-gateway.md" → "api-gateway" 141 + * "spec/deep/user-service.md" → "user-service" 142 + * "test.md" → "test" 143 + */ 144 + function deriveServiceName(docId: string): string { 145 + const base = docId.split('/').pop() || docId; 146 + return slugify(base.replace(/\.md$/i, '')); 147 + } 148 + 149 + /** 150 + * Clean up a section name to be a natural IU name. 151 + * "Security Constraints" → "Security Constraints" 152 + * "3.2 Authentication" → "Authentication" 153 + */ 154 + function cleanName(raw: string): string { 155 + return raw 156 + .replace(/^\d+(\.\d+)*\s*/, '') // strip leading numbers 157 + .replace(/\s+/g, ' ') 158 + .trim() || 'Main'; 159 + } 160 + 161 + /** 162 + * Derive typed contract inputs/outputs from canonical nodes. 163 + */ 164 + function deriveContract( 165 + nodes: CanonicalNode[], 166 + sectionName: string, 167 + ): { inputs: string[]; outputs: string[] } { 168 + const inputs: string[] = []; 169 + const outputs: string[] = []; 170 + 171 + // Look for common patterns in statements 172 + const allStatements = nodes.map(n => n.statement).join(' '); 173 + 174 + if (/\brequest\b/i.test(allStatements)) inputs.push('request'); 175 + if (/\buser\b/i.test(allStatements) && /\b(?:create|account|authenticate)\b/i.test(allStatements)) inputs.push('user'); 176 + if (/\btoken\b/i.test(allStatements)) inputs.push('token'); 177 + if (/\btemplate\b/i.test(allStatements)) inputs.push('template'); 178 + if (/\bnotification|message\b/i.test(allStatements)) inputs.push('notification'); 179 + if (/\bconfig\b/i.test(allStatements)) inputs.push('config'); 180 + 181 + if (/\bresponse\b/i.test(allStatements)) outputs.push('response'); 182 + if (/\bresult\b/i.test(allStatements)) outputs.push('result'); 183 + if (/\bevent\b/i.test(allStatements)) outputs.push('event'); 184 + 185 + return { inputs, outputs }; 186 + } 187 + 188 + function deriveRiskTier(nodes: CanonicalNode[]): 'low' | 'medium' | 'high' | 'critical' { 189 + const hasConstraint = nodes.some(n => n.type === 'CONSTRAINT'); 190 + const hasInvariant = nodes.some(n => n.type === 'INVARIANT'); 191 + const size = nodes.length; 192 + 193 + if (hasInvariant) return 'high'; 194 + if (hasConstraint && size > 2) return 'high'; 195 + if (hasConstraint) return 'medium'; 196 + if (size > 3) return 'medium'; 197 + return 'low'; 198 + } 199 + 200 + function evidenceForTier(tier: string): string[] { 201 + switch (tier) { 202 + case 'low': return ['typecheck', 'lint', 'boundary_validation']; 203 + case 'medium': return ['typecheck', 'lint', 'boundary_validation', 'unit_tests']; 204 + case 'high': return ['typecheck', 'lint', 'boundary_validation', 'unit_tests', 'property_tests', 'static_analysis']; 205 + case 'critical': return ['typecheck', 'lint', 'boundary_validation', 'unit_tests', 'property_tests', 'static_analysis', 'human_signoff']; 206 + default: return ['typecheck']; 207 + } 208 + } 209 + 210 + function slugify(name: string): string { 211 + return name 212 + .toLowerCase() 213 + .replace(/[^a-z0-9]+/g, '-') 214 + .replace(/^-|-$/g, ''); 215 + }
+70
src/manifest.ts
··· 1 + /** 2 + * Generated Manifest manager — tracks generated files for drift detection. 3 + */ 4 + 5 + import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; 6 + import { join } from 'node:path'; 7 + import type { GeneratedManifest, IUManifest } from './models/manifest.js'; 8 + 9 + export class ManifestManager { 10 + private manifestPath: string; 11 + 12 + constructor(phoenixRoot: string) { 13 + const dir = join(phoenixRoot, 'manifests'); 14 + mkdirSync(dir, { recursive: true }); 15 + this.manifestPath = join(dir, 'generated_manifest.json'); 16 + } 17 + 18 + load(): GeneratedManifest { 19 + if (!existsSync(this.manifestPath)) { 20 + return { iu_manifests: {}, generated_at: '' }; 21 + } 22 + return JSON.parse(readFileSync(this.manifestPath, 'utf8')); 23 + } 24 + 25 + save(manifest: GeneratedManifest): void { 26 + writeFileSync(this.manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); 27 + } 28 + 29 + /** 30 + * Record a single IU's generated files into the manifest. 31 + */ 32 + recordIU(iuManifest: IUManifest): void { 33 + const manifest = this.load(); 34 + manifest.iu_manifests[iuManifest.iu_id] = iuManifest; 35 + manifest.generated_at = new Date().toISOString(); 36 + this.save(manifest); 37 + } 38 + 39 + /** 40 + * Record multiple IU manifests at once. 41 + */ 42 + recordAll(iuManifests: IUManifest[]): void { 43 + const manifest = this.load(); 44 + for (const m of iuManifests) { 45 + manifest.iu_manifests[m.iu_id] = m; 46 + } 47 + manifest.generated_at = new Date().toISOString(); 48 + this.save(manifest); 49 + } 50 + 51 + /** 52 + * Get manifest for a specific IU. 53 + */ 54 + getIUManifest(iuId: string): IUManifest | null { 55 + const manifest = this.load(); 56 + return manifest.iu_manifests[iuId] ?? null; 57 + } 58 + 59 + /** 60 + * Get all tracked file paths across all IUs. 61 + */ 62 + getAllTrackedFiles(): string[] { 63 + const manifest = this.load(); 64 + const files: string[] = []; 65 + for (const iuManifest of Object.values(manifest.iu_manifests)) { 66 + files.push(...Object.keys(iuManifest.files)); 67 + } 68 + return files; 69 + } 70 + }
+25
src/models/bot.ts
··· 1 + /** 2 + * Bot models for Freeq integration. 3 + */ 4 + 5 + export type BotName = 'SpecBot' | 'ImplBot' | 'PolicyBot'; 6 + 7 + export interface BotCommand { 8 + bot: BotName; 9 + action: string; 10 + args: Record<string, string>; 11 + raw: string; 12 + } 13 + 14 + export interface BotResponse { 15 + bot: BotName; 16 + action: string; 17 + mutating: boolean; 18 + /** For mutating commands: the confirmation ID */ 19 + confirm_id?: string; 20 + /** Human-readable description of what will happen */ 21 + intent?: string; 22 + /** The result (for read-only commands or after confirmation) */ 23 + result?: unknown; 24 + message: string; 25 + }
+31
src/models/canonical.ts
··· 1 + /** 2 + * Canonical Node — structured requirement extracted from clauses. 3 + */ 4 + 5 + export enum CanonicalType { 6 + REQUIREMENT = 'REQUIREMENT', 7 + CONSTRAINT = 'CONSTRAINT', 8 + INVARIANT = 'INVARIANT', 9 + DEFINITION = 'DEFINITION', 10 + } 11 + 12 + export interface CanonicalNode { 13 + /** Content-addressed ID */ 14 + canon_id: string; 15 + /** Node type */ 16 + type: CanonicalType; 17 + /** Normalized canonical statement */ 18 + statement: string; 19 + /** Provenance: source clause IDs */ 20 + source_clause_ids: string[]; 21 + /** Edges to related canonical nodes */ 22 + linked_canon_ids: string[]; 23 + /** Extracted keywords/terms for linking */ 24 + tags: string[]; 25 + } 26 + 27 + export interface CanonicalGraph { 28 + nodes: Record<string, CanonicalNode>; 29 + /** Provenance edges: canon_id → clause_id[] */ 30 + provenance: Record<string, string[]>; 31 + }
+62
src/models/classification.ts
··· 1 + /** 2 + * Change Classification model. 3 + */ 4 + 5 + export enum ChangeClass { 6 + /** Trivial — formatting only */ 7 + A = 'A', 8 + /** Local semantic change */ 9 + B = 'B', 10 + /** Contextual semantic shift */ 11 + C = 'C', 12 + /** Uncertain */ 13 + D = 'D', 14 + } 15 + 16 + export interface ClassificationSignals { 17 + /** Normalized text edit distance (0 = identical, 1 = completely different) */ 18 + norm_diff: number; 19 + /** clause_semhash changed */ 20 + semhash_delta: boolean; 21 + /** context_semhash_cold changed */ 22 + context_cold_delta: boolean; 23 + /** Jaccard distance of extracted terms (0 = identical, 1 = no overlap) */ 24 + term_ref_delta: number; 25 + /** Section path changed */ 26 + section_structure_delta: boolean; 27 + /** Number of affected canonical nodes */ 28 + canon_impact: number; 29 + } 30 + 31 + export interface ChangeClassification { 32 + /** The assigned class */ 33 + change_class: ChangeClass; 34 + /** Confidence score 0–1 */ 35 + confidence: number; 36 + /** Signals used for classification */ 37 + signals: ClassificationSignals; 38 + /** Clause IDs involved */ 39 + clause_id_before?: string; 40 + clause_id_after?: string; 41 + } 42 + 43 + export enum DRateLevel { 44 + TARGET = 'TARGET', // ≤5% 45 + ACCEPTABLE = 'ACCEPTABLE', // ≤10% 46 + WARNING = 'WARNING', // ≤15% 47 + ALARM = 'ALARM', // >15% 48 + } 49 + 50 + export interface DRateStatus { 51 + rate: number; 52 + level: DRateLevel; 53 + window_size: number; 54 + d_count: number; 55 + total_count: number; 56 + } 57 + 58 + export enum BootstrapState { 59 + BOOTSTRAP_COLD = 'BOOTSTRAP_COLD', 60 + BOOTSTRAP_WARMING = 'BOOTSTRAP_WARMING', 61 + STEADY_STATE = 'STEADY_STATE', 62 + }
+47
src/models/clause.ts
··· 1 + /** 2 + * Core Clause model — the atomic unit of spec decomposition. 3 + * Every spec document is parsed into an array of Clauses. 4 + */ 5 + 6 + export interface Clause { 7 + /** Content-addressed ID: SHA-256(source_doc_id + section_path + normalized_text) */ 8 + clause_id: string; 9 + /** Document identifier (usually relative file path) */ 10 + source_doc_id: string; 11 + /** [startLine, endLine] 1-indexed inclusive */ 12 + source_line_range: [number, number]; 13 + /** Original text as found in the document */ 14 + raw_text: string; 15 + /** Normalized text for stable hashing */ 16 + normalized_text: string; 17 + /** Heading hierarchy, e.g. ["1. Adoption Scope", "v1 Scope"] */ 18 + section_path: string[]; 19 + /** SHA-256 of normalized_text — content identity */ 20 + clause_semhash: string; 21 + /** SHA-256 of normalized_text + section_path + neighbor hashes — local structural context */ 22 + context_semhash_cold: string; 23 + } 24 + 25 + export interface IngestResult { 26 + doc_id: string; 27 + clauses: Clause[]; 28 + timestamp: string; 29 + } 30 + 31 + export enum DiffType { 32 + ADDED = 'ADDED', 33 + REMOVED = 'REMOVED', 34 + MODIFIED = 'MODIFIED', 35 + MOVED = 'MOVED', 36 + UNCHANGED = 'UNCHANGED', 37 + } 38 + 39 + export interface ClauseDiff { 40 + diff_type: DiffType; 41 + clause_id_before?: string; 42 + clause_id_after?: string; 43 + clause_before?: Clause; 44 + clause_after?: Clause; 45 + section_path_before?: string[]; 46 + section_path_after?: string[]; 47 + }
+26
src/models/diagnostic.ts
··· 1 + /** 2 + * Diagnostic model — every status item in phoenix status. 3 + */ 4 + 5 + export type DiagnosticSeverity = 'error' | 'warning' | 'info'; 6 + 7 + export type DiagnosticCategory = 8 + | 'dependency_violation' 9 + | 'side_channel_violation' 10 + | 'drift' 11 + | 'boundary' 12 + | 'd-rate' 13 + | 'canon' 14 + | 'evidence' 15 + | 'regen'; 16 + 17 + export interface Diagnostic { 18 + severity: DiagnosticSeverity; 19 + category: DiagnosticCategory; 20 + subject: string; 21 + message: string; 22 + iu_id?: string; 23 + source_file?: string; 24 + source_line?: number; 25 + recommended_actions: string[]; 26 + }
+60
src/models/evidence.ts
··· 1 + /** 2 + * Evidence model — proof that an IU meets its risk-tier requirements. 3 + */ 4 + 5 + export enum EvidenceKind { 6 + TYPECHECK = 'typecheck', 7 + LINT = 'lint', 8 + BOUNDARY_VALIDATION = 'boundary_validation', 9 + UNIT_TEST = 'unit_tests', 10 + PROPERTY_TEST = 'property_tests', 11 + STATIC_ANALYSIS = 'static_analysis', 12 + THREAT_NOTE = 'threat_note', 13 + HUMAN_SIGNOFF = 'human_signoff', 14 + } 15 + 16 + export enum EvidenceStatus { 17 + PASS = 'PASS', 18 + FAIL = 'FAIL', 19 + PENDING = 'PENDING', 20 + SKIPPED = 'SKIPPED', 21 + } 22 + 23 + export interface EvidenceRecord { 24 + evidence_id: string; 25 + kind: EvidenceKind; 26 + status: EvidenceStatus; 27 + iu_id: string; 28 + /** Canonical nodes this evidence covers */ 29 + canon_ids: string[]; 30 + /** Hash of the artifact this evidence was run against */ 31 + artifact_hash?: string; 32 + message?: string; 33 + timestamp: string; 34 + } 35 + 36 + export interface PolicyEvaluation { 37 + iu_id: string; 38 + iu_name: string; 39 + risk_tier: string; 40 + required: string[]; 41 + satisfied: string[]; 42 + missing: string[]; 43 + failed: string[]; 44 + verdict: 'PASS' | 'FAIL' | 'INCOMPLETE'; 45 + } 46 + 47 + export interface CascadeEvent { 48 + source_iu_id: string; 49 + source_iu_name: string; 50 + failure_kind: string; 51 + affected_iu_ids: string[]; 52 + actions: CascadeAction[]; 53 + } 54 + 55 + export interface CascadeAction { 56 + iu_id: string; 57 + iu_name: string; 58 + action: string; 59 + reason: string; 60 + }
+88
src/models/iu.ts
··· 1 + /** 2 + * Implementation Unit (IU) — stable compilation boundary. 3 + * 4 + * Maps canonical requirements to generated code modules. 5 + */ 6 + 7 + export type IUKind = 'module' | 'function'; 8 + export type RiskTier = 'low' | 'medium' | 'high' | 'critical'; 9 + 10 + export interface IUContract { 11 + /** Description of what this IU does */ 12 + description: string; 13 + /** Input types / parameters */ 14 + inputs: string[]; 15 + /** Output types / return values */ 16 + outputs: string[]; 17 + /** Invariants that must hold */ 18 + invariants: string[]; 19 + } 20 + 21 + export interface BoundaryPolicy { 22 + code: { 23 + allowed_ius: string[]; 24 + allowed_packages: string[]; 25 + forbidden_ius: string[]; 26 + forbidden_packages: string[]; 27 + forbidden_paths: string[]; 28 + }; 29 + side_channels: { 30 + databases: string[]; 31 + queues: string[]; 32 + caches: string[]; 33 + config: string[]; 34 + external_apis: string[]; 35 + files: string[]; 36 + }; 37 + } 38 + 39 + export interface EnforcementConfig { 40 + dependency_violation: { severity: 'error' | 'warning' }; 41 + side_channel_violation: { severity: 'error' | 'warning' }; 42 + } 43 + 44 + export interface EvidencePolicy { 45 + /** What evidence is required for this risk tier */ 46 + required: string[]; 47 + } 48 + 49 + export interface ImplementationUnit { 50 + iu_id: string; 51 + kind: IUKind; 52 + name: string; 53 + risk_tier: RiskTier; 54 + contract: IUContract; 55 + source_canon_ids: string[]; 56 + dependencies: string[]; 57 + boundary_policy: BoundaryPolicy; 58 + enforcement: EnforcementConfig; 59 + evidence_policy: EvidencePolicy; 60 + output_files: string[]; 61 + } 62 + 63 + export function defaultBoundaryPolicy(): BoundaryPolicy { 64 + return { 65 + code: { 66 + allowed_ius: [], 67 + allowed_packages: [], 68 + forbidden_ius: [], 69 + forbidden_packages: [], 70 + forbidden_paths: [], 71 + }, 72 + side_channels: { 73 + databases: [], 74 + queues: [], 75 + caches: [], 76 + config: [], 77 + external_apis: [], 78 + files: [], 79 + }, 80 + }; 81 + } 82 + 83 + export function defaultEnforcement(): EnforcementConfig { 84 + return { 85 + dependency_violation: { severity: 'error' }, 86 + side_channel_violation: { severity: 'warning' }, 87 + }; 88 + }
+65
src/models/manifest.ts
··· 1 + /** 2 + * Generated manifest — tracks every generated file for drift detection. 3 + */ 4 + 5 + export interface FileManifestEntry { 6 + path: string; 7 + content_hash: string; 8 + size: number; 9 + } 10 + 11 + export interface RegenMetadata { 12 + model_id: string; 13 + promptpack_hash: string; 14 + toolchain_version: string; 15 + generated_at: string; 16 + } 17 + 18 + export interface IUManifest { 19 + iu_id: string; 20 + iu_name: string; 21 + files: Record<string, FileManifestEntry>; 22 + regen_metadata: RegenMetadata; 23 + } 24 + 25 + export interface GeneratedManifest { 26 + iu_manifests: Record<string, IUManifest>; 27 + generated_at: string; 28 + } 29 + 30 + export enum DriftStatus { 31 + /** File matches manifest hash */ 32 + CLEAN = 'CLEAN', 33 + /** File differs from manifest, no waiver */ 34 + DRIFTED = 'DRIFTED', 35 + /** File differs, waiver exists */ 36 + WAIVED = 'WAIVED', 37 + /** Manifest entry but no file on disk */ 38 + MISSING = 'MISSING', 39 + /** File on disk but not in manifest */ 40 + UNTRACKED = 'UNTRACKED', 41 + } 42 + 43 + export interface DriftEntry { 44 + status: DriftStatus; 45 + file_path: string; 46 + iu_id?: string; 47 + expected_hash?: string; 48 + actual_hash?: string; 49 + waiver?: DriftWaiver; 50 + } 51 + 52 + export interface DriftWaiver { 53 + kind: 'promote_to_requirement' | 'waiver' | 'temporary_patch'; 54 + reason: string; 55 + signed_by?: string; 56 + expires?: string; 57 + } 58 + 59 + export interface DriftReport { 60 + entries: DriftEntry[]; 61 + clean_count: number; 62 + drifted_count: number; 63 + missing_count: number; 64 + summary: string; 65 + }
+54
src/models/pipeline.ts
··· 1 + /** 2 + * Pipeline & Compaction models. 3 + */ 4 + 5 + export interface PipelineConfig { 6 + pipeline_id: string; 7 + model_id: string; 8 + promptpack_version: string; 9 + extraction_rules_version: string; 10 + diff_policy_version: string; 11 + } 12 + 13 + export interface ShadowDiffMetrics { 14 + node_change_pct: number; 15 + edge_change_pct: number; 16 + risk_escalations: number; 17 + orphan_nodes: number; 18 + out_of_scope_growth: number; 19 + semantic_stmt_drift: number; 20 + } 21 + 22 + export enum UpgradeClassification { 23 + SAFE = 'SAFE', 24 + COMPACTION_EVENT = 'COMPACTION_EVENT', 25 + REJECT = 'REJECT', 26 + } 27 + 28 + export interface ShadowResult { 29 + old_pipeline: PipelineConfig; 30 + new_pipeline: PipelineConfig; 31 + metrics: ShadowDiffMetrics; 32 + classification: UpgradeClassification; 33 + reason: string; 34 + } 35 + 36 + export enum StorageTier { 37 + HOT = 'HOT', 38 + ANCESTRY = 'ANCESTRY', 39 + COLD = 'COLD', 40 + } 41 + 42 + export interface CompactionEvent { 43 + type: 'CompactionEvent'; 44 + timestamp: string; 45 + trigger: 'size_threshold' | 'pipeline_upgrade' | 'time_based'; 46 + nodes_compacted: number; 47 + bytes_freed: number; 48 + preserved: { 49 + node_headers: number; 50 + provenance_edges: number; 51 + approvals: number; 52 + signatures: number; 53 + }; 54 + }
+74
src/normalizer.ts
··· 1 + /** 2 + * Text normalization for stable semantic hashing. 3 + * 4 + * Goals: 5 + * - Formatting-only changes produce identical normalized output 6 + * - List order does not affect hash (items sorted) 7 + * - Deterministic and idempotent 8 + */ 9 + 10 + /** 11 + * Normalize a block of text for semantic hashing. 12 + */ 13 + export function normalizeText(raw: string): string { 14 + let text = raw; 15 + 16 + // Remove fenced code blocks entirely (preserve that code existed but not its content) 17 + text = text.replace(/```[\s\S]*?```/g, '(code block)'); 18 + 19 + // Remove markdown heading markers 20 + text = text.replace(/^#{1,6}\s+/gm, ''); 21 + 22 + // Remove bold/italic markers 23 + text = text.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1'); 24 + text = text.replace(/_{1,3}([^_]+)_{1,3}/g, '$1'); 25 + 26 + // Remove inline code backticks (but keep content) 27 + text = text.replace(/`([^`]+)`/g, '$1'); 28 + 29 + // Remove link syntax, keep text: [text](url) → text 30 + text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); 31 + 32 + // Lowercase 33 + text = text.toLowerCase(); 34 + 35 + // Process lines 36 + const lines = text.split('\n'); 37 + const processed: string[] = []; 38 + let listBuffer: string[] = []; 39 + 40 + for (const line of lines) { 41 + const trimmed = line.replace(/\s+/g, ' ').trim(); 42 + if (trimmed === '') { 43 + // Flush list buffer on blank line 44 + if (listBuffer.length > 0) { 45 + listBuffer.sort(); 46 + processed.push(...listBuffer); 47 + listBuffer = []; 48 + } 49 + continue; 50 + } 51 + 52 + // Detect list items (-, *, •, numbered) 53 + const listMatch = trimmed.match(/^(?:[-*•]|\d+[.)]\s*)\s*(.*)/); 54 + if (listMatch) { 55 + listBuffer.push(listMatch[1].trim()); 56 + } else { 57 + // Flush any pending list 58 + if (listBuffer.length > 0) { 59 + listBuffer.sort(); 60 + processed.push(...listBuffer); 61 + listBuffer = []; 62 + } 63 + processed.push(trimmed); 64 + } 65 + } 66 + 67 + // Flush remaining list 68 + if (listBuffer.length > 0) { 69 + listBuffer.sort(); 70 + processed.push(...listBuffer); 71 + } 72 + 73 + return processed.join('\n'); 74 + }
+71
src/policy-engine.ts
··· 1 + /** 2 + * Policy Engine — evaluates whether an IU has sufficient evidence. 3 + * 4 + * Each risk tier requires specific evidence kinds. The engine checks 5 + * what's been collected and what's missing or failing. 6 + */ 7 + 8 + import type { ImplementationUnit } from './models/iu.js'; 9 + import type { EvidenceRecord, PolicyEvaluation } from './models/evidence.js'; 10 + import { EvidenceStatus } from './models/evidence.js'; 11 + 12 + /** 13 + * Evaluate an IU's evidence against its policy. 14 + */ 15 + export function evaluatePolicy( 16 + iu: ImplementationUnit, 17 + evidence: EvidenceRecord[], 18 + ): PolicyEvaluation { 19 + const required = iu.evidence_policy.required; 20 + const iuEvidence = evidence.filter(e => e.iu_id === iu.iu_id); 21 + 22 + const satisfied: string[] = []; 23 + const missing: string[] = []; 24 + const failed: string[] = []; 25 + 26 + for (const req of required) { 27 + const matching = iuEvidence.filter(e => e.kind === req); 28 + if (matching.length === 0) { 29 + missing.push(req); 30 + } else { 31 + const latest = matching[matching.length - 1]; 32 + if (latest.status === EvidenceStatus.PASS) { 33 + satisfied.push(req); 34 + } else if (latest.status === EvidenceStatus.FAIL) { 35 + failed.push(req); 36 + } else { 37 + missing.push(req); // PENDING or SKIPPED count as missing 38 + } 39 + } 40 + } 41 + 42 + let verdict: 'PASS' | 'FAIL' | 'INCOMPLETE'; 43 + if (failed.length > 0) { 44 + verdict = 'FAIL'; 45 + } else if (missing.length > 0) { 46 + verdict = 'INCOMPLETE'; 47 + } else { 48 + verdict = 'PASS'; 49 + } 50 + 51 + return { 52 + iu_id: iu.iu_id, 53 + iu_name: iu.name, 54 + risk_tier: iu.risk_tier, 55 + required, 56 + satisfied, 57 + missing, 58 + failed, 59 + verdict, 60 + }; 61 + } 62 + 63 + /** 64 + * Evaluate policy for all IUs. 65 + */ 66 + export function evaluateAllPolicies( 67 + ius: ImplementationUnit[], 68 + evidence: EvidenceRecord[], 69 + ): PolicyEvaluation[] { 70 + return ius.map(iu => evaluatePolicy(iu, evidence)); 71 + }
+403
src/regen.ts
··· 1 + /** 2 + * Regeneration Engine — generates code stubs for each IU. 3 + * 4 + * Produces natural-looking TypeScript modules with: 5 + * - Typed interfaces for inputs/outputs 6 + * - Function stubs derived from requirements 7 + * - Config types from constraints 8 + * - Clean, readable code a developer would recognize 9 + * 10 + * In production this would invoke an LLM with a promptpack. 11 + */ 12 + 13 + import type { ImplementationUnit } from './models/iu.js'; 14 + import type { IUManifest, RegenMetadata, FileManifestEntry } from './models/manifest.js'; 15 + import { sha256 } from './semhash.js'; 16 + 17 + const TOOLCHAIN_VERSION = 'phoenix-regen/0.1.0'; 18 + const MODEL_ID = 'stub-generator/1.0'; 19 + 20 + export interface RegenResult { 21 + iu_id: string; 22 + files: Map<string, string>; // path → content 23 + manifest: IUManifest; 24 + } 25 + 26 + /** 27 + * Generate code for a single IU. 28 + */ 29 + export function generateIU(iu: ImplementationUnit): RegenResult { 30 + const files = new Map<string, string>(); 31 + 32 + for (const outputPath of iu.output_files) { 33 + const content = generateModule(iu); 34 + files.set(outputPath, content); 35 + } 36 + 37 + // Build manifest entries 38 + const fileEntries: Record<string, FileManifestEntry> = {}; 39 + for (const [path, content] of files) { 40 + fileEntries[path] = { 41 + path, 42 + content_hash: sha256(content), 43 + size: content.length, 44 + }; 45 + } 46 + 47 + const now = new Date().toISOString(); 48 + const promptpackHash = sha256(JSON.stringify(iu.contract)); 49 + 50 + const metadata: RegenMetadata = { 51 + model_id: MODEL_ID, 52 + promptpack_hash: promptpackHash, 53 + toolchain_version: TOOLCHAIN_VERSION, 54 + generated_at: now, 55 + }; 56 + 57 + return { 58 + iu_id: iu.iu_id, 59 + files, 60 + manifest: { 61 + iu_id: iu.iu_id, 62 + iu_name: iu.name, 63 + files: fileEntries, 64 + regen_metadata: metadata, 65 + }, 66 + }; 67 + } 68 + 69 + /** 70 + * Generate code for all IUs. 71 + */ 72 + export function generateAll(ius: ImplementationUnit[]): RegenResult[] { 73 + return ius.map(iu => generateIU(iu)); 74 + } 75 + 76 + // ─── Module Generation ─────────────────────────────────────────────────────── 77 + 78 + /** 79 + * Generate a natural TypeScript module from an IU contract. 80 + */ 81 + function generateModule(iu: ImplementationUnit): string { 82 + const lines: string[] = []; 83 + const moduleName = toPascalCase(iu.name); 84 + const configName = `${moduleName}Config`; 85 + 86 + // Header 87 + lines.push(`/**`); 88 + lines.push(` * ${iu.name}`); 89 + lines.push(` *`); 90 + lines.push(` * AUTO-GENERATED by Phoenix VCS — DO NOT EDIT DIRECTLY`); 91 + lines.push(` * Risk Tier: ${iu.risk_tier}`); 92 + lines.push(` */`); 93 + lines.push(''); 94 + 95 + // Config interface from constraints/invariants 96 + if (iu.contract.invariants.length > 0) { 97 + const fields = iu.contract.invariants 98 + .map(inv => ({ inv, field: constraintToConfigField(inv) })) 99 + .filter((x): x is { inv: string; field: { name: string; type: string } } => x.field !== null); 100 + 101 + if (fields.length > 0) { 102 + lines.push(`/**`); 103 + lines.push(` * Configuration and constraints for ${iu.name}.`); 104 + lines.push(` */`); 105 + lines.push(`export interface ${configName} {`); 106 + for (const { inv, field } of fields) { 107 + lines.push(` /** ${inv} */`); 108 + lines.push(` ${field.name}: ${field.type};`); 109 + } 110 + lines.push('}'); 111 + lines.push(''); 112 + } 113 + } 114 + 115 + // Input/output interfaces 116 + const inputTypeName = `${moduleName}Input`; 117 + const outputTypeName = `${moduleName}Result`; 118 + 119 + if (iu.contract.inputs.length > 0) { 120 + lines.push(`export interface ${inputTypeName} {`); 121 + for (const inp of iu.contract.inputs) { 122 + lines.push(` ${inp}: unknown;`); 123 + } 124 + lines.push('}'); 125 + lines.push(''); 126 + } 127 + 128 + if (iu.contract.outputs.length > 0) { 129 + lines.push(`export interface ${outputTypeName} {`); 130 + for (const out of iu.contract.outputs) { 131 + lines.push(` ${out}: unknown;`); 132 + } 133 + lines.push('}'); 134 + lines.push(''); 135 + } 136 + 137 + // Extract distinct operations from requirement statements 138 + const operations = extractOperations(iu); 139 + 140 + // Collect and emit placeholder types referenced by operations 141 + if (operations.length > 0) { 142 + const builtinTypes = new Set(['unknown', 'void', 'boolean', 'string', 'number', 'object', 143 + inputTypeName, outputTypeName, configName]); 144 + const placeholders = new Set<string>(); 145 + for (const op of operations) { 146 + for (const t of extractTypeRefs(op.params, op.returnType)) { 147 + if (!builtinTypes.has(t)) placeholders.add(t); 148 + } 149 + } 150 + if (placeholders.size > 0) { 151 + for (const t of placeholders) { 152 + lines.push(`/** Placeholder type — replace with your domain model. */`); 153 + lines.push(`export type ${t} = Record<string, unknown>;`); 154 + lines.push(''); 155 + } 156 + } 157 + } 158 + 159 + if (operations.length > 0) { 160 + for (const op of operations) { 161 + lines.push(`/**`); 162 + lines.push(` * ${op.description}`); 163 + lines.push(` */`); 164 + lines.push(`export function ${op.name}(${op.params}): ${op.returnType} {`); 165 + lines.push(` // TODO: implement`); 166 + lines.push(` throw new Error('Not implemented: ${op.name}');`); 167 + lines.push('}'); 168 + lines.push(''); 169 + } 170 + } else { 171 + // Fallback: single entry-point function 172 + const funcName = toCamelCase(iu.name); 173 + const params = iu.contract.inputs.length > 0 174 + ? `input: ${inputTypeName}` 175 + : ''; 176 + const ret = iu.contract.outputs.length > 0 ? outputTypeName : 'void'; 177 + lines.push(`/**`); 178 + lines.push(` * ${iu.contract.description.split('.')[0] || iu.name}.`); 179 + lines.push(` */`); 180 + lines.push(`export function ${funcName}(${params}): ${ret} {`); 181 + lines.push(` // TODO: implement`); 182 + lines.push(` throw new Error('Not implemented: ${funcName}');`); 183 + lines.push('}'); 184 + lines.push(''); 185 + } 186 + 187 + // Phoenix metadata (compact) 188 + lines.push(`/** @internal Phoenix VCS traceability — do not remove. */`); 189 + lines.push(`export const _phoenix = {`); 190 + lines.push(` iu_id: '${iu.iu_id}',`); 191 + lines.push(` name: '${iu.name}',`); 192 + lines.push(` risk_tier: '${iu.risk_tier}',`); 193 + lines.push(` canon_ids: [${iu.source_canon_ids.length} as const],`); 194 + lines.push('} as const;'); 195 + lines.push(''); 196 + 197 + return lines.join('\n'); 198 + } 199 + 200 + // ─── Operation Extraction ──────────────────────────────────────────────────── 201 + 202 + interface Operation { 203 + name: string; 204 + description: string; 205 + params: string; 206 + returnType: string; 207 + } 208 + 209 + /** 210 + * Extract distinct function operations from an IU's canonical requirements. 211 + * Looks for verb patterns in requirement statements and deduplicates. 212 + */ 213 + function extractOperations(iu: ImplementationUnit): Operation[] { 214 + const ops: Operation[] = []; 215 + const seenNames = new Set<string>(); 216 + 217 + // Parse requirements for action verbs 218 + const patterns: { pattern: RegExp; verb: string }[] = [ 219 + { pattern: /\bmust (?:support |handle )?creat(?:e|ing)\b/i, verb: 'create' }, 220 + { pattern: /\bmust (?:support |handle )?validat(?:e|ing)\b/i, verb: 'validate' }, 221 + { pattern: /\bmust (?:support |handle )?verif(?:y|ying)\b/i, verb: 'verify' }, 222 + { pattern: /\bmust (?:support |handle )?authenticat(?:e|ing)\b/i, verb: 'authenticate' }, 223 + { pattern: /\bmust (?:support |handle )?delet(?:e|ing)\b/i, verb: 'delete' }, 224 + { pattern: /\bmust (?:support |handle )?updat(?:e|ing)\b/i, verb: 'update' }, 225 + { pattern: /\bmust (?:support |handle )?search(?:ing)?\b/i, verb: 'search' }, 226 + { pattern: /\bmust (?:support |handle )?send(?:ing)?\b/i, verb: 'send' }, 227 + { pattern: /\bmust (?:support |handle )?deliver(?:y|ing)?\b/i, verb: 'deliver' }, 228 + { pattern: /\bmust (?:support |handle )?publish(?:ing)?\b/i, verb: 'publish' }, 229 + { pattern: /\bmust (?:support |handle )?rout(?:e|ing)\b/i, verb: 'route' }, 230 + { pattern: /\bmust (?:support |handle )?log(?:ging)?\b/i, verb: 'log' }, 231 + { pattern: /\bmust (?:support |handle )?reject(?:ed|ing)?\b/i, verb: 'reject' }, 232 + { pattern: /\bmust (?:be )?rate.?limit(?:ed|ing)?\b/i, verb: 'rateLimit' }, 233 + { pattern: /\bmust (?:support |handle )?retr(?:y|ying|ied)\b/i, verb: 'retry' }, 234 + { pattern: /\bmust (?:support |handle )?configur(?:e|ing|able)\b/i, verb: 'configure' }, 235 + { pattern: /\bmust (?:support |handle )?expos(?:e|ing)\b/i, verb: 'expose' }, 236 + { pattern: /\bmust (?:support |handle )?implement(?:ing)?\b/i, verb: 'handle' }, 237 + { pattern: /\bmust (?:support |handle )?inject(?:ing)?\b/i, verb: 'inject' }, 238 + { pattern: /\bmust (?:support |handle )?stor(?:e|ing)\b/i, verb: 'store' }, 239 + { pattern: /\bmust (?:support |handle )?archiv(?:e|ing)\b/i, verb: 'archive' }, 240 + { pattern: /\bmust (?:support |handle )?mark(?:ing)?\b/i, verb: 'mark' }, 241 + { pattern: /\bmust (?:support |handle )?process(?:ing|ed)?\b/i, verb: 'process' }, 242 + ]; 243 + 244 + // Group requirements by detected verb 245 + const verbGroups = new Map<string, string[]>(); 246 + const moduleName = toPascalCase(iu.name); 247 + 248 + for (const statement of iu.contract.description.split('. ').filter(Boolean)) { 249 + for (const { pattern, verb } of patterns) { 250 + if (pattern.test(statement)) { 251 + const list = verbGroups.get(verb) ?? []; 252 + list.push(statement); 253 + verbGroups.set(verb, list); 254 + break; // one verb per statement 255 + } 256 + } 257 + } 258 + 259 + // Generate one function per unique verb 260 + for (const [verb, statements] of verbGroups) { 261 + if (seenNames.has(verb)) continue; 262 + seenNames.add(verb); 263 + 264 + // Derive params from the object being acted on 265 + const subject = extractSubject(statements[0], verb); 266 + const paramName = subject ? toCamelCase(subject) : 'input'; 267 + const paramType = subject ? toPascalCase(subject) : 'unknown'; 268 + 269 + ops.push({ 270 + name: verb, 271 + description: statements[0], 272 + params: `${paramName}: ${paramType}`, 273 + returnType: verb === 'validate' || verb === 'verify' 274 + ? 'boolean' 275 + : verb === 'search' 276 + ? `${paramType}[]` 277 + : verb === 'delete' || verb === 'log' || verb === 'archive' || verb === 'mark' 278 + ? 'void' 279 + : paramType, 280 + }); 281 + } 282 + 283 + // Limit to reasonable number 284 + return ops.slice(0, 8); 285 + } 286 + 287 + /** 288 + * Try to extract the object/subject from a requirement statement. 289 + * "the service must validate JWT tokens" → "token" 290 + * "the gateway must reject expired tokens" → "token" 291 + */ 292 + function extractSubject(statement: string, verb: string): string | null { 293 + // Pattern: "must <verb> <object>" 294 + const regex = new RegExp(`must\\s+(?:support\\s+|handle\\s+)?${verb}\\w*\\s+(.+?)(?:\\s+(?:with|from|to|for|on|in|at|by|using|via|when|after|before)\\b|[.;,]|$)`, 'i'); 295 + const match = statement.match(regex); 296 + if (match) { 297 + const raw = match[1] 298 + .replace(/^(?:a|an|the|all|each|every|new)\s+/i, '') 299 + .replace(/\s*\(.*?\)/g, '') 300 + .trim(); 301 + // Take the core noun — typically 1-2 meaningful words 302 + const words = raw.split(/\s+/) 303 + .filter(w => w.length > 1) 304 + .slice(0, 2); 305 + if (words.length > 0) { 306 + // Singularize simple plurals 307 + const noun = words[words.length - 1].replace(/s$/, ''); 308 + words[words.length - 1] = noun; 309 + return words.join(' '); 310 + } 311 + } 312 + return null; 313 + } 314 + 315 + /** 316 + * Convert a constraint statement to a config field. 317 + * Returns null for constraints that are better expressed as code logic 318 + * rather than configuration. 319 + */ 320 + function constraintToConfigField(constraint: string): { name: string; type: string } | null { 321 + // Numeric limits: "rate limited to 5 per minute", "limited to 100 characters" 322 + const numMatch = constraint.match(/(\d+)\s*(per\s+\w+|characters|bytes|kb|mb|seconds?|minutes?|hours?|days?|retries|attempts)/i); 323 + if (numMatch) { 324 + const unit = numMatch[2].replace(/\s+/g, '').toLowerCase(); 325 + const subject = extractConstraintSubject(constraint); 326 + if (/rate.?limit/i.test(constraint)) { 327 + return { name: `${subject}RateLimitPer${capitalize(unit)}`, type: 'number' }; 328 + } 329 + if (/expir|ttl|window/i.test(constraint)) { 330 + return { name: `${subject}Ttl${capitalize(unit)}`, type: 'number' }; 331 + } 332 + return { name: `${subject}Max${capitalize(unit)}`, type: 'number' }; 333 + } 334 + 335 + // Configurable things: "CORS headers must be configurable per route" 336 + if (/\bconfigurable\b/i.test(constraint)) { 337 + const subject = extractConstraintSubject(constraint); 338 + return { name: `${subject}Config`, type: 'Record<string, unknown>' }; 339 + } 340 + 341 + // Skip vague "must not" / "never" constraints — they're invariants, not config 342 + return null; 343 + } 344 + 345 + /** 346 + * Extract a short subject identifier from a constraint. 347 + * "the service must not send more than 10 emails" → "email" 348 + */ 349 + function extractConstraintSubject(statement: string): string { 350 + // Find the most specific noun near the numbers/keywords 351 + const words = statement 352 + .toLowerCase() 353 + .replace(/\b(?:the|a|an|must|be|is|are|not|no|shall|never|always|service|gateway|system)\b/g, '') 354 + .replace(/[^a-z0-9\s]/g, '') 355 + .trim() 356 + .split(/\s+/) 357 + .filter(w => w.length > 2); 358 + 359 + // Pick the most meaningful word (skip common verbs) 360 + const skip = new Set(['send', 'store', 'access', 'more', 'than', 'per', 'with', 'for', 'from', 'limited', 'exceed', 'larger']); 361 + const meaningful = words.filter(w => !skip.has(w)); 362 + return toCamelCase(meaningful.slice(0, 2).join(' ')) || 'value'; 363 + } 364 + 365 + function capitalize(s: string): string { 366 + return s.charAt(0).toUpperCase() + s.slice(1); 367 + } 368 + 369 + /** 370 + * Extract type references from param and return type strings. 371 + * "jwtToken: JwtToken" → ["JwtToken"] 372 + * "User[]" → ["User"] 373 + */ 374 + function extractTypeRefs(params: string, returnType: string): string[] { 375 + const types: string[] = []; 376 + // From params: "name: Type" patterns 377 + const paramMatches = params.matchAll(/:\s*([A-Z][A-Za-z0-9]*)/g); 378 + for (const m of paramMatches) types.push(m[1]); 379 + // From return type 380 + const retMatch = returnType.replace(/\[\]$/, ''); 381 + if (/^[A-Z]/.test(retMatch)) types.push(retMatch); 382 + return types; 383 + } 384 + 385 + // ─── Naming Utilities ──────────────────────────────────────────────────────── 386 + 387 + function toCamelCase(str: string): string { 388 + return str 389 + .replace(/[^a-zA-Z0-9 ]/g, ' ') 390 + .split(/\s+/) 391 + .filter(Boolean) 392 + .map((w, i) => i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) 393 + .join(''); 394 + } 395 + 396 + function toPascalCase(str: string): string { 397 + return str 398 + .replace(/[^a-zA-Z0-9 ]/g, ' ') 399 + .split(/\s+/) 400 + .filter(Boolean) 401 + .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) 402 + .join(''); 403 + }
+489
src/scaffold.ts
··· 1 + /** 2 + * Scaffold Generator — produces the runnable service shell around generated modules. 3 + * 4 + * Generates: 5 + * - Per-service index.ts (barrel re-exports) 6 + * - Per-service server.ts (HTTP server with health + metrics) 7 + * - Per-service test file 8 + * - Root index.ts (service registry) 9 + * - Project package.json and tsconfig.json 10 + */ 11 + 12 + import type { ImplementationUnit } from './models/iu.js'; 13 + import { sha256 } from './semhash.js'; 14 + 15 + export interface ServiceDescriptor { 16 + /** Service name, e.g. "api-gateway" */ 17 + name: string; 18 + /** Directory under src/generated/, e.g. "api-gateway" */ 19 + dir: string; 20 + /** Module file names (without path prefix), e.g. ["authentication.ts", "rate-limiting.ts"] */ 21 + modules: string[]; 22 + /** The IUs belonging to this service */ 23 + ius: ImplementationUnit[]; 24 + /** Default port for this service */ 25 + port: number; 26 + } 27 + 28 + export interface ScaffoldResult { 29 + files: Map<string, string>; // path → content 30 + } 31 + 32 + /** 33 + * Derive service descriptors from the IU plan. 34 + */ 35 + export function deriveServices(ius: ImplementationUnit[]): ServiceDescriptor[] { 36 + const serviceMap = new Map<string, ServiceDescriptor>(); 37 + let nextPort = 3000; 38 + 39 + for (const iu of ius) { 40 + for (const outputFile of iu.output_files) { 41 + // outputFile is like "src/generated/api-gateway/authentication.ts" 42 + const parts = outputFile.replace('src/generated/', '').split('/'); 43 + if (parts.length < 2) continue; 44 + 45 + const dir = parts[0]; 46 + const moduleFile = parts.slice(1).join('/'); 47 + 48 + let svc = serviceMap.get(dir); 49 + if (!svc) { 50 + svc = { 51 + name: dirToName(dir), 52 + dir, 53 + modules: [], 54 + ius: [], 55 + port: nextPort++, 56 + }; 57 + serviceMap.set(dir, svc); 58 + } 59 + svc.modules.push(moduleFile); 60 + svc.ius.push(iu); 61 + } 62 + } 63 + 64 + return [...serviceMap.values()].sort((a, b) => a.dir.localeCompare(b.dir)); 65 + } 66 + 67 + /** 68 + * Generate all scaffold files. 69 + */ 70 + export function generateScaffold( 71 + services: ServiceDescriptor[], 72 + projectName: string = 'phoenix-project', 73 + ): ScaffoldResult { 74 + const files = new Map<string, string>(); 75 + 76 + for (const svc of services) { 77 + // Service barrel index 78 + files.set( 79 + `src/generated/${svc.dir}/index.ts`, 80 + generateServiceIndex(svc), 81 + ); 82 + 83 + // Service HTTP server 84 + files.set( 85 + `src/generated/${svc.dir}/server.ts`, 86 + generateServiceServer(svc), 87 + ); 88 + 89 + // Service tests 90 + files.set( 91 + `src/generated/${svc.dir}/__tests__/${svc.dir}.test.ts`, 92 + generateServiceTests(svc), 93 + ); 94 + } 95 + 96 + // Root index 97 + files.set('src/generated/index.ts', generateRootIndex(services)); 98 + 99 + // Project config 100 + files.set('package.json', generatePackageJson(services, projectName)); 101 + files.set('tsconfig.json', generateTsConfig()); 102 + files.set('vitest.config.ts', generateVitestConfig()); 103 + 104 + return { files }; 105 + } 106 + 107 + // ─── Service Index ─────────────────────────────────────────────────────────── 108 + 109 + function generateServiceIndex(svc: ServiceDescriptor): string { 110 + const lines: string[] = []; 111 + 112 + lines.push(`/**`); 113 + lines.push(` * ${svc.name}`); 114 + lines.push(` *`); 115 + lines.push(` * AUTO-GENERATED by Phoenix VCS`); 116 + lines.push(` * Barrel export for all ${svc.name} modules.`); 117 + lines.push(` */`); 118 + lines.push(''); 119 + 120 + for (const mod of svc.modules) { 121 + const modName = mod.replace(/\.ts$/, ''); 122 + const importName = toCamelCase(modName); 123 + lines.push(`export * as ${importName} from './${modName}.js';`); 124 + } 125 + lines.push(''); 126 + 127 + return lines.join('\n'); 128 + } 129 + 130 + // ─── Service Server ────────────────────────────────────────────────────────── 131 + 132 + function generateServiceServer(svc: ServiceDescriptor): string { 133 + const lines: string[] = []; 134 + const portVar = `${svc.dir.toUpperCase().replace(/-/g, '_')}_PORT`; 135 + 136 + lines.push(`/**`); 137 + lines.push(` * ${svc.name} — HTTP Server`); 138 + lines.push(` *`); 139 + lines.push(` * AUTO-GENERATED by Phoenix VCS`); 140 + lines.push(` * Provides health check, metrics, and module endpoints.`); 141 + lines.push(` */`); 142 + lines.push(''); 143 + lines.push(`import { createServer, IncomingMessage, ServerResponse } from 'node:http';`); 144 + lines.push(''); 145 + 146 + // Import modules 147 + for (const mod of svc.modules) { 148 + const modName = mod.replace(/\.ts$/, ''); 149 + const importName = toCamelCase(modName); 150 + lines.push(`import * as ${importName} from './${modName}.js';`); 151 + } 152 + lines.push(''); 153 + 154 + // Metrics tracking 155 + lines.push(`// ─── Metrics ─────────────────────────────────────────────────────────────────`); 156 + lines.push(''); 157 + lines.push(`const metrics = {`); 158 + lines.push(` requests_total: 0,`); 159 + lines.push(` requests_by_path: {} as Record<string, number>,`); 160 + lines.push(` errors_total: 0,`); 161 + lines.push(` uptime_start: Date.now(),`); 162 + lines.push(`};`); 163 + lines.push(''); 164 + 165 + // Module registry 166 + lines.push(`// ─── Module Registry ─────────────────────────────────────────────────────────`); 167 + lines.push(''); 168 + lines.push(`const modules = {`); 169 + for (const mod of svc.modules) { 170 + const modName = mod.replace(/\.ts$/, ''); 171 + const importName = toCamelCase(modName); 172 + lines.push(` '${modName}': ${importName},`); 173 + } 174 + lines.push(`};`); 175 + lines.push(''); 176 + 177 + // Router 178 + lines.push(`// ─── Router ──────────────────────────────────────────────────────────────────`); 179 + lines.push(''); 180 + lines.push(`type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;`); 181 + lines.push(''); 182 + lines.push(`const routes: Record<string, Handler> = {`); 183 + lines.push(` '/health': (_req, res) => {`); 184 + lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 185 + lines.push(` res.end(JSON.stringify({`); 186 + lines.push(` status: 'ok',`); 187 + lines.push(` service: '${svc.name}',`); 188 + lines.push(` uptime: Math.floor((Date.now() - metrics.uptime_start) / 1000),`); 189 + lines.push(` modules: Object.keys(modules),`); 190 + lines.push(` }));`); 191 + lines.push(` },`); 192 + lines.push(''); 193 + lines.push(` '/metrics': (_req, res) => {`); 194 + lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 195 + lines.push(` res.end(JSON.stringify({`); 196 + lines.push(` ...metrics,`); 197 + lines.push(` uptime_seconds: Math.floor((Date.now() - metrics.uptime_start) / 1000),`); 198 + lines.push(` }, null, 2));`); 199 + lines.push(` },`); 200 + lines.push(''); 201 + lines.push(` '/modules': (_req, res) => {`); 202 + lines.push(` const info = Object.entries(modules).map(([name, mod]) => {`); 203 + lines.push(` const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined;`); 204 + lines.push(` return {`); 205 + lines.push(` name,`); 206 + lines.push(` risk_tier: phoenix?.risk_tier ?? 'unknown',`); 207 + lines.push(` exports: Object.keys(mod).filter(k => k !== '_phoenix'),`); 208 + lines.push(` };`); 209 + lines.push(` });`); 210 + lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 211 + lines.push(` res.end(JSON.stringify(info, null, 2));`); 212 + lines.push(` },`); 213 + lines.push(`};`); 214 + lines.push(''); 215 + 216 + // Server factory 217 + lines.push(`// ─── Server ──────────────────────────────────────────────────────────────────`); 218 + lines.push(''); 219 + lines.push(`function handleRequest(req: IncomingMessage, res: ServerResponse): void {`); 220 + lines.push(` const url = req.url ?? '/';`); 221 + lines.push(` const path = url.split('?')[0];`); 222 + lines.push(''); 223 + lines.push(` metrics.requests_total++;`); 224 + lines.push(` metrics.requests_by_path[path] = (metrics.requests_by_path[path] ?? 0) + 1;`); 225 + lines.push(''); 226 + lines.push(` const handler = routes[path];`); 227 + lines.push(` if (handler) {`); 228 + lines.push(` try {`); 229 + lines.push(` handler(req, res);`); 230 + lines.push(` } catch (err) {`); 231 + lines.push(` metrics.errors_total++;`); 232 + lines.push(` res.writeHead(500, { 'Content-Type': 'application/json' });`); 233 + lines.push(` res.end(JSON.stringify({ error: String(err) }));`); 234 + lines.push(` }`); 235 + lines.push(` } else {`); 236 + lines.push(` res.writeHead(404, { 'Content-Type': 'application/json' });`); 237 + lines.push(` res.end(JSON.stringify({`); 238 + lines.push(` error: 'Not Found',`); 239 + lines.push(` path,`); 240 + lines.push(` available: Object.keys(routes),`); 241 + lines.push(` }));`); 242 + lines.push(` }`); 243 + lines.push(`}`); 244 + lines.push(''); 245 + 246 + lines.push(`export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } {`); 247 + lines.push(` const requestedPort = port ?? parseInt(process.env.${portVar} ?? process.env.PORT ?? '${svc.port}', 10);`); 248 + lines.push(` const server = createServer(handleRequest);`); 249 + lines.push(` let actualPort = requestedPort;`); 250 + lines.push(''); 251 + lines.push(` const ready = new Promise<void>(resolve => {`); 252 + lines.push(` server.listen(requestedPort, () => {`); 253 + lines.push(` const addr = server.address();`); 254 + lines.push(` if (addr && typeof addr === 'object') actualPort = addr.port;`); 255 + lines.push(` result.port = actualPort;`); 256 + lines.push(` console.log(\`${svc.name} listening on http://localhost:\${actualPort}\`);`); 257 + lines.push(` console.log(\` /health — health check\`);`); 258 + lines.push(` console.log(\` /metrics — request metrics\`);`); 259 + lines.push(` console.log(\` /modules — registered modules\`);`); 260 + lines.push(` resolve();`); 261 + lines.push(` });`); 262 + lines.push(` });`); 263 + lines.push(''); 264 + lines.push(` const result = { server, port: actualPort, ready };`); 265 + lines.push(` return result;`); 266 + lines.push(`}`); 267 + lines.push(''); 268 + 269 + // Main 270 + lines.push(`// Start when run directly`); 271 + lines.push(`const isMain = process.argv[1]?.endsWith('/${svc.dir}/server.js') ||`); 272 + lines.push(` process.argv[1]?.endsWith('/${svc.dir}/server.ts');`); 273 + lines.push(`if (isMain) {`); 274 + lines.push(` startServer();`); 275 + lines.push(`}`); 276 + lines.push(''); 277 + 278 + return lines.join('\n'); 279 + } 280 + 281 + // ─── Service Tests ─────────────────────────────────────────────────────────── 282 + 283 + function generateServiceTests(svc: ServiceDescriptor): string { 284 + const lines: string[] = []; 285 + 286 + lines.push(`/**`); 287 + lines.push(` * ${svc.name} — Generated Tests`); 288 + lines.push(` *`); 289 + lines.push(` * AUTO-GENERATED by Phoenix VCS`); 290 + lines.push(` * Tests module structure, server health, and Phoenix traceability.`); 291 + lines.push(` */`); 292 + lines.push(''); 293 + lines.push(`import { describe, it, expect, afterAll } from 'vitest';`); 294 + lines.push(`import { startServer } from '../server.js';`); 295 + lines.push(''); 296 + 297 + // Import modules 298 + for (const mod of svc.modules) { 299 + const modName = mod.replace(/\.ts$/, ''); 300 + const importName = toCamelCase(modName); 301 + lines.push(`import * as ${importName} from '../${modName}.js';`); 302 + } 303 + lines.push(''); 304 + 305 + // Module structure tests 306 + lines.push(`describe('${svc.name} modules', () => {`); 307 + for (const mod of svc.modules) { 308 + const modName = mod.replace(/\.ts$/, ''); 309 + const importName = toCamelCase(modName); 310 + const iu = svc.ius.find(u => u.output_files.some(f => f.includes(modName))); 311 + const displayName = iu?.name || modName; 312 + 313 + lines.push(` describe('${displayName}', () => {`); 314 + lines.push(` it('exports Phoenix traceability metadata', () => {`); 315 + lines.push(` expect(${importName}._phoenix).toBeDefined();`); 316 + lines.push(` expect(${importName}._phoenix.name).toBe('${displayName}');`); 317 + lines.push(` expect(${importName}._phoenix.risk_tier).toBeTruthy();`); 318 + lines.push(` });`); 319 + lines.push(''); 320 + lines.push(` it('has exported functions', () => {`); 321 + lines.push(` const exports = Object.keys(${importName}).filter(k => k !== '_phoenix');`); 322 + lines.push(` expect(exports.length).toBeGreaterThan(0);`); 323 + lines.push(` });`); 324 + lines.push(` });`); 325 + lines.push(''); 326 + } 327 + lines.push(`});`); 328 + lines.push(''); 329 + 330 + // Server tests 331 + lines.push(`describe('${svc.name} server', () => {`); 332 + lines.push(` const instance = startServer(0); // random port`); 333 + lines.push(''); 334 + lines.push(` afterAll(() => new Promise<void>(resolve => instance.server.close(() => resolve())));`); 335 + lines.push(''); 336 + lines.push(` it('GET /health returns 200', async () => {`); 337 + lines.push(` await instance.ready;`); 338 + lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/health\`);`); 339 + lines.push(` expect(res.status).toBe(200);`); 340 + lines.push(` const body = await res.json() as Record<string, unknown>;`); 341 + lines.push(` expect(body.status).toBe('ok');`); 342 + lines.push(` expect(body.service).toBe('${svc.name}');`); 343 + lines.push(` });`); 344 + lines.push(''); 345 + lines.push(` it('GET /metrics returns request counts', async () => {`); 346 + lines.push(` await instance.ready;`); 347 + lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/metrics\`);`); 348 + lines.push(` expect(res.status).toBe(200);`); 349 + lines.push(` const body = await res.json() as Record<string, unknown>;`); 350 + lines.push(` expect(typeof body.requests_total).toBe('number');`); 351 + lines.push(` });`); 352 + lines.push(''); 353 + lines.push(` it('GET /modules lists all registered modules', async () => {`); 354 + lines.push(` await instance.ready;`); 355 + lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/modules\`);`); 356 + lines.push(` expect(res.status).toBe(200);`); 357 + lines.push(` const body = await res.json() as Array<Record<string, unknown>>;`); 358 + lines.push(` expect(body.length).toBe(${svc.modules.length});`); 359 + lines.push(` });`); 360 + lines.push(''); 361 + lines.push(` it('GET /unknown returns 404', async () => {`); 362 + lines.push(` await instance.ready;`); 363 + lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/unknown\`);`); 364 + lines.push(` expect(res.status).toBe(404);`); 365 + lines.push(` });`); 366 + lines.push(`});`); 367 + lines.push(''); 368 + 369 + return lines.join('\n'); 370 + } 371 + 372 + // ─── Root Index ────────────────────────────────────────────────────────────── 373 + 374 + function generateRootIndex(services: ServiceDescriptor[]): string { 375 + const lines: string[] = []; 376 + 377 + lines.push(`/**`); 378 + lines.push(` * Phoenix VCS — Generated Service Registry`); 379 + lines.push(` *`); 380 + lines.push(` * AUTO-GENERATED by Phoenix VCS`); 381 + lines.push(` */`); 382 + lines.push(''); 383 + 384 + for (const svc of services) { 385 + const importName = toCamelCase(svc.dir); 386 + lines.push(`export * as ${importName} from './${svc.dir}/index.js';`); 387 + } 388 + lines.push(''); 389 + 390 + lines.push(`export const services = [`); 391 + for (const svc of services) { 392 + lines.push(` { name: '${svc.name}', dir: '${svc.dir}', port: ${svc.port}, modules: ${svc.modules.length} },`); 393 + } 394 + lines.push(`] as const;`); 395 + lines.push(''); 396 + 397 + return lines.join('\n'); 398 + } 399 + 400 + // ─── Project Config ────────────────────────────────────────────────────────── 401 + 402 + function generatePackageJson( 403 + services: ServiceDescriptor[], 404 + projectName: string, 405 + ): string { 406 + const scripts: Record<string, string> = { 407 + build: 'tsc', 408 + typecheck: 'tsc --noEmit', 409 + test: 'vitest run', 410 + 'test:watch': 'vitest', 411 + }; 412 + 413 + // Add start script per service (build first, then run) 414 + for (const svc of services) { 415 + scripts[`start:${svc.dir}`] = `tsc && node dist/generated/${svc.dir}/server.js`; 416 + } 417 + 418 + // Default start = first service 419 + if (services.length > 0) { 420 + scripts.start = `tsc && node dist/generated/${services[0].dir}/server.js`; 421 + } 422 + 423 + const pkg = { 424 + name: projectName, 425 + version: '0.1.0', 426 + description: `Generated by Phoenix VCS — ${services.length} services`, 427 + type: 'module', 428 + scripts, 429 + devDependencies: { 430 + typescript: '^5.4.0', 431 + vitest: '^2.0.0', 432 + '@types/node': '^22.0.0', 433 + }, 434 + }; 435 + 436 + return JSON.stringify(pkg, null, 2) + '\n'; 437 + } 438 + 439 + function generateTsConfig(): string { 440 + const config = { 441 + compilerOptions: { 442 + target: 'ES2022', 443 + module: 'ESNext', 444 + moduleResolution: 'bundler', 445 + declaration: true, 446 + outDir: 'dist', 447 + rootDir: 'src', 448 + strict: true, 449 + esModuleInterop: true, 450 + skipLibCheck: true, 451 + forceConsistentCasingInFileNames: true, 452 + resolveJsonModule: true, 453 + sourceMap: true, 454 + }, 455 + include: ['src/**/*'], 456 + exclude: ['node_modules', 'dist'], 457 + }; 458 + 459 + return JSON.stringify(config, null, 2) + '\n'; 460 + } 461 + 462 + function generateVitestConfig(): string { 463 + return `import { defineConfig } from 'vitest/config'; 464 + 465 + export default defineConfig({ 466 + test: { 467 + include: ['src/**/__tests__/**/*.test.ts'], 468 + }, 469 + }); 470 + `; 471 + } 472 + 473 + // ─── Utilities ─────────────────────────────────────────────────────────────── 474 + 475 + function dirToName(dir: string): string { 476 + return dir 477 + .split('-') 478 + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) 479 + .join(' '); 480 + } 481 + 482 + function toCamelCase(str: string): string { 483 + return str 484 + .replace(/[^a-zA-Z0-9]/g, ' ') 485 + .split(/\s+/) 486 + .filter(Boolean) 487 + .map((w, i) => i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) 488 + .join(''); 489 + }
+58
src/semhash.ts
··· 1 + /** 2 + * Semantic hashing for clauses. 3 + * 4 + * Two hash types: 5 + * - clause_semhash: content identity (normalized text only) 6 + * - context_semhash_cold: local structural context (content + section + neighbors) 7 + */ 8 + 9 + import { createHash } from 'node:crypto'; 10 + 11 + /** 12 + * Compute SHA-256 hex digest of input string. 13 + */ 14 + export function sha256(input: string): string { 15 + return createHash('sha256').update(input, 'utf8').digest('hex'); 16 + } 17 + 18 + /** 19 + * Compute clause_semhash — pure content identity. 20 + */ 21 + export function clauseSemhash(normalizedText: string): string { 22 + return sha256(normalizedText); 23 + } 24 + 25 + /** 26 + * Compute context_semhash_cold — content + local structural context. 27 + * 28 + * Includes: 29 + * - normalized text 30 + * - section path (heading hierarchy) 31 + * - previous clause's semhash (or empty string) 32 + * - next clause's semhash (or empty string) 33 + */ 34 + export function contextSemhashCold( 35 + normalizedText: string, 36 + sectionPath: string[], 37 + prevClauseSemhash: string, 38 + nextClauseSemhash: string, 39 + ): string { 40 + const parts = [ 41 + normalizedText, 42 + sectionPath.join('/'), 43 + prevClauseSemhash, 44 + nextClauseSemhash, 45 + ]; 46 + return sha256(parts.join('\x00')); 47 + } 48 + 49 + /** 50 + * Compute content-addressed clause ID. 51 + */ 52 + export function clauseId( 53 + sourceDocId: string, 54 + sectionPath: string[], 55 + normalizedText: string, 56 + ): string { 57 + return sha256([sourceDocId, sectionPath.join('/'), normalizedText].join('\x00')); 58 + }
+97
src/shadow-pipeline.ts
··· 1 + /** 2 + * Shadow Pipeline — runs old and new canonicalization pipelines in parallel, 3 + * compares output, and classifies the upgrade. 4 + */ 5 + 6 + import type { CanonicalNode } from './models/canonical.js'; 7 + import type { ShadowDiffMetrics, ShadowResult, PipelineConfig } from './models/pipeline.js'; 8 + import { UpgradeClassification } from './models/pipeline.js'; 9 + 10 + /** 11 + * Compare two sets of canonical nodes produced by different pipeline versions. 12 + */ 13 + export function computeShadowDiff( 14 + oldNodes: CanonicalNode[], 15 + newNodes: CanonicalNode[], 16 + ): ShadowDiffMetrics { 17 + const oldIds = new Set(oldNodes.map(n => n.canon_id)); 18 + const newIds = new Set(newNodes.map(n => n.canon_id)); 19 + 20 + const addedNodes = newNodes.filter(n => !oldIds.has(n.canon_id)); 21 + const removedNodes = oldNodes.filter(n => !newIds.has(n.canon_id)); 22 + const keptNodes = newNodes.filter(n => oldIds.has(n.canon_id)); 23 + 24 + const totalNodes = Math.max(oldNodes.length, 1); 25 + const nodeChangePct = ((addedNodes.length + removedNodes.length) / totalNodes) * 100; 26 + 27 + // Edge changes 28 + const oldEdges = new Set(oldNodes.flatMap(n => n.linked_canon_ids.map(l => `${n.canon_id}->${l}`))); 29 + const newEdges = new Set(newNodes.flatMap(n => n.linked_canon_ids.map(l => `${n.canon_id}->${l}`))); 30 + const edgeAdded = [...newEdges].filter(e => !oldEdges.has(e)).length; 31 + const edgeRemoved = [...oldEdges].filter(e => !newEdges.has(e)).length; 32 + const totalEdges = Math.max(oldEdges.size, 1); 33 + const edgeChangePct = ((edgeAdded + edgeRemoved) / totalEdges) * 100; 34 + 35 + // Orphan nodes (new nodes with no links and no source) 36 + const orphanNodes = addedNodes.filter( 37 + n => n.linked_canon_ids.length === 0 && n.source_clause_ids.length === 0 38 + ).length; 39 + 40 + // Risk escalations: nodes that changed type (approximate by statement match) 41 + const oldByStmt = new Map(oldNodes.map(n => [n.statement, n])); 42 + let riskEscalations = 0; 43 + for (const nn of newNodes) { 44 + const old = oldByStmt.get(nn.statement); 45 + if (old && old.type !== nn.type) riskEscalations++; 46 + } 47 + 48 + // Semantic statement drift: how many statements are completely new 49 + const oldStmts = new Set(oldNodes.map(n => n.statement)); 50 + const driftCount = newNodes.filter(n => !oldStmts.has(n.statement)).length; 51 + const semanticDrift = (driftCount / Math.max(newNodes.length, 1)) * 100; 52 + 53 + return { 54 + node_change_pct: Math.round(nodeChangePct * 100) / 100, 55 + edge_change_pct: Math.round(edgeChangePct * 100) / 100, 56 + risk_escalations: riskEscalations, 57 + orphan_nodes: orphanNodes, 58 + out_of_scope_growth: addedNodes.length - removedNodes.length, 59 + semantic_stmt_drift: Math.round(semanticDrift * 100) / 100, 60 + }; 61 + } 62 + 63 + /** 64 + * Classify a shadow diff as SAFE, COMPACTION_EVENT, or REJECT. 65 + */ 66 + export function classifyShadowDiff(metrics: ShadowDiffMetrics): { 67 + classification: UpgradeClassification; 68 + reason: string; 69 + } { 70 + if (metrics.orphan_nodes > 0) { 71 + return { classification: UpgradeClassification.REJECT, reason: `${metrics.orphan_nodes} orphan nodes detected` }; 72 + } 73 + if (metrics.semantic_stmt_drift > 50) { 74 + return { classification: UpgradeClassification.REJECT, reason: `Semantic drift too high: ${metrics.semantic_stmt_drift}%` }; 75 + } 76 + if (metrics.node_change_pct <= 3 && metrics.risk_escalations === 0) { 77 + return { classification: UpgradeClassification.SAFE, reason: `Node change ${metrics.node_change_pct}% ≤ 3%, no risk escalations` }; 78 + } 79 + if (metrics.node_change_pct <= 25 && metrics.orphan_nodes === 0) { 80 + return { classification: UpgradeClassification.COMPACTION_EVENT, reason: `Node change ${metrics.node_change_pct}% ≤ 25%, no orphans` }; 81 + } 82 + return { classification: UpgradeClassification.REJECT, reason: `Excessive churn: ${metrics.node_change_pct}% node change` }; 83 + } 84 + 85 + /** 86 + * Run a full shadow comparison. 87 + */ 88 + export function runShadowPipeline( 89 + oldPipeline: PipelineConfig, 90 + newPipeline: PipelineConfig, 91 + oldNodes: CanonicalNode[], 92 + newNodes: CanonicalNode[], 93 + ): ShadowResult { 94 + const metrics = computeShadowDiff(oldNodes, newNodes); 95 + const { classification, reason } = classifyShadowDiff(metrics); 96 + return { old_pipeline: oldPipeline, new_pipeline: newPipeline, metrics, classification, reason }; 97 + }
+145
src/spec-parser.ts
··· 1 + /** 2 + * Spec Parser — Markdown → Clause[] 3 + * 4 + * Splits a Markdown document on heading boundaries. 5 + * Each heading + its body = one Clause. 6 + * Tracks section hierarchy for nested headings. 7 + */ 8 + 9 + import type { Clause } from './models/clause.js'; 10 + import { normalizeText } from './normalizer.js'; 11 + import { clauseSemhash, contextSemhashCold, clauseId } from './semhash.js'; 12 + 13 + interface RawSection { 14 + heading: string; 15 + level: number; 16 + startLine: number; // 1-indexed 17 + endLine: number; // 1-indexed, inclusive 18 + rawText: string; 19 + sectionPath: string[]; 20 + } 21 + 22 + /** 23 + * Parse a Markdown document into an array of Clauses. 24 + */ 25 + export function parseSpec(content: string, docId: string): Clause[] { 26 + const lines = content.split('\n'); 27 + const sections = extractSections(lines); 28 + 29 + if (sections.length === 0) { 30 + // No headings found — treat entire document as one clause 31 + if (content.trim().length === 0) return []; 32 + const normalizedText = normalizeText(content); 33 + const semhash = clauseSemhash(normalizedText); 34 + const sectionPath: string[] = []; 35 + const id = clauseId(docId, sectionPath, normalizedText); 36 + const ctxHash = contextSemhashCold(normalizedText, sectionPath, '', ''); 37 + return [{ 38 + clause_id: id, 39 + source_doc_id: docId, 40 + source_line_range: [1, lines.length], 41 + raw_text: content, 42 + normalized_text: normalizedText, 43 + section_path: sectionPath, 44 + clause_semhash: semhash, 45 + context_semhash_cold: ctxHash, 46 + }]; 47 + } 48 + 49 + // Build clauses without context hashes first 50 + const preClauses: Omit<Clause, 'context_semhash_cold'>[] = sections.map(sec => { 51 + const normalized = normalizeText(sec.rawText); 52 + const semhash = clauseSemhash(normalized); 53 + const id = clauseId(docId, sec.sectionPath, normalized); 54 + return { 55 + clause_id: id, 56 + source_doc_id: docId, 57 + source_line_range: [sec.startLine, sec.endLine] as [number, number], 58 + raw_text: sec.rawText, 59 + normalized_text: normalized, 60 + section_path: sec.sectionPath, 61 + clause_semhash: semhash, 62 + }; 63 + }); 64 + 65 + // Now compute context hashes with neighbor awareness 66 + const clauses: Clause[] = preClauses.map((pc, i) => { 67 + const prev = i > 0 ? preClauses[i - 1].clause_semhash : ''; 68 + const next = i < preClauses.length - 1 ? preClauses[i + 1].clause_semhash : ''; 69 + const ctxHash = contextSemhashCold(pc.normalized_text, pc.section_path, prev, next); 70 + return { ...pc, context_semhash_cold: ctxHash }; 71 + }); 72 + 73 + return clauses; 74 + } 75 + 76 + /** 77 + * Extract sections from Markdown lines. 78 + * A section = heading line through (but not including) the next heading of same or higher level, 79 + * or end of file. 80 + */ 81 + function extractSections(lines: string[]): RawSection[] { 82 + const headingPattern = /^(#{1,6})\s+(.+)/; 83 + const headingIndices: { index: number; level: number; text: string }[] = []; 84 + 85 + for (let i = 0; i < lines.length; i++) { 86 + const match = lines[i].match(headingPattern); 87 + if (match) { 88 + headingIndices.push({ 89 + index: i, 90 + level: match[1].length, 91 + text: match[2].trim(), 92 + }); 93 + } 94 + } 95 + 96 + if (headingIndices.length === 0) return []; 97 + 98 + // Build sections with proper section_path tracking 99 + const sections: RawSection[] = []; 100 + const pathStack: { level: number; text: string }[] = []; 101 + 102 + // Capture pre-heading content as a preamble section 103 + if (headingIndices.length > 0 && headingIndices[0].index > 0) { 104 + const preambleText = lines.slice(0, headingIndices[0].index).join('\n').trim(); 105 + if (preambleText.length > 0) { 106 + sections.push({ 107 + heading: '(preamble)', 108 + level: 0, 109 + startLine: 1, 110 + endLine: headingIndices[0].index, 111 + rawText: preambleText, 112 + sectionPath: ['(preamble)'], 113 + }); 114 + } 115 + } 116 + 117 + for (let h = 0; h < headingIndices.length; h++) { 118 + const { index, level, text } = headingIndices[h]; 119 + const startLine = index + 1; // 1-indexed 120 + const endLine = h < headingIndices.length - 1 121 + ? headingIndices[h + 1].index // line before next heading (0-indexed), = next heading 1-indexed - 1 122 + : lines.length; 123 + 124 + // Update section path stack 125 + while (pathStack.length > 0 && pathStack[pathStack.length - 1].level >= level) { 126 + pathStack.pop(); 127 + } 128 + pathStack.push({ level, text }); 129 + const sectionPath = pathStack.map(p => p.text); 130 + 131 + // Extract raw text for this section 132 + const rawText = lines.slice(index, endLine).join('\n'); 133 + 134 + sections.push({ 135 + heading: text, 136 + level, 137 + startLine, 138 + endLine, 139 + rawText, 140 + sectionPath: [...sectionPath], 141 + }); 142 + } 143 + 144 + return sections; 145 + }
+92
src/store/canonical-store.ts
··· 1 + /** 2 + * Canonical Store — manages the Canonical Graph 3 + * 4 + * Persists canonical nodes and their provenance edges. 5 + */ 6 + 7 + import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; 8 + import { join } from 'node:path'; 9 + import type { CanonicalNode, CanonicalGraph } from '../models/canonical.js'; 10 + import { ContentStore } from './content-store.js'; 11 + 12 + export class CanonicalStore { 13 + private contentStore: ContentStore; 14 + private graphPath: string; 15 + 16 + constructor(phoenixRoot: string) { 17 + this.contentStore = new ContentStore(phoenixRoot); 18 + const graphDir = join(phoenixRoot, 'graphs'); 19 + mkdirSync(graphDir, { recursive: true }); 20 + this.graphPath = join(graphDir, 'canonical.json'); 21 + } 22 + 23 + private loadGraph(): CanonicalGraph { 24 + if (!existsSync(this.graphPath)) { 25 + return { nodes: {}, provenance: {} }; 26 + } 27 + return JSON.parse(readFileSync(this.graphPath, 'utf8')); 28 + } 29 + 30 + private saveGraph(graph: CanonicalGraph): void { 31 + writeFileSync(this.graphPath, JSON.stringify(graph, null, 2), 'utf8'); 32 + } 33 + 34 + /** 35 + * Store canonical nodes and update the graph. 36 + */ 37 + saveNodes(nodes: CanonicalNode[]): void { 38 + const graph = this.loadGraph(); 39 + 40 + for (const node of nodes) { 41 + // Store in content store 42 + this.contentStore.put(node.canon_id, node); 43 + 44 + // Update graph index 45 + graph.nodes[node.canon_id] = node; 46 + 47 + // Update provenance 48 + for (const clauseId of node.source_clause_ids) { 49 + if (!graph.provenance[node.canon_id]) { 50 + graph.provenance[node.canon_id] = []; 51 + } 52 + if (!graph.provenance[node.canon_id].includes(clauseId)) { 53 + graph.provenance[node.canon_id].push(clauseId); 54 + } 55 + } 56 + } 57 + 58 + this.saveGraph(graph); 59 + } 60 + 61 + /** 62 + * Get a canonical node by ID. 63 + */ 64 + getNode(canonId: string): CanonicalNode | null { 65 + return this.contentStore.get<CanonicalNode>(canonId); 66 + } 67 + 68 + /** 69 + * Get all canonical nodes. 70 + */ 71 + getAllNodes(): CanonicalNode[] { 72 + const graph = this.loadGraph(); 73 + return Object.values(graph.nodes); 74 + } 75 + 76 + /** 77 + * Get canonical nodes sourced from a specific clause. 78 + */ 79 + getNodesByClause(clauseId: string): CanonicalNode[] { 80 + const graph = this.loadGraph(); 81 + return Object.values(graph.nodes).filter( 82 + n => n.source_clause_ids.includes(clauseId) 83 + ); 84 + } 85 + 86 + /** 87 + * Get the full canonical graph. 88 + */ 89 + getGraph(): CanonicalGraph { 90 + return this.loadGraph(); 91 + } 92 + }
+47
src/store/content-store.ts
··· 1 + /** 2 + * Content-Addressed Object Store 3 + * 4 + * Stores JSON objects by their content hash. 5 + * Operates on the filesystem under .phoenix/store/objects/ 6 + */ 7 + 8 + import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; 9 + import { join } from 'node:path'; 10 + 11 + export class ContentStore { 12 + private objectsDir: string; 13 + 14 + constructor(phoenixRoot: string) { 15 + this.objectsDir = join(phoenixRoot, 'store', 'objects'); 16 + mkdirSync(this.objectsDir, { recursive: true }); 17 + } 18 + 19 + /** 20 + * Store an object by its ID. ID is expected to be a hex hash. 21 + */ 22 + put(id: string, data: unknown): void { 23 + // Use first 2 chars as subdirectory for fan-out 24 + const subDir = join(this.objectsDir, id.slice(0, 2)); 25 + mkdirSync(subDir, { recursive: true }); 26 + const filePath = join(subDir, id); 27 + writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); 28 + } 29 + 30 + /** 31 + * Retrieve an object by ID. Returns null if not found. 32 + */ 33 + get<T = unknown>(id: string): T | null { 34 + const filePath = join(this.objectsDir, id.slice(0, 2), id); 35 + if (!existsSync(filePath)) return null; 36 + const raw = readFileSync(filePath, 'utf8'); 37 + return JSON.parse(raw) as T; 38 + } 39 + 40 + /** 41 + * Check if an object exists. 42 + */ 43 + has(id: string): boolean { 44 + const filePath = join(this.objectsDir, id.slice(0, 2), id); 45 + return existsSync(filePath); 46 + } 47 + }
+63
src/store/evidence-store.ts
··· 1 + /** 2 + * Evidence Store — persists evidence records. 3 + */ 4 + 5 + import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; 6 + import { join } from 'node:path'; 7 + import type { EvidenceRecord } from '../models/evidence.js'; 8 + 9 + interface EvidenceIndex { 10 + records: EvidenceRecord[]; 11 + } 12 + 13 + export class EvidenceStore { 14 + private indexPath: string; 15 + 16 + constructor(phoenixRoot: string) { 17 + const dir = join(phoenixRoot, 'graphs'); 18 + mkdirSync(dir, { recursive: true }); 19 + this.indexPath = join(dir, 'evidence.json'); 20 + } 21 + 22 + private load(): EvidenceIndex { 23 + if (!existsSync(this.indexPath)) return { records: [] }; 24 + return JSON.parse(readFileSync(this.indexPath, 'utf8')); 25 + } 26 + 27 + private save(index: EvidenceIndex): void { 28 + writeFileSync(this.indexPath, JSON.stringify(index, null, 2), 'utf8'); 29 + } 30 + 31 + addRecord(record: EvidenceRecord): void { 32 + const index = this.load(); 33 + index.records.push(record); 34 + this.save(index); 35 + } 36 + 37 + addRecords(records: EvidenceRecord[]): void { 38 + const index = this.load(); 39 + index.records.push(...records); 40 + this.save(index); 41 + } 42 + 43 + getByIU(iuId: string): EvidenceRecord[] { 44 + return this.load().records.filter(r => r.iu_id === iuId); 45 + } 46 + 47 + getAll(): EvidenceRecord[] { 48 + return this.load().records; 49 + } 50 + 51 + /** Get latest evidence of each kind for an IU */ 52 + getLatestByIU(iuId: string): Map<string, EvidenceRecord> { 53 + const records = this.getByIU(iuId); 54 + const latest = new Map<string, EvidenceRecord>(); 55 + for (const r of records) { 56 + const existing = latest.get(r.kind); 57 + if (!existing || r.timestamp > existing.timestamp) { 58 + latest.set(r.kind, r); 59 + } 60 + } 61 + return latest; 62 + } 63 + }
+130
src/store/spec-store.ts
··· 1 + /** 2 + * Spec Store — manages the Spec Graph 3 + * 4 + * Handles ingestion, retrieval, and diffing of spec documents. 5 + */ 6 + 7 + import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; 8 + import { join, relative } from 'node:path'; 9 + import type { Clause, IngestResult, ClauseDiff } from '../models/clause.js'; 10 + import { ContentStore } from './content-store.js'; 11 + import { parseSpec } from '../spec-parser.js'; 12 + import { diffClauses } from '../diff.js'; 13 + 14 + interface SpecGraphIndex { 15 + documents: Record<string, { 16 + clause_ids: string[]; 17 + last_ingested: string; 18 + }>; 19 + } 20 + 21 + export class SpecStore { 22 + private contentStore: ContentStore; 23 + private graphPath: string; 24 + private phoenixRoot: string; 25 + 26 + constructor(phoenixRoot: string) { 27 + this.phoenixRoot = phoenixRoot; 28 + this.contentStore = new ContentStore(phoenixRoot); 29 + const graphDir = join(phoenixRoot, 'graphs'); 30 + mkdirSync(graphDir, { recursive: true }); 31 + this.graphPath = join(graphDir, 'spec.json'); 32 + } 33 + 34 + private loadIndex(): SpecGraphIndex { 35 + if (!existsSync(this.graphPath)) { 36 + return { documents: {} }; 37 + } 38 + return JSON.parse(readFileSync(this.graphPath, 'utf8')); 39 + } 40 + 41 + private saveIndex(index: SpecGraphIndex): void { 42 + writeFileSync(this.graphPath, JSON.stringify(index, null, 2), 'utf8'); 43 + } 44 + 45 + /** 46 + * Ingest a spec document. Parses it into clauses, stores them, 47 + * and updates the spec graph index. 48 + */ 49 + ingestDocument(docPath: string, projectRoot?: string): IngestResult { 50 + const content = readFileSync(docPath, 'utf8'); 51 + const docId = projectRoot ? relative(projectRoot, docPath) : docPath; 52 + const clauses = parseSpec(content, docId); 53 + 54 + // Store each clause 55 + for (const clause of clauses) { 56 + this.contentStore.put(clause.clause_id, clause); 57 + } 58 + 59 + // Update index 60 + const index = this.loadIndex(); 61 + const timestamp = new Date().toISOString(); 62 + index.documents[docId] = { 63 + clause_ids: clauses.map(c => c.clause_id), 64 + last_ingested: timestamp, 65 + }; 66 + this.saveIndex(index); 67 + 68 + return { doc_id: docId, clauses, timestamp }; 69 + } 70 + 71 + /** 72 + * Ingest from raw content string (useful for testing). 73 + */ 74 + ingestContent(content: string, docId: string): IngestResult { 75 + const clauses = parseSpec(content, docId); 76 + 77 + for (const clause of clauses) { 78 + this.contentStore.put(clause.clause_id, clause); 79 + } 80 + 81 + const index = this.loadIndex(); 82 + const timestamp = new Date().toISOString(); 83 + index.documents[docId] = { 84 + clause_ids: clauses.map(c => c.clause_id), 85 + last_ingested: timestamp, 86 + }; 87 + this.saveIndex(index); 88 + 89 + return { doc_id: docId, clauses, timestamp }; 90 + } 91 + 92 + /** 93 + * Get all clauses for a document. 94 + */ 95 + getClauses(docId: string): Clause[] { 96 + const index = this.loadIndex(); 97 + const doc = index.documents[docId]; 98 + if (!doc) return []; 99 + return doc.clause_ids 100 + .map(id => this.contentStore.get<Clause>(id)) 101 + .filter((c): c is Clause => c !== null); 102 + } 103 + 104 + /** 105 + * Get a single clause by ID. 106 + */ 107 + getClause(clauseId: string): Clause | null { 108 + return this.contentStore.get<Clause>(clauseId); 109 + } 110 + 111 + /** 112 + * Diff a document: compare stored clauses vs current file content. 113 + */ 114 + diffDocument(docPath: string, projectRoot?: string): ClauseDiff[] { 115 + const content = readFileSync(docPath, 'utf8'); 116 + const docId = projectRoot ? relative(projectRoot, docPath) : docPath; 117 + const before = this.getClauses(docId); 118 + const after = parseSpec(content, docId); 119 + return diffClauses(before, after); 120 + } 121 + 122 + /** 123 + * Diff from content strings (useful for testing). 124 + */ 125 + diffContent(beforeContent: string, afterContent: string, docId: string): ClauseDiff[] { 126 + const before = parseSpec(beforeContent, docId); 127 + const after = parseSpec(afterContent, docId); 128 + return diffClauses(before, after); 129 + } 130 + }
+65
src/warm-hasher.ts
··· 1 + /** 2 + * Warm Context Hasher 3 + * 4 + * Computes context_semhash_warm after canonicalization is available. 5 + * Incorporates canonical graph context into the clause hash. 6 + */ 7 + 8 + import type { Clause } from './models/clause.js'; 9 + import type { CanonicalNode } from './models/canonical.js'; 10 + import { sha256 } from './semhash.js'; 11 + 12 + /** 13 + * Compute warm context hash for a clause, incorporating canonical context. 14 + * 15 + * Includes: 16 + * - normalized text 17 + * - section path 18 + * - sorted linked canonical node IDs 19 + * - sorted canonical node types 20 + */ 21 + export function contextSemhashWarm( 22 + clause: Clause, 23 + canonicalNodes: CanonicalNode[], 24 + ): string { 25 + // Find canonical nodes sourced from this clause 26 + const relatedNodes = canonicalNodes.filter( 27 + n => n.source_clause_ids.includes(clause.clause_id) 28 + ); 29 + 30 + // Collect all linked canon IDs (including transitive through this clause's nodes) 31 + const linkedIds = new Set<string>(); 32 + for (const node of relatedNodes) { 33 + linkedIds.add(node.canon_id); 34 + for (const linkedId of node.linked_canon_ids) { 35 + linkedIds.add(linkedId); 36 + } 37 + } 38 + 39 + // Collect types of related nodes 40 + const types = new Set(relatedNodes.map(n => n.type)); 41 + 42 + const parts = [ 43 + clause.normalized_text, 44 + clause.section_path.join('/'), 45 + [...linkedIds].sort().join(','), 46 + [...types].sort().join(','), 47 + ]; 48 + 49 + return sha256(parts.join('\x00')); 50 + } 51 + 52 + /** 53 + * Compute warm hashes for all clauses. 54 + * Returns a map of clause_id → context_semhash_warm. 55 + */ 56 + export function computeWarmHashes( 57 + clauses: Clause[], 58 + canonicalNodes: CanonicalNode[], 59 + ): Map<string, string> { 60 + const result = new Map<string, string>(); 61 + for (const clause of clauses) { 62 + result.set(clause.clause_id, contextSemhashWarm(clause, canonicalNodes)); 63 + } 64 + return result; 65 + }
+30
tests/fixtures/spec-auth-v1.md
··· 1 + # Authentication Service 2 + 3 + The authentication service handles user login, registration, and session management. 4 + 5 + ## Requirements 6 + 7 + - Users must authenticate with email and password 8 + - Sessions expire after 24 hours 9 + - Failed login attempts are rate-limited to 5 per minute 10 + - Passwords must be hashed with bcrypt (cost factor 12) 11 + 12 + ## API Endpoints 13 + 14 + ### POST /auth/login 15 + 16 + Accepts email and password. Returns a JWT token on success. 17 + 18 + ### POST /auth/register 19 + 20 + Creates a new user account. Requires email, password, and display name. 21 + 22 + ### POST /auth/logout 23 + 24 + Invalidates the current session token. 25 + 26 + ## Security Constraints 27 + 28 + - All endpoints must use HTTPS 29 + - Tokens must be signed with RS256 30 + - Password reset tokens expire after 1 hour
+36
tests/fixtures/spec-auth-v2.md
··· 1 + # Authentication Service 2 + 3 + The authentication service handles user login, registration, session management, and OAuth integration. 4 + 5 + ## Requirements 6 + 7 + - Users must authenticate with email and password 8 + - Sessions expire after 24 hours 9 + - Failed login attempts are rate-limited to 5 per minute 10 + - Passwords must be hashed with bcrypt (cost factor 12) 11 + - OAuth2 providers (Google, GitHub) must be supported 12 + 13 + ## API Endpoints 14 + 15 + ### POST /auth/login 16 + 17 + Accepts email and password. Returns a JWT token on success. 18 + 19 + ### POST /auth/register 20 + 21 + Creates a new user account. Requires email, password, and display name. 22 + 23 + ### POST /auth/logout 24 + 25 + Invalidates the current session token. 26 + 27 + ### GET /auth/oauth/:provider 28 + 29 + Initiates OAuth2 flow for the specified provider. 30 + 31 + ## Security Constraints 32 + 33 + - All endpoints must use HTTPS 34 + - Tokens must be signed with RS256 35 + - Password reset tokens expire after 1 hour 36 + - OAuth tokens must be stored encrypted at rest
+11
tests/fixtures/spec-simple.md
··· 1 + # Simple Service 2 + 3 + A minimal spec for testing. 4 + 5 + ## Feature A 6 + 7 + This feature does thing A. 8 + 9 + ## Feature B 10 + 11 + This feature does thing B.
+186
tests/functional/canonicalization.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { mkdtempSync, readFileSync } from 'node:fs'; 3 + import { join } from 'node:path'; 4 + import { tmpdir } from 'node:os'; 5 + import { parseSpec } from '../../src/spec-parser.js'; 6 + import { diffClauses } from '../../src/diff.js'; 7 + import { extractCanonicalNodes } from '../../src/canonicalizer.js'; 8 + import { computeWarmHashes } from '../../src/warm-hasher.js'; 9 + import { classifyChanges } from '../../src/classifier.js'; 10 + import { DRateTracker } from '../../src/d-rate.js'; 11 + import { BootstrapStateMachine } from '../../src/bootstrap.js'; 12 + import { CanonicalStore } from '../../src/store/canonical-store.js'; 13 + import { CanonicalType } from '../../src/models/canonical.js'; 14 + import { ChangeClass, BootstrapState, DRateLevel } from '../../src/models/classification.js'; 15 + 16 + const fixturesDir = join(import.meta.dirname, '..', 'fixtures'); 17 + 18 + describe('Functional: Full Canonicalization Pipeline', () => { 19 + let tempDir: string; 20 + 21 + beforeEach(() => { 22 + tempDir = mkdtempSync(join(tmpdir(), 'phoenix-canon-')); 23 + }); 24 + 25 + it('performs complete bootstrap flow on auth spec', () => { 26 + const content = readFileSync(join(fixturesDir, 'spec-auth-v1.md'), 'utf8'); 27 + const docId = 'spec-auth.md'; 28 + 29 + // Phase A: Parse 30 + const clauses = parseSpec(content, docId); 31 + expect(clauses.length).toBeGreaterThan(0); 32 + 33 + // Phase B: Canonicalize 34 + const canonNodes = extractCanonicalNodes(clauses); 35 + expect(canonNodes.length).toBeGreaterThan(0); 36 + 37 + // Should extract requirements (must authenticate, must expire, etc.) 38 + const reqs = canonNodes.filter(n => n.type === CanonicalType.REQUIREMENT); 39 + expect(reqs.length).toBeGreaterThan(0); 40 + 41 + // Should extract constraints (HTTPS, RS256, etc.) 42 + const constraints = canonNodes.filter(n => n.type === CanonicalType.CONSTRAINT); 43 + expect(constraints.length).toBeGreaterThan(0); 44 + 45 + // Compute warm hashes 46 + const warmHashes = computeWarmHashes(clauses, canonNodes); 47 + expect(warmHashes.size).toBe(clauses.length); 48 + 49 + // Persist canonical graph 50 + const canonStore = new CanonicalStore(tempDir); 51 + canonStore.saveNodes(canonNodes); 52 + const retrieved = canonStore.getAllNodes(); 53 + expect(retrieved.length).toBe(canonNodes.length); 54 + }); 55 + 56 + it('detects contextual changes when spec evolves v1 → v2', () => { 57 + const v1 = readFileSync(join(fixturesDir, 'spec-auth-v1.md'), 'utf8'); 58 + const v2 = readFileSync(join(fixturesDir, 'spec-auth-v2.md'), 'utf8'); 59 + const docId = 'spec-auth.md'; 60 + 61 + // Parse both 62 + const clausesV1 = parseSpec(v1, docId); 63 + const clausesV2 = parseSpec(v2, docId); 64 + 65 + // Canonicalize both 66 + const canonV1 = extractCanonicalNodes(clausesV1); 67 + const canonV2 = extractCanonicalNodes(clausesV2); 68 + 69 + // v2 should have more canonical nodes (OAuth requirements) 70 + expect(canonV2.length).toBeGreaterThanOrEqual(canonV1.length); 71 + 72 + // Compute warm hashes 73 + const warmV1 = computeWarmHashes(clausesV1, canonV1); 74 + const warmV2 = computeWarmHashes(clausesV2, canonV2); 75 + 76 + // Diff and classify 77 + const diffs = diffClauses(clausesV1, clausesV2); 78 + const classifications = classifyChanges(diffs, canonV1, canonV2, warmV1, warmV2); 79 + 80 + expect(classifications.length).toBe(diffs.length); 81 + 82 + // Should have some non-trivial changes 83 + const nonTrivial = classifications.filter(c => c.change_class !== ChangeClass.A); 84 + expect(nonTrivial.length).toBeGreaterThan(0); 85 + }); 86 + 87 + it('runs full bootstrap state machine flow', () => { 88 + const content = readFileSync(join(fixturesDir, 'spec-auth-v1.md'), 'utf8'); 89 + const docId = 'spec-auth.md'; 90 + 91 + // Start cold 92 + const bootstrap = new BootstrapStateMachine(); 93 + expect(bootstrap.getState()).toBe(BootstrapState.BOOTSTRAP_COLD); 94 + expect(bootstrap.shouldSuppressAlarms()).toBe(true); 95 + 96 + // Cold pass: parse 97 + const clauses = parseSpec(content, docId); 98 + 99 + // Canonicalize (triggers warm pass) 100 + const canonNodes = extractCanonicalNodes(clauses); 101 + const warmHashes = computeWarmHashes(clauses, canonNodes); 102 + 103 + // Mark warm pass complete 104 + bootstrap.markWarmPassComplete(); 105 + expect(bootstrap.getState()).toBe(BootstrapState.BOOTSTRAP_WARMING); 106 + expect(bootstrap.shouldDowngradeSeverity()).toBe(true); 107 + 108 + // Simulate classifications to build D-rate history 109 + const tracker = new DRateTracker(20); 110 + // Simulate good classifications 111 + for (let i = 0; i < 15; i++) { 112 + tracker.recordOne(ChangeClass.A); 113 + } 114 + tracker.recordOne(ChangeClass.B); 115 + tracker.recordOne(ChangeClass.C); 116 + 117 + const status = tracker.getStatus(); 118 + expect(status.level).toBe(DRateLevel.TARGET); 119 + 120 + // Evaluate transition 121 + bootstrap.evaluateTransition(status); 122 + expect(bootstrap.getState()).toBe(BootstrapState.STEADY_STATE); 123 + expect(bootstrap.shouldDowngradeSeverity()).toBe(false); 124 + expect(bootstrap.shouldSuppressAlarms()).toBe(false); 125 + }); 126 + 127 + it('D-rate tracker alarms on high uncertainty', () => { 128 + const tracker = new DRateTracker(20); 129 + 130 + // 4 out of 20 = 20% D rate → ALARM 131 + for (let i = 0; i < 16; i++) tracker.recordOne(ChangeClass.B); 132 + for (let i = 0; i < 4; i++) tracker.recordOne(ChangeClass.D); 133 + 134 + const status = tracker.getStatus(); 135 + expect(status.level).toBe(DRateLevel.ALARM); 136 + expect(status.rate).toBe(0.2); 137 + 138 + // Bootstrap should not transition to STEADY_STATE 139 + const bootstrap = new BootstrapStateMachine(); 140 + bootstrap.markWarmPassComplete(); 141 + bootstrap.evaluateTransition(status); 142 + expect(bootstrap.getState()).toBe(BootstrapState.BOOTSTRAP_WARMING); 143 + }); 144 + 145 + it('canonical nodes maintain provenance back to clauses', () => { 146 + const content = readFileSync(join(fixturesDir, 'spec-auth-v1.md'), 'utf8'); 147 + const clauses = parseSpec(content, 'spec-auth.md'); 148 + const canonNodes = extractCanonicalNodes(clauses); 149 + 150 + // Store and retrieve 151 + const canonStore = new CanonicalStore(tempDir); 152 + canonStore.saveNodes(canonNodes); 153 + 154 + // Every canonical node should trace back to a clause 155 + for (const node of canonNodes) { 156 + expect(node.source_clause_ids.length).toBeGreaterThan(0); 157 + // The clause ID should be from our parsed clauses 158 + const clauseIds = new Set(clauses.map(c => c.clause_id)); 159 + for (const srcId of node.source_clause_ids) { 160 + expect(clauseIds.has(srcId)).toBe(true); 161 + } 162 + } 163 + 164 + // Can look up nodes by clause 165 + for (const clause of clauses) { 166 + const nodes = canonStore.getNodesByClause(clause.clause_id); 167 + // Each returned node should reference this clause 168 + for (const node of nodes) { 169 + expect(node.source_clause_ids).toContain(clause.clause_id); 170 + } 171 + } 172 + }); 173 + 174 + it('warm hashes are stable when canonical graph is unchanged', () => { 175 + const content = readFileSync(join(fixturesDir, 'spec-auth-v1.md'), 'utf8'); 176 + const clauses = parseSpec(content, 'spec-auth.md'); 177 + const canonNodes = extractCanonicalNodes(clauses); 178 + 179 + const warm1 = computeWarmHashes(clauses, canonNodes); 180 + const warm2 = computeWarmHashes(clauses, canonNodes); 181 + 182 + for (const clause of clauses) { 183 + expect(warm1.get(clause.clause_id)).toBe(warm2.get(clause.clause_id)); 184 + } 185 + }); 186 + });
+147
tests/functional/evidence-cascade.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { mkdtempSync } from 'node:fs'; 3 + import { join } from 'node:path'; 4 + import { tmpdir } from 'node:os'; 5 + import { parseSpec } from '../../src/spec-parser.js'; 6 + import { extractCanonicalNodes } from '../../src/canonicalizer.js'; 7 + import { planIUs } from '../../src/iu-planner.js'; 8 + import { evaluatePolicy, evaluateAllPolicies } from '../../src/policy-engine.js'; 9 + import { computeCascade } from '../../src/cascade.js'; 10 + import { EvidenceKind, EvidenceStatus } from '../../src/models/evidence.js'; 11 + import type { EvidenceRecord } from '../../src/models/evidence.js'; 12 + import { EvidenceStore } from '../../src/store/evidence-store.js'; 13 + import { runShadowPipeline } from '../../src/shadow-pipeline.js'; 14 + import { runCompaction } from '../../src/compaction.js'; 15 + import { parseCommand, routeCommand } from '../../src/bot-router.js'; 16 + import type { BotCommand } from '../../src/models/bot.js'; 17 + 18 + const SPEC = `# Auth Service 19 + 20 + Users must authenticate with email and password. 21 + Passwords must be hashed with bcrypt. 22 + 23 + ## Security Constraints 24 + 25 + All endpoints must use HTTPS. 26 + Tokens must be signed with RS256.`; 27 + 28 + describe('Functional: Evidence, Policy, Cascade (Phase D)', () => { 29 + let tempDir: string; 30 + 31 + beforeEach(() => { 32 + tempDir = mkdtempSync(join(tmpdir(), 'phoenix-evd-')); 33 + }); 34 + 35 + it('full evidence lifecycle: submit → evaluate → cascade', () => { 36 + const clauses = parseSpec(SPEC, 'spec/auth.md'); 37 + const canon = extractCanonicalNodes(clauses); 38 + const ius = planIUs(canon, clauses); 39 + expect(ius.length).toBeGreaterThan(0); 40 + 41 + const iu = ius[0]; 42 + 43 + // No evidence yet → INCOMPLETE 44 + const eval1 = evaluatePolicy(iu, []); 45 + expect(eval1.verdict).toBe('INCOMPLETE'); 46 + expect(eval1.missing.length).toBeGreaterThan(0); 47 + 48 + // Submit passing evidence for each required kind 49 + const store = new EvidenceStore(tempDir); 50 + const records: EvidenceRecord[] = iu.evidence_policy.required.map(kind => ({ 51 + evidence_id: `ev-${kind}`, 52 + kind: kind as EvidenceKind, 53 + status: EvidenceStatus.PASS, 54 + iu_id: iu.iu_id, 55 + canon_ids: iu.source_canon_ids, 56 + timestamp: new Date().toISOString(), 57 + })); 58 + store.addRecords(records); 59 + 60 + // Now should PASS 61 + const eval2 = evaluatePolicy(iu, store.getAll()); 62 + expect(eval2.verdict).toBe('PASS'); 63 + 64 + // Simulate a failure 65 + store.addRecord({ 66 + evidence_id: 'ev-fail', 67 + kind: EvidenceKind.TYPECHECK, 68 + status: EvidenceStatus.FAIL, 69 + iu_id: iu.iu_id, 70 + canon_ids: [], 71 + message: 'Type error in auth module', 72 + timestamp: new Date(Date.now() + 1000).toISOString(), 73 + }); 74 + 75 + const eval3 = evaluatePolicy(iu, store.getAll()); 76 + expect(eval3.verdict).toBe('FAIL'); 77 + 78 + // Cascade should block this IU 79 + const cascadeEvents = computeCascade([eval3], ius); 80 + expect(cascadeEvents.length).toBeGreaterThan(0); 81 + expect(cascadeEvents[0].actions.some(a => a.action === 'BLOCK')).toBe(true); 82 + }); 83 + }); 84 + 85 + describe('Functional: Shadow Pipeline + Compaction (Phase E)', () => { 86 + it('shadow pipeline classifies identical graphs as SAFE', () => { 87 + const clauses = parseSpec(SPEC, 'spec/auth.md'); 88 + const canon = extractCanonicalNodes(clauses); 89 + const oldP = { pipeline_id: 'v1', model_id: 'm1', promptpack_version: '1', extraction_rules_version: '1', diff_policy_version: '1' }; 90 + const newP = { pipeline_id: 'v2', model_id: 'm2', promptpack_version: '2', extraction_rules_version: '2', diff_policy_version: '2' }; 91 + const result = runShadowPipeline(oldP, newP, canon, canon); 92 + expect(result.classification).toBe('SAFE'); 93 + }); 94 + 95 + it('compaction preserves critical data', () => { 96 + const objects = [ 97 + { object_id: '1', object_type: 'clause_body', age_days: 60, size_bytes: 5000, preserve: false }, 98 + { object_id: '2', object_type: 'node_header', age_days: 60, size_bytes: 100, preserve: true }, 99 + { object_id: '3', object_type: 'provenance_edge', age_days: 60, size_bytes: 50, preserve: true }, 100 + { object_id: '4', object_type: 'approval', age_days: 60, size_bytes: 200, preserve: true }, 101 + { object_id: '5', object_type: 'signature', age_days: 60, size_bytes: 300, preserve: true }, 102 + { object_id: '6', object_type: 'clause_body', age_days: 15, size_bytes: 3000, preserve: false }, 103 + ]; 104 + const event = runCompaction(objects, 'size_threshold', 30); 105 + expect(event.nodes_compacted).toBe(1); // only object '1' (old, non-preserved) 106 + expect(event.bytes_freed).toBe(5000); 107 + expect(event.preserved.node_headers).toBe(1); 108 + expect(event.preserved.provenance_edges).toBe(1); 109 + expect(event.preserved.approvals).toBe(1); 110 + expect(event.preserved.signatures).toBe(1); 111 + }); 112 + }); 113 + 114 + describe('Functional: Bot Integration (Phase F)', () => { 115 + it('SpecBot ingest flow: parse → confirm → result', () => { 116 + const parsed = parseCommand('SpecBot: ingest spec/auth.md'); 117 + expect('error' in parsed).toBe(false); 118 + const cmd = parsed as BotCommand; 119 + const resp = routeCommand(cmd); 120 + expect(resp.mutating).toBe(true); 121 + expect(resp.confirm_id).toBeTruthy(); 122 + expect(resp.intent).toContain('spec/auth.md'); 123 + }); 124 + 125 + it('PolicyBot status is read-only', () => { 126 + const parsed = parseCommand('PolicyBot: status'); 127 + const cmd = parsed as BotCommand; 128 + const resp = routeCommand(cmd); 129 + expect(resp.mutating).toBe(false); 130 + expect(resp.message.toLowerCase()).toContain('trust dashboard'); 131 + }); 132 + 133 + it('ImplBot regen requires confirmation', () => { 134 + const parsed = parseCommand('ImplBot: regen iu=AuthIU'); 135 + const cmd = parsed as BotCommand; 136 + const resp = routeCommand(cmd); 137 + expect(resp.mutating).toBe(true); 138 + expect(resp.intent).toContain('AuthIU'); 139 + }); 140 + 141 + it('all bots respond to help', () => { 142 + for (const bot of ['SpecBot', 'ImplBot', 'PolicyBot']) { 143 + const resp = routeCommand({ bot: bot as any, action: 'help', args: {}, raw: `${bot}: help` }); 144 + expect(resp.message).toContain('commands'); 145 + } 146 + }); 147 + });
+129
tests/functional/ingestion.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; 3 + import { join } from 'node:path'; 4 + import { tmpdir } from 'node:os'; 5 + import { SpecStore } from '../../src/store/spec-store.js'; 6 + import { DiffType } from '../../src/models/clause.js'; 7 + 8 + describe('Functional: Spec Ingestion Pipeline', () => { 9 + let tempDir: string; 10 + let store: SpecStore; 11 + 12 + beforeEach(() => { 13 + tempDir = mkdtempSync(join(tmpdir(), 'phoenix-test-')); 14 + store = new SpecStore(tempDir); 15 + }); 16 + 17 + const fixturesDir = join(import.meta.dirname, '..', 'fixtures'); 18 + 19 + it('ingests a real spec document and produces correct clauses', () => { 20 + const result = store.ingestDocument( 21 + join(fixturesDir, 'spec-auth-v1.md'), 22 + fixturesDir, 23 + ); 24 + 25 + expect(result.doc_id).toBe('spec-auth-v1.md'); 26 + expect(result.clauses.length).toBeGreaterThan(0); 27 + 28 + // Should have sections: Authentication Service, Requirements, API Endpoints, 29 + // POST /auth/login, POST /auth/register, POST /auth/logout, Security Constraints 30 + const headings = result.clauses.map(c => c.section_path[c.section_path.length - 1]); 31 + expect(headings).toContain('Authentication Service'); 32 + expect(headings).toContain('Requirements'); 33 + expect(headings).toContain('Security Constraints'); 34 + }); 35 + 36 + it('retrieves stored clauses by document ID', () => { 37 + store.ingestDocument( 38 + join(fixturesDir, 'spec-auth-v1.md'), 39 + fixturesDir, 40 + ); 41 + 42 + const clauses = store.getClauses('spec-auth-v1.md'); 43 + expect(clauses.length).toBeGreaterThan(0); 44 + expect(clauses[0].source_doc_id).toBe('spec-auth-v1.md'); 45 + }); 46 + 47 + it('retrieves individual clauses by ID', () => { 48 + const result = store.ingestDocument( 49 + join(fixturesDir, 'spec-auth-v1.md'), 50 + fixturesDir, 51 + ); 52 + 53 + const firstClause = result.clauses[0]; 54 + const retrieved = store.getClause(firstClause.clause_id); 55 + expect(retrieved).not.toBeNull(); 56 + expect(retrieved!.clause_id).toBe(firstClause.clause_id); 57 + expect(retrieved!.normalized_text).toBe(firstClause.normalized_text); 58 + }); 59 + 60 + it('produces stable hashes across re-ingestion', () => { 61 + const result1 = store.ingestDocument( 62 + join(fixturesDir, 'spec-auth-v1.md'), 63 + fixturesDir, 64 + ); 65 + const result2 = store.ingestDocument( 66 + join(fixturesDir, 'spec-auth-v1.md'), 67 + fixturesDir, 68 + ); 69 + 70 + expect(result1.clauses.map(c => c.clause_semhash)) 71 + .toEqual(result2.clauses.map(c => c.clause_semhash)); 72 + }); 73 + 74 + it('detects changes between v1 and v2 of a spec', () => { 75 + const v1Content = readFileSync(join(fixturesDir, 'spec-auth-v1.md'), 'utf8'); 76 + const v2Content = readFileSync(join(fixturesDir, 'spec-auth-v2.md'), 'utf8'); 77 + 78 + // Diff same logical document across versions 79 + const diffs = store.diffContent(v1Content, v2Content, 'spec-auth.md'); 80 + 81 + const added = diffs.filter(d => d.diff_type === DiffType.ADDED); 82 + const modified = diffs.filter(d => d.diff_type === DiffType.MODIFIED); 83 + const unchanged = diffs.filter(d => d.diff_type === DiffType.UNCHANGED); 84 + 85 + // Some clauses should be unchanged (login, register, logout endpoints) 86 + expect(unchanged.length).toBeGreaterThan(0); 87 + // There should be additions or modifications (OAuth endpoint added, requirements modified, etc.) 88 + expect(added.length + modified.length).toBeGreaterThan(0); 89 + }); 90 + 91 + it('handles multiple documents independently', () => { 92 + store.ingestDocument(join(fixturesDir, 'spec-auth-v1.md'), fixturesDir); 93 + store.ingestDocument(join(fixturesDir, 'spec-simple.md'), fixturesDir); 94 + 95 + const authClauses = store.getClauses('spec-auth-v1.md'); 96 + const simpleClauses = store.getClauses('spec-simple.md'); 97 + 98 + expect(authClauses.length).toBeGreaterThan(simpleClauses.length); 99 + expect(simpleClauses.length).toBe(3); // Simple Service, Feature A, Feature B 100 + }); 101 + 102 + it('all clauses have required fields populated', () => { 103 + const result = store.ingestDocument( 104 + join(fixturesDir, 'spec-auth-v1.md'), 105 + fixturesDir, 106 + ); 107 + 108 + for (const clause of result.clauses) { 109 + expect(clause.clause_id).toBeTruthy(); 110 + expect(clause.clause_id).toHaveLength(64); 111 + expect(clause.source_doc_id).toBe('spec-auth-v1.md'); 112 + expect(clause.source_line_range[0]).toBeGreaterThan(0); 113 + expect(clause.source_line_range[1]).toBeGreaterThanOrEqual(clause.source_line_range[0]); 114 + expect(clause.raw_text).toBeTruthy(); 115 + expect(clause.normalized_text).toBeTruthy(); 116 + expect(clause.section_path.length).toBeGreaterThan(0); 117 + expect(clause.clause_semhash).toHaveLength(64); 118 + expect(clause.context_semhash_cold).toHaveLength(64); 119 + } 120 + }); 121 + 122 + it('formatting-only changes produce no diffs', () => { 123 + const v1 = '# Test\n\nPhoenix is a VCS.\n\n## Details\n\nIt tracks intent.'; 124 + const v2 = '# Test\n\n**Phoenix** is a VCS.\n\n## Details\n\nIt tracks intent.'; 125 + 126 + const diffs = store.diffContent(v1, v2, 'test.md'); 127 + expect(diffs.every(d => d.diff_type === DiffType.UNCHANGED)).toBe(true); 128 + }); 129 + });
+188
tests/functional/iu-pipeline.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; 3 + import { join } from 'node:path'; 4 + import { tmpdir } from 'node:os'; 5 + import { parseSpec } from '../../src/spec-parser.js'; 6 + import { extractCanonicalNodes } from '../../src/canonicalizer.js'; 7 + import { planIUs } from '../../src/iu-planner.js'; 8 + import { generateAll } from '../../src/regen.js'; 9 + import { ManifestManager } from '../../src/manifest.js'; 10 + import { detectDrift } from '../../src/drift.js'; 11 + import { extractDependencies } from '../../src/dep-extractor.js'; 12 + import { validateBoundary, detectBoundaryChanges } from '../../src/boundary-validator.js'; 13 + import { DriftStatus } from '../../src/models/manifest.js'; 14 + 15 + const SPEC = `# Authentication Service 16 + 17 + The authentication service handles user login and session management. 18 + 19 + ## Requirements 20 + 21 + - Users must authenticate with email and password 22 + - Sessions expire after 24 hours 23 + - Failed login attempts are rate-limited to 5 per minute 24 + - Passwords must be hashed with bcrypt (cost factor 12) 25 + 26 + ## Security Constraints 27 + 28 + - All endpoints must use HTTPS 29 + - Tokens must be signed with RS256 30 + - Password reset tokens expire after 1 hour`; 31 + 32 + describe('Functional: Full IU Pipeline (C1 + C2)', () => { 33 + let projectRoot: string; 34 + let phoenixRoot: string; 35 + 36 + beforeEach(() => { 37 + projectRoot = mkdtempSync(join(tmpdir(), 'phoenix-iu-')); 38 + phoenixRoot = join(projectRoot, '.phoenix'); 39 + mkdirSync(phoenixRoot, { recursive: true }); 40 + }); 41 + 42 + it('end-to-end: spec → clauses → canon → IUs → generated code → manifest → drift check', () => { 43 + // Phase A: Parse 44 + const clauses = parseSpec(SPEC, 'spec/auth.md'); 45 + expect(clauses.length).toBeGreaterThan(0); 46 + 47 + // Phase B: Canonicalize 48 + const canon = extractCanonicalNodes(clauses); 49 + expect(canon.length).toBeGreaterThan(0); 50 + 51 + // Phase C1: Plan IUs 52 + const ius = planIUs(canon, clauses); 53 + expect(ius.length).toBeGreaterThan(0); 54 + 55 + // Phase C1: Generate code 56 + const results = generateAll(ius); 57 + expect(results.length).toBe(ius.length); 58 + 59 + // Write generated files to disk 60 + for (const result of results) { 61 + for (const [path, content] of result.files) { 62 + const fullPath = join(projectRoot, path); 63 + mkdirSync(join(fullPath, '..'), { recursive: true }); 64 + writeFileSync(fullPath, content, 'utf8'); 65 + } 66 + } 67 + 68 + // Phase C1: Record manifest 69 + const manifestMgr = new ManifestManager(phoenixRoot); 70 + manifestMgr.recordAll(results.map(r => r.manifest)); 71 + 72 + // Phase C1: Drift detection — should be clean 73 + const manifest = manifestMgr.load(); 74 + const report = detectDrift(manifest, projectRoot); 75 + expect(report.drifted_count).toBe(0); 76 + expect(report.missing_count).toBe(0); 77 + expect(report.clean_count).toBe(results.reduce((sum, r) => sum + r.files.size, 0)); 78 + }); 79 + 80 + it('detects drift after manual edit', () => { 81 + const clauses = parseSpec(SPEC, 'spec/auth.md'); 82 + const canon = extractCanonicalNodes(clauses); 83 + const ius = planIUs(canon, clauses); 84 + const results = generateAll(ius); 85 + 86 + // Write and record 87 + for (const result of results) { 88 + for (const [path, content] of result.files) { 89 + const fullPath = join(projectRoot, path); 90 + mkdirSync(join(fullPath, '..'), { recursive: true }); 91 + writeFileSync(fullPath, content, 'utf8'); 92 + } 93 + } 94 + const manifestMgr = new ManifestManager(phoenixRoot); 95 + manifestMgr.recordAll(results.map(r => r.manifest)); 96 + 97 + // Now manually edit a file 98 + const firstResult = results[0]; 99 + const firstFile = [...firstResult.files.keys()][0]; 100 + const fullPath = join(projectRoot, firstFile); 101 + writeFileSync(fullPath, '// manually hacked\n' + readFileSync(fullPath, 'utf8'), 'utf8'); 102 + 103 + // Drift detection should catch it 104 + const report = detectDrift(manifestMgr.load(), projectRoot); 105 + expect(report.drifted_count).toBeGreaterThan(0); 106 + expect(report.summary).toContain('DRIFT DETECTED'); 107 + }); 108 + 109 + it('regeneration after spec change produces new IUs', () => { 110 + // v1 111 + const clauses1 = parseSpec(SPEC, 'spec/auth.md'); 112 + const canon1 = extractCanonicalNodes(clauses1); 113 + const ius1 = planIUs(canon1, clauses1); 114 + 115 + // v2 — add OAuth 116 + const specV2 = SPEC + '\n- OAuth2 providers (Google, GitHub) must be supported\n'; 117 + const clauses2 = parseSpec(specV2, 'spec/auth.md'); 118 + const canon2 = extractCanonicalNodes(clauses2); 119 + const ius2 = planIUs(canon2, clauses2); 120 + 121 + // v2 should have same or more IUs covering more canon nodes 122 + const totalCanonV2 = ius2.reduce((sum, iu) => sum + iu.source_canon_ids.length, 0); 123 + const totalCanonV1 = ius1.reduce((sum, iu) => sum + iu.source_canon_ids.length, 0); 124 + expect(totalCanonV2).toBeGreaterThanOrEqual(totalCanonV1); 125 + }); 126 + 127 + it('boundary validator catches violations in generated code with bad imports', () => { 128 + const clauses = parseSpec(SPEC, 'spec/auth.md'); 129 + const canon = extractCanonicalNodes(clauses); 130 + const ius = planIUs(canon, clauses); 131 + 132 + // Simulate code with a forbidden import 133 + const badCode = `import axios from 'axios'; 134 + import { secret } from './internal/admin.js'; 135 + const key = process.env.UNDECLARED_SECRET; 136 + export function hack() {}`; 137 + 138 + const iu = { 139 + ...ius[0], 140 + boundary_policy: { 141 + code: { 142 + allowed_ius: [], 143 + allowed_packages: ['express'], 144 + forbidden_ius: [], 145 + forbidden_packages: ['axios'], 146 + forbidden_paths: ['./internal/**'], 147 + }, 148 + side_channels: { 149 + databases: [], queues: [], caches: [], 150 + config: [], external_apis: [], files: [], 151 + }, 152 + }, 153 + }; 154 + 155 + const graph = extractDependencies(badCode, 'src/auth.ts'); 156 + const diags = validateBoundary(graph, iu); 157 + 158 + // Should catch: axios (forbidden), express not used but that's fine, 159 + // ./internal/admin.js (forbidden path), UNDECLARED_SECRET (undeclared config) 160 + expect(diags.length).toBeGreaterThanOrEqual(3); 161 + 162 + const categories = diags.map(d => d.category); 163 + expect(categories).toContain('dependency_violation'); 164 + expect(categories).toContain('side_channel_violation'); 165 + }); 166 + 167 + it('boundary change detection triggers on policy update', () => { 168 + const clauses = parseSpec(SPEC, 'spec/auth.md'); 169 + const canon = extractCanonicalNodes(clauses); 170 + const ius = planIUs(canon, clauses); 171 + 172 + const iuBefore = ius[0]; 173 + const iuAfter = { 174 + ...iuBefore, 175 + boundary_policy: { 176 + ...iuBefore.boundary_policy, 177 + code: { 178 + ...iuBefore.boundary_policy.code, 179 + forbidden_packages: ['axios'], 180 + }, 181 + }, 182 + }; 183 + 184 + const change = detectBoundaryChanges(iuBefore, iuAfter); 185 + expect(change).not.toBeNull(); 186 + expect(change!.changes.length).toBeGreaterThan(0); 187 + }); 188 + });
+93
tests/unit/bootstrap.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { BootstrapStateMachine } from '../../src/bootstrap.js'; 3 + import { BootstrapState, DRateLevel } from '../../src/models/classification.js'; 4 + 5 + describe('BootstrapStateMachine', () => { 6 + it('starts in BOOTSTRAP_COLD by default', () => { 7 + const machine = new BootstrapStateMachine(); 8 + expect(machine.getState()).toBe(BootstrapState.BOOTSTRAP_COLD); 9 + }); 10 + 11 + it('transitions COLD → WARMING on warm pass complete', () => { 12 + const machine = new BootstrapStateMachine(); 13 + machine.markWarmPassComplete(); 14 + expect(machine.getState()).toBe(BootstrapState.BOOTSTRAP_WARMING); 15 + }); 16 + 17 + it('transitions WARMING → STEADY_STATE when D-rate is acceptable', () => { 18 + const machine = new BootstrapStateMachine(); 19 + machine.markWarmPassComplete(); 20 + machine.evaluateTransition({ 21 + rate: 0.03, 22 + level: DRateLevel.TARGET, 23 + window_size: 100, 24 + d_count: 3, 25 + total_count: 100, 26 + }); 27 + expect(machine.getState()).toBe(BootstrapState.STEADY_STATE); 28 + }); 29 + 30 + it('stays WARMING when D-rate is too high', () => { 31 + const machine = new BootstrapStateMachine(); 32 + machine.markWarmPassComplete(); 33 + machine.evaluateTransition({ 34 + rate: 0.20, 35 + level: DRateLevel.ALARM, 36 + window_size: 100, 37 + d_count: 20, 38 + total_count: 100, 39 + }); 40 + expect(machine.getState()).toBe(BootstrapState.BOOTSTRAP_WARMING); 41 + }); 42 + 43 + it('stays WARMING when insufficient data', () => { 44 + const machine = new BootstrapStateMachine(); 45 + machine.markWarmPassComplete(); 46 + machine.evaluateTransition({ 47 + rate: 0, 48 + level: DRateLevel.TARGET, 49 + window_size: 100, 50 + d_count: 0, 51 + total_count: 5, // < 10 minimum 52 + }); 53 + expect(machine.getState()).toBe(BootstrapState.BOOTSTRAP_WARMING); 54 + }); 55 + 56 + it('suppresses alarms during COLD', () => { 57 + const machine = new BootstrapStateMachine(); 58 + expect(machine.shouldSuppressAlarms()).toBe(true); 59 + }); 60 + 61 + it('does not suppress alarms after COLD', () => { 62 + const machine = new BootstrapStateMachine(); 63 + machine.markWarmPassComplete(); 64 + expect(machine.shouldSuppressAlarms()).toBe(false); 65 + }); 66 + 67 + it('downgrades severity during WARMING', () => { 68 + const machine = new BootstrapStateMachine(); 69 + machine.markWarmPassComplete(); 70 + expect(machine.shouldDowngradeSeverity()).toBe(true); 71 + }); 72 + 73 + it('does not downgrade severity in STEADY_STATE', () => { 74 + const machine = new BootstrapStateMachine(); 75 + machine.markWarmPassComplete(); 76 + machine.evaluateTransition({ 77 + rate: 0.02, 78 + level: DRateLevel.TARGET, 79 + window_size: 100, 80 + d_count: 2, 81 + total_count: 100, 82 + }); 83 + expect(machine.shouldDowngradeSeverity()).toBe(false); 84 + }); 85 + 86 + it('serializes and deserializes correctly', () => { 87 + const machine = new BootstrapStateMachine(); 88 + machine.markWarmPassComplete(); 89 + const json = machine.toJSON(); 90 + const restored = BootstrapStateMachine.fromJSON(json); 91 + expect(restored.getState()).toBe(BootstrapState.BOOTSTRAP_WARMING); 92 + }); 93 + });
+84
tests/unit/bot-router.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { parseCommand, routeCommand, getAllCommands } from '../../src/bot-router.js'; 3 + import type { BotCommand } from '../../src/models/bot.js'; 4 + 5 + describe('parseCommand', () => { 6 + it('parses basic command', () => { 7 + const result = parseCommand('SpecBot: ingest spec/auth.md'); 8 + expect('error' in result).toBe(false); 9 + const cmd = result as BotCommand; 10 + expect(cmd.bot).toBe('SpecBot'); 11 + expect(cmd.action).toBe('ingest'); 12 + expect(cmd.args['_']).toBe('spec/auth.md'); 13 + }); 14 + 15 + it('parses key=value args', () => { 16 + const result = parseCommand('ImplBot: regen iu=AuthIU'); 17 + expect('error' in result).toBe(false); 18 + const cmd = result as BotCommand; 19 + expect(cmd.args['iu']).toBe('AuthIU'); 20 + }); 21 + 22 + it('parses no-arg commands', () => { 23 + const result = parseCommand('PolicyBot: status'); 24 + expect('error' in result).toBe(false); 25 + const cmd = result as BotCommand; 26 + expect(cmd.bot).toBe('PolicyBot'); 27 + expect(cmd.action).toBe('status'); 28 + }); 29 + 30 + it('rejects unknown bot', () => { 31 + const result = parseCommand('FakeBot: do something'); 32 + expect('error' in result).toBe(true); 33 + }); 34 + 35 + it('rejects unknown command', () => { 36 + const result = parseCommand('SpecBot: destroy everything'); 37 + expect('error' in result).toBe(true); 38 + }); 39 + 40 + it('rejects malformed input', () => { 41 + const result = parseCommand('not a command'); 42 + expect('error' in result).toBe(true); 43 + }); 44 + }); 45 + 46 + describe('routeCommand', () => { 47 + it('returns confirmation for mutating commands', () => { 48 + const cmd: BotCommand = { bot: 'SpecBot', action: 'ingest', args: { _: 'spec/auth.md' }, raw: 'SpecBot: ingest spec/auth.md' }; 49 + const resp = routeCommand(cmd); 50 + expect(resp.mutating).toBe(true); 51 + expect(resp.confirm_id).toBeTruthy(); 52 + expect(resp.intent).toContain('spec/auth.md'); 53 + expect(resp.message).toContain('confirm'); 54 + }); 55 + 56 + it('executes read-only commands immediately', () => { 57 + const cmd: BotCommand = { bot: 'PolicyBot', action: 'status', args: {}, raw: 'PolicyBot: status' }; 58 + const resp = routeCommand(cmd); 59 + expect(resp.mutating).toBe(false); 60 + expect(resp.confirm_id).toBeUndefined(); 61 + }); 62 + 63 + it('handles help command', () => { 64 + const cmd: BotCommand = { bot: 'SpecBot', action: 'help', args: {}, raw: 'SpecBot: help' }; 65 + const resp = routeCommand(cmd); 66 + expect(resp.message).toContain('SpecBot commands'); 67 + expect(resp.result).toContain('ingest'); 68 + }); 69 + 70 + it('handles version command', () => { 71 + const cmd: BotCommand = { bot: 'ImplBot', action: 'version', args: {}, raw: 'ImplBot: version' }; 72 + const resp = routeCommand(cmd); 73 + expect(resp.message).toContain('0.1.0'); 74 + }); 75 + }); 76 + 77 + describe('getAllCommands', () => { 78 + it('returns commands for all three bots', () => { 79 + const cmds = getAllCommands(); 80 + expect(cmds.SpecBot).toContain('ingest'); 81 + expect(cmds.ImplBot).toContain('regen'); 82 + expect(cmds.PolicyBot).toContain('status'); 83 + }); 84 + });
+173
tests/unit/boundary-validator.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { validateBoundary, detectBoundaryChanges } from '../../src/boundary-validator.js'; 3 + import { extractDependencies } from '../../src/dep-extractor.js'; 4 + import type { ImplementationUnit } from '../../src/models/iu.js'; 5 + import { defaultBoundaryPolicy, defaultEnforcement } from '../../src/models/iu.js'; 6 + 7 + function makeIU(overrides?: Partial<ImplementationUnit>): ImplementationUnit { 8 + return { 9 + iu_id: 'test-iu', 10 + kind: 'module', 11 + name: 'TestIU', 12 + risk_tier: 'medium', 13 + contract: { description: 'test', inputs: [], outputs: [], invariants: [] }, 14 + source_canon_ids: [], 15 + dependencies: [], 16 + boundary_policy: defaultBoundaryPolicy(), 17 + enforcement: defaultEnforcement(), 18 + evidence_policy: { required: ['typecheck'] }, 19 + output_files: [], 20 + ...overrides, 21 + }; 22 + } 23 + 24 + describe('validateBoundary', () => { 25 + it('returns no diagnostics for clean code', () => { 26 + const source = `export function foo() { return 42; }`; 27 + const graph = extractDependencies(source, 'test.ts'); 28 + const iu = makeIU(); 29 + const diags = validateBoundary(graph, iu); 30 + expect(diags).toHaveLength(0); 31 + }); 32 + 33 + it('catches forbidden packages', () => { 34 + const source = `import axios from 'axios';`; 35 + const graph = extractDependencies(source, 'test.ts'); 36 + const iu = makeIU({ 37 + boundary_policy: { 38 + ...defaultBoundaryPolicy(), 39 + code: { ...defaultBoundaryPolicy().code, forbidden_packages: ['axios'] }, 40 + }, 41 + }); 42 + const diags = validateBoundary(graph, iu); 43 + expect(diags).toHaveLength(1); 44 + expect(diags[0].category).toBe('dependency_violation'); 45 + expect(diags[0].subject).toBe('axios'); 46 + expect(diags[0].severity).toBe('error'); 47 + }); 48 + 49 + it('catches packages not in allowlist', () => { 50 + const source = `import lodash from 'lodash';`; 51 + const graph = extractDependencies(source, 'test.ts'); 52 + const iu = makeIU({ 53 + boundary_policy: { 54 + ...defaultBoundaryPolicy(), 55 + code: { ...defaultBoundaryPolicy().code, allowed_packages: ['express'] }, 56 + }, 57 + }); 58 + const diags = validateBoundary(graph, iu); 59 + expect(diags).toHaveLength(1); 60 + expect(diags[0].subject).toBe('lodash'); 61 + }); 62 + 63 + it('allows packages in allowlist', () => { 64 + const source = `import express from 'express';`; 65 + const graph = extractDependencies(source, 'test.ts'); 66 + const iu = makeIU({ 67 + boundary_policy: { 68 + ...defaultBoundaryPolicy(), 69 + code: { ...defaultBoundaryPolicy().code, allowed_packages: ['express'] }, 70 + }, 71 + }); 72 + const diags = validateBoundary(graph, iu); 73 + expect(diags).toHaveLength(0); 74 + }); 75 + 76 + it('catches forbidden paths', () => { 77 + const source = `import { secret } from './internal/admin.js';`; 78 + const graph = extractDependencies(source, 'test.ts'); 79 + const iu = makeIU({ 80 + boundary_policy: { 81 + ...defaultBoundaryPolicy(), 82 + code: { ...defaultBoundaryPolicy().code, forbidden_paths: ['./internal/**'] }, 83 + }, 84 + }); 85 + const diags = validateBoundary(graph, iu); 86 + expect(diags).toHaveLength(1); 87 + expect(diags[0].category).toBe('dependency_violation'); 88 + }); 89 + 90 + it('catches undeclared side channels', () => { 91 + const source = `const key = process.env.SECRET_KEY;`; 92 + const graph = extractDependencies(source, 'test.ts'); 93 + const iu = makeIU(); 94 + const diags = validateBoundary(graph, iu); 95 + expect(diags).toHaveLength(1); 96 + expect(diags[0].category).toBe('side_channel_violation'); 97 + expect(diags[0].severity).toBe('warning'); // default enforcement 98 + }); 99 + 100 + it('allows declared side channels', () => { 101 + const source = `const key = process.env.SECRET_KEY;`; 102 + const graph = extractDependencies(source, 'test.ts'); 103 + const iu = makeIU({ 104 + boundary_policy: { 105 + ...defaultBoundaryPolicy(), 106 + side_channels: { ...defaultBoundaryPolicy().side_channels, config: ['SECRET_KEY'] }, 107 + }, 108 + }); 109 + const diags = validateBoundary(graph, iu); 110 + expect(diags).toHaveLength(0); 111 + }); 112 + 113 + it('uses enforcement config for severity', () => { 114 + const source = `const key = process.env.UNDECLARED;`; 115 + const graph = extractDependencies(source, 'test.ts'); 116 + const iu = makeIU({ 117 + enforcement: { 118 + dependency_violation: { severity: 'error' }, 119 + side_channel_violation: { severity: 'error' }, 120 + }, 121 + }); 122 + const diags = validateBoundary(graph, iu); 123 + expect(diags[0].severity).toBe('error'); 124 + }); 125 + 126 + it('includes source file and line in diagnostics', () => { 127 + const source = `line1\nimport bad from 'bad-pkg';`; 128 + const graph = extractDependencies(source, 'src/auth.ts'); 129 + const iu = makeIU({ 130 + boundary_policy: { 131 + ...defaultBoundaryPolicy(), 132 + code: { ...defaultBoundaryPolicy().code, forbidden_packages: ['bad-pkg'] }, 133 + }, 134 + }); 135 + const diags = validateBoundary(graph, iu); 136 + expect(diags[0].source_file).toBe('src/auth.ts'); 137 + expect(diags[0].source_line).toBe(2); 138 + }); 139 + }); 140 + 141 + describe('detectBoundaryChanges', () => { 142 + it('returns null when policies are identical', () => { 143 + const iu1 = makeIU(); 144 + const iu2 = makeIU(); 145 + expect(detectBoundaryChanges(iu1, iu2)).toBeNull(); 146 + }); 147 + 148 + it('detects code policy changes', () => { 149 + const iu1 = makeIU(); 150 + const iu2 = makeIU({ 151 + boundary_policy: { 152 + ...defaultBoundaryPolicy(), 153 + code: { ...defaultBoundaryPolicy().code, forbidden_packages: ['axios'] }, 154 + }, 155 + }); 156 + const change = detectBoundaryChanges(iu1, iu2); 157 + expect(change).not.toBeNull(); 158 + expect(change!.changes).toContain('code.forbidden_packages changed'); 159 + }); 160 + 161 + it('detects side channel policy changes', () => { 162 + const iu1 = makeIU(); 163 + const iu2 = makeIU({ 164 + boundary_policy: { 165 + ...defaultBoundaryPolicy(), 166 + side_channels: { ...defaultBoundaryPolicy().side_channels, databases: ['postgres'] }, 167 + }, 168 + }); 169 + const change = detectBoundaryChanges(iu1, iu2); 170 + expect(change).not.toBeNull(); 171 + expect(change!.changes).toContain('side_channels.databases changed'); 172 + }); 173 + });
+116
tests/unit/canonicalizer.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { extractCanonicalNodes, extractTerms } from '../../src/canonicalizer.js'; 3 + import { parseSpec } from '../../src/spec-parser.js'; 4 + import { CanonicalType } from '../../src/models/canonical.js'; 5 + 6 + describe('extractCanonicalNodes', () => { 7 + it('extracts requirements from "must" statements', () => { 8 + const clauses = parseSpec('# Auth\n\nUsers must authenticate with email.', 'test.md'); 9 + const nodes = extractCanonicalNodes(clauses); 10 + const reqs = nodes.filter(n => n.type === CanonicalType.REQUIREMENT); 11 + expect(reqs.length).toBeGreaterThan(0); 12 + expect(reqs[0].statement).toContain('authenticate'); 13 + }); 14 + 15 + it('extracts constraints from "must not" statements', () => { 16 + const clauses = parseSpec('# Rules\n\nUsers must not share passwords.', 'test.md'); 17 + const nodes = extractCanonicalNodes(clauses); 18 + const constraints = nodes.filter(n => n.type === CanonicalType.CONSTRAINT); 19 + expect(constraints.length).toBeGreaterThan(0); 20 + }); 21 + 22 + it('extracts constraints from "forbidden" statements', () => { 23 + const clauses = parseSpec('# Rules\n\nDirect database access is forbidden.', 'test.md'); 24 + const nodes = extractCanonicalNodes(clauses); 25 + const constraints = nodes.filter(n => n.type === CanonicalType.CONSTRAINT); 26 + expect(constraints.length).toBeGreaterThan(0); 27 + }); 28 + 29 + it('extracts invariants from "always" statements', () => { 30 + const clauses = parseSpec('# Guarantees\n\nData must always be encrypted at rest.', 'test.md'); 31 + const nodes = extractCanonicalNodes(clauses); 32 + const invariants = nodes.filter(n => n.type === CanonicalType.INVARIANT); 33 + expect(invariants.length).toBeGreaterThan(0); 34 + }); 35 + 36 + it('extracts definitions from colon patterns', () => { 37 + const clauses = parseSpec('# Glossary\n\nJWT: A JSON Web Token used for auth.', 'test.md'); 38 + const nodes = extractCanonicalNodes(clauses); 39 + const defs = nodes.filter(n => n.type === CanonicalType.DEFINITION); 40 + expect(defs.length).toBeGreaterThan(0); 41 + }); 42 + 43 + it('uses heading context for classification', () => { 44 + const clauses = parseSpec('# Security Constraints\n\nAll endpoints use HTTPS.', 'test.md'); 45 + const nodes = extractCanonicalNodes(clauses); 46 + // "All endpoints use HTTPS" doesn't match specific patterns, 47 + // but heading context "Security Constraints" → CONSTRAINT 48 + expect(nodes.some(n => n.type === CanonicalType.CONSTRAINT)).toBe(true); 49 + }); 50 + 51 + it('links nodes that share terms', () => { 52 + const spec = `# Auth 53 + 54 + Users must authenticate with JWT tokens. 55 + 56 + ## Security 57 + 58 + JWT tokens must be signed with RS256.`; 59 + const clauses = parseSpec(spec, 'test.md'); 60 + const nodes = extractCanonicalNodes(clauses); 61 + 62 + // Both mention "jwt" and "tokens" — should be linked 63 + const jwtNodes = nodes.filter(n => n.statement.includes('jwt')); 64 + if (jwtNodes.length >= 2) { 65 + expect(jwtNodes[0].linked_canon_ids.length).toBeGreaterThan(0); 66 + } 67 + }); 68 + 69 + it('sets source_clause_ids for provenance', () => { 70 + const clauses = parseSpec('# Auth\n\nUsers must log in.', 'test.md'); 71 + const nodes = extractCanonicalNodes(clauses); 72 + expect(nodes.length).toBeGreaterThan(0); 73 + expect(nodes[0].source_clause_ids).toContain(clauses[0].clause_id); 74 + }); 75 + 76 + it('generates unique canon_ids', () => { 77 + const spec = `# Auth 78 + 79 + Users must authenticate. 80 + Sessions must expire after 24h.`; 81 + const clauses = parseSpec(spec, 'test.md'); 82 + const nodes = extractCanonicalNodes(clauses); 83 + const ids = new Set(nodes.map(n => n.canon_id)); 84 + expect(ids.size).toBe(nodes.length); 85 + }); 86 + 87 + it('returns empty array for clause with no extractable content', () => { 88 + const clauses = parseSpec('# Title\n\nJust some description text.', 'test.md'); 89 + const nodes = extractCanonicalNodes(clauses); 90 + // "Just some description text" doesn't match any pattern and 91 + // heading "Title" doesn't give context 92 + expect(nodes).toEqual([]); 93 + }); 94 + }); 95 + 96 + describe('extractTerms', () => { 97 + it('extracts meaningful terms, excluding stop words', () => { 98 + const terms = extractTerms('users must authenticate with email and password'); 99 + expect(terms).toContain('users'); 100 + expect(terms).toContain('authenticate'); 101 + expect(terms).toContain('email'); 102 + expect(terms).toContain('password'); 103 + expect(terms).not.toContain('and'); 104 + expect(terms).not.toContain('with'); 105 + }); 106 + 107 + it('filters short words', () => { 108 + const terms = extractTerms('a is on it by'); 109 + expect(terms).toEqual([]); 110 + }); 111 + 112 + it('deduplicates terms', () => { 113 + const terms = extractTerms('token token token value'); 114 + expect(terms.filter(t => t === 'token')).toHaveLength(1); 115 + }); 116 + });
+75
tests/unit/cascade.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { computeCascade, getTransitiveDependents } from '../../src/cascade.js'; 3 + import type { PolicyEvaluation } from '../../src/models/evidence.js'; 4 + import type { ImplementationUnit } from '../../src/models/iu.js'; 5 + import { defaultBoundaryPolicy, defaultEnforcement } from '../../src/models/iu.js'; 6 + 7 + function makeIU(id: string, name: string, deps: string[] = []): ImplementationUnit { 8 + return { 9 + iu_id: id, kind: 'module', name, risk_tier: 'low', 10 + contract: { description: '', inputs: [], outputs: [], invariants: [] }, 11 + source_canon_ids: [], dependencies: deps, 12 + boundary_policy: defaultBoundaryPolicy(), enforcement: defaultEnforcement(), 13 + evidence_policy: { required: ['typecheck'] }, output_files: [], 14 + }; 15 + } 16 + 17 + describe('computeCascade', () => { 18 + it('produces no events when all pass', () => { 19 + const evals: PolicyEvaluation[] = [ 20 + { iu_id: 'a', iu_name: 'A', risk_tier: 'low', required: ['typecheck'], satisfied: ['typecheck'], missing: [], failed: [], verdict: 'PASS' }, 21 + ]; 22 + const events = computeCascade(evals, [makeIU('a', 'A')]); 23 + expect(events).toHaveLength(0); 24 + }); 25 + 26 + it('blocks failed IU', () => { 27 + const evals: PolicyEvaluation[] = [ 28 + { iu_id: 'a', iu_name: 'A', risk_tier: 'low', required: ['typecheck'], satisfied: [], missing: [], failed: ['typecheck'], verdict: 'FAIL' }, 29 + ]; 30 + const events = computeCascade(evals, [makeIU('a', 'A')]); 31 + expect(events).toHaveLength(1); 32 + expect(events[0].actions[0].action).toBe('BLOCK'); 33 + }); 34 + 35 + it('propagates to dependents', () => { 36 + const ius = [makeIU('a', 'A'), makeIU('b', 'B', ['a'])]; 37 + const evals: PolicyEvaluation[] = [ 38 + { iu_id: 'a', iu_name: 'A', risk_tier: 'low', required: ['typecheck'], satisfied: [], missing: [], failed: ['typecheck'], verdict: 'FAIL' }, 39 + ]; 40 + const events = computeCascade(evals, ius); 41 + const actions = events[0].actions; 42 + expect(actions.some(a => a.iu_id === 'a' && a.action === 'BLOCK')).toBe(true); 43 + expect(actions.some(a => a.iu_id === 'b' && a.action === 'RE_VALIDATE')).toBe(true); 44 + }); 45 + 46 + it('does not propagate from PASS', () => { 47 + const ius = [makeIU('a', 'A'), makeIU('b', 'B', ['a'])]; 48 + const evals: PolicyEvaluation[] = [ 49 + { iu_id: 'a', iu_name: 'A', risk_tier: 'low', required: ['typecheck'], satisfied: ['typecheck'], missing: [], failed: [], verdict: 'PASS' }, 50 + ]; 51 + const events = computeCascade(evals, ius); 52 + expect(events).toHaveLength(0); 53 + }); 54 + }); 55 + 56 + describe('getTransitiveDependents', () => { 57 + it('finds direct dependents', () => { 58 + const ius = [makeIU('a', 'A'), makeIU('b', 'B', ['a']), makeIU('c', 'C')]; 59 + const deps = getTransitiveDependents('a', ius); 60 + expect(deps).toContain('b'); 61 + expect(deps).not.toContain('c'); 62 + }); 63 + 64 + it('finds transitive dependents', () => { 65 + const ius = [makeIU('a', 'A'), makeIU('b', 'B', ['a']), makeIU('c', 'C', ['b'])]; 66 + const deps = getTransitiveDependents('a', ius); 67 + expect(deps).toContain('b'); 68 + expect(deps).toContain('c'); 69 + }); 70 + 71 + it('returns empty for no dependents', () => { 72 + const ius = [makeIU('a', 'A'), makeIU('b', 'B')]; 73 + expect(getTransitiveDependents('a', ius)).toEqual([]); 74 + }); 75 + });
+98
tests/unit/classifier.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { classifyChange, classifyChanges } from '../../src/classifier.js'; 3 + import { parseSpec } from '../../src/spec-parser.js'; 4 + import { diffClauses } from '../../src/diff.js'; 5 + import { extractCanonicalNodes } from '../../src/canonicalizer.js'; 6 + import { DiffType } from '../../src/models/clause.js'; 7 + import { ChangeClass } from '../../src/models/classification.js'; 8 + 9 + describe('classifyChange', () => { 10 + const docId = 'test.md'; 11 + 12 + it('classifies unchanged content as A (trivial)', () => { 13 + const content = '# Auth\n\nUsers must log in.'; 14 + const before = parseSpec(content, docId); 15 + const after = parseSpec(content, docId); 16 + const diffs = diffClauses(before, after); 17 + const canonBefore = extractCanonicalNodes(before); 18 + const canonAfter = extractCanonicalNodes(after); 19 + 20 + const result = classifyChange(diffs[0], canonBefore, canonAfter); 21 + expect(result.change_class).toBe(ChangeClass.A); 22 + expect(result.confidence).toBeGreaterThanOrEqual(0.8); 23 + }); 24 + 25 + it('classifies added clause as B (local semantic)', () => { 26 + const before = parseSpec('# Auth\n\nUsers must log in.', docId); 27 + const after = parseSpec('# Auth\n\nUsers must log in.\n\n# New\n\nNew feature required.', docId); 28 + const diffs = diffClauses(before, after); 29 + const canonBefore = extractCanonicalNodes(before); 30 + const canonAfter = extractCanonicalNodes(after); 31 + 32 + const added = diffs.find(d => d.diff_type === DiffType.ADDED)!; 33 + const result = classifyChange(added, canonBefore, canonAfter); 34 + expect(result.change_class).toBe(ChangeClass.B); 35 + }); 36 + 37 + it('classifies removed clause as B (local semantic)', () => { 38 + const before = parseSpec('# Auth\n\nUsers must log in.\n\n# Old\n\nOld feature.', docId); 39 + const after = parseSpec('# Auth\n\nUsers must log in.', docId); 40 + const diffs = diffClauses(before, after); 41 + const canonBefore = extractCanonicalNodes(before); 42 + const canonAfter = extractCanonicalNodes(after); 43 + 44 + const removed = diffs.find(d => d.diff_type === DiffType.REMOVED)!; 45 + const result = classifyChange(removed, canonBefore, canonAfter); 46 + expect(result.change_class).toBe(ChangeClass.B); 47 + }); 48 + 49 + it('classifies semantic modification with canon impact as C', () => { 50 + const before = parseSpec('# Security\n\nPasswords must be hashed with bcrypt.', docId); 51 + const after = parseSpec('# Security\n\nPasswords must be hashed with argon2id.', docId); 52 + const diffs = diffClauses(before, after); 53 + const canonBefore = extractCanonicalNodes(before); 54 + const canonAfter = extractCanonicalNodes(after); 55 + 56 + const modified = diffs.find(d => d.diff_type === DiffType.MODIFIED)!; 57 + const result = classifyChange(modified, canonBefore, canonAfter); 58 + // Should be C because canonical nodes are affected 59 + expect([ChangeClass.B, ChangeClass.C]).toContain(result.change_class); 60 + expect(result.signals.semhash_delta).toBe(true); 61 + }); 62 + 63 + it('provides confidence score between 0 and 1', () => { 64 + const before = parseSpec('# A\n\nOriginal text here.', docId); 65 + const after = parseSpec('# A\n\nModified text here.', docId); 66 + const diffs = diffClauses(before, after); 67 + const result = classifyChange(diffs[0], [], []); 68 + expect(result.confidence).toBeGreaterThanOrEqual(0); 69 + expect(result.confidence).toBeLessThanOrEqual(1); 70 + }); 71 + 72 + it('includes all signal fields', () => { 73 + const before = parseSpec('# A\n\nOriginal.', docId); 74 + const after = parseSpec('# A\n\nChanged completely to something new.', docId); 75 + const diffs = diffClauses(before, after); 76 + const result = classifyChange(diffs[0], [], []); 77 + 78 + expect(result.signals).toHaveProperty('norm_diff'); 79 + expect(result.signals).toHaveProperty('semhash_delta'); 80 + expect(result.signals).toHaveProperty('context_cold_delta'); 81 + expect(result.signals).toHaveProperty('term_ref_delta'); 82 + expect(result.signals).toHaveProperty('section_structure_delta'); 83 + expect(result.signals).toHaveProperty('canon_impact'); 84 + }); 85 + }); 86 + 87 + describe('classifyChanges', () => { 88 + it('classifies all diffs in a batch', () => { 89 + const before = parseSpec('# A\n\nText A\n\n# B\n\nText B', 'test.md'); 90 + const after = parseSpec('# A\n\nText A modified\n\n# B\n\nText B\n\n# C\n\nText C', 'test.md'); 91 + const diffs = diffClauses(before, after); 92 + const results = classifyChanges(diffs, [], []); 93 + expect(results).toHaveLength(diffs.length); 94 + for (const r of results) { 95 + expect([ChangeClass.A, ChangeClass.B, ChangeClass.C, ChangeClass.D]).toContain(r.change_class); 96 + } 97 + }); 98 + });
+68
tests/unit/compaction.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { identifyCandidates, runCompaction, shouldTriggerCompaction } from '../../src/compaction.js'; 3 + import type { CompactionCandidate } from '../../src/compaction.js'; 4 + 5 + function makeCandidate(id: string, type: string, ageDays: number, sizeBytes: number, preserve = false): CompactionCandidate { 6 + return { object_id: id, object_type: type, age_days: ageDays, size_bytes: sizeBytes, preserve }; 7 + } 8 + 9 + describe('identifyCandidates', () => { 10 + it('compacts old non-preserved objects', () => { 11 + const objects = [ 12 + makeCandidate('a', 'clause_body', 60, 1000), 13 + makeCandidate('b', 'clause_body', 10, 500), 14 + ]; 15 + const { toCompact, toPreserve } = identifyCandidates(objects, 30); 16 + expect(toCompact).toHaveLength(1); 17 + expect(toCompact[0].object_id).toBe('a'); 18 + expect(toPreserve).toHaveLength(1); 19 + }); 20 + 21 + it('never compacts preserved objects regardless of age', () => { 22 + const objects = [ 23 + makeCandidate('a', 'provenance_edge', 365, 100, true), 24 + makeCandidate('b', 'node_header', 365, 50, true), 25 + ]; 26 + const { toCompact, toPreserve } = identifyCandidates(objects, 30); 27 + expect(toCompact).toHaveLength(0); 28 + expect(toPreserve).toHaveLength(2); 29 + }); 30 + }); 31 + 32 + describe('runCompaction', () => { 33 + it('produces a CompactionEvent', () => { 34 + const objects = [ 35 + makeCandidate('a', 'clause_body', 60, 1000), 36 + makeCandidate('b', 'node_header', 60, 50, true), 37 + makeCandidate('c', 'provenance_edge', 60, 30, true), 38 + ]; 39 + const event = runCompaction(objects, 'size_threshold', 30); 40 + expect(event.type).toBe('CompactionEvent'); 41 + expect(event.nodes_compacted).toBe(1); 42 + expect(event.bytes_freed).toBe(1000); 43 + expect(event.preserved.node_headers).toBe(1); 44 + expect(event.preserved.provenance_edges).toBe(1); 45 + }); 46 + }); 47 + 48 + describe('shouldTriggerCompaction', () => { 49 + it('triggers on size threshold', () => { 50 + const stats = { total_objects: 100, total_bytes: 200_000_000, hot_objects: 50, hot_bytes: 100_000_000, cold_objects: 50, cold_bytes: 100_000_000 }; 51 + const { trigger, reason } = shouldTriggerCompaction(stats); 52 + expect(trigger).toBe(true); 53 + expect(reason).toBe('size_threshold'); 54 + }); 55 + 56 + it('triggers on time threshold', () => { 57 + const stats = { total_objects: 10, total_bytes: 1000, hot_objects: 10, hot_bytes: 1000, cold_objects: 0, cold_bytes: 0 }; 58 + const { trigger, reason } = shouldTriggerCompaction(stats, 100_000_000, 100, 90); 59 + expect(trigger).toBe(true); 60 + expect(reason).toBe('time_based'); 61 + }); 62 + 63 + it('does not trigger when below thresholds', () => { 64 + const stats = { total_objects: 10, total_bytes: 1000, hot_objects: 10, hot_bytes: 1000, cold_objects: 0, cold_bytes: 0 }; 65 + const { trigger } = shouldTriggerCompaction(stats); 66 + expect(trigger).toBe(false); 67 + }); 68 + });
+48
tests/unit/content-store.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { mkdtempSync } from 'node:fs'; 3 + import { join } from 'node:path'; 4 + import { tmpdir } from 'node:os'; 5 + import { ContentStore } from '../../src/store/content-store.js'; 6 + 7 + describe('ContentStore', () => { 8 + let store: ContentStore; 9 + 10 + beforeEach(() => { 11 + const tempDir = mkdtempSync(join(tmpdir(), 'phoenix-cs-')); 12 + store = new ContentStore(tempDir); 13 + }); 14 + 15 + it('stores and retrieves an object', () => { 16 + const obj = { name: 'test', value: 42 }; 17 + store.put('abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', obj); 18 + const retrieved = store.get('abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'); 19 + expect(retrieved).toEqual(obj); 20 + }); 21 + 22 + it('returns null for non-existent object', () => { 23 + expect(store.get('0000001234567890abcdef1234567890abcdef1234567890abcdef1234567890')).toBeNull(); 24 + }); 25 + 26 + it('reports existence correctly', () => { 27 + const id = 'aabbcc1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; 28 + expect(store.has(id)).toBe(false); 29 + store.put(id, { test: true }); 30 + expect(store.has(id)).toBe(true); 31 + }); 32 + 33 + it('handles multiple objects with same prefix', () => { 34 + const id1 = 'aa00001234567890abcdef1234567890abcdef1234567890abcdef1234567890'; 35 + const id2 = 'aa00002234567890abcdef1234567890abcdef1234567890abcdef1234567890'; 36 + store.put(id1, { which: 'first' }); 37 + store.put(id2, { which: 'second' }); 38 + expect(store.get<{ which: string }>(id1)?.which).toBe('first'); 39 + expect(store.get<{ which: string }>(id2)?.which).toBe('second'); 40 + }); 41 + 42 + it('overwrites existing object', () => { 43 + const id = 'bbccdd1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; 44 + store.put(id, { version: 1 }); 45 + store.put(id, { version: 2 }); 46 + expect(store.get<{ version: number }>(id)?.version).toBe(2); 47 + }); 48 + });
+91
tests/unit/d-rate.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { DRateTracker } from '../../src/d-rate.js'; 3 + import { ChangeClass, DRateLevel } from '../../src/models/classification.js'; 4 + 5 + describe('DRateTracker', () => { 6 + it('reports TARGET for empty window', () => { 7 + const tracker = new DRateTracker(); 8 + const status = tracker.getStatus(); 9 + expect(status.rate).toBe(0); 10 + expect(status.level).toBe(DRateLevel.TARGET); 11 + }); 12 + 13 + it('reports TARGET when no D classifications', () => { 14 + const tracker = new DRateTracker(10); 15 + for (let i = 0; i < 10; i++) { 16 + tracker.recordOne(ChangeClass.A); 17 + } 18 + const status = tracker.getStatus(); 19 + expect(status.rate).toBe(0); 20 + expect(status.level).toBe(DRateLevel.TARGET); 21 + }); 22 + 23 + it('reports TARGET for ≤5% D rate', () => { 24 + const tracker = new DRateTracker(100); 25 + for (let i = 0; i < 95; i++) tracker.recordOne(ChangeClass.A); 26 + for (let i = 0; i < 5; i++) tracker.recordOne(ChangeClass.D); 27 + const status = tracker.getStatus(); 28 + expect(status.rate).toBe(0.05); 29 + expect(status.level).toBe(DRateLevel.TARGET); 30 + }); 31 + 32 + it('reports ACCEPTABLE for 6-10% D rate', () => { 33 + const tracker = new DRateTracker(100); 34 + for (let i = 0; i < 92; i++) tracker.recordOne(ChangeClass.B); 35 + for (let i = 0; i < 8; i++) tracker.recordOne(ChangeClass.D); 36 + const status = tracker.getStatus(); 37 + expect(status.rate).toBe(0.08); 38 + expect(status.level).toBe(DRateLevel.ACCEPTABLE); 39 + }); 40 + 41 + it('reports WARNING for 11-15% D rate', () => { 42 + const tracker = new DRateTracker(100); 43 + for (let i = 0; i < 88; i++) tracker.recordOne(ChangeClass.C); 44 + for (let i = 0; i < 12; i++) tracker.recordOne(ChangeClass.D); 45 + const status = tracker.getStatus(); 46 + expect(status.rate).toBe(0.12); 47 + expect(status.level).toBe(DRateLevel.WARNING); 48 + }); 49 + 50 + it('reports ALARM for >15% D rate', () => { 51 + const tracker = new DRateTracker(100); 52 + for (let i = 0; i < 80; i++) tracker.recordOne(ChangeClass.A); 53 + for (let i = 0; i < 20; i++) tracker.recordOne(ChangeClass.D); 54 + const status = tracker.getStatus(); 55 + expect(status.rate).toBe(0.20); 56 + expect(status.level).toBe(DRateLevel.ALARM); 57 + }); 58 + 59 + it('respects rolling window size', () => { 60 + const tracker = new DRateTracker(10); 61 + // Fill window with D classifications 62 + for (let i = 0; i < 10; i++) tracker.recordOne(ChangeClass.D); 63 + expect(tracker.getStatus().rate).toBe(1.0); 64 + 65 + // Push out all D's with A's 66 + for (let i = 0; i < 10; i++) tracker.recordOne(ChangeClass.A); 67 + expect(tracker.getStatus().rate).toBe(0); 68 + }); 69 + 70 + it('records batch classifications', () => { 71 + const tracker = new DRateTracker(10); 72 + const emptySignals = { 73 + norm_diff: 0, semhash_delta: false, context_cold_delta: false, 74 + term_ref_delta: 0, section_structure_delta: false, canon_impact: 0, 75 + }; 76 + tracker.record([ 77 + { change_class: ChangeClass.A, confidence: 1, signals: emptySignals }, 78 + { change_class: ChangeClass.D, confidence: 0.3, signals: emptySignals }, 79 + ]); 80 + const status = tracker.getStatus(); 81 + expect(status.total_count).toBe(2); 82 + expect(status.d_count).toBe(1); 83 + }); 84 + 85 + it('resets correctly', () => { 86 + const tracker = new DRateTracker(10); 87 + tracker.recordOne(ChangeClass.D); 88 + tracker.reset(); 89 + expect(tracker.getStatus().total_count).toBe(0); 90 + }); 91 + });
+83
tests/unit/dep-extractor.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { extractDependencies } from '../../src/dep-extractor.js'; 3 + 4 + describe('extractDependencies', () => { 5 + it('extracts ES module imports', () => { 6 + const source = `import { foo } from 'bar'; 7 + import baz from '@scope/pkg';`; 8 + const graph = extractDependencies(source, 'test.ts'); 9 + expect(graph.imports).toHaveLength(2); 10 + expect(graph.imports[0].source).toBe('bar'); 11 + expect(graph.imports[1].source).toBe('@scope/pkg'); 12 + }); 13 + 14 + it('extracts relative imports', () => { 15 + const source = `import { helper } from './utils/helper.js';`; 16 + const graph = extractDependencies(source, 'test.ts'); 17 + expect(graph.imports[0].is_relative).toBe(true); 18 + expect(graph.imports[0].source).toBe('./utils/helper.js'); 19 + }); 20 + 21 + it('extracts require() calls', () => { 22 + const source = `const fs = require('fs');`; 23 + const graph = extractDependencies(source, 'test.ts'); 24 + expect(graph.imports).toHaveLength(1); 25 + expect(graph.imports[0].source).toBe('fs'); 26 + }); 27 + 28 + it('detects process.env access', () => { 29 + const source = `const key = process.env.API_KEY; 30 + const url = process.env['DATABASE_URL'];`; 31 + const graph = extractDependencies(source, 'test.ts'); 32 + expect(graph.side_channels).toHaveLength(2); 33 + expect(graph.side_channels[0].kind).toBe('config'); 34 + expect(graph.side_channels[0].identifier).toBe('API_KEY'); 35 + expect(graph.side_channels[1].identifier).toBe('DATABASE_URL'); 36 + }); 37 + 38 + it('detects fetch calls as external_api', () => { 39 + const source = `const resp = fetch('https://api.example.com/data');`; 40 + const graph = extractDependencies(source, 'test.ts'); 41 + expect(graph.side_channels).toHaveLength(1); 42 + expect(graph.side_channels[0].kind).toBe('external_api'); 43 + expect(graph.side_channels[0].identifier).toBe('https://api.example.com/data'); 44 + }); 45 + 46 + it('detects database connections', () => { 47 + const source = `const pool = new Pool({ connectionString: url });`; 48 + const graph = extractDependencies(source, 'test.ts'); 49 + expect(graph.side_channels).toHaveLength(1); 50 + expect(graph.side_channels[0].kind).toBe('database'); 51 + }); 52 + 53 + it('detects fs operations', () => { 54 + const source = `fs.readFile('/tmp/data.json', cb);`; 55 + const graph = extractDependencies(source, 'test.ts'); 56 + expect(graph.side_channels).toHaveLength(1); 57 + expect(graph.side_channels[0].kind).toBe('file'); 58 + }); 59 + 60 + it('detects Redis connections', () => { 61 + const source = `const client = new Redis();`; 62 + const graph = extractDependencies(source, 'test.ts'); 63 + expect(graph.side_channels).toHaveLength(1); 64 + expect(graph.side_channels[0].kind).toBe('cache'); 65 + }); 66 + 67 + it('records source line numbers', () => { 68 + const source = `line1 69 + import { foo } from 'bar'; 70 + line3 71 + const key = process.env.SECRET;`; 72 + const graph = extractDependencies(source, 'test.ts'); 73 + expect(graph.imports[0].source_line).toBe(2); 74 + expect(graph.side_channels[0].source_line).toBe(4); 75 + }); 76 + 77 + it('returns empty for clean code', () => { 78 + const source = `export function add(a: number, b: number) { return a + b; }`; 79 + const graph = extractDependencies(source, 'test.ts'); 80 + expect(graph.imports).toHaveLength(0); 81 + expect(graph.side_channels).toHaveLength(0); 82 + }); 83 + });
+71
tests/unit/diff.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { parseSpec } from '../../src/spec-parser.js'; 3 + import { diffClauses } from '../../src/diff.js'; 4 + import { DiffType } from '../../src/models/clause.js'; 5 + 6 + describe('diffClauses', () => { 7 + const docId = 'test.md'; 8 + 9 + it('reports UNCHANGED for identical documents', () => { 10 + const content = '# A\n\nText A\n\n# B\n\nText B'; 11 + const before = parseSpec(content, docId); 12 + const after = parseSpec(content, docId); 13 + const diffs = diffClauses(before, after); 14 + expect(diffs.every(d => d.diff_type === DiffType.UNCHANGED)).toBe(true); 15 + expect(diffs).toHaveLength(2); 16 + }); 17 + 18 + it('detects ADDED clauses', () => { 19 + const before = parseSpec('# A\n\nText A', docId); 20 + const after = parseSpec('# A\n\nText A\n\n# B\n\nText B', docId); 21 + const diffs = diffClauses(before, after); 22 + const added = diffs.filter(d => d.diff_type === DiffType.ADDED); 23 + expect(added).toHaveLength(1); 24 + expect(added[0].clause_after?.section_path).toEqual(['B']); 25 + }); 26 + 27 + it('detects REMOVED clauses', () => { 28 + const before = parseSpec('# A\n\nText A\n\n# B\n\nText B', docId); 29 + const after = parseSpec('# A\n\nText A', docId); 30 + const diffs = diffClauses(before, after); 31 + const removed = diffs.filter(d => d.diff_type === DiffType.REMOVED); 32 + expect(removed).toHaveLength(1); 33 + }); 34 + 35 + it('detects MODIFIED clauses (same section, different content)', () => { 36 + const before = parseSpec('# A\n\nOriginal text', docId); 37 + const after = parseSpec('# A\n\nUpdated text', docId); 38 + const diffs = diffClauses(before, after); 39 + const modified = diffs.filter(d => d.diff_type === DiffType.MODIFIED); 40 + expect(modified).toHaveLength(1); 41 + expect(modified[0].section_path_before).toEqual(['A']); 42 + }); 43 + 44 + it('detects MOVED clauses (same content, different parent)', () => { 45 + // Same subsection content moved under a different parent heading 46 + const before = parseSpec('# Parent A\n\n## Child\n\nShared text', docId); 47 + const after = parseSpec('# Parent B\n\n## Child\n\nShared text', docId); 48 + const diffs = diffClauses(before, after); 49 + const moved = diffs.filter(d => d.diff_type === DiffType.MOVED); 50 + // "Child" section has same normalized content but different section_path 51 + expect(moved).toHaveLength(1); 52 + expect(moved[0].section_path_before).toEqual(['Parent A', 'Child']); 53 + expect(moved[0].section_path_after).toEqual(['Parent B', 'Child']); 54 + }); 55 + 56 + it('handles complete replacement', () => { 57 + const before = parseSpec('# A\n\nOld', docId); 58 + const after = parseSpec('# X\n\nNew', docId); 59 + const diffs = diffClauses(before, after); 60 + const removed = diffs.filter(d => d.diff_type === DiffType.REMOVED); 61 + const added = diffs.filter(d => d.diff_type === DiffType.ADDED); 62 + expect(removed.length + added.length).toBe(2); 63 + }); 64 + 65 + it('ignores formatting-only changes', () => { 66 + const before = parseSpec('# A\n\nPhoenix is a VCS.', docId); 67 + const after = parseSpec('# A\n\n**Phoenix** is a VCS.', docId); 68 + const diffs = diffClauses(before, after); 69 + expect(diffs.every(d => d.diff_type === DiffType.UNCHANGED)).toBe(true); 70 + }); 71 + });
+108
tests/unit/drift.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; 3 + import { join } from 'node:path'; 4 + import { tmpdir } from 'node:os'; 5 + import { detectDrift } from '../../src/drift.js'; 6 + import { DriftStatus } from '../../src/models/manifest.js'; 7 + import type { GeneratedManifest, DriftWaiver } from '../../src/models/manifest.js'; 8 + import { sha256 } from '../../src/semhash.js'; 9 + 10 + describe('detectDrift', () => { 11 + let projectRoot: string; 12 + 13 + beforeEach(() => { 14 + projectRoot = mkdtempSync(join(tmpdir(), 'phoenix-drift-')); 15 + }); 16 + 17 + function writeFile(relPath: string, content: string) { 18 + const full = join(projectRoot, relPath); 19 + mkdirSync(join(full, '..'), { recursive: true }); 20 + writeFileSync(full, content, 'utf8'); 21 + } 22 + 23 + function makeManifest(files: Record<string, string>): GeneratedManifest { 24 + const iu_manifests: GeneratedManifest['iu_manifests'] = { 25 + 'test-iu': { 26 + iu_id: 'test-iu', 27 + iu_name: 'TestIU', 28 + files: Object.fromEntries( 29 + Object.entries(files).map(([path, content]) => [ 30 + path, 31 + { path, content_hash: sha256(content), size: content.length }, 32 + ]) 33 + ), 34 + regen_metadata: { 35 + model_id: 'test', 36 + promptpack_hash: 'abc', 37 + toolchain_version: '1.0', 38 + generated_at: new Date().toISOString(), 39 + }, 40 + }, 41 + }; 42 + return { iu_manifests, generated_at: new Date().toISOString() }; 43 + } 44 + 45 + it('reports CLEAN when files match manifest', () => { 46 + const content = 'export function auth() {}'; 47 + writeFile('src/auth.ts', content); 48 + const manifest = makeManifest({ 'src/auth.ts': content }); 49 + 50 + const report = detectDrift(manifest, projectRoot); 51 + expect(report.entries).toHaveLength(1); 52 + expect(report.entries[0].status).toBe(DriftStatus.CLEAN); 53 + expect(report.drifted_count).toBe(0); 54 + }); 55 + 56 + it('reports DRIFTED when file content differs', () => { 57 + const original = 'export function auth() {}'; 58 + const modified = 'export function auth() { /* hacked */ }'; 59 + writeFile('src/auth.ts', modified); 60 + const manifest = makeManifest({ 'src/auth.ts': original }); 61 + 62 + const report = detectDrift(manifest, projectRoot); 63 + expect(report.entries[0].status).toBe(DriftStatus.DRIFTED); 64 + expect(report.drifted_count).toBe(1); 65 + expect(report.summary).toContain('DRIFT DETECTED'); 66 + }); 67 + 68 + it('reports MISSING when file does not exist', () => { 69 + const manifest = makeManifest({ 'src/missing.ts': 'content' }); 70 + const report = detectDrift(manifest, projectRoot); 71 + expect(report.entries[0].status).toBe(DriftStatus.MISSING); 72 + expect(report.missing_count).toBe(1); 73 + }); 74 + 75 + it('reports WAIVED when file differs but has waiver', () => { 76 + const original = 'export function auth() {}'; 77 + const modified = 'export function auth() { /* patched */ }'; 78 + writeFile('src/auth.ts', modified); 79 + const manifest = makeManifest({ 'src/auth.ts': original }); 80 + 81 + const waiver: DriftWaiver = { 82 + kind: 'temporary_patch', 83 + reason: 'Hotfix for production issue', 84 + expires: '2026-03-01', 85 + }; 86 + const waivers = new Map([['src/auth.ts', waiver]]); 87 + 88 + const report = detectDrift(manifest, projectRoot, waivers); 89 + expect(report.entries[0].status).toBe(DriftStatus.WAIVED); 90 + expect(report.drifted_count).toBe(0); 91 + }); 92 + 93 + it('handles multiple files with mixed statuses', () => { 94 + writeFile('src/a.ts', 'clean content'); 95 + writeFile('src/b.ts', 'modified content'); 96 + const manifest = makeManifest({ 97 + 'src/a.ts': 'clean content', 98 + 'src/b.ts': 'original content', 99 + 'src/c.ts': 'missing content', 100 + }); 101 + 102 + const report = detectDrift(manifest, projectRoot); 103 + const statuses = report.entries.map(e => e.status); 104 + expect(statuses).toContain(DriftStatus.CLEAN); 105 + expect(statuses).toContain(DriftStatus.DRIFTED); 106 + expect(statuses).toContain(DriftStatus.MISSING); 107 + }); 108 + });
+85
tests/unit/iu-planner.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { planIUs } from '../../src/iu-planner.js'; 3 + import { parseSpec } from '../../src/spec-parser.js'; 4 + import { extractCanonicalNodes } from '../../src/canonicalizer.js'; 5 + 6 + describe('planIUs', () => { 7 + it('returns empty array for no canonical nodes', () => { 8 + expect(planIUs([], [])).toEqual([]); 9 + }); 10 + 11 + it('creates IUs from canonical nodes', () => { 12 + const clauses = parseSpec('# Auth\n\nUsers must log in.\nPasswords must be hashed.', 'test.md'); 13 + const canon = extractCanonicalNodes(clauses); 14 + const ius = planIUs(canon, clauses); 15 + expect(ius.length).toBeGreaterThan(0); 16 + }); 17 + 18 + it('groups linked canonical nodes into the same IU', () => { 19 + const spec = `# Auth 20 + 21 + Users must authenticate with JWT tokens. 22 + 23 + ## Security 24 + 25 + JWT tokens must be signed with RS256.`; 26 + const clauses = parseSpec(spec, 'test.md'); 27 + const canon = extractCanonicalNodes(clauses); 28 + const ius = planIUs(canon, clauses); 29 + // Linked nodes should be grouped — fewer IUs than nodes 30 + expect(ius.length).toBeLessThanOrEqual(canon.length); 31 + }); 32 + 33 + it('sets risk tier based on constraints', () => { 34 + const clauses = parseSpec('# Security Constraints\n\nDirect DB access is forbidden.\nRate limited to 5 per minute.', 'test.md'); 35 + const canon = extractCanonicalNodes(clauses); 36 + const ius = planIUs(canon, clauses); 37 + expect(ius.length).toBeGreaterThan(0); 38 + // Should be medium or high due to constraints 39 + expect(['medium', 'high', 'critical']).toContain(ius[0].risk_tier); 40 + }); 41 + 42 + it('populates all required IU fields', () => { 43 + const clauses = parseSpec('# Auth\n\nUsers must authenticate.', 'test.md'); 44 + const canon = extractCanonicalNodes(clauses); 45 + const ius = planIUs(canon, clauses); 46 + 47 + for (const iu of ius) { 48 + expect(iu.iu_id).toHaveLength(64); 49 + expect(iu.kind).toBe('module'); 50 + expect(iu.name).toBeTruthy(); 51 + expect(iu.risk_tier).toBeTruthy(); 52 + expect(iu.contract.description).toBeTruthy(); 53 + expect(iu.source_canon_ids.length).toBeGreaterThan(0); 54 + expect(iu.output_files.length).toBeGreaterThan(0); 55 + expect(iu.boundary_policy).toBeTruthy(); 56 + expect(iu.evidence_policy.required.length).toBeGreaterThan(0); 57 + } 58 + }); 59 + 60 + it('generates output file paths under src/generated/', () => { 61 + const clauses = parseSpec('# Auth\n\nUsers must log in.', 'test.md'); 62 + const canon = extractCanonicalNodes(clauses); 63 + const ius = planIUs(canon, clauses); 64 + 65 + for (const iu of ius) { 66 + for (const f of iu.output_files) { 67 + expect(f).toMatch(/^src\/generated\//); 68 + expect(f).toMatch(/\.ts$/); 69 + } 70 + } 71 + }); 72 + 73 + it('assigns evidence policy based on risk tier', () => { 74 + const clauses = parseSpec('# Auth\n\nUsers must log in.', 'test.md'); 75 + const canon = extractCanonicalNodes(clauses); 76 + const ius = planIUs(canon, clauses); 77 + 78 + for (const iu of ius) { 79 + expect(iu.evidence_policy.required).toContain('typecheck'); 80 + if (iu.risk_tier === 'medium' || iu.risk_tier === 'high') { 81 + expect(iu.evidence_policy.required).toContain('unit_tests'); 82 + } 83 + } 84 + }); 85 + });
+77
tests/unit/manifest.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { mkdtempSync } from 'node:fs'; 3 + import { join } from 'node:path'; 4 + import { tmpdir } from 'node:os'; 5 + import { ManifestManager } from '../../src/manifest.js'; 6 + import type { IUManifest } from '../../src/models/manifest.js'; 7 + 8 + describe('ManifestManager', () => { 9 + let tempDir: string; 10 + let manager: ManifestManager; 11 + 12 + beforeEach(() => { 13 + tempDir = mkdtempSync(join(tmpdir(), 'phoenix-manifest-')); 14 + manager = new ManifestManager(tempDir); 15 + }); 16 + 17 + function makeIUManifest(id: string, files: Record<string, { hash: string; size: number }>): IUManifest { 18 + return { 19 + iu_id: id, 20 + iu_name: `IU-${id}`, 21 + files: Object.fromEntries( 22 + Object.entries(files).map(([path, { hash, size }]) => [ 23 + path, { path, content_hash: hash, size }, 24 + ]) 25 + ), 26 + regen_metadata: { 27 + model_id: 'test', promptpack_hash: 'abc', 28 + toolchain_version: '1.0', generated_at: new Date().toISOString(), 29 + }, 30 + }; 31 + } 32 + 33 + it('returns empty manifest when none exists', () => { 34 + const manifest = manager.load(); 35 + expect(Object.keys(manifest.iu_manifests)).toHaveLength(0); 36 + }); 37 + 38 + it('records and retrieves a single IU manifest', () => { 39 + const ium = makeIUManifest('iu1', { 'src/a.ts': { hash: 'aaa', size: 100 } }); 40 + manager.recordIU(ium); 41 + const retrieved = manager.getIUManifest('iu1'); 42 + expect(retrieved).not.toBeNull(); 43 + expect(retrieved!.iu_name).toBe('IU-iu1'); 44 + }); 45 + 46 + it('records multiple IU manifests', () => { 47 + const iu1 = makeIUManifest('iu1', { 'src/a.ts': { hash: 'aaa', size: 100 } }); 48 + const iu2 = makeIUManifest('iu2', { 'src/b.ts': { hash: 'bbb', size: 200 } }); 49 + manager.recordAll([iu1, iu2]); 50 + expect(manager.getIUManifest('iu1')).not.toBeNull(); 51 + expect(manager.getIUManifest('iu2')).not.toBeNull(); 52 + }); 53 + 54 + it('returns null for non-existent IU', () => { 55 + expect(manager.getIUManifest('nonexistent')).toBeNull(); 56 + }); 57 + 58 + it('lists all tracked files', () => { 59 + const iu1 = makeIUManifest('iu1', { 'src/a.ts': { hash: 'a', size: 1 } }); 60 + const iu2 = makeIUManifest('iu2', { 'src/b.ts': { hash: 'b', size: 2 }, 'src/c.ts': { hash: 'c', size: 3 } }); 61 + manager.recordAll([iu1, iu2]); 62 + const files = manager.getAllTrackedFiles(); 63 + expect(files).toHaveLength(3); 64 + expect(files).toContain('src/a.ts'); 65 + expect(files).toContain('src/b.ts'); 66 + expect(files).toContain('src/c.ts'); 67 + }); 68 + 69 + it('overwrites existing IU manifest on re-record', () => { 70 + const v1 = makeIUManifest('iu1', { 'src/a.ts': { hash: 'old', size: 100 } }); 71 + manager.recordIU(v1); 72 + const v2 = makeIUManifest('iu1', { 'src/a.ts': { hash: 'new', size: 200 } }); 73 + manager.recordIU(v2); 74 + const retrieved = manager.getIUManifest('iu1'); 75 + expect(retrieved!.files['src/a.ts'].content_hash).toBe('new'); 76 + }); 77 + });
+75
tests/unit/normalizer.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { normalizeText } from '../../src/normalizer.js'; 3 + 4 + describe('normalizeText', () => { 5 + it('removes markdown heading markers', () => { 6 + const result = normalizeText('## Hello World'); 7 + expect(result).toBe('hello world'); 8 + }); 9 + 10 + it('removes bold and italic markers', () => { 11 + expect(normalizeText('**bold** and *italic*')).toBe('bold and italic'); 12 + expect(normalizeText('__bold__ and _italic_')).toBe('bold and italic'); 13 + }); 14 + 15 + it('removes inline code backticks but keeps content', () => { 16 + expect(normalizeText('use `foo()` here')).toBe('use foo() here'); 17 + }); 18 + 19 + it('removes link syntax, keeps text', () => { 20 + expect(normalizeText('[click here](https://example.com)')).toBe('click here'); 21 + }); 22 + 23 + it('lowercases everything', () => { 24 + expect(normalizeText('Hello WORLD FoO')).toBe('hello world foo'); 25 + }); 26 + 27 + it('collapses whitespace', () => { 28 + expect(normalizeText('hello world\t\there')).toBe('hello world here'); 29 + }); 30 + 31 + it('strips empty lines', () => { 32 + const input = 'line one\n\n\nline two\n\n'; 33 + expect(normalizeText(input)).toBe('line one\nline two'); 34 + }); 35 + 36 + it('sorts list items for order-invariant hashing', () => { 37 + const input1 = '- cherry\n- apple\n- banana'; 38 + const input2 = '- apple\n- banana\n- cherry'; 39 + expect(normalizeText(input1)).toBe(normalizeText(input2)); 40 + }); 41 + 42 + it('sorts numbered list items', () => { 43 + const input1 = '1. cherry\n2. apple\n3. banana'; 44 + const input2 = '1. apple\n2. banana\n3. cherry'; 45 + expect(normalizeText(input1)).toBe(normalizeText(input2)); 46 + }); 47 + 48 + it('handles mixed content and lists', () => { 49 + const input = 'Intro text\n\n- beta\n- alpha\n\nConclusion'; 50 + const result = normalizeText(input); 51 + expect(result).toBe('intro text\nalpha\nbeta\nconclusion'); 52 + }); 53 + 54 + it('strips fenced code blocks', () => { 55 + const input = 'Some text\n\n```typescript\nconst x = 1;\n# Not a heading\n- Not a list\n```\n\nMore text'; 56 + const result = normalizeText(input); 57 + expect(result).not.toContain('const x'); 58 + expect(result).not.toContain('# Not a heading'); 59 + expect(result).toContain('(code block)'); 60 + expect(result).toContain('more text'); 61 + }); 62 + 63 + it('is idempotent', () => { 64 + const input = '## **Bold** heading\n\n- z item\n- a item'; 65 + const once = normalizeText(input); 66 + const twice = normalizeText(once); 67 + expect(once).toBe(twice); 68 + }); 69 + 70 + it('produces same output for formatting-only changes', () => { 71 + const v1 = '## Overview\n\nPhoenix is a VCS.'; 72 + const v2 = '## Overview\n\n**Phoenix** is a VCS.'; 73 + expect(normalizeText(v1)).toBe(normalizeText(v2)); 74 + }); 75 + });
+77
tests/unit/policy-engine.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { evaluatePolicy } from '../../src/policy-engine.js'; 3 + import { EvidenceKind, EvidenceStatus } from '../../src/models/evidence.js'; 4 + import type { EvidenceRecord } from '../../src/models/evidence.js'; 5 + import type { ImplementationUnit } from '../../src/models/iu.js'; 6 + import { defaultBoundaryPolicy, defaultEnforcement } from '../../src/models/iu.js'; 7 + 8 + function makeIU(riskTier: string, required: string[]): ImplementationUnit { 9 + return { 10 + iu_id: 'test-iu', kind: 'module', name: 'TestIU', risk_tier: riskTier as any, 11 + contract: { description: '', inputs: [], outputs: [], invariants: [] }, 12 + source_canon_ids: [], dependencies: [], 13 + boundary_policy: defaultBoundaryPolicy(), enforcement: defaultEnforcement(), 14 + evidence_policy: { required }, output_files: [], 15 + }; 16 + } 17 + 18 + function makeEvidence(kind: EvidenceKind, status: EvidenceStatus): EvidenceRecord { 19 + return { 20 + evidence_id: `ev-${kind}-${status}`, kind, status, iu_id: 'test-iu', 21 + canon_ids: [], timestamp: new Date().toISOString(), 22 + }; 23 + } 24 + 25 + describe('evaluatePolicy', () => { 26 + it('returns PASS when all required evidence passes', () => { 27 + const iu = makeIU('low', ['typecheck', 'lint']); 28 + const evidence = [ 29 + makeEvidence(EvidenceKind.TYPECHECK, EvidenceStatus.PASS), 30 + makeEvidence(EvidenceKind.LINT, EvidenceStatus.PASS), 31 + ]; 32 + const result = evaluatePolicy(iu, evidence); 33 + expect(result.verdict).toBe('PASS'); 34 + expect(result.satisfied).toEqual(['typecheck', 'lint']); 35 + expect(result.missing).toEqual([]); 36 + expect(result.failed).toEqual([]); 37 + }); 38 + 39 + it('returns INCOMPLETE when evidence is missing', () => { 40 + const iu = makeIU('medium', ['typecheck', 'lint', 'unit_tests']); 41 + const evidence = [ 42 + makeEvidence(EvidenceKind.TYPECHECK, EvidenceStatus.PASS), 43 + ]; 44 + const result = evaluatePolicy(iu, evidence); 45 + expect(result.verdict).toBe('INCOMPLETE'); 46 + expect(result.missing).toContain('lint'); 47 + expect(result.missing).toContain('unit_tests'); 48 + }); 49 + 50 + it('returns FAIL when any evidence fails', () => { 51 + const iu = makeIU('low', ['typecheck', 'lint']); 52 + const evidence = [ 53 + makeEvidence(EvidenceKind.TYPECHECK, EvidenceStatus.PASS), 54 + makeEvidence(EvidenceKind.LINT, EvidenceStatus.FAIL), 55 + ]; 56 + const result = evaluatePolicy(iu, evidence); 57 + expect(result.verdict).toBe('FAIL'); 58 + expect(result.failed).toContain('lint'); 59 + }); 60 + 61 + it('treats PENDING as missing', () => { 62 + const iu = makeIU('low', ['typecheck']); 63 + const evidence = [makeEvidence(EvidenceKind.TYPECHECK, EvidenceStatus.PENDING)]; 64 + const result = evaluatePolicy(iu, evidence); 65 + expect(result.verdict).toBe('INCOMPLETE'); 66 + }); 67 + 68 + it('ignores evidence for other IUs', () => { 69 + const iu = makeIU('low', ['typecheck']); 70 + const evidence: EvidenceRecord[] = [{ 71 + evidence_id: 'other', kind: EvidenceKind.TYPECHECK, status: EvidenceStatus.PASS, 72 + iu_id: 'other-iu', canon_ids: [], timestamp: new Date().toISOString(), 73 + }]; 74 + const result = evaluatePolicy(iu, evidence); 75 + expect(result.verdict).toBe('INCOMPLETE'); 76 + }); 77 + });
+69
tests/unit/regen.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { generateIU, generateAll } from '../../src/regen.js'; 3 + import { planIUs } from '../../src/iu-planner.js'; 4 + import { parseSpec } from '../../src/spec-parser.js'; 5 + import { extractCanonicalNodes } from '../../src/canonicalizer.js'; 6 + 7 + describe('generateIU', () => { 8 + function makeIU() { 9 + const clauses = parseSpec('# Auth\n\nUsers must authenticate with email.', 'test.md'); 10 + const canon = extractCanonicalNodes(clauses); 11 + return planIUs(canon, clauses)[0]; 12 + } 13 + 14 + it('generates files for an IU', () => { 15 + const iu = makeIU(); 16 + const result = generateIU(iu); 17 + expect(result.files.size).toBeGreaterThan(0); 18 + }); 19 + 20 + it('generated code contains IU metadata', () => { 21 + const iu = makeIU(); 22 + const result = generateIU(iu); 23 + const content = [...result.files.values()][0]; 24 + expect(content).toContain('AUTO-GENERATED by Phoenix VCS'); 25 + expect(content).toContain(iu.iu_id); 26 + expect(content).toContain(iu.risk_tier); 27 + }); 28 + 29 + it('generated code contains a function stub', () => { 30 + const iu = makeIU(); 31 + const result = generateIU(iu); 32 + const content = [...result.files.values()][0]; 33 + expect(content).toContain('export function'); 34 + expect(content).toContain('throw new Error'); 35 + }); 36 + 37 + it('produces a manifest with correct file hashes', () => { 38 + const iu = makeIU(); 39 + const result = generateIU(iu); 40 + expect(result.manifest.iu_id).toBe(iu.iu_id); 41 + 42 + for (const [path, entry] of Object.entries(result.manifest.files)) { 43 + expect(entry.content_hash).toHaveLength(64); 44 + expect(entry.size).toBeGreaterThan(0); 45 + expect(result.files.has(path)).toBe(true); 46 + } 47 + }); 48 + 49 + it('records regen metadata', () => { 50 + const iu = makeIU(); 51 + const result = generateIU(iu); 52 + const meta = result.manifest.regen_metadata; 53 + expect(meta.model_id).toBeTruthy(); 54 + expect(meta.toolchain_version).toBeTruthy(); 55 + expect(meta.promptpack_hash).toHaveLength(64); 56 + expect(meta.generated_at).toBeTruthy(); 57 + }); 58 + }); 59 + 60 + describe('generateAll', () => { 61 + it('generates for multiple IUs', () => { 62 + const spec = `# Auth\n\nUsers must authenticate.\n\n# Billing\n\nPayments must be processed.`; 63 + const clauses = parseSpec(spec, 'test.md'); 64 + const canon = extractCanonicalNodes(clauses); 65 + const ius = planIUs(canon, clauses); 66 + const results = generateAll(ius); 67 + expect(results.length).toBe(ius.length); 68 + }); 69 + });
+62
tests/unit/semhash.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { sha256, clauseSemhash, contextSemhashCold, clauseId } from '../../src/semhash.js'; 3 + 4 + describe('sha256', () => { 5 + it('produces a 64-char hex string', () => { 6 + const hash = sha256('hello'); 7 + expect(hash).toHaveLength(64); 8 + expect(hash).toMatch(/^[0-9a-f]+$/); 9 + }); 10 + 11 + it('is deterministic', () => { 12 + expect(sha256('test')).toBe(sha256('test')); 13 + }); 14 + 15 + it('differs for different inputs', () => { 16 + expect(sha256('a')).not.toBe(sha256('b')); 17 + }); 18 + }); 19 + 20 + describe('clauseSemhash', () => { 21 + it('returns sha256 of the normalized text', () => { 22 + const text = 'phoenix is a vcs'; 23 + expect(clauseSemhash(text)).toBe(sha256(text)); 24 + }); 25 + }); 26 + 27 + describe('contextSemhashCold', () => { 28 + it('includes section path in hash', () => { 29 + const text = 'same text'; 30 + const h1 = contextSemhashCold(text, ['A', 'B'], '', ''); 31 + const h2 = contextSemhashCold(text, ['A', 'C'], '', ''); 32 + expect(h1).not.toBe(h2); 33 + }); 34 + 35 + it('includes neighbor hashes', () => { 36 + const text = 'same text'; 37 + const path = ['Section']; 38 + const h1 = contextSemhashCold(text, path, 'prev1', 'next1'); 39 + const h2 = contextSemhashCold(text, path, 'prev2', 'next1'); 40 + expect(h1).not.toBe(h2); 41 + }); 42 + 43 + it('is deterministic', () => { 44 + const h1 = contextSemhashCold('text', ['A'], 'p', 'n'); 45 + const h2 = contextSemhashCold('text', ['A'], 'p', 'n'); 46 + expect(h1).toBe(h2); 47 + }); 48 + }); 49 + 50 + describe('clauseId', () => { 51 + it('includes doc ID in hash', () => { 52 + const id1 = clauseId('doc1', ['A'], 'text'); 53 + const id2 = clauseId('doc2', ['A'], 'text'); 54 + expect(id1).not.toBe(id2); 55 + }); 56 + 57 + it('includes section path in hash', () => { 58 + const id1 = clauseId('doc', ['A'], 'text'); 59 + const id2 = clauseId('doc', ['B'], 'text'); 60 + expect(id1).not.toBe(id2); 61 + }); 62 + });
+85
tests/unit/shadow-pipeline.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { computeShadowDiff, classifyShadowDiff, runShadowPipeline } from '../../src/shadow-pipeline.js'; 3 + import { UpgradeClassification } from '../../src/models/pipeline.js'; 4 + import type { CanonicalNode } from '../../src/models/canonical.js'; 5 + import { CanonicalType } from '../../src/models/canonical.js'; 6 + 7 + function makeNode(id: string, stmt: string, links: string[] = [], type = CanonicalType.REQUIREMENT): CanonicalNode { 8 + return { canon_id: id, type, statement: stmt, source_clause_ids: ['clause1'], linked_canon_ids: links, tags: [] }; 9 + } 10 + 11 + describe('computeShadowDiff', () => { 12 + it('reports zero change for identical nodes', () => { 13 + const nodes = [makeNode('a', 'stmt a'), makeNode('b', 'stmt b')]; 14 + const metrics = computeShadowDiff(nodes, nodes); 15 + expect(metrics.node_change_pct).toBe(0); 16 + expect(metrics.orphan_nodes).toBe(0); 17 + }); 18 + 19 + it('detects added and removed nodes', () => { 20 + const old = [makeNode('a', 'stmt a'), makeNode('b', 'stmt b')]; 21 + const newN = [makeNode('a', 'stmt a'), makeNode('c', 'stmt c')]; 22 + const metrics = computeShadowDiff(old, newN); 23 + expect(metrics.node_change_pct).toBeGreaterThan(0); 24 + }); 25 + 26 + it('detects orphan nodes', () => { 27 + const old = [makeNode('a', 'stmt a')]; 28 + const newN = [makeNode('a', 'stmt a'), { ...makeNode('b', 'stmt b'), source_clause_ids: [] as string[] }]; 29 + const metrics = computeShadowDiff(old, newN); 30 + expect(metrics.orphan_nodes).toBe(1); 31 + }); 32 + 33 + it('detects risk escalations (type changes)', () => { 34 + const old = [makeNode('a', 'shared stmt', [], CanonicalType.REQUIREMENT)]; 35 + const newN = [makeNode('b', 'shared stmt', [], CanonicalType.CONSTRAINT)]; 36 + const metrics = computeShadowDiff(old, newN); 37 + expect(metrics.risk_escalations).toBe(1); 38 + }); 39 + }); 40 + 41 + describe('classifyShadowDiff', () => { 42 + it('classifies SAFE for small changes', () => { 43 + const { classification } = classifyShadowDiff({ 44 + node_change_pct: 2, edge_change_pct: 1, risk_escalations: 0, 45 + orphan_nodes: 0, out_of_scope_growth: 0, semantic_stmt_drift: 5, 46 + }); 47 + expect(classification).toBe(UpgradeClassification.SAFE); 48 + }); 49 + 50 + it('classifies COMPACTION_EVENT for moderate changes', () => { 51 + const { classification } = classifyShadowDiff({ 52 + node_change_pct: 15, edge_change_pct: 10, risk_escalations: 1, 53 + orphan_nodes: 0, out_of_scope_growth: 2, semantic_stmt_drift: 20, 54 + }); 55 + expect(classification).toBe(UpgradeClassification.COMPACTION_EVENT); 56 + }); 57 + 58 + it('classifies REJECT for orphan nodes', () => { 59 + const { classification } = classifyShadowDiff({ 60 + node_change_pct: 1, edge_change_pct: 0, risk_escalations: 0, 61 + orphan_nodes: 1, out_of_scope_growth: 0, semantic_stmt_drift: 0, 62 + }); 63 + expect(classification).toBe(UpgradeClassification.REJECT); 64 + }); 65 + 66 + it('classifies REJECT for high semantic drift', () => { 67 + const { classification } = classifyShadowDiff({ 68 + node_change_pct: 5, edge_change_pct: 5, risk_escalations: 0, 69 + orphan_nodes: 0, out_of_scope_growth: 0, semantic_stmt_drift: 60, 70 + }); 71 + expect(classification).toBe(UpgradeClassification.REJECT); 72 + }); 73 + }); 74 + 75 + describe('runShadowPipeline', () => { 76 + it('produces a full ShadowResult', () => { 77 + const oldP = { pipeline_id: 'v1', model_id: 'm1', promptpack_version: 'p1', extraction_rules_version: 'r1', diff_policy_version: 'd1' }; 78 + const newP = { pipeline_id: 'v2', model_id: 'm2', promptpack_version: 'p2', extraction_rules_version: 'r2', diff_policy_version: 'd2' }; 79 + const nodes = [makeNode('a', 'stmt')]; 80 + const result = runShadowPipeline(oldP, newP, nodes, nodes); 81 + expect(result.classification).toBe(UpgradeClassification.SAFE); 82 + expect(result.old_pipeline.pipeline_id).toBe('v1'); 83 + expect(result.new_pipeline.pipeline_id).toBe('v2'); 84 + }); 85 + });
+152
tests/unit/spec-parser.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { parseSpec } from '../../src/spec-parser.js'; 3 + 4 + describe('parseSpec', () => { 5 + it('returns empty array for empty content', () => { 6 + expect(parseSpec('', 'test.md')).toEqual([]); 7 + }); 8 + 9 + it('returns empty array for whitespace-only content', () => { 10 + expect(parseSpec(' \n \n ', 'test.md')).toEqual([]); 11 + }); 12 + 13 + it('treats headingless content as single clause', () => { 14 + const content = 'Just some text\nwith multiple lines'; 15 + const clauses = parseSpec(content, 'test.md'); 16 + expect(clauses).toHaveLength(1); 17 + expect(clauses[0].section_path).toEqual([]); 18 + expect(clauses[0].source_doc_id).toBe('test.md'); 19 + }); 20 + 21 + it('splits on headings', () => { 22 + const content = `# First 23 + 24 + Content one. 25 + 26 + # Second 27 + 28 + Content two.`; 29 + const clauses = parseSpec(content, 'test.md'); 30 + expect(clauses).toHaveLength(2); 31 + expect(clauses[0].section_path).toEqual(['First']); 32 + expect(clauses[1].section_path).toEqual(['Second']); 33 + }); 34 + 35 + it('tracks nested section paths', () => { 36 + const content = `# Parent 37 + 38 + ## Child A 39 + 40 + Text A 41 + 42 + ## Child B 43 + 44 + Text B`; 45 + const clauses = parseSpec(content, 'test.md'); 46 + expect(clauses).toHaveLength(3); 47 + expect(clauses[0].section_path).toEqual(['Parent']); 48 + expect(clauses[1].section_path).toEqual(['Parent', 'Child A']); 49 + expect(clauses[2].section_path).toEqual(['Parent', 'Child B']); 50 + }); 51 + 52 + it('computes correct line ranges', () => { 53 + const content = `# First 54 + 55 + Line 2 56 + 57 + # Second 58 + 59 + Line 6`; 60 + const clauses = parseSpec(content, 'test.md'); 61 + expect(clauses[0].source_line_range[0]).toBe(1); 62 + expect(clauses[1].source_line_range[0]).toBe(5); 63 + }); 64 + 65 + it('computes clause_semhash deterministically', () => { 66 + const content = '# Test\n\nSome content here.'; 67 + const c1 = parseSpec(content, 'doc')[0]; 68 + const c2 = parseSpec(content, 'doc')[0]; 69 + expect(c1.clause_semhash).toBe(c2.clause_semhash); 70 + }); 71 + 72 + it('computes unique clause IDs', () => { 73 + const content = `# A 74 + 75 + Text A 76 + 77 + # B 78 + 79 + Text B`; 80 + const clauses = parseSpec(content, 'test.md'); 81 + expect(clauses[0].clause_id).not.toBe(clauses[1].clause_id); 82 + }); 83 + 84 + it('context hash differs based on neighbors', () => { 85 + const content = `# A 86 + 87 + Text A 88 + 89 + # B 90 + 91 + Text B 92 + 93 + # C 94 + 95 + Text C`; 96 + const clauses = parseSpec(content, 'test.md'); 97 + // B has neighbors A and C; A only has neighbor B 98 + expect(clauses[0].context_semhash_cold).not.toBe(clauses[1].context_semhash_cold); 99 + }); 100 + 101 + it('handles deeply nested headings', () => { 102 + const content = `# L1 103 + 104 + ## L2 105 + 106 + ### L3 107 + 108 + #### L4 109 + 110 + Content`; 111 + const clauses = parseSpec(content, 'test.md'); 112 + expect(clauses).toHaveLength(4); 113 + expect(clauses[3].section_path).toEqual(['L1', 'L2', 'L3', 'L4']); 114 + }); 115 + 116 + it('resets section path on same-level heading', () => { 117 + const content = `# A 118 + 119 + ## B 120 + 121 + # C 122 + 123 + ## D`; 124 + const clauses = parseSpec(content, 'test.md'); 125 + expect(clauses[2].section_path).toEqual(['C']); 126 + expect(clauses[3].section_path).toEqual(['C', 'D']); 127 + }); 128 + 129 + it('captures pre-heading content as preamble', () => { 130 + const content = `This is a preamble before any heading. 131 + 132 + # First Section 133 + 134 + Content here.`; 135 + const clauses = parseSpec(content, 'test.md'); 136 + expect(clauses.length).toBe(2); 137 + expect(clauses[0].section_path).toEqual(['(preamble)']); 138 + expect(clauses[0].normalized_text).toContain('preamble'); 139 + expect(clauses[1].section_path).toEqual(['First Section']); 140 + }); 141 + 142 + it('skips empty pre-heading whitespace', () => { 143 + const content = ` 144 + 145 + # First Section 146 + 147 + Content here.`; 148 + const clauses = parseSpec(content, 'test.md'); 149 + expect(clauses.length).toBe(1); 150 + expect(clauses[0].section_path).toEqual(['First Section']); 151 + }); 152 + });
+60
tests/unit/warm-hasher.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { contextSemhashWarm, computeWarmHashes } from '../../src/warm-hasher.js'; 3 + import { parseSpec } from '../../src/spec-parser.js'; 4 + import { extractCanonicalNodes } from '../../src/canonicalizer.js'; 5 + 6 + describe('contextSemhashWarm', () => { 7 + it('produces a 64-char hex hash', () => { 8 + const clauses = parseSpec('# Auth\n\nUsers must log in.', 'test.md'); 9 + const canonNodes = extractCanonicalNodes(clauses); 10 + const hash = contextSemhashWarm(clauses[0], canonNodes); 11 + expect(hash).toHaveLength(64); 12 + expect(hash).toMatch(/^[0-9a-f]+$/); 13 + }); 14 + 15 + it('differs from cold context hash', () => { 16 + const clauses = parseSpec('# Auth\n\nUsers must log in.', 'test.md'); 17 + const canonNodes = extractCanonicalNodes(clauses); 18 + const warmHash = contextSemhashWarm(clauses[0], canonNodes); 19 + expect(warmHash).not.toBe(clauses[0].context_semhash_cold); 20 + }); 21 + 22 + it('changes when canonical links change', () => { 23 + const spec1 = '# Auth\n\nUsers must log in.'; 24 + const spec2 = '# Auth\n\nUsers must log in.\n\n# Security\n\nLogin must use HTTPS.'; 25 + 26 + const clauses1 = parseSpec(spec1, 'test.md'); 27 + const canonNodes1 = extractCanonicalNodes(clauses1); 28 + const warm1 = contextSemhashWarm(clauses1[0], canonNodes1); 29 + 30 + const clauses2 = parseSpec(spec2, 'test.md'); 31 + const canonNodes2 = extractCanonicalNodes(clauses2); 32 + const warm2 = contextSemhashWarm(clauses2[0], canonNodes2); 33 + 34 + // Adding related canonical nodes should change the warm hash 35 + // (only if the new nodes link back to auth clause) 36 + // They may or may not link depending on term overlap 37 + expect(warm1).toBeTruthy(); 38 + expect(warm2).toBeTruthy(); 39 + }); 40 + 41 + it('is deterministic', () => { 42 + const clauses = parseSpec('# Auth\n\nUsers must authenticate with email.', 'test.md'); 43 + const canonNodes = extractCanonicalNodes(clauses); 44 + const h1 = contextSemhashWarm(clauses[0], canonNodes); 45 + const h2 = contextSemhashWarm(clauses[0], canonNodes); 46 + expect(h1).toBe(h2); 47 + }); 48 + }); 49 + 50 + describe('computeWarmHashes', () => { 51 + it('returns a map with entries for all clauses', () => { 52 + const clauses = parseSpec('# A\n\nUsers must do X.\n\n# B\n\nSessions must expire.', 'test.md'); 53 + const canonNodes = extractCanonicalNodes(clauses); 54 + const hashMap = computeWarmHashes(clauses, canonNodes); 55 + expect(hashMap.size).toBe(clauses.length); 56 + for (const clause of clauses) { 57 + expect(hashMap.has(clause.clause_id)).toBe(true); 58 + } 59 + }); 60 + });
+18
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 + "declaration": true, 7 + "outDir": "dist", 8 + "rootDir": "src", 9 + "strict": true, 10 + "esModuleInterop": true, 11 + "skipLibCheck": true, 12 + "forceConsistentCasingInFileNames": true, 13 + "resolveJsonModule": true, 14 + "sourceMap": true 15 + }, 16 + "include": ["src/**/*"], 17 + "exclude": ["node_modules", "dist", "tests"] 18 + }
+8
vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config'; 2 + 3 + export default defineConfig({ 4 + test: { 5 + globals: true, 6 + include: ['tests/**/*.test.ts'], 7 + }, 8 + });