From 069844c4086ba0666571007219a34a5225908eac Mon Sep 17 00:00:00 2001 From: saoqixiaomm Date: Wed, 8 Apr 2026 11:08:43 +0800 Subject: [PATCH] feat(scala): add Scala language support and slim down AGENTS.md injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scala support: - Add tree-sitter-scala parser and language config - Implement Scala-specific field, method, and named-binding extractors - Add JVM import resolver support for Scala (wildcard imports, companion objects) - Add Scala type extractor for traits, case classes, objects, and sealed hierarchies - Add Scala export detection and framework detection (Play, Akka, Spark) AGENTS.md / CLAUDE.md injection optimization: - Remove dynamic stats (nodes/edges/processes) from injected block to eliminate diff churn when different users re-run analyze - Slim injected block from ~100 lines to ~25 lines, keeping only MUST-level rules inline and delegating detailed guides to skill files - Add idempotency check in upsertGitNexusSection — skip file write when content is unchanged Made-with: Cursor --- gitnexus-shared/src/language-detection.ts | 2 + gitnexus-shared/src/languages.ts | 1 + gitnexus/package-lock.json | 32 +++ gitnexus/package.json | 3 +- gitnexus/src/cli/ai-context.ts | 134 +++-------- .../src/core/ingestion/entry-point-scoring.ts | 7 + .../src/core/ingestion/export-detection.ts | 32 +++ .../field-extractors/configs/scala.ts | 122 ++++++++++ .../src/core/ingestion/framework-detection.ts | 14 ++ .../src/core/ingestion/import-processor.ts | 2 +- .../core/ingestion/import-resolvers/jvm.ts | 152 ++++++++++++ .../core/ingestion/import-resolvers/utils.ts | 4 + .../src/core/ingestion/languages/index.ts | 2 + .../src/core/ingestion/languages/scala.ts | 122 ++++++++++ .../method-extractors/configs/scala.ts | 162 +++++++++++++ .../core/ingestion/named-bindings/scala.ts | 82 +++++++ gitnexus/src/core/ingestion/pipeline.ts | 1 - .../src/core/ingestion/tree-sitter-queries.ts | 122 ++++++++++ .../src/core/ingestion/type-extractors/jvm.ts | 227 ++++++++++++++++++ .../core/ingestion/workers/parse-worker.ts | 15 +- .../src/core/tree-sitter/parser-loader.ts | 7 + gitnexus/test/unit/ai-context.test.ts | 26 ++ 22 files changed, 1164 insertions(+), 107 deletions(-) create mode 100644 gitnexus/src/core/ingestion/field-extractors/configs/scala.ts create mode 100644 gitnexus/src/core/ingestion/languages/scala.ts create mode 100644 gitnexus/src/core/ingestion/method-extractors/configs/scala.ts create mode 100644 gitnexus/src/core/ingestion/named-bindings/scala.ts diff --git a/gitnexus-shared/src/language-detection.ts b/gitnexus-shared/src/language-detection.ts index d31f9d58d..7ad80fdf9 100644 --- a/gitnexus-shared/src/language-detection.ts +++ b/gitnexus-shared/src/language-detection.ts @@ -42,6 +42,7 @@ const EXTENSION_MAP: Record = { [SupportedLanguages.Swift]: ['.swift'], [SupportedLanguages.Dart]: ['.dart'], [SupportedLanguages.Vue]: ['.vue'], + [SupportedLanguages.Scala]: ['.scala', '.sc'], [SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'], } satisfies Record; // Ensure exhaustiveness @@ -100,6 +101,7 @@ const SYNTAX_MAP: Record = { [SupportedLanguages.Swift]: 'swift', [SupportedLanguages.Dart]: 'dart', [SupportedLanguages.Vue]: 'typescript', + [SupportedLanguages.Scala]: 'scala', [SupportedLanguages.Cobol]: 'cobol', } satisfies Record; // Ensure exhaustiveness diff --git a/gitnexus-shared/src/languages.ts b/gitnexus-shared/src/languages.ts index 29edb8d1e..344f67c10 100644 --- a/gitnexus-shared/src/languages.ts +++ b/gitnexus-shared/src/languages.ts @@ -20,6 +20,7 @@ export enum SupportedLanguages { Swift = 'swift', Dart = 'dart', Vue = 'vue', + Scala = 'scala', /** Standalone regex processor — no tree-sitter, no LanguageProvider. */ Cobol = 'cobol', } diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 9abd15b0e..cd9ab967d 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -38,6 +38,7 @@ "tree-sitter-python": "0.23.4", "tree-sitter-ruby": "^0.23.1", "tree-sitter-rust": "0.23.1", + "tree-sitter-scala": "*", "tree-sitter-typescript": "^0.23.2", "uuid": "^13.0.0" }, @@ -63,6 +64,7 @@ "optionalDependencies": { "tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", + "tree-sitter-scala": "^0.24.0", "tree-sitter-swift": "^0.6.0" } }, @@ -5378,6 +5380,36 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/tree-sitter-scala": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/tree-sitter-scala/-/tree-sitter-scala-0.24.0.tgz", + "integrity": "sha512-vkMuAUrBZ1zZz2XcGDQk18Kz73JkpgaeXzbNVobPke0G35sd9jH32aUxG6OLRKM7et0TbsfqkWf4DeJoGk4K1g==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-scala/node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/tree-sitter-swift": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.6.0.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 7699208e2..f4a4117f2 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -86,10 +86,10 @@ "optionalDependencies": { "tree-sitter-dart": "github:UserNobody14/tree-sitter-dart#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", + "tree-sitter-scala": "^0.24.0", "tree-sitter-swift": "^0.6.0" }, "devDependencies": { - "gitnexus-shared": "file:../gitnexus-shared", "@types/cli-progress": "^3.11.6", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -97,6 +97,7 @@ "@types/node": "^20.0.0", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.0.18", + "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", "typescript": "^5.4.5", "vitest": "^4.0.18" diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 1c7a95d7a..9de0a6644 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -32,15 +32,14 @@ const GITNEXUS_START_MARKER = ''; const GITNEXUS_END_MARKER = ''; /** - * Generate the full GitNexus context content. + * Generate the GitNexus context content for AGENTS.md / CLAUDE.md. * - * Design principles (learned from real agent behavior and industry research): - * - Inline critical workflows — skills are skipped 56% of the time (Vercel eval data) - * - Use RFC 2119 language (MUST, NEVER, ALWAYS) — models follow imperative rules - * - Three-tier boundaries (Always/When/Never) — proven to change model behavior - * - Keep under 120 lines — adherence degrades past 150 lines - * - Exact tool commands with parameters — vague directives get ignored - * - Self-review checklist — forces model to verify its own work + * Design principles: + * - Keep the inline block short (~25 lines) — adherence degrades past 150 lines + * - Inline only MUST-level behavioral constraints that agents must always follow + * - Delegate detailed workflows to skill files (debugging, refactoring, etc.) + * - No dynamic stats — avoids diff churn when different users re-run analyze + * - Use RFC 2119 language (MUST, NEVER) — models follow imperative rules */ async function findGroupsContainingRegistryName(registryName: string): Promise { const { listGroups, getDefaultGitnexusDir, getGroupDir } = @@ -61,7 +60,7 @@ async function findGroupsContainingRegistryName(registryName: string): Promise - `| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`, + `| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/gitnexus/generated/${s.name}/SKILL.md\` |`, ) .join('\n') : ''; - const skillsTable = `| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` | -| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` | -| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` | -| Rename / extract / split / refactor | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` | -| Tools, resources, schema reference | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` | -| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |${generatedRows ? '\n' + generatedRows : ''}`; + const skillsTable = `| Task | Skill file | +|------|-----------| +| Architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` | +| Blast radius / "What breaks?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` | +| Debugging / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` | +| Refactoring / rename / extract | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` | +| Tools, resources, schema | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` | +| CLI commands (index, status, clean) | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |${generatedRows ? '\n' + generatedRows : ''}`; return `${GITNEXUS_START_MARKER} # GitNexus — Code Intelligence -This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **${projectName}**. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. For current stats, inspect \`.gitnexus/meta.json\`. > If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first. -## Always Do +## Critical Rules -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`gitnexus_impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`. - -## When Debugging - -1. \`gitnexus_query({query: ""})\` — find execution flows related to the issue -2. \`gitnexus_context({name: ""})\` — see all callers, callees, and process participation -3. \`READ gitnexus://repo/${projectName}/process/{processName}\` — trace the full execution flow step by step -4. For regressions: \`gitnexus_detect_changes({scope: "compare", base_ref: "main"})\` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with \`dry_run: false\`. -- **Extracting/Splitting**: MUST run \`gitnexus_context({name: "target"})\` to see all incoming/outgoing refs, then \`gitnexus_impact({target: "target", direction: "upstream"})\` to find all external callers before moving code. -- After any refactor: run \`gitnexus_detect_changes({scope: "all"})\` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running \`gitnexus_impact\` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use \`gitnexus_rename\` which understands the call graph. -- NEVER commit changes without running \`gitnexus_detect_changes()\` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| \`query\` | Find code by concept | \`gitnexus_query({query: "auth validation"})\` | -| \`context\` | 360-degree view of one symbol | \`gitnexus_context({name: "validateUser"})\` | -| \`impact\` | Blast radius before editing | \`gitnexus_impact({target: "X", direction: "upstream"})\` | -| \`detect_changes\` | Pre-commit scope check | \`gitnexus_detect_changes({scope: "staged"})\` | -| \`rename\` | Safe multi-file rename | \`gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})\` | -| \`cypher\` | Custom graph queries | \`gitnexus_cypher({query: "MATCH ..."})\` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| \`gitnexus://repo/${projectName}/context\` | Codebase overview, check index freshness | -| \`gitnexus://repo/${projectName}/clusters\` | All functional areas | -| \`gitnexus://repo/${projectName}/processes\` | All execution flows | -| \`gitnexus://repo/${projectName}/process/{name}\` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. \`gitnexus_impact\` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. \`gitnexus_detect_changes()\` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -\`\`\`bash -npx gitnexus analyze -\`\`\` - -If the index previously included embeddings, preserve them by adding \`--embeddings\`: - -\`\`\`bash -npx gitnexus analyze --embeddings -\`\`\` - -To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`stats.embeddings\` field shows the count (0 means no embeddings). **Running analyze without \`--embeddings\` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`. +- **MUST** run \`gitnexus_impact({target: "symbolName", direction: "upstream"})\` before editing any function, class, or method — report blast radius to the user. +- **MUST** run \`gitnexus_detect_changes()\` before committing to verify only expected symbols changed. +- **MUST** warn the user if impact analysis returns HIGH or CRITICAL risk before proceeding. +- **MUST** use \`gitnexus_rename()\` for renames instead of find-and-replace. +- When exploring unfamiliar code, prefer \`gitnexus_query()\` and \`gitnexus_context()\` over grepping. ${ groupNames && groupNames.length > 0 @@ -181,7 +106,7 @@ This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** ` : '' -}## CLI +}## Detailed Guides ${skillsTable} @@ -209,7 +134,7 @@ async function fileExists(filePath: string): Promise { async function upsertGitNexusSection( filePath: string, content: string, -): Promise<'created' | 'updated' | 'appended'> { +): Promise<'created' | 'updated' | 'appended' | 'unchanged'> { const exists = await fileExists(filePath); if (!exists) { @@ -224,6 +149,13 @@ async function upsertGitNexusSection( const endIdx = existingContent.indexOf(GITNEXUS_END_MARKER); if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { + const existingSection = existingContent.substring( + startIdx, + endIdx + GITNEXUS_END_MARKER.length, + ); + if (existingSection === content) { + return 'unchanged'; + } // Replace existing section const before = existingContent.substring(0, startIdx); const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length); diff --git a/gitnexus/src/core/ingestion/entry-point-scoring.ts b/gitnexus/src/core/ingestion/entry-point-scoring.ts index f8d85ae48..d1ff61ebc 100644 --- a/gitnexus/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus/src/core/ingestion/entry-point-scoring.ts @@ -227,6 +227,13 @@ export const ENTRY_POINT_PATTERNS = { /^mapEventToState$/, // Legacy BLoC pattern ], [SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns + [SupportedLanguages.Scala]: [ + /^main$/, // App entry + /^apply$/, // Companion object factory / case class apply + /^receive$/, // Akka actor message handler + /^routes$/, // Play routes entry + /^Action$/, // Play controller action + ], [SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points } satisfies Record; diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 3eb1b4f07..356df424e 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -246,3 +246,35 @@ export const rubyExportChecker: ExportChecker = (_node, _name) => true; /** Dart: public if no leading underscore (convention, same as Python). */ export const dartExportChecker: ExportChecker = (_node, name) => !name.startsWith('_'); + +/** + * Scala: default visibility is public (like Kotlin). + * access_modifier inside modifiers or as direct child marks private/protected. + */ +export const scalaExportChecker: ExportChecker = (node, _name) => { + let current: SyntaxNode | null = node; + while (current) { + if (current.parent) { + for (let i = 0; i < current.parent.childCount; i++) { + const child = current.parent.child(i); + if (child?.type === 'modifiers') { + for (let j = 0; j < child.namedChildCount; j++) { + const mod = child.namedChild(j); + if (mod?.type === 'access_modifier') { + const text = mod.text; + if (text.startsWith('private')) return false; + if (text.startsWith('protected')) return false; + } + } + } + if (child?.type === 'access_modifier') { + const text = child.text; + if (text.startsWith('private')) return false; + if (text.startsWith('protected')) return false; + } + } + } + current = current.parent; + } + return true; +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/scala.ts b/gitnexus/src/core/ingestion/field-extractors/configs/scala.ts new file mode 100644 index 000000000..0b774a2fd --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/scala.ts @@ -0,0 +1,122 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { FieldExtractionConfig } from '../generic.js'; +import { hasKeyword, typeFromField } from './helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { FieldVisibility } from '../../field-types.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +const SCALA_VIS = new Set(['public', 'private', 'protected', 'package']); + +function extractScalaVisibility(node: SyntaxNode): FieldVisibility { + const modifiers = node.childForFieldName('modifiers') ?? node.namedChildren[0] ?? null; + const text = modifiers?.text ?? ''; + if (text.includes('private[') || text.includes('protected[')) return 'package'; + if (text.includes('private')) return 'private'; + if (text.includes('protected')) return 'protected'; + return 'public'; +} + +function isCaseClass(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + if (node.child(i)?.text === 'case') return true; + } + return false; +} + +export const scalaConfig: FieldExtractionConfig = { + language: SupportedLanguages.Scala, + typeDeclarationNodes: [ + 'class_definition', + 'trait_definition', + 'object_definition', + 'enum_definition', + 'package_object', + ], + fieldNodeTypes: ['val_definition', 'val_declaration', 'var_definition', 'var_declaration'], + bodyNodeTypes: ['template_body', 'enum_body'], + defaultVisibility: 'public', + + extractName(node) { + return node.childForFieldName('name')?.text ?? node.childForFieldName('pattern')?.text; + }, + + extractNames(node) { + const directName = node.childForFieldName('name'); + if (directName?.type === 'identifier') return [directName.text]; + const patternNode = node.childForFieldName('pattern'); + if (patternNode?.type === 'identifier') return [patternNode.text]; + const identifiers = + patternNode?.type === 'identifiers' + ? patternNode + : node.namedChildren.find((child) => child.type === 'identifiers'); + if (!identifiers) return []; + const names: string[] = []; + for (let i = 0; i < identifiers.namedChildCount; i++) { + const child = identifiers.namedChild(i); + if (child?.type === 'identifier') names.push(child.text); + } + return names; + }, + + extractType(node) { + const direct = typeFromField(node, 'type'); + if (direct) return direct; + const typeNode = node.childForFieldName('type'); + return typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : undefined; + }, + + extractVisibility(node) { + return extractScalaVisibility(node); + }, + + isStatic(node) { + return node.parent?.parent?.type === 'package_object'; + }, + + isReadonly(node) { + return hasKeyword(node, 'val'); + }, + + extractPrimaryFields(ownerNode, _context) { + if (ownerNode.type !== 'class_definition') return []; + const out: Array<{ + name: string; + type: string | null; + visibility: FieldVisibility; + isStatic: boolean; + isReadonly: boolean; + sourceFile: string; + line: number; + }> = []; + const caseClass = isCaseClass(ownerNode); + for (let i = 0; i < ownerNode.namedChildCount; i++) { + const child = ownerNode.namedChild(i); + if (!child || child.type !== 'class_parameters') continue; + for (let j = 0; j < child.namedChildCount; j++) { + const node = child.namedChild(j); + if (!node || node.type !== 'class_parameter') continue; + const nameNode = node.childForFieldName('name'); + const typeNode = node.childForFieldName('type'); + if (!nameNode) continue; + let isProperty = caseClass; + for (let k = 0; k < node.childCount; k++) { + const c = node.child(k); + if (c?.text === 'val' || c?.text === 'var') isProperty = true; + } + if (!isProperty) continue; + out.push({ + name: nameNode.text, + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + visibility: extractScalaVisibility(node), + isStatic: false, + isReadonly: !hasKeyword(node, 'var'), + sourceFile: _context.filePath, + line: node.startPosition.row + 1, + }); + } + } + return out; + }, +}; diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 9ea43800c..15f93d2eb 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -892,6 +892,20 @@ export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = { }, ], [SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection + [SupportedLanguages.Scala]: [ + { + framework: 'play-framework', + entryPointMultiplier: 2.5, + reason: 'play-controller', + patterns: ['Controller', 'BaseController', 'AbstractController', 'InjectedController', 'Action'], + }, + { + framework: 'akka', + entryPointMultiplier: 2.0, + reason: 'akka-actor', + patterns: ['Actor', 'ActorRef', 'Props', 'receive', 'ActorSystem'], + }, + ], [SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns } satisfies Record; diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 3c52fa7a6..e39ff31f1 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -361,7 +361,7 @@ export const processImports = async ( match.captures.forEach((c) => (captureMap[c.name] = c.node)); if (captureMap['import']) { - const sourceNode = captureMap['import.source']; + const sourceNode = captureMap['import.source'] ?? captureMap['import']; if (!sourceNode) { if (isDev) { console.log(`⚠️ Import captured but no source node in ${file.path}`); diff --git a/gitnexus/src/core/ingestion/import-resolvers/jvm.ts b/gitnexus/src/core/ingestion/import-resolvers/jvm.ts index 2dd7636ab..aa816ea39 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/jvm.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/jvm.ts @@ -12,6 +12,9 @@ import { resolveStandard } from './standard.js'; /** Kotlin file extensions for JVM resolver reuse */ export const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts']; +/** Scala file extensions for JVM resolver reuse */ +export const SCALA_EXTENSIONS: readonly string[] = ['.scala', '.sc']; + /** * Append .* to a Kotlin import path if the AST has a wildcard_import sibling node. * Pure function — returns a new string without mutating the input. @@ -230,3 +233,152 @@ export function resolveKotlinImport( } return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Kotlin); } + +/** + * Normalize a raw Scala import path: strip `import ` prefix, `_root_` prefix, backticks. + */ +function normalizeScalaImportPath(rawImportPath: string): string { + return rawImportPath + .trim() + .replace(/^\s*import\s+/, '') + .replace(/^_root_\./, '') + .replace(/`/g, ''); +} + +/** + * Extract the module/package path from a Scala import, stripping selector braces and wildcards. + */ +function extractScalaModulePath(rawImportPath: string): string { + const trimmed = normalizeScalaImportPath(rawImportPath); + const braceIndex = trimmed.indexOf('{'); + if (braceIndex !== -1) { + return trimmed.slice(0, braceIndex).replace(/\.$/, ''); + } + if (trimmed.endsWith('._') || trimmed.endsWith('.*')) { + return trimmed.slice(0, -2); + } + return trimmed; +} + +/** + * Resolve a Scala package object file (package.scala) for a given package path. + */ +function resolveScalaPackageObject( + packagePath: string, + ctx: ResolveCtx, +): string | null { + const normalizedPkgPath = packagePath.replace(/\./g, '/').replace(/^\/+|\/+$/g, ''); + if (!normalizedPkgPath) return null; + const suffix = `${normalizedPkgPath}/package.scala`; + + if (ctx.index) { + return ctx.index.get(suffix) || ctx.index.getInsensitive(suffix) || null; + } + for (let i = 0; i < ctx.normalizedFileList.length; i++) { + const normalized = ctx.normalizedFileList[i]; + if ( + normalized === suffix || + normalized.endsWith(`/${suffix}`) || + normalized.toLowerCase().endsWith(`/${suffix.toLowerCase()}`) + ) { + return ctx.allFileList[i]; + } + } + return null; +} + +/** + * Append a package object file to the resolved files list if found. + */ +function appendScalaPackageObject( + files: string[], + packagePath: string, + ctx: ResolveCtx, +): string[] { + const pkgObject = resolveScalaPackageObject(packagePath, ctx); + if (!pkgObject || files.includes(pkgObject)) return files; + return [...files, pkgObject]; +} + +/** + * Scala: Full import resolution with _root_ stripping, backtick handling, + * selector imports, package object resolution, and Java interop fallback. + */ +export function resolveScalaImport( + rawImportPath: string, + filePath: string, + ctx: ResolveCtx, +): ImportResult { + const cleaned = normalizeScalaImportPath(rawImportPath); + const normalized = extractScalaModulePath(cleaned); + + // Selector imports: import com.example.{User, Order} + if (cleaned.includes('{') && cleaned.includes('}')) { + const wildcardPath = `${normalized}.*`; + const matches = appendScalaPackageObject( + resolveJvmWildcard(wildcardPath, ctx.normalizedFileList, ctx.allFileList, SCALA_EXTENSIONS, ctx.index), + normalized, ctx, + ); + if (matches.length > 0) return { kind: 'files', files: matches }; + const javaMatches = resolveJvmWildcard(wildcardPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index); + if (javaMatches.length > 0) return { kind: 'files', files: javaMatches }; + } + + // Wildcard imports: import com.example._ or .* + if (cleaned.endsWith('._') || cleaned.endsWith('.*')) { + const wildcardPath = `${normalized}.*`; + const matches = appendScalaPackageObject( + resolveJvmWildcard(wildcardPath, ctx.normalizedFileList, ctx.allFileList, SCALA_EXTENSIONS, ctx.index), + normalized, ctx, + ); + if (matches.length > 0) return { kind: 'files', files: matches }; + const javaMatches = resolveJvmWildcard(wildcardPath, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index); + if (javaMatches.length > 0) return { kind: 'files', files: javaMatches }; + } else { + // Member imports: import com.example.User + let memberResolved = resolveJvmMemberImport( + normalized, ctx.normalizedFileList, ctx.allFileList, SCALA_EXTENSIONS, ctx.index, + ); + if (!memberResolved) { + memberResolved = resolveJvmMemberImport( + normalized, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index, + ); + } + if (memberResolved) return { kind: 'files', files: [memberResolved] }; + + // Direct class import: import com.example.User → try com/example/User.scala + const classPath = normalized.replace(/\./g, '/'); + for (const ext of [...SCALA_EXTENSIONS, '.java']) { + const classSuffix = classPath + ext; + if (ctx.index) { + const result = ctx.index.get(classSuffix) || ctx.index.getInsensitive(classSuffix); + if (result) return { kind: 'files', files: [result] }; + } else { + const fullSuffix = '/' + classSuffix; + for (let i = 0; i < ctx.normalizedFileList.length; i++) { + if (ctx.normalizedFileList[i].endsWith(fullSuffix) || + ctx.normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) { + return { kind: 'files', files: [ctx.allFileList[i]] }; + } + } + } + } + + // Top-level function / companion object imports → package directory scan + const segments = normalized.split('.'); + const lastSeg = segments[segments.length - 1]; + if (segments.length >= 2 && lastSeg) { + const packagePath = segments.slice(0, -1).join('.'); + const pkgWildcard = `${packagePath}.*`; + let dirFiles = appendScalaPackageObject( + resolveJvmWildcard(pkgWildcard, ctx.normalizedFileList, ctx.allFileList, SCALA_EXTENSIONS, ctx.index), + packagePath, ctx, + ); + if (dirFiles.length === 0) { + dirFiles = resolveJvmWildcard(pkgWildcard, ctx.normalizedFileList, ctx.allFileList, ['.java'], ctx.index); + } + if (dirFiles.length > 0) return { kind: 'files', files: dirFiles }; + } + } + return resolveStandard(normalized, filePath, ctx, SupportedLanguages.Scala); +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/utils.ts b/gitnexus/src/core/ingestion/import-resolvers/utils.ts index 8d915eb7f..f917be742 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/utils.ts @@ -24,6 +24,10 @@ export const EXTENSIONS = [ // Kotlin '.kt', '.kts', + // Scala + '.scala', + '.sc', + '/package.scala', // C/C++ '.c', '.h', diff --git a/gitnexus/src/core/ingestion/languages/index.ts b/gitnexus/src/core/ingestion/languages/index.ts index 041af775d..7e32917cd 100644 --- a/gitnexus/src/core/ingestion/languages/index.ts +++ b/gitnexus/src/core/ingestion/languages/index.ts @@ -24,6 +24,7 @@ import { rubyProvider } from './ruby.js'; import { swiftProvider } from './swift.js'; import { dartProvider } from './dart.js'; import { vueProvider } from './vue.js'; +import { scalaProvider } from './scala.js'; import { cobolProvider } from './cobol.js'; export const providers = { @@ -42,6 +43,7 @@ export const providers = { [SupportedLanguages.Swift]: swiftProvider, [SupportedLanguages.Dart]: dartProvider, [SupportedLanguages.Vue]: vueProvider, + [SupportedLanguages.Scala]: scalaProvider, [SupportedLanguages.Cobol]: cobolProvider, } satisfies Record; diff --git a/gitnexus/src/core/ingestion/languages/scala.ts b/gitnexus/src/core/ingestion/languages/scala.ts new file mode 100644 index 000000000..5265e9a1f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/scala.ts @@ -0,0 +1,122 @@ +/** + * Scala language provider. + * + * Scala uses named imports with JVM wildcard/member resolution and + * Java-interop fallback. Default visibility is public (no modifier needed). + * Heritage uses EXTENDS by default with implements-split MRO for + * multiple trait implementation. Scala traits map to interfaces for + * EXTENDS vs IMPLEMENTS edge classification. + * + * Supports: classes, traits, objects (singletons/companions), case classes, + * sealed traits/classes, Scala 3 enums, def/val/var definitions, + * pattern matching, for-comprehensions, infix operators, package objects. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import { defineLanguage } from '../language-provider.js'; +import { scalaTypeConfig } from '../type-extractors/jvm.js'; +import { scalaExportChecker } from '../export-detection.js'; +import { resolveScalaImport } from '../import-resolvers/jvm.js'; +import { extractScalaNamedBindings } from '../named-bindings/scala.js'; +import { SCALA_QUERIES } from '../tree-sitter-queries.js'; +import { createFieldExtractor } from '../field-extractors/generic.js'; +import { scalaConfig } from '../field-extractors/configs/scala.js'; +import { createMethodExtractor } from '../method-extractors/generic.js'; +import { scalaMethodConfig } from '../method-extractors/configs/scala.js'; +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import type { NodeLabel } from 'gitnexus-shared'; + +const BUILT_INS: ReadonlySet = new Set([ + // I/O and assertions + 'println', 'print', 'printf', 'require', 'assert', 'assume', 'sys', + // Standard types + 'Some', 'None', 'Option', 'Left', 'Right', 'Nil', + 'List', 'Map', 'Set', 'Seq', 'Vector', 'Array', + 'Iterator', 'LazyList', 'Stream', 'Try', 'Success', 'Failure', + 'Future', 'Promise', + // Language keywords / universal methods + 'throw', 'classOf', 'isInstanceOf', 'asInstanceOf', + 'toString', 'hashCode', 'equals', 'copy', 'apply', 'unapply', + // Collection higher-order methods + 'map', 'flatMap', 'filter', 'foreach', 'collect', + 'foldLeft', 'foldRight', 'reduce', 'reduceLeft', 'reduceRight', + 'head', 'tail', 'last', 'init', 'isEmpty', 'nonEmpty', + 'size', 'length', 'contains', 'exists', 'forall', 'find', + 'zip', 'zipWithIndex', 'groupBy', 'sortBy', 'sorted', 'sortWith', + 'reverse', 'distinct', 'take', 'drop', 'takeWhile', 'dropWhile', + 'mkString', 'toList', 'toSeq', 'toSet', 'toMap', 'toVector', 'toArray', + 'getOrElse', 'orElse', 'fold', 'match', + 'recover', 'recoverWith', 'onComplete', 'andThen', 'compose', + 'sliding', 'grouped', 'patch', 'updated', 'diff', 'intersect', 'union', + 'sum', 'product', 'min', 'max', 'count', 'span', 'partition', + 'flatten', 'unzip', 'transpose', 'combinations', 'permutations', + 'to', 'until', 'by', + // Symbolic operators (infix noise) + '+', '-', '*', '/', '%', + '==', '!=', '<', '>', '<=', '>=', + '&&', '||', + '::', ':::', '++', '+:', ':+', + '->', '<-', + '+=', '-=', '*=', '/=', '%=', + '|=', '&=', '^=', '<<=', '>>=', +]); + +/** Traverse up from a function_definition to check if it's inside a class/trait/object body. */ +function isScalaMemberFunction(node: SyntaxNode): boolean { + let ancestor: SyntaxNode | null = node.parent; + while (ancestor) { + if ( + ancestor.type === 'class_definition' || + ancestor.type === 'object_definition' || + ancestor.type === 'trait_definition' || + ancestor.type === 'enum_definition' || + ancestor.type === 'package_object' + ) { + return true; + } + ancestor = ancestor.parent; + } + return false; +} + +/** Classify Scala object definitions: Module (default) or Enum (extends Enumeration). */ +function getScalaObjectLabel(node: SyntaxNode): NodeLabel { + if (node.type !== 'object_definition') return 'Class'; + const extendNode = node.childForFieldName?.('extend'); + const extendsText = extendNode?.text ?? ''; + if (extendsText.includes('Enumeration')) return 'Enum'; + return 'Module'; +} + +export const scalaProvider = defineLanguage({ + id: SupportedLanguages.Scala, + extensions: ['.scala', '.sc'], + treeSitterQueries: SCALA_QUERIES, + typeConfig: scalaTypeConfig, + exportChecker: scalaExportChecker, + importResolver: resolveScalaImport, + namedBindingExtractor: extractScalaNamedBindings, + mroStrategy: 'implements-split', + fieldExtractor: createFieldExtractor(scalaConfig), + methodExtractor: createMethodExtractor(scalaMethodConfig), + builtInNames: BUILT_INS, + + importPathPreprocessor: (cleaned, _importNode) => { + let path = cleaned + .replace(/^\s*import\s+/, '') + .replace(/^_root_\./, '') + .replace(/`/g, ''); + if (path.endsWith('._')) path = path.slice(0, -2) + '.*'; + return path; + }, + + labelOverride: (definitionNode, defaultLabel) => { + // Classify object definitions as Module or Enum + if (defaultLabel === 'Class' && definitionNode.type === 'object_definition') { + return getScalaObjectLabel(definitionNode); + } + if (defaultLabel !== 'Function') return defaultLabel; + if (isScalaMemberFunction(definitionNode)) return 'Method'; + return defaultLabel; + }, +}); diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/scala.ts b/gitnexus/src/core/ingestion/method-extractors/configs/scala.ts new file mode 100644 index 000000000..2b65e76d6 --- /dev/null +++ b/gitnexus/src/core/ingestion/method-extractors/configs/scala.ts @@ -0,0 +1,162 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { + MethodExtractionConfig, + ParameterInfo, + MethodVisibility, +} from '../../method-types.js'; +import { hasModifier, hasKeyword } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { findChild } from '../../utils/ast-helpers.js'; + +const SCALA_VIS = new Set(['public', 'private', 'protected', 'package']); + +function extractScalaVisibility(node: SyntaxNode): MethodVisibility { + const modifiers = node.childForFieldName('modifiers') ?? node.namedChildren[0] ?? null; + const text = modifiers?.text ?? ''; + if (text.includes('private[') || text.includes('protected[')) return 'package'; + if (text.includes('private')) return 'private'; + if (text.includes('protected')) return 'protected'; + return 'public'; +} + +function extractScalaAnnotations(node: SyntaxNode): string[] { + const annotations: string[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'annotation') { + const nameNode = child.childForFieldName('name') ?? child.firstNamedChild; + if (nameNode) annotations.push(`@${nameNode.text}`); + } + } + return annotations; +} + +function extractScalaParameters(node: SyntaxNode): ParameterInfo[] { + const params: ParameterInfo[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child || child.type !== 'parameters') continue; + const isImplicitParameterList = /\bimplicit\b/.test(child.text); + for (let j = 0; j < child.namedChildCount; j++) { + const param = child.namedChild(j); + if (!param || (param.type !== 'parameter' && param.type !== 'class_parameter')) continue; + const nameNode = param.childForFieldName('name'); + const typeNode = param.childForFieldName('type'); + if (!nameNode) continue; + let hasDefault = false; + for (let k = 0; k < param.childCount; k++) { + if (param.child(k)?.text === '=') { hasDefault = true; break; } + } + params.push({ + name: nameNode.text, + type: typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) : null, + isOptional: hasDefault || isImplicitParameterList, + isVariadic: false, + }); + } + } + return params; +} + +function extractPrimaryConstructor( + ownerNode: SyntaxNode, + context: { filePath: string }, +): { + name: string; + receiverType: null; + returnType: null; + parameters: ParameterInfo[]; + visibility: MethodVisibility; + isStatic: boolean; + isAbstract: boolean; + isFinal: boolean; + annotations: string[]; + sourceFile: string; + line: number; +} | null { + if (ownerNode.type !== 'class_definition') return null; + const nameNode = ownerNode.childForFieldName('name'); + if (!nameNode) return null; + + const params: ParameterInfo[] = []; + for (let i = 0; i < ownerNode.namedChildCount; i++) { + const child = ownerNode.namedChild(i); + if (!child || child.type !== 'class_parameters') continue; + for (let j = 0; j < child.namedChildCount; j++) { + const pnode = child.namedChild(j); + if (!pnode || pnode.type !== 'class_parameter') continue; + const paramName = pnode.childForFieldName('name'); + const typeNode = pnode.childForFieldName('type'); + if (paramName) { + params.push({ + name: paramName.text, + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + isOptional: pnode.text.includes('='), + isVariadic: false, + }); + } + } + } + + return { + name: nameNode.text, + receiverType: null, + returnType: null, + parameters: params, + visibility: 'public', + isStatic: false, + isAbstract: false, + isFinal: false, + annotations: [], + sourceFile: context.filePath, + line: ownerNode.startPosition.row + 1, + }; +} + +export const scalaMethodConfig: MethodExtractionConfig = { + language: SupportedLanguages.Scala, + typeDeclarationNodes: [ + 'class_definition', + 'trait_definition', + 'object_definition', + 'enum_definition', + 'package_object', + ], + methodNodeTypes: ['function_definition', 'function_declaration'], + bodyNodeTypes: ['template_body', 'enum_body'], + + extractName(node) { + return node.childForFieldName('name')?.text ?? findChild(node, 'identifier')?.text; + }, + + extractReturnType(node) { + const typeNode = node.childForFieldName('return_type'); + return typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : undefined; + }, + + extractParameters: extractScalaParameters, + + extractVisibility(node) { + return extractScalaVisibility(node); + }, + + isStatic(_node) { + return false; + }, + + isAbstract(node, ownerNode) { + if (hasModifier(node, 'modifiers', 'abstract') || hasKeyword(node, 'abstract')) return true; + return ownerNode.type === 'trait_definition' && node.type === 'function_declaration'; + }, + + isFinal(node) { + return hasModifier(node, 'modifiers', 'final') || hasKeyword(node, 'final'); + }, + + extractAnnotations: extractScalaAnnotations, + extractPrimaryConstructor, +}; diff --git a/gitnexus/src/core/ingestion/named-bindings/scala.ts b/gitnexus/src/core/ingestion/named-bindings/scala.ts new file mode 100644 index 000000000..3bd6eec82 --- /dev/null +++ b/gitnexus/src/core/ingestion/named-bindings/scala.ts @@ -0,0 +1,82 @@ +import { findChild, type SyntaxNode } from '../utils/ast-helpers.js'; +import type { NamedBinding } from './types.js'; + +const cleanScalaName = (text: string): string => text.replace(/`/g, '').trim(); + +/** + * Extract a binding from a single selector node (identifier, renamed, wildcard). + */ +function extractFromSelector(selectorNode: SyntaxNode): NamedBinding | null { + // Scala 2 "=>" rename or Scala 3 "as" rename + if ( + selectorNode.type === 'arrow_renamed_identifier' || + selectorNode.type === 'as_renamed_identifier' + ) { + const nameNode = selectorNode.childForFieldName('name'); + const aliasNode = selectorNode.childForFieldName('alias'); + if (!nameNode || !aliasNode || aliasNode.text === '_') return null; + return { local: cleanScalaName(aliasNode.text), exported: cleanScalaName(nameNode.text) }; + } + + if (selectorNode.type === 'namespace_wildcard') return null; + + if (selectorNode.type === 'identifier') { + const name = cleanScalaName(selectorNode.text); + return { local: name, exported: name }; + } + + return null; +} + +/** + * Extract named bindings from Scala import declarations. + * + * Scala import forms: + * import com.example.User → local="User", exported="User" + * import com.example.{User, Order} → two bindings + * import com.example.{User => U} → local="U", exported="User" + * import com.example.{User as U} → local="U", exported="User" (Scala 3) + * import com.example.{User => _} → excluded, null + * import com.example._ → wildcard, no named binding + * import com.example.* → wildcard, no named binding (Scala 3) + */ +export function extractScalaNamedBindings(importNode: SyntaxNode): NamedBinding[] | undefined { + if (importNode.type !== 'import_declaration') return undefined; + + const bindings: NamedBinding[] = []; + + // Check for namespace_selectors: import com.example.{A, B, C => D} + const selectors = findChild(importNode, 'namespace_selectors'); + if (selectors) { + for (let i = 0; i < selectors.namedChildCount; i++) { + const child = selectors.namedChild(i); + if (!child) continue; + const binding = extractFromSelector(child); + if (binding) bindings.push(binding); + } + return bindings.length > 0 ? bindings : undefined; + } + + // Check for a standalone renamed import (not inside selectors) + const renamed = + findChild(importNode, 'arrow_renamed_identifier') ?? + findChild(importNode, 'as_renamed_identifier'); + if (renamed) { + const binding = extractFromSelector(renamed); + return binding ? [binding] : undefined; + } + + // Check for namespace_wildcard: import com.example._ or import com.example.* + if (findChild(importNode, 'namespace_wildcard')) return undefined; + + // Simple import: import com.example.User + const pathNode = findChild(importNode, '_namespace_expression') ?? importNode.firstNamedChild; + const fullText = cleanScalaName(pathNode?.text ?? '').replace(/^_root_\./, ''); + if (!fullText || fullText.endsWith('._') || fullText.endsWith('.*')) return undefined; + + const segments = fullText.split('.'); + const name = segments[segments.length - 1]; + if (!name) return undefined; + + return [{ local: name, exported: name }]; +} diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e840e4025..3f83b8a79 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -921,7 +921,6 @@ async function runChunkedParseAndResolve( if (chunkWorkerData.typeEnvBindings?.length) { for (const _item of chunkWorkerData.typeEnvBindings) workerTypeEnvBindings.push(_item); } - // Collect fetch() calls for Next.js route matching if (chunkWorkerData.fetchCalls?.length) { for (const _item of chunkWorkerData.fetchCalls) allFetchCalls.push(_item); } diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index a6c8d74b4..004bf4a4c 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1174,6 +1174,127 @@ export const DART_QUERIES = ` (type_identifier) @heritage.trait))) @heritage `; +// Scala queries - works with tree-sitter-scala +export const SCALA_QUERIES = ` +; ── Classes ───────────────────────────────────────────────────────────── +(class_definition name: (identifier) @name) @definition.class +(object_definition name: (identifier) @name) @definition.class +(package_object name: (identifier) @name) @definition.module + +; ── Traits (mapped to interface) ──────────────────────────────────────── +(trait_definition name: (identifier) @name) @definition.interface + +; ── Enum definitions (Scala 3) ────────────────────────────────────────── +(enum_definition name: (identifier) @name) @definition.enum + +; ── Functions / Methods ───────────────────────────────────────────────── +(function_definition name: (identifier) @name) @definition.function +(function_declaration name: (identifier) @name) @definition.function + +; ── Primary constructor on class definitions ──────────────────────────── +(class_definition + name: (identifier) @name + class_parameters: (class_parameters) @definition.constructor) + +; ── Properties ────────────────────────────────────────────────────────── +(val_definition pattern: (identifier) @name) @definition.property +(val_definition pattern: (identifiers (identifier) @name)) @definition.property +(val_declaration name: (identifier) @name) @definition.property +(var_definition pattern: (identifier) @name) @definition.property +(var_definition pattern: (identifiers (identifier) @name)) @definition.property +(var_declaration name: (identifier) @name) @definition.property + +; ── Type aliases ──────────────────────────────────────────────────────── +(type_definition name: (type_identifier) @name) @definition.type + +; ── Imports ───────────────────────────────────────────────────────────── +(import_declaration) @import + +; ── Calls ─────────────────────────────────────────────────────────────── +(call_expression function: (identifier) @call.name) @call +(call_expression function: (field_expression field: (identifier) @call.name)) @call +(call_expression function: (generic_function function: (identifier) @call.name)) @call +(call_expression function: (generic_function function: (field_expression field: (identifier) @call.name))) @call +(instance_expression (type_identifier) @call.name) @call + +; ── Infix method calls: list flatMap f, actor ! msg ───────────────────── +(infix_expression operator: (identifier) @call.name) @call +(infix_expression operator: (operator_identifier) @call.name) @call + +; ── Postfix method calls: list sorted, option isEmpty ─────────────────── +(postfix_expression (_) (identifier) @call.name) @call + +; ── Heritage: compound_type (extends Base with Trait1 with Trait2) ────── +(class_definition + name: (identifier) @heritage.class + extend: (extends_clause + type: (compound_type + base: (_) @heritage.extends))) @heritage + +(class_definition + name: (identifier) @heritage.class + extend: (extends_clause + type: (compound_type + extra: (_) @heritage.implements))) @heritage.impl + +(class_definition + name: (identifier) @heritage.class + extend: (extends_clause + type: (_) @heritage.extends)) @heritage + +(trait_definition + name: (identifier) @heritage.class + extend: (extends_clause + type: (compound_type + base: (_) @heritage.extends))) @heritage + +(trait_definition + name: (identifier) @heritage.class + extend: (extends_clause + type: (compound_type + extra: (_) @heritage.implements))) @heritage.impl + +(object_definition + name: (identifier) @heritage.class + extend: (extends_clause + type: (compound_type + base: (_) @heritage.extends))) @heritage + +(object_definition + name: (identifier) @heritage.class + extend: (extends_clause + type: (compound_type + extra: (_) @heritage.implements))) @heritage.impl + +; ── Write access: obj.field = value ───────────────────────────────────── +(assignment_expression + left: (field_expression + value: (_) @assignment.receiver + field: (identifier) @assignment.property) + right: (_)) @assignment + +; ── Field reads: obj.field ────────────────────────────────────────────── +(field_expression + value: (identifier) @read.receiver + field: (identifier) @read.property) @read + +; ── Chained field reads: obj.a.b, method().field ─────────────────────── +(field_expression + value: (field_expression) @read.receiver + field: (identifier) @read.property) @read + +(field_expression + value: (call_expression) @read.receiver + field: (identifier) @read.property) @read + +; ── Compound assignment via infix: obj.field += value ────────────────── +(infix_expression + left: (field_expression + value: (_) @assignment.receiver + field: (identifier) @assignment.property) + operator: (operator_identifier)) @assignment +`; + import { SupportedLanguages } from 'gitnexus-shared'; export const LANGUAGE_QUERIES: Record = { @@ -1192,5 +1313,6 @@ export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.Swift]: SWIFT_QUERIES, [SupportedLanguages.Dart]: DART_QUERIES, [SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue