@amrishkhan05/frankly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.claude-plugin/marketplace.json +27 -0
  2. package/.claude-plugin/plugin.json +11 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +20 -0
  4. package/.github/ISSUE_TEMPLATE/documentation.md +15 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.md +15 -0
  6. package/.github/PULL_REQUEST_TEMPLATE.md +20 -0
  7. package/.github/copilot-instructions.md +12 -0
  8. package/.github/plugin/marketplace.json +20 -0
  9. package/.github/plugin/plugin.json +16 -0
  10. package/.mcp.json +10 -0
  11. package/LICENSE +21 -0
  12. package/README.md +394 -0
  13. package/commands/frankly-help.toml +2 -0
  14. package/commands/frankly-plan.toml +2 -0
  15. package/commands/frankly-review.toml +2 -0
  16. package/commands/frankly-verify.toml +2 -0
  17. package/commands/frankly.toml +2 -0
  18. package/dist/analysis/change-classifier.d.ts +65 -0
  19. package/dist/analysis/change-classifier.d.ts.map +1 -0
  20. package/dist/analysis/change-classifier.js +241 -0
  21. package/dist/analysis/change-classifier.js.map +1 -0
  22. package/dist/analysis/repository-intelligence.d.ts +15 -0
  23. package/dist/analysis/repository-intelligence.d.ts.map +1 -0
  24. package/dist/analysis/repository-intelligence.js +179 -0
  25. package/dist/analysis/repository-intelligence.js.map +1 -0
  26. package/dist/core/config.d.ts +393 -0
  27. package/dist/core/config.d.ts.map +1 -0
  28. package/dist/core/config.js +130 -0
  29. package/dist/core/config.js.map +1 -0
  30. package/dist/core/engine.d.ts +20 -0
  31. package/dist/core/engine.d.ts.map +1 -0
  32. package/dist/core/engine.js +117 -0
  33. package/dist/core/engine.js.map +1 -0
  34. package/dist/core/errors.d.ts +27 -0
  35. package/dist/core/errors.d.ts.map +1 -0
  36. package/dist/core/errors.js +48 -0
  37. package/dist/core/errors.js.map +1 -0
  38. package/dist/core/index.d.ts +8 -0
  39. package/dist/core/index.d.ts.map +1 -0
  40. package/dist/core/index.js +8 -0
  41. package/dist/core/index.js.map +1 -0
  42. package/dist/core/models.d.ts +155 -0
  43. package/dist/core/models.d.ts.map +1 -0
  44. package/dist/core/models.js +6 -0
  45. package/dist/core/models.js.map +1 -0
  46. package/dist/git/index.d.ts +7 -0
  47. package/dist/git/index.d.ts.map +1 -0
  48. package/dist/git/index.js +6 -0
  49. package/dist/git/index.js.map +1 -0
  50. package/dist/git/repository.d.ts +44 -0
  51. package/dist/git/repository.d.ts.map +1 -0
  52. package/dist/git/repository.js +198 -0
  53. package/dist/git/repository.js.map +1 -0
  54. package/dist/graph/symbols.d.ts +45 -0
  55. package/dist/graph/symbols.d.ts.map +1 -0
  56. package/dist/graph/symbols.js +204 -0
  57. package/dist/graph/symbols.js.map +1 -0
  58. package/dist/integrations/claude/checkpoint.d.ts +2 -0
  59. package/dist/integrations/claude/checkpoint.d.ts.map +1 -0
  60. package/dist/integrations/claude/checkpoint.js +33 -0
  61. package/dist/integrations/claude/checkpoint.js.map +1 -0
  62. package/dist/integrations/cli/demo.d.ts +3 -0
  63. package/dist/integrations/cli/demo.d.ts.map +1 -0
  64. package/dist/integrations/cli/demo.js +31 -0
  65. package/dist/integrations/cli/demo.js.map +1 -0
  66. package/dist/integrations/cli/index.d.ts +7 -0
  67. package/dist/integrations/cli/index.d.ts.map +1 -0
  68. package/dist/integrations/cli/index.js +181 -0
  69. package/dist/integrations/cli/index.js.map +1 -0
  70. package/dist/integrations/mcp/index.d.ts +3 -0
  71. package/dist/integrations/mcp/index.d.ts.map +1 -0
  72. package/dist/integrations/mcp/index.js +7 -0
  73. package/dist/integrations/mcp/index.js.map +1 -0
  74. package/dist/integrations/mcp/server.d.ts +14 -0
  75. package/dist/integrations/mcp/server.d.ts.map +1 -0
  76. package/dist/integrations/mcp/server.js +232 -0
  77. package/dist/integrations/mcp/server.js.map +1 -0
  78. package/dist/project/detector.d.ts +70 -0
  79. package/dist/project/detector.d.ts.map +1 -0
  80. package/dist/project/detector.js +214 -0
  81. package/dist/project/detector.js.map +1 -0
  82. package/dist/report/json.d.ts +26 -0
  83. package/dist/report/json.d.ts.map +1 -0
  84. package/dist/report/json.js +148 -0
  85. package/dist/report/json.js.map +1 -0
  86. package/dist/review/red-ink-review.d.ts +26 -0
  87. package/dist/review/red-ink-review.d.ts.map +1 -0
  88. package/dist/review/red-ink-review.js +103 -0
  89. package/dist/review/red-ink-review.js.map +1 -0
  90. package/fixtures/retry-429/README.md +8 -0
  91. package/fixtures/retry-429/baseline/package.json +4 -0
  92. package/fixtures/retry-429/baseline/src/client.test.ts +8 -0
  93. package/fixtures/retry-429/baseline/src/client.ts +3 -0
  94. package/fixtures/retry-429/baseline/src/retry-policy.ts +1 -0
  95. package/fixtures/retry-429/baseline/tsconfig.json +7 -0
  96. package/fixtures/retry-429/candidate/src/client.ts +5 -0
  97. package/fixtures/retry-429/candidate/src/retry-strategy.ts +9 -0
  98. package/fixtures/retry-429/candidate/src/unrelated-format.ts +1 -0
  99. package/hooks/copilot-activate.js +9 -0
  100. package/hooks/copilot-hooks.json +13 -0
  101. package/hooks/hooks.json +13 -0
  102. package/package.json +70 -0
  103. package/scripts/run.cjs +32 -0
  104. package/skills/frankly/SKILL.md +12 -0
  105. package/src/analysis/change-classifier.ts +285 -0
  106. package/src/analysis/repository-intelligence.ts +236 -0
  107. package/src/core/config.ts +149 -0
  108. package/src/core/engine.ts +143 -0
  109. package/src/core/errors.ts +56 -0
  110. package/src/core/index.ts +8 -0
  111. package/src/core/models.ts +280 -0
  112. package/src/git/index.ts +7 -0
  113. package/src/git/repository.ts +207 -0
  114. package/src/graph/symbols.ts +254 -0
  115. package/src/integrations/claude/checkpoint.ts +40 -0
  116. package/src/integrations/cli/demo.ts +34 -0
  117. package/src/integrations/cli/index.ts +211 -0
  118. package/src/integrations/mcp/index.ts +7 -0
  119. package/src/integrations/mcp/server.ts +257 -0
  120. package/src/project/detector.ts +242 -0
  121. package/src/report/json.ts +175 -0
  122. package/src/review/red-ink-review.ts +125 -0
  123. package/src/tests/phase1.test.ts +57 -0
