autonomous-flow-daemon 1.1.0 → 1.9.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 +85 -46
- package/LICENSE +21 -21
- package/README-ko.md +282 -0
- package/README.md +282 -337
- package/mcp-config.json +10 -10
- package/package.json +14 -6
- package/src/adapters/index.ts +370 -159
- package/src/cli.ts +162 -57
- package/src/commands/benchmark.ts +187 -0
- package/src/commands/correlate.ts +180 -0
- package/src/commands/dashboard.ts +404 -0
- package/src/commands/diagnose.ts +56 -14
- package/src/commands/doctor.ts +243 -0
- package/src/commands/evolution.ts +190 -0
- package/src/commands/fix.ts +158 -138
- package/src/commands/hooks.ts +136 -0
- package/src/commands/lang.ts +41 -41
- package/src/commands/mcp.ts +129 -0
- package/src/commands/plugin.ts +110 -0
- package/src/commands/restart.ts +14 -0
- package/src/commands/score.ts +276 -208
- package/src/commands/start.ts +155 -96
- package/src/commands/stats.ts +103 -0
- package/src/commands/status.ts +157 -0
- package/src/commands/stop.ts +68 -49
- package/src/commands/suggest.ts +211 -0
- package/src/commands/sync.ts +567 -21
- package/src/commands/vaccine.ts +177 -0
- package/src/constants.ts +32 -8
- package/src/core/boast.ts +280 -265
- package/src/core/config.ts +49 -49
- package/src/core/correlation-engine.ts +265 -0
- package/src/core/db.ts +145 -46
- package/src/core/discovery.ts +65 -65
- package/src/core/evolution.ts +215 -0
- package/src/core/federation.ts +129 -0
- package/src/core/hologram/engine.ts +71 -0
- package/src/core/hologram/fallback.ts +11 -0
- package/src/core/hologram/go-extractor.ts +203 -0
- package/src/core/hologram/incremental.ts +227 -0
- package/src/core/hologram/py-extractor.ts +132 -0
- package/src/core/hologram/rust-extractor.ts +244 -0
- package/src/core/hologram/ts-extractor.ts +406 -0
- package/src/core/hologram/types.ts +27 -0
- package/src/core/hologram.ts +73 -243
- package/src/core/hook-manager.ts +259 -0
- package/src/core/i18n/messages.ts +309 -266
- package/src/core/immune.ts +8 -123
- package/src/core/locale.ts +88 -88
- 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 +74 -66
- package/src/core/plugin-manager.ts +225 -0
- package/src/core/rule-engine.ts +287 -0
- package/src/core/rule-suggestion.ts +127 -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/validator-generator.ts +224 -0
- package/src/core/workspace.ts +28 -0
- package/src/core/yaml-minimal.ts +176 -0
- package/src/daemon/client.ts +78 -37
- package/src/daemon/event-batcher.ts +108 -0
- package/src/daemon/guards.ts +13 -0
- package/src/daemon/http-routes.ts +376 -0
- package/src/daemon/mcp-handler.ts +575 -0
- package/src/daemon/mcp-subscriptions.ts +81 -0
- package/src/daemon/mesh.ts +51 -0
- package/src/daemon/server.ts +655 -504
- package/src/daemon/types.ts +121 -0
- package/src/daemon/workspace-map.ts +104 -0
- package/src/platform.ts +60 -39
- package/src/version.ts +15 -0
- package/README.ko.md +0 -306
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Manager — third-party validator adapter system.
|
|
3
|
+
*
|
|
4
|
+
* Plugin manifest: .afd/plugins/<name>.json
|
|
5
|
+
* Installed validator wrapper: .afd/validators/plugin-<name>.js
|
|
6
|
+
*
|
|
7
|
+
* Installation strategy:
|
|
8
|
+
* 1. `bun add <package>` → installs to workspace node_modules
|
|
9
|
+
* 2. Wrap the package's main export in .afd/validators/plugin-<name>.js
|
|
10
|
+
* 3. Write manifest to .afd/plugins/<name>.json
|
|
11
|
+
* Hot-reload is automatic (existing fs.watch on validators dir)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from "fs";
|
|
15
|
+
import { join, resolve } from "path";
|
|
16
|
+
import { spawnSync } from "child_process";
|
|
17
|
+
import { findWorkspaceRoot } from "./workspace";
|
|
18
|
+
|
|
19
|
+
// ── Public Types ─────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Contract for third-party validator plugins.
|
|
23
|
+
* The npm package's main export must satisfy this interface.
|
|
24
|
+
* A plugin can export a function directly or an object with a `validate` method.
|
|
25
|
+
*/
|
|
26
|
+
export interface ValidatorPlugin {
|
|
27
|
+
/** Return true if the content is CORRUPTED (should be blocked). */
|
|
28
|
+
validate(newContent: string, filePath: string): boolean;
|
|
29
|
+
/** Optional metadata shown by `afd plugin list`. */
|
|
30
|
+
meta?: {
|
|
31
|
+
name?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
version?: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PluginManifest {
|
|
38
|
+
name: string;
|
|
39
|
+
package: string;
|
|
40
|
+
version: string;
|
|
41
|
+
description: string;
|
|
42
|
+
source: "npm";
|
|
43
|
+
validatorFile: string;
|
|
44
|
+
installDate: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Paths ────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function pluginsDir(): string {
|
|
50
|
+
const root = findWorkspaceRoot();
|
|
51
|
+
return join(root, ".afd", "plugins");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validatorsDir(): string {
|
|
55
|
+
const root = findWorkspaceRoot();
|
|
56
|
+
return join(root, ".afd", "validators");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function manifestPath(name: string): string {
|
|
60
|
+
return join(pluginsDir(), `${name}.json`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function wrapperPath(name: string): string {
|
|
64
|
+
return join(validatorsDir(), `plugin-${name}.js`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Install ──────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export interface InstallResult {
|
|
70
|
+
success: boolean;
|
|
71
|
+
message: string;
|
|
72
|
+
manifest?: PluginManifest;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function installPlugin(packageName: string): InstallResult {
|
|
76
|
+
const root = findWorkspaceRoot();
|
|
77
|
+
|
|
78
|
+
// 1. bun add <package>
|
|
79
|
+
const addResult = spawnSync("bun", ["add", packageName], {
|
|
80
|
+
cwd: root,
|
|
81
|
+
encoding: "utf-8",
|
|
82
|
+
stdio: "pipe",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (addResult.status !== 0) {
|
|
86
|
+
return { success: false, message: addResult.stderr?.trim() || `Failed to install ${packageName}` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. Resolve installed package entry point
|
|
90
|
+
let resolvedMain: string;
|
|
91
|
+
try {
|
|
92
|
+
resolvedMain = require.resolve(packageName, { paths: [root] });
|
|
93
|
+
} catch {
|
|
94
|
+
// Fallback: try node_modules/<package>/index.js
|
|
95
|
+
const fallback = join(root, "node_modules", packageName, "index.js");
|
|
96
|
+
if (!existsSync(fallback)) {
|
|
97
|
+
return { success: false, message: `Cannot resolve entry point for ${packageName}` };
|
|
98
|
+
}
|
|
99
|
+
resolvedMain = fallback;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 3. Validate the plugin exports a usable validator
|
|
103
|
+
let pluginExport: unknown;
|
|
104
|
+
try {
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
106
|
+
pluginExport = require(resolvedMain);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return { success: false, message: `Failed to load plugin: ${(e as Error).message}` };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const isDirectFn = typeof pluginExport === "function";
|
|
112
|
+
const hasValidate = typeof (pluginExport as ValidatorPlugin)?.validate === "function";
|
|
113
|
+
if (!isDirectFn && !hasValidate) {
|
|
114
|
+
return {
|
|
115
|
+
success: false,
|
|
116
|
+
message: `Plugin must export a function or an object with a validate(newContent, filePath) method`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 4. Read package.json for version/description
|
|
121
|
+
let pkgVersion = "unknown";
|
|
122
|
+
let pkgDescription = "";
|
|
123
|
+
try {
|
|
124
|
+
const pkgJson = JSON.parse(
|
|
125
|
+
readFileSync(join(root, "node_modules", packageName, "package.json"), "utf-8")
|
|
126
|
+
);
|
|
127
|
+
pkgVersion = pkgJson.version ?? "unknown";
|
|
128
|
+
pkgDescription = pkgJson.description ?? "";
|
|
129
|
+
} catch { /* best-effort */ }
|
|
130
|
+
|
|
131
|
+
// 5. Write wrapper into .afd/validators/
|
|
132
|
+
mkdirSync(validatorsDir(), { recursive: true });
|
|
133
|
+
const safeName = packageName.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
134
|
+
const wrapper = buildWrapper(packageName, resolvedMain, isDirectFn);
|
|
135
|
+
writeFileSync(wrapperPath(safeName), wrapper, "utf-8");
|
|
136
|
+
|
|
137
|
+
// 6. Write manifest into .afd/plugins/
|
|
138
|
+
mkdirSync(pluginsDir(), { recursive: true });
|
|
139
|
+
const manifest: PluginManifest = {
|
|
140
|
+
name: safeName,
|
|
141
|
+
package: packageName,
|
|
142
|
+
version: pkgVersion,
|
|
143
|
+
description: pkgDescription,
|
|
144
|
+
source: "npm",
|
|
145
|
+
validatorFile: `plugin-${safeName}.js`,
|
|
146
|
+
installDate: new Date().toISOString(),
|
|
147
|
+
};
|
|
148
|
+
writeFileSync(manifestPath(safeName), JSON.stringify(manifest, null, 2), "utf-8");
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
success: true,
|
|
152
|
+
message: `Installed ${packageName}@${pkgVersion} → .afd/validators/plugin-${safeName}.js`,
|
|
153
|
+
manifest,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── List ─────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export function listPlugins(): PluginManifest[] {
|
|
160
|
+
const dir = pluginsDir();
|
|
161
|
+
if (!existsSync(dir)) return [];
|
|
162
|
+
return readdirSync(dir)
|
|
163
|
+
.filter((f) => f.endsWith(".json"))
|
|
164
|
+
.map((f) => {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(readFileSync(join(dir, f), "utf-8")) as PluginManifest;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
.filter(Boolean) as PluginManifest[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Remove ───────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export interface RemoveResult {
|
|
177
|
+
success: boolean;
|
|
178
|
+
message: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function removePlugin(name: string): RemoveResult {
|
|
182
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
183
|
+
const mPath = manifestPath(safeName);
|
|
184
|
+
const wPath = wrapperPath(safeName);
|
|
185
|
+
|
|
186
|
+
if (!existsSync(mPath)) {
|
|
187
|
+
return { success: false, message: `Plugin not found: ${name}` };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let manifest: PluginManifest;
|
|
191
|
+
try {
|
|
192
|
+
manifest = JSON.parse(readFileSync(mPath, "utf-8"));
|
|
193
|
+
} catch {
|
|
194
|
+
return { success: false, message: `Corrupt manifest for ${name}` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Remove wrapper
|
|
198
|
+
if (existsSync(wPath)) unlinkSync(wPath);
|
|
199
|
+
|
|
200
|
+
// Remove manifest
|
|
201
|
+
unlinkSync(mPath);
|
|
202
|
+
|
|
203
|
+
// bun remove <package>
|
|
204
|
+
const root = findWorkspaceRoot();
|
|
205
|
+
spawnSync("bun", ["remove", manifest.package], { cwd: root, stdio: "pipe" });
|
|
206
|
+
|
|
207
|
+
return { success: true, message: `Removed plugin ${name} (${manifest.package})` };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Wrapper codegen ──────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function buildWrapper(packageName: string, resolvedMain: string, isDirectFn: boolean): string {
|
|
213
|
+
const rel = resolve(resolvedMain).replace(/\\/g, "/");
|
|
214
|
+
return [
|
|
215
|
+
`// [afd plugin wrapper] package: ${packageName}`,
|
|
216
|
+
`// Auto-generated — managed by \`afd plugin\`. Do not edit manually.`,
|
|
217
|
+
`const _plugin = require(${JSON.stringify(rel)});`,
|
|
218
|
+
`module.exports = function(newContent, filePath) {`,
|
|
219
|
+
isDirectFn
|
|
220
|
+
? ` return _plugin(newContent, filePath);`
|
|
221
|
+
: ` return _plugin.validate(newContent, filePath);`,
|
|
222
|
+
`};`,
|
|
223
|
+
``,
|
|
224
|
+
].join("\n");
|
|
225
|
+
}
|
|
@@ -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,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule Suggestion Engine — analyzes mistake_history to recommend
|
|
3
|
+
* auto-validator generation for frequently recurring failure patterns.
|
|
4
|
+
*
|
|
5
|
+
* Query strategy: aggregate by (file_path, mistake_type), rank by frequency,
|
|
6
|
+
* filter out patterns already covered by existing validators.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import { VALIDATORS_DIR } from "../daemon/types";
|
|
13
|
+
|
|
14
|
+
// ── Types ───────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface RuleSuggestion {
|
|
17
|
+
/** Target file path (workspace-relative) */
|
|
18
|
+
filePath: string;
|
|
19
|
+
/** Mistake category (English enum) */
|
|
20
|
+
mistakeType: string;
|
|
21
|
+
/** Number of occurrences in the analysis window */
|
|
22
|
+
frequency: number;
|
|
23
|
+
/** Most recent occurrence timestamp (epoch ms) */
|
|
24
|
+
lastSeen: number;
|
|
25
|
+
/** Representative description from the most recent event */
|
|
26
|
+
description: string;
|
|
27
|
+
/** Whether an existing validator already covers this file */
|
|
28
|
+
alreadyCovered: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SuggestionOptions {
|
|
32
|
+
/** Analysis window in days (default: 30) */
|
|
33
|
+
days?: number;
|
|
34
|
+
/** Minimum frequency to trigger a suggestion (default: 3) */
|
|
35
|
+
minFrequency?: number;
|
|
36
|
+
/** Maximum number of suggestions to return (default: 10) */
|
|
37
|
+
limit?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Core query ──────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Aggregate mistake_history and produce ranked suggestions.
|
|
44
|
+
*/
|
|
45
|
+
export function suggestRules(db: Database, opts: SuggestionOptions = {}): RuleSuggestion[] {
|
|
46
|
+
const days = opts.days ?? 30;
|
|
47
|
+
const minFreq = opts.minFrequency ?? 3;
|
|
48
|
+
const limit = opts.limit ?? 10;
|
|
49
|
+
|
|
50
|
+
const cutoffMs = Date.now() - days * 86_400_000;
|
|
51
|
+
|
|
52
|
+
// Single query: group by (file_path, mistake_type), count, get latest
|
|
53
|
+
const rows = db.prepare(`
|
|
54
|
+
SELECT
|
|
55
|
+
file_path,
|
|
56
|
+
mistake_type,
|
|
57
|
+
COUNT(*) AS frequency,
|
|
58
|
+
MAX(timestamp) AS last_seen,
|
|
59
|
+
-- Get the description from the most recent entry
|
|
60
|
+
(SELECT description FROM mistake_history m2
|
|
61
|
+
WHERE m2.file_path = m1.file_path AND m2.mistake_type = m1.mistake_type
|
|
62
|
+
ORDER BY m2.timestamp DESC LIMIT 1) AS description
|
|
63
|
+
FROM mistake_history m1
|
|
64
|
+
WHERE timestamp >= ?
|
|
65
|
+
GROUP BY file_path, mistake_type
|
|
66
|
+
HAVING COUNT(*) >= ?
|
|
67
|
+
ORDER BY frequency DESC, last_seen DESC
|
|
68
|
+
LIMIT ?
|
|
69
|
+
`).all(cutoffMs, minFreq, limit) as {
|
|
70
|
+
file_path: string;
|
|
71
|
+
mistake_type: string;
|
|
72
|
+
frequency: number;
|
|
73
|
+
last_seen: number;
|
|
74
|
+
description: string;
|
|
75
|
+
}[];
|
|
76
|
+
|
|
77
|
+
const coveredFiles = getExistingValidatorTargets();
|
|
78
|
+
|
|
79
|
+
return rows.map(row => ({
|
|
80
|
+
filePath: row.file_path,
|
|
81
|
+
mistakeType: row.mistake_type,
|
|
82
|
+
frequency: row.frequency,
|
|
83
|
+
lastSeen: row.last_seen,
|
|
84
|
+
description: row.description,
|
|
85
|
+
alreadyCovered: coveredFiles.has(row.file_path),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Validator coverage detection ────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Scan existing `.afd/validators/*.js` files and extract the file targets
|
|
93
|
+
* they protect (by parsing the `endsWith("...")` pattern in the source).
|
|
94
|
+
*/
|
|
95
|
+
function getExistingValidatorTargets(): Set<string> {
|
|
96
|
+
const targets = new Set<string>();
|
|
97
|
+
const dir = VALIDATORS_DIR;
|
|
98
|
+
if (!existsSync(dir)) return targets;
|
|
99
|
+
|
|
100
|
+
let files: string[];
|
|
101
|
+
try { files = readdirSync(dir).filter(f => f.endsWith(".js")); } catch { return targets; }
|
|
102
|
+
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
try {
|
|
105
|
+
const code = readFileSync(join(dir, file), "utf-8");
|
|
106
|
+
// Extract endsWith("...") targets from generated validators
|
|
107
|
+
const matches = code.matchAll(/endsWith\(["']([^"']+)["']\)/g);
|
|
108
|
+
for (const m of matches) {
|
|
109
|
+
targets.add(m[1]);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// skip unreadable files
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return targets;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if a specific file path is already covered by a validator.
|
|
120
|
+
*/
|
|
121
|
+
export function isFileCovered(filePath: string, coveredTargets?: Set<string>): boolean {
|
|
122
|
+
const targets = coveredTargets ?? getExistingValidatorTargets();
|
|
123
|
+
for (const target of targets) {
|
|
124
|
+
if (filePath.endsWith(target)) return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|