@praneeth_54/agentdoctor 2.1.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -3
- package/README.md +490 -290
- package/dist/agent/chat/deterministic.d.ts +6 -0
- package/dist/agent/chat/deterministic.js +180 -0
- package/dist/agent/chat/service.js +8 -14
- package/dist/agent/context/retrieve.d.ts +2 -1
- package/dist/agent/context/retrieve.js +14 -1
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.js +1 -0
- package/dist/agent/loop.d.ts +2 -0
- package/dist/agent/loop.js +31 -0
- package/dist/agent/roles.d.ts +12 -0
- package/dist/agent/roles.js +138 -0
- package/dist/agent/runtime.d.ts +21 -5
- package/dist/agent/runtime.js +141 -28
- package/dist/agent/tools/execute.d.ts +2 -0
- package/dist/agent/tools/execute.js +49 -43
- package/dist/agent/tools/write.d.ts +1 -1
- package/dist/agent/tools/write.js +18 -4
- package/dist/ai/config.js +1 -0
- package/dist/ai/providers/adversarial-local.d.ts +7 -0
- package/dist/ai/providers/adversarial-local.js +146 -0
- package/dist/ai/redact.d.ts +1 -0
- package/dist/ai/redact.js +47 -7
- package/dist/ai/types.d.ts +1 -1
- package/dist/cli/commands/agent.d.ts +2 -0
- package/dist/cli/commands/agent.js +32 -11
- package/dist/cli/commands/architecture.js +7 -2
- package/dist/cli/commands/brain.js +7 -2
- package/dist/cli/commands/chat.js +15 -7
- package/dist/cli/commands/complete.js +13 -3
- package/dist/cli/commands/fix.js +6 -0
- package/dist/cli/commands/learn.js +7 -2
- package/dist/cli/commands/mcp.js +7 -3
- package/dist/cli/commands/platform.js +7 -2
- package/dist/cli/commands/policy-graph-run.js +7 -0
- package/dist/cli/commands/product.d.ts +19 -0
- package/dist/cli/commands/product.js +157 -0
- package/dist/cli/commands/scan.js +6 -0
- package/dist/cli/commands/start.d.ts +15 -0
- package/dist/cli/commands/start.js +86 -0
- package/dist/cli/commands/v2.js +61 -11
- package/dist/cli/commands/verify.js +6 -0
- package/dist/cli/program.js +141 -0
- package/dist/cli/safe-root.d.ts +17 -0
- package/dist/cli/safe-root.js +32 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/brain-cli/service.js +5 -2
- package/dist/core/monorepo/detect.js +10 -0
- package/dist/core/secrets/scan.js +9 -0
- package/dist/core/understanding/brain/storage/store.d.ts +4 -0
- package/dist/core/understanding/brain/storage/store.js +22 -2
- package/dist/dashboard/page.d.ts +2 -0
- package/dist/dashboard/page.js +53 -0
- package/dist/dashboard/server.js +117 -140
- package/dist/dashboard/ui/client.d.ts +2 -0
- package/dist/dashboard/ui/client.js +2 -0
- package/dist/dashboard/ui/styles.d.ts +2 -0
- package/dist/dashboard/ui/styles.js +138 -0
- package/dist/discovery/files.js +13 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/intelligence/graph/build.js +46 -38
- package/dist/intelligence/graph/incremental.d.ts +2 -0
- package/dist/intelligence/graph/incremental.js +9 -0
- package/dist/intelligence/resolve/imports.js +1 -1
- package/dist/languages/dart.d.ts +10 -0
- package/dist/languages/dart.js +99 -0
- package/dist/languages/go.d.ts +13 -7
- package/dist/languages/go.js +84 -24
- package/dist/languages/index.d.ts +5 -1
- package/dist/languages/index.js +13 -36
- package/dist/languages/java.d.ts +10 -0
- package/dist/languages/java.js +80 -0
- package/dist/languages/kotlin.d.ts +10 -0
- package/dist/languages/kotlin.js +85 -0
- package/dist/languages/rust.d.ts +10 -0
- package/dist/languages/rust.js +93 -0
- package/dist/languages/types.d.ts +4 -3
- package/dist/mcp/agent/registry.d.ts +1 -1
- package/dist/mcp/agent/registry.js +109 -23
- package/dist/mcp/intelligence/handlers.d.ts +3 -0
- package/dist/mcp/intelligence/handlers.js +28 -0
- package/dist/mcp/intelligence/path-safety.d.ts +6 -1
- package/dist/mcp/intelligence/path-safety.js +55 -2
- package/dist/mcp/intelligence/registry.d.ts +1 -1
- package/dist/mcp/intelligence/registry.js +30 -1
- package/dist/platform/graph/build.js +9 -0
- package/dist/product/api/doctor.d.ts +14 -0
- package/dist/product/api/doctor.js +185 -0
- package/dist/product/api/openapi.d.ts +7 -0
- package/dist/product/api/openapi.js +122 -0
- package/dist/product/approval/model.d.ts +30 -0
- package/dist/product/approval/model.js +64 -0
- package/dist/product/approval/session.d.ts +49 -0
- package/dist/product/approval/session.js +134 -0
- package/dist/product/database/doctor.d.ts +30 -0
- package/dist/product/database/doctor.js +184 -0
- package/dist/product/decisions/ledger.d.ts +36 -0
- package/dist/product/decisions/ledger.js +141 -0
- package/dist/product/deps/analyze.d.ts +46 -0
- package/dist/product/deps/analyze.js +137 -0
- package/dist/product/deps/lockfiles.d.ts +25 -0
- package/dist/product/deps/lockfiles.js +200 -0
- package/dist/product/discovery/roots.d.ts +52 -0
- package/dist/product/discovery/roots.js +231 -0
- package/dist/product/dna/build.d.ts +50 -0
- package/dist/product/dna/build.js +255 -0
- package/dist/product/eval/lab.d.ts +17 -0
- package/dist/product/eval/lab.js +218 -0
- package/dist/product/events/doctor.d.ts +21 -0
- package/dist/product/events/doctor.js +147 -0
- package/dist/product/evidence-scan.d.ts +12 -0
- package/dist/product/evidence-scan.js +46 -0
- package/dist/product/evolution/timeline.d.ts +29 -0
- package/dist/product/evolution/timeline.js +123 -0
- package/dist/product/features/intelligence.d.ts +23 -0
- package/dist/product/features/intelligence.js +158 -0
- package/dist/product/forensic/mode.d.ts +25 -0
- package/dist/product/forensic/mode.js +70 -0
- package/dist/product/graph/enrich-languages.d.ts +20 -0
- package/dist/product/graph/enrich-languages.js +168 -0
- package/dist/product/health/code-health.d.ts +20 -0
- package/dist/product/health/code-health.js +149 -0
- package/dist/product/index.d.ts +72 -0
- package/dist/product/index.js +36 -0
- package/dist/product/ledger/change-ledger.d.ts +23 -0
- package/dist/product/ledger/change-ledger.js +64 -0
- package/dist/product/map/software-map.d.ts +17 -0
- package/dist/product/map/software-map.js +96 -0
- package/dist/product/memory/institutional.d.ts +16 -0
- package/dist/product/memory/institutional.js +92 -0
- package/dist/product/ops/incident.d.ts +20 -0
- package/dist/product/ops/incident.js +61 -0
- package/dist/product/ops/infra.d.ts +15 -0
- package/dist/product/ops/infra.js +112 -0
- package/dist/product/org/model.d.ts +34 -0
- package/dist/product/org/model.js +195 -0
- package/dist/product/privacy/doctor.d.ts +13 -0
- package/dist/product/privacy/doctor.js +90 -0
- package/dist/product/requirements/trace.d.ts +21 -0
- package/dist/product/requirements/trace.js +166 -0
- package/dist/product/search/index.d.ts +29 -0
- package/dist/product/search/index.js +116 -0
- package/dist/product/search/software-search.d.ts +19 -0
- package/dist/product/search/software-search.js +105 -0
- package/dist/product/security/doctor.d.ts +23 -0
- package/dist/product/security/doctor.js +124 -0
- package/dist/product/self/diagnose.d.ts +15 -0
- package/dist/product/self/diagnose.js +82 -0
- package/dist/product/techdebt/roadmap.d.ts +20 -0
- package/dist/product/techdebt/roadmap.js +118 -0
- package/dist/product/testbrain/analyze.d.ts +29 -0
- package/dist/product/testbrain/analyze.js +129 -0
- package/dist/product/truth.d.ts +12 -0
- package/dist/product/truth.js +30 -0
- package/dist/product/twin/digital-twin.d.ts +23 -0
- package/dist/product/twin/digital-twin.js +52 -0
- package/dist/product/twin/store.d.ts +21 -0
- package/dist/product/twin/store.js +78 -0
- package/dist/product/whatif/engine.d.ts +28 -0
- package/dist/product/whatif/engine.js +91 -0
- package/dist/project/ownership.d.ts +65 -0
- package/dist/project/ownership.js +169 -0
- package/dist/utils/fs.js +10 -2
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { DEFAULT_MAX_FILE_SIZE_BYTES } from "../constants.js";
|
|
3
|
+
import { detectProject } from "../detectors/project.js";
|
|
4
|
+
import { readTextFile } from "../utils/fs.js";
|
|
5
|
+
import { resolveRepoRoot } from "../utils/path.js";
|
|
6
|
+
export async function loadProjectSourceFiles(rootInput, filter, maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_BYTES) {
|
|
7
|
+
const root = resolveRepoRoot(rootInput);
|
|
8
|
+
const detection = await detectProject(root, maxFileSizeBytes);
|
|
9
|
+
const limitations = [];
|
|
10
|
+
if (detection.diagnostics.length) {
|
|
11
|
+
limitations.push(...detection.diagnostics.slice(0, 5));
|
|
12
|
+
}
|
|
13
|
+
const files = [];
|
|
14
|
+
for (const entry of detection.discovery.files) {
|
|
15
|
+
const rel = entry.relativePath.replace(/\\/g, "/");
|
|
16
|
+
if (!filter(rel))
|
|
17
|
+
continue;
|
|
18
|
+
const text = await readTextFile(path.join(root, rel), maxFileSizeBytes);
|
|
19
|
+
if (text === null)
|
|
20
|
+
continue;
|
|
21
|
+
files.push({ relativePath: rel, content: text });
|
|
22
|
+
}
|
|
23
|
+
return { root, files, limitations };
|
|
24
|
+
}
|
|
25
|
+
export function lineNumberAt(content, index) {
|
|
26
|
+
if (index <= 0)
|
|
27
|
+
return 1;
|
|
28
|
+
let line = 1;
|
|
29
|
+
for (let i = 0; i < index && i < content.length; i++) {
|
|
30
|
+
if (content.charCodeAt(i) === 10)
|
|
31
|
+
line++;
|
|
32
|
+
}
|
|
33
|
+
return line;
|
|
34
|
+
}
|
|
35
|
+
export function isMarkdownDoc(relativePath) {
|
|
36
|
+
const lower = relativePath.toLowerCase();
|
|
37
|
+
return lower.endsWith(".md") || lower.endsWith(".markdown");
|
|
38
|
+
}
|
|
39
|
+
export function isLikelyRequirementsDoc(relativePath) {
|
|
40
|
+
const base = relativePath.split("/").pop()?.toLowerCase() ?? "";
|
|
41
|
+
if (base === "requirements.md" || base === "user-stories.md")
|
|
42
|
+
return true;
|
|
43
|
+
if (relativePath.startsWith("docs/") && isMarkdownDoc(relativePath))
|
|
44
|
+
return true;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { analyzeGitIntelligence } from "../../intelligence/git/analyze.js";
|
|
2
|
+
import type { TruthLabel } from "../truth.js";
|
|
3
|
+
export interface EvolutionTimelineEvent {
|
|
4
|
+
id: string;
|
|
5
|
+
date: string;
|
|
6
|
+
author: string;
|
|
7
|
+
subject: string;
|
|
8
|
+
filesTouched: number;
|
|
9
|
+
truth: TruthLabel;
|
|
10
|
+
}
|
|
11
|
+
export interface EvolutionTrend {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
direction: "up" | "down" | "stable";
|
|
15
|
+
metric: string;
|
|
16
|
+
truth: TruthLabel;
|
|
17
|
+
note: string;
|
|
18
|
+
}
|
|
19
|
+
export interface SoftwareEvolutionReport {
|
|
20
|
+
root: string;
|
|
21
|
+
gitAvailable: boolean;
|
|
22
|
+
events: EvolutionTimelineEvent[];
|
|
23
|
+
trends: EvolutionTrend[];
|
|
24
|
+
gitIntelligence: Awaited<ReturnType<typeof analyzeGitIntelligence>>;
|
|
25
|
+
limitations: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function buildSoftwareEvolutionTimeline(rootInput: string, options?: {
|
|
28
|
+
maxCommits?: number;
|
|
29
|
+
}): Promise<SoftwareEvolutionReport>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { analyzeGitIntelligence } from "../../intelligence/git/analyze.js";
|
|
3
|
+
import { resolveRepoRoot } from "../../utils/path.js";
|
|
4
|
+
function git(root, args) {
|
|
5
|
+
const r = spawnSync("git", args, {
|
|
6
|
+
cwd: root,
|
|
7
|
+
encoding: "utf8",
|
|
8
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
9
|
+
});
|
|
10
|
+
return { ok: r.status === 0, out: typeof r.stdout === "string" ? r.stdout : "" };
|
|
11
|
+
}
|
|
12
|
+
function parseLogSummary(root, maxCommits) {
|
|
13
|
+
const log = git(root, [
|
|
14
|
+
"log",
|
|
15
|
+
`--max-count=${maxCommits}`,
|
|
16
|
+
"--pretty=format:%H%x09%ai%x09%an%x09%s",
|
|
17
|
+
"--name-only",
|
|
18
|
+
]);
|
|
19
|
+
if (!log.ok)
|
|
20
|
+
return [];
|
|
21
|
+
const events = [];
|
|
22
|
+
let pending = null;
|
|
23
|
+
let fileCount = 0;
|
|
24
|
+
const flush = () => {
|
|
25
|
+
if (!pending)
|
|
26
|
+
return;
|
|
27
|
+
events.push({ ...pending, filesTouched: fileCount });
|
|
28
|
+
pending = null;
|
|
29
|
+
fileCount = 0;
|
|
30
|
+
};
|
|
31
|
+
for (const line of log.out.split(/\r?\n/)) {
|
|
32
|
+
if (!line.trim())
|
|
33
|
+
continue;
|
|
34
|
+
if (line.includes("\t")) {
|
|
35
|
+
flush();
|
|
36
|
+
const [hash, date, author, subject] = line.split("\t");
|
|
37
|
+
if (!hash || !date)
|
|
38
|
+
continue;
|
|
39
|
+
pending = {
|
|
40
|
+
id: hash.slice(0, 12),
|
|
41
|
+
date: date.trim(),
|
|
42
|
+
author: (author ?? "unknown").trim(),
|
|
43
|
+
subject: (subject ?? "").trim().slice(0, 240),
|
|
44
|
+
truth: "VERIFIED",
|
|
45
|
+
};
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (pending)
|
|
49
|
+
fileCount += 1;
|
|
50
|
+
}
|
|
51
|
+
flush();
|
|
52
|
+
return events;
|
|
53
|
+
}
|
|
54
|
+
function inferTrends(events, gitReport) {
|
|
55
|
+
const trends = [];
|
|
56
|
+
if (events.length >= 2) {
|
|
57
|
+
const recent = events.slice(0, Math.min(20, events.length));
|
|
58
|
+
const older = events.slice(Math.min(20, events.length));
|
|
59
|
+
const avgRecent = recent.reduce((s, e) => s + e.filesTouched, 0) / Math.max(1, recent.length);
|
|
60
|
+
const avgOlder = older.length > 0 ? older.reduce((s, e) => s + e.filesTouched, 0) / older.length : avgRecent;
|
|
61
|
+
const direction = avgRecent > avgOlder * 1.15 ? "up" : avgRecent < avgOlder * 0.85 ? "down" : "stable";
|
|
62
|
+
trends.push({
|
|
63
|
+
id: "change-breadth",
|
|
64
|
+
label: "Average files touched per commit (recent window)",
|
|
65
|
+
direction,
|
|
66
|
+
metric: avgRecent.toFixed(2),
|
|
67
|
+
truth: "INFERRED",
|
|
68
|
+
note: "Compared recent ≤20 commits vs earlier commits in the same log window.",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (gitReport.hotspots.length >= 2) {
|
|
72
|
+
const top = gitReport.hotspots[0];
|
|
73
|
+
const second = gitReport.hotspots[1];
|
|
74
|
+
trends.push({
|
|
75
|
+
id: "hotspot-concentration",
|
|
76
|
+
label: "Hotspot file churn concentration",
|
|
77
|
+
direction: top.commits > second.commits * 2 ? "up" : "stable",
|
|
78
|
+
metric: `${top.path} (${top.commits} commits)`,
|
|
79
|
+
truth: "INFERRED",
|
|
80
|
+
note: gitReport.hotspots[0]?.method ?? "git intelligence hotspot method",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const authors = new Set(events.map((e) => e.author));
|
|
84
|
+
trends.push({
|
|
85
|
+
id: "contributor-breadth",
|
|
86
|
+
label: "Distinct authors in timeline window",
|
|
87
|
+
direction: authors.size >= 3 ? "stable" : "down",
|
|
88
|
+
metric: String(authors.size),
|
|
89
|
+
truth: "INFERRED",
|
|
90
|
+
note: "Author count in parsed log — not organizational bus factor.",
|
|
91
|
+
});
|
|
92
|
+
return trends;
|
|
93
|
+
}
|
|
94
|
+
export async function buildSoftwareEvolutionTimeline(rootInput, options) {
|
|
95
|
+
const root = resolveRepoRoot(rootInput);
|
|
96
|
+
const maxCommits = Math.max(10, Math.min(500, options?.maxCommits ?? 120));
|
|
97
|
+
const gitIntelligence = await analyzeGitIntelligence(root);
|
|
98
|
+
const limitations = [
|
|
99
|
+
"Timeline events come from git log — shallow or missing git yields empty timeline.",
|
|
100
|
+
"Trends are INFERRED heuristics — not predictive analytics.",
|
|
101
|
+
...gitIntelligence.limitations,
|
|
102
|
+
];
|
|
103
|
+
if (!gitIntelligence.gitAvailable) {
|
|
104
|
+
return {
|
|
105
|
+
root,
|
|
106
|
+
gitAvailable: false,
|
|
107
|
+
events: [],
|
|
108
|
+
trends: [],
|
|
109
|
+
gitIntelligence,
|
|
110
|
+
limitations,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const events = parseLogSummary(root, maxCommits);
|
|
114
|
+
const trends = inferTrends(events, gitIntelligence);
|
|
115
|
+
return {
|
|
116
|
+
root,
|
|
117
|
+
gitAvailable: true,
|
|
118
|
+
events,
|
|
119
|
+
trends,
|
|
120
|
+
gitIntelligence,
|
|
121
|
+
limitations,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ProductEvidence, TruthLabel } from "../truth.js";
|
|
2
|
+
export interface FeatureFlowLink {
|
|
3
|
+
layer: "ui" | "api" | "service" | "db";
|
|
4
|
+
label: string;
|
|
5
|
+
path?: string;
|
|
6
|
+
truth: TruthLabel;
|
|
7
|
+
evidence: ProductEvidence[];
|
|
8
|
+
}
|
|
9
|
+
export interface FeatureFlow {
|
|
10
|
+
featureId: string;
|
|
11
|
+
title: string;
|
|
12
|
+
docSource?: string;
|
|
13
|
+
docLine?: number;
|
|
14
|
+
links: FeatureFlowLink[];
|
|
15
|
+
zombie: boolean;
|
|
16
|
+
truth: TruthLabel;
|
|
17
|
+
}
|
|
18
|
+
export interface FeatureIntelligenceReport {
|
|
19
|
+
root: string;
|
|
20
|
+
features: FeatureFlow[];
|
|
21
|
+
limitations: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare function analyzeFeatureIntelligence(rootInput: string, maxFileSizeBytes?: number): Promise<FeatureIntelligenceReport>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { analyzeApiSurface } from "../api/doctor.js";
|
|
3
|
+
import { analyzeDatabaseSchema } from "../database/doctor.js";
|
|
4
|
+
import { isLikelyRequirementsDoc, isMarkdownDoc, loadProjectSourceFiles, } from "../evidence-scan.js";
|
|
5
|
+
import { readTextFile } from "../../utils/fs.js";
|
|
6
|
+
import { detectProject } from "../../detectors/project.js";
|
|
7
|
+
import { resolveRepoRoot } from "../../utils/path.js";
|
|
8
|
+
import { DEFAULT_MAX_FILE_SIZE_BYTES } from "../../constants.js";
|
|
9
|
+
const FEATURE_MARKER = /^(?:#{1,4}\s+)?(?:FEATURE|Feature)[:\s-]+(.+)$|<!--\s*FEATURE:\s*(.+?)\s*-->/i;
|
|
10
|
+
const FEATURE_INLINE = /FEATURE:\s*([^\n\r]+)/gi;
|
|
11
|
+
const UI_HEURISTICS = [
|
|
12
|
+
/pages?\//i,
|
|
13
|
+
/views?\//i,
|
|
14
|
+
/screens?\//i,
|
|
15
|
+
/components?\//i,
|
|
16
|
+
/\.tsx$/i,
|
|
17
|
+
/\.vue$/i,
|
|
18
|
+
/filament/i,
|
|
19
|
+
/livewire/i,
|
|
20
|
+
];
|
|
21
|
+
const SERVICE_HEURISTICS = [/services?\//i, /usecases?\//i, /domain\//i, /handlers?\//i];
|
|
22
|
+
function slugFeature(title, source, line) {
|
|
23
|
+
const slug = title
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
26
|
+
.replace(/^-|-$/g, "")
|
|
27
|
+
.slice(0, 48);
|
|
28
|
+
return `${source.replace(/[^\w./-]/g, "_")}:${line}:${slug || "feature"}`;
|
|
29
|
+
}
|
|
30
|
+
function tokenize(title) {
|
|
31
|
+
return title
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.split(/\W+/)
|
|
34
|
+
.filter((t) => t.length > 3);
|
|
35
|
+
}
|
|
36
|
+
function fileMatchesTokens(relativePath, content, tokens) {
|
|
37
|
+
if (!tokens.length)
|
|
38
|
+
return false;
|
|
39
|
+
const hay = `${relativePath}\n${content}`.toLowerCase();
|
|
40
|
+
const hits = tokens.filter((t) => hay.includes(t));
|
|
41
|
+
return hits.length >= Math.min(2, tokens.length);
|
|
42
|
+
}
|
|
43
|
+
function classifyUiPath(relativePath) {
|
|
44
|
+
return UI_HEURISTICS.some((re) => re.test(relativePath));
|
|
45
|
+
}
|
|
46
|
+
function classifyServicePath(relativePath) {
|
|
47
|
+
return SERVICE_HEURISTICS.some((re) => re.test(relativePath));
|
|
48
|
+
}
|
|
49
|
+
async function collectFeatureMarkersFromDocs(root, maxFileSizeBytes) {
|
|
50
|
+
const detection = await detectProject(root, maxFileSizeBytes);
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const entry of detection.discovery.files) {
|
|
53
|
+
const rel = entry.relativePath.replace(/\\/g, "/");
|
|
54
|
+
if (!isMarkdownDoc(rel))
|
|
55
|
+
continue;
|
|
56
|
+
if (!isLikelyRequirementsDoc(rel) && !rel.toLowerCase().includes("feature"))
|
|
57
|
+
continue;
|
|
58
|
+
const content = await readTextFile(path.join(root, rel), maxFileSizeBytes);
|
|
59
|
+
if (content === null)
|
|
60
|
+
continue;
|
|
61
|
+
const lines = content.split(/\r?\n/);
|
|
62
|
+
for (let i = 0; i < lines.length; i++) {
|
|
63
|
+
const line = lines[i] ?? "";
|
|
64
|
+
const heading = FEATURE_MARKER.exec(line.trim());
|
|
65
|
+
if (heading) {
|
|
66
|
+
const title = (heading[1] ?? heading[2] ?? "").trim();
|
|
67
|
+
if (title)
|
|
68
|
+
out.push({ title, sourcePath: rel, line: i + 1 });
|
|
69
|
+
}
|
|
70
|
+
FEATURE_INLINE.lastIndex = 0;
|
|
71
|
+
let inline;
|
|
72
|
+
while ((inline = FEATURE_INLINE.exec(line)) !== null) {
|
|
73
|
+
const title = inline[1]?.trim();
|
|
74
|
+
if (title) {
|
|
75
|
+
out.push({ title, sourcePath: rel, line: i + 1 });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
export async function analyzeFeatureIntelligence(rootInput, maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_BYTES) {
|
|
83
|
+
const root = resolveRepoRoot(rootInput);
|
|
84
|
+
const limitations = [
|
|
85
|
+
"Feature markers parsed from docs (FEATURE / HTML comments) — not a product management integration.",
|
|
86
|
+
"UI→API→service→db links are heuristic substring matches — not runtime tracing.",
|
|
87
|
+
"Zombie features = doc marker with no matching source file token hits.",
|
|
88
|
+
];
|
|
89
|
+
const markers = await collectFeatureMarkersFromDocs(root, maxFileSizeBytes);
|
|
90
|
+
const { files } = await loadProjectSourceFiles(root, (p) => !p.includes("node_modules/") && !p.startsWith(".agentdoctor/"), maxFileSizeBytes);
|
|
91
|
+
const apiReport = await analyzeApiSurface(root);
|
|
92
|
+
const dbReport = await analyzeDatabaseSchema(root);
|
|
93
|
+
const features = [];
|
|
94
|
+
for (const marker of markers) {
|
|
95
|
+
const tokens = tokenize(marker.title);
|
|
96
|
+
const links = [];
|
|
97
|
+
let codeHits = 0;
|
|
98
|
+
for (const f of files) {
|
|
99
|
+
if (isMarkdownDoc(f.relativePath))
|
|
100
|
+
continue;
|
|
101
|
+
if (!fileMatchesTokens(f.relativePath, f.content, tokens))
|
|
102
|
+
continue;
|
|
103
|
+
codeHits += 1;
|
|
104
|
+
if (classifyUiPath(f.relativePath)) {
|
|
105
|
+
links.push({
|
|
106
|
+
layer: "ui",
|
|
107
|
+
label: path.basename(f.relativePath),
|
|
108
|
+
path: f.relativePath,
|
|
109
|
+
truth: "INFERRED",
|
|
110
|
+
evidence: [{ path: f.relativePath, excerpt: marker.title.slice(0, 80) }],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else if (classifyServicePath(f.relativePath)) {
|
|
114
|
+
links.push({
|
|
115
|
+
layer: "service",
|
|
116
|
+
label: path.basename(f.relativePath),
|
|
117
|
+
path: f.relativePath,
|
|
118
|
+
truth: "INFERRED",
|
|
119
|
+
evidence: [{ path: f.relativePath, excerpt: marker.title.slice(0, 80) }],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const ep of apiReport.endpoints) {
|
|
124
|
+
const hay = `${ep.pathPattern} ${ep.method}`.toLowerCase();
|
|
125
|
+
if (tokens.some((t) => hay.includes(t))) {
|
|
126
|
+
links.push({
|
|
127
|
+
layer: "api",
|
|
128
|
+
label: `${ep.method} ${ep.pathPattern}`,
|
|
129
|
+
truth: ep.truth,
|
|
130
|
+
evidence: ep.evidence,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const obj of dbReport.objects) {
|
|
135
|
+
const hay = obj.name.toLowerCase();
|
|
136
|
+
if (tokens.some((t) => hay.includes(t))) {
|
|
137
|
+
links.push({
|
|
138
|
+
layer: "db",
|
|
139
|
+
label: obj.name,
|
|
140
|
+
truth: obj.truth,
|
|
141
|
+
evidence: obj.evidence,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const zombie = codeHits === 0;
|
|
146
|
+
const truth = links.length > 0 ? "INFERRED" : zombie ? "PARTIAL" : "UNKNOWN";
|
|
147
|
+
features.push({
|
|
148
|
+
featureId: slugFeature(marker.title, marker.sourcePath, marker.line),
|
|
149
|
+
title: marker.title,
|
|
150
|
+
docSource: marker.sourcePath,
|
|
151
|
+
docLine: marker.line,
|
|
152
|
+
links,
|
|
153
|
+
zombie,
|
|
154
|
+
truth,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return { root, features, limitations };
|
|
158
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { analyzeGitIntelligence } from "../../intelligence/git/analyze.js";
|
|
2
|
+
import { getBrainStatus } from "../../core/brain-cli/service.js";
|
|
3
|
+
import type { TruthLabel } from "../truth.js";
|
|
4
|
+
export interface ForensicAnalysisReport {
|
|
5
|
+
root: string;
|
|
6
|
+
mode: "read-only";
|
|
7
|
+
brainStatus: Awaited<ReturnType<typeof getBrainStatus>>;
|
|
8
|
+
git: Awaited<ReturnType<typeof analyzeGitIntelligence>>;
|
|
9
|
+
changeLedgerEntryCount: number;
|
|
10
|
+
decisionCount: number;
|
|
11
|
+
findings: Array<{
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
truth: TruthLabel;
|
|
15
|
+
detail: string;
|
|
16
|
+
}>;
|
|
17
|
+
limitations: string[];
|
|
18
|
+
}
|
|
19
|
+
export declare class ForensicWriteRefusedError extends Error {
|
|
20
|
+
readonly code = "FORENSIC_READ_ONLY";
|
|
21
|
+
constructor(message?: string);
|
|
22
|
+
}
|
|
23
|
+
/** Call before any mutating product operation when forensic mode is active. */
|
|
24
|
+
export declare function assertForensicReadOnly(forensicMode: boolean): void;
|
|
25
|
+
export declare function runForensicAnalysis(rootInput: string): Promise<ForensicAnalysisReport>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { analyzeGitIntelligence } from "../../intelligence/git/analyze.js";
|
|
2
|
+
import { getBrainStatus } from "../../core/brain-cli/service.js";
|
|
3
|
+
import { readChangeLedger } from "../ledger/change-ledger.js";
|
|
4
|
+
import { loadDecisionLedger } from "../decisions/ledger.js";
|
|
5
|
+
import { resolveRepoRoot } from "../../utils/path.js";
|
|
6
|
+
export class ForensicWriteRefusedError extends Error {
|
|
7
|
+
code = "FORENSIC_READ_ONLY";
|
|
8
|
+
constructor(message = "Forensic mode refuses write operations") {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ForensicWriteRefusedError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Call before any mutating product operation when forensic mode is active. */
|
|
14
|
+
export function assertForensicReadOnly(forensicMode) {
|
|
15
|
+
if (forensicMode) {
|
|
16
|
+
throw new ForensicWriteRefusedError();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function runForensicAnalysis(rootInput) {
|
|
20
|
+
const root = resolveRepoRoot(rootInput);
|
|
21
|
+
const limitations = [
|
|
22
|
+
"Forensic mode aggregates read-only signals; it does not modify the repository.",
|
|
23
|
+
"Change/decision ledgers may be incomplete if never used.",
|
|
24
|
+
];
|
|
25
|
+
const [brainStatus, git, changeLedger, decisions] = await Promise.all([
|
|
26
|
+
getBrainStatus(root),
|
|
27
|
+
analyzeGitIntelligence(root),
|
|
28
|
+
readChangeLedger(root),
|
|
29
|
+
loadDecisionLedger(root),
|
|
30
|
+
]);
|
|
31
|
+
const findings = [];
|
|
32
|
+
findings.push({
|
|
33
|
+
id: "brain-snapshot",
|
|
34
|
+
label: "Project Brain snapshot present",
|
|
35
|
+
truth: brainStatus.hasSnapshot ? "VERIFIED" : "UNKNOWN",
|
|
36
|
+
detail: brainStatus.hasSnapshot
|
|
37
|
+
? `latest=${brainStatus.latestSnapshotId}`
|
|
38
|
+
: "No snapshot in .agentdoctor/project-brain",
|
|
39
|
+
});
|
|
40
|
+
findings.push({
|
|
41
|
+
id: "git-available",
|
|
42
|
+
label: "Git repository available",
|
|
43
|
+
truth: git.gitAvailable ? "VERIFIED" : "UNKNOWN",
|
|
44
|
+
detail: git.gitAvailable
|
|
45
|
+
? `hotspots=${git.hotspots.length}`
|
|
46
|
+
: "Not a git repo or git unavailable",
|
|
47
|
+
});
|
|
48
|
+
findings.push({
|
|
49
|
+
id: "change-ledger",
|
|
50
|
+
label: "AI change ledger entries",
|
|
51
|
+
truth: changeLedger.entries.length ? "VERIFIED" : "UNKNOWN",
|
|
52
|
+
detail: `entries=${changeLedger.entries.length}`,
|
|
53
|
+
});
|
|
54
|
+
findings.push({
|
|
55
|
+
id: "decisions",
|
|
56
|
+
label: "Decision records (ADR + ledger)",
|
|
57
|
+
truth: decisions.decisions.length ? "VERIFIED" : "PARTIAL",
|
|
58
|
+
detail: `decisions=${decisions.decisions.length}`,
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
root,
|
|
62
|
+
mode: "read-only",
|
|
63
|
+
brainStatus,
|
|
64
|
+
git,
|
|
65
|
+
changeLedgerEntryCount: changeLedger.entries.length,
|
|
66
|
+
decisionCount: decisions.decisions.length,
|
|
67
|
+
findings,
|
|
68
|
+
limitations,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RepositoryGraph } from "../../platform/types.js";
|
|
2
|
+
import type { LanguageId } from "../../languages/types.js";
|
|
3
|
+
export interface LanguageGraphEnrichmentStats {
|
|
4
|
+
filesAttempted: number;
|
|
5
|
+
filesParsed: number;
|
|
6
|
+
filesSkippedUnsupported: number;
|
|
7
|
+
nodesAdded: number;
|
|
8
|
+
edgesAdded: number;
|
|
9
|
+
byLanguage: Partial<Record<LanguageId, number>>;
|
|
10
|
+
limitations: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Walk a bounded set of Python/PHP/Go sources and merge adapter symbols/imports/calls into the graph.
|
|
14
|
+
*/
|
|
15
|
+
export declare function enrichGraphWithLanguageAdapters(graph: RepositoryGraph, rootInput: string, options?: {
|
|
16
|
+
maxFiles?: number;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
graph: RepositoryGraph;
|
|
19
|
+
stats: LanguageGraphEnrichmentStats;
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { getAdapterForFile, parseSourceFile } from "../../languages/index.js";
|
|
4
|
+
import { discoverFiles } from "../../discovery/files.js";
|
|
5
|
+
import { readTextFile } from "../../utils/fs.js";
|
|
6
|
+
import { resolveRepoRoot, toPosixRelative } from "../../utils/path.js";
|
|
7
|
+
function nodeId(kind, key) {
|
|
8
|
+
return `${kind}:${createHash("sha1").update(key).digest("hex").slice(0, 12)}`;
|
|
9
|
+
}
|
|
10
|
+
const LANG_FILE_RE = /\.(py|php|go|java|kt|kts|rs|dart)$/i;
|
|
11
|
+
async function listLanguageSourceFiles(root, limit = 120) {
|
|
12
|
+
const discovered = await discoverFiles({ root });
|
|
13
|
+
return discovered.files
|
|
14
|
+
.filter((f) => LANG_FILE_RE.test(f.relativePath))
|
|
15
|
+
.map((f) => f.absolutePath)
|
|
16
|
+
.sort()
|
|
17
|
+
.slice(0, limit);
|
|
18
|
+
}
|
|
19
|
+
function evidenceFromAdapter(kind) {
|
|
20
|
+
if (kind === "ast")
|
|
21
|
+
return "verified";
|
|
22
|
+
return "unknown";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Walk a bounded set of Python/PHP/Go sources and merge adapter symbols/imports/calls into the graph.
|
|
26
|
+
*/
|
|
27
|
+
export async function enrichGraphWithLanguageAdapters(graph, rootInput, options) {
|
|
28
|
+
const root = resolveRepoRoot(rootInput);
|
|
29
|
+
const maxFiles = options?.maxFiles ?? 120;
|
|
30
|
+
const limitations = [
|
|
31
|
+
"Language enrichment covers .py/.php/.go/.java/.kt/.kts/.rs/.dart, capped file budget, skips unsupported adapters.",
|
|
32
|
+
"Symbol/call edges from language adapters may be partial — evidence follows adapter (ast→verified, else unknown).",
|
|
33
|
+
];
|
|
34
|
+
const existingNodeIds = new Set(graph.nodes.map((n) => n.id));
|
|
35
|
+
const existingEdgeIds = new Set(graph.edges.map((e) => e.id));
|
|
36
|
+
const nodes = [...graph.nodes];
|
|
37
|
+
const edges = [...graph.edges];
|
|
38
|
+
const pushNode = (n) => {
|
|
39
|
+
if (existingNodeIds.has(n.id))
|
|
40
|
+
return;
|
|
41
|
+
existingNodeIds.add(n.id);
|
|
42
|
+
nodes.push(n);
|
|
43
|
+
};
|
|
44
|
+
const pushEdge = (e) => {
|
|
45
|
+
if (existingEdgeIds.has(e.id))
|
|
46
|
+
return;
|
|
47
|
+
existingEdgeIds.add(e.id);
|
|
48
|
+
edges.push(e);
|
|
49
|
+
};
|
|
50
|
+
const nodesBefore = nodes.length;
|
|
51
|
+
const edgesBefore = edges.length;
|
|
52
|
+
const byLanguage = {};
|
|
53
|
+
let filesAttempted = 0;
|
|
54
|
+
let filesParsed = 0;
|
|
55
|
+
let filesSkippedUnsupported = 0;
|
|
56
|
+
const files = await listLanguageSourceFiles(root, maxFiles);
|
|
57
|
+
if (files.length >= maxFiles) {
|
|
58
|
+
limitations.push(`Language file walk capped at ${maxFiles} files`);
|
|
59
|
+
}
|
|
60
|
+
for (const absolute of files) {
|
|
61
|
+
filesAttempted += 1;
|
|
62
|
+
const rel = toPosixRelative(root, absolute);
|
|
63
|
+
const adapter = getAdapterForFile(rel);
|
|
64
|
+
if (!adapter) {
|
|
65
|
+
filesSkippedUnsupported += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const caps = adapter.capabilities();
|
|
69
|
+
if (caps.parse !== "supported") {
|
|
70
|
+
filesSkippedUnsupported += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const source = await readTextFile(absolute, 512 * 1024);
|
|
74
|
+
if (source === null)
|
|
75
|
+
continue;
|
|
76
|
+
const parsed = await parseSourceFile(rel, source);
|
|
77
|
+
if (!parsed.ok) {
|
|
78
|
+
limitations.push(`Parse failed for ${rel}: ${parsed.diagnostics[0] ?? "unknown"}`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
filesParsed += 1;
|
|
82
|
+
byLanguage[parsed.language] = (byLanguage[parsed.language] ?? 0) + 1;
|
|
83
|
+
const fileId = nodeId("file", rel);
|
|
84
|
+
pushNode({ id: fileId, kind: "file", label: path.basename(rel), path: rel });
|
|
85
|
+
for (const sym of parsed.symbols) {
|
|
86
|
+
if (sym.evidence === "unsupported")
|
|
87
|
+
continue;
|
|
88
|
+
const symId = nodeId("function", `${rel}:${sym.name}:${sym.kind}`);
|
|
89
|
+
pushNode({
|
|
90
|
+
id: symId,
|
|
91
|
+
kind: sym.kind === "class" ? "class" : "function",
|
|
92
|
+
label: sym.name,
|
|
93
|
+
path: rel,
|
|
94
|
+
meta: {
|
|
95
|
+
parser: `language-${parsed.language}`,
|
|
96
|
+
symbolKind: sym.kind,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
pushEdge({
|
|
100
|
+
id: nodeId("edge", `${fileId}->${symId}`),
|
|
101
|
+
from: fileId,
|
|
102
|
+
to: symId,
|
|
103
|
+
kind: "defines",
|
|
104
|
+
evidence: evidenceFromAdapter(sym.evidence),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
for (const imp of parsed.imports) {
|
|
108
|
+
if (imp.evidence === "unsupported")
|
|
109
|
+
continue;
|
|
110
|
+
const depId = nodeId("dependency", `${rel}->${imp.specifier}`);
|
|
111
|
+
pushNode({
|
|
112
|
+
id: depId,
|
|
113
|
+
kind: "dependency",
|
|
114
|
+
label: imp.specifier,
|
|
115
|
+
path: rel,
|
|
116
|
+
meta: { parser: `language-${parsed.language}`, specifier: imp.specifier },
|
|
117
|
+
});
|
|
118
|
+
pushEdge({
|
|
119
|
+
id: nodeId("edge", `${fileId}->${depId}`),
|
|
120
|
+
from: fileId,
|
|
121
|
+
to: depId,
|
|
122
|
+
kind: "imports",
|
|
123
|
+
evidence: evidenceFromAdapter(imp.evidence),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
for (const call of parsed.calls) {
|
|
127
|
+
if (call.evidence === "unsupported")
|
|
128
|
+
continue;
|
|
129
|
+
const callId = nodeId("function", `call:${rel}:${call.callee}`);
|
|
130
|
+
pushNode({
|
|
131
|
+
id: callId,
|
|
132
|
+
kind: "function",
|
|
133
|
+
label: call.callee,
|
|
134
|
+
path: rel,
|
|
135
|
+
meta: { callSite: true, parser: `language-${parsed.language}` },
|
|
136
|
+
});
|
|
137
|
+
pushEdge({
|
|
138
|
+
id: nodeId("edge", `${fileId}->call:${call.callee}:${call.callee}`),
|
|
139
|
+
from: fileId,
|
|
140
|
+
to: callId,
|
|
141
|
+
kind: "calls",
|
|
142
|
+
evidence: "inferred",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
147
|
+
edges.sort((a, b) => a.id.localeCompare(b.id));
|
|
148
|
+
if (filesParsed === 0 && filesAttempted > 0 && filesSkippedUnsupported === filesAttempted) {
|
|
149
|
+
limitations.push("No supported language adapters available for discovered multi-language source files.");
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
graph: {
|
|
153
|
+
...graph,
|
|
154
|
+
nodes,
|
|
155
|
+
edges,
|
|
156
|
+
limitations: [...graph.limitations, ...limitations],
|
|
157
|
+
},
|
|
158
|
+
stats: {
|
|
159
|
+
filesAttempted,
|
|
160
|
+
filesParsed,
|
|
161
|
+
filesSkippedUnsupported,
|
|
162
|
+
nodesAdded: nodes.length - nodesBefore,
|
|
163
|
+
edgesAdded: edges.length - edgesBefore,
|
|
164
|
+
byLanguage,
|
|
165
|
+
limitations,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|