composto-ai 0.1.0
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 +293 -0
- package/dist/index.js +857 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Composto
|
|
2
|
+
|
|
3
|
+
**Proactive AI team companion — less tokens, more insight.**
|
|
4
|
+
|
|
5
|
+
Every AI coding tool sends raw source code to LLMs. Composto sends **meaning** — compressed code enriched with codebase health data. The result: fewer tokens carrying more information than raw source ever could.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What Makes It Different
|
|
10
|
+
|
|
11
|
+
| | Traditional AI Tools | Composto |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| **Paradigm** | Reactive (you ask, it does) | Proactive (it finds, you approve) |
|
|
14
|
+
| **What LLM sees** | Raw source code | Health-Aware IR |
|
|
15
|
+
| **Token usage** | Full files every time | 60-75% savings |
|
|
16
|
+
| **Health context** | None | Hotspots, decay, inconsistencies |
|
|
17
|
+
| **Codebase monitoring** | None | Watcher Engine |
|
|
18
|
+
|
|
19
|
+
### Health-Aware IR
|
|
20
|
+
|
|
21
|
+
Raw source tells the LLM *what* the code says. Composto IR tells it *what the code means* and *how healthy it is*:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
// Raw source: 340 tokens, zero health context
|
|
25
|
+
import { useState, useEffect } from "react";
|
|
26
|
+
export function UserProfile({ userId }) {
|
|
27
|
+
const [user, setUser] = useState(null);
|
|
28
|
+
const [loading, setLoading] = useState(true);
|
|
29
|
+
useEffect(() => { fetchUser(userId).then(...) }, [userId]);
|
|
30
|
+
if (loading) return <Spinner />;
|
|
31
|
+
if (!user) return <NotFound />;
|
|
32
|
+
return <div>{user.name}</div>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Composto IR: 85 tokens + health context
|
|
36
|
+
USE:react{useState,useEffect}
|
|
37
|
+
OUT FN:UserProfile({userId}) [HOT:12/30 FIX:67% COV:↓ INCON]
|
|
38
|
+
VAR:user = useState(null)
|
|
39
|
+
VAR:loading = useState(true)
|
|
40
|
+
IF:loading -> RET <Spinner />
|
|
41
|
+
IF:!user -> RET <NotFound />
|
|
42
|
+
RET <div>{user.name}</div>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The LLM sees less, knows more, decides better.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
### Claude Code
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
/plugin install composto
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Cursor
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
/add-plugin composto
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Any platform (CLI)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm install -g composto
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
### CLI Commands
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Scan codebase for issues
|
|
77
|
+
composto scan .
|
|
78
|
+
|
|
79
|
+
# Analyze codebase health trends
|
|
80
|
+
composto trends .
|
|
81
|
+
|
|
82
|
+
# Generate Health-Aware IR for a file
|
|
83
|
+
composto ir src/auth/login.ts L1
|
|
84
|
+
|
|
85
|
+
# Layer options:
|
|
86
|
+
# L0 — Structure map (~10 tokens)
|
|
87
|
+
# L1 — Health-Aware IR (~85 tokens)
|
|
88
|
+
# L2 — Delta context (~65 tokens)
|
|
89
|
+
# L3 — Raw source (fallback)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### As a Plugin
|
|
93
|
+
|
|
94
|
+
Once installed, Composto activates automatically. Your AI agent will:
|
|
95
|
+
|
|
96
|
+
1. **Scan** the codebase for issues before starting work
|
|
97
|
+
2. **Check trends** for files being modified
|
|
98
|
+
3. **Use IR** instead of raw source when sharing code context
|
|
99
|
+
|
|
100
|
+
No commands needed — it just works.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## What It Does
|
|
105
|
+
|
|
106
|
+
### IR Engine — Send meaning, not code
|
|
107
|
+
|
|
108
|
+
Four layers of code representation, from most compact to full source:
|
|
109
|
+
|
|
110
|
+
| Layer | Tokens | Use |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| L0: Structure Map | ~10 | File outline — functions, classes, line numbers |
|
|
113
|
+
| L1: Health-Aware IR | ~85 | Compressed code + health annotations |
|
|
114
|
+
| L2: Delta + Context | ~65 | Only what changed, with surrounding context |
|
|
115
|
+
| L3: Raw Source | variable | Original code, specific lines only |
|
|
116
|
+
|
|
117
|
+
No AST parser. No language-specific dependencies. Works with TypeScript, JavaScript, Python, Go, and more.
|
|
118
|
+
|
|
119
|
+
### Watcher Engine — Proactive issue detection
|
|
120
|
+
|
|
121
|
+
Detects problems without being asked:
|
|
122
|
+
|
|
123
|
+
- **Security** — Hardcoded secrets, API keys, tokens
|
|
124
|
+
- **Debug artifacts** — `console.log`, `console.debug` left in source
|
|
125
|
+
- **Context-aware severity** — Same issue, different severity in `src/` vs `tests/`
|
|
126
|
+
|
|
127
|
+
### Trend Analysis — Codebase health over time
|
|
128
|
+
|
|
129
|
+
Analyzes git history to find:
|
|
130
|
+
|
|
131
|
+
- **Hotspots** — Files that change too often with too many bug fixes
|
|
132
|
+
- **Decay signals** — Areas where churn is accelerating
|
|
133
|
+
- **Inconsistencies** — Files touched by many authors with conflicting patterns
|
|
134
|
+
|
|
135
|
+
All trend analysis is zero-token — pure local git analysis.
|
|
136
|
+
|
|
137
|
+
### Health Annotations — The killer feature
|
|
138
|
+
|
|
139
|
+
IR Engine and Trend Analysis are not separate systems. Health data is embedded directly into code representation:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
FN:handleAuth({credentials}) [HOT:15/30 FIX:73% COV:↓ INCON]
|
|
143
|
+
VAR:session = createSession(credentials)
|
|
144
|
+
IF:!session -> RET 401
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- `[HOT:15/30]` — 15 changes in last 30 commits
|
|
148
|
+
- `[FIX:73%]` — 73% of changes were bug fixes
|
|
149
|
+
- `[COV:↓]` — Test coverage declining
|
|
150
|
+
- `[INCON]` — Inconsistent patterns from multiple authors
|
|
151
|
+
|
|
152
|
+
Only unhealthy code gets annotated. Healthy files stay clean.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Architecture
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
+----------------------------------------------+
|
|
160
|
+
| Platform Adapters |
|
|
161
|
+
| Claude Code | VS Code | Cursor | CLI |
|
|
162
|
+
+----------------------------------------------+
|
|
163
|
+
| Watcher Engine |
|
|
164
|
+
| Detector (0 token) -> Interpreter (~100 tok) |
|
|
165
|
+
| + Trend Analysis (hotspots, decay, incon.) |
|
|
166
|
+
+----------------------------------------------+
|
|
167
|
+
| IR Engine |
|
|
168
|
+
| Indentation Intel | Fingerprinting | Delta |
|
|
169
|
+
| + Health Annotations (from Trend Analysis) |
|
|
170
|
+
+----------------------------------------------+
|
|
171
|
+
| Rule-Based Router |
|
|
172
|
+
| Deterministic routing, zero tokens |
|
|
173
|
+
+----------------------------------------------+
|
|
174
|
+
| Agent Pool |
|
|
175
|
+
| Fixer (Haiku) | Reviewer (Sonnet) |
|
|
176
|
+
+----------------------------------------------+
|
|
177
|
+
| Project Memory |
|
|
178
|
+
| .composto/config.yaml | decisions/*.md |
|
|
179
|
+
+----------------------------------------------+
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Configuration
|
|
185
|
+
|
|
186
|
+
Create `.composto/config.yaml` in your project root:
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
watchers:
|
|
190
|
+
security:
|
|
191
|
+
enabled: true
|
|
192
|
+
severity:
|
|
193
|
+
"src/**": warning
|
|
194
|
+
"tests/**": info
|
|
195
|
+
consoleLog:
|
|
196
|
+
enabled: true
|
|
197
|
+
severity:
|
|
198
|
+
"src/**": warning
|
|
199
|
+
"tests/**": info
|
|
200
|
+
|
|
201
|
+
agents:
|
|
202
|
+
fixer:
|
|
203
|
+
enabled: true
|
|
204
|
+
model: haiku
|
|
205
|
+
|
|
206
|
+
ir:
|
|
207
|
+
deltaContextLines: 3
|
|
208
|
+
confidenceThreshold: 0.6
|
|
209
|
+
genericPatterns: default
|
|
210
|
+
|
|
211
|
+
trends:
|
|
212
|
+
enabled: true
|
|
213
|
+
hotspotThreshold: 10
|
|
214
|
+
bugFixRatioThreshold: 0.5
|
|
215
|
+
decayCheckTrigger: on-commit
|
|
216
|
+
fullReportSchedule: weekly
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
All settings have sensible defaults. The config file is optional.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## How It Works
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
1. Developer saves src/auth/login.ts
|
|
227
|
+
|
|
|
228
|
+
2. Watcher Engine triggers (debounced)
|
|
229
|
+
|
|
|
230
|
+
3. Detector: pattern match → "hardcoded secret, line 23" (0 tokens)
|
|
231
|
+
|
|
|
232
|
+
4. IR Engine: generates Health-Aware IR + annotations (0 tokens)
|
|
233
|
+
|
|
|
234
|
+
5. Router: severity=critical → route to Fixer (0 tokens)
|
|
235
|
+
|
|
|
236
|
+
6. Fixer: generates fix via IR, not full source (~150 tokens)
|
|
237
|
+
|
|
|
238
|
+
7. User: "login.ts:23 has a hardcoded secret.
|
|
239
|
+
You added it for debugging. Move to .env?"
|
|
240
|
+
|
|
|
241
|
+
8. User approves → patch applied
|
|
242
|
+
|
|
243
|
+
Total cost: ~250 tokens. Traditional tools: ~3000+ tokens.
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Tech Stack
|
|
249
|
+
|
|
250
|
+
- **Language:** TypeScript
|
|
251
|
+
- **Runtime:** Node.js
|
|
252
|
+
- **Testing:** Vitest (70 tests)
|
|
253
|
+
- **Build:** tsup
|
|
254
|
+
- **Zero native dependencies** — no tree-sitter, no language-specific parsers
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Roadmap
|
|
259
|
+
|
|
260
|
+
### v0.5 — Usable Alpha
|
|
261
|
+
- Watcher Interpreter (batch Haiku calls for contextual explanations)
|
|
262
|
+
- Reviewer Agent (Sonnet, code review with challenge mode)
|
|
263
|
+
- Project Memory (decisions/ with YAML frontmatter)
|
|
264
|
+
- Python + Go language support
|
|
265
|
+
|
|
266
|
+
### v1.0 — Public Release
|
|
267
|
+
- Framework-specific fingerprint patterns (React, Express, etc.)
|
|
268
|
+
- VS Code / Cursor / Claude Code deep integrations
|
|
269
|
+
- Benchmark results: Health-Aware IR vs raw source
|
|
270
|
+
|
|
271
|
+
### v2.0 — Platform
|
|
272
|
+
- Security / Architect agents
|
|
273
|
+
- Custom Agent API
|
|
274
|
+
- Team sync features
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Contributing
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
git clone https://github.com/mertcanaltin/composto
|
|
282
|
+
cd composto
|
|
283
|
+
pnpm install
|
|
284
|
+
pnpm test # 70 tests
|
|
285
|
+
pnpm build # builds to dist/
|
|
286
|
+
pnpm dev scan . # run locally
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## License
|
|
292
|
+
|
|
293
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/commands.ts
|
|
4
|
+
import { readFileSync as readFileSync2, readdirSync } from "fs";
|
|
5
|
+
import { join as join2, relative } from "path";
|
|
6
|
+
|
|
7
|
+
// src/config/loader.ts
|
|
8
|
+
import { readFileSync, existsSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { parse } from "yaml";
|
|
11
|
+
var DEFAULT_CONFIG = {
|
|
12
|
+
watchers: {
|
|
13
|
+
security: {
|
|
14
|
+
enabled: true,
|
|
15
|
+
severity: { "src/**": "warning", "tests/**": "info" }
|
|
16
|
+
},
|
|
17
|
+
deadCode: { enabled: true, trigger: "on-commit" },
|
|
18
|
+
consoleLog: { enabled: true, severity: { "src/**": "warning", "tests/**": "info" } }
|
|
19
|
+
},
|
|
20
|
+
agents: {
|
|
21
|
+
fixer: { enabled: true, model: "haiku" },
|
|
22
|
+
reviewer: { enabled: false, model: "sonnet" }
|
|
23
|
+
},
|
|
24
|
+
ir: {
|
|
25
|
+
deltaContextLines: 3,
|
|
26
|
+
confidenceThreshold: 0.6,
|
|
27
|
+
genericPatterns: "default"
|
|
28
|
+
},
|
|
29
|
+
trends: {
|
|
30
|
+
enabled: true,
|
|
31
|
+
hotspotThreshold: 10,
|
|
32
|
+
bugFixRatioThreshold: 0.5,
|
|
33
|
+
decayCheckTrigger: "on-commit",
|
|
34
|
+
fullReportSchedule: "weekly"
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
function deepMerge(target, source) {
|
|
38
|
+
const result = { ...target };
|
|
39
|
+
for (const key of Object.keys(source)) {
|
|
40
|
+
if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
|
|
41
|
+
result[key] = deepMerge(target[key] ?? {}, source[key]);
|
|
42
|
+
} else {
|
|
43
|
+
result[key] = source[key];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
function parseConfig(yamlContent) {
|
|
49
|
+
if (!yamlContent.trim()) return { ...DEFAULT_CONFIG };
|
|
50
|
+
const parsed = parse(yamlContent) ?? {};
|
|
51
|
+
return deepMerge(DEFAULT_CONFIG, parsed);
|
|
52
|
+
}
|
|
53
|
+
function loadConfig(projectPath) {
|
|
54
|
+
const configPath = join(projectPath, ".composto", "config.yaml");
|
|
55
|
+
if (!existsSync(configPath)) return { ...DEFAULT_CONFIG };
|
|
56
|
+
const content = readFileSync(configPath, "utf-8");
|
|
57
|
+
return parseConfig(content);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/watcher/detector.ts
|
|
61
|
+
import picomatch from "picomatch";
|
|
62
|
+
function getSeverity(filePath, severityMap) {
|
|
63
|
+
for (const [glob, severity] of Object.entries(severityMap)) {
|
|
64
|
+
if (picomatch.isMatch(filePath, glob)) return severity;
|
|
65
|
+
}
|
|
66
|
+
return "info";
|
|
67
|
+
}
|
|
68
|
+
var SECRET_PATTERNS = [
|
|
69
|
+
/["'](sk-[a-zA-Z0-9-]{20,})["']/,
|
|
70
|
+
/["'](AKIA[0-9A-Z]{16})["']/,
|
|
71
|
+
/["'](ghp_[a-zA-Z0-9]{36})["']/,
|
|
72
|
+
/(?:password|secret|token|api_?key)\s*[:=]\s*["']([^"']{8,})["']/i
|
|
73
|
+
];
|
|
74
|
+
function securityRule(code, filePath, severityMap) {
|
|
75
|
+
const findings = [];
|
|
76
|
+
const severity = getSeverity(filePath, severityMap);
|
|
77
|
+
const lines = code.split("\n");
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
const line = lines[i];
|
|
80
|
+
if (line.trim().startsWith("//") || line.trim().startsWith("/*")) continue;
|
|
81
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
82
|
+
if (pattern.test(line)) {
|
|
83
|
+
findings.push({
|
|
84
|
+
watcherId: "security",
|
|
85
|
+
severity,
|
|
86
|
+
file: filePath,
|
|
87
|
+
line: i + 1,
|
|
88
|
+
message: "Potential hardcoded secret detected",
|
|
89
|
+
action: {
|
|
90
|
+
type: "agent-required",
|
|
91
|
+
agentHint: { role: "fixer", model: "haiku", contextFiles: [filePath] }
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
100
|
+
function consoleLogRule(code, filePath, severityMap) {
|
|
101
|
+
const findings = [];
|
|
102
|
+
const severity = getSeverity(filePath, severityMap);
|
|
103
|
+
const lines = code.split("\n");
|
|
104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
105
|
+
if (/\bconsole\.(log|debug|info|warn)\b/.test(lines[i])) {
|
|
106
|
+
findings.push({
|
|
107
|
+
watcherId: "consoleLog",
|
|
108
|
+
severity,
|
|
109
|
+
file: filePath,
|
|
110
|
+
line: i + 1,
|
|
111
|
+
message: "console.log detected \u2014 likely debug artifact",
|
|
112
|
+
action: { type: "auto-fix", autoFix: "remove-line" }
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
118
|
+
var RULES = {
|
|
119
|
+
security: securityRule,
|
|
120
|
+
consoleLog: consoleLogRule
|
|
121
|
+
};
|
|
122
|
+
function runDetector(code, filePath, watcherConfigs) {
|
|
123
|
+
const findings = [];
|
|
124
|
+
for (const [name, config] of Object.entries(watcherConfigs)) {
|
|
125
|
+
if (!config.enabled) continue;
|
|
126
|
+
const rule = RULES[name];
|
|
127
|
+
if (rule && config.severity) {
|
|
128
|
+
findings.push(...rule(code, filePath, config.severity));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return findings;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/ir/structure.ts
|
|
135
|
+
var CLASSIFIERS = [
|
|
136
|
+
[/^(function|def|fn|func)\b/, "function-start"],
|
|
137
|
+
[/^(class|struct|interface)\b/, "type-start"],
|
|
138
|
+
[/^(if|else|elif|switch|match|case)\b/, "branch"],
|
|
139
|
+
[/^(for|while|loop|do)\b/, "loop"],
|
|
140
|
+
[/^(return|yield)\b/, "exit"],
|
|
141
|
+
[/^(import|require|use|from)\b/, "import"],
|
|
142
|
+
[/^(export|pub|public)\b/, "export"],
|
|
143
|
+
[/^(const|let|var|val|mut)\b/, "assignment"],
|
|
144
|
+
[/^(try|catch|except|finally)\b/, "error-handling"],
|
|
145
|
+
[/^(async|await)\b/, "async"],
|
|
146
|
+
[/^(\/\/|\/\*|#)/, "comment"]
|
|
147
|
+
];
|
|
148
|
+
function classifyLine(firstToken) {
|
|
149
|
+
if (firstToken === "") return "blank";
|
|
150
|
+
for (const [pattern, type] of CLASSIFIERS) {
|
|
151
|
+
if (pattern.test(firstToken)) return type;
|
|
152
|
+
}
|
|
153
|
+
return "unknown";
|
|
154
|
+
}
|
|
155
|
+
function extractStructure(code) {
|
|
156
|
+
const lines = code.split("\n");
|
|
157
|
+
return lines.map((raw, i) => {
|
|
158
|
+
const indent = raw.search(/\S/);
|
|
159
|
+
const trimmed = raw.trim();
|
|
160
|
+
const firstToken = trimmed.split(/[\s({<]/)[0];
|
|
161
|
+
const type = classifyLine(firstToken);
|
|
162
|
+
return {
|
|
163
|
+
line: i + 1,
|
|
164
|
+
indent: indent === -1 ? -1 : indent,
|
|
165
|
+
type,
|
|
166
|
+
raw
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/ir/fingerprint.ts
|
|
172
|
+
var PATTERNS = [
|
|
173
|
+
// import { x, y } from "module"
|
|
174
|
+
{
|
|
175
|
+
match: /^import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["'];?\s*$/,
|
|
176
|
+
transform: (m) => `USE:${m[2]}{${m[1].replace(/\s/g, "")}}`,
|
|
177
|
+
confidence: 0.95
|
|
178
|
+
},
|
|
179
|
+
// import x from "module"
|
|
180
|
+
{
|
|
181
|
+
match: /^import\s+(\w+)\s+from\s+["']([^"']+)["'];?\s*$/,
|
|
182
|
+
transform: (m) => `USE:${m[2]}{${m[1]}}`,
|
|
183
|
+
confidence: 0.95
|
|
184
|
+
},
|
|
185
|
+
// const x = require("module")
|
|
186
|
+
{
|
|
187
|
+
match: /^(?:const|let|var)\s+(\w+)\s*=\s*require\(["']([^"']+)["']\);?\s*$/,
|
|
188
|
+
transform: (m) => `USE:${m[2]}{${m[1]}}`,
|
|
189
|
+
confidence: 0.95
|
|
190
|
+
},
|
|
191
|
+
// export function name(params) {
|
|
192
|
+
{
|
|
193
|
+
match: /^export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)\s*(?::\s*\S+\s*)?\{?\s*$/,
|
|
194
|
+
transform: (m) => `OUT FN:${m[1]}(${m[2].replace(/\s/g, "")})`,
|
|
195
|
+
confidence: 0.95
|
|
196
|
+
},
|
|
197
|
+
// function name(params) {
|
|
198
|
+
{
|
|
199
|
+
match: /^(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)\s*(?::\s*\S+\s*)?\{?\s*$/,
|
|
200
|
+
transform: (m) => `FN:${m[1]}(${m[2].replace(/\s/g, "")})`,
|
|
201
|
+
confidence: 0.95
|
|
202
|
+
},
|
|
203
|
+
// export class Name extends Base {
|
|
204
|
+
{
|
|
205
|
+
match: /^export\s+class\s+(\w+)(?:\s+extends\s+(\w+))?\s*(?:implements\s+\S+\s*)?\{?\s*$/,
|
|
206
|
+
transform: (m) => `OUT CLASS:${m[1]}${m[2] ? ` < ${m[2]}` : ""}`,
|
|
207
|
+
confidence: 0.95
|
|
208
|
+
},
|
|
209
|
+
// class Name extends Base {
|
|
210
|
+
{
|
|
211
|
+
match: /^class\s+(\w+)(?:\s+extends\s+(\w+))?\s*(?:implements\s+\S+\s*)?\{?\s*$/,
|
|
212
|
+
transform: (m) => `CLASS:${m[1]}${m[2] ? ` < ${m[2]}` : ""}`,
|
|
213
|
+
confidence: 0.95
|
|
214
|
+
},
|
|
215
|
+
// if (cond) return expr;
|
|
216
|
+
{
|
|
217
|
+
match: /^if\s*\(([^)]+)\)\s*return\s+(.+);?\s*$/,
|
|
218
|
+
transform: (m) => `IF:${m[1].trim()} -> RET ${m[2].trim().replace(/;$/, "")}`,
|
|
219
|
+
confidence: 0.95
|
|
220
|
+
},
|
|
221
|
+
// if (cond) {
|
|
222
|
+
{
|
|
223
|
+
match: /^if\s*\(([^)]+)\)\s*\{?\s*$/,
|
|
224
|
+
transform: (m) => `IF:${m[1].trim()}`,
|
|
225
|
+
confidence: 0.9
|
|
226
|
+
},
|
|
227
|
+
// for (... of/in ...) {
|
|
228
|
+
{
|
|
229
|
+
match: /^for\s*\((?:const|let|var)\s+(\w+)\s+(?:of|in)\s+(\w+)\)\s*\{?\s*$/,
|
|
230
|
+
transform: (m) => `LOOP:${m[2]} -> ${m[1]}`,
|
|
231
|
+
confidence: 0.9
|
|
232
|
+
},
|
|
233
|
+
// return expr
|
|
234
|
+
{
|
|
235
|
+
match: /^return\s+(.+);?\s*$/,
|
|
236
|
+
transform: (m) => `RET ${m[1].trim().replace(/;$/, "")}`,
|
|
237
|
+
confidence: 0.95
|
|
238
|
+
},
|
|
239
|
+
// return;
|
|
240
|
+
{
|
|
241
|
+
match: /^return;?\s*$/,
|
|
242
|
+
transform: () => "RET",
|
|
243
|
+
confidence: 0.95
|
|
244
|
+
},
|
|
245
|
+
// try {
|
|
246
|
+
{
|
|
247
|
+
match: /^try\s*\{\s*$/,
|
|
248
|
+
transform: () => "TRY:",
|
|
249
|
+
confidence: 0.9
|
|
250
|
+
},
|
|
251
|
+
// catch (e) {
|
|
252
|
+
{
|
|
253
|
+
match: /^(?:\}\s*)?catch\s*\((\w+)\)\s*\{?\s*$/,
|
|
254
|
+
transform: (m) => `CATCH:${m[1]}`,
|
|
255
|
+
confidence: 0.9
|
|
256
|
+
},
|
|
257
|
+
// const [a, b] = expr (destructuring — before regular assignment)
|
|
258
|
+
{
|
|
259
|
+
match: /^(?:const|let|var)\s+\[([^\]]+)\]\s*=\s*(.+);?\s*$/,
|
|
260
|
+
transform: (m) => `VAR:[${m[1].replace(/\s/g, "")}] = ${m[2].replace(/;$/, "").trim()}`,
|
|
261
|
+
confidence: 0.65
|
|
262
|
+
},
|
|
263
|
+
// const name = value;
|
|
264
|
+
{
|
|
265
|
+
match: /^(?:export\s+)?(?:const|let|var)\s+(\w+)(?:\s*:\s*[^=]+)?\s*=\s*(.+);?\s*$/,
|
|
266
|
+
transform: (m) => {
|
|
267
|
+
const prefix = m[0].startsWith("export") ? "OUT " : "";
|
|
268
|
+
return `${prefix}VAR:${m[1]} = ${m[2].replace(/;$/, "").trim()}`;
|
|
269
|
+
},
|
|
270
|
+
confidence: 0.7
|
|
271
|
+
}
|
|
272
|
+
];
|
|
273
|
+
function fingerprintLine(line) {
|
|
274
|
+
const trimmed = line.trim();
|
|
275
|
+
if (trimmed === "" || trimmed === "{" || trimmed === "}" || trimmed === "});" || trimmed === ");") {
|
|
276
|
+
return { type: "fingerprint", ir: "", confidence: 1 };
|
|
277
|
+
}
|
|
278
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) {
|
|
279
|
+
return { type: "fingerprint", ir: "", confidence: 1 };
|
|
280
|
+
}
|
|
281
|
+
for (const pattern of PATTERNS) {
|
|
282
|
+
const match = trimmed.match(pattern.match);
|
|
283
|
+
if (match) {
|
|
284
|
+
const ir = pattern.transform(match);
|
|
285
|
+
if (pattern.confidence > 0.9) {
|
|
286
|
+
return { type: "fingerprint", ir, confidence: pattern.confidence };
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
type: "fingerprint+hint",
|
|
290
|
+
ir,
|
|
291
|
+
hint: trimmed,
|
|
292
|
+
confidence: pattern.confidence
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return { type: "raw", ir: trimmed, confidence: 0.3 };
|
|
297
|
+
}
|
|
298
|
+
function fingerprintFile(code, confidenceThreshold = 0.6) {
|
|
299
|
+
const lines = code.split("\n");
|
|
300
|
+
const irLines = [];
|
|
301
|
+
for (const line of lines) {
|
|
302
|
+
const indent = line.search(/\S/);
|
|
303
|
+
const indentStr = indent > 0 ? " ".repeat(Math.floor(indent / 2)) : "";
|
|
304
|
+
const result = fingerprintLine(line);
|
|
305
|
+
if (result.ir === "") continue;
|
|
306
|
+
if (result.confidence >= confidenceThreshold) {
|
|
307
|
+
irLines.push(`${indentStr}${result.ir}`);
|
|
308
|
+
} else {
|
|
309
|
+
irLines.push(`${indentStr}${result.ir}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return irLines.join("\n");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/ir/health.ts
|
|
316
|
+
var CHURN_THRESHOLD = 10;
|
|
317
|
+
var FIX_RATIO_THRESHOLD = 0.5;
|
|
318
|
+
function buildHealthTag(health) {
|
|
319
|
+
const parts = [];
|
|
320
|
+
if (health.churn > CHURN_THRESHOLD) parts.push(`HOT:${health.churn}/30`);
|
|
321
|
+
if (health.fixRatio > FIX_RATIO_THRESHOLD) parts.push(`FIX:${Math.round(health.fixRatio * 100)}%`);
|
|
322
|
+
if (health.coverageTrend === "down") parts.push("COV:\u2193");
|
|
323
|
+
if (health.consistency === "low") parts.push("INCON");
|
|
324
|
+
return parts.length > 0 ? `[${parts.join(" ")}]` : "";
|
|
325
|
+
}
|
|
326
|
+
function annotateIR(ir, health) {
|
|
327
|
+
const tag = buildHealthTag(health);
|
|
328
|
+
if (!tag) return ir;
|
|
329
|
+
const lines = ir.split("\n");
|
|
330
|
+
lines[0] = `${lines[0]} ${tag}`;
|
|
331
|
+
return lines.join("\n");
|
|
332
|
+
}
|
|
333
|
+
function computeHealthFromTrends(file, trends) {
|
|
334
|
+
const hotspot = trends.hotspots.find((h) => h.file === file);
|
|
335
|
+
const decay = trends.decaySignals.find((d) => d.file === file);
|
|
336
|
+
const inconsistency = trends.inconsistencies.find((i) => i.file === file);
|
|
337
|
+
return {
|
|
338
|
+
churn: hotspot?.changesInLast30Commits ?? 0,
|
|
339
|
+
fixRatio: hotspot?.bugFixRatio ?? 0,
|
|
340
|
+
coverageTrend: decay?.trend === "declining" ? "down" : decay?.trend === "improving" ? "up" : "stable",
|
|
341
|
+
staleness: "",
|
|
342
|
+
authorCount: hotspot?.authorCount ?? 0,
|
|
343
|
+
consistency: inconsistency ? "low" : "high"
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/ir/layers.ts
|
|
348
|
+
function generateL0(code, filePath) {
|
|
349
|
+
const structure = extractStructure(code);
|
|
350
|
+
const topLevel = structure.filter(
|
|
351
|
+
(s) => s.indent === 0 && ["function-start", "type-start", "export"].includes(s.type)
|
|
352
|
+
);
|
|
353
|
+
const declarations = topLevel.map((s) => {
|
|
354
|
+
const name = s.raw.match(
|
|
355
|
+
/(?:function|class|interface|const|let|var|export\s+(?:default\s+)?(?:function|class|async\s+function))\s+(\w+)/
|
|
356
|
+
)?.[1] ?? "unknown";
|
|
357
|
+
return ` ${s.type === "type-start" ? "CLASS" : "FN"}:${name} L${s.line}`;
|
|
358
|
+
});
|
|
359
|
+
return `${filePath}
|
|
360
|
+
${declarations.join("\n")}`;
|
|
361
|
+
}
|
|
362
|
+
function generateL1(code, health) {
|
|
363
|
+
const ir = fingerprintFile(code, 0.6);
|
|
364
|
+
if (health) {
|
|
365
|
+
return annotateIR(ir, health);
|
|
366
|
+
}
|
|
367
|
+
return ir;
|
|
368
|
+
}
|
|
369
|
+
function generateL2(delta, health) {
|
|
370
|
+
const parts = [`FILE: ${delta.file}`];
|
|
371
|
+
for (const hunk of delta.hunks) {
|
|
372
|
+
if (hunk.functionScope) parts.push(`SCOPE: ${hunk.functionScope}`);
|
|
373
|
+
parts.push(`CHANGED: ${hunk.changed.join("\n ")}`);
|
|
374
|
+
if (hunk.surroundingIR) parts.push(`CONTEXT: ${hunk.surroundingIR}`);
|
|
375
|
+
if (hunk.blame) {
|
|
376
|
+
parts.push(`BLAME: ${hunk.blame.author}, ${hunk.blame.date}, commit:"${hunk.blame.commitMessage}"`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const ir = parts.join("\n");
|
|
380
|
+
if (health) return annotateIR(ir, health);
|
|
381
|
+
return ir;
|
|
382
|
+
}
|
|
383
|
+
function generateL3(code, startLine, endLine) {
|
|
384
|
+
const lines = code.split("\n");
|
|
385
|
+
return lines.slice(startLine - 1, endLine).join("\n");
|
|
386
|
+
}
|
|
387
|
+
function generateLayer(layer, options) {
|
|
388
|
+
switch (layer) {
|
|
389
|
+
case "L0":
|
|
390
|
+
return generateL0(options.code, options.filePath);
|
|
391
|
+
case "L1":
|
|
392
|
+
return generateL1(options.code, options.health);
|
|
393
|
+
case "L2":
|
|
394
|
+
if (!options.delta) return generateL1(options.code, options.health);
|
|
395
|
+
return generateL2(options.delta, options.health);
|
|
396
|
+
case "L3":
|
|
397
|
+
if (options.lineRange) {
|
|
398
|
+
return generateL3(options.code, options.lineRange.start, options.lineRange.end);
|
|
399
|
+
}
|
|
400
|
+
return options.code;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/trends/git-log-parser.ts
|
|
405
|
+
import { execSync } from "child_process";
|
|
406
|
+
var BUG_FIX_PATTERNS = [
|
|
407
|
+
/\bfix\b/i,
|
|
408
|
+
/\bbugfix\b/i,
|
|
409
|
+
/\bhotfix\b/i,
|
|
410
|
+
/\bpatch\b/i,
|
|
411
|
+
/\bresolve\b/i,
|
|
412
|
+
/\bbug\b/i
|
|
413
|
+
];
|
|
414
|
+
function isBugFixCommit(message) {
|
|
415
|
+
return BUG_FIX_PATTERNS.some((p) => p.test(message));
|
|
416
|
+
}
|
|
417
|
+
function parseGitLogOutput(output) {
|
|
418
|
+
const entries = [];
|
|
419
|
+
const lines = output.split("\n");
|
|
420
|
+
let i = 0;
|
|
421
|
+
while (i < lines.length) {
|
|
422
|
+
const line = lines[i].trim();
|
|
423
|
+
if (!line || !line.includes("|")) {
|
|
424
|
+
i++;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const [hash, author, date, ...messageParts] = line.split("|");
|
|
428
|
+
const message = messageParts.join("|");
|
|
429
|
+
const files = [];
|
|
430
|
+
i++;
|
|
431
|
+
while (i < lines.length && lines[i].trim() !== "" && !lines[i].includes("|")) {
|
|
432
|
+
const fileLine = lines[i].trim();
|
|
433
|
+
if (fileLine) files.push(fileLine);
|
|
434
|
+
i++;
|
|
435
|
+
}
|
|
436
|
+
entries.push({ hash, author, date, message, files });
|
|
437
|
+
}
|
|
438
|
+
return entries;
|
|
439
|
+
}
|
|
440
|
+
function getGitLog(repoPath, count = 100) {
|
|
441
|
+
try {
|
|
442
|
+
const output = execSync(
|
|
443
|
+
`git log --format="%h|%an|%as|%s" --name-only -n ${count}`,
|
|
444
|
+
{ cwd: repoPath, encoding: "utf-8", timeout: 1e4 }
|
|
445
|
+
);
|
|
446
|
+
return parseGitLogOutput(output);
|
|
447
|
+
} catch {
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/trends/hotspot.ts
|
|
453
|
+
function detectHotspots(entries, options) {
|
|
454
|
+
const fileStats = /* @__PURE__ */ new Map();
|
|
455
|
+
for (const entry of entries) {
|
|
456
|
+
const isFix = isBugFixCommit(entry.message);
|
|
457
|
+
for (const file of entry.files) {
|
|
458
|
+
const stats = fileStats.get(file) ?? { changes: 0, fixes: 0, authors: /* @__PURE__ */ new Set() };
|
|
459
|
+
stats.changes++;
|
|
460
|
+
if (isFix) stats.fixes++;
|
|
461
|
+
stats.authors.add(entry.author);
|
|
462
|
+
fileStats.set(file, stats);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const hotspots = [];
|
|
466
|
+
for (const [file, stats] of fileStats) {
|
|
467
|
+
const fixRatio = stats.changes > 0 ? stats.fixes / stats.changes : 0;
|
|
468
|
+
if (stats.changes >= options.threshold && fixRatio >= options.fixRatioThreshold) {
|
|
469
|
+
hotspots.push({
|
|
470
|
+
file,
|
|
471
|
+
changesInLast30Commits: stats.changes,
|
|
472
|
+
bugFixRatio: fixRatio,
|
|
473
|
+
authorCount: stats.authors.size
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return hotspots.sort((a, b) => b.changesInLast30Commits - a.changesInLast30Commits);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// src/trends/decay.ts
|
|
481
|
+
function detectDecay(entries) {
|
|
482
|
+
const fileChanges = /* @__PURE__ */ new Map();
|
|
483
|
+
for (const entry of entries) {
|
|
484
|
+
for (const file of entry.files) {
|
|
485
|
+
const changes = fileChanges.get(file) ?? [];
|
|
486
|
+
changes.push({ date: entry.date });
|
|
487
|
+
fileChanges.set(file, changes);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const signals = [];
|
|
491
|
+
for (const [file, changes] of fileChanges) {
|
|
492
|
+
if (changes.length < 4) continue;
|
|
493
|
+
const sorted = [...changes].sort((a, b) => a.date.localeCompare(b.date));
|
|
494
|
+
const firstDate = new Date(sorted[0].date).getTime();
|
|
495
|
+
const lastDate = new Date(sorted[sorted.length - 1].date).getTime();
|
|
496
|
+
const midDate = firstDate + (lastDate - firstDate) / 2;
|
|
497
|
+
const firstHalfCount = sorted.filter((c) => new Date(c.date).getTime() <= midDate).length;
|
|
498
|
+
const secondHalfCount = sorted.length - firstHalfCount;
|
|
499
|
+
if (secondHalfCount > firstHalfCount) {
|
|
500
|
+
signals.push({
|
|
501
|
+
file,
|
|
502
|
+
metric: "churn",
|
|
503
|
+
trend: "declining",
|
|
504
|
+
dataPoints: sorted.map((c, i) => ({ date: c.date, value: i + 1 }))
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return signals;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// src/trends/inconsistency.ts
|
|
512
|
+
function detectInconsistencies(entries, minAuthors = 3) {
|
|
513
|
+
const fileAuthors = /* @__PURE__ */ new Map();
|
|
514
|
+
for (const entry of entries) {
|
|
515
|
+
for (const file of entry.files) {
|
|
516
|
+
const authors = fileAuthors.get(file) ?? /* @__PURE__ */ new Map();
|
|
517
|
+
const commits = authors.get(entry.author) ?? [];
|
|
518
|
+
commits.push(entry.message);
|
|
519
|
+
authors.set(entry.author, commits);
|
|
520
|
+
fileAuthors.set(file, authors);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const inconsistencies = [];
|
|
524
|
+
for (const [file, authors] of fileAuthors) {
|
|
525
|
+
if (authors.size >= minAuthors) {
|
|
526
|
+
const patterns = Array.from(authors.entries()).map(([author, commits]) => ({
|
|
527
|
+
author,
|
|
528
|
+
style: categorizeStyle(commits)
|
|
529
|
+
}));
|
|
530
|
+
inconsistencies.push({ file, patterns });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return inconsistencies;
|
|
534
|
+
}
|
|
535
|
+
function categorizeStyle(commits) {
|
|
536
|
+
const types = commits.map((m) => {
|
|
537
|
+
if (m.match(/^fix/i)) return "fix";
|
|
538
|
+
if (m.match(/^feat/i)) return "feature";
|
|
539
|
+
if (m.match(/^refactor/i)) return "refactor";
|
|
540
|
+
return "other";
|
|
541
|
+
});
|
|
542
|
+
const primary = mode(types);
|
|
543
|
+
return `primarily ${primary} (${commits.length} commits)`;
|
|
544
|
+
}
|
|
545
|
+
function mode(arr) {
|
|
546
|
+
const counts = /* @__PURE__ */ new Map();
|
|
547
|
+
for (const item of arr) {
|
|
548
|
+
counts.set(item, (counts.get(item) ?? 0) + 1);
|
|
549
|
+
}
|
|
550
|
+
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/router/router.ts
|
|
554
|
+
import picomatch2 from "picomatch";
|
|
555
|
+
var DEFAULT_ROUTES = [
|
|
556
|
+
{ pattern: "**/auth/**", agents: ["reviewer"], irLayer: "L1" },
|
|
557
|
+
{ pattern: "**/*.test.*", agents: ["reviewer"], irLayer: "L0" },
|
|
558
|
+
{ pattern: "**/*.spec.*", agents: ["reviewer"], irLayer: "L0" },
|
|
559
|
+
{ pattern: "**/*.md", agents: ["fixer"], irLayer: "L0" },
|
|
560
|
+
{ pattern: "**/*.json", agents: ["fixer"], irLayer: "L0" },
|
|
561
|
+
{ pattern: "**/*.yaml", agents: ["fixer"], irLayer: "L0" },
|
|
562
|
+
{ pattern: "**/*.yml", agents: ["fixer"], irLayer: "L0" }
|
|
563
|
+
];
|
|
564
|
+
var FALLBACK = {
|
|
565
|
+
agents: ["fixer"],
|
|
566
|
+
irLayer: "L1",
|
|
567
|
+
deterministic: true
|
|
568
|
+
};
|
|
569
|
+
function route(finding, rules) {
|
|
570
|
+
for (const rule of rules) {
|
|
571
|
+
if (!picomatch2.isMatch(finding.file, rule.pattern)) continue;
|
|
572
|
+
if (rule.contentSignal) {
|
|
573
|
+
const content = finding.message + (finding.file ?? "");
|
|
574
|
+
if (!rule.contentSignal.test(content)) continue;
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
agents: rule.agents,
|
|
578
|
+
irLayer: rule.irLayer,
|
|
579
|
+
deterministic: true
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
return FALLBACK;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// src/cli/adapter.ts
|
|
586
|
+
var CLIAdapter = class {
|
|
587
|
+
onFileChange(_event) {
|
|
588
|
+
}
|
|
589
|
+
onCommand(_cmd, _args) {
|
|
590
|
+
}
|
|
591
|
+
onApproval(_proposalId, _approved) {
|
|
592
|
+
}
|
|
593
|
+
notify(message) {
|
|
594
|
+
switch (message.type) {
|
|
595
|
+
case "finding":
|
|
596
|
+
this.printFinding(message.data);
|
|
597
|
+
break;
|
|
598
|
+
case "trend-report":
|
|
599
|
+
this.printTrendReport(message.data);
|
|
600
|
+
break;
|
|
601
|
+
case "proposal":
|
|
602
|
+
console.log(`
|
|
603
|
+
Proposal [${message.data.id}]: ${message.data.description}`);
|
|
604
|
+
for (const f of message.data.findings) this.printFinding(f);
|
|
605
|
+
break;
|
|
606
|
+
case "edit":
|
|
607
|
+
console.log(` [EDIT] ${message.data.file}`);
|
|
608
|
+
break;
|
|
609
|
+
case "question":
|
|
610
|
+
console.log(` [?] ${message.data.text}`);
|
|
611
|
+
if (message.data.options) {
|
|
612
|
+
message.data.options.forEach((o, i) => console.log(` ${i + 1}. ${o}`));
|
|
613
|
+
}
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
printFinding(finding) {
|
|
618
|
+
const icon = finding.severity === "critical" ? "!!" : finding.severity === "warning" ? " !" : " ";
|
|
619
|
+
const loc = finding.line ? `:${finding.line}` : "";
|
|
620
|
+
console.log(` ${icon} [${finding.severity.toUpperCase()}] ${finding.file}${loc}`);
|
|
621
|
+
console.log(` ${finding.message}`);
|
|
622
|
+
}
|
|
623
|
+
printTrendReport(trends) {
|
|
624
|
+
if (trends.hotspots.length === 0 && trends.decaySignals.length === 0 && trends.inconsistencies.length === 0) {
|
|
625
|
+
console.log(" Codebase is healthy. No trends to report.");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (trends.hotspots.length > 0) {
|
|
629
|
+
console.log("\n Hotspots:");
|
|
630
|
+
for (const h of trends.hotspots) {
|
|
631
|
+
console.log(` ${h.file} \u2014 ${h.changesInLast30Commits} changes, ${Math.round(h.bugFixRatio * 100)}% fixes, ${h.authorCount} authors`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (trends.decaySignals.length > 0) {
|
|
635
|
+
console.log("\n Decay Signals:");
|
|
636
|
+
for (const d of trends.decaySignals) {
|
|
637
|
+
console.log(` ${d.file} \u2014 ${d.metric} is ${d.trend}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (trends.inconsistencies.length > 0) {
|
|
641
|
+
console.log("\n Inconsistencies:");
|
|
642
|
+
for (const ic of trends.inconsistencies) {
|
|
643
|
+
console.log(` ${ic.file} \u2014 ${ic.patterns.length} different patterns`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
// src/benchmark/tokenizer.ts
|
|
650
|
+
function estimateTokens(text) {
|
|
651
|
+
if (!text) return 0;
|
|
652
|
+
const tokens = text.split(/[\s]+|(?<=[{}()[\];,.:=<>!&|?+\-*/^~@#$%\\])|(?=[{}()[\];,.:=<>!&|?+\-*/^~@#$%\\])/).filter(Boolean);
|
|
653
|
+
return tokens.length;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/benchmark/runner.ts
|
|
657
|
+
function benchmarkFile(code, filePath) {
|
|
658
|
+
const rawTokens = estimateTokens(code);
|
|
659
|
+
const irL0 = generateLayer("L0", { code, filePath, health: null });
|
|
660
|
+
const irL1 = generateLayer("L1", { code, filePath, health: null });
|
|
661
|
+
const irL0Tokens = estimateTokens(irL0);
|
|
662
|
+
const irL1Tokens = estimateTokens(irL1);
|
|
663
|
+
const lines = code.split("\n");
|
|
664
|
+
let totalConf = 0;
|
|
665
|
+
let count = 0;
|
|
666
|
+
for (const line of lines) {
|
|
667
|
+
const result = fingerprintLine(line);
|
|
668
|
+
if (result.ir !== "") {
|
|
669
|
+
totalConf += result.confidence;
|
|
670
|
+
count++;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const savedPercent = rawTokens > 0 ? (rawTokens - irL1Tokens) / rawTokens * 100 : 0;
|
|
674
|
+
const avgConfidence = count > 0 ? totalConf / count : 0;
|
|
675
|
+
return { file: filePath, rawTokens, irL0Tokens, irL1Tokens, savedPercent, avgConfidence };
|
|
676
|
+
}
|
|
677
|
+
function summarize(results) {
|
|
678
|
+
const totalRaw = results.reduce((s, r) => s + r.rawTokens, 0);
|
|
679
|
+
const totalIRL0 = results.reduce((s, r) => s + r.irL0Tokens, 0);
|
|
680
|
+
const totalIRL1 = results.reduce((s, r) => s + r.irL1Tokens, 0);
|
|
681
|
+
const totalSavedPercent = totalRaw > 0 ? (totalRaw - totalIRL1) / totalRaw * 100 : 0;
|
|
682
|
+
const avgConfidence = results.length > 0 ? results.reduce((s, r) => s + r.avgConfidence, 0) / results.length : 0;
|
|
683
|
+
return { fileCount: results.length, totalRaw, totalIRL0, totalIRL1, totalSavedPercent, avgConfidence };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// src/cli/commands.ts
|
|
687
|
+
function collectFiles(dir, extensions) {
|
|
688
|
+
const files = [];
|
|
689
|
+
try {
|
|
690
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
691
|
+
for (const entry of entries) {
|
|
692
|
+
const fullPath = join2(dir, entry.name);
|
|
693
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") continue;
|
|
694
|
+
if (entry.isDirectory()) {
|
|
695
|
+
files.push(...collectFiles(fullPath, extensions));
|
|
696
|
+
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
|
697
|
+
files.push(fullPath);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
} catch {
|
|
701
|
+
}
|
|
702
|
+
return files;
|
|
703
|
+
}
|
|
704
|
+
function runScan(projectPath) {
|
|
705
|
+
const adapter = new CLIAdapter();
|
|
706
|
+
const config = loadConfig(projectPath);
|
|
707
|
+
console.log("composto v0.1.0 \u2014 scanning...\n");
|
|
708
|
+
const files = collectFiles(projectPath, [".ts", ".tsx", ".js", ".jsx"]);
|
|
709
|
+
console.log(` Found ${files.length} files
|
|
710
|
+
`);
|
|
711
|
+
const allFindings = [];
|
|
712
|
+
for (const file of files) {
|
|
713
|
+
const code = readFileSync2(file, "utf-8");
|
|
714
|
+
const relPath = relative(projectPath, file);
|
|
715
|
+
const findings = runDetector(code, relPath, config.watchers);
|
|
716
|
+
allFindings.push(...findings);
|
|
717
|
+
}
|
|
718
|
+
if (allFindings.length > 0) {
|
|
719
|
+
console.log(` Findings (${allFindings.length}):
|
|
720
|
+
`);
|
|
721
|
+
for (const finding of allFindings) {
|
|
722
|
+
adapter.notify({ type: "finding", data: finding });
|
|
723
|
+
const decision = route(finding, DEFAULT_ROUTES);
|
|
724
|
+
console.log(` -> Route: ${decision.agents.join(",")} @ ${decision.irLayer}
|
|
725
|
+
`);
|
|
726
|
+
}
|
|
727
|
+
} else {
|
|
728
|
+
console.log(" No issues found.\n");
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function runTrends(projectPath) {
|
|
732
|
+
const adapter = new CLIAdapter();
|
|
733
|
+
const config = loadConfig(projectPath);
|
|
734
|
+
console.log("composto v0.1.0 \u2014 trend analysis...\n");
|
|
735
|
+
const entries = getGitLog(projectPath, 100);
|
|
736
|
+
if (entries.length === 0) {
|
|
737
|
+
console.log(" No git history found.\n");
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
console.log(` Analyzed ${entries.length} commits
|
|
741
|
+
`);
|
|
742
|
+
const trends = {
|
|
743
|
+
hotspots: detectHotspots(entries, {
|
|
744
|
+
threshold: config.trends.hotspotThreshold,
|
|
745
|
+
fixRatioThreshold: config.trends.bugFixRatioThreshold
|
|
746
|
+
}),
|
|
747
|
+
decaySignals: detectDecay(entries),
|
|
748
|
+
inconsistencies: detectInconsistencies(entries)
|
|
749
|
+
};
|
|
750
|
+
adapter.notify({ type: "trend-report", data: trends });
|
|
751
|
+
}
|
|
752
|
+
function runIR(projectPath, filePath, layer) {
|
|
753
|
+
const config = loadConfig(projectPath);
|
|
754
|
+
const code = readFileSync2(filePath, "utf-8");
|
|
755
|
+
const relPath = relative(projectPath, filePath);
|
|
756
|
+
const entries = getGitLog(projectPath, 100);
|
|
757
|
+
const trends = {
|
|
758
|
+
hotspots: detectHotspots(entries, {
|
|
759
|
+
threshold: config.trends.hotspotThreshold,
|
|
760
|
+
fixRatioThreshold: config.trends.bugFixRatioThreshold
|
|
761
|
+
}),
|
|
762
|
+
decaySignals: detectDecay(entries),
|
|
763
|
+
inconsistencies: detectInconsistencies(entries)
|
|
764
|
+
};
|
|
765
|
+
const health = computeHealthFromTrends(relPath, trends);
|
|
766
|
+
const irLayer = layer || "L1";
|
|
767
|
+
const result = generateLayer(irLayer, {
|
|
768
|
+
code,
|
|
769
|
+
filePath: relPath,
|
|
770
|
+
health: health.churn > 0 ? health : null
|
|
771
|
+
});
|
|
772
|
+
console.log(result);
|
|
773
|
+
}
|
|
774
|
+
function runBenchmark(projectPath) {
|
|
775
|
+
console.log("composto v0.1.0 \u2014 benchmark\n");
|
|
776
|
+
const files = collectFiles(projectPath, [".ts", ".tsx", ".js", ".jsx"]);
|
|
777
|
+
console.log(` ${files.length} files
|
|
778
|
+
`);
|
|
779
|
+
const results = files.map((file) => {
|
|
780
|
+
const code = readFileSync2(file, "utf-8");
|
|
781
|
+
const relPath = relative(projectPath, file);
|
|
782
|
+
return benchmarkFile(code, relPath);
|
|
783
|
+
});
|
|
784
|
+
results.sort((a, b) => b.savedPercent - a.savedPercent);
|
|
785
|
+
const header = " File Raw L0 L1 Saved Conf";
|
|
786
|
+
const divider = " " + "\u2500".repeat(header.length - 2);
|
|
787
|
+
console.log(header);
|
|
788
|
+
console.log(divider);
|
|
789
|
+
for (const r of results) {
|
|
790
|
+
const file = r.file.length > 38 ? "\u2026" + r.file.slice(-37) : r.file.padEnd(38);
|
|
791
|
+
const raw = String(r.rawTokens).padStart(5);
|
|
792
|
+
const l0 = String(r.irL0Tokens).padStart(7);
|
|
793
|
+
const l1 = String(r.irL1Tokens).padStart(7);
|
|
794
|
+
const saved = (r.savedPercent.toFixed(1) + "%").padStart(7);
|
|
795
|
+
const conf = r.avgConfidence.toFixed(2).padStart(6);
|
|
796
|
+
console.log(` ${file} ${raw} ${l0} ${l1} ${saved} ${conf}`);
|
|
797
|
+
}
|
|
798
|
+
const summary = summarize(results);
|
|
799
|
+
console.log(divider);
|
|
800
|
+
const totalLabel = "TOTAL".padEnd(38);
|
|
801
|
+
const totalRaw = String(summary.totalRaw).padStart(5);
|
|
802
|
+
const totalL0 = String(summary.totalIRL0).padStart(7);
|
|
803
|
+
const totalL1 = String(summary.totalIRL1).padStart(7);
|
|
804
|
+
const totalSaved = (summary.totalSavedPercent.toFixed(1) + "%").padStart(7);
|
|
805
|
+
const totalConf = summary.avgConfidence.toFixed(2).padStart(6);
|
|
806
|
+
console.log(` ${totalLabel} ${totalRaw} ${totalL0} ${totalL1} ${totalSaved} ${totalConf}`);
|
|
807
|
+
const l0Percent = summary.totalRaw > 0 ? (summary.totalRaw - summary.totalIRL0) / summary.totalRaw * 100 : 0;
|
|
808
|
+
console.log(`
|
|
809
|
+
L0 (structure map): ${summary.totalRaw} \u2192 ${summary.totalIRL0} tokens (${l0Percent.toFixed(1)}% reduction)`);
|
|
810
|
+
console.log(` L1 (full IR): ${summary.totalRaw} \u2192 ${summary.totalIRL1} tokens (${summary.totalSavedPercent.toFixed(1)}% reduction)`);
|
|
811
|
+
console.log(` Files analyzed: ${summary.fileCount}`);
|
|
812
|
+
console.log(` Avg confidence: ${summary.avgConfidence.toFixed(2)}`);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// src/index.ts
|
|
816
|
+
import { resolve } from "path";
|
|
817
|
+
var args = process.argv.slice(2);
|
|
818
|
+
var command = args[0];
|
|
819
|
+
switch (command) {
|
|
820
|
+
case "scan": {
|
|
821
|
+
const projectPath = resolve(args[1] ?? ".");
|
|
822
|
+
runScan(projectPath);
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
case "trends": {
|
|
826
|
+
const projectPath = resolve(args[1] ?? ".");
|
|
827
|
+
runTrends(projectPath);
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
case "ir": {
|
|
831
|
+
const filePath = args[1];
|
|
832
|
+
const layer = args[2] ?? "L1";
|
|
833
|
+
if (!filePath) {
|
|
834
|
+
console.error("Usage: composto ir <file> [L0|L1|L2|L3]");
|
|
835
|
+
process.exit(1);
|
|
836
|
+
}
|
|
837
|
+
runIR(resolve("."), resolve(filePath), layer);
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
case "benchmark": {
|
|
841
|
+
const projectPath = resolve(args[1] ?? ".");
|
|
842
|
+
runBenchmark(projectPath);
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
case "version":
|
|
846
|
+
console.log("composto v0.1.0");
|
|
847
|
+
break;
|
|
848
|
+
default:
|
|
849
|
+
console.log("composto v0.1.0 \u2014 less tokens, more insight\n");
|
|
850
|
+
console.log("Commands:");
|
|
851
|
+
console.log(" scan [path] Scan codebase for issues");
|
|
852
|
+
console.log(" trends [path] Analyze codebase health trends");
|
|
853
|
+
console.log(" ir <file> [layer] Generate IR for a file (L0|L1|L2|L3)");
|
|
854
|
+
console.log(" benchmark [path] Benchmark IR token savings");
|
|
855
|
+
console.log(" version Show version");
|
|
856
|
+
break;
|
|
857
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "composto-ai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Proactive AI team companion — less tokens, more insight",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"composto": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/mertcanaltin/composto"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"dev": "tsx src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"keywords": ["ai", "coding", "companion", "proactive"],
|
|
24
|
+
"author": "Mert Can Altin",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"packageManager": "pnpm@10.30.1",
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^25.5.2",
|
|
29
|
+
"@types/picomatch": "^4.0.3",
|
|
30
|
+
"tsup": "^8.5.1",
|
|
31
|
+
"tsx": "^4.21.0",
|
|
32
|
+
"typescript": "^6.0.2",
|
|
33
|
+
"vitest": "^4.1.4"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@anthropic-ai/sdk": "^0.87.0",
|
|
37
|
+
"picomatch": "^4.0.4",
|
|
38
|
+
"yaml": "^2.8.3"
|
|
39
|
+
}
|
|
40
|
+
}
|