all-hands-cli 0.1.14 → 0.1.16

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.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Complexity command - Get complexity metrics for files or directories.
3
3
  *
4
- * Uses ctags to count symbols for broader language support.
4
+ * Provides line counts and estimated token counts for source files.
5
5
  *
6
6
  * Command:
7
7
  * ah complexity <path> - Get complexity metrics
@@ -17,15 +17,11 @@ import {
17
17
  CommandResult,
18
18
  } from "../lib/base-command.js";
19
19
  import { getProjectRoot } from "../lib/git.js";
20
- import {
21
- checkCtagsAvailable,
22
- generateCtagsIndex,
23
- getFileSymbols,
24
- } from "../lib/ctags.js";
20
+
21
+ const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".java", ".swift", ".rb", ".kt"];
25
22
 
26
23
  /**
27
24
  * Get complexity metrics for a file or directory.
28
- * Uses ctags to count symbols instead of AST parsing.
29
25
  */
30
26
  async function complexity(pathArg: string): Promise<CommandResult> {
31
27
  const projectRoot = getProjectRoot();
@@ -41,70 +37,27 @@ async function complexity(pathArg: string): Promise<CommandResult> {
41
37
  };
42
38
  }
43
39
 
44
- // Check ctags availability
45
- const ctagsCheck = checkCtagsAvailable();
46
- if (!ctagsCheck.available) {
47
- return {
48
- success: false,
49
- error: `ctags_unavailable: ${ctagsCheck.error}`,
50
- };
51
- }
52
-
53
40
  const stat = statSync(absolutePath);
54
41
 
55
42
  if (stat.isFile()) {
56
- // Single file complexity
57
43
  const content = readFileSync(absolutePath, "utf-8");
58
44
  const lines = content.split("\n").length;
59
45
 
60
- // Get symbols via ctags
61
- const { index } = generateCtagsIndex(projectRoot, { target: relativePath });
62
- const symbols = getFileSymbols(index, relativePath);
63
-
64
- const functions = symbols.filter(
65
- (s) => s.kind === "function" || s.kind === "method"
66
- ).length;
67
- const classes = symbols.filter((s) => s.kind === "class").length;
68
- const interfaces = symbols.filter(
69
- (s) => s.kind === "interface" || s.kind === "type"
70
- ).length;
71
-
72
- // Count imports/exports with regex (simple heuristic)
73
- const importMatches = content.match(/^import\s/gm);
74
- const exportMatches = content.match(/^export\s/gm);
75
-
76
46
  return {
77
47
  success: true,
78
48
  data: {
79
49
  path: relativePath,
80
50
  type: "file",
81
- metrics: {
82
- lines,
83
- functions,
84
- classes,
85
- interfaces,
86
- imports: importMatches?.length || 0,
87
- exports: exportMatches?.length || 0,
88
- total_symbols: symbols.length,
89
- },
51
+ metrics: { lines },
90
52
  estimated_tokens: Math.ceil(lines * 10),
91
53
  },
92
54
  };
93
55
  }
94
56
 
95
57
  // Directory complexity
96
- const { index, entryCount } = generateCtagsIndex(projectRoot, {
97
- target: relativePath,
98
- });
99
-
100
- // Aggregate stats
101
58
  let totalLines = 0;
102
- let totalFunctions = 0;
103
- let totalClasses = 0;
104
- let totalInterfaces = 0;
105
59
  let fileCount = 0;
106
60
 
