@ctxo/cli 0.7.2 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -262
- package/dist/atomic-write-AQNYJFAG.js +10 -0
- package/dist/{chunk-WXA2JJLH.js → chunk-2TVHKCCU.js} +3 -2
- package/dist/chunk-2TVHKCCU.js.map +1 -0
- package/dist/chunk-6EKERTJ7.js +80 -0
- package/dist/chunk-6EKERTJ7.js.map +1 -0
- package/dist/{chunk-4M4V5V36.js → chunk-HXEPS2XY.js} +466 -78
- package/dist/chunk-HXEPS2XY.js.map +1 -0
- package/dist/{cli-router-T3VZZJD6.js → cli-router-HEZER4G7.js} +892 -201
- package/dist/cli-router-HEZER4G7.js.map +1 -0
- package/dist/index.js +310 -55
- package/dist/index.js.map +1 -1
- package/dist/json-index-reader-XOEE6VMD.js +8 -0
- package/dist/json-index-reader-XOEE6VMD.js.map +1 -0
- package/package.json +4 -2
- package/pages/visualize-template.html +285 -19
- package/dist/chunk-4M4V5V36.js.map +0 -1
- package/dist/chunk-WXA2JJLH.js.map +0 -1
- package/dist/cli-router-T3VZZJD6.js.map +0 -1
- package/dist/json-index-reader-LME4EDA5.js +0 -8
- /package/dist/{json-index-reader-LME4EDA5.js.map → atomic-write-AQNYJFAG.js.map} +0 -0
package/README.md
CHANGED
|
@@ -10,296 +10,59 @@
|
|
|
10
10
|
<img alt="Ctxo — Code intelligence for AI agents" src="docs/img/hero-svg.svg" width="100%">
|
|
11
11
|
</picture>
|
|
12
12
|
|
|
13
|
-
</div>
|
|
14
|
-
|
|
15
|
-
***
|
|
16
|
-
|
|
17
|
-
### The Problem
|
|
18
|
-
|
|
19
|
-
AI coding assistants like Copilot, Claude Code, and Cursor rely on generic tools `grep`, `find`, file reads to understand your codebase. On brownfield projects with thousands of files, this brute-force exploration creates a chain of problems:
|
|
20
|
-
|
|
21
|
-
* **Context window saturation** The agent fills its window reading files one by one, leaving little room for actual reasoning
|
|
22
|
-
* **Partial context hallucination** It sees a function but misses its dependencies, leading to wrong assumptions and broken suggestions
|
|
23
|
-
* **Lost-in-the-middle** Critical information buried deep in a long context gets ignored by the model
|
|
24
|
-
* **Context poisoning** Irrelevant code pulled in during exploration biases the model's output
|
|
25
|
-
* **Iteration overhead** Understanding one symbol takes 10-20 tool calls, each adding more noise to the context
|
|
26
|
-
* **Stale reasoning** After too many iterations, the agent contradicts its own earlier assumptions
|
|
27
|
-
|
|
28
|
-
The result: more tokens burned, slower responses, higher cost, and lower quality output.
|
|
29
|
-
|
|
30
|
-
### The Solution
|
|
31
|
-
|
|
32
|
-
Ctxo is an **MCP server** that **enhances** your existing AI tools with dependency-aware, history-enriched code intelligence. Instead of hundreds of `grep` and `read_file` calls, your agent gets the full picture symbol graphs, blast radius, git intent, and risk scores in a **single MCP call**.
|
|
33
|
-
|
|
34
|
-
* **Clean context** Only relevant symbols and their transitive dependencies, nothing more
|
|
35
|
-
* **Fewer iterations** One call replaces an entire exploration cycle
|
|
36
|
-
* **Higher quality** The agent reasons over structured, complete context instead of fragmented file reads
|
|
37
|
-
|
|
38
|
-
> A senior developer takes \~10 minutes to gather context across files. Ctxo delivers that same context in **under 500ms**.
|
|
39
|
-
|
|
40
|
-
## Quick Start
|
|
41
|
-
|
|
42
|
-
Install the CLI, then run the setup wizard:
|
|
43
|
-
|
|
44
|
-
```Shell
|
|
45
|
-
npm i -g @ctxo/cli
|
|
46
|
-
ctxo init
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
That's it. The interactive wizard detects your AI tools, registers the ctxo MCP server in the correct config file (`.mcp.json`, `.vscode/mcp.json`, etc.), generates usage rules so your assistant knows when to call each tool, and prompts to install language plugins it detects in your project (pass `--no-install` to skip).
|
|
50
|
-
|
|
51
|
-
### Language Plugins
|
|
52
|
-
|
|
53
|
-
As of v0.7, Ctxo discovers language support from npm packages listed in your project's `package.json`. Three official plugins ship today:
|
|
54
|
-
|
|
55
13
|
```Shell
|
|
56
|
-
|
|
14
|
+
npx @ctxo/init
|
|
15
|
+
npx @ctxo/cli index
|
|
57
16
|
```
|
|
58
17
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
<summary>Manual MCP config (if not using <code>ctxo init</code>)</summary>
|
|
63
|
-
|
|
64
|
-
**Claude Code / Cursor / Windsurf / Augment / Antigravity** — `.mcp.json`:
|
|
65
|
-
|
|
66
|
-
```JSON
|
|
67
|
-
{ "mcpServers": { "ctxo": { "command": "npx", "args": ["-y", "@ctxo/cli"] } } }
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
**VS Code (Copilot)** — `.vscode/mcp.json`:
|
|
71
|
-
|
|
72
|
-
```JSON
|
|
73
|
-
{ "servers": { "ctxo": { "type": "stdio", "command": "npx", "args": ["-y", "@ctxo/cli"] } } }
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
**Amazon Q** — `.amazonq/mcp.json`:
|
|
77
|
-
|
|
78
|
-
```JSON
|
|
79
|
-
{ "mcpServers": { "ctxo": { "command": "npx", "args": ["-y", "@ctxo/cli"] } } }
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
**Zed** — `settings.json`:
|
|
83
|
-
|
|
84
|
-
```JSON
|
|
85
|
-
{ "context_servers": { "ctxo": { "command": { "path": "npx", "args": ["-y", "@ctxo/cli"] } } } }
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
</details>
|
|
89
|
-
|
|
90
|
-
## 14 Tools
|
|
91
|
-
|
|
92
|
-
| Tool | What it does |
|
|
93
|
-
| --------------------------- | ---------------------------------------------------------------------------------------- |
|
|
94
|
-
| `get_logic_slice` | Symbol + transitive dependencies (L1-L4 progressive detail) |
|
|
95
|
-
| `get_blast_radius` | What breaks if this changes (3-tier: confirmed/likely/potential) |
|
|
96
|
-
| `get_architectural_overlay` | Project layer map (Domain/Infrastructure/Adapter) |
|
|
97
|
-
| `get_why_context` | Git commit intent + anti-pattern warnings (reverts, rollbacks) |
|
|
98
|
-
| `get_change_intelligence` | Complexity x churn composite score |
|
|
99
|
-
| `find_dead_code` | Unreachable symbols, unused exports, scaffolding markers |
|
|
100
|
-
| `get_context_for_task` | Task-optimized context (fix/extend/refactor/understand) |
|
|
101
|
-
| `get_ranked_context` | Two-phase BM25 search (camelCase-aware, fuzzy correction) + PageRank within token budget |
|
|
102
|
-
| `search_symbols` | Symbol name/regex search across index (`mode: 'fts'` for BM25) |
|
|
103
|
-
| `get_changed_symbols` | Symbols in recently changed files (git diff) |
|
|
104
|
-
| `find_importers` | Reverse dependency lookup ("who uses this?") |
|
|
105
|
-
| `get_class_hierarchy` | Class inheritance tree (ancestors + descendants) |
|
|
106
|
-
| `get_symbol_importance` | PageRank centrality ranking |
|
|
107
|
-
| `get_pr_impact` | Full PR risk assessment in a single call |
|
|
108
|
-
|
|
109
|
-
## Tool Selection Guide
|
|
110
|
-
|
|
111
|
-
```
|
|
112
|
-
Reviewing a PR? → get_pr_impact
|
|
113
|
-
About to modify code? → get_blast_radius → get_why_context
|
|
114
|
-
Understanding a symbol? → get_context_for_task(taskType: "understand")
|
|
115
|
-
Fixing a bug? → get_context_for_task(taskType: "fix")
|
|
116
|
-
Refactoring? → get_context_for_task(taskType: "refactor")
|
|
117
|
-
Don't know the name? → search_symbols or get_ranked_context
|
|
118
|
-
Finding unused code? → find_dead_code
|
|
119
|
-
Safe to delete? → find_importers
|
|
120
|
-
Onboarding? → get_architectural_overlay → get_symbol_importance
|
|
121
|
-
```
|
|
18
|
+
<a href="https://alperhankendi.github.io/Ctxo/docs/">
|
|
19
|
+
<img src="https://img.shields.io/badge/Get%20Started-%E2%86%92-06b6d4?style=for-the-badge&labelColor=0f172a" alt="Get Started →">
|
|
20
|
+
</a>
|
|
122
21
|
|
|
123
|
-
|
|
22
|
+
<sub>Detects your languages, installs the right plugins, wires Ctxo into your AI client, installs git hooks, and builds the first index one command.</sub>
|
|
124
23
|
|
|
125
|
-
|
|
126
|
-
# Setup
|
|
127
|
-
ctxo init # Interactive setup (detects languages, prompts to install plugins, writes AI tool rules, git hooks)
|
|
128
|
-
ctxo init --tools claude-code,cursor -y # Non-interactive setup
|
|
129
|
-
ctxo init --no-install # Skip plugin install prompt
|
|
130
|
-
ctxo init --rules # Regenerate AI tool rules only
|
|
131
|
-
ctxo init --dry-run # Preview what would be created
|
|
132
|
-
|
|
133
|
-
# Plugins (v0.7+)
|
|
134
|
-
ctxo install # Install plugins for all detected languages
|
|
135
|
-
ctxo install typescript go csharp # Install specific plugins
|
|
136
|
-
ctxo install typescript --yes --pm pnpm # CI-friendly, pick package manager
|
|
137
|
-
ctxo install typescript --global --version 0.7.0 # Pin version, install globally
|
|
138
|
-
|
|
139
|
-
# Indexing
|
|
140
|
-
ctxo index # Build full codebase index
|
|
141
|
-
ctxo index --install-missing # Auto-install any missing plugins first
|
|
142
|
-
ctxo index --check # CI gate: fail if index stale
|
|
143
|
-
ctxo index --skip-history # Fast re-index without git history
|
|
144
|
-
ctxo watch # File watcher for incremental re-index
|
|
145
|
-
ctxo sync # Rebuild SQLite cache from committed JSON
|
|
146
|
-
|
|
147
|
-
# Diagnostics
|
|
148
|
-
ctxo status # Show index manifest
|
|
149
|
-
ctxo doctor # Health check all subsystems (--json, --quiet)
|
|
150
|
-
ctxo doctor --fix # Dependency-ordered auto-remediation (--dry-run, --yes)
|
|
151
|
-
ctxo stats # Show usage statistics (--json, --days N, --clear)
|
|
152
|
-
ctxo --version # Print CLI version (--verbose, --json)
|
|
153
|
-
ctxo version # Verbose version subcommand (CLI + plugins + runtime)
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
**Example output:**
|
|
157
|
-
|
|
158
|
-
```
|
|
159
|
-
ctxo stats
|
|
160
|
-
|
|
161
|
-
Usage Summary (all time)
|
|
162
|
-
────────────────────────────────────────
|
|
163
|
-
Total tool calls: 30
|
|
164
|
-
Total tokens served: 26.0K
|
|
165
|
-
|
|
166
|
-
Top Tools
|
|
167
|
-
────────────────────────────────────────
|
|
168
|
-
get_logic_slice 14 calls avg 352 tokens
|
|
169
|
-
get_blast_radius 3 calls avg 1,279 tokens
|
|
170
|
-
get_ranked_context 3 calls avg 898 tokens
|
|
171
|
-
find_importers 2 calls avg 1,342 tokens
|
|
172
|
-
get_context_for_task 2 calls avg 740 tokens
|
|
173
|
-
|
|
174
|
-
Top Queried Symbols
|
|
175
|
-
────────────────────────────────────────
|
|
176
|
-
SymbolNode 15 queries
|
|
177
|
-
LogicSliceQuery 4 queries
|
|
178
|
-
SqliteStorageAdapter 2 queries
|
|
179
|
-
|
|
180
|
-
Detail Level Distribution
|
|
181
|
-
────────────────────────────────────────
|
|
182
|
-
L1: ███░░░░░░░ 25%
|
|
183
|
-
L2: ███░░░░░░░ 25%
|
|
184
|
-
L3: ███░░░░░░░ 25%
|
|
185
|
-
L4: ███░░░░░░░ 25%
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
## CI Integration
|
|
189
|
-
|
|
190
|
-
Keep the committed `.ctxo/index/` in sync with the source tree by failing PRs when the index is stale. Drop-in examples:
|
|
191
|
-
|
|
192
|
-
- GitHub Actions: [examples/github-actions/ctxo-check.yml](examples/github-actions/ctxo-check.yml)
|
|
193
|
-
- GitLab CI: [examples/gitlab-ci/.gitlab-ci.yml](examples/gitlab-ci/.gitlab-ci.yml)
|
|
194
|
-
|
|
195
|
-
Both run `ctxo index --check` on every PR/MR. If the index is stale, the job fails and the author runs `ctxo index` locally, then commits the refreshed index. Combined with the `post-commit` hook from `ctxo init`, this keeps the index current without any webhook infrastructure.
|
|
196
|
-
|
|
197
|
-
## Features
|
|
198
|
-
|
|
199
|
-
**Response Envelope** All responses include `_meta` with item counts, truncation info, and drill-in hints. Large results auto-truncated at 8KB (configurable via `CTXO_RESPONSE_LIMIT`).
|
|
200
|
-
|
|
201
|
-
**Intent Filtering** 4 tools accept `intent` parameter for keyword-based result filtering. `get_blast_radius(symbolId, intent: "test")` returns only test-related impacts.
|
|
202
|
-
|
|
203
|
-
**Tool Annotations** All tools declare `readOnlyHint: true`, `idempotentHint: true`, `openWorldHint: false` for safe auto-approval in agent frameworks.
|
|
204
|
-
|
|
205
|
-
**Privacy Masking** AWS keys, GCP service accounts, Azure connection strings, JWTs, private IPs, env secrets automatically redacted. Extensible via `.ctxo/masking.json`.
|
|
206
|
-
|
|
207
|
-
**Interactive Graph** `ctxo visualize` generates a self-contained HTML with vis.js dependency graph. ForceAtlas2 layout, PageRank-based sizing, layer coloring, blast radius on click, git history per symbol, dead code and anti-pattern filters, dark/light theme.
|
|
208
|
-
|
|
209
|
-
**Debug Mode** `DEBUG=ctxo:*` for all debug output, or `DEBUG=ctxo:git,ctxo:storage` for specific namespaces.
|
|
210
|
-
|
|
211
|
-
**Per-tool savings vs manual approach:**
|
|
212
|
-
|
|
213
|
-
```
|
|
214
|
-
Manual Tokens Ctxo Tokens Savings
|
|
215
|
-
get_logic_slice ████████ 1,950 █ 150 92%
|
|
216
|
-
get_blast_radius ███ 800 ██ 600 25%
|
|
217
|
-
get_overlay ████████████ 25K██ 500 98%
|
|
218
|
-
get_why_context █ 200 █ 200 0%
|
|
219
|
-
get_change_intelligence ████████ 2,100 ▏ 50 98%
|
|
220
|
-
find_dead_code █████████ 5,000 ████ 2,000 60%
|
|
221
|
-
────────────────────────────────────────────────────────────
|
|
222
|
-
TOTAL 35,050 tokens 3,500 tokens 90%
|
|
223
|
-
329+ calls 6 calls 98%
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
## Agentic AI Usage
|
|
227
|
-
|
|
228
|
-
**Claude Agent SDK:**
|
|
229
|
-
|
|
230
|
-
```TypeScript
|
|
231
|
-
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
232
|
-
|
|
233
|
-
for await (const message of query({
|
|
234
|
-
prompt: "Analyze the blast radius of AuthService",
|
|
235
|
-
options: {
|
|
236
|
-
mcpServers: { ctxo: { command: "npx", args: ["-y", "@ctxo/cli"] } },
|
|
237
|
-
allowedTools: ["mcp__ctxo__*"]
|
|
238
|
-
}
|
|
239
|
-
})) { /* ... */ }
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
**OpenAI Agents SDK:**
|
|
24
|
+
</div>
|
|
243
25
|
|
|
244
|
-
|
|
245
|
-
from agents import Agent, Runner
|
|
246
|
-
from agents.mcp import MCPServerStdio
|
|
26
|
+
***
|
|
247
27
|
|
|
248
|
-
|
|
249
|
-
agent = Agent(name="Reviewer", mcp_servers=[ctxo])
|
|
250
|
-
result = await Runner.run(agent, "Review the PR impact")
|
|
251
|
-
```
|
|
28
|
+
## The Problem: agents code blind
|
|
252
29
|
|
|
253
|
-
|
|
30
|
+
Drop a modern coding agent into a real repo. It ripgreps a symbol, gets 47 hits, reads five files to find the definition, five more for callers **misses the subclass entirely** (inheritance doesn't show up in text search), **never checks git history** (and confidently reintroduces a bug that was reverted three weeks ago), then runs out of context and starts hallucinating.
|
|
254
31
|
|
|
255
|
-
|
|
32
|
+
It's not a skill gap. It's a **sensory gap**. The agent is navigating your codebase with its eyes closed and a phone book.
|
|
256
33
|
|
|
257
|
-
|
|
34
|
+
## The Solution: proactive, not reactive
|
|
258
35
|
|
|
259
|
-
|
|
260
|
-
| --------------------- | ----------------------- | ---------------------------- | ------ | -------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
261
|
-
| TypeScript/JavaScript | `@ctxo/lang-typescript` | ts-morph | Full | bundled | Type-aware resolution, cross-file imports, `this.method()` calls, complexity metrics |
|
|
262
|
-
| C# | `@ctxo/lang-csharp` | `ctxo-roslyn` (Roslyn) | Full | .NET SDK ≥ 8 on PATH | Symbols + `calls` / `uses` / `implements` / `extends`, cross-project resolution, complexity, tree-sitter fallback |
|
|
263
|
-
| Go | `@ctxo/lang-go` | `ctxo-go-analyzer` | Full | Go ≥ 1.22 on PATH | Symbols + `calls` / `uses` / `implements` / `extends`, reflect-safe dead-code, generics, tree-sitter fallback |
|
|
264
|
-
| All other languages | `@ctxo/lang-*` / `ctxo-lang-*` (plugin protocol) | tree-sitter (community adapter) | Syntax | bundled per plugin | Symbol inventory (functions, classes, types, methods), import edges, cyclomatic complexity, unexported symbols |
|
|
36
|
+
The core shift: your agent stops reacting to files it stumbles into and starts planning from a complete map. **Blast radius before the edit. Git intent before the bug fix. Importer list before the rename.**
|
|
265
37
|
|
|
266
|
-
|
|
38
|
+
Ctxo indexes your repo once kept fresh by file watchers and git hooks into a deterministic graph: every symbol, every edge (imports, calls, extends, implements), every relevant git commit with intent classified, every anti-pattern. All exposed through 14 semantic MCP tools.
|
|
267
39
|
|
|
268
|
-
|
|
40
|
+
One `get_blast_radius` call replaces an entire ripgrep/read spiral. One `get_pr_impact` replaces a full review session of "wait, what calls this?"
|
|
269
41
|
|
|
270
|
-
|
|
42
|
+
The agent still writes the code. It just stops writing it **blind** so the bug never has to be caught by the compiler, the tests, CI, or a user.
|
|
271
43
|
|
|
272
|
-
|
|
44
|
+
## Codebase Dashboard
|
|
273
45
|
|
|
274
|
-
|
|
46
|
+
Full-stack analytics UI with eight views: File Tree, Heatmap, Co-Changes, Timeline, Architecture, MCP Explorer, and Diff. Deployed to GitHub Pages on every push.
|
|
275
47
|
|
|
276
|
-
[
|
|
48
|
+

|
|
277
49
|
|
|
278
|
-
|
|
50
|
+
[Open Dashboard](https://alperhankendi.github.io/Ctxo/ctxo-visualizer.html)
|
|
279
51
|
|
|
280
|
-
|
|
52
|
+
## Dependency Graph
|
|
281
53
|
|
|
282
|
-
|
|
54
|
+
`ctxo visualize` generates a self-contained HTML from your `.ctxo/` index. Interactive force-directed graph with PageRank sizing, layer coloring, blast radius on click, and dark/light theme.
|
|
283
55
|
|
|
284
|
-
|
|
56
|
+

|
|
285
57
|
|
|
286
|
-
|
|
287
|
-
.ctxo/
|
|
288
|
-
index/ ← committed (per-file JSON, reviewable in PRs)
|
|
289
|
-
.cache/ ← gitignored (local SQLite, auto-rebuilt)
|
|
290
|
-
config.yaml ← committed (team settings)
|
|
291
|
-
masking.json ← committed (custom masking patterns)
|
|
292
|
-
```
|
|
58
|
+
[Open Dependency Graph](https://alperhankendi.github.io/Ctxo/visualize.html)
|
|
293
59
|
|
|
294
60
|
## Links
|
|
295
61
|
|
|
62
|
+
* [Docs](https://alperhankendi.github.io/Ctxo/docs/) quick start, MCP tools, CLI reference, integrations
|
|
296
63
|
* [npm](https://www.npmjs.com/package/@ctxo/cli)
|
|
297
64
|
* [Changelog](CHANGELOG.md)
|
|
298
65
|
* [LLM Reference](llms-full.txt)
|
|
299
|
-
* [MCP Validation Runbook](docs/runbook/mcp-validation/mcp-validation.md)
|
|
300
|
-
* [CLI Validation Runbook](docs/runbook/cli-validation/cli-validation.md)
|
|
301
|
-
* [Architecture](docs/artifacts/architecture.md)
|
|
302
|
-
* [PRD](docs/artifacts/prd.md)
|
|
303
66
|
|
|
304
67
|
## License
|
|
305
68
|
|
|
@@ -132,8 +132,9 @@ var JsonIndexReader = class {
|
|
|
132
132
|
if (entry.isSymbolicLink()) continue;
|
|
133
133
|
const fullPath = join(dir, entry.name);
|
|
134
134
|
if (entry.isDirectory()) {
|
|
135
|
+
if (entry.name === "communities.history") continue;
|
|
135
136
|
files.push(...this.collectJsonFiles(fullPath, visited));
|
|
136
|
-
} else if (entry.name.endsWith(".json") && entry.name !== "co-changes.json") {
|
|
137
|
+
} else if (entry.name.endsWith(".json") && entry.name !== "co-changes.json" && entry.name !== "communities.json" && entry.name !== "drift-events.json" && entry.name !== "boundary-violations.json") {
|
|
137
138
|
files.push(fullPath);
|
|
138
139
|
}
|
|
139
140
|
}
|
|
@@ -145,4 +146,4 @@ export {
|
|
|
145
146
|
DetailLevelSchema,
|
|
146
147
|
JsonIndexReader
|
|
147
148
|
};
|
|
148
|
-
//# sourceMappingURL=chunk-
|
|
149
|
+
//# sourceMappingURL=chunk-2TVHKCCU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/adapters/storage/json-index-reader.ts","../src/core/types.ts"],"sourcesContent":["import { readFileSync, readdirSync, existsSync, realpathSync } from 'node:fs';\nimport { join, relative } from 'node:path';\nimport { FileIndexSchema, type FileIndex } from '../../core/types.js';\n\nexport class JsonIndexReader {\n private readonly indexDir: string;\n\n constructor(ctxoRoot: string) {\n this.indexDir = join(ctxoRoot, 'index');\n }\n\n readAll(): FileIndex[] {\n if (!existsSync(this.indexDir)) {\n return [];\n }\n\n const jsonFiles = this.collectJsonFiles(this.indexDir);\n const results: FileIndex[] = [];\n\n for (const filePath of jsonFiles) {\n const parsed = this.readSingle(filePath);\n if (parsed) {\n results.push(parsed);\n }\n }\n\n return results;\n }\n\n readSingle(absolutePath: string): FileIndex | undefined {\n try {\n const raw = readFileSync(absolutePath, 'utf-8');\n const data: unknown = JSON.parse(raw);\n const result = FileIndexSchema.safeParse(data);\n\n if (!result.success) {\n const rel = relative(this.indexDir, absolutePath);\n console.error(\n `[ctxo:json-reader] Invalid schema in ${rel}: ${result.error.message}`,\n );\n return undefined;\n }\n\n return result.data;\n } catch (err) {\n const rel = relative(this.indexDir, absolutePath);\n console.error(\n `[ctxo:json-reader] Failed to read ${rel}: ${(err as Error).message}`,\n );\n return undefined;\n }\n }\n\n private collectJsonFiles(dir: string, visited: Set<string> = new Set()): string[] {\n const realDir = realpathSync(dir);\n if (visited.has(realDir)) return []; // Guard against symlink loops\n visited.add(realDir);\n\n const files: string[] = [];\n\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (entry.isSymbolicLink()) continue; // Skip symlinks entirely\n\n const fullPath = join(dir, entry.name);\n if (entry.isDirectory()) {\n // Skip non-FileIndex directories under .ctxo/index/\n if (entry.name === 'communities.history') continue;\n files.push(...this.collectJsonFiles(fullPath, visited));\n } else if (\n entry.name.endsWith('.json') &&\n entry.name !== 'co-changes.json' &&\n entry.name !== 'communities.json' &&\n entry.name !== 'drift-events.json' &&\n entry.name !== 'boundary-violations.json'\n ) {\n files.push(fullPath);\n }\n }\n\n return files;\n }\n}\n","import { z } from 'zod';\n\n// ── Symbol Kinds ────────────────────────────────────────────────\n\nexport const SYMBOL_KINDS = [\n 'function',\n 'class',\n 'interface',\n 'method',\n 'variable',\n 'type',\n] as const;\n\nexport const SymbolKindSchema = z.enum(SYMBOL_KINDS);\nexport type SymbolKind = z.infer<typeof SymbolKindSchema>;\n\n// ── Edge Kinds ──────────────────────────────────────────────────\n\nexport const EDGE_KINDS = [\n 'imports',\n 'calls',\n 'extends',\n 'implements',\n 'uses',\n] as const;\n\nexport const EdgeKindSchema = z.enum(EDGE_KINDS);\nexport type EdgeKind = z.infer<typeof EdgeKindSchema>;\n\n// ── Detail Levels ───────────────────────────────────────────────\n\nexport const DETAIL_LEVELS = [1, 2, 3, 4] as const;\n\nexport const DetailLevelSchema = z.union([\n z.literal(1),\n z.literal(2),\n z.literal(3),\n z.literal(4),\n]);\nexport type DetailLevel = z.infer<typeof DetailLevelSchema>;\n\n// ── Symbol ID ───────────────────────────────────────────────────\n\n/**\n * Format: \"<relativeFile>::<name>::<kind>\"\n * Example: \"src/foo.ts::myFn::function\"\n */\nexport const SymbolIdSchema = z\n .string()\n .min(1)\n .refine(\n (value) => {\n const parts = value.split('::');\n if (parts.length !== 3) return false;\n const [file, name, kind] = parts;\n if (!file || !name || !kind) return false;\n return SymbolKindSchema.safeParse(kind).success;\n },\n { message: 'Symbol ID must match format \"<file>::<name>::<kind>\"' },\n );\nexport type SymbolId = z.infer<typeof SymbolIdSchema>;\n\n// ── Symbol Node ─────────────────────────────────────────────────\n\nexport const SymbolNodeSchema = z\n .object({\n symbolId: SymbolIdSchema,\n name: z.string().min(1),\n kind: SymbolKindSchema,\n startLine: z.number().int().nonnegative(),\n endLine: z.number().int().nonnegative(),\n startOffset: z.number().int().nonnegative().optional(),\n endOffset: z.number().int().nonnegative().optional(),\n })\n .refine((node) => node.endLine >= node.startLine, {\n message: 'endLine must be >= startLine',\n });\nexport type SymbolNode = z.infer<typeof SymbolNodeSchema>;\n\n// ── Graph Edge ──────────────────────────────────────────────────\n\nexport const GraphEdgeSchema = z.object({\n from: SymbolIdSchema,\n to: SymbolIdSchema,\n kind: EdgeKindSchema,\n typeOnly: z.boolean().optional(),\n});\nexport type GraphEdge = z.infer<typeof GraphEdgeSchema>;\n\n// ── Commit Intent ───────────────────────────────────────────────\n\nexport const CommitIntentSchema = z.object({\n hash: z.string().min(1),\n message: z.string(),\n date: z.string().min(1),\n kind: z.literal('commit'),\n});\nexport type CommitIntent = z.infer<typeof CommitIntentSchema>;\n\n// ── Anti-Pattern ────────────────────────────────────────────────\n\nexport const AntiPatternSchema = z.object({\n hash: z.string().min(1),\n message: z.string(),\n date: z.string().min(1),\n});\nexport type AntiPattern = z.infer<typeof AntiPatternSchema>;\n\n// ── File Index ──────────────────────────────────────────────────\n\nexport const ComplexityMetricsSchema = z.object({\n symbolId: z.string().min(1),\n cyclomatic: z.number().nonnegative(),\n});\n\nexport const FileIndexSchema = z.object({\n file: z.string().min(1),\n lastModified: z.number().nonnegative(),\n contentHash: z.string().optional(),\n symbols: z.array(SymbolNodeSchema),\n edges: z.array(GraphEdgeSchema),\n complexity: z.array(ComplexityMetricsSchema).optional(),\n intent: z.array(CommitIntentSchema),\n antiPatterns: z.array(AntiPatternSchema),\n});\nexport type FileIndex = z.infer<typeof FileIndexSchema>;\n\n// ── Logic-Slice Result ──────────────────────────────────────────\n\nexport interface LogicSliceResult {\n readonly root: SymbolNode;\n readonly dependencies: readonly SymbolNode[];\n readonly edges: readonly GraphEdge[];\n}\n\n// ── Formatted Slice ─────────────────────────────────────────────\n\nexport interface TruncationInfo {\n readonly truncated: true;\n readonly reason: 'token_budget_exceeded';\n}\n\nexport interface FormattedSlice {\n readonly root: SymbolNode;\n readonly dependencies: readonly SymbolNode[];\n readonly edges: readonly GraphEdge[];\n readonly level: DetailLevel;\n readonly levelDescription?: string;\n readonly truncation?: TruncationInfo;\n}\n\n// ── Complexity & Churn ──────────────────────────────────────────\n\nexport interface ComplexityMetrics {\n readonly symbolId: string;\n readonly cyclomatic: number;\n}\n\nexport interface ChurnData {\n readonly filePath: string;\n readonly commitCount: number;\n}\n\nexport const SCORE_BANDS = ['low', 'medium', 'high'] as const;\nexport type ScoreBand = (typeof SCORE_BANDS)[number];\n\nexport interface ChangeIntelligenceScore {\n readonly symbolId: string;\n readonly complexity: number;\n readonly churn: number;\n readonly composite: number;\n readonly band: ScoreBand;\n}\n\n// ── Why-Context Result ──────────────────────────────────────────\n\nexport interface WhyContextResult {\n readonly commitHistory: readonly CommitIntent[];\n readonly antiPatternWarnings: readonly AntiPattern[];\n readonly changeIntelligence?: ChangeIntelligenceScore;\n}\n\n// ── Co-Change Analysis ─────────────────────────────────────────\n\nexport interface CoChangeEntry {\n readonly file1: string;\n readonly file2: string;\n readonly sharedCommits: number;\n readonly frequency: number;\n}\n\nexport interface CoChangeMatrix {\n readonly version: 1;\n readonly timestamp: number;\n readonly entries: readonly CoChangeEntry[];\n}\n\n// ── Commit Record (from git adapter) ────────────────────────────\n\nexport interface CommitRecord {\n readonly hash: string;\n readonly message: string;\n readonly date: string;\n readonly author: string;\n}\n\n// ── Blame Line (from git adapter) ───────────────────────────────\n\nexport interface BlameLine {\n readonly hash: string;\n readonly lineNumber: number;\n readonly author: string;\n readonly date: string;\n}\n\n// ── Search Types ───────────────────────────────────────────────\n\nexport interface SearchResult {\n symbolId: string;\n name: string;\n kind: string;\n filePath: string;\n /** BM25 relevance score (higher = more relevant) */\n relevanceScore: number;\n /** PageRank importance score (0-1) */\n importanceScore: number;\n /** Final combined score after all boosts */\n combinedScore: number;\n}\n\nexport interface SearchMetrics {\n porterHits: number;\n trigramHits: number;\n phase2Activated: boolean;\n fuzzyApplied: boolean;\n latencyMs: number;\n}\n\nexport interface FuzzyCorrection {\n originalQuery: string;\n correctedQuery: string;\n corrections: Array<{\n original: string;\n corrected: string;\n distance: number;\n }>;\n}\n\nexport interface SearchResponse {\n query: string;\n results: SearchResult[];\n metrics: SearchMetrics;\n fuzzyCorrection?: FuzzyCorrection;\n}\n\n// ── Community Detection ────────────────────────────────────────\n\nexport interface CommunityEntry {\n readonly symbolId: string;\n readonly communityId: number;\n readonly communityLabel: string;\n}\n\nexport interface GodNode {\n readonly symbolId: string;\n readonly bridgedCommunities: readonly number[];\n readonly centralityScore: number;\n}\n\nexport const EDGE_QUALITY = ['full', 'syntax-only', 'mixed'] as const;\nexport type EdgeQuality = (typeof EDGE_QUALITY)[number];\n\nexport interface CommunitySnapshot {\n readonly version: 1;\n readonly computedAt: string;\n readonly commitSha: string;\n readonly modularity: number;\n readonly communities: readonly CommunityEntry[];\n readonly godNodes: readonly GodNode[];\n readonly edgeQuality: EdgeQuality;\n readonly crossClusterEdges: number;\n}\n\nexport interface DriftEvent {\n readonly symbolId: string;\n readonly movedFrom: { readonly id: number; readonly label: string };\n readonly movedTo: { readonly id: number; readonly label: string };\n readonly firstSeenInNewCluster: string;\n}\n\nexport interface BoundaryViolation {\n readonly from: {\n readonly symbolId: string;\n readonly communityId: number;\n readonly label: string;\n };\n readonly to: {\n readonly symbolId: string;\n readonly communityId: number;\n readonly label: string;\n };\n readonly edgeKind: EdgeKind;\n readonly historicalEdgesBetweenClusters: number;\n readonly severity: 'high' | 'medium';\n}\n\n// ── Persisted architectural artifacts ──────────────────────────\n// Written by `ctxo index` next to communities.json so the static\n// HTML visualizer can render Architecture views without re-running\n// detectors in the browser.\n\nexport type ConfidenceLevel = 'high' | 'medium' | 'low';\n\nexport interface DriftEventsArtifact {\n readonly version: 1;\n readonly computedAt: string;\n readonly commitSha: string;\n readonly confidence: ConfidenceLevel;\n readonly snapshotsAvailable: number;\n readonly hint?: string;\n readonly events: readonly DriftEvent[];\n}\n\nexport interface BoundaryViolationsArtifact {\n readonly version: 1;\n readonly computedAt: string;\n readonly commitSha: string;\n readonly confidence: ConfidenceLevel;\n readonly snapshotsAvailable: number;\n readonly hint?: string;\n readonly violations: readonly BoundaryViolation[];\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/adapters/storage/atomic-write.ts
|
|
4
|
+
import { readdirSync, renameSync, statSync, unlinkSync, writeFileSync, existsSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
|
|
7
|
+
// src/core/logger.ts
|
|
8
|
+
function isDebugEnabled(namespace) {
|
|
9
|
+
const debugEnv = process.env["DEBUG"] ?? "";
|
|
10
|
+
if (!debugEnv) return false;
|
|
11
|
+
const patterns = debugEnv.split(",").map((p) => p.trim());
|
|
12
|
+
return patterns.some((p) => {
|
|
13
|
+
if (p === "*" || p === "ctxo:*") return true;
|
|
14
|
+
if (p === namespace) return true;
|
|
15
|
+
if (p.endsWith(":*") && namespace.startsWith(p.slice(0, -1))) return true;
|
|
16
|
+
return false;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function createLogger(namespace) {
|
|
20
|
+
const prefix = `[${namespace}]`;
|
|
21
|
+
return {
|
|
22
|
+
debug(message, ...args) {
|
|
23
|
+
if (isDebugEnabled(namespace)) {
|
|
24
|
+
console.error(`${prefix} DEBUG ${message}`, ...args);
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
info(message, ...args) {
|
|
28
|
+
console.error(`${prefix} ${message}`, ...args);
|
|
29
|
+
},
|
|
30
|
+
warn(message, ...args) {
|
|
31
|
+
console.error(`${prefix} WARN ${message}`, ...args);
|
|
32
|
+
},
|
|
33
|
+
error(message, ...args) {
|
|
34
|
+
console.error(`${prefix} ERROR ${message}`, ...args);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/adapters/storage/atomic-write.ts
|
|
40
|
+
var log = createLogger("ctxo:storage");
|
|
41
|
+
var TMP_SUFFIX_RE = /\.\d+\.tmp$/;
|
|
42
|
+
var TMP_STALE_THRESHOLD_MS = 6e4;
|
|
43
|
+
function atomicWrite(targetPath, content) {
|
|
44
|
+
const tmpPath = `${targetPath}.${process.pid}.tmp`;
|
|
45
|
+
writeFileSync(tmpPath, content, "utf-8");
|
|
46
|
+
renameSync(tmpPath, targetPath);
|
|
47
|
+
}
|
|
48
|
+
function sweepStaleTmpFiles(dir, now = Date.now()) {
|
|
49
|
+
if (!existsSync(dir)) return 0;
|
|
50
|
+
let cleaned = 0;
|
|
51
|
+
try {
|
|
52
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
53
|
+
if (entry.isSymbolicLink()) continue;
|
|
54
|
+
if (entry.isDirectory()) {
|
|
55
|
+
cleaned += sweepStaleTmpFiles(join(dir, entry.name), now);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!TMP_SUFFIX_RE.test(entry.name)) continue;
|
|
59
|
+
const full = join(dir, entry.name);
|
|
60
|
+
try {
|
|
61
|
+
const age = now - statSync(full).mtimeMs;
|
|
62
|
+
if (age < TMP_STALE_THRESHOLD_MS) continue;
|
|
63
|
+
unlinkSync(full);
|
|
64
|
+
cleaned++;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
log.error(`failed to sweep ${entry.name}: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
log.error(`failed to scan ${dir}: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
return cleaned;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
createLogger,
|
|
77
|
+
atomicWrite,
|
|
78
|
+
sweepStaleTmpFiles
|
|
79
|
+
};
|
|
80
|
+
//# sourceMappingURL=chunk-6EKERTJ7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/adapters/storage/atomic-write.ts","../src/core/logger.ts"],"sourcesContent":["import { readdirSync, renameSync, statSync, unlinkSync, writeFileSync, existsSync } from 'node:fs';\nimport { join } from 'node:path';\n\nimport { createLogger } from '../../core/logger.js';\n\nconst log = createLogger('ctxo:storage');\n\nconst TMP_SUFFIX_RE = /\\.\\d+\\.tmp$/;\nconst TMP_STALE_THRESHOLD_MS = 60_000; // anything older than 60 s is from a dead process\n\nexport function atomicWrite(targetPath: string, content: string): void {\n const tmpPath = `${targetPath}.${process.pid}.tmp`;\n writeFileSync(tmpPath, content, 'utf-8');\n renameSync(tmpPath, targetPath);\n}\n\n/**\n * Remove orphaned .<pid>.tmp files left behind when a previous write crashed\n * (SIGKILL / power loss / ENOSPC). Only files older than a threshold are\n * removed so we never race a concurrent writer.\n *\n * Safe to call on every startup — walks the supplied directory non-recursively.\n */\nexport function sweepStaleTmpFiles(dir: string, now: number = Date.now()): number {\n if (!existsSync(dir)) return 0;\n let cleaned = 0;\n try {\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (entry.isSymbolicLink()) continue;\n if (entry.isDirectory()) {\n cleaned += sweepStaleTmpFiles(join(dir, entry.name), now);\n continue;\n }\n if (!TMP_SUFFIX_RE.test(entry.name)) continue;\n const full = join(dir, entry.name);\n try {\n const age = now - statSync(full).mtimeMs;\n if (age < TMP_STALE_THRESHOLD_MS) continue;\n unlinkSync(full);\n cleaned++;\n } catch (err) {\n log.error(`failed to sweep ${entry.name}: ${(err as Error).message}`);\n }\n }\n } catch (err) {\n log.error(`failed to scan ${dir}: ${(err as Error).message}`);\n }\n return cleaned;\n}\n","/**\n * Structured logger for Ctxo — all output goes to stderr (stdout reserved for MCP JSON-RPC).\n *\n * Supports debug mode via DEBUG environment variable:\n * DEBUG=ctxo:* — all debug output\n * DEBUG=ctxo:git — only git adapter debug\n * DEBUG=ctxo:index — only indexing debug\n * DEBUG=ctxo:mcp — only MCP handler debug\n * DEBUG=ctxo:storage — only storage adapter debug\n * DEBUG=ctxo:masking — only masking pipeline debug\n *\n * Always outputs: info, warn, error (regardless of DEBUG).\n * Only outputs debug when matching namespace is enabled.\n */\n\nfunction isDebugEnabled(namespace: string): boolean {\n const debugEnv = process.env['DEBUG'] ?? '';\n if (!debugEnv) return false;\n const patterns = debugEnv.split(',').map(p => p.trim());\n return patterns.some(p => {\n if (p === '*' || p === 'ctxo:*') return true;\n if (p === namespace) return true;\n if (p.endsWith(':*') && namespace.startsWith(p.slice(0, -1))) return true;\n return false;\n });\n}\n\nexport interface Logger {\n debug(message: string, ...args: unknown[]): void;\n info(message: string, ...args: unknown[]): void;\n warn(message: string, ...args: unknown[]): void;\n error(message: string, ...args: unknown[]): void;\n}\n\nexport function createLogger(namespace: string): Logger {\n const prefix = `[${namespace}]`;\n\n return {\n debug(message: string, ...args: unknown[]) {\n if (isDebugEnabled(namespace)) {\n console.error(`${prefix} DEBUG ${message}`, ...args);\n }\n },\n info(message: string, ...args: unknown[]) {\n console.error(`${prefix} ${message}`, ...args);\n },\n warn(message: string, ...args: unknown[]) {\n console.error(`${prefix} WARN ${message}`, ...args);\n },\n error(message: string, ...args: unknown[]) {\n console.error(`${prefix} ERROR ${message}`, ...args);\n },\n };\n}\n"],"mappings":";;;AAAA,SAAS,aAAa,YAAY,UAAU,YAAY,eAAe,kBAAkB;AACzF,SAAS,YAAY;;;ACcrB,SAAS,eAAe,WAA4B;AAClD,QAAM,WAAW,QAAQ,IAAI,OAAO,KAAK;AACzC,MAAI,CAAC,SAAU,QAAO;AACtB,QAAM,WAAW,SAAS,MAAM,GAAG,EAAE,IAAI,OAAK,EAAE,KAAK,CAAC;AACtD,SAAO,SAAS,KAAK,OAAK;AACxB,QAAI,MAAM,OAAO,MAAM,SAAU,QAAO;AACxC,QAAI,MAAM,UAAW,QAAO;AAC5B,QAAI,EAAE,SAAS,IAAI,KAAK,UAAU,WAAW,EAAE,MAAM,GAAG,EAAE,CAAC,EAAG,QAAO;AACrE,WAAO;AAAA,EACT,CAAC;AACH;AASO,SAAS,aAAa,WAA2B;AACtD,QAAM,SAAS,IAAI,SAAS;AAE5B,SAAO;AAAA,IACL,MAAM,YAAoB,MAAiB;AACzC,UAAI,eAAe,SAAS,GAAG;AAC7B,gBAAQ,MAAM,GAAG,MAAM,UAAU,OAAO,IAAI,GAAG,IAAI;AAAA,MACrD;AAAA,IACF;AAAA,IACA,KAAK,YAAoB,MAAiB;AACxC,cAAQ,MAAM,GAAG,MAAM,IAAI,OAAO,IAAI,GAAG,IAAI;AAAA,IAC/C;AAAA,IACA,KAAK,YAAoB,MAAiB;AACxC,cAAQ,MAAM,GAAG,MAAM,SAAS,OAAO,IAAI,GAAG,IAAI;AAAA,IACpD;AAAA,IACA,MAAM,YAAoB,MAAiB;AACzC,cAAQ,MAAM,GAAG,MAAM,UAAU,OAAO,IAAI,GAAG,IAAI;AAAA,IACrD;AAAA,EACF;AACF;;;ADhDA,IAAM,MAAM,aAAa,cAAc;AAEvC,IAAM,gBAAgB;AACtB,IAAM,yBAAyB;AAExB,SAAS,YAAY,YAAoB,SAAuB;AACrE,QAAM,UAAU,GAAG,UAAU,IAAI,QAAQ,GAAG;AAC5C,gBAAc,SAAS,SAAS,OAAO;AACvC,aAAW,SAAS,UAAU;AAChC;AASO,SAAS,mBAAmB,KAAa,MAAc,KAAK,IAAI,GAAW;AAChF,MAAI,CAAC,WAAW,GAAG,EAAG,QAAO;AAC7B,MAAI,UAAU;AACd,MAAI;AACF,eAAW,SAAS,YAAY,KAAK,EAAE,eAAe,KAAK,CAAC,GAAG;AAC7D,UAAI,MAAM,eAAe,EAAG;AAC5B,UAAI,MAAM,YAAY,GAAG;AACvB,mBAAW,mBAAmB,KAAK,KAAK,MAAM,IAAI,GAAG,GAAG;AACxD;AAAA,MACF;AACA,UAAI,CAAC,cAAc,KAAK,MAAM,IAAI,EAAG;AACrC,YAAM,OAAO,KAAK,KAAK,MAAM,IAAI;AACjC,UAAI;AACF,cAAM,MAAM,MAAM,SAAS,IAAI,EAAE;AACjC,YAAI,MAAM,uBAAwB;AAClC,mBAAW,IAAI;AACf;AAAA,MACF,SAAS,KAAK;AACZ,YAAI,MAAM,mBAAmB,MAAM,IAAI,KAAM,IAAc,OAAO,EAAE;AAAA,MACtE;AAAA,IACF;AAAA,EACF,SAAS,KAAK;AACZ,QAAI,MAAM,kBAAkB,GAAG,KAAM,IAAc,OAAO,EAAE;AAAA,EAC9D;AACA,SAAO;AACT;","names":[]}
|