@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,207 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { GitError, RepositoryError } from "../core/errors.js";
|
|
5
|
+
import type { ChangeType } from "../core/models.js";
|
|
6
|
+
|
|
7
|
+
export interface DiffHunk {
|
|
8
|
+
oldStart: number;
|
|
9
|
+
oldLines: number;
|
|
10
|
+
newStart: number;
|
|
11
|
+
newLines: number;
|
|
12
|
+
content: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FileDiff {
|
|
16
|
+
path: string;
|
|
17
|
+
status: ChangeType;
|
|
18
|
+
hunks: DiffHunk[];
|
|
19
|
+
oldPath?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Repository {
|
|
23
|
+
constructor(private readonly rootPath: string) {
|
|
24
|
+
try {
|
|
25
|
+
this.git(["rev-parse", "--git-dir"]);
|
|
26
|
+
} catch {
|
|
27
|
+
throw new RepositoryError("Not a git repository", { path: rootPath });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private git(args: string[], allowFailure = false): string {
|
|
32
|
+
try {
|
|
33
|
+
return execFileSync("git", args, {
|
|
34
|
+
cwd: this.rootPath,
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
stdio: ["ignore", "pipe", allowFailure ? "ignore" : "pipe"],
|
|
37
|
+
}).trimEnd();
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (allowFailure) return "";
|
|
40
|
+
throw new GitError(`Git command failed: git ${args.join(" ")}`, { args, error: String(error) });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getRootPath(): string {
|
|
45
|
+
return this.rootPath;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getWorkingTreeDiff(): Promise<FileDiff[]> {
|
|
49
|
+
if (!this.hasHead()) return this.getInitialTreeDiff();
|
|
50
|
+
const tracked = this.git(["diff", "HEAD", "--no-color", "--no-ext-diff", "--find-renames"]);
|
|
51
|
+
const files = this.parseDiff(tracked);
|
|
52
|
+
const known = new Set(files.map((file) => file.path));
|
|
53
|
+
|
|
54
|
+
for (const filePath of this.getStatus().untracked) {
|
|
55
|
+
if (known.has(filePath)) continue;
|
|
56
|
+
const fullPath = path.join(this.rootPath, filePath);
|
|
57
|
+
if (!fs.statSync(fullPath).isFile()) continue;
|
|
58
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
59
|
+
const lines = lineCount(content);
|
|
60
|
+
files.push({
|
|
61
|
+
path: filePath,
|
|
62
|
+
status: "added",
|
|
63
|
+
hunks: [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: lines, content: addedContent(content) }],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getDiffBetween(base: string, head: string): Promise<FileDiff[]> {
|
|
71
|
+
return this.parseDiff(this.git(["diff", "--no-color", "--no-ext-diff", "--find-renames", base, head]));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getStagedDiff(): Promise<FileDiff[]> {
|
|
75
|
+
return this.parseDiff(this.git(["diff", "--staged", "--no-color", "--no-ext-diff", "--find-renames"]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
fileExists(filePath: string): boolean {
|
|
79
|
+
return this.git(["ls-files", "--error-unmatch", "--", filePath], true) !== "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async getFileContent(filePath: string, revision = "HEAD"): Promise<string> {
|
|
83
|
+
const value = this.git(["show", `${revision}:${filePath}`], true);
|
|
84
|
+
if (!value && !this.hasHead()) throw new GitError(`Revision ${revision} does not exist`);
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getCurrentBranch(): string {
|
|
89
|
+
return this.git(["rev-parse", "--abbrev-ref", "HEAD"], true) || "HEAD";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getStatus(): { modified: string[]; staged: string[]; untracked: string[] } {
|
|
93
|
+
const modified: string[] = [];
|
|
94
|
+
const staged: string[] = [];
|
|
95
|
+
const untracked: string[] = [];
|
|
96
|
+
for (const line of this.git(["status", "--porcelain=v1", "-z"]).split("\0")) {
|
|
97
|
+
if (!line) continue;
|
|
98
|
+
const codes = line.slice(0, 2);
|
|
99
|
+
const filePath = line.slice(3);
|
|
100
|
+
if (codes === "??") untracked.push(filePath);
|
|
101
|
+
else {
|
|
102
|
+
if (codes[0] !== " ") staged.push(filePath);
|
|
103
|
+
if (codes[1] !== " ") modified.push(filePath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { modified, staged, untracked };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async getBlame(filePath: string, line: number): Promise<{ commit: string; author: string } | null> {
|
|
110
|
+
const value = this.git(["blame", "-L", `${line},${line}`, "--porcelain", "--", filePath], true);
|
|
111
|
+
if (!value) return null;
|
|
112
|
+
const rows = value.split("\n");
|
|
113
|
+
return {
|
|
114
|
+
commit: rows[0].split(" ")[0],
|
|
115
|
+
author: rows.find((row) => row.startsWith("author "))?.slice(7) || "unknown",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private hasHead(): boolean {
|
|
120
|
+
return this.git(["rev-parse", "--verify", "HEAD"], true) !== "";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private getInitialTreeDiff(): FileDiff[] {
|
|
124
|
+
return this.git(["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).split("\0").flatMap((filePath): FileDiff[] => {
|
|
125
|
+
if (!filePath) return [];
|
|
126
|
+
const fullPath = path.join(this.rootPath, filePath);
|
|
127
|
+
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) return [];
|
|
128
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
129
|
+
return [{
|
|
130
|
+
path: filePath,
|
|
131
|
+
status: "added",
|
|
132
|
+
hunks: [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: lineCount(content), content: addedContent(content) }],
|
|
133
|
+
}];
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private parseDiff(diff: string): FileDiff[] {
|
|
138
|
+
const files: FileDiff[] = [];
|
|
139
|
+
let current: FileDiff | undefined;
|
|
140
|
+
let hunk: DiffHunk | undefined;
|
|
141
|
+
|
|
142
|
+
const flushHunk = () => {
|
|
143
|
+
if (current && hunk) current.hunks.push(hunk);
|
|
144
|
+
hunk = undefined;
|
|
145
|
+
};
|
|
146
|
+
const flushFile = () => {
|
|
147
|
+
flushHunk();
|
|
148
|
+
if (current) files.push(current);
|
|
149
|
+
current = undefined;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
for (const line of diff.split("\n")) {
|
|
153
|
+
if (line.startsWith("diff --git ")) {
|
|
154
|
+
flushFile();
|
|
155
|
+
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
156
|
+
current = { path: match?.[2] || "", oldPath: match?.[1], status: "modified", hunks: [] };
|
|
157
|
+
} else if (line.startsWith("new file mode")) {
|
|
158
|
+
if (current) current.status = "added";
|
|
159
|
+
} else if (line.startsWith("deleted file mode")) {
|
|
160
|
+
if (current) current.status = "deleted";
|
|
161
|
+
} else if (line.startsWith("rename from ")) {
|
|
162
|
+
if (current) {
|
|
163
|
+
current.status = "renamed";
|
|
164
|
+
current.oldPath = line.slice(12);
|
|
165
|
+
}
|
|
166
|
+
} else if (line.startsWith("rename to ")) {
|
|
167
|
+
if (current) current.path = line.slice(10);
|
|
168
|
+
} else if (line.startsWith("@@")) {
|
|
169
|
+
flushHunk();
|
|
170
|
+
const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
171
|
+
if (match) hunk = {
|
|
172
|
+
oldStart: Number(match[1]),
|
|
173
|
+
oldLines: Number(match[2] ?? 1),
|
|
174
|
+
newStart: Number(match[3]),
|
|
175
|
+
newLines: Number(match[4] ?? 1),
|
|
176
|
+
content: "",
|
|
177
|
+
};
|
|
178
|
+
} else if (hunk && (/^[+\- ]/.test(line) || line.startsWith("\\ No newline"))) {
|
|
179
|
+
hunk.content += `${line}\n`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
flushFile();
|
|
183
|
+
return files;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function countDiffLines(file: FileDiff): { additions: number; deletions: number } {
|
|
188
|
+
const lines = file.hunks.flatMap((hunk) => hunk.content.split("\n"));
|
|
189
|
+
return {
|
|
190
|
+
additions: lines.filter((line) => line.startsWith("+") && !line.startsWith("+++")).length,
|
|
191
|
+
deletions: lines.filter((line) => line.startsWith("-") && !line.startsWith("---")).length,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function lineCount(content: string): number {
|
|
196
|
+
return content === "" ? 0 : content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function addedContent(content: string): string {
|
|
200
|
+
if (!content) return "";
|
|
201
|
+
const body = content.endsWith("\n") ? content.slice(0, -1) : content;
|
|
202
|
+
return body.split("\n").map((line) => `+${line}`).join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function createRepository(rootPath: string): Promise<Repository> {
|
|
206
|
+
return new Repository(rootPath);
|
|
207
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol extraction from TypeScript/JavaScript code
|
|
3
|
+
* Uses TypeScript Compiler API for semantic analysis
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as ts from "typescript";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import type { ChangedSymbol, SymbolType, Visibility } from "../core/models.js";
|
|
10
|
+
import { SemanticError } from "../core/errors.js";
|
|
11
|
+
|
|
12
|
+
export interface ExtractedSymbol {
|
|
13
|
+
name: string;
|
|
14
|
+
type: SymbolType;
|
|
15
|
+
visibility: Visibility;
|
|
16
|
+
isExported: boolean;
|
|
17
|
+
line: number;
|
|
18
|
+
column: number;
|
|
19
|
+
lineRange: [number, number];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SymbolExtractor {
|
|
23
|
+
private compilerOptions: ts.CompilerOptions;
|
|
24
|
+
private rootPath: string;
|
|
25
|
+
|
|
26
|
+
constructor(rootPath: string, tsConfigPath?: string) {
|
|
27
|
+
this.rootPath = rootPath;
|
|
28
|
+
this.compilerOptions = this.loadCompilerOptions(tsConfigPath);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load TypeScript compiler options
|
|
33
|
+
*/
|
|
34
|
+
private loadCompilerOptions(tsConfigPath?: string): ts.CompilerOptions {
|
|
35
|
+
let configPath = tsConfigPath || path.join(this.rootPath, "tsconfig.json");
|
|
36
|
+
|
|
37
|
+
if (!fs.existsSync(configPath)) {
|
|
38
|
+
// Use default options
|
|
39
|
+
return {
|
|
40
|
+
target: ts.ScriptTarget.ES2020,
|
|
41
|
+
module: ts.ModuleKind.ESNext,
|
|
42
|
+
lib: ["es2020"],
|
|
43
|
+
strict: true,
|
|
44
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const configFile = ts.readConfigFile(configPath, (p) => fs.readFileSync(p, "utf-8"));
|
|
50
|
+
const config = ts.parseJsonConfigFileContent(
|
|
51
|
+
configFile.config,
|
|
52
|
+
ts.sys,
|
|
53
|
+
path.dirname(configPath),
|
|
54
|
+
);
|
|
55
|
+
return config.options;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new SemanticError("Failed to load TypeScript config", { error, path: configPath });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract symbols from a file
|
|
63
|
+
*/
|
|
64
|
+
extractSymbols(filePath: string): ExtractedSymbol[] {
|
|
65
|
+
try {
|
|
66
|
+
const sourceText = fs.readFileSync(filePath, "utf-8");
|
|
67
|
+
const sourceFile = ts.createSourceFile(
|
|
68
|
+
filePath,
|
|
69
|
+
sourceText,
|
|
70
|
+
this.compilerOptions.target || ts.ScriptTarget.ES2020,
|
|
71
|
+
true,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const symbols: ExtractedSymbol[] = [];
|
|
75
|
+
this.visitNode(sourceFile, symbols, sourceText);
|
|
76
|
+
return symbols;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new SemanticError("Failed to extract symbols", { error, filePath });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Recursively visit AST nodes
|
|
84
|
+
*/
|
|
85
|
+
private visitNode(node: ts.Node, symbols: ExtractedSymbol[], sourceText: string): void {
|
|
86
|
+
// Function declarations
|
|
87
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
88
|
+
const name = node.name?.text || "anonymous";
|
|
89
|
+
const isExported = this.isExported(node);
|
|
90
|
+
const lineRange = this.getLineRange(node, sourceText);
|
|
91
|
+
const line = ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()).line;
|
|
92
|
+
|
|
93
|
+
symbols.push({
|
|
94
|
+
name,
|
|
95
|
+
type: "function",
|
|
96
|
+
visibility: this.getVisibility(node),
|
|
97
|
+
isExported,
|
|
98
|
+
line,
|
|
99
|
+
column: ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()).character,
|
|
100
|
+
lineRange,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Class declarations
|
|
105
|
+
if (ts.isClassDeclaration(node)) {
|
|
106
|
+
const name = node.name?.text || "anonymous";
|
|
107
|
+
const isExported = this.isExported(node);
|
|
108
|
+
const lineRange = this.getLineRange(node, sourceText);
|
|
109
|
+
const line = ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()).line;
|
|
110
|
+
|
|
111
|
+
symbols.push({
|
|
112
|
+
name,
|
|
113
|
+
type: "class",
|
|
114
|
+
visibility: this.getVisibility(node),
|
|
115
|
+
isExported,
|
|
116
|
+
line,
|
|
117
|
+
column: ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()).character,
|
|
118
|
+
lineRange,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Extract class methods
|
|
122
|
+
for (const member of node.members) {
|
|
123
|
+
if (ts.isMethodDeclaration(member)) {
|
|
124
|
+
const methodName = member.name?.getText() || "anonymous";
|
|
125
|
+
const memberLineRange = this.getLineRange(member, sourceText);
|
|
126
|
+
const memberLine = ts.getLineAndCharacterOfPosition(
|
|
127
|
+
node.getSourceFile(),
|
|
128
|
+
member.getStart(),
|
|
129
|
+
).line;
|
|
130
|
+
|
|
131
|
+
symbols.push({
|
|
132
|
+
name: `${name}.${methodName}`,
|
|
133
|
+
type: "method",
|
|
134
|
+
visibility: this.getVisibility(member),
|
|
135
|
+
isExported: false,
|
|
136
|
+
line: memberLine,
|
|
137
|
+
column: ts.getLineAndCharacterOfPosition(node.getSourceFile(), member.getStart())
|
|
138
|
+
.character,
|
|
139
|
+
lineRange: memberLineRange,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Interface declarations
|
|
146
|
+
if (ts.isInterfaceDeclaration(node)) {
|
|
147
|
+
const name = node.name?.text || "anonymous";
|
|
148
|
+
const isExported = this.isExported(node);
|
|
149
|
+
const lineRange = this.getLineRange(node, sourceText);
|
|
150
|
+
const line = ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()).line;
|
|
151
|
+
|
|
152
|
+
symbols.push({
|
|
153
|
+
name,
|
|
154
|
+
type: "interface",
|
|
155
|
+
visibility: this.getVisibility(node),
|
|
156
|
+
isExported,
|
|
157
|
+
line,
|
|
158
|
+
column: ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()).character,
|
|
159
|
+
lineRange,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Type aliases
|
|
164
|
+
if (ts.isTypeAliasDeclaration(node)) {
|
|
165
|
+
const name = node.name?.text || "anonymous";
|
|
166
|
+
const isExported = this.isExported(node);
|
|
167
|
+
const lineRange = this.getLineRange(node, sourceText);
|
|
168
|
+
const line = ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()).line;
|
|
169
|
+
|
|
170
|
+
symbols.push({
|
|
171
|
+
name,
|
|
172
|
+
type: "type",
|
|
173
|
+
visibility: this.getVisibility(node),
|
|
174
|
+
isExported,
|
|
175
|
+
line,
|
|
176
|
+
column: ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()).character,
|
|
177
|
+
lineRange,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Variable declarations
|
|
182
|
+
if (ts.isVariableStatement(node)) {
|
|
183
|
+
for (const decl of node.declarationList.declarations) {
|
|
184
|
+
const name = decl.name.getText();
|
|
185
|
+
const isExported = this.isExported(node);
|
|
186
|
+
const lineRange = this.getLineRange(decl, sourceText);
|
|
187
|
+
const line = ts.getLineAndCharacterOfPosition(node.getSourceFile(), decl.getStart()).line;
|
|
188
|
+
|
|
189
|
+
symbols.push({
|
|
190
|
+
name,
|
|
191
|
+
type: decl.initializer ? "variable" : "constant",
|
|
192
|
+
visibility: this.getVisibility(node),
|
|
193
|
+
isExported,
|
|
194
|
+
line,
|
|
195
|
+
column: ts.getLineAndCharacterOfPosition(node.getSourceFile(), decl.getStart()).character,
|
|
196
|
+
lineRange,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Export declarations
|
|
202
|
+
if (ts.isExportDeclaration(node)) {
|
|
203
|
+
symbols.push({
|
|
204
|
+
name: "export",
|
|
205
|
+
type: "export",
|
|
206
|
+
visibility: "public",
|
|
207
|
+
isExported: true,
|
|
208
|
+
line: ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()).line,
|
|
209
|
+
column: ts.getLineAndCharacterOfPosition(node.getSourceFile(), node.getStart()).character,
|
|
210
|
+
lineRange: this.getLineRange(node, sourceText),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
ts.forEachChild(node, (child) => this.visitNode(child, symbols, sourceText));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Check if node is exported
|
|
219
|
+
*/
|
|
220
|
+
private isExported(node: ts.Node): boolean {
|
|
221
|
+
const flags = ts.getCombinedModifierFlags(node as ts.Declaration);
|
|
222
|
+
return (flags & ts.ModifierFlags.Export) !== 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get visibility level
|
|
227
|
+
*/
|
|
228
|
+
private getVisibility(node: ts.Node): Visibility {
|
|
229
|
+
const flags = ts.getCombinedModifierFlags(node as ts.Declaration);
|
|
230
|
+
|
|
231
|
+
if (flags & ts.ModifierFlags.Private) return "private";
|
|
232
|
+
if (flags & ts.ModifierFlags.Protected) return "protected";
|
|
233
|
+
if (flags & ts.ModifierFlags.Public) return "public";
|
|
234
|
+
|
|
235
|
+
return "internal";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Get line range of a node
|
|
240
|
+
*/
|
|
241
|
+
private getLineRange(node: ts.Node, sourceText: string): [number, number] {
|
|
242
|
+
const sourceFile = node.getSourceFile();
|
|
243
|
+
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line;
|
|
244
|
+
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line;
|
|
245
|
+
return [start + 1, end + 1]; // Convert to 1-based
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function createSymbolExtractor(
|
|
250
|
+
rootPath: string,
|
|
251
|
+
tsConfigPath?: string,
|
|
252
|
+
): SymbolExtractor {
|
|
253
|
+
return new SymbolExtractor(rootPath, tsConfigPath);
|
|
254
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { loadRepositoryConfig } from "../../core/config.js";
|
|
3
|
+
import { createAnalysisEngine } from "../../core/engine.js";
|
|
4
|
+
|
|
5
|
+
interface StopHookInput {
|
|
6
|
+
cwd?: string;
|
|
7
|
+
last_assistant_message?: string;
|
|
8
|
+
stop_hook_active?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function main(): Promise<void> {
|
|
12
|
+
const input = JSON.parse(readFileSync(0, "utf8")) as StopHookInput;
|
|
13
|
+
if (input.stop_hook_active) return;
|
|
14
|
+
|
|
15
|
+
const root = input.cwd || process.cwd();
|
|
16
|
+
const config = loadRepositoryConfig(root);
|
|
17
|
+
if (config.trigger !== "checkpoint" || config.intensity === "off") return;
|
|
18
|
+
|
|
19
|
+
const task = input.last_assistant_message || "Review current changes";
|
|
20
|
+
const result = await (await createAnalysisEngine(root)).analyze(task);
|
|
21
|
+
if (result.review.verdict === "CLEAN") return;
|
|
22
|
+
|
|
23
|
+
const findings = result.review.findings
|
|
24
|
+
.slice(0, 5)
|
|
25
|
+
.map((finding) => `- ${finding.title}: ${finding.description}`)
|
|
26
|
+
.join("\n");
|
|
27
|
+
const message = `Frankly Red Ink Review\n${result.review.recommendation}\n${findings}`;
|
|
28
|
+
process.stdout.write(JSON.stringify({
|
|
29
|
+
hookSpecificOutput: {
|
|
30
|
+
hookEventName: "Stop",
|
|
31
|
+
additionalContext: message,
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
main().catch((error) => {
|
|
37
|
+
process.stdout.write(JSON.stringify({
|
|
38
|
+
systemMessage: `Frankly checkpoint skipped: ${error instanceof Error ? error.message : String(error)}`,
|
|
39
|
+
}));
|
|
40
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { createAnalysisEngine } from "../../core/engine.js";
|
|
8
|
+
import { createReportFormatter } from "../../report/json.js";
|
|
9
|
+
|
|
10
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frankly-demo-"));
|
|
11
|
+
const fixtureRoot = fileURLToPath(new URL("../../../fixtures/retry-429", import.meta.url));
|
|
12
|
+
try {
|
|
13
|
+
copyFixture("baseline");
|
|
14
|
+
git(["init", "-q"]);
|
|
15
|
+
git(["config", "user.email", "demo@frankly.local"]);
|
|
16
|
+
git(["config", "user.name", "Frankly Demo"]);
|
|
17
|
+
git(["add", "."]);
|
|
18
|
+
git(["commit", "-qm", "baseline"]);
|
|
19
|
+
|
|
20
|
+
copyFixture("candidate");
|
|
21
|
+
|
|
22
|
+
const result = await (await createAnalysisEngine(root)).analyze("Retry HTTP 429 responses");
|
|
23
|
+
console.log(createReportFormatter().formatAsTerminal(result));
|
|
24
|
+
} finally {
|
|
25
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function copyFixture(name: "baseline" | "candidate"): void {
|
|
29
|
+
fs.cpSync(path.join(fixtureRoot, name), root, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function git(args: string[]): void {
|
|
33
|
+
execFileSync("git", args, { cwd: root });
|
|
34
|
+
}
|