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.
Files changed (59) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.ko.md +142 -125
  3. package/README.md +119 -134
  4. package/package.json +11 -5
  5. package/src/adapters/index.ts +247 -35
  6. package/src/cli.ts +79 -1
  7. package/src/commands/benchmark.ts +187 -0
  8. package/src/commands/diagnose.ts +56 -14
  9. package/src/commands/doctor.ts +243 -0
  10. package/src/commands/evolution.ts +107 -0
  11. package/src/commands/fix.ts +22 -2
  12. package/src/commands/hooks.ts +136 -0
  13. package/src/commands/lang.ts +41 -0
  14. package/src/commands/mcp.ts +129 -0
  15. package/src/commands/restart.ts +14 -0
  16. package/src/commands/score.ts +192 -64
  17. package/src/commands/start.ts +137 -37
  18. package/src/commands/stats.ts +103 -0
  19. package/src/commands/status.ts +157 -0
  20. package/src/commands/stop.ts +42 -9
  21. package/src/commands/sync.ts +253 -20
  22. package/src/commands/vaccine.ts +177 -0
  23. package/src/constants.ts +26 -1
  24. package/src/core/boast.ts +280 -0
  25. package/src/core/config.ts +49 -0
  26. package/src/core/db.ts +74 -3
  27. package/src/core/discovery.ts +65 -0
  28. package/src/core/evolution.ts +215 -0
  29. package/src/core/hologram/engine.ts +71 -0
  30. package/src/core/hologram/fallback.ts +11 -0
  31. package/src/core/hologram/incremental.ts +227 -0
  32. package/src/core/hologram/py-extractor.ts +132 -0
  33. package/src/core/hologram/ts-extractor.ts +320 -0
  34. package/src/core/hologram/types.ts +25 -0
  35. package/src/core/hologram.ts +64 -236
  36. package/src/core/hook-manager.ts +259 -0
  37. package/src/core/i18n/messages.ts +309 -0
  38. package/src/core/immune.ts +8 -123
  39. package/src/core/locale.ts +88 -0
  40. package/src/core/log-rotate.ts +33 -0
  41. package/src/core/log-utils.ts +38 -0
  42. package/src/core/lru-map.ts +61 -0
  43. package/src/core/notify.ts +53 -14
  44. package/src/core/rule-engine.ts +287 -0
  45. package/src/core/semantic-diff.ts +432 -0
  46. package/src/core/telemetry.ts +94 -0
  47. package/src/core/vaccine-registry.ts +212 -0
  48. package/src/core/workspace.ts +28 -0
  49. package/src/core/yaml-minimal.ts +176 -0
  50. package/src/daemon/client.ts +34 -6
  51. package/src/daemon/event-batcher.ts +108 -0
  52. package/src/daemon/guards.ts +13 -0
  53. package/src/daemon/http-routes.ts +293 -0
  54. package/src/daemon/mcp-handler.ts +270 -0
  55. package/src/daemon/server.ts +492 -273
  56. package/src/daemon/types.ts +100 -0
  57. package/src/daemon/workspace-map.ts +92 -0
  58. package/src/platform.ts +60 -0
  59. 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
+ }