107
- // Find source files and count lines
108
61
  const countDir = (dir: string): void => {
109
62
  const entries = readdirSync(dir);
110
63
  for (const entry of entries) {
@@ -115,7 +68,7 @@ async function complexity(pathArg: string): Promise<CommandResult> {
115
68
  countDir(fullPath);
116
69
  } else {
117
70
  const ext = extname(entry);
118
- if ([".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".java"].includes(ext)) {
71
+ if (SOURCE_EXTENSIONS.includes(ext)) {
119
72
  fileCount++;
120
73
  const content = readFileSync(fullPath, "utf-8");
121
74
  totalLines += content.split("\n").length;
@@ -126,34 +79,13 @@ async function complexity(pathArg: string): Promise<CommandResult> {
126
79
 
127
80
  countDir(absolutePath);
128
81
 
129
- // Count symbol types from index
130
- for (const fileMap of Array.from(index.values())) {
131
- for (const entries of Array.from(fileMap.values())) {
132
- for (const entry of entries) {
133
- if (entry.kind === "function" || entry.kind === "method") {
134
- totalFunctions++;
135
- } else if (entry.kind === "class") {
136
- totalClasses++;
137
- } else if (entry.kind === "interface" || entry.kind === "type") {
138
- totalInterfaces++;
139
- }
140
- }
141
- }
142
- }
143
-
144
82
  return {
145
83
  success: true,
146
84
  data: {
147
85
  path: relativePath,
148
86
  type: "directory",
149
87
  file_count: fileCount,
150
- metrics: {
151
- lines: totalLines,
152
- functions: totalFunctions,
153
- classes: totalClasses,
154
- interfaces: totalInterfaces,
155
- total_symbols: entryCount,
156
- },
88
+ metrics: { lines: totalLines },
157
89
  estimated_tokens: Math.ceil(totalLines * 10),
158
90
  },
159
91
  };
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Documentation commands - validation and reference finalization.
3
3
  *
4
- * Uses ctags for symbol lookup (instead of AST parsing) for broader language support
5
- * and simpler implementation.
4
+ * Symbols in refs are author-provided labels. Staleness is detected via
5
+ * file-level git blob hashes.
6
6
  *
7
7
  * Commands:
8
8
  * ah docs validate [--path <path>] - Validate all refs in docs/
@@ -19,15 +19,10 @@ import {
19
19
  executeCommand,
20
20
  parseContext,
21
21
  } from "../lib/base-command.js";
22
- import {
23
- checkCtagsAvailable,
24
- findSymbolInFile,
25
- generateCtagsIndex,
26
- } from "../lib/ctags.js";
27
22
  import {
28
23
  batchGetBlobHashes,
29
24
  findMarkdownFiles,
30
- isCodeFile,
25
+ REF_PATTERN,
31
26
  validateDocsAsync,
32
27
  } from "../lib/docs-validation.js";
33
28
  import { getProjectRoot } from "../lib/git.js";
@@ -44,15 +39,6 @@ async function validate(docsPath: string, options?: { useCache?: boolean }): Pro
44
39
  ? docsPath
45
40
  : join(projectRoot, docsPath);
46
41
 
47
- // Check ctags availability first
48
- const ctagsCheck = checkCtagsAvailable();
49
- if (!ctagsCheck.available) {
50
- return {
51
- success: false,
52
- error: `ctags_unavailable: ${ctagsCheck.error}`,
53
- };
54
- }
55
-
56
42
  const excludePaths = EXCLUDED_DOC_PATHS.map((p) => join(projectRoot, p));
57
43
 
58
44
  // Run validation (with optional caching)
@@ -261,25 +247,7 @@ function finalizeSingleFile(
261
247
  continue;
262
248
  }
263
249
 
264
- // For non-code files (markdown, yaml, json, etc.), treat symbol as a label (no ctags lookup)
265
- if (!isCodeFile(placeholder.file)) {
266
- const fullRef = `[ref:${placeholder.file}:${placeholder.symbol}:${hashResult.hash}]`;
267
- finalizedContent = finalizedContent.replaceAll(placeholder.match, fullRef);
268
- replacements.push({ from: placeholder.match, to: fullRef });
269
- continue;
270
- }
271
-
272
- // For code files with symbol refs, verify symbol exists via ctags
273
- const entry = findSymbolInFile(absoluteFilePath, placeholder.symbol, projectRoot);
274
- if (!entry) {
275
- errors.push({
276
- placeholder: placeholder.match,
277
- reason: `Symbol '${placeholder.symbol}' not found in ${placeholder.file}`,
278
- });
279
- continue;
280
- }
281
-
282
- // Create full ref
250
+ // Symbol is an author-provided label produce the full ref with hash directly
283
251
  const fullRef = `[ref:${placeholder.file}:${placeholder.symbol}:${hashResult.hash}]`;
284
252
  finalizedContent = finalizedContent.replaceAll(placeholder.match, fullRef);
285
253
  replacements.push({ from: placeholder.match, to: fullRef });
@@ -371,18 +339,6 @@ async function finalize(docsPath: string, options?: { refresh?: boolean }): Prom
371
339
  };
372
340
  }
373
341
 
374
- // Check ctags availability
375
- const ctagsCheck = checkCtagsAvailable();
376
- if (!ctagsCheck.available) {
377
- return {
378
- success: false,
379
- error: `ctags_unavailable: ${ctagsCheck.error}`,
380
- };
381
- }
382
-
383
- // Generate ctags index for symbol lookup
384
- generateCtagsIndex(projectRoot);
385
-
386
342
  // Determine if path is a file or directory
387
343
  const excludePaths = EXCLUDED_DOC_PATHS.map((p) => join(projectRoot, p));
388
344
  const stat = statSync(absolutePath);
@@ -2,11 +2,11 @@
2
2
  * Documentation validation utilities.
3
3
  *
4
4
  * Validates references in documentation files against the actual codebase
5
- * using ctags for symbol lookup and git for staleness detection.
5
+ * using git blob hashes for staleness detection.
6
6
  *
7
7
  * Reference formats:
8
- * [ref:file:symbol:hash] - Symbol reference (validated via ctags)
9
- * [ref:file::hash] - File-only reference (no symbol validation)
8
+ * [ref:file:symbol:hash] - Symbol reference (symbol is an author-provided label)
9
+ * [ref:file::hash] - File-only reference
10
10
  *
11
11
  * Where hash = git blob hash (content-addressable, stable across merges/rebases)
12
12
  */
@@ -14,40 +14,8 @@
14
14
  import { spawnSync } from "child_process";
15
15
  import { createHash } from "crypto";
16
16
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
17
- import { extname, join, relative } from "path";
17
+ import { join, relative } from "path";
18
18
  import matter from "gray-matter";
19
- import { CtagsIndex, generateCtagsIndex, generateCtagsIndexAsync, lookupSymbol } from "./ctags.js";
20
-
21
- /**
22
- * File extensions that ctags can process (programming languages).
23
- * Files with other extensions are treated as non-code where symbols are just labels.
24
- */
25
- const CODE_EXTENSIONS = new Set([
26
- ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", // TypeScript/JavaScript
27
- ".py", ".pyw", // Python
28
- ".go", // Go
29
- ".rs", // Rust
30
- ".java", // Java
31
- ".rb", // Ruby
32
- ".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", // C/C++
33
- ".cs", // C#
34
- ".php", // PHP
35
- ".kt", ".kts", // Kotlin
36
- ".swift", // Swift
37
- ".scala", // Scala
38
- ".lua", // Lua
39
- ".sh", ".bash", ".zsh", // Shell scripts
40
- ".vim", // Vim script
41
- ".el", // Emacs Lisp
42
- ]);
43
-
44
- /**
45
- * Check if a file is a code file that ctags can process.
46
- */
47
- export function isCodeFile(filePath: string): boolean {
48
- const ext = extname(filePath).toLowerCase();
49
- return CODE_EXTENSIONS.has(ext);
50
- }
51
19
 
52
20
  /**
53
21
  * Validation cache for faster repeated validation runs.
@@ -484,13 +452,11 @@ export function findMarkdownFiles(dir: string, excludeReadme = false, excludePat
484
452
  /**
485
453
  * Validate a single reference.
486
454
  * @param ref - The parsed reference to validate
487
- * @param ctagsIndex - The ctags index for symbol lookup
488
455
  * @param projectRoot - The project root directory
489
456
  * @param hashCache - Optional pre-fetched hash map for performance
490
457
  */
491
458
  export function validateRef(
492
459
  ref: ParsedRef,
493
- ctagsIndex: CtagsIndex,
494
460
  projectRoot: string,
495
461
  hashCache?: Map<string, { hash: string; success: boolean }>
496
462
  ): ValidatedRef {
@@ -525,49 +491,12 @@ export function validateRef(
525
491
  };
526
492
  }
527
493
 
528
- // File-only reference: just check hash
529
- if (ref.isFileOnly) {
530
- if (currentHash !== ref.hash) {
531
- return {
532
- ...ref,
533
- state: "stale",
534
- reason: "File has been modified",
535
- currentHash,
536
- };
537
- }
538
- return { ...ref, state: "valid" };
539
- }
540
-
541
- // Non-code files (markdown, yaml, json, etc.): treat symbol as label, just check hash
542
- if (!isCodeFile(ref.file)) {
543
- if (currentHash !== ref.hash) {
544
- return {
545
- ...ref,
546
- state: "stale",
547
- reason: "File has been modified",
548
- currentHash,
549
- };
550
- }
551
- return { ...ref, state: "valid" };
552
- }
553
-
554
- // Code file symbol reference: check symbol exists via ctags
555
- const entries = lookupSymbol(ctagsIndex, ref.file, ref.symbol!);
556
-
557
- if (entries.length === 0) {
558
- return {
559
- ...ref,
560
- state: "invalid",
561
- reason: `Symbol '${ref.symbol}' not found in ${ref.file}`,
562
- };
563
- }
564
-
565
- // Check hash staleness (using file-level hash for ctags approach)
494
+ // Check hash staleness (symbols are author-provided labels, not verified)
566
495
  if (currentHash !== ref.hash) {
567
496
  return {
568
497
  ...ref,
569
498
  state: "stale",
570
- reason: "File has been modified since reference was created",
499
+ reason: "File has been modified",
571
500
  currentHash,
572
501
  };
573
502
  }
@@ -581,7 +510,7 @@ export function validateRef(
581
510
  export function validateDocs(
582
511
  docsPath: string,
583
512
  projectRoot: string,
584
- options?: { ctagsIndex?: CtagsIndex; useCache?: boolean; excludePaths?: string[] }
513
+ options?: { useCache?: boolean; excludePaths?: string[] }
585
514
  ): ValidationResult {
586
515
  // Initialize result
587
516
  const result: ValidationResult = {
@@ -627,11 +556,6 @@ export function validateDocs(
627
556
  refFileHashes: {},
628
557
  };
629
558
 
630
- // Generate ctags index (or use provided one)
631
- const ctagsIndex =
632
- options?.ctagsIndex ||
633
- generateCtagsIndex(projectRoot).index;
634
-
635
559
  // Helper to get/create doc file entry
636
560
  const getDocEntry = (docFile: string): DocFileIssues => {
637
561
  if (!result.by_doc_file[docFile]) {
@@ -759,7 +683,7 @@ export function validateDocs(
759
683
 
760
684
  // Validate each reference (using cached hashes)
761
685
  for (const ref of allRefs) {
762
- const validated = validateRef(ref, ctagsIndex, projectRoot, hashCache);
686
+ const validated = validateRef(ref, projectRoot, hashCache);
763
687
 
764
688
  if (validated.state === "valid") {
765
689
  result.valid_count++;
@@ -856,52 +780,12 @@ export function validateDocs(
856
780
 
857
781
  /**
858
782
  * Validate all documentation in a directory (async version).
859
- *
860
- * Identical to validateDocs but uses generateCtagsIndexAsync to avoid
861
- * blocking the event loop during ctags execution.
783
+ * Kept async for API compatibility. Delegates directly to validateDocs.
862
784
  */
863
785
  export async function validateDocsAsync(
864
786
  docsPath: string,
865
787
  projectRoot: string,
866
- options?: { ctagsIndex?: CtagsIndex; useCache?: boolean; excludePaths?: string[] }
788
+ options?: { useCache?: boolean; excludePaths?: string[] }
867
789
  ): Promise<ValidationResult> {
868
- // If a ctags index was provided, delegate to the sync version directly
869
- if (options?.ctagsIndex) {
870
- return validateDocs(docsPath, projectRoot, options);
871
- }
872
-
873
- // Generate ctags index asynchronously
874
- const ctagsResult = await generateCtagsIndexAsync(projectRoot);
875
-
876
- // If ctags is unavailable or failed, run validation with the empty map
877
- // (to prevent a sync fallback that would also fail), then strip out the
878
- // symbol-ref invalids that are false positives from the empty index.
879
- if (!ctagsResult.success) {
880
- const baseResult = validateDocs(docsPath, projectRoot, {
881
- ...options,
882
- ctagsIndex: ctagsResult.index, // empty map — avoids sync fallback
883
- });
884
- // Remove symbol-ref invalid entries (they're false positives from empty index)
885
- const symbolInvalidCount = baseResult.invalid.filter((i) =>
886
- i.reason.startsWith("Symbol '")
887
- ).length;
888
- baseResult.invalid = baseResult.invalid.filter(
889
- (i) => !i.reason.startsWith("Symbol '")
890
- );
891
- // Also strip from per-doc entries
892
- for (const entry of Object.values(baseResult.by_doc_file)) {
893
- entry.invalid = entry.invalid.filter(
894
- (i) => !i.reason.startsWith("Symbol '")
895
- );
896
- }
897
- baseResult.invalid_count -= symbolInvalidCount;
898
- baseResult.message = `ctags unavailable: ${ctagsResult.error ?? "unknown error"} — symbol ref validation skipped`;
899
- return baseResult;
900
- }
901
-
902
- // Delegate to sync validateDocs with the pre-built index
903
- return validateDocs(docsPath, projectRoot, {
904
- ...options,
905
- ctagsIndex: ctagsResult.index,
906
- });
790
+ return validateDocs(docsPath, projectRoot, options);
907
791
  }
@@ -80,6 +80,7 @@ Prompt files reference suites in `validation_suites` frontmatter. During executi
80
80
 
81
81
  - **External tooling** (xctrace, simctl, playwright, etc.) — Document explicitly: commands, flags, use cases inline with motivations. Stable and unfamiliar to agents by default. Example from xcode-automation: `xcrun xctrace record --template 'Time Profiler' --device '<UDID>' --attach '<PID>'` — flags, ordering constraints, and PID discovery are external tool knowledge that belongs in the suite.
82
82
  - **Internal codebase commands** — Document patterns, not inventories: teach discovery (`package.json` scripts, `--help`), naming conventions, motivations for test categories. Pattern-based suites age gracefully; command inventories require constant maintenance.
83
+ - **Anti-pattern — exhaustive command tables**: A deterministic section listing every `test:*` command as individual rows goes stale as suites evolve. Instead: document the minimum CI gate command explicitly, teach the naming pattern, point to the source of truth (e.g., `package.json`), and explain how to choose scope. The agent reads the source at execution time — always fresher than a static table.
83
84
 
84
85
  ## Decision Tree Requirement
85
86
 
@@ -117,6 +118,24 @@ New suites: draft, then test on a feature branch before marking guidance as prov
117
118
 
118
119
  The plan/handoff document persists as the hypothesis record for future work.
119
120
 
121
+ ## Established Practice vs Unvalidated Ideas
122
+
123
+ Suites contain two kinds of guidance, both authoritative:
124
+
125
+ - **Established practice**: Verified hands-on. Stated authoritatively — agents follow directly.
126
+ - **Unvalidated ideas**: Worth pursuing but not yet exercised. Authoritative as direction — agents encountering them should attempt, assess feasibility, and document outcomes.
127
+
128
+ Ideas carry an implicit mandate: try to make them work, then capture what happened (see Learnings Destination). Over time, ideas that prove out become established practice. Ideas that prove infeasible get revised or removed. This is the crystallization lifecycle applied to the documentation itself.
129
+
130
+ ## Learnings Destination
131
+
132
+ When agents discover something during validation:
133
+
134
+ - **Suite-level learnings** (about the validation *practice*) → update the suite doc directly. The suite is what agents read — improvements compound immediately. Examples: tool flag doesn't work on this OS, one approach is more reliable than another.
135
+ - **Task-specific learnings** (about *what was validated*) → knowledge compounding via prompt files or knowledge docs. Examples: endpoint edge case behavior, migration quirks.
136
+
137
+ Distinction: if it helps any agent validating in this domain regardless of task, it belongs in the suite. If it's specific to a feature, it compounds through knowledge docs.
138
+
120
139
  ## Cross-Referencing Between Suites
121
140
 
122
141
  - **Reference** for complex multi-step setup — point to the authoritative suite's decision tree
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "all-hands-cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Agentic harness for model-first software development",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,244 +0,0 @@
1
- /**
2
- * Tests for ctags utilities.
3
- *
4
- * Note: These tests require universal-ctags to be installed.
5
- * Tests are skipped if ctags is not available.
6
- */
7
-
8
- import { describe, expect, it, beforeAll } from "vitest";
9
- import { join } from "path";
10
- import { writeFileSync, mkdirSync, rmSync, existsSync } from "fs";
11
- import {
12
- checkCtagsAvailable,
13
- generateCtagsIndex,
14
- lookupSymbol,
15
- searchSymbol,
16
- getFileSymbols,
17
- generateFileCtags,
18
- findSymbolInFile,
19
- CtagsIndex,
20
- } from "../ctags.js";
21
-
22
- // Check if ctags is available for tests
23
- const ctagsCheck = checkCtagsAvailable();
24
- const hasCtagsInstalled = ctagsCheck.available;
25
-
26
- describe("checkCtagsAvailable", () => {
27
- it("returns availability status", () => {
28
- const result = checkCtagsAvailable();
29
- expect(result).toHaveProperty("available");
30
-
31
- if (result.available) {
32
- expect(result.version).toBeDefined();
33
- } else {
34
- expect(result.error).toBeDefined();
35
- }
36
- });
37
- });
38
-
39
- // Skip remaining tests if ctags not installed
40
- describe.skipIf(!hasCtagsInstalled)("ctags with installed ctags", () => {
41
- const testDir = join(process.cwd(), ".test-ctags-temp");
42
- const testFile = join(testDir, "test-sample.ts");
43
-
44
- const sampleCode = `
45
- // Test TypeScript file for ctags tests
46
-
47
- export class MyClass {
48
- private value: number;
49
-
50
- constructor(value: number) {
51
- this.value = value;
52
- }
53
-
54
- getValue(): number {
55
- return this.value;
56
- }
57
-
58
- setValue(newValue: number): void {
59
- this.value = newValue;
60
- }
61
- }
62
-
63
- export interface MyInterface {
64
- name: string;
65
- age: number;
66
- }
67
-
68
- export function myFunction(arg: string): string {
69
- return arg.toUpperCase();
70
- }
71
-
72
- export const MY_CONSTANT = 42;
73
-
74
- type MyType = string | number;
75
- `;
76
-
77
- beforeAll(() => {
78
- // Create test directory and file
79
- if (existsSync(testDir)) {
80
- rmSync(testDir, { recursive: true });
81
- }
82
- mkdirSync(testDir, { recursive: true });
83
- writeFileSync(testFile, sampleCode, "utf-8");
84
-
85
- return () => {
86
- // Cleanup
87
- if (existsSync(testDir)) {
88
- rmSync(testDir, { recursive: true });
89
- }
90
- };
91
- });
92
-
93
- describe("generateFileCtags", () => {
94
- it("generates ctags for a single file", () => {
95
- const result = generateFileCtags(testFile, testDir);
96
-
97
- expect(result.success).toBe(true);
98
- expect(result.entries.length).toBeGreaterThan(0);
99
-
100
- // Should find known symbols
101
- const symbolNames = result.entries.map((e) => e.name);
102
- expect(symbolNames).toContain("MyClass");
103
- expect(symbolNames).toContain("myFunction");
104
- });
105
-
106
- it("returns error for non-existent file", () => {
107
- const result = generateFileCtags(join(testDir, "nonexistent.ts"), testDir);
108
-
109
- expect(result.success).toBe(false);
110
- expect(result.error).toBeDefined();
111
- });
112
-
113
- it("includes symbol metadata", () => {
114
- const result = generateFileCtags(testFile, testDir);
115
- expect(result.success).toBe(true);
116
-
117
- const classEntry = result.entries.find((e) => e.name === "MyClass");
118
- expect(classEntry).toBeDefined();
119
- expect(classEntry!.kind).toBe("class");
120
- expect(classEntry!.line).toBeGreaterThan(0);
121
- });
122
- });
123
-
124
- describe("generateCtagsIndex", () => {
125
- it("generates index for directory", () => {
126
- const result = generateCtagsIndex(testDir);
127
-
128
- expect(result.success).toBe(true);
129
- expect(result.entryCount).toBeGreaterThan(0);
130
- expect(result.index.size).toBeGreaterThan(0);
131
- });
132
-
133
- it("indexes symbols by file and name", () => {
134
- const { index, success } = generateCtagsIndex(testDir);
135
- expect(success).toBe(true);
136
-
137
- // The file path should be relative to cwd
138
- const relPath = "test-sample.ts";
139
- const fileMap = index.get(relPath);
140
- expect(fileMap).toBeDefined();
141
-
142
- // Should have MyClass
143
- const myClass = fileMap!.get("MyClass");
144
- expect(myClass).toBeDefined();
145
- expect(myClass!.length).toBeGreaterThan(0);
146
- });
147
- });
148
-
149
- describe("lookupSymbol", () => {
150
- let index: CtagsIndex;
151
-
152
- beforeAll(() => {
153
- const result = generateCtagsIndex(testDir);
154
- index = result.index;
155
- });
156
-
157
- it("finds symbol in specific file", () => {
158
- const entries = lookupSymbol(index, "test-sample.ts", "MyClass");
159
-
160
- expect(entries.length).toBeGreaterThan(0);
161
- expect(entries[0].name).toBe("MyClass");
162
- });
163
-
164
- it("returns empty array for non-existent symbol", () => {
165
- const entries = lookupSymbol(index, "test-sample.ts", "NonExistent");
166
- expect(entries).toHaveLength(0);
167
- });
168
-
169
- it("returns empty array for non-existent file", () => {
170
- const entries = lookupSymbol(index, "nonexistent.ts", "MyClass");
171
- expect(entries).toHaveLength(0);
172
- });
173
- });
174
-
175
- describe("searchSymbol", () => {
176
- let index: CtagsIndex;
177
-
178
- beforeAll(() => {
179
- const result = generateCtagsIndex(testDir);
180
- index = result.index;
181
- });
182
-
183
- it("finds symbol across all files", () => {
184
- const results = searchSymbol(index, "MyClass");
185
-
186
- expect(results.length).toBeGreaterThan(0);
187
- expect(results[0].name).toBe("MyClass");
188
- expect(results[0].file).toBeDefined();
189
- });
190
-
191
- it("returns empty for non-existent symbol", () => {
192
- const results = searchSymbol(index, "NonExistent");
193
- expect(results).toHaveLength(0);
194
- });
195
- });
196
-
197
- describe("getFileSymbols", () => {
198
- let index: CtagsIndex;
199
-
200
- beforeAll(() => {
201
- const result = generateCtagsIndex(testDir);
202
- index = result.index;
203
- });
204
-
205
- it("returns all symbols in a file", () => {
206
- const symbols = getFileSymbols(index, "test-sample.ts");
207
-
208
- expect(symbols.length).toBeGreaterThan(0);
209
-
210
- // Should include class, function, interface
211
- const names = symbols.map((s) => s.name);
212
- expect(names).toContain("MyClass");
213
- expect(names).toContain("myFunction");
214
- });
215
-
216
- it("returns symbols sorted by line number", () => {
217
- const symbols = getFileSymbols(index, "test-sample.ts");
218
-
219
- for (let i = 1; i < symbols.length; i++) {
220
- expect(symbols[i].line).toBeGreaterThanOrEqual(symbols[i - 1].line);
221
- }
222
- });
223
-
224
- it("returns empty for non-existent file", () => {
225
- const symbols = getFileSymbols(index, "nonexistent.ts");
226
- expect(symbols).toHaveLength(0);
227
- });
228
- });
229
-
230
- describe("findSymbolInFile", () => {
231
- it("finds symbol in file", () => {
232
- const entry = findSymbolInFile(testFile, "MyClass", testDir);
233
-
234
- expect(entry).not.toBeNull();
235
- expect(entry!.name).toBe("MyClass");
236
- expect(entry!.kind).toBe("class");
237
- });
238
-
239
- it("returns null for non-existent symbol", () => {
240
- const entry = findSymbolInFile(testFile, "NonExistent", testDir);
241
- expect(entry).toBeNull();
242
- });
243
- });
244
- });
@@ -1,497 +0,0 @@
1
- /**
2
- * Ctags utilities for symbol lookup and validation.
3
- *
4
- * Uses universal-ctags to generate a symbol index for fast O(1) lookups.
5
- * This enables documentation reference validation without AST parsing.
6
- */
7
-
8
- import { spawn, spawnSync } from "child_process";
9
- import { existsSync } from "fs";
10
-
11
- /**
12
- * Single ctags entry representing a symbol in a file.
13
- */
14
- export interface CtagsEntry {
15
- name: string;
16
- path: string;
17
- line: number;
18
- kind: string;
19
- signature?: string;
20
- }
21
-
22
- /**
23
- * Indexed ctags data for O(1) lookups.
24
- * Structure: Map<file, Map<symbol, CtagsEntry[]>>
25
- * Multiple entries per symbol possible (overloads, same name in different scopes).
26
- */
27
- export type CtagsIndex = Map<string, Map<string, CtagsEntry[]>>;
28
-
29
- /**
30
- * Common paths where universal-ctags might be installed.
31
- * Checked in order before falling back to PATH.
32
- */
33
- const CTAGS_PATHS = [
34
- "/opt/homebrew/bin/ctags", // macOS ARM homebrew
35
- "/usr/local/bin/ctags", // macOS Intel homebrew / Linux
36
- "/usr/bin/ctags", // System PATH (may be BSD ctags)
37
- "ctags", // Fall back to PATH
38
- ];
39
-
40
- /** Cached path to universal-ctags binary */
41
- let cachedCtagsPath: string | null = null;
42
-
43
- /**
44
- * Find the universal-ctags binary, checking common paths first.
45
- */
46
- function findUniversalCtags(): { path: string; version: string } | null {
47
- for (const ctagsPath of CTAGS_PATHS) {
48
- // Skip absolute paths that don't exist
49
- if (ctagsPath.startsWith("/") && !existsSync(ctagsPath)) {
50
- continue;
51
- }
52
-
53
- const result = spawnSync(ctagsPath, ["--version"], { encoding: "utf-8" });
54
-
55
- if (result.status !== 0) {
56
- continue;
57
- }
58
-
59
- const output = result.stdout || "";
60
- if (output.includes("Universal Ctags") || output.includes("universal-ctags")) {
61
- const versionMatch = output.match(/Universal Ctags\s+([\d.]+)/i);
62
- const version = versionMatch ? versionMatch[1] : "unknown";
63
- return { path: ctagsPath, version };
64
- }
65
- }
66
-
67
- return null;
68
- }
69
-
70
- /**
71
- * Get the path to universal-ctags (cached).
72
- */
73
- export function getCtagsPath(): string | null {
74
- if (cachedCtagsPath !== null) {
75
- return cachedCtagsPath;
76
- }
77
-
78
- const found = findUniversalCtags();
79
- if (found) {
80
- cachedCtagsPath = found.path;
81
- return cachedCtagsPath;
82
- }
83
-
84
- return null;
85
- }
86
-
87
- /**
88
- * Check if ctags (universal-ctags) is available.
89
- */
90
- export function checkCtagsAvailable(): { available: boolean; version?: string; error?: string; path?: string } {
91
- const found = findUniversalCtags();
92
-
93
- if (!found) {
94
- return {
95
- available: false,
96
- error:
97
- "Universal Ctags not found. Install with: brew install universal-ctags\n" +
98
- "Note: The BSD ctags that ships with macOS is not compatible.",
99
- };
100
- }
101
-
102
- cachedCtagsPath = found.path;
103
-
104
- return {
105
- available: true,
106
- version: found.version,
107
- path: found.path,
108
- };
109
- }
110
-
111
- /**
112
- * Parse a single line of ctags JSON output.
113
- */
114
- function parseCtagsLine(line: string): CtagsEntry | null {
115
- try {
116
- const entry = JSON.parse(line);
117
-
118
- // Skip ptag entries (pseudo-tags with metadata)
119
- if (entry._type === "ptag") {
120
- return null;
121
- }
122
-
123
- if (entry._type !== "tag" || !entry.name || !entry.path) {
124
- return null;
125
- }
126
-
127
- return {
128
- name: entry.name,
129
- path: entry.path,
130
- line: entry.line || 0,
131
- kind: entry.kind || "unknown",
132
- signature: entry.signature,
133
- };
134
- } catch {
135
- return null;
136
- }
137
- }
138
-
139
- /**
140
- * Generate a ctags index for a directory.
141
- *
142
- * Uses ctags with JSON output format for reliable parsing.
143
- * Excludes common non-source directories.
144
- */
145
- export function generateCtagsIndex(
146
- cwd: string,
147
- options?: {
148
- /** Additional exclude patterns */
149
- exclude?: string[];
150
- /** Specific file or directory to index (relative to cwd) */
151
- target?: string;
152
- }
153
- ): { index: CtagsIndex; success: boolean; error?: string; entryCount: number } {
154
- const check = checkCtagsAvailable();
155
- if (!check.available) {
156
- return {
157
- index: new Map(),
158
- success: false,
159
- error: check.error,
160
- entryCount: 0,
161
- };
162
- }
163
-
164
- const excludes = [
165
- "node_modules",
166
- ".git",
167
- "dist",
168
- "build",
169
- ".next",
170
- "coverage",
171
- "__pycache__",
172
- ".venv",
173
- "venv",
174
- ...(options?.exclude || []),
175
- ];
176
-
177
- const args = [
178
- "-R",
179
- "--output-format=json",
180
- "--fields=+nKS", // +n=line number, +K=kind, +S=signature
181
- "-o",
182
- "-", // Output to stdout
183
- ];
184
-
185
- // Add exclude patterns
186
- for (const exclude of excludes) {
187
- args.push(`--exclude=${exclude}`);
188
- }
189
-
190
- // Add target if specified, otherwise index current directory
191
- const target = options?.target || ".";
192
- args.push(target);
193
-
194
- const ctagsPath = getCtagsPath()!;
195
- const result = spawnSync(ctagsPath, args, {
196
- encoding: "utf-8",
197
- cwd,
198
- maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large repos
199
- });
200
-
201
- if (result.status !== 0) {
202
- return {
203
- index: new Map(),
204
- success: false,
205
- error: `ctags failed: ${result.stderr || "unknown error"}`,
206
- entryCount: 0,
207
- };
208
- }
209
-
210
- // Parse JSON output into index
211
- const index: CtagsIndex = new Map();
212
- let entryCount = 0;
213
-
214
- const lines = result.stdout.split("\n");
215
- for (const line of lines) {
216
- if (!line.trim()) continue;
217
-
218
- const entry = parseCtagsLine(line);
219
- if (!entry) continue;
220
-
221
- entryCount++;
222
-
223
- // Get or create file map
224
- let fileMap = index.get(entry.path);
225
- if (!fileMap) {
226
- fileMap = new Map();
227
- index.set(entry.path, fileMap);
228
- }
229
-
230
- // Get or create symbol array
231
- let symbols = fileMap.get(entry.name);
232
- if (!symbols) {
233
- symbols = [];
234
- fileMap.set(entry.name, symbols);
235
- }
236
-
237
- symbols.push(entry);
238
- }
239
-
240
- return { index, success: true, entryCount };
241
- }
242
-
243
- /**
244
- * Look up a symbol in a specific file.
245
- *
246
- * Returns all matching entries (may have multiple for overloads).
247
- */
248
- export function lookupSymbol(
249
- index: CtagsIndex,
250
- filePath: string,
251
- symbolName: string
252
- ): CtagsEntry[] {
253
- const fileMap = index.get(filePath);
254
- if (!fileMap) {
255
- return [];
256
- }
257
-
258
- return fileMap.get(symbolName) || [];
259
- }
260
-
261
- /**
262
- * Look up a symbol in any file (for searching).
263
- *
264
- * Returns all matching entries across all files.
265
- */
266
- export function searchSymbol(
267
- index: CtagsIndex,
268
- symbolName: string
269
- ): Array<CtagsEntry & { file: string }> {
270
- const results: Array<CtagsEntry & { file: string }> = [];
271
-
272
- for (const [file, fileMap] of index) {
273
- const entries = fileMap.get(symbolName);
274
- if (entries) {
275
- for (const entry of entries) {
276
- results.push({ ...entry, file });
277
- }
278
- }
279
- }
280
-
281
- return results;
282
- }
283
-
284
- /**
285
- * Get all symbols in a file.
286
- */
287
- export function getFileSymbols(index: CtagsIndex, filePath: string): CtagsEntry[] {
288
- const fileMap = index.get(filePath);
289
- if (!fileMap) {
290
- return [];
291
- }
292
-
293
- const symbols: CtagsEntry[] = [];
294
- for (const entries of fileMap.values()) {
295
- symbols.push(...entries);
296
- }
297
-
298
- // Sort by line number
299
- return symbols.sort((a, b) => a.line - b.line);
300
- }
301
-
302
- /**
303
- * Generate ctags for a single file (faster for format-reference command).
304
- */
305
- export function generateFileCtags(
306
- filePath: string,
307
- cwd: string
308
- ): { entries: CtagsEntry[]; success: boolean; error?: string } {
309
- if (!existsSync(filePath)) {
310
- return { entries: [], success: false, error: "File not found" };
311
- }
312
-
313
- const check = checkCtagsAvailable();
314
- if (!check.available) {
315
- return { entries: [], success: false, error: check.error };
316
- }
317
-
318
- const args = [
319
- "--output-format=json",
320
- "--fields=+nKS",
321
- "-o",
322
- "-",
323
- filePath,
324
- ];
325
-
326
- const ctagsPath = getCtagsPath()!;
327
- const result = spawnSync(ctagsPath, args, {
328
- encoding: "utf-8",
329
- cwd,
330
- maxBuffer: 10 * 1024 * 1024, // 10MB buffer
331
- });
332
-
333
- if (result.status !== 0) {
334
- return {
335
- entries: [],
336
- success: false,
337
- error: `ctags failed: ${result.stderr || "unknown error"}`,
338
- };
339
- }
340
-
341
- const entries: CtagsEntry[] = [];
342
- const lines = result.stdout.split("\n");
343
-
344
- for (const line of lines) {
345
- if (!line.trim()) continue;
346
-
347
- const entry = parseCtagsLine(line);
348
- if (entry) {
349
- entries.push(entry);
350
- }
351
- }
352
-
353
- return { entries, success: true };
354
- }
355
-
356
- /**
357
- * Find a specific symbol in a file (convenience function).
358
- * Uses single-file ctags for efficiency.
359
- */
360
- export function findSymbolInFile(
361
- filePath: string,
362
- symbolName: string,
363
- cwd: string
364
- ): CtagsEntry | null {
365
- const { entries, success } = generateFileCtags(filePath, cwd);
366
- if (!success) {
367
- return null;
368
- }
369
-
370
- // Return first match (usually there's only one)
371
- return entries.find((e) => e.name === symbolName) || null;
372
- }
373
-
374
- /**
375
- * Generate a ctags index asynchronously (non-blocking).
376
- *
377
- * Same behavior as generateCtagsIndex but uses spawn instead of spawnSync,
378
- * keeping the event loop free during the ctags process.
379
- */
380
- export async function generateCtagsIndexAsync(
381
- cwd: string,
382
- options?: {
383
- /** Additional exclude patterns */
384
- exclude?: string[];
385
- /** Specific file or directory to index (relative to cwd) */
386
- target?: string;
387
- }
388
- ): Promise<{ index: CtagsIndex; success: boolean; error?: string; entryCount: number }> {
389
- const check = checkCtagsAvailable();
390
- if (!check.available) {
391
- return {
392
- index: new Map(),
393
- success: false,
394
- error: check.error,
395
- entryCount: 0,
396
- };
397
- }
398
-
399
- const excludes = [
400
- "node_modules",
401
- ".git",
402
- "dist",
403
- "build",
404
- ".next",
405
- "coverage",
406
- "__pycache__",
407
- ".venv",
408
- "venv",
409
- ...(options?.exclude || []),
410
- ];
411
-
412
- const args = [
413
- "-R",
414
- "--output-format=json",
415
- "--fields=+nKS",
416
- "-o",
417
- "-",
418
- ];
419
-
420
- for (const exclude of excludes) {
421
- args.push(`--exclude=${exclude}`);
422
- }
423
-
424
- const target = options?.target || ".";
425
- args.push(target);
426
-
427
- const ctagsPath = getCtagsPath()!;
428
-
429
- return new Promise((resolve) => {
430
- const child = spawn(ctagsPath, args, {
431
- cwd,
432
- stdio: ["pipe", "pipe", "pipe"],
433
- });
434
-
435
- const chunks: Buffer[] = [];
436
- let stderrOutput = "";
437
-
438
- child.stdout?.on("data", (data: Buffer) => {
439
- chunks.push(data);
440
- });
441
-
442
- child.stderr?.on("data", (data: Buffer) => {
443
- stderrOutput += data.toString();
444
- });
445
-
446
- child.on("close", (code) => {
447
- if (code !== 0) {
448
- resolve({
449
- index: new Map(),
450
- success: false,
451
- error: `ctags failed: ${stderrOutput || "unknown error"}`,
452
- entryCount: 0,
453
- });
454
- return;
455
- }
456
-
457
- const stdout = Buffer.concat(chunks).toString("utf-8");
458
- const index: CtagsIndex = new Map();
459
- let entryCount = 0;
460
-
461
- const lines = stdout.split("\n");
462
- for (const line of lines) {
463
- if (!line.trim()) continue;
464
-
465
- const entry = parseCtagsLine(line);
466
- if (!entry) continue;
467
-
468
- entryCount++;
469
-
470
- let fileMap = index.get(entry.path);
471
- if (!fileMap) {
472
- fileMap = new Map();
473
- index.set(entry.path, fileMap);
474
- }
475
-
476
- let symbols = fileMap.get(entry.name);
477
- if (!symbols) {
478
- symbols = [];
479
- fileMap.set(entry.name, symbols);
480
- }
481
-
482
- symbols.push(entry);
483
- }
484
-
485
- resolve({ index, success: true, entryCount });
486
- });
487
-
488
- child.on("error", (err) => {
489
- resolve({
490
- index: new Map(),
491
- success: false,
492
- error: `ctags spawn error: ${err.message}`,
493
- entryCount: 0,
494
- });
495
- });
496
- });
497
- }