@payconductor/houndsman 0.5.0 → 0.7.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 (175) hide show
  1. package/README.md +24 -4
  2. package/assets/grammars/tree-sitter-javascript.wasm +0 -0
  3. package/assets/grammars/tree-sitter-tsx.wasm +0 -0
  4. package/assets/grammars/tree-sitter-typescript.wasm +0 -0
  5. package/assets/grammars/web-tree-sitter.wasm +0 -0
  6. package/package.json +11 -3
  7. package/src/cli/commands/command.ts +5 -0
  8. package/src/cli/commands/doctor-command.ts +116 -24
  9. package/src/cli/commands/hunt-command.ts +25 -0
  10. package/src/cli/commands/server-command.ts +7 -1
  11. package/src/cli/help.ts +18 -2
  12. package/src/cli/index.ts +10 -6
  13. package/src/core/ai/ai-client.ts +221 -0
  14. package/src/core/ai/ai-sdk-model-provider.ts +101 -0
  15. package/src/core/ai/errors.ts +36 -0
  16. package/src/core/ai/features/brief-enricher.ts +62 -0
  17. package/src/core/ai/features/classifier.ts +50 -0
  18. package/src/core/ai/features/context-summarizer.ts +31 -0
  19. package/src/core/ai/features/pre-analyzer.ts +51 -0
  20. package/src/core/ai/features/verifier.ts +45 -0
  21. package/src/core/ai/index.ts +56 -0
  22. package/src/core/ai/model-factory.ts +137 -0
  23. package/src/core/ai/prompts/base.ts +36 -0
  24. package/src/core/ai/prompts/brief.ts +87 -0
  25. package/src/core/ai/prompts/classification.ts +36 -0
  26. package/src/core/ai/prompts/context.ts +29 -0
  27. package/src/core/ai/prompts/pre-analysis.ts +49 -0
  28. package/src/core/ai/prompts/verify.ts +40 -0
  29. package/src/core/ai/schemas.ts +110 -0
  30. package/src/core/ai/types.ts +76 -0
  31. package/src/core/ai/usage-budget.ts +75 -0
  32. package/src/core/ai/usage-recorder.ts +79 -0
  33. package/src/core/assets.ts +16 -0
  34. package/src/core/ast/diff.ts +220 -0
  35. package/src/core/ast/impact.ts +127 -0
  36. package/src/core/ast/parser.ts +167 -0
  37. package/src/core/ast/service.ts +233 -0
  38. package/src/core/ast/symbols.ts +294 -0
  39. package/src/core/ast/types.ts +125 -0
  40. package/src/core/brief.ts +138 -0
  41. package/src/core/commands/findings.ts +65 -2
  42. package/src/core/commands/review.ts +2 -0
  43. package/src/core/commands/scaffold.ts +159 -10
  44. package/src/core/commands/update.ts +5 -3
  45. package/src/core/config.ts +85 -0
  46. package/src/core/defaults.ts +27 -0
  47. package/src/core/domain/watchdog.ts +43 -5
  48. package/src/core/engine/houndsman-engine.ts +16 -1
  49. package/src/core/engine/review-scope.ts +52 -6
  50. package/src/core/engine/review-session.ts +27 -2
  51. package/src/core/env.ts +101 -0
  52. package/src/core/file-classifier.ts +25 -4
  53. package/src/core/git-runner.ts +25 -13
  54. package/src/core/github-review.ts +10 -7
  55. package/src/core/jev/jev-client.ts +8 -7
  56. package/src/core/jev/jev-verify.ts +2 -1
  57. package/src/core/logging/index.ts +17 -0
  58. package/src/core/logging/logger.ts +187 -0
  59. package/src/core/orchestrator.ts +357 -9
  60. package/src/core/project-config.ts +123 -1
  61. package/src/core/registry-check.ts +148 -6
  62. package/src/core/renderers/manifest-writer.ts +33 -0
  63. package/src/core/rule-text.ts +97 -0
  64. package/src/core/services/file-classifier.ts +10 -3
  65. package/src/core/services/gh-client.ts +38 -22
  66. package/src/core/services/verifier.ts +14 -3
  67. package/src/core/storage/paths.ts +6 -5
  68. package/src/core/storage/types.ts +6 -0
  69. package/src/core/storage/work-area.ts +12 -0
  70. package/src/core/types.ts +37 -1
  71. package/src/core/verify.ts +17 -5
  72. package/src/harness/README.md +15 -0
  73. package/src/harness/agent/agent-context.ts +33 -0
  74. package/src/harness/agent/agent-errors.ts +30 -0
  75. package/src/harness/agent/agent-factory.ts +45 -0
  76. package/src/harness/agent/agent-reporter.ts +34 -0
  77. package/src/harness/agent/agent-result.ts +16 -0
  78. package/src/harness/agent/agent-runner.ts +32 -0
  79. package/src/harness/agent/agent-scheduler.ts +37 -0
  80. package/src/harness/agent/review-agent.ts +391 -0
  81. package/src/harness/config/harness-config.ts +84 -0
  82. package/src/harness/hunt/hunt-harness.ts +83 -0
  83. package/src/harness/hunt/hunt-options.ts +75 -0
  84. package/src/harness/hunt/hunt-result.ts +17 -0
  85. package/src/harness/hunt/hunt-session.ts +372 -0
  86. package/src/harness/hunt/review-planner.ts +98 -0
  87. package/src/harness/index.ts +36 -0
  88. package/src/harness/investigation/agent-state.ts +134 -0
  89. package/src/harness/investigation/evidence-verifier.ts +129 -0
  90. package/src/harness/investigation/evidence.ts +110 -0
  91. package/src/harness/investigation/finding-critic.ts +96 -0
  92. package/src/harness/investigation/investigation-planner.ts +118 -0
  93. package/src/harness/investigation/investigation-task.ts +10 -0
  94. package/src/harness/investigation/review-journal.ts +87 -0
  95. package/src/harness/investigation/rule-analyzer.ts +178 -0
  96. package/src/harness/investigation/rule-signals.ts +110 -0
  97. package/src/harness/prompts/agent-schemas.ts +57 -0
  98. package/src/harness/prompts/reviewer-prompt-builder.ts +101 -0
  99. package/src/harness/prompts/states/conclude-prompt.ts +16 -0
  100. package/src/harness/prompts/states/investigate-prompt.ts +37 -0
  101. package/src/harness/prompts/states/orient-prompt.ts +15 -0
  102. package/src/harness/prompts/states/verify-prompt.ts +22 -0
  103. package/src/harness/tools/agent-tool.ts +102 -0
  104. package/src/harness/tools/builtin/changed-symbols-tool.ts +48 -0
  105. package/src/harness/tools/builtin/doc-context-tool.ts +74 -0
  106. package/src/harness/tools/builtin/file-outline-tool.ts +66 -0
  107. package/src/harness/tools/builtin/find-references-tool.ts +66 -0
  108. package/src/harness/tools/builtin/find-symbol-tool.ts +91 -0
  109. package/src/harness/tools/builtin/helpers.ts +115 -0
  110. package/src/harness/tools/builtin/impact-tool.ts +48 -0
  111. package/src/harness/tools/builtin/imports-exports-tool.ts +53 -0
  112. package/src/harness/tools/builtin/index.ts +59 -0
  113. package/src/harness/tools/builtin/list-files-tool.ts +85 -0
  114. package/src/harness/tools/builtin/pr-context-tool.ts +41 -0
  115. package/src/harness/tools/builtin/read-file-range-tool.ts +73 -0
  116. package/src/harness/tools/builtin/rule-signals-tool.ts +36 -0
  117. package/src/harness/tools/builtin/search-code-tool.ts +65 -0
  118. package/src/harness/tools/builtin/symbol-context-tool.ts +106 -0
  119. package/src/harness/tools/builtin/tests-for-symbol-tool.ts +61 -0
  120. package/src/harness/tools/path-guard.ts +90 -0
  121. package/src/harness/tools/result-limiter.ts +49 -0
  122. package/src/harness/tools/secret-redactor.ts +54 -0
  123. package/src/harness/tools/tool-cache.ts +50 -0
  124. package/src/harness/tools/tool-call-budget.ts +60 -0
  125. package/src/harness/tools/tool-context.ts +5 -0
  126. package/src/harness/tools/tool-middleware.ts +105 -0
  127. package/src/harness/tools/tool-policy.ts +51 -0
  128. package/src/harness/tools/tool-registry.ts +112 -0
  129. package/src/harness/tools/tool-result.ts +15 -0
  130. package/src/harness/tools/toolbox.ts +36 -0
  131. package/src/harness/validation/finding-validator.ts +119 -0
  132. package/src/index.ts +46 -0
  133. package/src/server/client/App.tsx +38 -12
  134. package/src/server/client/CommentsPanel.tsx +255 -0
  135. package/src/server/client/FindingCard.tsx +12 -4
  136. package/src/server/client/FindingsList.tsx +4 -1
  137. package/src/server/client/Sidebar.tsx +397 -0
  138. package/src/server/client/SubmitModal.tsx +61 -5
  139. package/src/server/client/icons.tsx +19 -0
  140. package/src/server/client/index.tsx +1 -1
  141. package/src/server/client/utils/constants.ts +29 -5
  142. package/src/server/client/utils/format.ts +47 -2
  143. package/src/server/client/utils/types.ts +88 -0
  144. package/src/server/editor.ts +129 -0
  145. package/src/server/index.ts +115 -5
  146. package/src/server/pr-comments.ts +256 -0
  147. package/src/server/pr-inbox.ts +202 -0
  148. package/src/server/run-repository.ts +33 -1
  149. package/src/skill/OUTPUT-FORMAT.md +12 -0
  150. package/src/skill/SETUP.md +6 -2
  151. package/src/skill/hunt.md +14 -2
  152. package/src/skill/verify.md +1 -1
  153. package/src/tests/__snapshots__/baseline.test.ts.snap +96 -14
  154. package/src/tests/ai-core.test.ts +120 -0
  155. package/src/tests/ai-features.test.ts +183 -0
  156. package/src/tests/ast.test.ts +335 -0
  157. package/src/tests/cli.test.ts +33 -0
  158. package/src/tests/env.test.ts +69 -0
  159. package/src/tests/fallback.test.ts +172 -0
  160. package/src/tests/fixtures/fake-agent-tools.ts +69 -0
  161. package/src/tests/fixtures/fake-investigation.ts +64 -0
  162. package/src/tests/fixtures/fake-model-provider.ts +48 -0
  163. package/src/tests/github-review.test.ts +9 -0
  164. package/src/tests/harness-agent.test.ts +122 -0
  165. package/src/tests/harness-investigation.test.ts +206 -0
  166. package/src/tests/harness-tools.test.ts +148 -0
  167. package/src/tests/hunt-command.test.ts +212 -0
  168. package/src/tests/logger.test.ts +83 -0
  169. package/src/tests/review-scope.test.ts +11 -11
  170. package/src/tests/rule-analyzer.test.ts +61 -0
  171. package/src/tests/server-editor.test.ts +100 -0
  172. package/src/tests/server.test.ts +295 -2
  173. package/src/tests/tool-contracts.test.ts +82 -0
  174. package/src/tests/verify.test.ts +1 -1
  175. package/src/server/client/RunList.tsx +0 -75
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Delta estrutural entre duas versões de um arquivo (biblioteca).
3
+ *
4
+ * O patch unificado informa quais linhas novas mudaram; os símbolos da versão
5
+ * atual com intervalo tocado viram `modifiedSymbols`. Símbolos que só existem
6
+ * em um dos lados viram `added`/`removed`. Tudo é puro e determinístico.
7
+ */
8
+
9
+ import type {
10
+ AstDelta,
11
+ AstExportChange,
12
+ AstFacts,
13
+ AstSignatureChange,
14
+ AstSymbol,
15
+ } from "./types";
16
+
17
+ /** Um hunk do patch, com as linhas novas adicionadas. */
18
+ export type AstHunk = {
19
+ oldStart: number;
20
+ oldLines: number;
21
+ newStart: number;
22
+ newLines: number;
23
+ /** Linhas novas (1-based) introduzidas pelo hunk. */
24
+ addedLines: number[];
25
+ };
26
+
27
+ /** Regex do cabeçalho de hunk (`@@ -a,b +c,d @@`). */
28
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
29
+
30
+ /** Converte um número opcional do header (default 1). */
31
+ function countOf(value: string | undefined): number {
32
+ const parsed = value ? Number.parseInt(value, 10) : 1;
33
+ return Number.isNaN(parsed) ? 1 : parsed;
34
+ }
35
+
36
+ /** Fatia um patch unificado por arquivo em hunks. */
37
+ export function parseHunks(patch: string): AstHunk[] {
38
+ const hunks: AstHunk[] = [];
39
+ let current: AstHunk | null = null;
40
+ let newLine = 0;
41
+
42
+ for (const line of patch.split("\n")) {
43
+ if (line.startsWith("@@ ")) {
44
+ const match = HUNK_HEADER.exec(line);
45
+ if (!match) {
46
+ current = null;
47
+ continue;
48
+ }
49
+ current = {
50
+ oldStart: Number.parseInt(match[1], 10),
51
+ oldLines: countOf(match[2]),
52
+ newStart: Number.parseInt(match[3], 10),
53
+ newLines: countOf(match[4]),
54
+ addedLines: [],
55
+ };
56
+ newLine = current.newStart;
57
+ hunks.push(current);
58
+ continue;
59
+ }
60
+ if (!current) continue;
61
+ if (line.startsWith("diff --git ")) {
62
+ current = null;
63
+ continue;
64
+ }
65
+ if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
66
+ if (line.startsWith("+")) {
67
+ current.addedLines.push(newLine);
68
+ newLine++;
69
+ continue;
70
+ }
71
+ if (line.startsWith("-")) continue;
72
+ if (line.startsWith("\\")) continue;
73
+ if (line.startsWith(" ")) newLine++;
74
+ }
75
+
76
+ return hunks;
77
+ }
78
+
79
+ /** Conjunto de linhas novas (1-based) adicionadas pelo patch. */
80
+ export function changedNewLines(patch: string): Set<number> {
81
+ const lines = new Set<number>();
82
+ for (const hunk of parseHunks(patch)) {
83
+ for (const line of hunk.addedLines) lines.add(line);
84
+ }
85
+ return lines;
86
+ }
87
+
88
+ /** Chave estável de um símbolo (espécie + nome). */
89
+ function symbolKey(symbol: AstSymbol): string {
90
+ return `${symbol.kind}:${symbol.name}`;
91
+ }
92
+
93
+ /** Indexa símbolos por chave. */
94
+ function indexSymbols(symbols: readonly AstSymbol[]): Map<string, AstSymbol> {
95
+ const map = new Map<string, AstSymbol>();
96
+ for (const symbol of symbols) map.set(symbolKey(symbol), symbol);
97
+ return map;
98
+ }
99
+
100
+ /** `true` se alguma linha alterada cai no intervalo do símbolo. */
101
+ function touches(symbol: AstSymbol, changed: ReadonlySet<number>): boolean {
102
+ for (const line of changed) {
103
+ if (line >= symbol.startLine && line <= symbol.endLine) return true;
104
+ }
105
+ return false;
106
+ }
107
+
108
+ /** Fonte dos imports (deduplicada e ordenada). */
109
+ function importSources(facts: AstFacts | null): Set<string> {
110
+ return new Set((facts?.imports ?? []).map((item) => item.source));
111
+ }
112
+
113
+ /** Nomes dos exports (deduplicados e ordenados). */
114
+ function exportNames(facts: AstFacts | null): Set<string> {
115
+ return new Set((facts?.exports ?? []).map((item) => item.name));
116
+ }
117
+
118
+ /** Compara dois conjuntos e devolve o que foi adicionado/removido. */
119
+ function setDelta(
120
+ before: Set<string>,
121
+ after: Set<string>,
122
+ ): {
123
+ added: string[];
124
+ removed: string[];
125
+ } {
126
+ const added = [...after].filter((item) => !before.has(item)).sort();
127
+ const removed = [...before].filter((item) => !after.has(item)).sort();
128
+ return { added, removed };
129
+ }
130
+
131
+ /** Delta vazio (arquivos sem alteração estrutural). */
132
+ export function emptyDelta(): AstDelta {
133
+ return {
134
+ addedSymbols: [],
135
+ removedSymbols: [],
136
+ modifiedSymbols: [],
137
+ changedSignatures: [],
138
+ addedImports: [],
139
+ removedImports: [],
140
+ changedExports: [],
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Compara a versão base com a atual e o patch do arquivo.
146
+ * `before`/`after` ausentes representam arquivo adicionado/deletado.
147
+ */
148
+ export function computeDelta(
149
+ before: AstFacts | null,
150
+ after: AstFacts | null,
151
+ patch: string,
152
+ ): AstDelta {
153
+ if (!before && !after) return emptyDelta();
154
+
155
+ const changed = changedNewLines(patch);
156
+ const beforeSymbols = indexSymbols(before?.symbols ?? []);
157
+ const afterSymbols = indexSymbols(after?.symbols ?? []);
158
+ const beforeExportNames = exportNames(before);
159
+ const afterExportNames = exportNames(after);
160
+ const withExport = (symbol: AstSymbol, names: ReadonlySet<string>): AstSymbol =>
161
+ symbol.exported || names.has(symbol.name)
162
+ ? { ...symbol, exported: true }
163
+ : symbol;
164
+
165
+ const addedSymbols: AstSymbol[] = [];
166
+ const removedSymbols: AstSymbol[] = [];
167
+ const modifiedSymbols: AstSymbol[] = [];
168
+ const changedSignatures: AstSignatureChange[] = [];
169
+
170
+ for (const [key, symbol] of afterSymbols) {
171
+ const previous = beforeSymbols.get(key);
172
+ if (!previous) {
173
+ addedSymbols.push(withExport(symbol, afterExportNames));
174
+ continue;
175
+ }
176
+ if (touches(symbol, changed)) {
177
+ modifiedSymbols.push(withExport(symbol, afterExportNames));
178
+ }
179
+ if (
180
+ (previous.signature || symbol.signature) &&
181
+ previous.signature !== symbol.signature
182
+ ) {
183
+ changedSignatures.push({
184
+ name: symbol.name,
185
+ before: previous.signature ?? "",
186
+ after: symbol.signature ?? "",
187
+ exported:
188
+ symbol.exported ||
189
+ previous.exported ||
190
+ beforeExportNames.has(symbol.name) ||
191
+ afterExportNames.has(symbol.name),
192
+ });
193
+ }
194
+ }
195
+ for (const [key, symbol] of beforeSymbols) {
196
+ if (!afterSymbols.has(key)) {
197
+ removedSymbols.push(withExport(symbol, beforeExportNames));
198
+ }
199
+ }
200
+
201
+ const imports = setDelta(importSources(before), importSources(after));
202
+ const exports = setDelta(exportNames(before), exportNames(after));
203
+ const changedExports: AstExportChange[] = [
204
+ ...exports.added.map((name) => ({ name, change: "added" as const })),
205
+ ...exports.removed.map((name) => ({ name, change: "removed" as const })),
206
+ ];
207
+
208
+ const byPosition = (a: AstSymbol, b: AstSymbol): number =>
209
+ a.startLine - b.startLine || a.name.localeCompare(b.name);
210
+
211
+ return {
212
+ addedSymbols: addedSymbols.sort(byPosition),
213
+ removedSymbols: removedSymbols.sort(byPosition),
214
+ modifiedSymbols: modifiedSymbols.sort(byPosition),
215
+ changedSignatures: changedSignatures.sort((a, b) => a.name.localeCompare(b.name)),
216
+ addedImports: imports.added,
217
+ removedImports: imports.removed,
218
+ changedExports,
219
+ };
220
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Impacto estrutural (biblioteca).
3
+ *
4
+ * Resolve imports relativos/aliases e monta o mapa reverso (quem importa os
5
+ * arquivos alterados) e os exports públicos que mudaram. O serviço decide como
6
+ * descobrir candidatos; aqui ficam apenas as funções puras.
7
+ */
8
+
9
+ import { posix } from "node:path";
10
+ import type { AstAnalysis, AstImpact } from "./types";
11
+
12
+ /** Testa se um caminho existe (working tree ou índice do git). */
13
+ export type PathExists = (path: string) => boolean;
14
+
15
+ /** Extensões tentadas ao resolver um import sem extensão. */
16
+ const CANDIDATE_EXTENSIONS = [".ts", ".tsx", ".d.ts", ".js", ".jsx", ".mjs", ".cjs"];
17
+
18
+ /** Índices de diretório tentados. */
19
+ const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx"];
20
+
21
+ /** Remove query/hash de um specifier. */
22
+ function stripQuery(source: string): string {
23
+ const index = source.search(/[?#]/);
24
+ return index >= 0 ? source.slice(0, index) : source;
25
+ }
26
+
27
+ /** Remove a extensão de runtime usada em imports ESM (`./x.js` → `./x`). */
28
+ function stripRuntimeExtension(source: string): string {
29
+ return source.replace(/\.(?:js|jsx|mjs|cjs)$/i, "");
30
+ }
31
+
32
+ /** Normaliza uma chave de alias (`@/*` / `@/` → `@/`). */
33
+ function normalizeAliasKey(key: string): string {
34
+ const withoutWildcard = key.endsWith("*") ? key.slice(0, -1) : key;
35
+ return withoutWildcard.endsWith("/") ? withoutWildcard : `${withoutWildcard}/`;
36
+ }
37
+
38
+ /** Normaliza o alvo do alias (`src/*` → `src/`). */
39
+ function normalizeAliasTarget(target: string): string {
40
+ const withoutWildcard = target.endsWith("*") ? target.slice(0, -1) : target;
41
+ return withoutWildcard.replace(/\/+$/, "");
42
+ }
43
+
44
+ /**
45
+ * Resolve um specifier para um caminho relativo à raiz do repo.
46
+ * `null` quando é pacote externo, alias desconhecido ou candidato inexistente.
47
+ */
48
+ export function resolveImport(
49
+ fromFile: string,
50
+ source: string,
51
+ aliases: Record<string, string>,
52
+ exists: PathExists,
53
+ ): string | null {
54
+ const clean = stripRuntimeExtension(stripQuery(source.trim()));
55
+ if (!clean) return null;
56
+
57
+ let base: string | null = null;
58
+ if (clean.startsWith(".")) {
59
+ base = posix.normalize(posix.join(posix.dirname(fromFile), clean));
60
+ } else {
61
+ for (const [rawKey, rawTarget] of Object.entries(aliases)) {
62
+ const key = normalizeAliasKey(rawKey);
63
+ if (!clean.startsWith(key)) continue;
64
+ const target = normalizeAliasTarget(rawTarget);
65
+ const rest = clean.slice(key.length);
66
+ base = posix.normalize(posix.join(target, rest));
67
+ break;
68
+ }
69
+ }
70
+ if (!base) return null;
71
+
72
+ const candidates = [base];
73
+ for (const extension of CANDIDATE_EXTENSIONS) candidates.push(`${base}${extension}`);
74
+ for (const index of INDEX_FILES) candidates.push(posix.join(base, index));
75
+
76
+ for (const candidate of candidates) {
77
+ const normalized = candidate.replace(/^\.\//, "");
78
+ if (exists(normalized)) return normalized;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Monta o impacto a partir das análises dos arquivos alterados e do mapa de
85
+ * importadores descoberto pelo serviço (`arquivo alterado → importadores`).
86
+ */
87
+ export function buildImpact(
88
+ analyses: ReadonlyMap<string, AstAnalysis>,
89
+ importersByFile: ReadonlyMap<string, ReadonlySet<string>>,
90
+ ): AstImpact {
91
+ const importers: Record<string, string[]> = {};
92
+ const affected = new Set<string>();
93
+ const exportedChanges: AstImpact["exportedChanges"] = [];
94
+
95
+ for (const [file, files] of importersByFile) {
96
+ const sorted = [...files].sort();
97
+ if (sorted.length === 0) continue;
98
+ importers[file] = sorted;
99
+ for (const importer of sorted) affected.add(importer);
100
+ }
101
+
102
+ for (const [file, analysis] of analyses) {
103
+ const delta = analysis.delta;
104
+ if (!delta) continue;
105
+ for (const symbol of delta.removedSymbols) {
106
+ if (!symbol.exported) continue;
107
+ exportedChanges.push({ file, symbol: symbol.name, change: "removed" });
108
+ }
109
+ for (const change of delta.changedSignatures) {
110
+ if (!change.exported) continue;
111
+ exportedChanges.push({ file, symbol: change.name, change: "signature" });
112
+ }
113
+ }
114
+
115
+ exportedChanges.sort(
116
+ (a, b) =>
117
+ a.file.localeCompare(b.file) ||
118
+ a.symbol.localeCompare(b.symbol) ||
119
+ a.change.localeCompare(b.change),
120
+ );
121
+
122
+ return {
123
+ importers,
124
+ affectedFiles: [...affected].sort(),
125
+ exportedChanges,
126
+ };
127
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Parser tree-sitter (biblioteca).
3
+ *
4
+ * Inicialização lazy e por linguagem: o wasm só é carregado quando existe um
5
+ * arquivo compatível. Qualquer falha (grammar ausente, ABI incompatível, arquivo
6
+ * grande/binário) devolve `null` — o chamador segue sem AST.
7
+ */
8
+
9
+ import { existsSync, readFileSync, statSync } from "node:fs";
10
+ import { extname, join } from "node:path";
11
+ import { Language, Parser } from "web-tree-sitter";
12
+ import { grammarPath } from "../assets";
13
+ import { DEFAULT_AST_MAX_FILE_BYTES } from "../defaults";
14
+ import { extractFacts } from "./symbols";
15
+ import type { AstFacts, AstLanguage } from "./types";
16
+
17
+ /** Arquivo wasm de cada linguagem suportada. */
18
+ const GRAMMAR_BY_LANGUAGE: Record<AstLanguage, string> = {
19
+ typescript: "tree-sitter-typescript.wasm",
20
+ tsx: "tree-sitter-tsx.wasm",
21
+ javascript: "tree-sitter-javascript.wasm",
22
+ };
23
+
24
+ /** Extensões reconhecidas por padrão (override via config). */
25
+ const DEFAULT_LANGUAGE_BY_EXTENSION: Record<string, AstLanguage> = {
26
+ ".ts": "typescript",
27
+ ".mts": "typescript",
28
+ ".cts": "typescript",
29
+ ".tsx": "tsx",
30
+ ".jsx": "tsx",
31
+ ".js": "javascript",
32
+ ".mjs": "javascript",
33
+ ".cjs": "javascript",
34
+ };
35
+
36
+ /** Teto default de bytes por arquivo (evita parsear bundles/minificados). */
37
+ export const DEFAULT_MAX_FILE_BYTES = DEFAULT_AST_MAX_FILE_BYTES;
38
+
39
+ /** Opções do parser. */
40
+ export type AstParserOptions = {
41
+ maxFileBytes?: number;
42
+ /** Override de extensão → linguagem (chave com ou sem ponto). */
43
+ languages?: Record<string, AstLanguage>;
44
+ /** Diretório com grammars locais (`.houndsman/core/grammars`) — tem precedência. */
45
+ grammarDir?: string;
46
+ };
47
+
48
+ /** Normaliza a chave de extensão (`ts`/`.ts` → `.ts`). */
49
+ function normalizeExtension(key: string): string {
50
+ const trimmed = key.trim().toLowerCase();
51
+ return trimmed.startsWith(".") ? trimmed : `.${trimmed}`;
52
+ }
53
+
54
+ /**
55
+ * Resolve a linguagem de um arquivo pela extensão. `null` quando não suportado.
56
+ */
57
+ export function languageForPath(
58
+ filePath: string,
59
+ overrides: Record<string, AstLanguage> = {},
60
+ ): AstLanguage | null {
61
+ const extension = extname(filePath).toLowerCase();
62
+ if (!extension) return null;
63
+ const normalized = Object.fromEntries(
64
+ Object.entries(overrides).map(([key, value]) => [normalizeExtension(key), value]),
65
+ );
66
+ return normalized[extension] ?? DEFAULT_LANGUAGE_BY_EXTENSION[extension] ?? null;
67
+ }
68
+
69
+ /** Parser lazy, com uma instância por linguagem. */
70
+ export class AstParser {
71
+ private initPromise: Promise<void> | null = null;
72
+ private readonly languages = new Map<AstLanguage, Language>();
73
+ private readonly parsers = new Map<AstLanguage, Parser>();
74
+ private readonly options: Required<Pick<AstParserOptions, "maxFileBytes">> &
75
+ AstParserOptions;
76
+
77
+ constructor(options: AstParserOptions = {}) {
78
+ this.options = {
79
+ maxFileBytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
80
+ languages: options.languages,
81
+ grammarDir: options.grammarDir,
82
+ };
83
+ }
84
+
85
+ /** Resolve uma grammar: camada do projeto primeiro, pacote como fallback. */
86
+ private grammarFile(name: string): string {
87
+ if (this.options.grammarDir) {
88
+ const local = join(this.options.grammarDir, name);
89
+ if (existsSync(local)) return local;
90
+ }
91
+ return grammarPath(name);
92
+ }
93
+
94
+ /** Inicializa o runtime wasm (idempotente). */
95
+ async init(): Promise<void> {
96
+ if (!this.initPromise) {
97
+ this.initPromise = Parser.init({
98
+ locateFile: (name: string) => this.grammarFile(name),
99
+ });
100
+ }
101
+ await this.initPromise;
102
+ }
103
+
104
+ /** Parser configurado para a linguagem (carrega a grammar sob demanda). */
105
+ private async parserFor(language: AstLanguage): Promise<Parser> {
106
+ const existing = this.parsers.get(language);
107
+ if (existing) return existing;
108
+
109
+ await this.init();
110
+ const loaded = await Language.load(
111
+ this.grammarFile(GRAMMAR_BY_LANGUAGE[language]),
112
+ );
113
+ const parser = new Parser();
114
+ parser.setLanguage(loaded);
115
+ this.languages.set(language, loaded);
116
+ this.parsers.set(language, parser);
117
+ return parser;
118
+ }
119
+
120
+ /** Parseia um texto e devolve os fatos; `null` se a linguagem falhar. */
121
+ async parseSource(language: AstLanguage, source: string): Promise<AstFacts | null> {
122
+ try {
123
+ const parser = await this.parserFor(language);
124
+ const tree = parser.parse(source);
125
+ if (!tree) return null;
126
+ try {
127
+ return extractFacts(tree.rootNode, language);
128
+ } finally {
129
+ tree.delete();
130
+ }
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Parseia um arquivo do working tree. `source` permite reaproveitar conteúdo
138
+ * já lido; sem ele, o arquivo é lido do disco.
139
+ */
140
+ async parseFile(filePath: string, source?: string): Promise<AstFacts | null> {
141
+ const language = languageForPath(filePath, this.options.languages);
142
+ if (!language) return null;
143
+
144
+ let content = source;
145
+ if (content === undefined) {
146
+ try {
147
+ const size = statSync(filePath).size;
148
+ if (size <= 0 || size > this.options.maxFileBytes) return null;
149
+ content = readFileSync(filePath, "utf8");
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+ if (content.length > this.options.maxFileBytes || content.includes("\0")) {
155
+ return null;
156
+ }
157
+ return this.parseSource(language, content);
158
+ }
159
+
160
+ /** Libera parsers/grammars (usado em testes). */
161
+ dispose(): void {
162
+ for (const parser of this.parsers.values()) parser.delete();
163
+ this.parsers.clear();
164
+ this.languages.clear();
165
+ this.initPromise = null;
166
+ }
167
+ }