@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,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
+ });