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