@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,233 @@
1
+ /**
2
+ * Serviço de AST (biblioteca) — fachada usada pelo engine/pipeline.
3
+ *
4
+ * Concentra parse, delta e impacto. É **fail-open**: sem grammar, arquivo
5
+ * grande/binário, erro de git ou ABI incompatível, o resultado é vazio e a
6
+ * revisão segue pelo caminho de regex.
7
+ */
8
+
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { basename, dirname, extname, join } from "node:path";
11
+ import type { HoundsmanConfig } from "../config";
12
+ import type { DiffSource } from "../diff-source";
13
+ import { CORE_DIR } from "../paths";
14
+ import { ProjectConfig } from "../project-config";
15
+ import { GitRepository } from "../services/git-repository";
16
+ import { computeDelta } from "./diff";
17
+ import { buildImpact, resolveImport } from "./impact";
18
+ import { AstParser, languageForPath } from "./parser";
19
+ import type { AstAnalysis, AstEntry, AstFacts, AstImpact } from "./types";
20
+
21
+ /** Padrões de arquivos candidatos para a busca reversa de imports. */
22
+ const GREP_PATHSPECS = ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs"];
23
+
24
+ /** Serviço de análise estrutural por AST. */
25
+ export class AstService {
26
+ private readonly project: ProjectConfig;
27
+ private readonly git: GitRepository;
28
+ private readonly parser: AstParser;
29
+
30
+ constructor(config: HoundsmanConfig, git: GitRepository = new GitRepository()) {
31
+ this.project = new ProjectConfig(config);
32
+ this.git = git;
33
+ const layerGrammars = join(process.cwd(), CORE_DIR, "grammars");
34
+ this.parser = new AstParser({
35
+ maxFileBytes: this.project.astMaxFileBytes,
36
+ languages: this.project.ast.languages,
37
+ grammarDir: existsSync(layerGrammars) ? layerGrammars : undefined,
38
+ });
39
+ }
40
+
41
+ /** `true` quando o AST está habilitado na configuração. */
42
+ get enabled(): boolean {
43
+ return this.project.astEnabled;
44
+ }
45
+
46
+ /**
47
+ * Smoke test do runtime/grammar (usado pelo `doctor`). Não lança: devolve o
48
+ * diagnóstico para o chamador decidir. Fail-open.
49
+ */
50
+ async check(): Promise<{ ok: boolean; detail: string }> {
51
+ if (!this.enabled) return { ok: true, detail: "desabilitado na config" };
52
+ const facts = await this.parser.parseSource(
53
+ "typescript",
54
+ "export const houndsmanAstCheck = 1;\n",
55
+ );
56
+ return facts
57
+ ? { ok: true, detail: "typescript disponível" }
58
+ : { ok: false, detail: "grammar indisponível ou ABI incompatível" };
59
+ }
60
+
61
+ /**
62
+ * Analisa os arquivos alterados: fatos da versão atual, delta contra a base
63
+ * e (para deletados) os fatos da última versão disponível.
64
+ */
65
+ async analyze(
66
+ entries: readonly AstEntry[],
67
+ diff: DiffSource,
68
+ baseRef: string,
69
+ ): Promise<Map<string, AstAnalysis>> {
70
+ const analyses = new Map<string, AstAnalysis>();
71
+ if (!this.enabled) return analyses;
72
+
73
+ for (const entry of entries) {
74
+ const language = languageForPath(entry.path, this.project.ast.languages);
75
+ if (!language) continue;
76
+ const patch = diff.byFile.get(entry.path) ?? "";
77
+
78
+ if (entry.status === "deleted") {
79
+ const oldContent = this.readAt(baseRef, entry.path);
80
+ const facts = await this.parseContent(entry.path, oldContent);
81
+ if (facts) {
82
+ analyses.set(entry.path, {
83
+ facts,
84
+ delta: computeDelta(facts, null, patch),
85
+ deleted: true,
86
+ });
87
+ }
88
+ continue;
89
+ }
90
+
91
+ const current = this.readWorkingFile(entry.path);
92
+ if (current === null) continue;
93
+ const facts = await this.parser.parseFile(entry.path, current);
94
+ if (!facts) continue;
95
+
96
+ const oldPath = entry.oldPath ?? entry.path;
97
+ const oldContent =
98
+ entry.status === "added" ? null : this.readAt(baseRef, oldPath);
99
+ const before = oldContent
100
+ ? await this.parseContent(oldPath, oldContent)
101
+ : null;
102
+
103
+ analyses.set(entry.path, {
104
+ facts,
105
+ delta: computeDelta(before, facts, patch),
106
+ });
107
+ }
108
+
109
+ return analyses;
110
+ }
111
+
112
+ /**
113
+ * Descobre importadores dos arquivos alterados. A busca é heurística e
114
+ * limitada por `ast.maxImpactFiles`: usa `git grep` pelo nome do módulo,
115
+ * depois confirma o import resolvido (relativo/alias) no candidato.
116
+ */
117
+ async impact(analyses: ReadonlyMap<string, AstAnalysis>): Promise<AstImpact> {
118
+ const changed = [...analyses.keys()];
119
+ const importersByFile = new Map<string, Set<string>>();
120
+ for (const file of changed) importersByFile.set(file, new Set<string>());
121
+ if (!this.enabled || changed.length === 0) {
122
+ return buildImpact(analyses, importersByFile);
123
+ }
124
+
125
+ const maxCandidates = this.project.astMaxImpactFiles;
126
+ if (maxCandidates <= 0) return buildImpact(analyses, importersByFile);
127
+
128
+ const changedSet = new Set(changed);
129
+ const candidates = this.candidateFiles(changed, maxCandidates);
130
+ const cache = new Map<string, AstFacts | null>();
131
+
132
+ for (const candidate of candidates) {
133
+ const content = this.readWorkingFile(candidate);
134
+ if (content === null) continue;
135
+ let facts: AstFacts | null;
136
+ if (cache.has(candidate)) {
137
+ facts = cache.get(candidate) ?? null;
138
+ } else {
139
+ facts = await this.parser.parseFile(candidate, content);
140
+ cache.set(candidate, facts);
141
+ }
142
+ if (!facts) continue;
143
+
144
+ for (const item of facts.imports) {
145
+ const resolved = resolveImport(
146
+ candidate,
147
+ item.source,
148
+ this.project.ast.aliases ?? {},
149
+ (path) => existsSync(path) || changedSet.has(path),
150
+ );
151
+ if (!resolved) continue;
152
+ const importers = importersByFile.get(resolved);
153
+ if (importers) importers.add(candidate);
154
+ }
155
+ }
156
+
157
+ return buildImpact(analyses, importersByFile);
158
+ }
159
+
160
+ /** Fatos estruturais do arquivo atual (usado pelas tools do harness). */
161
+ async factsOf(filePath: string): Promise<AstFacts | null> {
162
+ if (!this.enabled) return null;
163
+ const content = this.readWorkingFile(filePath);
164
+ if (content === null) return null;
165
+ return this.parser.parseFile(filePath, content);
166
+ }
167
+
168
+ /** Lê o conteúdo atual do arquivo; `null` se ausente/ilegível. */
169
+ private readWorkingFile(filePath: string): string | null {
170
+ try {
171
+ return readFileSync(filePath, "utf8");
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /** Lê o conteúdo de um arquivo numa ref; `null` se ausente. */
178
+ private readAt(ref: string, filePath: string): string | null {
179
+ if (!ref) return null;
180
+ try {
181
+ return this.git.readFileAt(ref, filePath);
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /** Parseia conteúdo textual reaproveitando a linguagem do caminho. */
188
+ private async parseContent(
189
+ filePath: string,
190
+ content: string | null,
191
+ ): Promise<AstFacts | null> {
192
+ if (!content) return null;
193
+ const language = languageForPath(filePath, this.project.ast.languages);
194
+ if (!language) return null;
195
+ return this.parser.parseSource(language, content);
196
+ }
197
+
198
+ /** Candidatos a importadores via `git grep` pelo nome do módulo. */
199
+ private candidateFiles(changed: readonly string[], maxCandidates: number): string[] {
200
+ const candidates = new Set<string>();
201
+ for (const file of changed) {
202
+ for (const needle of needlesFor(file)) {
203
+ const result = this.git.tryRun([
204
+ "grep",
205
+ "-l",
206
+ "-I",
207
+ "-F",
208
+ "-e",
209
+ needle,
210
+ "--",
211
+ ...GREP_PATHSPECS,
212
+ ]);
213
+ for (const line of result.stdout.split("\n")) {
214
+ const candidate = line.trim().replace(/\\/g, "/");
215
+ if (!candidate) continue;
216
+ candidates.add(candidate);
217
+ if (candidates.size >= maxCandidates) return [...candidates];
218
+ }
219
+ }
220
+ }
221
+ return [...candidates].sort();
222
+ }
223
+ }
224
+
225
+ /** Nomes usados para localizar importadores de um arquivo. */
226
+ function needlesFor(filePath: string): string[] {
227
+ const extension = extname(filePath);
228
+ const withoutExtension = extension ? filePath.slice(0, -extension.length) : filePath;
229
+ const base = basename(withoutExtension);
230
+ if (base !== "index") return base.length >= 2 ? [base] : [];
231
+ const directory = basename(dirname(filePath));
232
+ return directory.length >= 2 ? [directory] : [];
233
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Extração de fatos estruturais (biblioteca).
3
+ *
4
+ * Usa a árvore do tree-sitter sem depender da API de queries: percorre os nós
5
+ * nomeados e extrai imports, exports, declarações, assinaturas e decorators.
6
+ * Tolerante a sintaxe inválida — o que der para ler vira fato; o resto é
7
+ * ignorado.
8
+ */
9
+
10
+ import type { Node } from "web-tree-sitter";
11
+ import type {
12
+ AstExport,
13
+ AstFacts,
14
+ AstImport,
15
+ AstLanguage,
16
+ AstSymbol,
17
+ AstSymbolKind,
18
+ } from "./types";
19
+
20
+ /** Tipos de nó que viram símbolo, com a espécie correspondente. */
21
+ const DECLARATION_KINDS: Record<string, AstSymbolKind> = {
22
+ function_declaration: "function",
23
+ generator_function_declaration: "function",
24
+ class_declaration: "class",
25
+ interface_declaration: "interface",
26
+ type_alias_declaration: "type",
27
+ enum_declaration: "enum",
28
+ method_definition: "method",
29
+ method_signature: "method",
30
+ abstract_method_signature: "method",
31
+ public_field_definition: "property",
32
+ property_signature: "property",
33
+ };
34
+
35
+ /** Tipos de contêiner usados para qualificar métodos (`Classe.método`). */
36
+ const OWNER_TYPES = ["class_declaration", "interface_declaration"];
37
+
38
+ /** Percorre a árvore em profundidade, visitando nós nomeados. */
39
+ function walk(node: Node, visit: (node: Node) => void): void {
40
+ visit(node);
41
+ for (const child of node.namedChildren) walk(child, visit);
42
+ }
43
+
44
+ /** Remove aspas simples/duplas de um literal de string. */
45
+ function unquote(value: string): string {
46
+ const trimmed = value.trim();
47
+ if (trimmed.length < 2) return trimmed;
48
+ const first = trimmed[0];
49
+ const last = trimmed[trimmed.length - 1];
50
+ if ((first === '"' || first === "'") && last === first) {
51
+ return trimmed.slice(1, -1);
52
+ }
53
+ return trimmed;
54
+ }
55
+
56
+ /** Colapsa espaços em branco e trunca em `max` caracteres. */
57
+ function collapse(value: string, max = 240): string {
58
+ const normalized = value.replace(/\s+/g, " ").trim();
59
+ return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
60
+ }
61
+
62
+ /** Sobe a árvore até encontrar um nó de um dos tipos informados. */
63
+ function findAncestor(node: Node, types: readonly string[]): Node | null {
64
+ let current = node.parent;
65
+ while (current) {
66
+ if (types.includes(current.type)) return current;
67
+ current = current.parent;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /** `true` quando a declaração está sob um `export_statement`. */
73
+ function isExported(node: Node): boolean {
74
+ return findAncestor(node, ["export_statement"]) !== null;
75
+ }
76
+
77
+ /** Nome da declaração (campo `name`). */
78
+ function nameOf(node: Node): string | undefined {
79
+ return node.childForFieldName("name")?.text?.trim() || undefined;
80
+ }
81
+
82
+ /** Assinatura normalizada de função/método/arrow (parâmetros + retorno). */
83
+ function signatureOf(node: Node, name: string): string | undefined {
84
+ const params = node.childForFieldName("parameters")?.text ?? "";
85
+ const returnType = node.childForFieldName("return_type")?.text ?? "";
86
+ if (!params && !returnType) return undefined;
87
+ return collapse(`${name}${params}${returnType}`);
88
+ }
89
+
90
+ /** Nome qualificado de métodos/propriedades (`Classe.membro`). */
91
+ function qualifiedName(node: Node, name: string): string {
92
+ const owner = findAncestor(node, OWNER_TYPES);
93
+ const ownerName = owner ? nameOf(owner) : undefined;
94
+ return ownerName ? `${ownerName}.${name}` : name;
95
+ }
96
+
97
+ /** Adiciona um símbolo evitando duplicatas por espécie + nome. */
98
+ function addSymbol(map: Map<string, AstSymbol>, symbol: AstSymbol): void {
99
+ const key = `${symbol.kind}:${symbol.name}`;
100
+ const existing = map.get(key);
101
+ if (
102
+ !existing ||
103
+ symbol.endLine - symbol.startLine > existing.endLine - existing.startLine
104
+ ) {
105
+ map.set(key, symbol);
106
+ }
107
+ }
108
+
109
+ /** Extrai nomes de um `import_specifier` (importado e alias local). */
110
+ function importSpecifierNames(node: Node, names: Set<string>): void {
111
+ const imported = node.childForFieldName("name")?.text?.trim();
112
+ const alias = node.childForFieldName("alias")?.text?.trim();
113
+ if (imported) names.add(imported);
114
+ if (alias) names.add(alias);
115
+ }
116
+
117
+ /** Nomes locais de um `import_clause` (default/namespace). */
118
+ function importClauseNames(node: Node, names: Set<string>): void {
119
+ for (const child of node.namedChildren) {
120
+ if (child.type === "identifier") {
121
+ names.add(child.text.trim());
122
+ continue;
123
+ }
124
+ if (child.type === "namespace_import") {
125
+ const local = child.namedChildren.find((item) => item.type === "identifier");
126
+ if (local) names.add(local.text.trim());
127
+ }
128
+ }
129
+ }
130
+
131
+ /** Imports (`import`, `require` e `import()` dinâmico). */
132
+ function collectImports(root: Node): AstImport[] {
133
+ const imports: AstImport[] = [];
134
+ walk(root, (node) => {
135
+ if (node.type === "import_statement") {
136
+ const sourceNode = node.childForFieldName("source");
137
+ const source = sourceNode ? unquote(sourceNode.text) : "";
138
+ if (!source) return;
139
+ const names = new Set<string>();
140
+ for (const child of node.descendantsOfType("import_specifier")) {
141
+ importSpecifierNames(child, names);
142
+ }
143
+ for (const child of node.descendantsOfType("import_clause")) {
144
+ importClauseNames(child, names);
145
+ }
146
+ imports.push({
147
+ source,
148
+ names: [...names],
149
+ typeOnly: /^import\s+type\b/.test(node.text.trim()),
150
+ });
151
+ return;
152
+ }
153
+
154
+ if (node.type !== "call_expression") return;
155
+ const fn = node.childForFieldName("function")?.text?.trim();
156
+ if (fn !== "require" && fn !== "import") return;
157
+ const args = node.childForFieldName("arguments");
158
+ const first = args?.namedChildren[0];
159
+ if (!first || first.type !== "string") return;
160
+ const source = unquote(first.text);
161
+ if (!source) return;
162
+ const names = new Set<string>();
163
+ const declarator = node.parent;
164
+ if (declarator?.type === "variable_declarator") {
165
+ const name = declarator.childForFieldName("name")?.text?.trim();
166
+ if (name) names.add(name);
167
+ }
168
+ imports.push({ source, names: [...names], typeOnly: false });
169
+ });
170
+ return imports;
171
+ }
172
+
173
+ /** Declarações (funções, classes, métodos, variáveis, tipos…). */
174
+ function collectSymbols(root: Node): AstSymbol[] {
175
+ const symbols = new Map<string, AstSymbol>();
176
+
177
+ walk(root, (node) => {
178
+ // Declarações locais (dentro de função/bloco) não são símbolos de topo.
179
+ if (findAncestor(node, ["statement_block"]) !== null) return;
180
+
181
+ if (node.type === "lexical_declaration" || node.type === "variable_declaration") {
182
+ for (const declarator of node.namedChildren) {
183
+ if (declarator.type !== "variable_declarator") continue;
184
+ const name = nameOf(declarator);
185
+ if (!name || name.startsWith("{") || name.startsWith("[")) continue;
186
+ const value = declarator.childForFieldName("value");
187
+ const kind: AstSymbolKind =
188
+ value?.type === "arrow_function" ||
189
+ value?.type === "function_expression"
190
+ ? "function"
191
+ : "variable";
192
+ addSymbol(symbols, {
193
+ name,
194
+ kind,
195
+ exported: isExported(declarator),
196
+ startLine: declarator.startPosition.row + 1,
197
+ endLine: declarator.endPosition.row + 1,
198
+ signature: value ? signatureOf(value, name) : undefined,
199
+ });
200
+ }
201
+ return;
202
+ }
203
+
204
+ const kind = DECLARATION_KINDS[node.type];
205
+ if (!kind) return;
206
+ const rawName = nameOf(node);
207
+ if (!rawName) return;
208
+ const name =
209
+ kind === "method" || kind === "property"
210
+ ? qualifiedName(node, rawName)
211
+ : rawName;
212
+ const signature =
213
+ kind === "function" || kind === "method"
214
+ ? signatureOf(node, name)
215
+ : kind === "interface" || kind === "type"
216
+ ? collapse(node.text)
217
+ : undefined;
218
+ addSymbol(symbols, {
219
+ name,
220
+ kind,
221
+ exported: isExported(node),
222
+ startLine: node.startPosition.row + 1,
223
+ endLine: node.endPosition.row + 1,
224
+ signature,
225
+ });
226
+ });
227
+
228
+ return [...symbols.values()].sort(
229
+ (a, b) => a.startLine - b.startLine || a.name.localeCompare(b.name),
230
+ );
231
+ }
232
+
233
+ /** Exports declarados (inclui `export {}` e re-exports). */
234
+ function collectExports(root: Node, symbols: readonly AstSymbol[]): AstExport[] {
235
+ const exports = new Map<string, AstExport>();
236
+
237
+ for (const symbol of symbols) {
238
+ if (symbol.exported)
239
+ exports.set(symbol.name, { name: symbol.name, kind: symbol.kind });
240
+ }
241
+
242
+ walk(root, (node) => {
243
+ if (node.type !== "export_statement") return;
244
+ const before = exports.size;
245
+ const clause = node.descendantsOfType("export_clause")[0];
246
+ if (clause) {
247
+ for (const specifier of clause.descendantsOfType("export_specifier")) {
248
+ const imported = specifier.childForFieldName("name")?.text?.trim();
249
+ const alias = specifier.childForFieldName("alias")?.text?.trim();
250
+ const name = alias ?? imported;
251
+ if (name) exports.set(name, { name, kind: "unknown" });
252
+ }
253
+ }
254
+ const source = node.childForFieldName("source");
255
+ if (source && !clause) {
256
+ exports.set("*", { name: "*", kind: "re-export" });
257
+ }
258
+ if (
259
+ !clause &&
260
+ /^export\s+default\b/.test(node.text.trim()) &&
261
+ exports.size === before
262
+ ) {
263
+ exports.set("default", { name: "default", kind: "unknown" });
264
+ }
265
+ });
266
+
267
+ return [...exports.values()].sort((a, b) => a.name.localeCompare(b.name));
268
+ }
269
+
270
+ /** Decorators normalizados (`@Controller` → `Controller`). */
271
+ function collectDecorators(root: Node): string[] {
272
+ const decorators = new Set<string>();
273
+ walk(root, (node) => {
274
+ if (node.type !== "decorator") return;
275
+ const match = /^@\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)/.exec(
276
+ node.text.trim(),
277
+ );
278
+ if (match) decorators.add(match[1]);
279
+ });
280
+ return [...decorators].sort();
281
+ }
282
+
283
+ /** Extrai todos os fatos de uma árvore já parseada. */
284
+ export function extractFacts(root: Node, language: AstLanguage): AstFacts {
285
+ const symbols = collectSymbols(root);
286
+ return {
287
+ language,
288
+ symbols,
289
+ imports: collectImports(root),
290
+ exports: collectExports(root, symbols),
291
+ decorators: collectDecorators(root),
292
+ hasSyntaxErrors: root.hasError,
293
+ };
294
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Tipos do subsistema AST (biblioteca).
3
+ *
4
+ * O AST é uma camada determinística de enriquecimento: fatos estruturais por
5
+ * arquivo, delta entre duas versões e impacto (importadores). Nunca substitui a
6
+ * classificação por regex — apenas a complementa quando a grammar está
7
+ * disponível.
8
+ */
9
+
10
+ /** Linguagens suportadas na primeira fase (TS/JS). */
11
+ export type AstLanguage = "typescript" | "tsx" | "javascript";
12
+
13
+ /** Espécie de símbolo extraído do arquivo. */
14
+ export type AstSymbolKind =
15
+ | "function"
16
+ | "class"
17
+ | "method"
18
+ | "interface"
19
+ | "type"
20
+ | "enum"
21
+ | "variable"
22
+ | "property"
23
+ | "unknown";
24
+
25
+ /** Símbolo declarado no arquivo (nome, espécie, intervalo e assinatura). */
26
+ export type AstSymbol = {
27
+ name: string;
28
+ kind: AstSymbolKind;
29
+ /** `true` quando a declaração está sob um `export_statement`. */
30
+ exported: boolean;
31
+ /** Linha inicial (1-based). */
32
+ startLine: number;
33
+ /** Linha final (1-based). */
34
+ endLine: number;
35
+ /** Assinatura normalizada (parâmetros/retorno), quando aplicável. */
36
+ signature?: string;
37
+ };
38
+
39
+ /** Import declarado no arquivo. */
40
+ export type AstImport = {
41
+ source: string;
42
+ /** Nomes importados/locais (útil para classificação por símbolo). */
43
+ names: string[];
44
+ /** `import type` / `export type`. */
45
+ typeOnly: boolean;
46
+ };
47
+
48
+ /** Export declarado no arquivo (inclui re-exports). */
49
+ export type AstExport = {
50
+ name: string;
51
+ kind: AstSymbolKind | "re-export";
52
+ };
53
+
54
+ /** Fatos estruturais de um arquivo. */
55
+ export type AstFacts = {
56
+ language: AstLanguage;
57
+ symbols: AstSymbol[];
58
+ imports: AstImport[];
59
+ exports: AstExport[];
60
+ decorators: string[];
61
+ /** `true` quando o parser encontrou erro de sintaxe. */
62
+ hasSyntaxErrors: boolean;
63
+ };
64
+
65
+ /** Mudança de assinatura de um símbolo entre duas versões. */
66
+ export type AstSignatureChange = {
67
+ name: string;
68
+ before: string;
69
+ after: string;
70
+ exported: boolean;
71
+ };
72
+
73
+ /** Mudança de export (nome adicionado/removido). */
74
+ export type AstExportChange = {
75
+ name: string;
76
+ change: "added" | "removed";
77
+ };
78
+
79
+ /** Delta estrutural entre a versão base e a versão atual. */
80
+ export type AstDelta = {
81
+ addedSymbols: AstSymbol[];
82
+ removedSymbols: AstSymbol[];
83
+ modifiedSymbols: AstSymbol[];
84
+ changedSignatures: AstSignatureChange[];
85
+ addedImports: string[];
86
+ removedImports: string[];
87
+ changedExports: AstExportChange[];
88
+ };
89
+
90
+ /** Análise de um arquivo alterado: fatos atuais + delta. */
91
+ export type AstAnalysis = {
92
+ facts: AstFacts;
93
+ delta?: AstDelta;
94
+ /** `true` quando o arquivo foi removido (fatos são da versão base). */
95
+ deleted?: boolean;
96
+ };
97
+
98
+ /** Impacto estrutural de um conjunto de alterações. */
99
+ export type AstImpact = {
100
+ /** arquivo alterado → arquivos que o importam (reverse deps). */
101
+ importers: Record<string, string[]>;
102
+ /** União ordenada dos importadores. */
103
+ affectedFiles: string[];
104
+ /** Exports públicos removidos/alterados (candidatos a breaking change). */
105
+ exportedChanges: {
106
+ file: string;
107
+ symbol: string;
108
+ change: "removed" | "signature";
109
+ }[];
110
+ };
111
+
112
+ /** Resumo de AST injetado no brief de um watchdog. */
113
+ export type AstFileBrief = {
114
+ file: string;
115
+ delta: AstDelta;
116
+ /** Arquivos que importam este arquivo (impacto reverso). */
117
+ importers?: readonly string[];
118
+ };
119
+
120
+ /** Entrada mínima para analisar um arquivo alterado. */
121
+ export type AstEntry = {
122
+ path: string;
123
+ oldPath?: string;
124
+ status: string;
125
+ };