@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.
- package/.claude-plugin/marketplace.json +27 -0
- package/.claude-plugin/plugin.json +11 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +20 -0
- package/.github/ISSUE_TEMPLATE/documentation.md +15 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +15 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +20 -0
- package/.github/copilot-instructions.md +12 -0
- package/.github/plugin/marketplace.json +20 -0
- package/.github/plugin/plugin.json +16 -0
- package/.mcp.json +10 -0
- package/LICENSE +21 -0
- package/README.md +394 -0
- package/commands/frankly-help.toml +2 -0
- package/commands/frankly-plan.toml +2 -0
- package/commands/frankly-review.toml +2 -0
- package/commands/frankly-verify.toml +2 -0
- package/commands/frankly.toml +2 -0
- package/dist/analysis/change-classifier.d.ts +65 -0
- package/dist/analysis/change-classifier.d.ts.map +1 -0
- package/dist/analysis/change-classifier.js +241 -0
- package/dist/analysis/change-classifier.js.map +1 -0
- package/dist/analysis/repository-intelligence.d.ts +15 -0
- package/dist/analysis/repository-intelligence.d.ts.map +1 -0
- package/dist/analysis/repository-intelligence.js +179 -0
- package/dist/analysis/repository-intelligence.js.map +1 -0
- package/dist/core/config.d.ts +393 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +130 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/engine.d.ts +20 -0
- package/dist/core/engine.d.ts.map +1 -0
- package/dist/core/engine.js +117 -0
- package/dist/core/engine.js.map +1 -0
- package/dist/core/errors.d.ts +27 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +48 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +8 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/models.d.ts +155 -0
- package/dist/core/models.d.ts.map +1 -0
- package/dist/core/models.js +6 -0
- package/dist/core/models.js.map +1 -0
- package/dist/git/index.d.ts +7 -0
- package/dist/git/index.d.ts.map +1 -0
- package/dist/git/index.js +6 -0
- package/dist/git/index.js.map +1 -0
- package/dist/git/repository.d.ts +44 -0
- package/dist/git/repository.d.ts.map +1 -0
- package/dist/git/repository.js +198 -0
- package/dist/git/repository.js.map +1 -0
- package/dist/graph/symbols.d.ts +45 -0
- package/dist/graph/symbols.d.ts.map +1 -0
- package/dist/graph/symbols.js +204 -0
- package/dist/graph/symbols.js.map +1 -0
- package/dist/integrations/claude/checkpoint.d.ts +2 -0
- package/dist/integrations/claude/checkpoint.d.ts.map +1 -0
- package/dist/integrations/claude/checkpoint.js +33 -0
- package/dist/integrations/claude/checkpoint.js.map +1 -0
- package/dist/integrations/cli/demo.d.ts +3 -0
- package/dist/integrations/cli/demo.d.ts.map +1 -0
- package/dist/integrations/cli/demo.js +31 -0
- package/dist/integrations/cli/demo.js.map +1 -0
- package/dist/integrations/cli/index.d.ts +7 -0
- package/dist/integrations/cli/index.d.ts.map +1 -0
- package/dist/integrations/cli/index.js +181 -0
- package/dist/integrations/cli/index.js.map +1 -0
- package/dist/integrations/mcp/index.d.ts +3 -0
- package/dist/integrations/mcp/index.d.ts.map +1 -0
- package/dist/integrations/mcp/index.js +7 -0
- package/dist/integrations/mcp/index.js.map +1 -0
- package/dist/integrations/mcp/server.d.ts +14 -0
- package/dist/integrations/mcp/server.d.ts.map +1 -0
- package/dist/integrations/mcp/server.js +232 -0
- package/dist/integrations/mcp/server.js.map +1 -0
- package/dist/project/detector.d.ts +70 -0
- package/dist/project/detector.d.ts.map +1 -0
- package/dist/project/detector.js +214 -0
- package/dist/project/detector.js.map +1 -0
- package/dist/report/json.d.ts +26 -0
- package/dist/report/json.d.ts.map +1 -0
- package/dist/report/json.js +148 -0
- package/dist/report/json.js.map +1 -0
- package/dist/review/red-ink-review.d.ts +26 -0
- package/dist/review/red-ink-review.d.ts.map +1 -0
- package/dist/review/red-ink-review.js +103 -0
- package/dist/review/red-ink-review.js.map +1 -0
- package/fixtures/retry-429/README.md +8 -0
- package/fixtures/retry-429/baseline/package.json +4 -0
- package/fixtures/retry-429/baseline/src/client.test.ts +8 -0
- package/fixtures/retry-429/baseline/src/client.ts +3 -0
- package/fixtures/retry-429/baseline/src/retry-policy.ts +1 -0
- package/fixtures/retry-429/baseline/tsconfig.json +7 -0
- package/fixtures/retry-429/candidate/src/client.ts +5 -0
- package/fixtures/retry-429/candidate/src/retry-strategy.ts +9 -0
- package/fixtures/retry-429/candidate/src/unrelated-format.ts +1 -0
- package/hooks/copilot-activate.js +9 -0
- package/hooks/copilot-hooks.json +13 -0
- package/hooks/hooks.json +13 -0
- package/package.json +70 -0
- package/scripts/run.cjs +32 -0
- package/skills/frankly/SKILL.md +12 -0
- package/src/analysis/change-classifier.ts +285 -0
- package/src/analysis/repository-intelligence.ts +236 -0
- package/src/core/config.ts +149 -0
- package/src/core/engine.ts +143 -0
- package/src/core/errors.ts +56 -0
- package/src/core/index.ts +8 -0
- package/src/core/models.ts +280 -0
- package/src/git/index.ts +7 -0
- package/src/git/repository.ts +207 -0
- package/src/graph/symbols.ts +254 -0
- package/src/integrations/claude/checkpoint.ts +40 -0
- package/src/integrations/cli/demo.ts +34 -0
- package/src/integrations/cli/index.ts +211 -0
- package/src/integrations/mcp/index.ts +7 -0
- package/src/integrations/mcp/server.ts +257 -0
- package/src/project/detector.ts +242 -0
- package/src/report/json.ts +175 -0
- package/src/review/red-ink-review.ts +125 -0
- package/src/tests/phase1.test.ts +57 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project detection and analysis
|
|
3
|
+
* Identifies TypeScript/JavaScript projects, workspaces, and configuration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import { ProjectError } from "../core/errors.js";
|
|
9
|
+
|
|
10
|
+
export type PackageManager = "npm" | "pnpm" | "yarn";
|
|
11
|
+
|
|
12
|
+
export interface Project {
|
|
13
|
+
root: string;
|
|
14
|
+
type: "typescript" | "javascript";
|
|
15
|
+
packageManager: PackageManager;
|
|
16
|
+
isWorkspace: boolean;
|
|
17
|
+
workspaceRoot?: string;
|
|
18
|
+
packages?: string[];
|
|
19
|
+
hasTsConfig: boolean;
|
|
20
|
+
testRunner?: "jest" | "vitest" | "nx";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ProjectDetector {
|
|
24
|
+
private rootPath: string;
|
|
25
|
+
private cache: Map<string, unknown> = new Map();
|
|
26
|
+
|
|
27
|
+
constructor(rootPath: string) {
|
|
28
|
+
this.rootPath = rootPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect project configuration
|
|
33
|
+
*/
|
|
34
|
+
async detectProject(): Promise<Project> {
|
|
35
|
+
const cacheKey = "project";
|
|
36
|
+
if (this.cache.has(cacheKey)) {
|
|
37
|
+
return this.cache.get(cacheKey) as Project;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const packageJson = this.readPackageJson(this.rootPath);
|
|
41
|
+
if (!packageJson) {
|
|
42
|
+
throw new ProjectError("No package.json found", { path: this.rootPath });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const project: Project = {
|
|
46
|
+
root: this.rootPath,
|
|
47
|
+
type: this.detectLanguageType(),
|
|
48
|
+
packageManager: this.detectPackageManager(),
|
|
49
|
+
isWorkspace: this.isWorkspace(packageJson),
|
|
50
|
+
hasTsConfig: this.hasTsConfig(this.rootPath),
|
|
51
|
+
testRunner: this.detectTestRunner(packageJson),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (project.isWorkspace) {
|
|
55
|
+
project.workspaceRoot = this.rootPath;
|
|
56
|
+
project.packages = this.detectWorkspacePackages(packageJson);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this.cache.set(cacheKey, project);
|
|
60
|
+
return project;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect language type from tsconfig or file extensions
|
|
65
|
+
*/
|
|
66
|
+
private detectLanguageType(): "typescript" | "javascript" {
|
|
67
|
+
if (this.hasTsConfig(this.rootPath)) {
|
|
68
|
+
return "typescript";
|
|
69
|
+
}
|
|
70
|
+
// Check for .ts files
|
|
71
|
+
return this.hasTypeScriptFiles(this.rootPath) ? "typescript" : "javascript";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if tsconfig.json exists
|
|
76
|
+
*/
|
|
77
|
+
private hasTsConfig(dir: string): boolean {
|
|
78
|
+
return fs.existsSync(path.join(dir, "tsconfig.json"));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check for TypeScript files
|
|
83
|
+
*/
|
|
84
|
+
private hasTypeScriptFiles(dir: string, depth = 2): boolean {
|
|
85
|
+
if (depth <= 0) return false;
|
|
86
|
+
try {
|
|
87
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
90
|
+
if (entry.isFile() && entry.name.endsWith(".ts")) return true;
|
|
91
|
+
if (entry.isDirectory() && this.hasTypeScriptFiles(path.join(dir, entry.name), depth - 1)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Ignore errors
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Detect package manager from lock files
|
|
103
|
+
*/
|
|
104
|
+
private detectPackageManager(): PackageManager {
|
|
105
|
+
if (fs.existsSync(path.join(this.rootPath, "pnpm-lock.yaml"))) return "pnpm";
|
|
106
|
+
if (fs.existsSync(path.join(this.rootPath, "yarn.lock"))) return "yarn";
|
|
107
|
+
return "npm";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if project is a workspace
|
|
112
|
+
*/
|
|
113
|
+
private isWorkspace(packageJson: Record<string, unknown>): boolean {
|
|
114
|
+
return (
|
|
115
|
+
Array.isArray(packageJson.workspaces) || typeof packageJson.workspaces === "object"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Detect workspace packages
|
|
121
|
+
*/
|
|
122
|
+
private detectWorkspacePackages(packageJson: Record<string, unknown>): string[] {
|
|
123
|
+
const workspaces = packageJson.workspaces;
|
|
124
|
+
if (!workspaces) return [];
|
|
125
|
+
|
|
126
|
+
let patterns: string[] = [];
|
|
127
|
+
if (Array.isArray(workspaces)) {
|
|
128
|
+
patterns = workspaces as string[];
|
|
129
|
+
} else if (typeof workspaces === "object" && workspaces !== null) {
|
|
130
|
+
const w = workspaces as Record<string, unknown>;
|
|
131
|
+
if (Array.isArray(w.packages)) {
|
|
132
|
+
patterns = w.packages as string[];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Resolve glob patterns to actual packages
|
|
137
|
+
const packages: string[] = [];
|
|
138
|
+
for (const pattern of patterns) {
|
|
139
|
+
const resolved = this.resolveGlobPattern(pattern);
|
|
140
|
+
packages.push(...resolved);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return packages;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve glob pattern to actual directories
|
|
148
|
+
*/
|
|
149
|
+
private resolveGlobPattern(pattern: string): string[] {
|
|
150
|
+
const result: string[] = [];
|
|
151
|
+
if (!pattern.includes("*")) {
|
|
152
|
+
const fullPath = path.join(this.rootPath, pattern);
|
|
153
|
+
if (fs.existsSync(fullPath)) {
|
|
154
|
+
result.push(pattern);
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Simple glob resolution
|
|
160
|
+
const parts = pattern.split("/");
|
|
161
|
+
const basePart = parts[0];
|
|
162
|
+
|
|
163
|
+
if (basePart.includes("*")) {
|
|
164
|
+
const basePattern = basePart.replace("*", "");
|
|
165
|
+
try {
|
|
166
|
+
const entries = fs.readdirSync(this.rootPath);
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (entry.startsWith(basePattern)) {
|
|
169
|
+
const fullPath = path.join(this.rootPath, entry);
|
|
170
|
+
if (fs.statSync(fullPath).isDirectory()) {
|
|
171
|
+
result.push(entry);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Ignore errors
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Detect test runner from package.json
|
|
185
|
+
*/
|
|
186
|
+
private detectTestRunner(packageJson: Record<string, unknown>): "jest" | "vitest" | "nx" | undefined {
|
|
187
|
+
const deps = {
|
|
188
|
+
...((packageJson.dependencies as Record<string, unknown>) || {}),
|
|
189
|
+
...((packageJson.devDependencies as Record<string, unknown>) || {}),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
if ("nx" in deps) return "nx";
|
|
193
|
+
if ("vitest" in deps) return "vitest";
|
|
194
|
+
if ("jest" in deps) return "jest";
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read package.json from directory
|
|
200
|
+
*/
|
|
201
|
+
private readPackageJson(dir: string): Record<string, unknown> | null {
|
|
202
|
+
const packageJsonPath = path.join(dir, "package.json");
|
|
203
|
+
try {
|
|
204
|
+
const content = fs.readFileSync(packageJsonPath, "utf-8");
|
|
205
|
+
return JSON.parse(content) as Record<string, unknown>;
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Find package.json for a given file path
|
|
213
|
+
*/
|
|
214
|
+
findPackageJsonForFile(filePath: string): string | null {
|
|
215
|
+
let dir = path.dirname(filePath);
|
|
216
|
+
while (dir !== path.dirname(dir)) {
|
|
217
|
+
const packageJsonPath = path.join(dir, "package.json");
|
|
218
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
219
|
+
return packageJsonPath;
|
|
220
|
+
}
|
|
221
|
+
dir = path.dirname(dir);
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Read tsconfig.json
|
|
228
|
+
*/
|
|
229
|
+
readTsConfig(dir: string = this.rootPath): Record<string, unknown> | null {
|
|
230
|
+
const tsConfigPath = path.join(dir, "tsconfig.json");
|
|
231
|
+
try {
|
|
232
|
+
const content = fs.readFileSync(tsConfigPath, "utf-8");
|
|
233
|
+
return JSON.parse(content) as Record<string, unknown>;
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function createProjectDetector(rootPath: string): Promise<ProjectDetector> {
|
|
241
|
+
return new ProjectDetector(rootPath);
|
|
242
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report formatting
|
|
3
|
+
* Outputs analysis results in various formats
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AnalysisResult, RedInkReview, Finding } from "../core/models.js";
|
|
7
|
+
|
|
8
|
+
export class ReportFormatter {
|
|
9
|
+
/**
|
|
10
|
+
* Format as JSON
|
|
11
|
+
*/
|
|
12
|
+
formatAsJson(result: AnalysisResult): string {
|
|
13
|
+
return JSON.stringify(result, null, 2);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Format as compact terminal output
|
|
18
|
+
*/
|
|
19
|
+
formatAsTerminal(result: AnalysisResult): string {
|
|
20
|
+
const lines: string[] = [];
|
|
21
|
+
|
|
22
|
+
lines.push("FRANKLY · RED INK REVIEW\n");
|
|
23
|
+
|
|
24
|
+
// Task
|
|
25
|
+
lines.push(`Task\n${result.task}\n`);
|
|
26
|
+
|
|
27
|
+
// Verdict
|
|
28
|
+
const review = result.review;
|
|
29
|
+
lines.push(`CHANGE VERDICT\n${review.verdict}\n`);
|
|
30
|
+
|
|
31
|
+
// Metrics
|
|
32
|
+
lines.push(`TASK ALIGNMENT\n${review.taskAlignment}\n`);
|
|
33
|
+
lines.push(`CHANGE NECESSITY\n${review.changeNecessity}/100\n`);
|
|
34
|
+
lines.push(`CHANGE SURFACE\n${review.changeSurface}\n`);
|
|
35
|
+
lines.push(`SCOPE DRIFT\n${Math.round(review.scopeDrift * 100)}%\n`);
|
|
36
|
+
lines.push(`MINIMUM PATCH\n${result.files.length} file(s), ${result.symbols.length} changed symbol(s)\n`);
|
|
37
|
+
|
|
38
|
+
// Red marks
|
|
39
|
+
if (review.findings.length > 0) {
|
|
40
|
+
lines.push("RED MARKS\n");
|
|
41
|
+
for (const finding of review.findings) {
|
|
42
|
+
const icon = finding.severity === "error" ? "✗" : "⚠";
|
|
43
|
+
lines.push(`${icon} ${finding.title}`);
|
|
44
|
+
}
|
|
45
|
+
lines.push("");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Blast radius
|
|
49
|
+
lines.push("BLAST RADIUS\n");
|
|
50
|
+
lines.push(`Direct callers ${review.blastRadius.directCallers}`);
|
|
51
|
+
lines.push(`Transitive callers ${review.blastRadius.transitiveCallers}`);
|
|
52
|
+
lines.push(`Packages ${review.blastRadius.affectedPackages}\n`);
|
|
53
|
+
|
|
54
|
+
// Test impact
|
|
55
|
+
lines.push("TEST IMPACT\n");
|
|
56
|
+
lines.push(`Likely affected ${review.testImpact.likelyAffected}`);
|
|
57
|
+
lines.push(`Possibly affected ${review.testImpact.possiblyAffected}`);
|
|
58
|
+
lines.push(`Executed ${review.testImpact.executed}`);
|
|
59
|
+
lines.push(`Passed ${review.testImpact.passed}\n`);
|
|
60
|
+
for (const test of review.testImpact.predicted.slice(0, 10)) {
|
|
61
|
+
lines.push(` ${test.status.padEnd(19)} ${test.path}`);
|
|
62
|
+
}
|
|
63
|
+
if (review.testImpact.predicted.length) lines.push("");
|
|
64
|
+
|
|
65
|
+
if (review.contractChanges.length) {
|
|
66
|
+
lines.push("CONTRACT CHANGES\n");
|
|
67
|
+
for (const contract of review.contractChanges.slice(0, 10)) lines.push(`⚠ ${contract.type} ${contract.location}`);
|
|
68
|
+
lines.push("");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Recommendation
|
|
72
|
+
lines.push("RECOMMENDATION\n");
|
|
73
|
+
lines.push(review.recommendation);
|
|
74
|
+
|
|
75
|
+
if (review.correction) lines.push(`\nCORRECTION PASS (1/1)\n${review.correction.instruction}`);
|
|
76
|
+
|
|
77
|
+
if (review.personality) {
|
|
78
|
+
lines.push(`\nFrank: "${review.personality}"`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Format as markdown
|
|
86
|
+
*/
|
|
87
|
+
formatAsMarkdown(result: AnalysisResult): string {
|
|
88
|
+
const lines: string[] = [];
|
|
89
|
+
|
|
90
|
+
lines.push("# Frankly · Red Ink Review\n");
|
|
91
|
+
|
|
92
|
+
// Task
|
|
93
|
+
lines.push(`## Task\n${result.task}\n`);
|
|
94
|
+
|
|
95
|
+
// Verdict
|
|
96
|
+
const review = result.review;
|
|
97
|
+
lines.push(`## Change Verdict\n**${review.verdict}**\n`);
|
|
98
|
+
|
|
99
|
+
// Metrics
|
|
100
|
+
lines.push("## Analysis\n");
|
|
101
|
+
lines.push(`- **Task Alignment**: ${review.taskAlignment}`);
|
|
102
|
+
lines.push(`- **Change Necessity**: ${review.changeNecessity}/100`);
|
|
103
|
+
lines.push(`- **Change Surface**: ${review.changeSurface}`);
|
|
104
|
+
lines.push(`- **Scope Drift**: ${Math.round(review.scopeDrift * 100)}%\n`);
|
|
105
|
+
lines.push(`- **Behavioral Changes**: ${review.behavioralChanges.join(", ") || "none detected"}`);
|
|
106
|
+
|
|
107
|
+
// Findings
|
|
108
|
+
if (review.findings.length > 0) {
|
|
109
|
+
lines.push("## Findings\n");
|
|
110
|
+
for (const finding of review.findings) {
|
|
111
|
+
lines.push(
|
|
112
|
+
`- **${finding.title}** (${finding.severity})\n ${finding.description}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
lines.push("");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Impact
|
|
119
|
+
lines.push("## Impact\n");
|
|
120
|
+
lines.push("### Blast Radius");
|
|
121
|
+
lines.push(`- Direct callers: ${review.blastRadius.directCallers}`);
|
|
122
|
+
lines.push(`- Transitive callers: ${review.blastRadius.transitiveCallers}`);
|
|
123
|
+
lines.push(`- Affected packages: ${review.blastRadius.affectedPackages}\n`);
|
|
124
|
+
|
|
125
|
+
lines.push("### Test Impact");
|
|
126
|
+
lines.push(`- Likely affected: ${review.testImpact.likelyAffected}`);
|
|
127
|
+
lines.push(`- Possibly affected: ${review.testImpact.possiblyAffected}`);
|
|
128
|
+
lines.push(`- Executed: ${review.testImpact.executed}`);
|
|
129
|
+
lines.push(`- Passed: ${review.testImpact.passed}\n`);
|
|
130
|
+
|
|
131
|
+
// Recommendation
|
|
132
|
+
lines.push("## Recommendation\n");
|
|
133
|
+
lines.push(review.recommendation);
|
|
134
|
+
|
|
135
|
+
if (review.correction) lines.push(`\n## Correction Pass\n\n${review.correction.instruction}`);
|
|
136
|
+
|
|
137
|
+
return lines.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Format as summary
|
|
142
|
+
*/
|
|
143
|
+
formatAsSummary(result: AnalysisResult): string {
|
|
144
|
+
const review = result.review;
|
|
145
|
+
return `
|
|
146
|
+
FRANKLY ANALYSIS
|
|
147
|
+
|
|
148
|
+
Verdict: ${review.verdict}
|
|
149
|
+
Files changed: ${result.files.length}
|
|
150
|
+
Symbols changed: ${result.symbols.length}
|
|
151
|
+
Findings: ${review.findings.length}
|
|
152
|
+
Task alignment: ${review.taskAlignment}
|
|
153
|
+
Change necessity: ${review.changeNecessity}/100
|
|
154
|
+
|
|
155
|
+
${review.recommendation}
|
|
156
|
+
`.trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
formatAsCi(result: AnalysisResult): string {
|
|
160
|
+
return JSON.stringify({
|
|
161
|
+
schemaVersion: result.schemaVersion,
|
|
162
|
+
verdict: result.review.verdict,
|
|
163
|
+
necessityScore: result.review.changeNecessity,
|
|
164
|
+
scopeDrift: result.review.scopeDrift,
|
|
165
|
+
findings: result.review.findings.map(({ id, severity, confidence, title, affectedSymbols }) => ({ id, severity, confidence, title, affectedSymbols })),
|
|
166
|
+
predictedTests: result.review.testImpact.predicted,
|
|
167
|
+
executedTests: result.review.testImpact.executedResults,
|
|
168
|
+
contracts: result.review.contractChanges,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createReportFormatter(): ReportFormatter {
|
|
174
|
+
return new ReportFormatter();
|
|
175
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangedFile,
|
|
3
|
+
ExecutedTest,
|
|
4
|
+
Finding,
|
|
5
|
+
RedInkReview,
|
|
6
|
+
Verdict,
|
|
7
|
+
} from "../core/models.js";
|
|
8
|
+
import type { ClassifiedChange } from "../analysis/change-classifier.js";
|
|
9
|
+
import type { RepositoryIntelligence } from "../analysis/repository-intelligence.js";
|
|
10
|
+
import type { FranklyAction, FranklyIntensity, FranklyPersonality } from "../core/config.js";
|
|
11
|
+
|
|
12
|
+
export interface ReviewInput {
|
|
13
|
+
task: string;
|
|
14
|
+
files: ChangedFile[];
|
|
15
|
+
classifications: ClassifiedChange[];
|
|
16
|
+
scopeDrift: number;
|
|
17
|
+
fileCount: number;
|
|
18
|
+
symbolCount: number;
|
|
19
|
+
intelligence: RepositoryIntelligence;
|
|
20
|
+
executedTests: ExecutedTest[];
|
|
21
|
+
personality: FranklyPersonality;
|
|
22
|
+
action: FranklyAction;
|
|
23
|
+
intensity: FranklyIntensity;
|
|
24
|
+
disabled: boolean;
|
|
25
|
+
maxCorrectionPasses: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class RedInkReviewBuilder {
|
|
29
|
+
build(input: ReviewInput): RedInkReview {
|
|
30
|
+
const confidence = input.intensity === "lite" ? ["high"] : input.intensity === "ultra" ? ["high", "medium", "low"] : ["high", "medium"];
|
|
31
|
+
const findings = input.disabled ? [] : [...this.classificationFindings(input.classifications), ...input.intelligence.findings].filter((item) => confidence.includes(item.confidence));
|
|
32
|
+
const hasUntestedImpact = !input.disabled && input.intelligence.predictedTests.length > 0 && input.executedTests.length === 0;
|
|
33
|
+
const baseVerdict = this.verdict(findings, input.disabled ? 0 : input.scopeDrift, hasUntestedImpact);
|
|
34
|
+
const verdict = input.action === "enforce" && baseVerdict !== "CLEAN" ? "BLOCK" : baseVerdict;
|
|
35
|
+
const justified = input.classifications.filter((item) => ["REQUIRED", "SUPPORTING", "TEST", "GENERATED"].includes(item.classification)).length;
|
|
36
|
+
const necessity = input.disabled ? 100 : input.classifications.length
|
|
37
|
+
? Math.max(0, Math.round((justified / input.classifications.length) * 100 - findings.filter((item) => item.confidence !== "low").length * 5))
|
|
38
|
+
: 100;
|
|
39
|
+
const passed = input.executedTests.filter((test) => test.status === "PASSED").length;
|
|
40
|
+
const correction = input.action === "correct" && input.maxCorrectionPasses > 0 && verdict !== "CLEAN" ? input.intelligence.correction : undefined;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
task: input.task,
|
|
44
|
+
verdict,
|
|
45
|
+
taskAlignment: necessity >= 80 ? "high" : necessity >= 50 ? "medium" : "low",
|
|
46
|
+
changeNecessity: necessity,
|
|
47
|
+
changeSurface: surface(input.files, input.symbolCount, input.intelligence),
|
|
48
|
+
scopeDrift: input.disabled ? 0 : input.scopeDrift,
|
|
49
|
+
findings,
|
|
50
|
+
blastRadius: {
|
|
51
|
+
directCallers: input.intelligence.directCallers.length,
|
|
52
|
+
transitiveCallers: input.intelligence.transitiveCallers.length,
|
|
53
|
+
affectedPackages: input.intelligence.packages.length,
|
|
54
|
+
callers: [...input.intelligence.directCallers, ...input.intelligence.transitiveCallers],
|
|
55
|
+
packages: input.intelligence.packages,
|
|
56
|
+
},
|
|
57
|
+
testImpact: {
|
|
58
|
+
likelyAffected: input.intelligence.predictedTests.filter((test) => test.status === "LIKELY_AFFECTED").length,
|
|
59
|
+
possiblyAffected: input.intelligence.predictedTests.filter((test) => test.status === "POSSIBLY_AFFECTED").length,
|
|
60
|
+
executed: input.executedTests.length,
|
|
61
|
+
passed,
|
|
62
|
+
predicted: input.intelligence.predictedTests,
|
|
63
|
+
executedResults: input.executedTests,
|
|
64
|
+
},
|
|
65
|
+
contractChanges: input.intelligence.contracts,
|
|
66
|
+
behavioralChanges: input.intelligence.behavioralChanges,
|
|
67
|
+
reuseCandidates: input.intelligence.reuseCandidates,
|
|
68
|
+
correction,
|
|
69
|
+
recommendation: recommendation(verdict, findings, Boolean(correction)),
|
|
70
|
+
personality: personalityLine(input.personality, verdict),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private classificationFindings(classifications: ClassifiedChange[]): Finding[] {
|
|
75
|
+
return classifications.flatMap((item, index): Finding[] => {
|
|
76
|
+
if (!["SUSPICIOUS", "UNEXPLAINED", "REFACTOR", "INCIDENTAL"].includes(item.classification)) return [];
|
|
77
|
+
const target = "path" in item.symbol ? item.symbol.path : item.symbol.id;
|
|
78
|
+
return [{
|
|
79
|
+
id: `classification-${index + 1}`,
|
|
80
|
+
severity: item.classification === "UNEXPLAINED" ? "error" : "warning",
|
|
81
|
+
confidence: item.confidence,
|
|
82
|
+
title: item.classification === "REFACTOR" ? "Refactor contamination" : `${item.classification[0]}${item.classification.slice(1).toLowerCase()} change`,
|
|
83
|
+
description: item.justification,
|
|
84
|
+
affectedSymbols: [target],
|
|
85
|
+
evidence: item.evidence,
|
|
86
|
+
recommendation: "Remove the change or provide repository evidence that the task requires it.",
|
|
87
|
+
autoCorrectible: true,
|
|
88
|
+
}];
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private verdict(findings: Finding[], drift: number, untested: boolean): Verdict {
|
|
93
|
+
if (findings.some((item) => item.title === "Refactor contamination")) return "SPLIT";
|
|
94
|
+
if (findings.some((item) => item.severity === "error") || drift > 0.3 || findings.length > 3) return "SIMPLIFY";
|
|
95
|
+
if (untested || findings.some((item) => item.title === "Missing regression coverage")) return "VERIFY";
|
|
96
|
+
if (findings.length) return "REVIEW";
|
|
97
|
+
return "CLEAN";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function surface(files: ChangedFile[], symbols: number, intelligence: RepositoryIntelligence): "LOW" | "MEDIUM" | "HIGH" {
|
|
102
|
+
const score = files.filter((file) => !/(?:-lock\.json|pnpm-lock\.yaml|yarn\.lock)$/.test(file.path)).length
|
|
103
|
+
+ Math.ceil(symbols / 3)
|
|
104
|
+
+ intelligence.contracts.length * 2
|
|
105
|
+
+ Math.max(0, intelligence.packages.length - 1) * 3;
|
|
106
|
+
return score <= 4 ? "LOW" : score <= 12 ? "MEDIUM" : "HIGH";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function recommendation(verdict: Verdict, findings: Finding[], correction: boolean): string {
|
|
110
|
+
if (verdict === "CLEAN") return "Patch appears appropriately scoped; no red marks found.";
|
|
111
|
+
if (correction) return "Give the coding agent the single correction pass below, then run verify_change.";
|
|
112
|
+
if (verdict === "VERIFY") return "Run the predicted related tests and record their actual results.";
|
|
113
|
+
if (verdict === "SPLIT") return "Move unrelated refactoring into a separate patch.";
|
|
114
|
+
return `Review ${findings.length} evidence-backed finding(s) before approval.`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function personalityLine(personality: FranklyPersonality, verdict: Verdict): string {
|
|
118
|
+
if (personality === "conservative") return verdict === "CLEAN" ? "The patch appears proportionate." : "These changes may be broader than required.";
|
|
119
|
+
if (personality === "witty") return verdict === "CLEAN" ? "Nothing to circle in red." : "The diff brought guests the task did not invite.";
|
|
120
|
+
return verdict === "CLEAN" ? "Every changed line has a plausible reason." : "This task does not yet justify this patch.";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createRedInkReviewBuilder(): RedInkReviewBuilder {
|
|
124
|
+
return new RedInkReviewBuilder();
|
|
125
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic fixture for testing
|
|
3
|
+
* Simple TypeScript project
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import { FranklyAnalysisEngine, createAnalysisEngine } from "../core/engine.js";
|
|
8
|
+
import { Repository } from "../git/repository.js";
|
|
9
|
+
import { ProjectDetector } from "../project/detector.js";
|
|
10
|
+
import { SymbolExtractor } from "../graph/symbols.js";
|
|
11
|
+
|
|
12
|
+
describe("Frankly Phase 1 Foundation", () => {
|
|
13
|
+
it("should detect a TypeScript project", async () => {
|
|
14
|
+
const detector = new ProjectDetector(process.cwd());
|
|
15
|
+
const project = await detector.detectProject();
|
|
16
|
+
|
|
17
|
+
expect(project).toBeDefined();
|
|
18
|
+
expect(project.type).toBe("typescript");
|
|
19
|
+
expect(project.hasTsConfig).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should extract symbols from TypeScript files", () => {
|
|
23
|
+
const extractor = new SymbolExtractor(process.cwd());
|
|
24
|
+
const symbols = extractor.extractSymbols("./src/core/models.ts");
|
|
25
|
+
|
|
26
|
+
expect(symbols).toBeDefined();
|
|
27
|
+
expect(symbols.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should classify changes", async () => {
|
|
31
|
+
const repository = new Repository(process.cwd());
|
|
32
|
+
const status = repository.getStatus();
|
|
33
|
+
|
|
34
|
+
// Should return status without errors
|
|
35
|
+
expect(status).toBeDefined();
|
|
36
|
+
expect(Array.isArray(status.modified)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should plan a task using normalized intent and scope estimation", async () => {
|
|
40
|
+
const engine = await createAnalysisEngine(process.cwd());
|
|
41
|
+
const plan = await engine.plan("Please fix retry logic in the API client and add tests");
|
|
42
|
+
|
|
43
|
+
expect(plan).toBeDefined();
|
|
44
|
+
expect(plan.normalizedIntent).toContain("retry");
|
|
45
|
+
expect(plan.keywords.length).toBeGreaterThan(0);
|
|
46
|
+
expect(plan.estimatedFiles).toBeGreaterThan(0);
|
|
47
|
+
expect(plan.estimatedFiles).toBeLessThanOrEqual(12);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should surface existing repo matches as reuse candidates", async () => {
|
|
51
|
+
const engine = await createAnalysisEngine(process.cwd());
|
|
52
|
+
const plan = await engine.plan("review the red ink logic and findings");
|
|
53
|
+
|
|
54
|
+
expect(plan.reuseCandidates.length).toBeGreaterThan(0);
|
|
55
|
+
expect(plan.reuseCandidates.some((candidate) => candidate.toLowerCase().includes("review") || candidate.toLowerCase().includes("analysis"))).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|