mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
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:
parent
be2401061e
commit
069844c408
22 changed files with 1164 additions and 107 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
32
gitnexus/package-lock.json
generated
32
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
122
gitnexus/src/core/ingestion/field-extractors/configs/scala.ts
Normal file
122
gitnexus/src/core/ingestion/field-extractors/configs/scala.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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[]>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ export const EXTENSIONS = [
|
|||
// Kotlin
|
||||
'.kt',
|
||||
'.kts',
|
||||
// Scala
|
||||
'.scala',
|
||||
'.sc',
|
||||
'/package.scala',
|
||||
// C/C++
|
||||
'.c',
|
||||
'.h',
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
122
gitnexus/src/core/ingestion/languages/scala.ts
Normal file
122
gitnexus/src/core/ingestion/languages/scala.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
162
gitnexus/src/core/ingestion/method-extractors/configs/scala.ts
Normal file
162
gitnexus/src/core/ingestion/method-extractors/configs/scala.ts
Normal 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,
|
||||
};
|
||||
82
gitnexus/src/core/ingestion/named-bindings/scala.ts
Normal file
82
gitnexus/src/core/ingestion/named-bindings/scala.ts
Normal 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 }];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue