@praneeth_54/agentdoctor 2.1.0 → 3.0.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 +57 -3
- package/README.md +489 -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/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 +10 -0
- package/dist/ai/config.js +1 -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 +19 -8
- package/dist/cli/commands/chat.js +2 -4
- package/dist/cli/commands/product.d.ts +19 -0
- package/dist/cli/commands/product.js +147 -0
- package/dist/cli/commands/start.d.ts +15 -0
- package/dist/cli/commands/start.js +80 -0
- package/dist/cli/program.js +141 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/dashboard/server.js +255 -53
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/intelligence/graph/build.js +36 -8
- 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/registry.d.ts +1 -1
- package/dist/mcp/intelligence/registry.js +30 -1
- 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 +24 -0
- package/dist/product/decisions/ledger.js +110 -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 +42 -0
- package/dist/product/discovery/roots.js +215 -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 +193 -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 +69 -0
- package/dist/product/index.js +35 -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 +95 -0
- package/dist/product/memory/institutional.d.ts +16 -0
- package/dist/product/memory/institutional.js +87 -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 +100 -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 +70 -0
- package/dist/product/whatif/engine.d.ts +28 -0
- package/dist/product/whatif/engine.js +70 -0
- package/dist/utils/fs.js +10 -2
- package/package.json +1 -1
|
@@ -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,193 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { getAdapterForFile, parseSourceFile } from "../../languages/index.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 out = [];
|
|
13
|
+
const skip = new Set(["node_modules", ".git", "dist", "coverage", ".agentdoctor", "vendor"]);
|
|
14
|
+
async function walk(dir) {
|
|
15
|
+
if (out.length >= limit)
|
|
16
|
+
return;
|
|
17
|
+
let entries;
|
|
18
|
+
try {
|
|
19
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
if (out.length >= limit)
|
|
26
|
+
return;
|
|
27
|
+
if (skip.has(e.name))
|
|
28
|
+
continue;
|
|
29
|
+
const abs = path.join(dir, e.name);
|
|
30
|
+
if (e.isSymbolicLink())
|
|
31
|
+
continue;
|
|
32
|
+
if (e.isDirectory()) {
|
|
33
|
+
await walk(abs);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (LANG_FILE_RE.test(e.name)) {
|
|
37
|
+
out.push(abs);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
await walk(root);
|
|
42
|
+
return out.sort();
|
|
43
|
+
}
|
|
44
|
+
function evidenceFromAdapter(kind) {
|
|
45
|
+
if (kind === "ast")
|
|
46
|
+
return "verified";
|
|
47
|
+
return "unknown";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Walk a bounded set of Python/PHP/Go sources and merge adapter symbols/imports/calls into the graph.
|
|
51
|
+
*/
|
|
52
|
+
export async function enrichGraphWithLanguageAdapters(graph, rootInput, options) {
|
|
53
|
+
const root = resolveRepoRoot(rootInput);
|
|
54
|
+
const maxFiles = options?.maxFiles ?? 120;
|
|
55
|
+
const limitations = [
|
|
56
|
+
"Language enrichment covers .py/.php/.go/.java/.kt/.kts/.rs/.dart, capped file budget, skips unsupported adapters.",
|
|
57
|
+
"Symbol/call edges from language adapters may be partial — evidence follows adapter (ast→verified, else unknown).",
|
|
58
|
+
];
|
|
59
|
+
const existingNodeIds = new Set(graph.nodes.map((n) => n.id));
|
|
60
|
+
const existingEdgeIds = new Set(graph.edges.map((e) => e.id));
|
|
61
|
+
const nodes = [...graph.nodes];
|
|
62
|
+
const edges = [...graph.edges];
|
|
63
|
+
const pushNode = (n) => {
|
|
64
|
+
if (existingNodeIds.has(n.id))
|
|
65
|
+
return;
|
|
66
|
+
existingNodeIds.add(n.id);
|
|
67
|
+
nodes.push(n);
|
|
68
|
+
};
|
|
69
|
+
const pushEdge = (e) => {
|
|
70
|
+
if (existingEdgeIds.has(e.id))
|
|
71
|
+
return;
|
|
72
|
+
existingEdgeIds.add(e.id);
|
|
73
|
+
edges.push(e);
|
|
74
|
+
};
|
|
75
|
+
const nodesBefore = nodes.length;
|
|
76
|
+
const edgesBefore = edges.length;
|
|
77
|
+
const byLanguage = {};
|
|
78
|
+
let filesAttempted = 0;
|
|
79
|
+
let filesParsed = 0;
|
|
80
|
+
let filesSkippedUnsupported = 0;
|
|
81
|
+
const files = await listLanguageSourceFiles(root, maxFiles);
|
|
82
|
+
if (files.length >= maxFiles) {
|
|
83
|
+
limitations.push(`Language file walk capped at ${maxFiles} files`);
|
|
84
|
+
}
|
|
85
|
+
for (const absolute of files) {
|
|
86
|
+
filesAttempted += 1;
|
|
87
|
+
const rel = toPosixRelative(root, absolute);
|
|
88
|
+
const adapter = getAdapterForFile(rel);
|
|
89
|
+
if (!adapter) {
|
|
90
|
+
filesSkippedUnsupported += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const caps = adapter.capabilities();
|
|
94
|
+
if (caps.parse !== "supported") {
|
|
95
|
+
filesSkippedUnsupported += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const source = await readTextFile(absolute, 512 * 1024);
|
|
99
|
+
if (source === null)
|
|
100
|
+
continue;
|
|
101
|
+
const parsed = await parseSourceFile(rel, source);
|
|
102
|
+
if (!parsed.ok) {
|
|
103
|
+
limitations.push(`Parse failed for ${rel}: ${parsed.diagnostics[0] ?? "unknown"}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
filesParsed += 1;
|
|
107
|
+
byLanguage[parsed.language] = (byLanguage[parsed.language] ?? 0) + 1;
|
|
108
|
+
const fileId = nodeId("file", rel);
|
|
109
|
+
pushNode({ id: fileId, kind: "file", label: path.basename(rel), path: rel });
|
|
110
|
+
for (const sym of parsed.symbols) {
|
|
111
|
+
if (sym.evidence === "unsupported")
|
|
112
|
+
continue;
|
|
113
|
+
const symId = nodeId("function", `${rel}:${sym.name}:${sym.kind}`);
|
|
114
|
+
pushNode({
|
|
115
|
+
id: symId,
|
|
116
|
+
kind: sym.kind === "class" ? "class" : "function",
|
|
117
|
+
label: sym.name,
|
|
118
|
+
path: rel,
|
|
119
|
+
meta: {
|
|
120
|
+
parser: `language-${parsed.language}`,
|
|
121
|
+
symbolKind: sym.kind,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
pushEdge({
|
|
125
|
+
id: nodeId("edge", `${fileId}->${symId}`),
|
|
126
|
+
from: fileId,
|
|
127
|
+
to: symId,
|
|
128
|
+
kind: "defines",
|
|
129
|
+
evidence: evidenceFromAdapter(sym.evidence),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
for (const imp of parsed.imports) {
|
|
133
|
+
if (imp.evidence === "unsupported")
|
|
134
|
+
continue;
|
|
135
|
+
const depId = nodeId("dependency", `${rel}->${imp.specifier}`);
|
|
136
|
+
pushNode({
|
|
137
|
+
id: depId,
|
|
138
|
+
kind: "dependency",
|
|
139
|
+
label: imp.specifier,
|
|
140
|
+
path: rel,
|
|
141
|
+
meta: { parser: `language-${parsed.language}`, specifier: imp.specifier },
|
|
142
|
+
});
|
|
143
|
+
pushEdge({
|
|
144
|
+
id: nodeId("edge", `${fileId}->${depId}`),
|
|
145
|
+
from: fileId,
|
|
146
|
+
to: depId,
|
|
147
|
+
kind: "imports",
|
|
148
|
+
evidence: evidenceFromAdapter(imp.evidence),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
for (const call of parsed.calls) {
|
|
152
|
+
if (call.evidence === "unsupported")
|
|
153
|
+
continue;
|
|
154
|
+
const callId = nodeId("function", `call:${rel}:${call.callee}`);
|
|
155
|
+
pushNode({
|
|
156
|
+
id: callId,
|
|
157
|
+
kind: "function",
|
|
158
|
+
label: call.callee,
|
|
159
|
+
path: rel,
|
|
160
|
+
meta: { callSite: true, parser: `language-${parsed.language}` },
|
|
161
|
+
});
|
|
162
|
+
pushEdge({
|
|
163
|
+
id: nodeId("edge", `${fileId}->call:${call.callee}:${call.callee}`),
|
|
164
|
+
from: fileId,
|
|
165
|
+
to: callId,
|
|
166
|
+
kind: "calls",
|
|
167
|
+
evidence: "inferred",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
nodes.sort((a, b) => a.id.localeCompare(b.id));
|
|
172
|
+
edges.sort((a, b) => a.id.localeCompare(b.id));
|
|
173
|
+
if (filesParsed === 0 && filesAttempted > 0 && filesSkippedUnsupported === filesAttempted) {
|
|
174
|
+
limitations.push("No supported language adapters available for discovered multi-language source files.");
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
graph: {
|
|
178
|
+
...graph,
|
|
179
|
+
nodes,
|
|
180
|
+
edges,
|
|
181
|
+
limitations: [...graph.limitations, ...limitations],
|
|
182
|
+
},
|
|
183
|
+
stats: {
|
|
184
|
+
filesAttempted,
|
|
185
|
+
filesParsed,
|
|
186
|
+
filesSkippedUnsupported,
|
|
187
|
+
nodesAdded: nodes.length - nodesBefore,
|
|
188
|
+
edgesAdded: edges.length - edgesBefore,
|
|
189
|
+
byLanguage,
|
|
190
|
+
limitations,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { GitIntelligenceReport } from "../../intelligence/git/analyze.js";
|
|
2
|
+
import type { ArchitectureCheckResult } from "../../architecture/contract.js";
|
|
3
|
+
import type { ProductEvidence, TruthLabel } from "../truth.js";
|
|
4
|
+
export interface CodeHealthIndicator {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
value: number | string;
|
|
8
|
+
truth: TruthLabel;
|
|
9
|
+
evidence: ProductEvidence[];
|
|
10
|
+
}
|
|
11
|
+
export interface CodeHealthReport {
|
|
12
|
+
root: string;
|
|
13
|
+
indicators: CodeHealthIndicator[];
|
|
14
|
+
limitations: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare function analyzeCodeHealth(rootInput: string, options?: {
|
|
17
|
+
gitReport?: GitIntelligenceReport;
|
|
18
|
+
architectureCheck?: ArchitectureCheckResult;
|
|
19
|
+
maxFileSizeBytes?: number;
|
|
20
|
+
}): Promise<CodeHealthReport>;
|
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
function isTestPath(relativePath) {
|
|
7
|
+
const p = relativePath.toLowerCase();
|
|
8
|
+
return (p.includes(".test.") ||
|
|
9
|
+
p.includes(".spec.") ||
|
|
10
|
+
p.startsWith("tests/") ||
|
|
11
|
+
p.startsWith("test/") ||
|
|
12
|
+
p.includes("__tests__/"));
|
|
13
|
+
}
|
|
14
|
+
function isSourcePath(relativePath) {
|
|
15
|
+
const p = relativePath.toLowerCase();
|
|
16
|
+
if (isTestPath(p))
|
|
17
|
+
return false;
|
|
18
|
+
return (p.endsWith(".ts") ||
|
|
19
|
+
p.endsWith(".tsx") ||
|
|
20
|
+
p.endsWith(".js") ||
|
|
21
|
+
p.endsWith(".jsx") ||
|
|
22
|
+
p.endsWith(".py") ||
|
|
23
|
+
p.endsWith(".php") ||
|
|
24
|
+
p.endsWith(".go") ||
|
|
25
|
+
p.endsWith(".rs"));
|
|
26
|
+
}
|
|
27
|
+
async function countTodoFixme(root, files, maxBytes) {
|
|
28
|
+
let todo = 0;
|
|
29
|
+
let fixme = 0;
|
|
30
|
+
const evidence = [];
|
|
31
|
+
const re = /\b(TODO|FIXME)\b/g;
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
const text = await readTextFile(path.join(root, f.relativePath), maxBytes);
|
|
34
|
+
if (!text)
|
|
35
|
+
continue;
|
|
36
|
+
re.lastIndex = 0;
|
|
37
|
+
let m;
|
|
38
|
+
while ((m = re.exec(text)) !== null) {
|
|
39
|
+
if (m[1] === "TODO")
|
|
40
|
+
todo++;
|
|
41
|
+
else
|
|
42
|
+
fixme++;
|
|
43
|
+
if (evidence.length < 8) {
|
|
44
|
+
const line = text.slice(0, m.index).split("\n").length;
|
|
45
|
+
const excerpt = text.split(/\r?\n/)[line - 1]?.trim().slice(0, 100);
|
|
46
|
+
evidence.push({
|
|
47
|
+
path: f.relativePath.replace(/\\/g, "/"),
|
|
48
|
+
line,
|
|
49
|
+
...(excerpt !== undefined ? { excerpt } : {}),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { todo, fixme, evidence };
|
|
55
|
+
}
|
|
56
|
+
export async function analyzeCodeHealth(rootInput, options) {
|
|
57
|
+
const maxFileSizeBytes = options?.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES;
|
|
58
|
+
const root = resolveRepoRoot(rootInput);
|
|
59
|
+
const detection = await detectProject(root, maxFileSizeBytes);
|
|
60
|
+
const paths = detection.discovery.files.map((f) => f.relativePath.replace(/\\/g, "/"));
|
|
61
|
+
const limitations = [
|
|
62
|
+
"Scores are descriptive indicators with evidence; not a single authoritative debt score.",
|
|
63
|
+
"Hotspot overlap requires git intelligence input.",
|
|
64
|
+
];
|
|
65
|
+
const sourceFiles = paths.filter(isSourcePath);
|
|
66
|
+
const testFiles = paths.filter(isTestPath);
|
|
67
|
+
const { todo, fixme, evidence: todoEvidence, } = await countTodoFixme(root, detection.discovery.files, maxFileSizeBytes);
|
|
68
|
+
const indicators = [
|
|
69
|
+
{
|
|
70
|
+
id: "file-count",
|
|
71
|
+
label: "Discovered files",
|
|
72
|
+
value: paths.length,
|
|
73
|
+
truth: "VERIFIED",
|
|
74
|
+
evidence: [{ path: ".", excerpt: `discovery.files.length=${paths.length}` }],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "source-file-count",
|
|
78
|
+
label: "Source-like files",
|
|
79
|
+
value: sourceFiles.length,
|
|
80
|
+
truth: "INFERRED",
|
|
81
|
+
evidence: [{ path: ".", excerpt: `extension/heuristic count=${sourceFiles.length}` }],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "test-file-count",
|
|
85
|
+
label: "Test-like files",
|
|
86
|
+
value: testFiles.length,
|
|
87
|
+
truth: "INFERRED",
|
|
88
|
+
evidence: [{ path: ".", excerpt: `test path heuristic count=${testFiles.length}` }],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "test-ratio",
|
|
92
|
+
label: "Test file ratio (test / (test+source))",
|
|
93
|
+
value: sourceFiles.length + testFiles.length === 0
|
|
94
|
+
? "n/a"
|
|
95
|
+
: (testFiles.length / (sourceFiles.length + testFiles.length)).toFixed(3),
|
|
96
|
+
truth: "INFERRED",
|
|
97
|
+
evidence: [
|
|
98
|
+
{
|
|
99
|
+
path: ".",
|
|
100
|
+
excerpt: `tests=${testFiles.length}, sources=${sourceFiles.length}`,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "todo-count",
|
|
106
|
+
label: "TODO markers",
|
|
107
|
+
value: todo,
|
|
108
|
+
truth: "VERIFIED",
|
|
109
|
+
evidence: todoEvidence.filter((e) => e.excerpt?.includes("TODO")),
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: "fixme-count",
|
|
113
|
+
label: "FIXME markers",
|
|
114
|
+
value: fixme,
|
|
115
|
+
truth: "VERIFIED",
|
|
116
|
+
evidence: todoEvidence.filter((e) => e.excerpt?.includes("FIXME")),
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
if (options?.architectureCheck) {
|
|
120
|
+
indicators.push({
|
|
121
|
+
id: "architecture-violations",
|
|
122
|
+
label: "Architecture contract violations",
|
|
123
|
+
value: options.architectureCheck.violations.length,
|
|
124
|
+
truth: "VERIFIED",
|
|
125
|
+
evidence: options.architectureCheck.violations.slice(0, 5).map((v) => ({
|
|
126
|
+
path: v.fromPath || v.toPath || ".",
|
|
127
|
+
excerpt: `${v.ruleId}: ${v.description}`.slice(0, 120),
|
|
128
|
+
})),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (options?.gitReport?.gitAvailable) {
|
|
132
|
+
const hotspotPaths = new Set(options.gitReport.hotspots.map((h) => h.path.replace(/\\/g, "/")));
|
|
133
|
+
const overlap = sourceFiles.filter((p) => hotspotPaths.has(p)).length;
|
|
134
|
+
indicators.push({
|
|
135
|
+
id: "hotspot-source-overlap",
|
|
136
|
+
label: "Source files overlapping git hotspots",
|
|
137
|
+
value: overlap,
|
|
138
|
+
truth: "INFERRED",
|
|
139
|
+
evidence: options.gitReport.hotspots.slice(0, 5).map((h) => ({
|
|
140
|
+
path: h.path,
|
|
141
|
+
excerpt: `commits=${h.commits}`,
|
|
142
|
+
})),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
limitations.push("Git hotspot overlap skipped (git unavailable or not provided).");
|
|
147
|
+
}
|
|
148
|
+
return { root, indicators, limitations };
|
|
149
|
+
}
|