feat(scala): add Scala language support and slim down AGENTS.md injection

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
This commit is contained in:
saoqixiaomm 2026-04-08 11:08:43 +08:00
parent be2401061e
commit 069844c408
22 changed files with 1164 additions and 107 deletions

View file

@ -42,6 +42,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Swift]: ['.swift'],
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.Scala]: ['.scala', '.sc'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
@ -100,6 +101,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Swift]: 'swift',
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.Scala]: 'scala',
[SupportedLanguages.Cobol]: 'cobol',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness

View file

@ -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',
}

View file

@ -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",

View file

@ -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"

View file

@ -32,15 +32,14 @@ const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
/**
* 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<string[]> {
const { listGroups, getDefaultGitnexusDir, getGroupDir } =
@ -61,7 +60,7 @@ async function findGroupsContainingRegistryName(registryName: string): Promise<s
function generateGitNexusContent(
projectName: string,
stats: RepoStats,
_stats: RepoStats,
generatedSkills?: GeneratedSkillInfo[],
groupNames?: string[],
): string {
@ -70,108 +69,34 @@ function generateGitNexusContent(
? generatedSkills
.map(
(s) =>
`| 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: "<error or symptom>"})\` — find execution flows related to the issue
2. \`gitnexus_context({name: "<suspect function>"})\` — 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<boolean> {
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);

View file

@ -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<SupportedLanguages, RegExp[]>;

View file

@ -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;
};

View file

@ -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<FieldVisibility>(['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;
},
};

View file

@ -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<SupportedLanguages, AstFrameworkPatternConfig[]>;

View file

@ -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}`);

View file

@ -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);
}

View file

@ -24,6 +24,10 @@ export const EXTENSIONS = [
// Kotlin
'.kt',
'.kts',
// Scala
'.scala',
'.sc',
'/package.scala',
// C/C++
'.c',
'.h',

View file

@ -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<SupportedLanguages, LanguageProvider>;

View file

@ -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<string> = 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;
},
});

View file

@ -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<MethodVisibility>(['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,
};

View file

@ -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 }];
}

View file

@ -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);
}

View file

@ -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<SupportedLanguages, string> = {
@ -1192,5 +1313,6 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Swift]: SWIFT_QUERIES,
[SupportedLanguages.Dart]: DART_QUERIES,
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
[SupportedLanguages.Scala]: SCALA_QUERIES,
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
};

View file

@ -847,6 +847,233 @@ const extractKotlinPatternBinding: PatternBindingExtractor = (
return undefined;
};
// ── Scala ─────────────────────────────────────────────────────────────────
/** Infer Scala literal types (Int, Double, String, Boolean, Char). */
const inferScalaLiteralType: LiteralTypeInferrer = (node) => {
switch (node.type) {
case 'integer_literal':
return 'Int';
case 'floating_point_literal':
return 'Double';
case 'string_literal':
return 'String';
case 'boolean_literal':
case 'true':
case 'false':
return 'Boolean';
case 'character_literal':
return 'Char';
case 'null_literal':
return 'Null';
default:
return undefined;
}
};
const SCALA_DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'val_definition',
'val_declaration',
'var_definition',
'var_declaration',
'class_parameter',
]);
/** Helper to get the binding name from a Scala val/var/class_parameter node. */
const getScalaBindingName = (node: SyntaxNode): string | undefined => {
return (
node.childForFieldName('name')?.text ??
node.childForFieldName('pattern')?.text ??
findChild(node, 'identifier')?.text
);
};
/** Helper to get the type annotation from a Scala node. */
const getScalaBindingType = (node: SyntaxNode): string | undefined => {
const typeNode = node.childForFieldName('type');
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text;
return undefined;
};
/** Scala: val x: Foo = ... or class_parameter(name: Type) */
const extractScalaDeclaration: TypeBindingExtractor = (
node: SyntaxNode,
env: Map<string, string>,
): void => {
// class_parameter: constructor parameter binding
if (node.type === 'class_parameter') {
const name = getScalaBindingName(node);
const typeName = getScalaBindingType(node);
if (name && typeName) env.set(name, typeName);
return;
}
const explicitName = getScalaBindingName(node);
const explicitType = getScalaBindingType(node);
if (explicitName && explicitType) {
env.set(explicitName, explicitType);
return;
}
// Multi-identifier declarations: val (a, b): Foo = ...
const identifiers = findChild(node, 'identifiers');
if (identifiers && explicitType) {
for (let i = 0; i < identifiers.namedChildCount; i++) {
const child = identifiers.namedChild(i);
if (child?.type === 'identifier') env.set(child.text, explicitType);
}
}
};
/** Scala: parameter → type name */
const extractScalaParameter: ParameterExtractor = (
node: SyntaxNode,
env: Map<string, string>,
): void => {
const nameNode = node.childForFieldName('name');
const typeNode = node.childForFieldName('type');
if (!nameNode || !typeNode) return;
const typeName = extractSimpleTypeName(typeNode) ?? typeNode.text;
if (typeName) env.set(nameNode.text, typeName);
};
/** Find a constructor call in a val/var initializer (new Foo() or Foo()). */
const findScalaConstructorCall = (
node: SyntaxNode,
classNames: ClassNameLookup,
): string | undefined => {
const valueNode = node.childForFieldName('value');
if (!valueNode) return undefined;
if (valueNode.type === 'call_expression') {
const callee = valueNode.childForFieldName('function') ?? valueNode.firstNamedChild;
const calleeName = callee ? (extractSimpleTypeName(callee) ?? callee.text) : undefined;
if (calleeName && classNames.has(calleeName)) return calleeName;
}
if (valueNode.type === 'instance_expression') {
const typeNode = valueNode.childForFieldName('type') ?? valueNode.firstNamedChild;
const typeName = typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text) : undefined;
if (typeName && classNames.has(typeName)) return typeName;
}
if (valueNode.type === 'identifier' && classNames.has(valueNode.text)) {
return valueNode.text;
}
return undefined;
};
/** Scala: val user = new User() or val user = User() — infer type from constructor. */
const extractScalaInitializer: InitializerExtractor = (
node: SyntaxNode,
env: Map<string, string>,
classNames: ClassNameLookup,
): void => {
const name = getScalaBindingName(node);
if (!name || env.has(name)) return;
const ctorType = findScalaConstructorCall(node, classNames);
if (ctorType) env.set(name, ctorType);
};
/** Scala: detect constructor type even for typed declarations (virtual dispatch). */
const detectScalaConstructorType: ConstructorTypeDetector = (node, classNames) => {
return findScalaConstructorCall(node, classNames);
};
const SCALA_FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set(['for_expression']);
/** Scala: for (user <- users) yield ... — extract loop variable binding */
const extractScalaForLoopBinding: ForLoopExtractor = (node, ctx): void => {
const { scopeEnv } = ctx;
const enumerators = findChild(node, 'enumerators');
if (!enumerators) return;
for (let i = 0; i < enumerators.namedChildCount; i++) {
const enum_ = enumerators.namedChild(i);
if (!enum_ || enum_.type !== 'enumerator') continue;
const pattern = enum_.childForFieldName('pattern');
const value = enum_.childForFieldName('value');
if (!pattern || pattern.type !== 'identifier' || !value) continue;
const varName = extractVarName(pattern);
if (!varName) continue;
if (value.type === 'identifier') {
const containerType = scopeEnv.get(value.text);
if (containerType) {
const inner = containerType.match(/<(.+)>$/)?.[1] ?? containerType.match(/\[(.+)\]$/)?.[1];
if (inner) scopeEnv.set(varName, inner);
}
}
}
};
/** Scala: val alias = u — pending assignments with call, field access, method call support. */
const extractScalaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
const lhs = getScalaBindingName(node);
if (!lhs || scopeEnv.has(lhs)) return undefined;
const valueNode = node.childForFieldName('value');
if (!valueNode) return undefined;
if (valueNode.type === 'identifier') {
return { kind: 'copy', lhs, rhs: valueNode.text };
}
if (valueNode.type === 'call_expression') {
const fn = valueNode.childForFieldName('function') ?? valueNode.firstNamedChild;
if (!fn) return undefined;
if (fn.type === 'identifier') return { kind: 'callResult', lhs, callee: fn.text };
if (fn.type === 'generic_function') {
const inner = fn.childForFieldName('function') ?? fn.firstNamedChild;
if (inner?.type === 'identifier') return { kind: 'callResult', lhs, callee: inner.text };
if (inner?.type === 'field_expression') {
const receiver = inner.childForFieldName('object') ?? inner.childForFieldName('value');
const field = inner.childForFieldName('field');
if (receiver?.type === 'identifier' && field?.type === 'identifier') {
return { kind: 'methodCallResult', lhs, receiver: receiver.text, method: field.text };
}
}
}
if (fn.type === 'field_expression') {
const receiver = fn.childForFieldName('object') ?? fn.childForFieldName('value');
const field = fn.childForFieldName('field');
if (receiver?.type === 'identifier' && field?.type === 'identifier') {
return { kind: 'methodCallResult', lhs, receiver: receiver.text, method: field.text };
}
}
}
if (valueNode.type === 'instance_expression') {
const typeNode = valueNode.childForFieldName('type') ?? valueNode.firstNamedChild;
if (typeNode?.type === 'type_identifier' || typeNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: typeNode.text };
}
}
if (valueNode.type === 'field_expression') {
const receiver = valueNode.childForFieldName('object') ?? valueNode.childForFieldName('value');
const field = valueNode.childForFieldName('field');
if (receiver?.type === 'identifier' && field?.type === 'identifier') {
return { kind: 'fieldAccess', lhs, receiver: receiver.text, field: field.text };
}
}
return undefined;
};
export const scalaTypeConfig: LanguageTypeConfig = {
declarationNodeTypes: SCALA_DECLARATION_NODE_TYPES,
forLoopNodeTypes: SCALA_FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set([]),
extractDeclaration: extractScalaDeclaration,
extractParameter: extractScalaParameter,
extractInitializer: extractScalaInitializer,
extractForLoopBinding: extractScalaForLoopBinding,
extractPendingAssignment: extractScalaPendingAssignment,
inferLiteralType: inferScalaLiteralType,
detectConstructorType: detectScalaConstructorType,
};
export const kotlinTypeConfig: LanguageTypeConfig = {
allowPatternBindingOverwrite: true,
declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES,

View file

@ -38,6 +38,12 @@ let Kotlin: TreeSitterLanguage | null = null;
try {
Kotlin = _require('tree-sitter-kotlin');
} catch {}
// tree-sitter-scala is an optionalDependency — may not be installed
let Scala: TreeSitterLanguage | null = null;
try {
Scala = _require('tree-sitter-scala');
} catch {}
import { getLanguageFromFilename } from 'gitnexus-shared';
import {
FUNCTION_NODE_TYPES,
@ -290,6 +296,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
[SupportedLanguages.Go]: Go,
[SupportedLanguages.Rust]: Rust,
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
...(Scala ? { [SupportedLanguages.Scala]: Scala } : {}),
[SupportedLanguages.PHP]: PHP.php_only,
[SupportedLanguages.Ruby]: Ruby,
[SupportedLanguages.Vue]: TypeScript.typescript,
@ -1406,9 +1413,10 @@ const processFileGroup = (
}
// Extract import paths before skipping
if (captureMap['import'] && captureMap['import.source']) {
if (captureMap['import']) {
const sourceNode = captureMap['import.source'] ?? captureMap['import'];
const rawImportPath = preprocessImportPath(
captureMap['import.source'].text,
sourceNode.text,
captureMap['import'],
provider,
);
@ -1562,7 +1570,8 @@ const processFileGroup = (
languageSeed.callForm === 'member' &&
(language === SupportedLanguages.Java ||
language === SupportedLanguages.CSharp ||
language === SupportedLanguages.Kotlin)
language === SupportedLanguages.Kotlin ||
language === SupportedLanguages.Scala)
) {
const c0 = receiverName.charCodeAt(0);
if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName;

View file

@ -30,6 +30,12 @@ try {
Kotlin = _require('tree-sitter-kotlin');
} catch {}
// tree-sitter-scala is an optionalDependency — may not be installed
let Scala: any = null;
try {
Scala = _require('tree-sitter-scala');
} catch {}
let parser: Parser | null = null;
const languageMap: Record<string, any> = {
@ -44,6 +50,7 @@ const languageMap: Record<string, any> = {
[SupportedLanguages.Go]: Go,
[SupportedLanguages.Rust]: Rust,
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
...(Scala ? { [SupportedLanguages.Scala]: Scala } : {}),
[SupportedLanguages.PHP]: PHP.php_only,
[SupportedLanguages.Ruby]: Ruby,
[SupportedLanguages.Vue]: TypeScript.typescript,

View file

@ -45,6 +45,17 @@ describe('generateAIContextFiles', () => {
expect(content).toContain('TestProject');
});
it('does not embed dynamic stats in the injected block', async () => {
const stats = { nodes: 12345, edges: 67890, processes: 42 };
await generateAIContextFiles(tmpDir, storagePath, 'StatsProject', stats);
const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
const content = await fs.readFile(claudeMdPath, 'utf-8');
expect(content).not.toContain('12345');
expect(content).not.toContain('67890');
expect(content).toContain('.gitnexus/meta.json');
});
it('handles empty stats', async () => {
const stats = {};
const result = await generateAIContextFiles(tmpDir, storagePath, 'EmptyProject', stats);
@ -66,6 +77,21 @@ describe('generateAIContextFiles', () => {
expect(starts).toBe(1);
});
it('returns unchanged when content has not changed', async () => {
const freshDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-idem-'));
const stats = { nodes: 10 };
// First run creates files
const result1 = await generateAIContextFiles(freshDir, storagePath, 'IdempotentProject', stats);
expect(result1.files.some((f) => f.includes('created') || f.includes('updated'))).toBe(true);
// Second run with identical content should be unchanged
const result2 = await generateAIContextFiles(freshDir, storagePath, 'IdempotentProject', stats);
expect(result2.files.filter((f) => f.includes('unchanged')).length).toBeGreaterThanOrEqual(1);
await fs.rm(freshDir, { recursive: true, force: true }).catch(() => {});
});
it('installs skills files', async () => {
const stats = { nodes: 10 };
const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);