autonomous-flow-daemon 1.0.0 → 1.6.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/CHANGELOG.md +39 -0
- package/README.ko.md +142 -125
- package/README.md +119 -134
- package/package.json +11 -5
- package/src/adapters/index.ts +247 -35
- package/src/cli.ts +79 -1
- package/src/commands/benchmark.ts +187 -0
- package/src/commands/diagnose.ts +56 -14
- package/src/commands/doctor.ts +243 -0
- package/src/commands/evolution.ts +107 -0
- package/src/commands/fix.ts +22 -2
- package/src/commands/hooks.ts +136 -0
- package/src/commands/lang.ts +41 -0
- package/src/commands/mcp.ts +129 -0
- package/src/commands/restart.ts +14 -0
- package/src/commands/score.ts +192 -64
- package/src/commands/start.ts +137 -37
- package/src/commands/stats.ts +103 -0
- package/src/commands/status.ts +157 -0
- package/src/commands/stop.ts +42 -9
- package/src/commands/sync.ts +253 -20
- package/src/commands/vaccine.ts +177 -0
- package/src/constants.ts +26 -1
- package/src/core/boast.ts +280 -0
- package/src/core/config.ts +49 -0
- package/src/core/db.ts +74 -3
- package/src/core/discovery.ts +65 -0
- package/src/core/evolution.ts +215 -0
- package/src/core/hologram/engine.ts +71 -0
- package/src/core/hologram/fallback.ts +11 -0
- package/src/core/hologram/incremental.ts +227 -0
- package/src/core/hologram/py-extractor.ts +132 -0
- package/src/core/hologram/ts-extractor.ts +320 -0
- package/src/core/hologram/types.ts +25 -0
- package/src/core/hologram.ts +64 -236
- package/src/core/hook-manager.ts +259 -0
- package/src/core/i18n/messages.ts +309 -0
- package/src/core/immune.ts +8 -123
- package/src/core/locale.ts +88 -0
- package/src/core/log-rotate.ts +33 -0
- package/src/core/log-utils.ts +38 -0
- package/src/core/lru-map.ts +61 -0
- package/src/core/notify.ts +53 -14
- package/src/core/rule-engine.ts +287 -0
- package/src/core/semantic-diff.ts +432 -0
- package/src/core/telemetry.ts +94 -0
- package/src/core/vaccine-registry.ts +212 -0
- package/src/core/workspace.ts +28 -0
- package/src/core/yaml-minimal.ts +176 -0
- package/src/daemon/client.ts +34 -6
- package/src/daemon/event-batcher.ts +108 -0
- package/src/daemon/guards.ts +13 -0
- package/src/daemon/http-routes.ts +293 -0
- package/src/daemon/mcp-handler.ts +270 -0
- package/src/daemon/server.ts +492 -273
- package/src/daemon/types.ts +100 -0
- package/src/daemon/workspace-map.ts +92 -0
- package/src/platform.ts +60 -0
- package/src/version.ts +15 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Diagnostic Rule Engine
|
|
3
|
+
*
|
|
4
|
+
* Loads diagnostic rules from:
|
|
5
|
+
* 1. Built-in rules (hardcoded IMM-001~003 equivalents)
|
|
6
|
+
* 2. Project rules: .afd/rules/*.yml
|
|
7
|
+
*
|
|
8
|
+
* Each rule defines a condition + severity + auto-heal patches.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
12
|
+
import { join, extname } from "path";
|
|
13
|
+
import { parse as parseYaml } from "./yaml-minimal";
|
|
14
|
+
|
|
15
|
+
// Re-declare types locally to avoid circular import with immune.ts
|
|
16
|
+
interface Symptom {
|
|
17
|
+
id: string;
|
|
18
|
+
patternType: string;
|
|
19
|
+
fileTarget: string;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
severity: "critical" | "warning" | "info";
|
|
23
|
+
patches: PatchOp[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PatchOp {
|
|
27
|
+
op: "add" | "remove" | "replace" | "move" | "copy" | "test";
|
|
28
|
+
path: string;
|
|
29
|
+
value?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Rule Definition ──
|
|
33
|
+
|
|
34
|
+
export interface DiagnosticRule {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
description: string;
|
|
38
|
+
severity: "critical" | "warning" | "info";
|
|
39
|
+
condition: RuleCondition;
|
|
40
|
+
patches: PatchOp[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type RuleCondition =
|
|
44
|
+
| { type: "file-missing"; path: string }
|
|
45
|
+
| { type: "file-empty"; path: string; minLength?: number }
|
|
46
|
+
| { type: "file-invalid-json"; path: string }
|
|
47
|
+
| { type: "file-missing-line"; path: string; pattern: string }
|
|
48
|
+
| { type: "file-contains"; path: string; pattern: string }; // inverse: triggers when pattern IS found
|
|
49
|
+
|
|
50
|
+
// ── Built-in Rules (IMM-001~003) ──
|
|
51
|
+
|
|
52
|
+
const CLAUDEIGNORE_DEFAULT = `# Autonomous Flow Daemon defaults
|
|
53
|
+
node_modules/
|
|
54
|
+
dist/
|
|
55
|
+
.afd/
|
|
56
|
+
*.log
|
|
57
|
+
.env
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const HOOKS_DEFAULT = `{
|
|
61
|
+
"hooks": []
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const BUILTIN_RULES: DiagnosticRule[] = [
|
|
66
|
+
{
|
|
67
|
+
id: "IMM-001",
|
|
68
|
+
title: "Missing .claudeignore",
|
|
69
|
+
description:
|
|
70
|
+
"No .claudeignore found. Without it, AI agents ingest node_modules, build artifacts, and other noise — wasting tokens and degrading context quality.",
|
|
71
|
+
severity: "critical",
|
|
72
|
+
condition: { type: "file-missing", path: ".claudeignore" },
|
|
73
|
+
patches: [{ op: "add", path: "/.claudeignore", value: CLAUDEIGNORE_DEFAULT }],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "IMM-002",
|
|
77
|
+
title: "Missing or invalid hooks.json",
|
|
78
|
+
description:
|
|
79
|
+
"No valid .claude/hooks.json found. Without a hooks file, pre/post-command automation cannot be configured.",
|
|
80
|
+
severity: "warning",
|
|
81
|
+
condition: { type: "file-invalid-json", path: ".claude/hooks.json" },
|
|
82
|
+
patches: [{ op: "add", path: "/.claude/hooks.json", value: HOOKS_DEFAULT }],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "IMM-003",
|
|
86
|
+
title: "Missing or empty CLAUDE.md",
|
|
87
|
+
description:
|
|
88
|
+
"No CLAUDE.md found or content is too short. AI agents have no project constitution to follow.",
|
|
89
|
+
severity: "critical",
|
|
90
|
+
condition: { type: "file-empty", path: "CLAUDE.md", minLength: 20 },
|
|
91
|
+
patches: [{ op: "add", path: "/CLAUDE.md", value: "# Project Constitution\n\n<!-- Add project rules here -->\n" }],
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// ── Condition Evaluator ──
|
|
96
|
+
|
|
97
|
+
function evaluateCondition(cond: RuleCondition): { triggered: boolean; detail?: string } {
|
|
98
|
+
switch (cond.type) {
|
|
99
|
+
case "file-missing":
|
|
100
|
+
return { triggered: !existsSync(cond.path) };
|
|
101
|
+
|
|
102
|
+
case "file-empty": {
|
|
103
|
+
if (!existsSync(cond.path)) return { triggered: true, detail: "file not found" };
|
|
104
|
+
try {
|
|
105
|
+
const content = readFileSync(cond.path, "utf-8").trim();
|
|
106
|
+
const min = cond.minLength ?? 1;
|
|
107
|
+
return {
|
|
108
|
+
triggered: content.length < min,
|
|
109
|
+
detail: `${content.length} chars (min: ${min})`,
|
|
110
|
+
};
|
|
111
|
+
} catch {
|
|
112
|
+
return { triggered: true, detail: "unreadable" };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case "file-invalid-json": {
|
|
117
|
+
if (!existsSync(cond.path)) return { triggered: true, detail: "file not found" };
|
|
118
|
+
try {
|
|
119
|
+
JSON.parse(readFileSync(cond.path, "utf-8"));
|
|
120
|
+
return { triggered: false };
|
|
121
|
+
} catch {
|
|
122
|
+
return { triggered: true, detail: "invalid JSON" };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case "file-missing-line": {
|
|
127
|
+
if (!existsSync(cond.path)) return { triggered: true, detail: "file not found" };
|
|
128
|
+
try {
|
|
129
|
+
const content = readFileSync(cond.path, "utf-8");
|
|
130
|
+
const regex = new RegExp(cond.pattern);
|
|
131
|
+
return { triggered: !regex.test(content), detail: `pattern /${cond.pattern}/ not found` };
|
|
132
|
+
} catch {
|
|
133
|
+
return { triggered: true, detail: "unreadable" };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case "file-contains": {
|
|
138
|
+
if (!existsSync(cond.path)) return { triggered: false }; // file doesn't exist → pattern can't be found
|
|
139
|
+
try {
|
|
140
|
+
const content = readFileSync(cond.path, "utf-8");
|
|
141
|
+
const regex = new RegExp(cond.pattern);
|
|
142
|
+
return { triggered: regex.test(content), detail: `pattern /${cond.pattern}/ found` };
|
|
143
|
+
} catch {
|
|
144
|
+
return { triggered: false };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
default:
|
|
149
|
+
return { triggered: false };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Rule Loader ──
|
|
154
|
+
|
|
155
|
+
function loadYamlRules(rulesDir: string): DiagnosticRule[] {
|
|
156
|
+
if (!existsSync(rulesDir)) return [];
|
|
157
|
+
|
|
158
|
+
const rules: DiagnosticRule[] = [];
|
|
159
|
+
|
|
160
|
+
let files: string[];
|
|
161
|
+
try {
|
|
162
|
+
files = readdirSync(rulesDir).filter(f => extname(f) === ".yml" || extname(f) === ".yaml");
|
|
163
|
+
} catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
const filePath = join(rulesDir, file);
|
|
169
|
+
try {
|
|
170
|
+
if (!statSync(filePath).isFile()) continue;
|
|
171
|
+
const content = readFileSync(filePath, "utf-8");
|
|
172
|
+
const parsed = parseYaml(content);
|
|
173
|
+
if (!parsed || !parsed.id || !parsed.condition) continue;
|
|
174
|
+
|
|
175
|
+
const rule: DiagnosticRule = {
|
|
176
|
+
id: String(parsed.id),
|
|
177
|
+
title: String(parsed.title ?? parsed.id),
|
|
178
|
+
description: String(parsed.description ?? ""),
|
|
179
|
+
severity: validateSeverity(parsed.severity),
|
|
180
|
+
condition: parseCondition(parsed.condition),
|
|
181
|
+
patches: parsePatchOps(parsed.patches),
|
|
182
|
+
};
|
|
183
|
+
rules.push(rule);
|
|
184
|
+
} catch {
|
|
185
|
+
// Skip malformed rule files — crash-only design
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return rules;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateSeverity(val: unknown): "critical" | "warning" | "info" {
|
|
193
|
+
if (val === "critical" || val === "warning" || val === "info") return val;
|
|
194
|
+
return "warning";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseCondition(raw: unknown): RuleCondition {
|
|
198
|
+
if (!raw || typeof raw !== "object") return { type: "file-missing", path: "" };
|
|
199
|
+
const obj = raw as Record<string, unknown>;
|
|
200
|
+
const type = String(obj.type ?? "file-missing");
|
|
201
|
+
const path = String(obj.path ?? "");
|
|
202
|
+
|
|
203
|
+
switch (type) {
|
|
204
|
+
case "file-missing":
|
|
205
|
+
return { type: "file-missing", path };
|
|
206
|
+
case "file-empty":
|
|
207
|
+
return { type: "file-empty", path, minLength: Number(obj.minLength ?? 1) };
|
|
208
|
+
case "file-invalid-json":
|
|
209
|
+
return { type: "file-invalid-json", path };
|
|
210
|
+
case "file-missing-line":
|
|
211
|
+
return { type: "file-missing-line", path, pattern: String(obj.pattern ?? "") };
|
|
212
|
+
case "file-contains":
|
|
213
|
+
return { type: "file-contains", path, pattern: String(obj.pattern ?? "") };
|
|
214
|
+
default:
|
|
215
|
+
return { type: "file-missing", path };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parsePatchOps(raw: unknown): PatchOp[] {
|
|
220
|
+
if (!Array.isArray(raw)) return [];
|
|
221
|
+
return raw
|
|
222
|
+
.filter((p): p is Record<string, unknown> => p && typeof p === "object")
|
|
223
|
+
.map(p => ({
|
|
224
|
+
op: (String(p.op ?? "add")) as PatchOp["op"],
|
|
225
|
+
path: String(p.path ?? ""),
|
|
226
|
+
value: p.value !== undefined ? String(p.value) : undefined,
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Public API ──
|
|
231
|
+
|
|
232
|
+
const RULES_DIR = join(".afd", "rules");
|
|
233
|
+
|
|
234
|
+
export function loadAllRules(): DiagnosticRule[] {
|
|
235
|
+
const custom = loadYamlRules(RULES_DIR);
|
|
236
|
+
// Custom rules can override built-in by ID
|
|
237
|
+
const customIds = new Set(custom.map(r => r.id));
|
|
238
|
+
const builtins = BUILTIN_RULES.filter(r => !customIds.has(r.id));
|
|
239
|
+
return [...builtins, ...custom];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function evaluateRules(
|
|
243
|
+
rules: DiagnosticRule[],
|
|
244
|
+
knownAntibodies: string[],
|
|
245
|
+
opts?: { raw?: boolean },
|
|
246
|
+
): { symptoms: Symptom[]; healthy: string[] } {
|
|
247
|
+
const symptoms: Symptom[] = [];
|
|
248
|
+
const healthy: string[] = [];
|
|
249
|
+
|
|
250
|
+
for (const rule of rules) {
|
|
251
|
+
const result = evaluateCondition(rule.condition);
|
|
252
|
+
|
|
253
|
+
if (!result.triggered) {
|
|
254
|
+
healthy.push("OK");
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// In raw mode, report all symptoms regardless of antibodies
|
|
259
|
+
if (!opts?.raw && knownAntibodies.includes(rule.id)) {
|
|
260
|
+
healthy.push(`${rule.id} (immunized)`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
symptoms.push({
|
|
265
|
+
id: rule.id,
|
|
266
|
+
patternType: rule.condition.type,
|
|
267
|
+
fileTarget: "path" in rule.condition ? rule.condition.path : "",
|
|
268
|
+
title: rule.title,
|
|
269
|
+
description: result.detail
|
|
270
|
+
? `${rule.description} (${result.detail})`
|
|
271
|
+
: rule.description,
|
|
272
|
+
severity: rule.severity,
|
|
273
|
+
patches: rule.patches,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { symptoms, healthy };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Convenience: load rules + evaluate in one call (replaces old diagnose()) */
|
|
281
|
+
export function diagnoseWithRules(
|
|
282
|
+
knownAntibodies: string[],
|
|
283
|
+
opts?: { raw?: boolean },
|
|
284
|
+
): { symptoms: Symptom[]; healthy: string[] } {
|
|
285
|
+
const rules = loadAllRules();
|
|
286
|
+
return evaluateRules(rules, knownAntibodies, opts);
|
|
287
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Diff Engine — AST-based change classification for TypeScript/JavaScript.
|
|
3
|
+
*
|
|
4
|
+
* Instead of line-by-line text diff, parses both old and new source into AST,
|
|
5
|
+
* extracts top-level declarations, and classifies changes as:
|
|
6
|
+
* - signature-change (breaking)
|
|
7
|
+
* - body-change (non-breaking)
|
|
8
|
+
* - added / removed
|
|
9
|
+
* - renamed
|
|
10
|
+
* - comment-only
|
|
11
|
+
*
|
|
12
|
+
* Falls back to text diff for non-TS/JS files.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import ts from "typescript";
|
|
16
|
+
|
|
17
|
+
export type ChangeKind =
|
|
18
|
+
| "signature-change" // function/class signature modified (breaking)
|
|
19
|
+
| "body-change" // implementation changed, signature intact
|
|
20
|
+
| "added" // new declaration
|
|
21
|
+
| "removed" // declaration deleted
|
|
22
|
+
| "export-change" // export modifier added/removed
|
|
23
|
+
| "comment-only" // only comments changed
|
|
24
|
+
| "whitespace-only" // only formatting changed
|
|
25
|
+
| "type-change" // type annotation changed
|
|
26
|
+
| "unknown";
|
|
27
|
+
|
|
28
|
+
export interface SemanticChange {
|
|
29
|
+
kind: ChangeKind;
|
|
30
|
+
name: string; // declaration name
|
|
31
|
+
nodeType: string; // "function", "class", "interface", "variable", etc.
|
|
32
|
+
breaking: boolean; // true if this could break consumers
|
|
33
|
+
detail?: string; // human-readable description
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SemanticDiffResult {
|
|
37
|
+
changes: SemanticChange[];
|
|
38
|
+
hasBreakingChanges: boolean;
|
|
39
|
+
summary: string; // one-line summary for logging
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface DeclInfo {
|
|
43
|
+
name: string;
|
|
44
|
+
nodeType: string;
|
|
45
|
+
signature: string; // type signature (no body)
|
|
46
|
+
body: string; // full text including body
|
|
47
|
+
exported: boolean;
|
|
48
|
+
hasComments: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Check if a file path is TypeScript or JavaScript */
|
|
52
|
+
export function isAstSupported(filePath: string): boolean {
|
|
53
|
+
return /\.(ts|tsx|js|jsx|mts|mjs|cts|cjs)$/.test(filePath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Generate semantic diff between old and new source */
|
|
57
|
+
export function semanticDiff(filePath: string, oldSource: string, newSource: string): SemanticDiffResult {
|
|
58
|
+
if (!isAstSupported(filePath)) {
|
|
59
|
+
return textFallback(oldSource, newSource);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const oldDecls = extractDeclarations(filePath, oldSource);
|
|
64
|
+
const newDecls = extractDeclarations(filePath, newSource);
|
|
65
|
+
return compareDeclarations(oldDecls, newDecls);
|
|
66
|
+
} catch {
|
|
67
|
+
// AST parsing failed — fall back to text diff
|
|
68
|
+
return textFallback(oldSource, newSource);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function extractDeclarations(filePath: string, source: string): Map<string, DeclInfo> {
|
|
73
|
+
const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
|
74
|
+
const decls = new Map<string, DeclInfo>();
|
|
75
|
+
|
|
76
|
+
for (const stmt of sf.statements) {
|
|
77
|
+
const infos = extractDeclInfos(stmt, source);
|
|
78
|
+
for (const info of infos) {
|
|
79
|
+
decls.set(info.name, info);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return decls;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extractDeclInfos(node: ts.Statement, _source: string): DeclInfo[] {
|
|
87
|
+
const exported = hasExportModifier(node);
|
|
88
|
+
const fullText = node.getFullText().trim();
|
|
89
|
+
const hasComments = /^\s*\/[/*]/.test(node.getFullText());
|
|
90
|
+
|
|
91
|
+
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
92
|
+
return [{
|
|
93
|
+
name: node.name.text,
|
|
94
|
+
nodeType: "function",
|
|
95
|
+
signature: getFunctionSignature(node),
|
|
96
|
+
body: fullText,
|
|
97
|
+
exported,
|
|
98
|
+
hasComments,
|
|
99
|
+
}];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
103
|
+
return [{
|
|
104
|
+
name: node.name.text,
|
|
105
|
+
nodeType: "class",
|
|
106
|
+
signature: getClassSignature(node),
|
|
107
|
+
body: fullText,
|
|
108
|
+
exported,
|
|
109
|
+
hasComments,
|
|
110
|
+
}];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (ts.isInterfaceDeclaration(node)) {
|
|
114
|
+
return [{
|
|
115
|
+
name: node.name.text,
|
|
116
|
+
nodeType: "interface",
|
|
117
|
+
signature: normalizeWhitespace(node.getText()),
|
|
118
|
+
body: fullText,
|
|
119
|
+
exported,
|
|
120
|
+
hasComments,
|
|
121
|
+
}];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (ts.isTypeAliasDeclaration(node)) {
|
|
125
|
+
return [{
|
|
126
|
+
name: node.name.text,
|
|
127
|
+
nodeType: "type",
|
|
128
|
+
signature: normalizeWhitespace(node.getText()),
|
|
129
|
+
body: fullText,
|
|
130
|
+
exported,
|
|
131
|
+
hasComments,
|
|
132
|
+
}];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (ts.isEnumDeclaration(node)) {
|
|
136
|
+
return [{
|
|
137
|
+
name: node.name.text,
|
|
138
|
+
nodeType: "enum",
|
|
139
|
+
signature: normalizeWhitespace(node.getText()),
|
|
140
|
+
body: fullText,
|
|
141
|
+
exported,
|
|
142
|
+
hasComments,
|
|
143
|
+
}];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (ts.isVariableStatement(node)) {
|
|
147
|
+
const results: DeclInfo[] = [];
|
|
148
|
+
for (const decl of node.declarationList.declarations) {
|
|
149
|
+
if (ts.isIdentifier(decl.name)) {
|
|
150
|
+
const typeStr = decl.type ? `: ${decl.type.getText()}` : "";
|
|
151
|
+
const isArrowFn = decl.initializer && ts.isArrowFunction(decl.initializer);
|
|
152
|
+
results.push({
|
|
153
|
+
name: decl.name.text,
|
|
154
|
+
nodeType: isArrowFn ? "function" : "variable",
|
|
155
|
+
signature: `${decl.name.text}${typeStr}`,
|
|
156
|
+
body: fullText,
|
|
157
|
+
exported,
|
|
158
|
+
hasComments,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (ts.isImportDeclaration(node)) {
|
|
166
|
+
const importText = normalizeWhitespace(node.getText());
|
|
167
|
+
return [{
|
|
168
|
+
name: `import:${importText}`,
|
|
169
|
+
nodeType: "import",
|
|
170
|
+
signature: importText,
|
|
171
|
+
body: fullText,
|
|
172
|
+
exported: false,
|
|
173
|
+
hasComments,
|
|
174
|
+
}];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (ts.isExportDeclaration(node)) {
|
|
178
|
+
const exportText = normalizeWhitespace(node.getText());
|
|
179
|
+
return [{
|
|
180
|
+
name: `export:${exportText}`,
|
|
181
|
+
nodeType: "export",
|
|
182
|
+
signature: exportText,
|
|
183
|
+
body: fullText,
|
|
184
|
+
exported: true,
|
|
185
|
+
hasComments,
|
|
186
|
+
}];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function compareDeclarations(
|
|
193
|
+
oldDecls: Map<string, DeclInfo>,
|
|
194
|
+
newDecls: Map<string, DeclInfo>,
|
|
195
|
+
): SemanticDiffResult {
|
|
196
|
+
const changes: SemanticChange[] = [];
|
|
197
|
+
|
|
198
|
+
// Check removed declarations
|
|
199
|
+
for (const [name, oldInfo] of oldDecls) {
|
|
200
|
+
if (!newDecls.has(name)) {
|
|
201
|
+
changes.push({
|
|
202
|
+
kind: "removed",
|
|
203
|
+
name,
|
|
204
|
+
nodeType: oldInfo.nodeType,
|
|
205
|
+
breaking: oldInfo.exported,
|
|
206
|
+
detail: oldInfo.exported ? `exported ${oldInfo.nodeType} removed` : `${oldInfo.nodeType} removed`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check added and modified declarations
|
|
212
|
+
for (const [name, newInfo] of newDecls) {
|
|
213
|
+
const oldInfo = oldDecls.get(name);
|
|
214
|
+
|
|
215
|
+
if (!oldInfo) {
|
|
216
|
+
changes.push({
|
|
217
|
+
kind: "added",
|
|
218
|
+
name,
|
|
219
|
+
nodeType: newInfo.nodeType,
|
|
220
|
+
breaking: false,
|
|
221
|
+
detail: `new ${newInfo.nodeType}`,
|
|
222
|
+
});
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Same full text → no change
|
|
227
|
+
if (normalizeWhitespace(oldInfo.body) === normalizeWhitespace(newInfo.body)) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Check export modifier change
|
|
232
|
+
if (oldInfo.exported !== newInfo.exported) {
|
|
233
|
+
changes.push({
|
|
234
|
+
kind: "export-change",
|
|
235
|
+
name,
|
|
236
|
+
nodeType: newInfo.nodeType,
|
|
237
|
+
breaking: oldInfo.exported && !newInfo.exported, // removing export is breaking
|
|
238
|
+
detail: newInfo.exported ? "export added" : "export removed",
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check if only comments changed
|
|
244
|
+
const oldNoComments = stripComments(oldInfo.body);
|
|
245
|
+
const newNoComments = stripComments(newInfo.body);
|
|
246
|
+
if (normalizeWhitespace(oldNoComments) === normalizeWhitespace(newNoComments)) {
|
|
247
|
+
changes.push({
|
|
248
|
+
kind: "comment-only",
|
|
249
|
+
name,
|
|
250
|
+
nodeType: oldInfo.nodeType,
|
|
251
|
+
breaking: false,
|
|
252
|
+
detail: "comments modified",
|
|
253
|
+
});
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Check if only whitespace changed
|
|
258
|
+
if (oldNoComments.replace(/\s+/g, "") === newNoComments.replace(/\s+/g, "")) {
|
|
259
|
+
changes.push({
|
|
260
|
+
kind: "whitespace-only",
|
|
261
|
+
name,
|
|
262
|
+
nodeType: oldInfo.nodeType,
|
|
263
|
+
breaking: false,
|
|
264
|
+
detail: "formatting changed",
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Signature changed?
|
|
270
|
+
if (oldInfo.signature !== newInfo.signature) {
|
|
271
|
+
// Check if it's just a type annotation change
|
|
272
|
+
const oldSigNoType = stripTypeAnnotations(oldInfo.signature);
|
|
273
|
+
const newSigNoType = stripTypeAnnotations(newInfo.signature);
|
|
274
|
+
if (oldSigNoType !== newSigNoType) {
|
|
275
|
+
changes.push({
|
|
276
|
+
kind: "signature-change",
|
|
277
|
+
name,
|
|
278
|
+
nodeType: oldInfo.nodeType,
|
|
279
|
+
breaking: oldInfo.exported,
|
|
280
|
+
detail: `${oldInfo.signature} → ${newInfo.signature}`,
|
|
281
|
+
});
|
|
282
|
+
} else {
|
|
283
|
+
changes.push({
|
|
284
|
+
kind: "type-change",
|
|
285
|
+
name,
|
|
286
|
+
nodeType: oldInfo.nodeType,
|
|
287
|
+
breaking: oldInfo.exported,
|
|
288
|
+
detail: "type annotation changed",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Signature same, body different → body-only change
|
|
295
|
+
changes.push({
|
|
296
|
+
kind: "body-change",
|
|
297
|
+
name,
|
|
298
|
+
nodeType: oldInfo.nodeType,
|
|
299
|
+
breaking: false,
|
|
300
|
+
detail: "implementation changed",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const hasBreakingChanges = changes.some(c => c.breaking);
|
|
305
|
+
const summary = buildSummary(changes);
|
|
306
|
+
|
|
307
|
+
return { changes, hasBreakingChanges, summary };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function buildSummary(changes: SemanticChange[]): string {
|
|
311
|
+
if (changes.length === 0) return "no semantic changes";
|
|
312
|
+
|
|
313
|
+
const counts: Record<string, number> = {};
|
|
314
|
+
for (const c of changes) {
|
|
315
|
+
counts[c.kind] = (counts[c.kind] ?? 0) + 1;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const parts: string[] = [];
|
|
319
|
+
for (const [kind, count] of Object.entries(counts)) {
|
|
320
|
+
parts.push(`${count} ${kind}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const breaking = changes.filter(c => c.breaking).length;
|
|
324
|
+
if (breaking > 0) parts.push(`⚠️ ${breaking} breaking`);
|
|
325
|
+
|
|
326
|
+
return parts.join(", ");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Helpers ──
|
|
330
|
+
|
|
331
|
+
function hasExportModifier(node: ts.Statement): boolean {
|
|
332
|
+
if (!ts.canHaveModifiers(node)) return false;
|
|
333
|
+
const mods = ts.getModifiers(node);
|
|
334
|
+
return mods?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getFunctionSignature(node: ts.FunctionDeclaration): string {
|
|
338
|
+
const name = node.name?.text ?? "anonymous";
|
|
339
|
+
const params = node.parameters.map(p => {
|
|
340
|
+
const pName = p.name.getText();
|
|
341
|
+
const pType = p.type ? `: ${p.type.getText()}` : "";
|
|
342
|
+
const optional = p.questionToken ? "?" : "";
|
|
343
|
+
return `${pName}${optional}${pType}`;
|
|
344
|
+
}).join(", ");
|
|
345
|
+
const ret = node.type ? `: ${node.type.getText()}` : "";
|
|
346
|
+
return `${name}(${params})${ret}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function getClassSignature(node: ts.ClassDeclaration): string {
|
|
350
|
+
const name = node.name?.text ?? "anonymous";
|
|
351
|
+
const heritage = node.heritageClauses?.map(h => h.getText()).join(" ") ?? "";
|
|
352
|
+
const members: string[] = [];
|
|
353
|
+
for (const member of node.members) {
|
|
354
|
+
if (ts.isPropertyDeclaration(member) || ts.isMethodDeclaration(member)) {
|
|
355
|
+
const mName = member.name?.getText() ?? "";
|
|
356
|
+
if (ts.isMethodDeclaration(member)) {
|
|
357
|
+
const params = member.parameters.map(p => p.getText()).join(", ");
|
|
358
|
+
const ret = member.type ? `: ${member.type.getText()}` : "";
|
|
359
|
+
members.push(`${mName}(${params})${ret}`);
|
|
360
|
+
} else {
|
|
361
|
+
const type = (member as ts.PropertyDeclaration).type?.getText() ?? "";
|
|
362
|
+
members.push(`${mName}: ${type}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return `class ${name} ${heritage} { ${members.join("; ")} }`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Strip type annotations from a signature, handling nested braces/parens/generics */
|
|
370
|
+
function stripTypeAnnotations(sig: string): string {
|
|
371
|
+
let result = "";
|
|
372
|
+
let depth = 0;
|
|
373
|
+
let inType = false;
|
|
374
|
+
for (let i = 0; i < sig.length; i++) {
|
|
375
|
+
const ch = sig[i];
|
|
376
|
+
if (ch === ":" && depth === 0 && !inType) {
|
|
377
|
+
inType = true;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (inType) {
|
|
381
|
+
if (ch === "<" || ch === "{" || ch === "(") depth++;
|
|
382
|
+
else if (ch === ">" || ch === "}" || ch === ")") {
|
|
383
|
+
if (depth > 0) { depth--; continue; }
|
|
384
|
+
// depth === 0 and closing paren → end of type, keep the paren
|
|
385
|
+
inType = false;
|
|
386
|
+
result += ch;
|
|
387
|
+
} else if (ch === "," && depth === 0) {
|
|
388
|
+
inType = false;
|
|
389
|
+
result += ch;
|
|
390
|
+
}
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
result += ch;
|
|
394
|
+
}
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function normalizeWhitespace(s: string): string {
|
|
399
|
+
return s.replace(/\s+/g, " ").trim();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function stripComments(source: string): string {
|
|
403
|
+
return source
|
|
404
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
405
|
+
.replace(/\/\/.*$/gm, "")
|
|
406
|
+
.trim();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Fallback for non-TS/JS files */
|
|
410
|
+
function textFallback(oldSource: string, newSource: string): SemanticDiffResult {
|
|
411
|
+
const oldLines = oldSource.split("\n");
|
|
412
|
+
const newLines = newSource.split("\n");
|
|
413
|
+
let added = 0, removed = 0, changed = 0;
|
|
414
|
+
const maxLen = Math.max(oldLines.length, newLines.length);
|
|
415
|
+
|
|
416
|
+
for (let i = 0; i < maxLen; i++) {
|
|
417
|
+
if (i >= oldLines.length) { added++; continue; }
|
|
418
|
+
if (i >= newLines.length) { removed++; continue; }
|
|
419
|
+
if (oldLines[i] !== newLines[i]) changed++;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const changes: SemanticChange[] = [];
|
|
423
|
+
if (added > 0) changes.push({ kind: "added", name: `${added} lines`, nodeType: "text", breaking: false });
|
|
424
|
+
if (removed > 0) changes.push({ kind: "removed", name: `${removed} lines`, nodeType: "text", breaking: false });
|
|
425
|
+
if (changed > 0) changes.push({ kind: "body-change", name: `${changed} lines`, nodeType: "text", breaking: false });
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
changes,
|
|
429
|
+
hasBreakingChanges: false,
|
|
430
|
+
summary: `text diff: +${added} -${removed} ~${changed} lines`,
|
|
431
|
+
};
|
|
432
|
+
}
|