@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,195 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { redactSecrets } from "../../platform/security/redact.js";
|
|
4
|
+
import { resolveRepoRoot } from "../../utils/path.js";
|
|
5
|
+
import { DEFAULT_MAX_FILE_SIZE_BYTES } from "../../constants.js";
|
|
6
|
+
import { pathExists, readJsonFile } from "../../utils/fs.js";
|
|
7
|
+
const FORBIDDEN_KEY = /^(secret|token|password|api[_-]?key|credential|private[_-]?key)$/i;
|
|
8
|
+
function modelPath(root) {
|
|
9
|
+
return path.join(resolveRepoRoot(root), ".agentdoctor", "org", "model.json");
|
|
10
|
+
}
|
|
11
|
+
function sanitizeString(value) {
|
|
12
|
+
return redactSecrets(value).text.slice(0, 2000);
|
|
13
|
+
}
|
|
14
|
+
function sanitizeEntityRef(raw) {
|
|
15
|
+
if (!raw || typeof raw !== "object")
|
|
16
|
+
return null;
|
|
17
|
+
const o = raw;
|
|
18
|
+
for (const key of Object.keys(o)) {
|
|
19
|
+
if (FORBIDDEN_KEY.test(key))
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const id = typeof o.id === "string" ? sanitizeString(o.id.trim()) : "";
|
|
23
|
+
const name = typeof o.name === "string" ? sanitizeString(o.name.trim()) : "";
|
|
24
|
+
if (!id || !name)
|
|
25
|
+
return null;
|
|
26
|
+
const description = typeof o.description === "string" ? sanitizeString(o.description.trim()) : undefined;
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
name,
|
|
30
|
+
...(description !== undefined && description.length > 0 ? { description } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function sanitizeProject(raw, catalogRoot) {
|
|
34
|
+
if (!raw || typeof raw !== "object")
|
|
35
|
+
return null;
|
|
36
|
+
const o = raw;
|
|
37
|
+
for (const key of Object.keys(o)) {
|
|
38
|
+
if (FORBIDDEN_KEY.test(key))
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const id = typeof o.id === "string" ? sanitizeString(o.id.trim()) : "";
|
|
42
|
+
const name = typeof o.name === "string" ? sanitizeString(o.name.trim()) : "";
|
|
43
|
+
const rootPathRaw = typeof o.rootPath === "string" ? o.rootPath.trim() : catalogRoot;
|
|
44
|
+
if (!id || !name)
|
|
45
|
+
return null;
|
|
46
|
+
const resolved = path.isAbsolute(rootPathRaw)
|
|
47
|
+
? path.normalize(rootPathRaw)
|
|
48
|
+
: path.normalize(path.join(catalogRoot, rootPathRaw));
|
|
49
|
+
if (!resolved.startsWith(catalogRoot)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const teamIds = Array.isArray(o.teamIds)
|
|
53
|
+
? o.teamIds.filter((x) => typeof x === "string").map((x) => sanitizeString(x))
|
|
54
|
+
: [];
|
|
55
|
+
const serviceIds = Array.isArray(o.serviceIds)
|
|
56
|
+
? o.serviceIds.filter((x) => typeof x === "string").map((x) => sanitizeString(x))
|
|
57
|
+
: [];
|
|
58
|
+
const ownerIds = Array.isArray(o.ownerIds)
|
|
59
|
+
? o.ownerIds.filter((x) => typeof x === "string").map((x) => sanitizeString(x))
|
|
60
|
+
: [];
|
|
61
|
+
return { id, name, rootPath: resolved, teamIds, serviceIds, ownerIds };
|
|
62
|
+
}
|
|
63
|
+
function emptyModel(catalogRoot) {
|
|
64
|
+
return {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
updatedAt: new Date().toISOString(),
|
|
67
|
+
catalogRoot,
|
|
68
|
+
teams: [],
|
|
69
|
+
projects: [],
|
|
70
|
+
services: [],
|
|
71
|
+
owners: [],
|
|
72
|
+
truth: "VERIFIED",
|
|
73
|
+
limitations: [
|
|
74
|
+
"Org model is repo-local metadata only — not synced across machines or tenants.",
|
|
75
|
+
"Secret-like fields are rejected on load/save; values are redacted when persisted.",
|
|
76
|
+
"Linking a project root does not grant cross-repo file access — workspace rules still apply.",
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export async function loadOrganizationModel(rootInput) {
|
|
81
|
+
const catalogRoot = resolveRepoRoot(rootInput);
|
|
82
|
+
const file = modelPath(catalogRoot);
|
|
83
|
+
if (!(await pathExists(file))) {
|
|
84
|
+
return emptyModel(catalogRoot);
|
|
85
|
+
}
|
|
86
|
+
const parsed = await readJsonFile(file, DEFAULT_MAX_FILE_SIZE_BYTES);
|
|
87
|
+
if (!parsed.ok || typeof parsed.data !== "object" || parsed.data === null) {
|
|
88
|
+
return {
|
|
89
|
+
...emptyModel(catalogRoot),
|
|
90
|
+
truth: "PARTIAL",
|
|
91
|
+
limitations: [
|
|
92
|
+
...emptyModel(catalogRoot).limitations,
|
|
93
|
+
"Corrupt org model file — using empty model.",
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const o = parsed.data;
|
|
98
|
+
const teams = [];
|
|
99
|
+
const services = [];
|
|
100
|
+
const owners = [];
|
|
101
|
+
const projects = [];
|
|
102
|
+
if (Array.isArray(o.teams)) {
|
|
103
|
+
for (const item of o.teams) {
|
|
104
|
+
const ref = sanitizeEntityRef(item);
|
|
105
|
+
if (ref)
|
|
106
|
+
teams.push(ref);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(o.services)) {
|
|
110
|
+
for (const item of o.services) {
|
|
111
|
+
const ref = sanitizeEntityRef(item);
|
|
112
|
+
if (ref)
|
|
113
|
+
services.push(ref);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(o.owners)) {
|
|
117
|
+
for (const item of o.owners) {
|
|
118
|
+
const ref = sanitizeEntityRef(item);
|
|
119
|
+
if (ref)
|
|
120
|
+
owners.push(ref);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(o.projects)) {
|
|
124
|
+
for (const item of o.projects) {
|
|
125
|
+
const link = sanitizeProject(item, catalogRoot);
|
|
126
|
+
if (link)
|
|
127
|
+
projects.push(link);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
schemaVersion: 1,
|
|
132
|
+
updatedAt: typeof o.updatedAt === "string" && o.updatedAt.trim()
|
|
133
|
+
? o.updatedAt.trim()
|
|
134
|
+
: new Date().toISOString(),
|
|
135
|
+
catalogRoot,
|
|
136
|
+
teams,
|
|
137
|
+
projects,
|
|
138
|
+
services,
|
|
139
|
+
owners,
|
|
140
|
+
truth: "VERIFIED",
|
|
141
|
+
limitations: emptyModel(catalogRoot).limitations,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export async function saveOrganizationModel(rootInput, model) {
|
|
145
|
+
const catalogRoot = resolveRepoRoot(rootInput);
|
|
146
|
+
const teams = model.teams
|
|
147
|
+
.map((t) => sanitizeEntityRef(t))
|
|
148
|
+
.filter((t) => t !== null);
|
|
149
|
+
const services = model.services
|
|
150
|
+
.map((s) => sanitizeEntityRef(s))
|
|
151
|
+
.filter((s) => s !== null);
|
|
152
|
+
const owners = model.owners
|
|
153
|
+
.map((o) => sanitizeEntityRef(o))
|
|
154
|
+
.filter((o) => o !== null);
|
|
155
|
+
const projects = model.projects
|
|
156
|
+
.map((p) => sanitizeProject(p, catalogRoot))
|
|
157
|
+
.filter((p) => p !== null);
|
|
158
|
+
const record = {
|
|
159
|
+
...emptyModel(catalogRoot),
|
|
160
|
+
teams,
|
|
161
|
+
services,
|
|
162
|
+
owners,
|
|
163
|
+
projects,
|
|
164
|
+
updatedAt: new Date().toISOString(),
|
|
165
|
+
};
|
|
166
|
+
const dir = path.dirname(modelPath(catalogRoot));
|
|
167
|
+
await fs.mkdir(dir, { recursive: true });
|
|
168
|
+
await fs.writeFile(modelPath(catalogRoot), `${JSON.stringify(record, null, 2)}\n`, {
|
|
169
|
+
encoding: "utf8",
|
|
170
|
+
mode: 0o600,
|
|
171
|
+
});
|
|
172
|
+
return record;
|
|
173
|
+
}
|
|
174
|
+
/** Register or update a project link under the catalog root (path must stay within catalogRoot). */
|
|
175
|
+
export async function linkProjectToOrgModel(rootInput, link) {
|
|
176
|
+
const catalogRoot = resolveRepoRoot(rootInput);
|
|
177
|
+
const current = await loadOrganizationModel(catalogRoot);
|
|
178
|
+
const rootPath = link.rootPath ?? catalogRoot;
|
|
179
|
+
const sanitized = sanitizeProject({ ...link, rootPath }, catalogRoot);
|
|
180
|
+
if (!sanitized) {
|
|
181
|
+
throw new Error("Invalid project link — path escape or forbidden fields");
|
|
182
|
+
}
|
|
183
|
+
const projects = current.projects.filter((p) => p.id !== sanitized.id);
|
|
184
|
+
projects.push(sanitized);
|
|
185
|
+
return saveOrganizationModel(catalogRoot, {
|
|
186
|
+
teams: current.teams,
|
|
187
|
+
services: current.services,
|
|
188
|
+
owners: current.owners,
|
|
189
|
+
projects,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
export function resolveLinkedProjectRoot(model, projectId) {
|
|
193
|
+
const hit = model.projects.find((p) => p.id === projectId);
|
|
194
|
+
return hit?.rootPath ?? null;
|
|
195
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ProductEvidence, TruthLabel } from "../truth.js";
|
|
2
|
+
export interface PrivacyFinding {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
truth: TruthLabel;
|
|
6
|
+
evidence: ProductEvidence[];
|
|
7
|
+
}
|
|
8
|
+
export interface PrivacyDoctorReport {
|
|
9
|
+
root: string;
|
|
10
|
+
findings: PrivacyFinding[];
|
|
11
|
+
limitations: string[];
|
|
12
|
+
}
|
|
13
|
+
export declare function analyzePrivacySurface(rootInput: string): Promise<PrivacyDoctorReport>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { loadLatestBrain, searchBrain } from "../../core/brain-cli/service.js";
|
|
2
|
+
import { resolveRepoRoot } from "../../utils/path.js";
|
|
3
|
+
import { lineNumberAt, loadProjectSourceFiles } from "../evidence-scan.js";
|
|
4
|
+
const PII_PATTERNS = [
|
|
5
|
+
{
|
|
6
|
+
id: "email-like",
|
|
7
|
+
label: "Email-like string",
|
|
8
|
+
re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: "ssn-like",
|
|
12
|
+
label: "SSN-like digit groups",
|
|
13
|
+
re: /\b\d{3}-\d{2}-\d{4}\b/g,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "credit-card-like",
|
|
17
|
+
label: "Credit-card-like digit groups",
|
|
18
|
+
re: /\b(?:\d[ -]*?){13,19}\b/g,
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
function redactPiiExcerpt(line, match) {
|
|
22
|
+
const idx = line.indexOf(match);
|
|
23
|
+
if (idx < 0)
|
|
24
|
+
return "[REDACTED]";
|
|
25
|
+
const before = line.slice(0, idx).slice(-24);
|
|
26
|
+
const after = line.slice(idx + match.length).slice(0, 12);
|
|
27
|
+
return `${before}[REDACTED]${after}`.trim().slice(0, 120);
|
|
28
|
+
}
|
|
29
|
+
export async function analyzePrivacySurface(rootInput) {
|
|
30
|
+
const root = resolveRepoRoot(rootInput);
|
|
31
|
+
const limitations = [
|
|
32
|
+
"Privacy doctor is a lightweight PII-ish heuristic scan — not legal/compliance advice (not GDPR/HIPAA/PCI attestation).",
|
|
33
|
+
"Matches are INFERRED; many are fixtures, tests, or placeholders.",
|
|
34
|
+
"Matched values are redacted in excerpts; never export raw PII from this report.",
|
|
35
|
+
];
|
|
36
|
+
const findings = [];
|
|
37
|
+
const { files } = await loadProjectSourceFiles(root, () => true);
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
for (const pattern of PII_PATTERNS) {
|
|
40
|
+
pattern.re.lastIndex = 0;
|
|
41
|
+
let match;
|
|
42
|
+
while ((match = pattern.re.exec(file.content)) !== null) {
|
|
43
|
+
const raw = match[0] ?? "";
|
|
44
|
+
if (pattern.id === "credit-card-like" && raw.replace(/\D/g, "").length < 13)
|
|
45
|
+
continue;
|
|
46
|
+
const line = lineNumberAt(file.content, match.index);
|
|
47
|
+
const lineText = file.content.split(/\r?\n/)[line - 1] ?? "";
|
|
48
|
+
findings.push({
|
|
49
|
+
id: pattern.id,
|
|
50
|
+
label: pattern.label,
|
|
51
|
+
truth: "INFERRED",
|
|
52
|
+
evidence: [
|
|
53
|
+
{
|
|
54
|
+
path: file.relativePath,
|
|
55
|
+
line,
|
|
56
|
+
excerpt: redactPiiExcerpt(lineText, raw),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
if (findings.length >= 100)
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
if (findings.length >= 100)
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (findings.length >= 100) {
|
|
67
|
+
limitations.push("PII finding cap reached (100)");
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const brain = await loadLatestBrain(root);
|
|
72
|
+
if (brain) {
|
|
73
|
+
const brainHits = searchBrain(brain, "email");
|
|
74
|
+
if (brainHits.length > 0) {
|
|
75
|
+
findings.push({
|
|
76
|
+
id: "brain-claim-hint",
|
|
77
|
+
label: "Project brain mentions email-related claims (review manually)",
|
|
78
|
+
truth: "INFERRED",
|
|
79
|
+
evidence: brainHits.slice(0, 3).map((h) => ({
|
|
80
|
+
path: ".agentdoctor/project-brain",
|
|
81
|
+
excerpt: h.text.slice(0, 100),
|
|
82
|
+
})),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
limitations.push("Project brain not available for supplemental search.");
|
|
88
|
+
}
|
|
89
|
+
return { root, findings, limitations };
|
|
90
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ProductEvidence, TruthLabel } from "../truth.js";
|
|
2
|
+
export interface RequirementTrace {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
sourcePath: string;
|
|
6
|
+
sourceLine: number;
|
|
7
|
+
acceptanceCriteria: string[];
|
|
8
|
+
truth: TruthLabel;
|
|
9
|
+
evidence: ProductEvidence[];
|
|
10
|
+
missingTestHeuristic: boolean;
|
|
11
|
+
missingTestNote?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface RequirementsTraceReport {
|
|
14
|
+
root: string;
|
|
15
|
+
requirements: RequirementTrace[];
|
|
16
|
+
limitations: string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Scan repository docs for requirements / user stories with file evidence only.
|
|
20
|
+
*/
|
|
21
|
+
export declare function traceRequirements(rootInput: string, maxFileSizeBytes?: number): Promise<RequirementsTraceReport>;
|
|
@@ -0,0 +1,166 @@
|
|
|
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
|
+
import { isLikelyRequirementsDoc, isMarkdownDoc } from "../evidence-scan.js";
|
|
7
|
+
const REQ_HEADING = /^(#{1,4})\s+(?:REQ[-\s]?(\d+)[:\s]+|Requirement\s+(\d+)[:\s]+|US[-\s]?(\d+)[:\s]+|User\s+Story\s+(\d+)[:\s]+)?(.+)$/i;
|
|
8
|
+
const AC_LINE = /^\s*[-*]\s*(?:AC|Acceptance Criteria)[:\s]+(.+)$/i;
|
|
9
|
+
const AC_SECTION = /^#{1,4}\s+Acceptance Criteria\s*$/i;
|
|
10
|
+
function slugId(sourcePath, line, title) {
|
|
11
|
+
const slug = title
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
14
|
+
.replace(/^-|-$/g, "")
|
|
15
|
+
.slice(0, 40);
|
|
16
|
+
return `${sourcePath.replace(/[^\w./-]/g, "_")}:${line}:${slug || "req"}`;
|
|
17
|
+
}
|
|
18
|
+
function testFileMightCover(title, testPaths) {
|
|
19
|
+
const tokens = title
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.split(/\W+/)
|
|
22
|
+
.filter((t) => t.length > 3);
|
|
23
|
+
if (!tokens.length)
|
|
24
|
+
return false;
|
|
25
|
+
for (const tp of testPaths) {
|
|
26
|
+
const lower = tp.toLowerCase();
|
|
27
|
+
if (tokens.some((t) => lower.includes(t)))
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
function isTestPath(relativePath) {
|
|
33
|
+
const p = relativePath.toLowerCase();
|
|
34
|
+
return (p.includes(".test.") ||
|
|
35
|
+
p.includes(".spec.") ||
|
|
36
|
+
p.startsWith("tests/") ||
|
|
37
|
+
p.startsWith("test/") ||
|
|
38
|
+
p.includes("__tests__/"));
|
|
39
|
+
}
|
|
40
|
+
function docEligible(relativePath, content) {
|
|
41
|
+
if (!isMarkdownDoc(relativePath))
|
|
42
|
+
return false;
|
|
43
|
+
if (isLikelyRequirementsDoc(relativePath))
|
|
44
|
+
return true;
|
|
45
|
+
if (/acceptance criteria/i.test(content))
|
|
46
|
+
return true;
|
|
47
|
+
if (/^#{1,4}\s+(?:REQ|Requirement|User Story)/im.test(content))
|
|
48
|
+
return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function parseRequirementsFromDoc(relativePath, content, testPaths) {
|
|
52
|
+
const lines = content.split(/\r?\n/);
|
|
53
|
+
const out = [];
|
|
54
|
+
let inAcSection = false;
|
|
55
|
+
let current = null;
|
|
56
|
+
const flush = () => {
|
|
57
|
+
if (!current)
|
|
58
|
+
return;
|
|
59
|
+
if (current.acceptanceCriteria.length === 0) {
|
|
60
|
+
current.truth = "PARTIAL";
|
|
61
|
+
}
|
|
62
|
+
current.missingTestHeuristic = !testFileMightCover(current.title, testPaths);
|
|
63
|
+
if (current.missingTestHeuristic) {
|
|
64
|
+
current.missingTestNote =
|
|
65
|
+
"No test file path matched requirement title tokens (heuristic only).";
|
|
66
|
+
}
|
|
67
|
+
out.push(current);
|
|
68
|
+
current = null;
|
|
69
|
+
inAcSection = false;
|
|
70
|
+
};
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const line = lines[i] ?? "";
|
|
73
|
+
const lineNo = i + 1;
|
|
74
|
+
if (AC_SECTION.test(line.trim())) {
|
|
75
|
+
inAcSection = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const heading = REQ_HEADING.exec(line.trim());
|
|
79
|
+
if (heading) {
|
|
80
|
+
flush();
|
|
81
|
+
const reqNum = heading[2] ?? heading[3] ?? heading[4] ?? heading[5];
|
|
82
|
+
const titlePart = (heading[6] ?? "").trim();
|
|
83
|
+
const base = relativePath.split("/").pop()?.toLowerCase() ?? "";
|
|
84
|
+
const explicitCatalog = base === "requirements.md" || base === "user-stories.md";
|
|
85
|
+
if (!reqNum && !explicitCatalog)
|
|
86
|
+
continue;
|
|
87
|
+
const title = reqNum ? titlePart : titlePart || line.replace(/^#{1,4}\s+/, "").trim();
|
|
88
|
+
if (!title || title.length < 3)
|
|
89
|
+
continue;
|
|
90
|
+
const displayTitle = reqNum ? `REQ-${reqNum}: ${title}` : title;
|
|
91
|
+
current = {
|
|
92
|
+
id: slugId(relativePath, lineNo, displayTitle),
|
|
93
|
+
title: displayTitle,
|
|
94
|
+
sourcePath: relativePath,
|
|
95
|
+
sourceLine: lineNo,
|
|
96
|
+
acceptanceCriteria: [],
|
|
97
|
+
truth: "VERIFIED",
|
|
98
|
+
evidence: [{ path: relativePath, line: lineNo, excerpt: line.trim().slice(0, 120) }],
|
|
99
|
+
missingTestHeuristic: false,
|
|
100
|
+
};
|
|
101
|
+
inAcSection = false;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!current)
|
|
105
|
+
continue;
|
|
106
|
+
const acInline = AC_LINE.exec(line);
|
|
107
|
+
if (acInline?.[1]) {
|
|
108
|
+
current.acceptanceCriteria.push(acInline[1].trim());
|
|
109
|
+
current.evidence.push({
|
|
110
|
+
path: relativePath,
|
|
111
|
+
line: lineNo,
|
|
112
|
+
excerpt: line.trim().slice(0, 120),
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (inAcSection && /^\s*[-*]\s+/.test(line)) {
|
|
117
|
+
const item = line.replace(/^\s*[-*]\s+/, "").trim();
|
|
118
|
+
if (item) {
|
|
119
|
+
current.acceptanceCriteria.push(item);
|
|
120
|
+
current.evidence.push({
|
|
121
|
+
path: relativePath,
|
|
122
|
+
line: lineNo,
|
|
123
|
+
excerpt: item.slice(0, 120),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else if (inAcSection && line.trim() === "") {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
else if (inAcSection && /^#{1,4}\s/.test(line)) {
|
|
131
|
+
inAcSection = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
flush();
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Scan repository docs for requirements / user stories with file evidence only.
|
|
139
|
+
*/
|
|
140
|
+
export async function traceRequirements(rootInput, maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_BYTES) {
|
|
141
|
+
const root = resolveRepoRoot(rootInput);
|
|
142
|
+
const detection = await detectProject(root, maxFileSizeBytes);
|
|
143
|
+
const limitations = [
|
|
144
|
+
"Requirements are extracted only from markdown headings and acceptance-criteria sections present in files.",
|
|
145
|
+
"Missing-test detection is a filename/token heuristic, not coverage analysis.",
|
|
146
|
+
];
|
|
147
|
+
if (detection.diagnostics.length) {
|
|
148
|
+
limitations.push(...detection.diagnostics.slice(0, 3));
|
|
149
|
+
}
|
|
150
|
+
const testPaths = detection.discovery.files.map((f) => f.relativePath).filter(isTestPath);
|
|
151
|
+
const requirements = [];
|
|
152
|
+
for (const entry of detection.discovery.files) {
|
|
153
|
+
const rel = entry.relativePath.replace(/\\/g, "/");
|
|
154
|
+
const abs = path.join(root, rel);
|
|
155
|
+
const text = await readTextFile(abs, maxFileSizeBytes);
|
|
156
|
+
if (text === null)
|
|
157
|
+
continue;
|
|
158
|
+
if (!docEligible(rel, text))
|
|
159
|
+
continue;
|
|
160
|
+
requirements.push(...parseRequirementsFromDoc(rel, text, testPaths));
|
|
161
|
+
}
|
|
162
|
+
requirements.sort((a, b) => a.sourcePath === b.sourcePath
|
|
163
|
+
? a.sourceLine - b.sourceLine
|
|
164
|
+
: a.sourcePath.localeCompare(b.sourcePath));
|
|
165
|
+
return { root, requirements, limitations };
|
|
166
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface IndexDocument {
|
|
2
|
+
id: string;
|
|
3
|
+
path: string;
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
export interface TfidfIndex {
|
|
7
|
+
documents: IndexDocument[];
|
|
8
|
+
idf: Map<string, number>;
|
|
9
|
+
docVectors: Map<string, Map<string, number>>;
|
|
10
|
+
limitations: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function buildIndex(documents: IndexDocument[]): TfidfIndex;
|
|
13
|
+
export declare function searchIndex(index: TfidfIndex, query: string, maxHits?: number): Array<{
|
|
14
|
+
document: IndexDocument;
|
|
15
|
+
score: number;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function documentId(path: string, content: string): string;
|
|
18
|
+
export declare function searchHybrid(index: TfidfIndex, query: string, substringHits: Array<{
|
|
19
|
+
path: string;
|
|
20
|
+
line?: number;
|
|
21
|
+
excerpt: string;
|
|
22
|
+
score: number;
|
|
23
|
+
}>, maxHits?: number): Array<{
|
|
24
|
+
path: string;
|
|
25
|
+
line?: number;
|
|
26
|
+
excerpt: string;
|
|
27
|
+
score: number;
|
|
28
|
+
kind: "tfidf" | "keyword";
|
|
29
|
+
}>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const TOKEN_RE = /[a-zA-Z][a-zA-Z0-9_]{1,48}|[0-9]+/g;
|
|
3
|
+
function tokenize(text) {
|
|
4
|
+
const lower = text.toLowerCase();
|
|
5
|
+
const matches = lower.match(TOKEN_RE);
|
|
6
|
+
return matches ?? [];
|
|
7
|
+
}
|
|
8
|
+
function termFrequency(tokens) {
|
|
9
|
+
const tf = new Map();
|
|
10
|
+
for (const t of tokens) {
|
|
11
|
+
tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
12
|
+
}
|
|
13
|
+
const max = Math.max(1, ...tf.values());
|
|
14
|
+
for (const [k, v] of tf) {
|
|
15
|
+
tf.set(k, v / max);
|
|
16
|
+
}
|
|
17
|
+
return tf;
|
|
18
|
+
}
|
|
19
|
+
export function buildIndex(documents) {
|
|
20
|
+
const limitations = [
|
|
21
|
+
"Local index uses keyword TF-IDF — not neural embeddings or semantic similarity.",
|
|
22
|
+
];
|
|
23
|
+
const docTokens = new Map();
|
|
24
|
+
const df = new Map();
|
|
25
|
+
for (const doc of documents) {
|
|
26
|
+
const tokens = tokenize(doc.text);
|
|
27
|
+
docTokens.set(doc.id, tokens);
|
|
28
|
+
const unique = new Set(tokens);
|
|
29
|
+
for (const t of unique) {
|
|
30
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const n = Math.max(1, documents.length);
|
|
34
|
+
const idf = new Map();
|
|
35
|
+
for (const [term, count] of df) {
|
|
36
|
+
idf.set(term, Math.log((1 + n) / (1 + count)) + 1);
|
|
37
|
+
}
|
|
38
|
+
const docVectors = new Map();
|
|
39
|
+
for (const doc of documents) {
|
|
40
|
+
const tf = termFrequency(docTokens.get(doc.id) ?? []);
|
|
41
|
+
const vec = new Map();
|
|
42
|
+
for (const [term, weight] of tf) {
|
|
43
|
+
const idfVal = idf.get(term) ?? 1;
|
|
44
|
+
vec.set(term, weight * idfVal);
|
|
45
|
+
}
|
|
46
|
+
docVectors.set(doc.id, vec);
|
|
47
|
+
}
|
|
48
|
+
return { documents, idf, docVectors, limitations };
|
|
49
|
+
}
|
|
50
|
+
function vectorNorm(vec) {
|
|
51
|
+
let sum = 0;
|
|
52
|
+
for (const v of vec.values())
|
|
53
|
+
sum += v * v;
|
|
54
|
+
return Math.sqrt(sum) || 1;
|
|
55
|
+
}
|
|
56
|
+
function cosineSimilarity(a, b) {
|
|
57
|
+
let dot = 0;
|
|
58
|
+
const smaller = a.size <= b.size ? a : b;
|
|
59
|
+
const other = a.size <= b.size ? b : a;
|
|
60
|
+
for (const [term, weight] of smaller) {
|
|
61
|
+
const ow = other.get(term);
|
|
62
|
+
if (ow !== undefined)
|
|
63
|
+
dot += weight * ow;
|
|
64
|
+
}
|
|
65
|
+
return dot / (vectorNorm(a) * vectorNorm(b));
|
|
66
|
+
}
|
|
67
|
+
export function searchIndex(index, query, maxHits = 40) {
|
|
68
|
+
const qTokens = tokenize(query);
|
|
69
|
+
if (!qTokens.length)
|
|
70
|
+
return [];
|
|
71
|
+
const qTf = termFrequency(qTokens);
|
|
72
|
+
const qVec = new Map();
|
|
73
|
+
for (const [term, weight] of qTf) {
|
|
74
|
+
qVec.set(term, weight * (index.idf.get(term) ?? 1));
|
|
75
|
+
}
|
|
76
|
+
const hits = [];
|
|
77
|
+
for (const doc of index.documents) {
|
|
78
|
+
const dVec = index.docVectors.get(doc.id);
|
|
79
|
+
if (!dVec)
|
|
80
|
+
continue;
|
|
81
|
+
const score = cosineSimilarity(qVec, dVec);
|
|
82
|
+
if (score > 0)
|
|
83
|
+
hits.push({ document: doc, score });
|
|
84
|
+
}
|
|
85
|
+
hits.sort((a, b) => b.score - a.score || a.document.path.localeCompare(b.document.path));
|
|
86
|
+
return hits.slice(0, maxHits);
|
|
87
|
+
}
|
|
88
|
+
export function documentId(path, content) {
|
|
89
|
+
return createHash("sha1").update(`${path}\0${content.length}`).digest("hex").slice(0, 16);
|
|
90
|
+
}
|
|
91
|
+
export function searchHybrid(index, query, substringHits, maxHits = 40) {
|
|
92
|
+
const tfidf = searchIndex(index, query, maxHits);
|
|
93
|
+
const merged = new Map();
|
|
94
|
+
for (const h of tfidf) {
|
|
95
|
+
const key = h.document.path;
|
|
96
|
+
merged.set(key, {
|
|
97
|
+
path: h.document.path,
|
|
98
|
+
excerpt: h.document.text.slice(0, 140),
|
|
99
|
+
score: h.score + 0.05,
|
|
100
|
+
kind: "tfidf",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
for (const h of substringHits) {
|
|
104
|
+
const key = `${h.path}:${h.line ?? 0}`;
|
|
105
|
+
const prev = merged.get(key);
|
|
106
|
+
if (!prev || h.score > prev.score) {
|
|
107
|
+
merged.set(key, { ...h, kind: "keyword" });
|
|
108
|
+
}
|
|
109
|
+
else if (prev) {
|
|
110
|
+
merged.set(key, { ...prev, score: prev.score + h.score * 0.5 });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...merged.values()]
|
|
114
|
+
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
|
|
115
|
+
.slice(0, maxHits);
|
|
116
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TruthLabel } from "../truth.js";
|
|
2
|
+
export interface SoftwareSearchHit {
|
|
3
|
+
kind: "filename" | "content" | "brain" | "tfidf";
|
|
4
|
+
path: string;
|
|
5
|
+
line?: number;
|
|
6
|
+
excerpt: string;
|
|
7
|
+
score: number;
|
|
8
|
+
truth: TruthLabel;
|
|
9
|
+
}
|
|
10
|
+
export interface SoftwareSearchReport {
|
|
11
|
+
root: string;
|
|
12
|
+
query: string;
|
|
13
|
+
hits: SoftwareSearchHit[];
|
|
14
|
+
limitations: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare function searchSymbolsAndConcepts(rootInput: string, query: string, options?: {
|
|
17
|
+
maxHits?: number;
|
|
18
|
+
maxFiles?: number;
|
|
19
|
+
}): Promise<SoftwareSearchReport>;
|