@@ -0,0 +1,13 @@
1
+ {
2
+ "description": "Run one local Red Ink Review when Claude finishes a task.",
3
+ "hooks": {
4
+ "Stop": [{
5
+ "hooks": [{
6
+ "type": "command",
7
+ "command": "node",
8
+ "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/run.cjs", "checkpoint", "${CLAUDE_PLUGIN_DATA}"],
9
+ "timeout": 60
10
+ }]
11
+ }]
12
+ }
13
+ }
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@amrishkhan05/frankly",
3
+ "version": "0.1.0",
4
+ "description": "Change intelligence for AI coding agents. Every changed line should earn its place.",
5
+ "type": "module",
6
+ "bin": {
7
+ "frankly": "dist/integrations/cli/index.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "tsx src/integrations/cli/index.ts",
11
+ "build": "tsc -p tsconfig.json",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest",
14
+ "mcp": "tsx src/integrations/mcp/index.ts",
15
+ "review": "tsx src/integrations/cli/index.ts review",
16
+ "verify": "tsx src/integrations/cli/index.ts verify",
17
+ "demo": "tsx src/integrations/cli/demo.ts",
18
+ "benchmark": "tsx benchmarks/run.ts",
19
+ "prepublishOnly": "npm test && npm run build"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.0.0",
26
+ "tsx": "^4.7.0",
27
+ "typescript": "^5.3.3",
28
+ "zod": "^3.22.4"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^26.4.0",
32
+ "vitest": "^4.1.11"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src",
37
+ "scripts",
38
+ ".claude-plugin",
39
+ ".github",
40
+ ".mcp.json",
41
+ "hooks",
42
+ "skills",
43
+ "commands",
44
+ "fixtures",
45
+ "README.md",
46
+ "LICENSE"
47
+ ],
48
+ "keywords": [
49
+ "ai",
50
+ "code-review",
51
+ "mcp",
52
+ "claude-code",
53
+ "change-intelligence"
54
+ ],
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/amrishkhan05/frankly.git"
58
+ },
59
+ "homepage": "https://github.com/amrishkhan05/frankly#readme",
60
+ "bugs": {
61
+ "url": "https://github.com/amrishkhan05/frankly/issues"
62
+ },
63
+ "license": "MIT",
64
+ "author": "Amrish Khan <amrishkhan05@gmail.com>",
65
+ "main": "index.js",
66
+ "directories": {
67
+ "doc": "docs",
68
+ "example": "examples"
69
+ }
70
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ const { execFileSync, spawnSync } = require("node:child_process");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const root = path.resolve(__dirname, "..");
7
+ const mode = process.argv[2] || "mcp";
8
+ const data = process.env.FRANKLY_PLUGIN_DATA || (mode === "checkpoint" ? process.argv[3] : undefined) || root;
9
+
10
+ if (data !== root) {
11
+ const marker = path.join(data, ".frankly-version");
12
+ const version = require(path.join(root, "package.json")).version;
13
+ if (!fs.existsSync(marker) || fs.readFileSync(marker, "utf8") !== version) {
14
+ fs.mkdirSync(data, { recursive: true });
15
+ fs.cpSync(path.join(root, "src"), path.join(data, "src"), { recursive: true });
16
+ fs.copyFileSync(path.join(root, "package.json"), path.join(data, "package.json"));
17
+ execFileSync("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], { cwd: data, stdio: "inherit" });
18
+ fs.writeFileSync(marker, version);
19
+ }
20
+ }
21
+
22
+ const entry = mode === "checkpoint"
23
+ ? "src/integrations/claude/checkpoint.ts"
24
+ : mode === "mcp"
25
+ ? "src/integrations/mcp/index.ts"
26
+ : "src/integrations/cli/index.ts";
27
+ const tsx = path.join(data, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx");
28
+ const result = spawnSync(tsx, [path.join(data, entry), ...(mode === "checkpoint" || mode === "mcp" ? [] : process.argv.slice(2))], {
29
+ cwd: process.cwd(),
30
+ stdio: "inherit",
31
+ });
32
+ process.exit(result.status ?? 1);
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: frankly
3
+ description: Plan, review, minimize, and verify code changes with local repository evidence.
4
+ ---
5
+
6
+ # Frankly
7
+
8
+ Before editing, call `plan_change` with the user's task when scope is uncertain.
9
+
10
+ At a meaningful completion checkpoint, call `analyze_change`. If its verdict warrants correction, follow the single correction instruction returned by `minimize_change`, preserving requested behavior and safety checks. Then call `verify_change` once with any tests actually executed.
11
+
12
+ Never describe predicted tests as executed. Never repeat a correction pass.
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Change analysis and classification
3
+ * Determines necessity and classification of changed symbols
4
+ */
5
+
6
+ import type {
7
+ ChangedFile,
8
+ ChangedSymbol,
9
+ ChangeClassification,
10
+ Evidence,
11
+ } from "../core/models.js";
12
+ import { countDiffLines, type FileDiff } from "../git/repository.js";
13
+ import type { ExtractedSymbol } from "../graph/symbols.js";
14
+
15
+ export interface ChangeAnalysisInput {
16
+ task: string;
17
+ files: FileDiff[];
18
+ symbols: Map<string, ExtractedSymbol[]>;
19
+ }
20
+
21
+ export interface ClassifiedChange {
22
+ symbol: ChangedSymbol | ChangedFile;
23
+ classification: ChangeClassification;
24
+ confidence: "high" | "medium" | "low";
25
+ justification: string;
26
+ evidence: Evidence[];
27
+ }
28
+
29
+ export class ChangeAnalyzer {
30
+ /**
31
+ * Classify all changes
32
+ */
33
+ classifyChanges(input: ChangeAnalysisInput): ClassifiedChange[] {
34
+ const taskKeywords = this.extractTaskKeywords(input.task);
35
+ const classifications: ClassifiedChange[] = [];
36
+
37
+ // Classify files
38
+ for (const file of input.files) {
39
+ const classification = this.classifyFile(file, taskKeywords);
40
+ classifications.push(classification);
41
+
42
+ for (const symbol of (input.symbols.get(file.path) || []).filter((item) =>
43
+ file.status === "added" || file.hunks.some((hunk) => rangesOverlap(item.lineRange, [hunk.newStart, hunk.newStart + Math.max(0, hunk.newLines - 1)])),
44
+ )) {
45
+ const changed = this.toChangedSymbol(file, symbol);
46
+ const matched = taskKeywords.some((keyword) => changed.name.toLowerCase().includes(keyword));
47
+ classifications.push({
48
+ symbol: changed,
49
+ classification: this.isTestFile(file.path) ? "TEST" : matched ? "REQUIRED" : "SUPPORTING",
50
+ confidence: matched ? "high" : "medium",
51
+ justification: matched ? "Changed symbol matches task intent" : "Symbol is inside a changed hunk",
52
+ evidence: [{
53
+ type: matched ? "TASK_MATCH" : "DIFF_DEPENDENT",
54
+ confidence: matched ? "high" : "medium",
55
+ explanation: matched ? `Symbol ${changed.name} matches task keywords` : `Symbol ${changed.name} overlaps a changed hunk`,
56
+ reference: changed.id,
57
+ }],
58
+ });
59
+ }
60
+ }
61
+
62
+ return classifications;
63
+ }
64
+
65
+ /**
66
+ * Extract keywords from task description
67
+ */
68
+ public extractTaskKeywords(task: string): string[] {
69
+ return task
70
+ .toLowerCase()
71
+ .replace(/[^a-z0-9\s]/g, " ")
72
+ .split(/\s+/)
73
+ .filter((word) => word.length > 2 && !this.isCommonWord(word));
74
+ }
75
+
76
+ /**
77
+ * Normalize a task into a compact intent string.
78
+ */
79
+ public normalizeTaskIntent(task: string): string {
80
+ const keywords = this.extractTaskKeywords(task);
81
+ return keywords.join(" ") || task.trim();
82
+ }
83
+
84
+ /**
85
+ * Check if word is a common English word
86
+ */
87
+ private isCommonWord(word: string): boolean {
88
+ const common = new Set([
89
+ "the",
90
+ "and",
91
+ "for",
92
+ "with",
93
+ "that",
94
+ "this",
95
+ "from",
96
+ "have",
97
+ "will",
98
+ "when",
99
+ "can",
100
+ "are",
101
+ "all",
102
+ "each",
103
+ "where",
104
+ "should",
105
+ "add",
106
+ "fix",
107
+ "update",
108
+ "change",
109
+ "modify",
110
+ "remove",
111
+ "create",
112
+ ]);
113
+ return common.has(word);
114
+ }
115
+
116
+ /**
117
+ * Classify a file change
118
+ */
119
+ private classifyFile(file: FileDiff, taskKeywords: string[]): ClassifiedChange {
120
+ const evidence: Evidence[] = [];
121
+
122
+ // Check if file matches task keywords
123
+ const pathMatch = this.calculatePathMatchScore(`${file.path}\n${file.hunks.map((hunk) => hunk.content).join("\n")}`, taskKeywords);
124
+ if (pathMatch > 0) {
125
+ evidence.push({
126
+ type: "TASK_MATCH",
127
+ confidence: "high",
128
+ explanation: `File path matches task keywords: ${file.path}`,
129
+ });
130
+ }
131
+
132
+ // Calculate additions/deletions/changes
133
+ const { additions, deletions } = countDiffLines(file);
134
+ const changes = file.hunks.length;
135
+
136
+ let classification: ChangeClassification = "UNEXPLAINED";
137
+ let confidence: "high" | "medium" | "low" = "low";
138
+ let justification = "File change appears unrelated to task";
139
+
140
+ if (this.isGeneratedFile(file.path)) {
141
+ classification = "GENERATED";
142
+ confidence = "high";
143
+ justification = "Generated artifact changed with the patch";
144
+ } else if (file.status === "deleted") {
145
+ classification = "UNEXPLAINED";
146
+ confidence = "high";
147
+ justification = "Deleted file - requires explanation";
148
+ } else if (file.status === "added") {
149
+ if (pathMatch > 0) {
150
+ classification = "SUPPORTING";
151
+ confidence = "high";
152
+ justification = "New file related to task";
153
+ } else {
154
+ classification = "SUSPICIOUS";
155
+ confidence = "medium";
156
+ justification = "New file with unclear relationship to task";
157
+ }
158
+ } else if (file.status === "modified") {
159
+ if (pathMatch > 0) {
160
+ classification = "REQUIRED";
161
+ confidence = "high";
162
+ justification = "File directly related to task";
163
+ } else if (this.isTestFile(file.path)) {
164
+ classification = "TEST";
165
+ confidence = "medium";
166
+ justification = "Test file modification";
167
+ } else if (this.isConfigFile(file.path)) {
168
+ classification = "SUPPORTING";
169
+ confidence = "medium";
170
+ justification = "Configuration file change";
171
+ } else {
172
+ classification = "SUSPICIOUS";
173
+ confidence = "low";
174
+ justification = "File change weakly related to task";
175
+ }
176
+ } else if (file.status === "renamed") {
177
+ classification = "SUSPICIOUS";
178
+ confidence = "medium";
179
+ justification = "File rename requires explanation";
180
+ }
181
+
182
+ return {
183
+ symbol: {
184
+ path: file.path,
185
+ changeType: file.status,
186
+ additions,
187
+ deletions,
188
+ changes,
189
+ symbols: [],
190
+ },
191
+ classification,
192
+ confidence,
193
+ justification,
194
+ evidence,
195
+ };
196
+ }
197
+
198
+ private toChangedSymbol(file: FileDiff, symbol: ExtractedSymbol): ChangedSymbol {
199
+ return {
200
+ id: `${file.path}:${symbol.name}:${symbol.lineRange[0]}`,
201
+ name: symbol.name,
202
+ type: symbol.type,
203
+ filePath: file.path,
204
+ lineRange: symbol.lineRange,
205
+ changeType: file.status,
206
+ visibility: symbol.visibility,
207
+ isExported: symbol.isExported,
208
+ callers: [],
209
+ callees: [],
210
+ implementations: [],
211
+ interfaces: [],
212
+ referencedContracts: [],
213
+ relatedTests: [],
214
+ evidence: [],
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Calculate how much a file path matches task keywords
220
+ */
221
+ private calculatePathMatchScore(filePath: string, keywords: string[]): number {
222
+ if (keywords.length === 0) return 0;
223
+
224
+ const pathParts = filePath.toLowerCase().split(/[\\/\.]/);
225
+ let matches = 0;
226
+
227
+ for (const keyword of keywords) {
228
+ if (pathParts.some((part) => part.includes(keyword))) {
229
+ matches++;
230
+ }
231
+ }
232
+
233
+ return matches / keywords.length;
234
+ }
235
+
236
+ /**
237
+ * Check if file is a test file
238
+ */
239
+ private isTestFile(filePath: string): boolean {
240
+ return /\.(test|spec)\.(ts|js|tsx|jsx)$/.test(filePath) || filePath.includes("__tests__");
241
+ }
242
+
243
+ /**
244
+ * Check if file is a config file
245
+ */
246
+ private isConfigFile(filePath: string): boolean {
247
+ return (
248
+ /\.(config|rc)\.(ts|js|json)$/.test(filePath) ||
249
+ /^\./.test(filePath) ||
250
+ ["package.json", "tsconfig.json", "jest.config.js"].includes(filePath.split("/").pop() || "")
251
+ );
252
+ }
253
+
254
+ private isGeneratedFile(filePath: string): boolean {
255
+ return /(?:^|\/)(?:dist|build|generated)\//.test(filePath) || /(?:-lock\.json|pnpm-lock\.yaml|yarn\.lock|\.snap)$/.test(filePath);
256
+ }
257
+
258
+ /**
259
+ * Detect scope drift percentage
260
+ */
261
+ detectScopeDrift(
262
+ classifications: ClassifiedChange[],
263
+ totalChanges: number,
264
+ ): number {
265
+ const suspiciousCount = classifications.filter((c) => c.classification === "SUSPICIOUS").length;
266
+ return suspiciousCount / Math.max(totalChanges, 1);
267
+ }
268
+
269
+ /**
270
+ * Detect refactor contamination
271
+ */
272
+ detectRefactorContamination(classifications: ClassifiedChange[]): string[] {
273
+ return classifications
274
+ .filter((c) => c.classification === "REFACTOR")
275
+ .map((c) => (typeof c.symbol === "object" && "path" in c.symbol) ? c.symbol.path : "unknown");
276
+ }
277
+ }
278
+
279
+ function rangesOverlap(left: [number, number], right: [number, number]): boolean {
280
+ return left[0] <= right[1] && right[0] <= left[1];
281
+ }
282
+
283
+ export function createChangeAnalyzer(): ChangeAnalyzer {
284
+ return new ChangeAnalyzer();
285
+ }
@@ -0,0 +1,236 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as ts from "typescript";
4
+ import type {
5
+ BehavioralChangeType,
6
+ ChangedSymbol,
7
+ ContractChange,
8
+ CorrectionPlan,
9
+ Evidence,
10
+ ExecutedTest,
11
+ Finding,
12
+ PredictedTest,
13
+ } from "../core/models.js";
14
+ import type { FileDiff } from "../git/repository.js";
15
+
16
+ export interface RepositoryIntelligence {
17
+ directCallers: string[];
18
+ transitiveCallers: string[];
19
+ packages: string[];
20
+ predictedTests: PredictedTest[];
21
+ contracts: ContractChange[];
22
+ behavioralChanges: BehavioralChangeType[];
23
+ reuseCandidates: string[];
24
+ findings: Finding[];
25
+ correction?: CorrectionPlan;
26
+ }
27
+
28
+ const SOURCE_RE = /\.[cm]?[jt]sx?$/;
29
+ const TEST_RE = /(?:^|\/)(?:__tests__\/.*|.*\.(?:test|spec))\.[cm]?[jt]sx?$/;
30
+ const SKIP = new Set([".git", "node_modules", "dist", "build", "coverage", ".next"]);
31
+
32
+ export function inspectRepository(
33
+ root: string,
34
+ taskKeywords: string[],
35
+ diffs: FileDiff[],
36
+ changedSymbols: ChangedSymbol[],
37
+ executedTests: ExecutedTest[] = [],
38
+ ): RepositoryIntelligence {
39
+ const sourceFiles = walk(root).filter((file) => SOURCE_RE.test(file));
40
+ const relativeFiles = sourceFiles.map((file) => path.relative(root, file).split(path.sep).join("/"));
41
+ const changed = new Set(diffs.map((file) => file.path));
42
+ const imports = new Map<string, string[]>();
43
+ const contents = new Map<string, string>();
44
+
45
+ for (const relative of relativeFiles) {
46
+ const content = fs.readFileSync(path.join(root, relative), "utf8");
47
+ contents.set(relative, content);
48
+ imports.set(relative, extractImports(content).map((specifier) => resolveImport(relative, specifier, relativeFiles)).filter(Boolean) as string[]);
49
+ }
50
+
51
+ const directCallers = relativeFiles.filter((file) => imports.get(file)?.some((target) => changed.has(target)));
52
+ const transitive = new Set(directCallers);
53
+ let frontier = [...directCallers];
54
+ while (frontier.length) {
55
+ const targets = new Set(frontier);
56
+ frontier = relativeFiles.filter((file) => !transitive.has(file) && imports.get(file)?.some((target) => targets.has(target)));
57
+ frontier.forEach((file) => transitive.add(file));
58
+ }
59
+
60
+ const predictedTests = relativeFiles.filter((file) => TEST_RE.test(file)).flatMap((file): PredictedTest[] => {
61
+ const body = contents.get(file) || "";
62
+ const related = directCallers.includes(file) || imports.get(file)?.some((target) => changed.has(target));
63
+ const mentionsSymbol = changedSymbols.some((symbol) => body.includes(symbol.name.split(".").pop() || symbol.name));
64
+ const sameStem = diffs.some((diff) => path.basename(file).includes(path.basename(diff.path).replace(/\.[^.]+$/, "")));
65
+ if (!related && !mentionsSymbol && !sameStem) return [];
66
+ const status = related || mentionsSymbol ? "LIKELY_AFFECTED" : "POSSIBLY_AFFECTED";
67
+ return [{
68
+ path: file,
69
+ name: path.basename(file),
70
+ status,
71
+ evidence: [{
72
+ type: related ? "IMPORT_DEPENDENCY" : "TEST_REFERENCE",
73
+ confidence: related ? "high" : "medium",
74
+ explanation: related ? "Test imports a changed module" : "Test name or contents reference a changed symbol",
75
+ reference: file,
76
+ }],
77
+ }];
78
+ });
79
+
80
+ const contracts = changedSymbols.filter((symbol) => symbol.isExported).map((symbol): ContractChange => ({
81
+ type: symbol.changeType === "added" ? "ADDED" : symbol.changeType === "deleted" ? "REMOVED" : "CHANGED",
82
+ location: `${symbol.filePath}:${symbol.lineRange[0]} ${symbol.name}`,
83
+ severity: symbol.changeType === "added" ? "compatible" : "potentially-breaking",
84
+ confidence: symbol.changeType === "added" ? "high" : "medium",
85
+ affectedCallers: directCallers.filter((file) => imports.get(file)?.includes(symbol.filePath)),
86
+ }));
87
+
88
+ const diffText = diffs.flatMap((file) => file.hunks.map((hunk) => hunk.content)).join("\n").toLowerCase();
89
+ const behavioralChanges = behaviorFrom(diffText, diffs);
90
+ const reuseCandidates = findReuseCandidates(taskKeywords, changed, contents, root);
91
+ const findings: Finding[] = [];
92
+
93
+ if (reuseCandidates[0]) findings.push(finding(
94
+ "reuse-candidate", "warning", "medium", "Existing code may be reusable",
95
+ `${reuseCandidates[0]} contains task-related symbols or text and is unchanged by this patch.`, [reuseCandidates[0]],
96
+ "Inspect this existing mechanism before keeping a parallel implementation.",
97
+ { type: "EXISTING_UTILITY", confidence: "medium", explanation: "Unchanged repository code matches task intent", reference: reuseCandidates[0] },
98
+ ));
99
+
100
+ for (const symbol of changedSymbols.filter((item) => item.changeType === "added" && ["interface", "class"].includes(item.type))) {
101
+ const uses = [...contents.values()].reduce((count, body) => count + occurrences(body, symbol.name), 0);
102
+ if (uses <= 2) findings.push(finding(
103
+ "unnecessary-abstraction",
104
+ "warning",
105
+ "medium",
106
+ "Possible unnecessary abstraction",
107
+ `${symbol.type} ${symbol.name} has no evidence of reuse outside its declaration.`,
108
+ [symbol.id],
109
+ "Keep it local unless another consumer requires the abstraction.",
110
+ { type: "SYMBOL_REFERENCE", confidence: "medium", explanation: `Found ${Math.max(0, uses - 1)} use(s) outside the declaration`, reference: symbol.id },
111
+ ));
112
+ }
113
+
114
+ for (const file of diffs.filter((item) => item.path === "package.json" && item.status === "modified")) {
115
+ const additions = file.hunks.flatMap((hunk) => hunk.content.split("\n")).filter((line) => /^\+\s*"[^"]+"\s*:/.test(line));
116
+ if (additions.length) findings.push(finding(
117
+ "dependency-addition", "warning", "high", "Dependency addition needs justification",
118
+ `package.json adds ${additions.length} dependency entry or entries.`, [file.path],
119
+ "Use the runtime or an existing dependency unless the task requires this package.",
120
+ { type: "DEPENDENCY_USAGE", confidence: "high", explanation: additions.join(", "), reference: file.path },
121
+ ));
122
+ }
123
+
124
+ if (behavioralChanges.some((type) => type !== "TEST_ONLY" && type !== "REFACTOR_ONLY") && predictedTests.length === 0) {
125
+ findings.push(finding(
126
+ "missing-regression-test", "warning", "medium", "Missing regression coverage",
127
+ `Behavioral change detected (${behavioralChanges.join(", ")}) but no related test was found.`, [],
128
+ "Add one regression test for the changed behavior.",
129
+ { type: "TEST_REFERENCE", confidence: "medium", explanation: "No test imports or references the changed code" },
130
+ ));
131
+ }
132
+
133
+ const packages = new Set([...changed, ...directCallers].map((file) => packageFor(root, file)).filter(Boolean) as string[]);
134
+ const removalPaths = diffs.filter((file) =>
135
+ !taskKeywords.some((word) => file.path.toLowerCase().includes(word))
136
+ && !TEST_RE.test(file.path)
137
+ && !imports.get(file.path)?.some((target) => changed.has(target)),
138
+ ).map((file) => file.path);
139
+ const evidence: Evidence[] = removalPaths.map((file) => ({ type: "TASK_MATCH", confidence: "medium", explanation: "No task keyword matched this path", reference: file }));
140
+ const correction = findings.length || removalPaths.length ? {
141
+ pass: 1,
142
+ removals: removalPaths,
143
+ simplifications: findings.filter((item) => item.id.startsWith("unnecessary-abstraction")).map((item) => ({ symbol: item.affectedSymbols[0], suggestion: item.recommendation || "Inline it" })),
144
+ evidence,
145
+ instruction: correctionInstruction([...findings, ...removalPaths.map((file) => finding("scope", "warning", "medium", "Weak task evidence", file, [file], "Remove or justify it", evidence.find((item) => item.reference === file)!))]),
146
+ } satisfies CorrectionPlan : undefined;
147
+
148
+ return {
149
+ directCallers,
150
+ transitiveCallers: [...transitive].filter((file) => !directCallers.includes(file)),
151
+ packages: [...packages],
152
+ predictedTests,
153
+ contracts,
154
+ behavioralChanges,
155
+ reuseCandidates,
156
+ findings,
157
+ correction,
158
+ };
159
+ }
160
+
161
+ function walk(root: string): string[] {
162
+ const files: string[] = [];
163
+ const visit = (directory: string) => {
164
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
165
+ if (SKIP.has(entry.name)) continue;
166
+ const full = path.join(directory, entry.name);
167
+ if (entry.isDirectory()) visit(full);
168
+ else if (entry.isFile()) files.push(full);
169
+ }
170
+ };
171
+ visit(root);
172
+ return files;
173
+ }
174
+
175
+ function extractImports(content: string): string[] {
176
+ const source = ts.createSourceFile("file.ts", content, ts.ScriptTarget.Latest, true);
177
+ return source.statements.flatMap((statement): string[] => {
178
+ if ((ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) return [statement.moduleSpecifier.text];
179
+ return [];
180
+ });
181
+ }
182
+
183
+ function resolveImport(from: string, specifier: string, files: string[]): string | undefined {
184
+ if (!specifier.startsWith(".")) return undefined;
185
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier)).replace(/\.js$/, "");
186
+ return files.find((file) => file.replace(/\.[^.]+$/, "") === base || file.replace(/\/index\.[^.]+$/, "") === base);
187
+ }
188
+
189
+ function findReuseCandidates(keywords: string[], changed: Set<string>, contents: Map<string, string>, root: string): string[] {
190
+ const scored: Array<[string, number]> = [];
191
+ for (const [file, body] of contents) {
192
+ if (changed.has(file) || TEST_RE.test(file)) continue;
193
+ const lower = `${file}\n${body}`.toLowerCase();
194
+ const pathScore = keywords.filter((word) => file.toLowerCase().includes(word)).length;
195
+ const score = pathScore * 10 + keywords.filter((word) => lower.includes(word)).length;
196
+ if (score) scored.push([file, score]);
197
+ }
198
+ return scored.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([file]) => path.relative(root, path.join(root, file)));
199
+ }
200
+
201
+ function behaviorFrom(text: string, diffs: FileDiff[]): BehavioralChangeType[] {
202
+ const found = new Set<BehavioralChangeType>();
203
+ const rules: Array<[RegExp, BehavioralChangeType]> = [
204
+ [/throw|catch|error/, "ERROR_HANDLING"], [/retry|429|backoff/, "RETRY"], [/cache|memo/, "CACHE"],
205
+ [/auth|permission|role/, "AUTHORIZATION"], [/insert|update|delete|save|write/, "STATE_MUTATION"],
206
+ [/route|request|response|status/, "API_BEHAVIOR"], [/env|config/, "CONFIGURATION"], [/promise|async|await|lock/, "CONCURRENCY"],
207
+ ];
208
+ rules.forEach(([pattern, type]) => { if (pattern.test(text)) found.add(type); });
209
+ if (diffs.every((file) => TEST_RE.test(file.path))) found.add("TEST_ONLY");
210
+ return [...found];
211
+ }
212
+
213
+ function packageFor(root: string, file: string): string | undefined {
214
+ let directory = path.dirname(path.join(root, file));
215
+ while (directory.startsWith(root)) {
216
+ const manifest = path.join(directory, "package.json");
217
+ if (fs.existsSync(manifest)) {
218
+ try { return JSON.parse(fs.readFileSync(manifest, "utf8")).name || path.relative(root, directory) || "root"; } catch { return path.relative(root, directory) || "root"; }
219
+ }
220
+ if (directory === root) break;
221
+ directory = path.dirname(directory);
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ function occurrences(body: string, word: string): number {
227
+ return body.match(new RegExp(`\\b${word.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\b`, "g"))?.length || 0;
228
+ }
229
+
230
+ function finding(id: string, severity: "error" | "warning" | "info", confidence: "high" | "medium" | "low", title: string, description: string, affectedSymbols: string[], recommendation: string, evidence: Evidence): Finding {
231
+ return { id: `${id}-${affectedSymbols[0] || "change"}`, severity, confidence, title, description, affectedSymbols, recommendation, evidence: [evidence], autoCorrectible: severity !== "error" };
232
+ }
233
+
234
+ function correctionInstruction(findings: Finding[]): string {
235
+ return `Revisit the current patch once. Preserve requested behavior and all validation, security, accessibility, data integrity, error handling, and regression coverage. Do not add functionality. Resolve or justify these red marks:\n${findings.map((item) => `- ${item.title}: ${item.description}`).join("\n")}\nReturn the smallest safe patch that satisfies the task.`;
236
+ }