@haystackeditor/cli 0.7.2 → 0.8.1

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 (36) hide show
  1. package/README.md +59 -12
  2. package/dist/assets/hooks/agent-context/detect.ts +136 -0
  3. package/dist/assets/hooks/agent-context/format.ts +99 -0
  4. package/dist/assets/hooks/agent-context/index.ts +39 -0
  5. package/dist/assets/hooks/agent-context/parsers/claude.ts +253 -0
  6. package/dist/assets/hooks/agent-context/parsers/gemini.ts +155 -0
  7. package/dist/assets/hooks/agent-context/parsers/opencode.ts +174 -0
  8. package/dist/assets/hooks/agent-context/tsconfig.json +13 -0
  9. package/dist/assets/hooks/agent-context/types.ts +58 -0
  10. package/dist/assets/hooks/llm-rules-template.md +35 -0
  11. package/dist/assets/hooks/package.json +11 -0
  12. package/dist/assets/hooks/scripts/commit-msg.sh +4 -0
  13. package/dist/assets/hooks/scripts/post-commit.sh +4 -0
  14. package/dist/assets/hooks/scripts/pre-commit.sh +92 -0
  15. package/dist/assets/hooks/scripts/pre-push.sh +5 -0
  16. package/dist/assets/hooks/scripts/prepare-commit-msg.sh +3 -0
  17. package/dist/assets/hooks/truncation-checker/ast-analyzer.ts +528 -0
  18. package/dist/assets/hooks/truncation-checker/index.ts +595 -0
  19. package/dist/assets/hooks/truncation-checker/tsconfig.json +13 -0
  20. package/dist/commands/config.d.ts +14 -0
  21. package/dist/commands/config.js +89 -0
  22. package/dist/commands/hooks.d.ts +17 -0
  23. package/dist/commands/hooks.js +269 -0
  24. package/dist/commands/init.d.ts +1 -1
  25. package/dist/commands/init.js +20 -239
  26. package/dist/commands/secrets.d.ts +15 -0
  27. package/dist/commands/secrets.js +83 -0
  28. package/dist/commands/skills.d.ts +8 -0
  29. package/dist/commands/skills.js +215 -0
  30. package/dist/index.js +107 -7
  31. package/dist/types.d.ts +32 -8
  32. package/dist/utils/hooks.d.ts +26 -0
  33. package/dist/utils/hooks.js +226 -0
  34. package/dist/utils/skill.d.ts +1 -1
  35. package/dist/utils/skill.js +481 -13
  36. package/package.json +2 -2
@@ -0,0 +1,528 @@
1
+ /**
2
+ * Tree-sitter based truncation detection.
3
+ *
4
+ * Detects semantic truncation patterns that regex can't catch:
5
+ * - Truncation in function returns
6
+ * - LLM context variable truncation
7
+ * - Loop-based truncation
8
+ * - Chained truncations
9
+ */
10
+
11
+ import Parser from "tree-sitter";
12
+ import TypeScript from "tree-sitter-typescript";
13
+ import { readFileSync, existsSync } from "fs";
14
+ import { extname } from "path";
15
+
16
+ // Initialize parsers
17
+ const tsxParser = new Parser();
18
+ tsxParser.setLanguage(TypeScript.tsx as unknown as Parser.Language);
19
+
20
+ const tsParser = new Parser();
21
+ tsParser.setLanguage(TypeScript.typescript as unknown as Parser.Language);
22
+
23
+ // ============================================================================
24
+ // TYPES
25
+ // ============================================================================
26
+
27
+ export interface ASTViolation {
28
+ file: string;
29
+ line: number;
30
+ column: number;
31
+ type: string;
32
+ description: string;
33
+ code: string;
34
+ severity: "error" | "warning";
35
+ }
36
+
37
+ // Variables that likely contain LLM context
38
+ const LLM_CONTEXT_VARS = new Set([
39
+ "context",
40
+ "prompt",
41
+ "messages",
42
+ "output",
43
+ "result",
44
+ "response",
45
+ "content",
46
+ "text",
47
+ "data",
48
+ "input",
49
+ "toolOutput",
50
+ "toolResult",
51
+ "searchResults",
52
+ "fileContent",
53
+ "fileContents",
54
+ "codeContent",
55
+ "sourceCode",
56
+ ]);
57
+
58
+ // Truncation method names
59
+ const TRUNCATION_METHODS = new Set([
60
+ "slice",
61
+ "substring",
62
+ "substr",
63
+ "splice",
64
+ "truncate",
65
+ "take",
66
+ "head",
67
+ "limit",
68
+ ]);
69
+
70
+ // ============================================================================
71
+ // HELPERS
72
+ // ============================================================================
73
+
74
+ function getParser(filePath: string): Parser | null {
75
+ const ext = extname(filePath);
76
+ if (ext === ".tsx" || ext === ".jsx") return tsxParser;
77
+ if (ext === ".ts" || ext === ".js") return tsParser;
78
+ return null;
79
+ }
80
+
81
+ function nodeText(node: Parser.SyntaxNode, source: string): string {
82
+ return source.slice(node.startIndex, node.endIndex);
83
+ }
84
+
85
+ function findNodes(
86
+ node: Parser.SyntaxNode,
87
+ types: string[],
88
+ results: Parser.SyntaxNode[] = []
89
+ ): Parser.SyntaxNode[] {
90
+ if (types.includes(node.type)) {
91
+ results.push(node);
92
+ }
93
+ for (const child of node.children) {
94
+ findNodes(child, types, results);
95
+ }
96
+ return results;
97
+ }
98
+
99
+ function findParent(
100
+ node: Parser.SyntaxNode,
101
+ types: string[]
102
+ ): Parser.SyntaxNode | null {
103
+ let current = node.parent;
104
+ while (current) {
105
+ if (types.includes(current.type)) return current;
106
+ current = current.parent;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function getLineContent(source: string, line: number): string {
112
+ const lines = source.split("\n");
113
+ return lines[line - 1] || "";
114
+ }
115
+
116
+ // ============================================================================
117
+ // DETECTION: Return statement truncation
118
+ // ============================================================================
119
+
120
+ function detectReturnTruncation(
121
+ tree: Parser.Tree,
122
+ source: string,
123
+ filePath: string
124
+ ): ASTViolation[] {
125
+ const violations: ASTViolation[] = [];
126
+
127
+ // Find all return statements
128
+ const returnStatements = findNodes(tree.rootNode, ["return_statement"]);
129
+
130
+ for (const ret of returnStatements) {
131
+ const retText = nodeText(ret, source);
132
+
133
+ // Check if return contains truncation
134
+ const callExpressions = findNodes(ret, ["call_expression"]);
135
+ for (const call of callExpressions) {
136
+ const funcNode = call.childForFieldName("function");
137
+ if (!funcNode) continue;
138
+
139
+ // Check for method calls like .slice(), .substring()
140
+ if (funcNode.type === "member_expression") {
141
+ const property = funcNode.childForFieldName("property");
142
+ if (property && TRUNCATION_METHODS.has(nodeText(property, source))) {
143
+ // Check if it has numeric arguments (indicates truncation)
144
+ const args = call.childForFieldName("arguments");
145
+ if (args) {
146
+ const hasNumericArg = findNodes(args, ["number"]).length > 0;
147
+ const hasLimitVar = /max|limit|MAX|LIMIT/i.test(nodeText(args, source));
148
+
149
+ if (hasNumericArg || hasLimitVar) {
150
+ violations.push({
151
+ file: filePath,
152
+ line: ret.startPosition.row + 1,
153
+ column: ret.startPosition.column + 1,
154
+ type: "return-truncation",
155
+ description: `Function returns truncated data via .${nodeText(property, source)}()`,
156
+ code: retText.slice(0, 100),
157
+ severity: "error",
158
+ });
159
+ }
160
+ }
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ return violations;
167
+ }
168
+
169
+ // ============================================================================
170
+ // DETECTION: LLM context variable truncation
171
+ // ============================================================================
172
+
173
+ function detectContextVarTruncation(
174
+ tree: Parser.Tree,
175
+ source: string,
176
+ filePath: string
177
+ ): ASTViolation[] {
178
+ const violations: ASTViolation[] = [];
179
+
180
+ // Find all call expressions
181
+ const callExpressions = findNodes(tree.rootNode, ["call_expression"]);
182
+
183
+ for (const call of callExpressions) {
184
+ const funcNode = call.childForFieldName("function");
185
+ if (!funcNode || funcNode.type !== "member_expression") continue;
186
+
187
+ const object = funcNode.childForFieldName("object");
188
+ const property = funcNode.childForFieldName("property");
189
+ if (!object || !property) continue;
190
+
191
+ const methodName = nodeText(property, source);
192
+ if (!TRUNCATION_METHODS.has(methodName)) continue;
193
+
194
+ // Check if the object is an LLM context variable
195
+ const objectName = nodeText(object, source);
196
+
197
+ // Direct variable: context.slice()
198
+ if (LLM_CONTEXT_VARS.has(objectName)) {
199
+ violations.push({
200
+ file: filePath,
201
+ line: call.startPosition.row + 1,
202
+ column: call.startPosition.column + 1,
203
+ type: "context-var-truncation",
204
+ description: `LLM context variable '${objectName}' is truncated via .${methodName}()`,
205
+ code: nodeText(call, source).slice(0, 100),
206
+ severity: "error",
207
+ });
208
+ continue;
209
+ }
210
+
211
+ // Property access: result.content.slice()
212
+ if (object.type === "member_expression") {
213
+ const innerProp = object.childForFieldName("property");
214
+ if (innerProp && LLM_CONTEXT_VARS.has(nodeText(innerProp, source))) {
215
+ violations.push({
216
+ file: filePath,
217
+ line: call.startPosition.row + 1,
218
+ column: call.startPosition.column + 1,
219
+ type: "context-var-truncation",
220
+ description: `LLM context property '${nodeText(innerProp, source)}' is truncated via .${methodName}()`,
221
+ code: nodeText(call, source).slice(0, 100),
222
+ severity: "error",
223
+ });
224
+ }
225
+ }
226
+ }
227
+
228
+ return violations;
229
+ }
230
+
231
+ // ============================================================================
232
+ // DETECTION: Loop-based truncation (Math.min pattern)
233
+ // ============================================================================
234
+
235
+ function detectLoopTruncation(
236
+ tree: Parser.Tree,
237
+ source: string,
238
+ filePath: string
239
+ ): ASTViolation[] {
240
+ const violations: ASTViolation[] = [];
241
+
242
+ // Find for loops
243
+ const forLoops = findNodes(tree.rootNode, ["for_statement"]);
244
+
245
+ for (const loop of forLoops) {
246
+ const loopText = nodeText(loop, source);
247
+
248
+ // Check for Math.min pattern in condition
249
+ if (/Math\.min\s*\([^)]*\.length/.test(loopText)) {
250
+ violations.push({
251
+ file: filePath,
252
+ line: loop.startPosition.row + 1,
253
+ column: loop.startPosition.column + 1,
254
+ type: "loop-truncation",
255
+ description: "Loop uses Math.min() to limit iterations (may truncate data)",
256
+ code: loopText.split("\n")[0].slice(0, 100),
257
+ severity: "warning",
258
+ });
259
+ }
260
+
261
+ // Check for hardcoded limit comparison: i < 10
262
+ const condition = loop.children.find((c) => c.type === "binary_expression");
263
+ if (condition) {
264
+ const condText = nodeText(condition, source);
265
+ // Pattern: i < NUMBER where NUMBER is small
266
+ const limitMatch = condText.match(/[a-z_]\w*\s*<\s*(\d+)/i);
267
+ if (limitMatch && parseInt(limitMatch[1]) <= 100) {
268
+ // Check if it's iterating over an array
269
+ if (/\.length|\.size/.test(loopText)) {
270
+ violations.push({
271
+ file: filePath,
272
+ line: loop.startPosition.row + 1,
273
+ column: loop.startPosition.column + 1,
274
+ type: "loop-truncation",
275
+ description: `Loop hardcodes limit of ${limitMatch[1]} iterations`,
276
+ code: loopText.split("\n")[0].slice(0, 100),
277
+ severity: "warning",
278
+ });
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ return violations;
285
+ }
286
+
287
+ // ============================================================================
288
+ // DETECTION: Filter/map that drops items
289
+ // ============================================================================
290
+
291
+ function detectFilterTruncation(
292
+ tree: Parser.Tree,
293
+ source: string,
294
+ filePath: string
295
+ ): ASTViolation[] {
296
+ const violations: ASTViolation[] = [];
297
+
298
+ const callExpressions = findNodes(tree.rootNode, ["call_expression"]);
299
+
300
+ for (const call of callExpressions) {
301
+ const funcNode = call.childForFieldName("function");
302
+ if (!funcNode || funcNode.type !== "member_expression") continue;
303
+
304
+ const property = funcNode.childForFieldName("property");
305
+ if (!property) continue;
306
+
307
+ const methodName = nodeText(property, source);
308
+
309
+ // Check .filter((_, i) => i < N) pattern
310
+ if (methodName === "filter") {
311
+ const args = call.childForFieldName("arguments");
312
+ if (args) {
313
+ const argsText = nodeText(args, source);
314
+ // Pattern: filter with index comparison
315
+ if (/\(\s*_?\s*,\s*\w+\s*\)\s*=>\s*\w+\s*<\s*\d+/.test(argsText)) {
316
+ violations.push({
317
+ file: filePath,
318
+ line: call.startPosition.row + 1,
319
+ column: call.startPosition.column + 1,
320
+ type: "filter-truncation",
321
+ description: ".filter() uses index to limit results (truncates data)",
322
+ code: nodeText(call, source).slice(0, 100),
323
+ severity: "error",
324
+ });
325
+ }
326
+ }
327
+ }
328
+
329
+ // Check for chained .slice() after .map()/.filter()
330
+ if (methodName === "slice") {
331
+ const object = funcNode.childForFieldName("object");
332
+ if (object && object.type === "call_expression") {
333
+ const innerFunc = object.childForFieldName("function");
334
+ if (innerFunc && innerFunc.type === "member_expression") {
335
+ const innerMethod = innerFunc.childForFieldName("property");
336
+ if (innerMethod) {
337
+ const innerMethodName = nodeText(innerMethod, source);
338
+ if (["map", "filter", "flatMap", "reduce"].includes(innerMethodName)) {
339
+ violations.push({
340
+ file: filePath,
341
+ line: call.startPosition.row + 1,
342
+ column: call.startPosition.column + 1,
343
+ type: "chained-truncation",
344
+ description: `.${innerMethodName}() results are truncated with .slice()`,
345
+ code: nodeText(call, source).slice(0, 100),
346
+ severity: "error",
347
+ });
348
+ }
349
+ }
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ return violations;
356
+ }
357
+
358
+ // ============================================================================
359
+ // DETECTION: Conditional early return with truncation
360
+ // ============================================================================
361
+
362
+ function detectConditionalTruncation(
363
+ tree: Parser.Tree,
364
+ source: string,
365
+ filePath: string
366
+ ): ASTViolation[] {
367
+ const violations: ASTViolation[] = [];
368
+
369
+ // Find if statements
370
+ const ifStatements = findNodes(tree.rootNode, ["if_statement"]);
371
+
372
+ for (const ifStmt of ifStatements) {
373
+ const condition = ifStmt.childForFieldName("condition");
374
+ const consequence = ifStmt.childForFieldName("consequence");
375
+
376
+ if (!condition || !consequence) continue;
377
+
378
+ const condText = nodeText(condition, source);
379
+
380
+ // Check for length comparison in condition
381
+ if (/\.length\s*>\s*\d+/.test(condText)) {
382
+ // Check if consequence contains return with slice
383
+ const returns = findNodes(consequence, ["return_statement"]);
384
+ for (const ret of returns) {
385
+ const retText = nodeText(ret, source);
386
+ if (/\.slice\s*\(/.test(retText)) {
387
+ violations.push({
388
+ file: filePath,
389
+ line: ifStmt.startPosition.row + 1,
390
+ column: ifStmt.startPosition.column + 1,
391
+ type: "conditional-return-truncation",
392
+ description: "Conditional early return truncates data when length exceeds limit",
393
+ code: `if ${condText} { ${retText.slice(0, 50)}... }`,
394
+ severity: "error",
395
+ });
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ return violations;
402
+ }
403
+
404
+ // ============================================================================
405
+ // DETECTION: Assignment truncation (result = x.slice())
406
+ // ============================================================================
407
+
408
+ function detectAssignmentTruncation(
409
+ tree: Parser.Tree,
410
+ source: string,
411
+ filePath: string
412
+ ): ASTViolation[] {
413
+ const violations: ASTViolation[] = [];
414
+
415
+ // Find assignments and variable declarations
416
+ const assignments = [
417
+ ...findNodes(tree.rootNode, ["assignment_expression"]),
418
+ ...findNodes(tree.rootNode, ["variable_declarator"]),
419
+ ];
420
+
421
+ for (const assign of assignments) {
422
+ // Get the left side (variable name)
423
+ const left =
424
+ assign.childForFieldName("left") || assign.childForFieldName("name");
425
+ if (!left) continue;
426
+
427
+ const varName = nodeText(left, source);
428
+
429
+ // Check if it's an LLM context variable being assigned
430
+ if (!LLM_CONTEXT_VARS.has(varName)) continue;
431
+
432
+ // Get the right side (value)
433
+ const right =
434
+ assign.childForFieldName("right") || assign.childForFieldName("value");
435
+ if (!right) continue;
436
+
437
+ // Check if right side contains truncation
438
+ const calls = findNodes(right, ["call_expression"]);
439
+ for (const call of calls) {
440
+ const funcNode = call.childForFieldName("function");
441
+ if (funcNode && funcNode.type === "member_expression") {
442
+ const property = funcNode.childForFieldName("property");
443
+ if (property && TRUNCATION_METHODS.has(nodeText(property, source))) {
444
+ violations.push({
445
+ file: filePath,
446
+ line: assign.startPosition.row + 1,
447
+ column: assign.startPosition.column + 1,
448
+ type: "assignment-truncation",
449
+ description: `LLM context variable '${varName}' assigned truncated value`,
450
+ code: nodeText(assign, source).slice(0, 100),
451
+ severity: "error",
452
+ });
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ return violations;
459
+ }
460
+
461
+ // ============================================================================
462
+ // MAIN ANALYZER
463
+ // ============================================================================
464
+
465
+ export function analyzeFile(filePath: string): ASTViolation[] {
466
+ if (!existsSync(filePath)) return [];
467
+
468
+ const parser = getParser(filePath);
469
+ if (!parser) return [];
470
+
471
+ const source = readFileSync(filePath, "utf-8");
472
+ const tree = parser.parse(source);
473
+
474
+ const violations: ASTViolation[] = [
475
+ ...detectReturnTruncation(tree, source, filePath),
476
+ ...detectContextVarTruncation(tree, source, filePath),
477
+ ...detectLoopTruncation(tree, source, filePath),
478
+ ...detectFilterTruncation(tree, source, filePath),
479
+ ...detectConditionalTruncation(tree, source, filePath),
480
+ ...detectAssignmentTruncation(tree, source, filePath),
481
+ ];
482
+
483
+ return violations;
484
+ }
485
+
486
+ export function analyzeFiles(filePaths: string[]): ASTViolation[] {
487
+ const allViolations: ASTViolation[] = [];
488
+
489
+ for (const filePath of filePaths) {
490
+ const violations = analyzeFile(filePath);
491
+ allViolations.push(...violations);
492
+ }
493
+
494
+ return allViolations;
495
+ }
496
+
497
+ export function formatASTViolations(violations: ASTViolation[]): string {
498
+ if (violations.length === 0) return "";
499
+
500
+ const lines: string[] = [];
501
+ lines.push("");
502
+ lines.push("========================================");
503
+ lines.push(" AST-BASED TRUNCATION VIOLATIONS");
504
+ lines.push("========================================");
505
+ lines.push("");
506
+
507
+ // Group by file
508
+ const byFile = new Map<string, ASTViolation[]>();
509
+ for (const v of violations) {
510
+ const existing = byFile.get(v.file) || [];
511
+ existing.push(v);
512
+ byFile.set(v.file, existing);
513
+ }
514
+
515
+ for (const [file, fileViolations] of byFile) {
516
+ lines.push(`📄 ${file}`);
517
+ for (const v of fileViolations) {
518
+ const severity = v.severity === "error" ? "❌" : "⚠️";
519
+ lines.push(` ${severity} Line ${v.line}: ${v.type}`);
520
+ lines.push(` ${v.description}`);
521
+ lines.push(` Code: ${v.code}`);
522
+ lines.push("");
523
+ }
524
+ }
525
+
526
+ lines.push("========================================");
527
+ return lines.join("\n");
528
+ }