diff --git a/skill-data/wiki/references/agents/kb-doc-generator.md b/skill-data/wiki/references/agents/kb-doc-generator.md
index 448c7afe4..da308d242 100644
--- a/skill-data/wiki/references/agents/kb-doc-generator.md
+++ b/skill-data/wiki/references/agents/kb-doc-generator.md
@@ -131,6 +131,7 @@ Use the `Glob → Grep → Read` three-step method (**adapt to the language of t
TypeScript: app.ts / index.ts / main.ts / server.ts
Rust: main.rs / src/main.rs
Swift: main.swift / App.swift
+ Scala: Main.scala / App.scala
2. Grep: locate the core Handlers/Routers (choose the pattern by language + framework)
Go: grep -rn 'func.*Handler\|\.GET\|\.POST\|router\.\|@handler'
diff --git a/skill-data/wiki/scripts/scan_repo.py b/skill-data/wiki/scripts/scan_repo.py
index 08691ab2b..ca965ae18 100644
--- a/skill-data/wiki/scripts/scan_repo.py
+++ b/skill-data/wiki/scripts/scan_repo.py
@@ -53,7 +53,7 @@
# Language extension map
LANG_MAP = {
".py": "Python", ".go": "Go", ".js": "JavaScript", ".ts": "TypeScript",
- ".java": "Java", ".rs": "Rust", ".swift": "Swift", ".rb": "Ruby", ".php": "PHP",
+ ".java": "Java", ".rs": "Rust", ".swift": "Swift", ".scala": "Scala", ".rb": "Ruby", ".php": "PHP",
".c": "C", ".cpp": "C++", ".h": "C/C++ Header",
".proto": "Protobuf", ".thrift": "Thrift", ".graphql": "GraphQL",
".sql": "SQL", ".sh": "Shell", ".bash": "Shell",
diff --git a/src/__tests__/scala-extractor.test.ts b/src/__tests__/scala-extractor.test.ts
new file mode 100644
index 000000000..716db1147
--- /dev/null
+++ b/src/__tests__/scala-extractor.test.ts
@@ -0,0 +1,490 @@
+import { describe, it, expect } from 'vitest';
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import type { CodeCollectedFile } from '../wiki-engine/code-knowledge/code-collector.js';
+import { collectCode } from '../wiki-engine/code-knowledge/code-collector.js';
+import { buildCodeGraph } from '../wiki-engine/code-knowledge/code-graph.js';
+import { traceCallChains } from '../wiki-engine/call-chain-tracer.js';
+import { extractScala } from '../wiki-engine/code-knowledge/extractors/scala.js';
+import { extractForLanguage, supportedLanguages } from '../wiki-engine/code-knowledge/extractors/index.js';
+
+function scalaFile(content: string, relativePath = 'src/main/scala/com/payments/App.scala'): CodeCollectedFile {
+ return {
+ path: `/virtual/${relativePath}`,
+ relativePath,
+ language: 'scala',
+ sha256: 'test',
+ content,
+ };
+}
+
+/** Collect the `kind:name` pairs the extractor produced. */
+function extracted(content: string): string[] {
+ return extractScala([scalaFile(content)]).map((f) => `${f.kind}:${f.name}`);
+}
+
+describe('Scala heuristic extractor', () => {
+ it('is registered for the scala language', () => {
+ expect(supportedLanguages()).toContain('scala');
+ expect(extractForLanguage('scala', [scalaFile('object App { }\n')])).not.toEqual([]);
+ });
+
+ it('extracts classes, objects, traits, enums and defs', () => {
+ const facts = extracted(
+ [
+ 'package com.payments',
+ '',
+ 'trait PaymentGateway {',
+ ' def charge(order: Order): Receipt',
+ '}',
+ '',
+ 'final case class Invoice(id: Int, amount: BigDecimal)',
+ '',
+ 'case object Paid',
+ '',
+ 'class OrderRepository(cache: Cache)',
+ '',
+ 'enum Color {',
+ ' case Red, Blue',
+ '}',
+ '',
+ 'object Main extends App {',
+ ' def run(): Unit = ()',
+ '}',
+ ].join('\n'),
+ );
+
+ expect(facts).toContain('interface:PaymentGateway');
+ expect(facts).toContain('component:charge');
+ expect(facts).toContain('component:Invoice');
+ expect(facts).toContain('component:Paid');
+ expect(facts).toContain('component:OrderRepository');
+ expect(facts).toContain('component:Color');
+ expect(facts).toContain('component:Main');
+ expect(facts).toContain('component:run');
+ });
+
+ it('sees past annotations and modifiers, including a `case` match clause', () => {
+ const facts = extracted(
+ [
+ '@Singleton',
+ 'final case class Invoice(id: Int)',
+ '',
+ 'sealed abstract class Payment(val id: String)',
+ '',
+ 'private[payments] object PaymentRegistry',
+ '',
+ 'implicit class RichString(s: String) {',
+ ' def quoted: String = s',
+ '}',
+ '',
+ 'transparent inline def show(x: Any): String = x.toString',
+ '',
+ 'val label = payload match {',
+ ' case Invoice(id) => id',
+ ' case other => 0',
+ '}',
+ ].join('\n'),
+ );
+
+ // `case Invoice(id) =>` is a match clause, not a declaration.
+ expect(facts.filter((f) => f === 'component:Invoice')).toHaveLength(1);
+ expect(facts).toContain('component:Payment');
+ expect(facts).toContain('component:PaymentRegistry');
+ expect(facts).toContain('component:RichString');
+ expect(facts).toContain('component:quoted');
+ expect(facts).toContain('component:show');
+ });
+
+ it('extracts import relations regardless of the import form', () => {
+ const facts = extracted(
+ [
+ 'import com.payments.gateway',
+ 'import java.util.concurrent.TimeUnit',
+ 'import scala.collection.mutable.{Map => MMap}',
+ 'import com.payments.events._',
+ 'import com.payments.core.*',
+ 'import com.payments.model.{Invoice as Inv, Order => O}',
+ 'import com.demo.core.Invoice.apply',
+ ].join('\n'),
+ );
+
+ // Relation names are slash-separated paths: buildCodeGraph and the
+ // call-chain tracer both match against file paths, not dotted packages.
+ // Brace selectors expand to one relation per imported symbol; a member
+ // import narrows to the type's path.
+ expect(facts).toEqual([
+ 'relation:com/payments/gateway',
+ 'relation:java/util/concurrent/TimeUnit',
+ 'relation:scala/collection/mutable/Map',
+ 'relation:scala-wildcard:com/payments/events',
+ 'relation:com/payments/events',
+ 'relation:scala-wildcard:com/payments/core',
+ 'relation:com/payments/core',
+ 'relation:com/payments/model/Invoice',
+ 'relation:com/payments/model/Order',
+ 'relation:com/demo/core/Invoice',
+ ]);
+ });
+
+ it('falls back to the package path when no collected file matches the import', () => {
+ const facts = extracted(
+ ['import com.demo.core.{Invoice => _, _}', 'import com.demo.core.{Order => _}'].join('\n'),
+ );
+
+ // The fixture file sits outside com/demo/core, so the wildcard has no
+ // collected file to name. A selector of only hidden names (`{Order => _}`)
+ // imports nothing and emits nothing — no relation for `Invoice` either.
+ expect(facts).toEqual(['relation:scala-wildcard:com/demo/core', 'relation:com/demo/core']);
+ });
+
+ it('reads a brace import scalafmt wraps across lines', () => {
+ const facts = extracted(['import com.demo.core.{', ' Invoice,', ' Order => O', '}'].join('\n'));
+
+ expect(facts).toEqual(['relation:com/demo/core/Invoice', 'relation:com/demo/core/Order']);
+ });
+
+ it('reads every clause of a comma-separated import', () => {
+ const facts = extracted('import com.demo.A, com.demo.B');
+
+ expect(facts).toEqual(['relation:com/demo/A', 'relation:com/demo/B']);
+ });
+
+ it('expands a wildcard over the package files and skips hidden names', () => {
+ const gateway = scalaFile('package com.demo.payments\n\nimport com.demo.core.{Invoice => _, _}\n', 'src/main/scala/com/demo/payments/Gateway.scala');
+ const invoice = scalaFile('package com.demo.core\n\ncase class Invoice(id: Int)\n', 'src/main/scala/com/demo/core/Invoice.scala');
+ const main = scalaFile('package com.demo.core\n\nobject Main\n', 'src/main/scala/com/demo/core/Main.scala');
+
+ const names = extractScala([gateway, invoice, main]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Main.scala', 'scala-decl:Invoice', 'scala-decl:Main']);
+ });
+
+ it('reads top-level declarations under a Scala 3 package block', () => {
+ const models = scalaFile(
+ ['package com.demo.core:', ' case class Invoice(id: Int)', ' object Registry:', ' def build: Int = 1'].join('\n'),
+ 'src/main/scala/com/demo/core/Models.scala',
+ );
+ const main = scalaFile('package com.demo.core:\n object Main\n', 'src/main/scala/com/demo/core/Main.scala');
+ const gateway = scalaFile(
+ 'package com.demo.payments\n\nimport com.demo.core.{Invoice => _, _}\nimport com.demo.core.Registry\n',
+ 'src/main/scala/com/demo/payments/Gateway.scala',
+ );
+
+ const names = extractScala([models, main, gateway]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ // `def build` sits one indent deeper than Registry — a member, not a name
+ // the package exports; Invoice is hidden, Registry keeps Models.scala in.
+ expect(names).toEqual(['scala-decl:Invoice,Registry', 'scala-decl:Main', 'scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Models.scala', 'src/main/scala/com/demo/core/Main.scala']);
+ });
+
+ it('ignores nested defs when a hidden name decides wildcard exclusion', () => {
+ const models = scalaFile('package com.demo.core\n\nclass Invoice {\n def total: Int = 1\n}\n', 'src/main/scala/com/demo/core/Models.scala');
+ const main = scalaFile('package com.demo.core\n\nobject Main\n', 'src/main/scala/com/demo/core/Main.scala');
+ const gateway = scalaFile('package com.demo.payments\n\nimport com.demo.core.{Invoice => _, _}\n', 'src/main/scala/com/demo/payments/Gateway.scala');
+
+ const names = extractScala([models, main, gateway]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ // Models.scala's only package-level name is the hidden Invoice — its
+ // nested `def total` is not something the wildcard could have imported.
+ expect(names).toEqual(['scala-decl:Invoice', 'scala-decl:Main', 'scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Main.scala']);
+ });
+
+ it('resolves a wildcard on an object to the file declaring it', () => {
+ const domain = scalaFile('package com.demo\n\nobject Models {\n val all = List(1)\n}\n', 'src/main/scala/com/demo/Domain.scala');
+ const main = scalaFile('package com.demo.app\n\nimport com.demo.Models.*\n', 'src/main/scala/com/demo/app/Main.scala');
+
+ const names = extractScala([domain, main]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-decl:Models', 'scala-wildcard:com/demo/Models', 'src/main/scala/com/demo/Domain.scala']);
+ });
+
+ it('expands a plain wildcard over the package files', () => {
+ const main = scalaFile('package com.demo.app\n\nimport com.demo.core._\n', 'src/main/scala/com/demo/app/Main.scala');
+ const invoice = scalaFile('package com.demo.core\n\ncase class Invoice(id: Int)\n', 'src/main/scala/com/demo/core/Invoice.scala');
+ const admin = scalaFile('package com.demo.core\n\nobject InternalAdmin\n', 'src/main/scala/com/demo/core/InternalAdmin.scala');
+
+ const names = extractScala([main, invoice, admin]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Invoice.scala', 'src/main/scala/com/demo/core/InternalAdmin.scala', 'scala-decl:Invoice', 'scala-decl:InternalAdmin']);
+ });
+
+ it('expands a Scala 3 `*` wildcard the same way as `_`', () => {
+ const main = scalaFile('package com.demo.app\n\nimport com.demo.core.*\n', 'src/main/scala/com/demo/app/Main.scala');
+ const invoice = scalaFile('package com.demo.core\n\ncase class Invoice(id: Int)\n', 'src/main/scala/com/demo/core/Invoice.scala');
+ const admin = scalaFile('package com.demo.core\n\nobject InternalAdmin\n', 'src/main/scala/com/demo/core/InternalAdmin.scala');
+
+ const names = extractScala([main, invoice, admin]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Invoice.scala', 'src/main/scala/com/demo/core/InternalAdmin.scala', 'scala-decl:Invoice', 'scala-decl:InternalAdmin']);
+ });
+
+ it('expands a wildcard over Java files of the package, minus hidden names', () => {
+ const gateway = scalaFile('package com.demo.payments\n\nimport com.demo.core.{Invoice => _, _}\n', 'src/main/scala/com/demo/payments/Gateway.scala');
+ const invoiceJava: CodeCollectedFile = {
+ path: '/virtual/src/main/java/com/demo/core/Invoice.java',
+ relativePath: 'src/main/java/com/demo/core/Invoice.java',
+ language: 'java',
+ sha256: 'test',
+ content: 'package com.demo.core;\n\npublic class Invoice {}\n',
+ };
+ const orderJava: CodeCollectedFile = {
+ ...invoiceJava,
+ relativePath: 'src/main/java/com/demo/core/Order.java',
+ content: 'package com.demo.core;\n\npublic class Order {}\n',
+ };
+ const collected = [gateway, invoiceJava, orderJava];
+
+ const names = extractScala([gateway], { allFiles: collected, priorDeclarations: new Map() })
+ .filter((f) => f.kind === 'relation')
+ .map((f) => f.name);
+
+ expect(names).toEqual(['scala-wildcard:com/demo/core', 'src/main/java/com/demo/core/Order.java']);
+ });
+
+ it('resolves against a previous run\'s declarations when only the importer changed', () => {
+ // Incremental run: the batch holds only the changed importer; the rest of
+ // the project is known by path (content-less stubs) and cached facts.
+ const gateway = scalaFile('package com.demo.payments\n\nimport com.demo.core.{Invoice => _, _}\nimport com.demo.core.Order\n', 'src/main/scala/com/demo/payments/Gateway.scala');
+ const stub = (relativePath: string): CodeCollectedFile => ({ path: `/virtual/${relativePath}`, relativePath, language: 'scala', sha256: '', content: '' });
+ const priorDeclarations = new Map>([
+ ['src/main/scala/com/demo/core/Invoice.scala', new Set(['Invoice'])],
+ ['src/main/scala/com/demo/core/Main.scala', new Set(['Main'])],
+ ['src/main/scala/com/demo/core/Models.scala', new Set(['Order'])],
+ ]);
+
+ const names = extractScala([gateway], {
+ allFiles: [gateway, stub('src/main/scala/com/demo/core/Invoice.scala'), stub('src/main/scala/com/demo/core/Main.scala'), stub('src/main/scala/com/demo/core/Models.scala')],
+ priorDeclarations,
+ })
+ .filter((f) => f.kind === 'relation')
+ .map((f) => f.name);
+
+ expect(names).toEqual(['scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Main.scala', 'src/main/scala/com/demo/core/Models.scala']);
+ });
+
+ it('keeps a declared-hidden symbol out of the wildcard even in a differently named file', () => {
+ const models = scalaFile('package com.demo.core\n\ncase class Invoice(id: Int)\n', 'src/main/scala/com/demo/core/Models.scala');
+ const main = scalaFile('package com.demo.core\n\nobject Main\n', 'src/main/scala/com/demo/core/Main.scala');
+ const gateway = scalaFile('package com.demo.payments\n\nimport com.demo.core.{Invoice => _, _}\n', 'src/main/scala/com/demo/payments/Gateway.scala');
+
+ const names = extractScala([models, main, gateway]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ // Models.scala declares only the hidden Invoice, so the wildcard skips it too.
+ expect(names).toEqual(['scala-decl:Invoice', 'scala-decl:Main', 'scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Main.scala']);
+ });
+
+ it('does not import package-adjacent resources', () => {
+ const main = scalaFile('package com.demo.app\n\nimport com.demo.core._\n', 'src/main/scala/com/demo/app/Main.scala');
+ const invoice = scalaFile('package com.demo.core\n\ncase class Invoice(id: Int)\n', 'src/main/scala/com/demo/core/Invoice.scala');
+ const schema = scalaFile('-- schema', 'src/main/resources/com/demo/core/schema.sql');
+
+ const names = extractScala([main, invoice], { allFiles: [main, invoice, schema], priorDeclarations: new Map() })
+ .filter((f) => f.kind === 'relation')
+ .map((f) => f.name);
+
+ expect(names).toEqual(['scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Invoice.scala', 'scala-decl:Invoice']);
+ });
+
+ it('resolves a top-level def import to its file', () => {
+ const helpers = scalaFile('package com.demo.core\n\ndef validate(s: String): Boolean = true\n', 'src/main/scala/com/demo/core/Helpers.scala');
+ const main = scalaFile('package com.demo.app\n\nimport com.demo.core.validate\n', 'src/main/scala/com/demo/app/Main.scala');
+
+ const names = extractScala([helpers, main]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-decl:validate', 'src/main/scala/com/demo/core/Helpers.scala']);
+ });
+
+ it('strips the _root_ qualifier from an import', () => {
+ const models = scalaFile('package com.demo.core\n\ncase class Invoice(id: Int)\n', 'src/main/scala/com/demo/core/Models.scala');
+ const gateway = scalaFile('package com.demo.payments\n\nimport _root_.com.demo.core.Invoice\n', 'src/main/scala/com/demo/payments/Gateway.scala');
+
+ const names = extractScala([models, gateway]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-decl:Invoice', 'src/main/scala/com/demo/core/Models.scala']);
+ });
+
+ it('reads nested Scala 3 package blocks', () => {
+ const models = scalaFile(
+ ['package com.demo:', ' package core:', ' case class Invoice(id: Int)', ' object Registry'].join('\n'),
+ 'src/main/scala/com/demo/core/Models.scala',
+ );
+ const gateway = scalaFile('package com.demo.payments\n\nimport com.demo.core.Invoice\n', 'src/main/scala/com/demo/payments/Gateway.scala');
+
+ const names = extractScala([models, gateway]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-decl:Invoice,Registry', 'src/main/scala/com/demo/core/Models.scala']);
+ });
+
+ it('reads declarations inside a braced package', () => {
+ const models = scalaFile(['package com.demo.core {', ' class Invoice {', ' def total: Int = 1', ' }', '}'].join('\n'), 'src/main/scala/com/demo/core/Models.scala');
+ const main = scalaFile('package com.demo.core\n\nobject Main\n', 'src/main/scala/com/demo/core/Main.scala');
+ const gateway = scalaFile('package com.demo.payments\n\nimport com.demo.core.{Invoice => _, _}\n', 'src/main/scala/com/demo/payments/Gateway.scala');
+
+ const names = extractScala([models, main, gateway]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ // The package brace does not nest the declarations away, and Models.scala
+ // declares only the hidden Invoice — the wildcard skips it.
+ expect(names).toEqual(['scala-decl:Invoice', 'scala-decl:Main', 'scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Main.scala']);
+ });
+
+ it('resolves an import of a member declared inside an object', () => {
+ const domain = scalaFile('package com.demo\n\nobject Models {\n case class Invoice(id: Int)\n}\n', 'src/main/scala/com/demo/Domain.scala');
+ const main = scalaFile('package com.demo.app\n\nimport com.demo.Models.Invoice\n', 'src/main/scala/com/demo/app/Main.scala');
+
+ const names = extractScala([domain, main]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-decl:Models', 'src/main/scala/com/demo/Domain.scala']);
+ });
+
+ it('reads a comma-separated import with a plain `as` rename', () => {
+ const facts = extracted('import com.demo.A as Alias, com.demo.B');
+
+ expect(facts).toEqual(['relation:com/demo/A', 'relation:com/demo/B']);
+ });
+
+ it('keeps a file whose hidden name shares it with live symbols', () => {
+ const invoice = scalaFile('package com.demo.core\n\ncase class Invoice(id: Int)\ncase class Order(id: Int)\n', 'src/main/scala/com/demo/core/Invoice.scala');
+ const main = scalaFile('package com.demo.core\n\nobject Main\n', 'src/main/scala/com/demo/core/Main.scala');
+ const gateway = scalaFile('package com.demo.payments\n\nimport com.demo.core.{Invoice as _, *}\n', 'src/main/scala/com/demo/payments/Gateway.scala');
+
+ const names = extractScala([invoice, main, gateway]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ // Hiding Invoice does not hide Order, which lives in the same file.
+ expect(names).toEqual(['scala-decl:Invoice,Order', 'scala-decl:Main', 'scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Invoice.scala', 'src/main/scala/com/demo/core/Main.scala']);
+ });
+
+ it('reads declarations after a braced package closes', () => {
+ const models = scalaFile(['package a.b {', ' class X', '}', '', 'package c.d {', ' class Y', '}'].join('\n'), 'src/main/scala/c/d/Models.scala');
+
+ const names = extractScala([models]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-decl:X,Y']);
+ });
+
+ it('resolves a named import to the file that declares the symbol', () => {
+ const models = scalaFile('package com.demo.core\n\ncase class Invoice(id: Int)\ncase class Order(id: Int)\n', 'src/main/scala/com/demo/core/Models.scala');
+ const gateway = scalaFile('package com.demo.payments\n\nimport com.demo.core.Order\n', 'src/main/scala/com/demo/payments/Gateway.scala');
+
+ const names = extractScala([models, gateway]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-decl:Invoice,Order', 'src/main/scala/com/demo/core/Models.scala']);
+ });
+
+ it('excludes a subpackage from a wildcard import', () => {
+ const main = scalaFile('package com.demo.app\n\nimport com.demo.core._\n', 'src/main/scala/com/demo/app/Main.scala');
+ const invoice = scalaFile('package com.demo.core\n\ncase class Invoice(id: Int)\n', 'src/main/scala/com/demo/core/Invoice.scala');
+ const admin = scalaFile('package com.demo.core.internal\n\nobject Admin\n', 'src/main/scala/com/demo/core/internal/Admin.scala');
+
+ const names = extractScala([main, invoice, admin]).filter((f) => f.kind === 'relation').map((f) => f.name);
+
+ expect(names).toEqual(['scala-wildcard:com/demo/core', 'src/main/scala/com/demo/core/Invoice.scala', 'scala-decl:Invoice', 'scala-decl:Admin']);
+ });
+
+ it('produces a dependency edge for an internal import', () => {
+ const gateway = scalaFile(
+ ['package com.demo.payments', '', 'import com.demo.core.Invoice', '', 'object Gateway {', ' def charge(i: Invoice): Boolean = true', '}'].join('\n'),
+ 'src/main/scala/com/demo/payments/Gateway.scala',
+ );
+ const invoice = scalaFile(
+ ['package com.demo.core', '', 'case class Invoice(id: Int)'].join('\n'),
+ 'src/main/scala/com/demo/core/Invoice.scala',
+ );
+
+ const graph = buildCodeGraph([...extractScala([gateway]), ...extractScala([invoice])]);
+ const edge = graph.edges.find((e) => e.relation === 'DEPENDS_ON');
+
+ expect(edge?.from).toBe('src/main/scala/com/demo/payments/Gateway.scala');
+ expect(edge?.to).toBe('src/main/scala/com/demo/core/Invoice.scala');
+ });
+
+ it('scopes a brace import to the selected symbol, not the whole package directory', () => {
+ const gateway = scalaFile(
+ ['package com.demo.payments', '', 'import com.demo.core.{Invoice}', '', 'object Gateway {', ' def charge(i: Invoice): Boolean = true', '}'].join('\n'),
+ 'src/main/scala/com/demo/payments/Gateway.scala',
+ );
+ const invoice = scalaFile(
+ ['package com.demo.core', '', 'case class Invoice(id: Int)'].join('\n'),
+ 'src/main/scala/com/demo/core/Invoice.scala',
+ );
+ const admin = scalaFile(
+ ['package com.demo.core', '', 'object InternalAdmin'].join('\n'),
+ 'src/main/scala/com/demo/core/InternalAdmin.scala',
+ );
+
+ const graph = buildCodeGraph([...extractScala([gateway]), ...extractScala([invoice]), ...extractScala([admin])]);
+ const deps = graph.edges.filter((e) => e.relation === 'DEPENDS_ON');
+
+ expect(deps).toHaveLength(1);
+ expect(deps[0].from).toBe('src/main/scala/com/demo/payments/Gateway.scala');
+ expect(deps[0].to).toBe('src/main/scala/com/demo/core/Invoice.scala');
+ });
+
+ it('resolves a brace import in the call-chain tracer', () => {
+ const main = scalaFile(
+ ['package com.demo.app', '', 'import com.demo.core.{Invoice}', '', 'object Main extends App {', ' val i: Invoice = null', '}'].join('\n'),
+ 'src/main/scala/com/demo/app/Main.scala',
+ );
+ const invoice = scalaFile(
+ ['package com.demo.core', '', 'case class Invoice(id: Int)'].join('\n'),
+ 'src/main/scala/com/demo/core/Invoice.scala',
+ );
+ const admin = scalaFile(
+ ['package com.demo.core', '', 'object InternalAdmin'].join('\n'),
+ 'src/main/scala/com/demo/core/InternalAdmin.scala',
+ );
+ const files = [main, invoice, admin];
+ const facts = files.flatMap((f) => extractScala([f]));
+
+ const chains = traceCallChains(facts, files);
+ const mainChain = chains.find((c) => c.entryPoint.includes('Main.scala'));
+
+ // The brace import resolves to Invoice.scala's component; the sibling
+ // InternalAdmin.scala in the same package directory is not pulled in.
+ expect(mainChain?.steps[0]?.callsTo).toContain('Invoice');
+ expect(mainChain?.steps.some((s) => s.file.endsWith('InternalAdmin.scala'))).toBe(false);
+ });
+
+ it('infers error types from the Error/Exception suffix and reads environment config', () => {
+ const facts = extracted(
+ [
+ 'case class PaymentError(msg: String) extends RuntimeException("boom")',
+ '',
+ 'sealed trait AppException extends Exception',
+ '',
+ 'object Config {',
+ ' val apiKey = sys.env("PAYMENTS_API_KEY")',
+ ' val region = System.getenv("AWS_REGION")',
+ '}',
+ ].join('\n'),
+ );
+
+ expect(facts).toContain('error:PaymentError');
+ expect(facts).toContain('error:AppException');
+ expect(facts).toContain('config:PAYMENTS_API_KEY');
+ expect(facts).toContain('config:AWS_REGION');
+ });
+});
+
+describe('Scala source collection', () => {
+ it('collects .scala files with the scala language and key-file marking', async () => {
+ const root = mkdtempSync(path.join(os.tmpdir(), 'teamai-scala-collect-'));
+ try {
+ mkdirSync(path.join(root, 'src', 'main', 'scala'), { recursive: true });
+ writeFileSync(path.join(root, 'src', 'main', 'scala', 'Main.scala'), 'object Main extends App\n');
+ writeFileSync(path.join(root, 'src', 'main', 'scala', 'Helper.scala'), 'object Helper { }\n');
+
+ const { manifest } = await collectCode({ root });
+ const byPath = new Map(manifest.files.map((f) => [f.relativePath, f]));
+
+ expect(byPath.get('src/main/scala/Main.scala')?.language).toBe('scala');
+ expect(byPath.get('src/main/scala/Main.scala')?.isKeyFile).toBe(true);
+ expect(byPath.get('src/main/scala/Helper.scala')?.language).toBe('scala');
+ expect(byPath.get('src/main/scala/Helper.scala')?.isKeyFile).toBe(false);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/ci/extract-mr.ts b/src/ci/extract-mr.ts
index 44185ddde..9f53edc84 100644
--- a/src/ci/extract-mr.ts
+++ b/src/ci/extract-mr.ts
@@ -256,7 +256,7 @@ export async function ciExtractMr(opts: CiExtractMrOptions): Promise {
{ cwd: businessRepo, encoding: 'utf-8', timeout: 10_000 },
);
changedFiles = diffOutput.trim().split('\n')
- .filter(f => f && /\.(ts|tsx|js|jsx|py|go|rs|java)$/.test(f));
+ .filter(f => f && /\.(ts|tsx|js|jsx|py|go|rs|java|swift|scala)$/.test(f));
if (changedFiles.length > 0) break;
} catch {
continue;
diff --git a/src/codebase-extract.ts b/src/codebase-extract.ts
index 41a2092df..8acf5a5b7 100644
--- a/src/codebase-extract.ts
+++ b/src/codebase-extract.ts
@@ -28,6 +28,11 @@ import {
formatAstStatsSummary,
} from './wiki-engine/adapters/index.js';
import type { CodeFact, InterfaceInventory, CallChain } from './wiki-engine/adapters/index.js';
+import type { CodeCollectedFile } from './wiki-engine/code-knowledge/code-collector.js';
+import { CODE_COLLECTION_VERSION } from './wiki-engine/code-knowledge/code-collector.js';
+import type { ExtractorContext } from './wiki-engine/code-knowledge/extractors/index.js';
+import { SCALA_DECL_PREFIX, SCALA_WILDCARD_PREFIX } from './wiki-engine/code-knowledge/extractors/index.js';
+import { isMetadataRelation } from './wiki-engine/code-knowledge/code-extractors.js';
import {
loadFactsCache,
saveFactsCache,
@@ -82,6 +87,11 @@ interface KnowledgeGap {
source: string;
}
+/** 用户可见的 facts:元数据 relation(通配包名、顶层声明标记)不进任何统计。 */
+function visibleFactsOf(facts: CodeFact[]): CodeFact[] {
+ return facts.filter((f) => f.kind !== 'relation' || !isMetadataRelation(f.name));
+}
+
function detectKnowledgeGaps(
facts: CodeFact[],
graph: GraphIndex,
@@ -96,7 +106,7 @@ function detectKnowledgeGaps(
}
// 1. 未解析的外部依赖:import target 不在扫描范围内
- const relationFacts = facts.filter((f) => f.kind === 'relation');
+ const relationFacts = facts.filter((f) => f.kind === 'relation' && !isMetadataRelation(f.name));
const unresolvedImports = new Set();
for (const rel of relationFacts) {
const target = rel.name;
@@ -216,7 +226,7 @@ function buildEvidencePages(
pages.set(`${kind}.md`, lines.join('\n'));
}
- const relationFacts = facts.filter((f) => f.kind === 'relation');
+ const relationFacts = facts.filter((f) => f.kind === 'relation' && !isMetadataRelation(f.name));
if (relationFacts.length > 0) {
const byDir = new Map();
for (const fact of relationFacts) {
@@ -298,7 +308,7 @@ function buildEvidencePages(
'',
`# ${project}`,
'',
- `Facts: ${facts.length} | Pages: ${pages.size}`,
+ `Facts: ${visibleFactsOf(facts).length} | Pages: ${pages.size}`,
'',
];
@@ -467,7 +477,7 @@ function buildOverview(
'',
`# ${project}`,
'',
- `**${facts.length} facts** extracted from ${new Set(facts.map(f => f.file)).size} files.`,
+ `**${visibleFactsOf(facts).length} facts** extracted from ${new Set(facts.map(f => f.file)).size} files.`,
`Graph: ${graph.nodes.length} nodes, ${graph.edges.length} edges.`,
'',
'## Module Structure',
@@ -565,7 +575,7 @@ export async function extractCodebase(opts: ExtractCodebaseOptions): Promise();
+ for (const fact of cachedFacts) {
+ if (fact.kind !== 'relation' || fact.name.startsWith(SCALA_DECL_PREFIX)) continue;
+ const touchedPackage = (pkg: string): boolean =>
+ originalChangedFiles.some((f) => f.includes(`/${pkg}/`) || f.startsWith(`${pkg}/`)) ||
+ deletedFiles.some((f) => f.includes(`/${pkg}/`) || f.startsWith(`${pkg}/`));
+ if (fact.name.startsWith(SCALA_WILDCARD_PREFIX)) {
+ if (touchedPackage(fact.name.slice(SCALA_WILDCARD_PREFIX.length))) staleImporters.add(fact.file);
+ } else if (/\.(?:scala|java)$/.test(fact.name)) {
+ // 物化目标本身变了——importer 必须重新解析
+ if (changedSet.has(fact.name)) staleImporters.add(fact.file);
+ } else {
+ // 未物化的常规路径(com/demo/core/Invoice):包目录里有文件增删时,
+ // 原本解析不到的目标可能已经可解析
+ const pkg = fact.name.split('/').slice(0, -1).join('/');
+ if (pkg && touchedPackage(pkg)) staleImporters.add(fact.file);
+ }
+ }
+ if (staleImporters.size > 0) {
+ changedFiles = [...new Set([...changedFiles, ...staleImporters])];
+ }
+ }
+
const { files, manifest: collectionManifest } = await collectCode({ root, maxFiles, changedFiles });
if (files.length === 0 && !changedFiles) {
// 全量模式下无文件
@@ -597,17 +640,39 @@ export async function extractCodebase(opts: ExtractCodebaseOptions): Promise 0 ? extractCodeFacts(files) : [];
+ // 增量模式下,跨文件解析(通配展开、符号定位到声明文件)需要未变更文件的
+ // 路径与声明;两者都能从上一轮 facts 缓存得到,未变更文件无需重新读取。
+ // 声明只从 scala-decl: 标记重建——component facts 分不出嵌套成员,
+ // 标记在提取时就只记包级名字
+ let extractionContext: ExtractorContext | undefined;
+ if (changedFiles !== undefined) {
+ const priorDeclarations = new Map>();
+ const stubs = new Map();
+ const removed = new Set([...changedFiles, ...deletedFiles]);
+ for (const fact of cachedFacts ?? []) {
+ if (!removed.has(fact.file)) {
+ stubs.set(fact.file, { path: fact.file, relativePath: fact.file, language: 'text', sha256: '', content: '' });
+ }
+ if (fact.kind === 'relation' && fact.name.startsWith(SCALA_DECL_PREFIX)) {
+ const names = new Set(fact.name.slice(SCALA_DECL_PREFIX.length).split(','));
+ priorDeclarations.set(fact.file, names);
+ }
+ }
+ for (const file of files) {
+ stubs.delete(file.relativePath); // 变更文件以本批为准
+ }
+ extractionContext = { allFiles: [...files, ...stubs.values()], priorDeclarations };
+ }
+
+ const newFacts = files.length > 0 ? extractCodeFacts(files, extractionContext) : [];
// 增量模式:加载缓存 → 剪除 → 合并
let facts: CodeFact[];
let interfaceInventory: InterfaceInventory;
- const indicesDir = path.join(wikiRoot, '.indices');
if (changedFiles !== undefined) {
// 增量模式(含 changedFiles=[] 即仅删除场景)
- const oldFacts = await loadFactsCache(indicesDir);
+ const oldFacts = cachedFacts ?? (await loadFactsCache(indicesDir));
const oldInterfaces = await loadInterfacesCache(indicesDir);
// 剪除已变更/删除的旧数据
@@ -823,7 +888,7 @@ export async function extractCodebase(opts: ExtractCodebaseOptions): Promise 0 ? ifByType : undefined,
@@ -908,7 +973,7 @@ export async function extractCodebase(opts: ExtractCodebaseOptions): Promise = {
- version: 1,
+ version: CODE_COLLECTION_VERSION,
lastScan: new Date().toISOString(),
files: allManifestFiles,
};
@@ -929,14 +994,14 @@ export async function extractCodebase(opts: ExtractCodebaseOptions): Promise = {};
- for (const fact of facts) {
+ for (const fact of visibleFactsOf(facts)) {
byKind[fact.kind] = (byKind[fact.kind] ?? 0) + 1;
}
const result: ExtractResult = {
project,
filesScanned: files.length,
- facts: { total: facts.length, byKind },
+ facts: { total: visibleFactsOf(facts).length, byKind },
graph: { nodes: repoGraph.nodes.length, edges: repoGraph.edges.length },
incremental: !!opts.incremental && !!changedFiles,
outputDir: wikiRoot,
diff --git a/src/import-repo.ts b/src/import-repo.ts
index f7b8fd360..4d559c479 100644
--- a/src/import-repo.ts
+++ b/src/import-repo.ts
@@ -119,7 +119,7 @@ export function detectCrossRepoEdges(
for (const edge of overlay.edges) {
if (edge.relation !== 'imports') continue;
const segments = edge.to.split('/');
- const fileName = segments[segments.length - 1]?.replace(/\.(ts|tsx|js|jsx|py|go|rs|java|swift)$/, '') ?? '';
+ const fileName = segments[segments.length - 1]?.replace(/\.(ts|tsx|js|jsx|py|go|rs|java|swift|scala)$/, '') ?? '';
const pascalName = fileName.split(/[-_]/).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('');
const match = existingIndex.get(pascalName.toLowerCase());
@@ -146,7 +146,7 @@ export function detectCrossRepoEdges(
for (const edge of existing.edges) {
if (edge.relation !== 'imports') continue;
const segments = edge.to.split('/');
- const fileName = segments[segments.length - 1]?.replace(/\.(ts|tsx|js|jsx|py|go|rs|java|swift)$/, '') ?? '';
+ const fileName = segments[segments.length - 1]?.replace(/\.(ts|tsx|js|jsx|py|go|rs|java|swift|scala)$/, '') ?? '';
const pascalName = fileName.split(/[-_]/).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('');
const match = overlayIndex.get(pascalName.toLowerCase());
diff --git a/src/wiki-engine/call-chain-tracer.ts b/src/wiki-engine/call-chain-tracer.ts
index 0332dbe25..cd7b2773d 100644
--- a/src/wiki-engine/call-chain-tracer.ts
+++ b/src/wiki-engine/call-chain-tracer.ts
@@ -1,5 +1,5 @@
import type { CodeCollectedFile } from './code-knowledge/code-collector.js';
-import type { CodeFact } from './code-knowledge/code-extractors.js';
+import { type CodeFact, isMetadataRelation } from './code-knowledge/code-extractors.js';
export type CallChainLayer = "entry" | "orchestration" | "service" | "data";
@@ -24,9 +24,11 @@ const ENTRY_PATTERNS = [
/route/i,
/controller/i,
/endpoint/i,
- /main\.(ts|go|py|rs|java|swift)$/,
- /server\.(ts|go|py|rs|java|swift)$/,
- /app\.(ts|go|py|rs|java|swift)$/,
+ // Case-insensitive: Scala's key files are Main.scala / App.scala, and a
+ // capitalized App.ts or Main.go is just as much an entry point.
+ /main\.(ts|go|py|rs|java|swift|scala)$/i,
+ /server\.(ts|go|py|rs|java|swift|scala)$/i,
+ /app\.(ts|go|py|rs|java|swift|scala)$/i,
];
const ORCHESTRATION_PATTERNS = [
@@ -195,7 +197,9 @@ function findEntryPoints(facts: CodeFact[], files: CodeCollectedFile[]): EntryPo
function buildRelationsByFile(facts: CodeFact[]): Map {
const map = new Map();
for (const fact of facts) {
- if (fact.kind !== "relation") continue;
+ // Metadata relations (wildcard packages, declaration markers) name no
+ // target and would only crowd the bounded relation slice in traversal.
+ if (fact.kind !== "relation" || isMetadataRelation(fact.name)) continue;
const group = map.get(fact.file) ?? [];
group.push(fact);
map.set(fact.file, group);
@@ -237,7 +241,7 @@ function resolveRelationTarget(importPath: string, filesByModule: Map = {
java: [/Application\.java$/, /Controller\.java$/, /Service\.java$/],
typescript: [/index\.ts$/, /server\.ts$/, /app\.ts$/, /router\.ts$/],
rust: [/main\.rs$/, /lib\.rs$/, /mod\.rs$/],
- swift: [/main\.swift$/, /App\.swift$/, /Package\.swift$/]
+ swift: [/main\.swift$/, /App\.swift$/, /Package\.swift$/],
+ scala: [/Main\.scala$/, /App\.scala$/]
};
export function isKeyFile(relativePath: string, language: string): boolean {
@@ -132,7 +141,7 @@ async function walk(directory: string, results: string[], includeTests: boolean)
}
function isCodeFile(filePath: string): boolean {
- return [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rs", ".java", ".swift", ".json", ".yaml", ".yml", ".toml", ".sql", ".conf", ".ini"].includes(
+ return [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".rs", ".java", ".swift", ".scala", ".json", ".yaml", ".yml", ".toml", ".sql", ".conf", ".ini"].includes(
path.extname(filePath).toLowerCase()
);
}
@@ -145,7 +154,7 @@ function languageFor(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const map: Record = {
".ts": "typescript", ".tsx": "typescript", ".js": "javascript", ".jsx": "javascript",
- ".py": "python", ".go": "go", ".rs": "rust", ".java": "java", ".swift": "swift",
+ ".py": "python", ".go": "go", ".rs": "rust", ".java": "java", ".swift": "swift", ".scala": "scala",
".json": "json", ".yaml": "yaml", ".yml": "yaml",
".toml": "toml", ".sql": "sql", ".conf": "toml", ".ini": "toml",
};
diff --git a/src/wiki-engine/code-knowledge/code-extractors.ts b/src/wiki-engine/code-knowledge/code-extractors.ts
index 7624014e4..ac550056a 100644
--- a/src/wiki-engine/code-knowledge/code-extractors.ts
+++ b/src/wiki-engine/code-knowledge/code-extractors.ts
@@ -1,5 +1,5 @@
import { type CodeCollectedFile } from "./code-collector.js";
-import { extractForLanguage } from "./extractors/index.js";
+import { extractForLanguage, type ExtractorContext } from "./extractors/index.js";
export type CodeFactKind = "component" | "interface" | "config" | "error" | "data" | "style" | "relation";
@@ -36,15 +36,28 @@ export interface CodeFact {
evidenceType?: CodeEvidenceType;
}
+/**
+ * Relations that carry extraction metadata for the incremental layer — a
+ * Scala wildcard's package, a file's top-level declarations — rather than a
+ * dependency. Evidence pages and gap detection skip them, and the graph
+ * builder resolves none of them.
+ */
+export function isMetadataRelation(name: string): boolean {
+ return /^scala-(?:wildcard|decl):/u.test(name);
+}
+
/**
* Extract code facts from collected files.
* Groups files by language, then dispatches to language-specific extractors.
+ * `context` — the run's full file list and the previous run's declarations —
+ * lets extractors resolve cross-file constructs (see ExtractorContext).
*/
-export function extractCodeFacts(files: CodeCollectedFile[]): CodeFact[] {
+export function extractCodeFacts(files: CodeCollectedFile[], context?: ExtractorContext): CodeFact[] {
const byLanguage = groupByLanguage(files);
const allFacts: CodeFact[] = [];
+ const effectiveContext: ExtractorContext = context ?? { allFiles: files, priorDeclarations: new Map() };
for (const [language, langFiles] of byLanguage) {
- allFacts.push(...extractForLanguage(language, langFiles));
+ allFacts.push(...extractForLanguage(language, langFiles, effectiveContext));
}
// Deduplicate facts by kind:name:file (same symbol in same file only kept once)
const seen = new Set();
diff --git a/src/wiki-engine/code-knowledge/code-graph.ts b/src/wiki-engine/code-knowledge/code-graph.ts
index f91b7585c..a4339fefd 100644
--- a/src/wiki-engine/code-knowledge/code-graph.ts
+++ b/src/wiki-engine/code-knowledge/code-graph.ts
@@ -1,6 +1,6 @@
import path from "node:path";
-import { type CodeFact } from "./code-extractors.js";
+import { type CodeFact, isMetadataRelation } from "./code-extractors.js";
import {
type GraphIndex,
type GraphNode,
@@ -27,7 +27,7 @@ export function buildCodeGraph(facts: CodeFact[]): GraphIndex {
const nodeFiles = new Set(facts.filter(f => f.kind !== "relation").map(f => f.file));
const edges: GraphEdge[] = facts
- .filter((fact) => fact.kind === "relation")
+ .filter((fact) => fact.kind === "relation" && !isMetadataRelation(fact.name))
.flatMap((fact) => {
// AST-derived relation facts carry a resolved target file in fact.name and a
// "(code-ast)" marker in detail — trust them directly instead of fuzzy matching.
diff --git a/src/wiki-engine/code-knowledge/code-incremental.ts b/src/wiki-engine/code-knowledge/code-incremental.ts
index 641f4847c..1211938c4 100644
--- a/src/wiki-engine/code-knowledge/code-incremental.ts
+++ b/src/wiki-engine/code-knowledge/code-incremental.ts
@@ -1,7 +1,7 @@
import { readFile, writeFile, stat, mkdir } from "node:fs/promises";
import path from "node:path";
-import { collectCode, gitCommit, gitDiffNameStatus, isWorkingTreeClean } from "./code-collector.js";
+import { CODE_COLLECTION_VERSION, collectCode, gitCommit, gitDiffNameStatus, isWorkingTreeClean } from "./code-collector.js";
import type { CodeFact } from "./code-extractors.js";
import type { InterfaceInventory } from "../interface-scanner.js";
@@ -16,22 +16,29 @@ export async function detectCodeIncrementalChanges(
root: string,
manifestPath: string,
project: string,
+ maxFiles?: number,
): Promise {
const previous = (await exists(manifestPath))
? (JSON.parse(await readFile(manifestPath, "utf8")) as {
headSha?: string;
+ version?: number;
files?: Array<{ relativePath: string; sha256: string }>;
})
: { files: [] };
const oldSha = previous.headSha;
const newSha = await gitCommit(root);
-
- // Git incremental path: only when both commits are known AND the working
- // tree is clean. A dirty tree has uncommitted/untracked changes that a
- // commit-to-commit diff cannot see, so we fall back to the full sha256
- // scan (which reads the working tree) to avoid silent staleness.
- if (oldSha && newSha && (await isWorkingTreeClean(root))) {
+ // A manifest from an older collector may simply not know files this
+ // version collects (.scala, say) — a commit-to-commit diff would report
+ // no changes and keep them absent. The sha256 path below spots them.
+ const manifestIsCurrent = previous.version === CODE_COLLECTION_VERSION;
+
+ // Git incremental path: only when both commits are known, the manifest
+ // comes from this collector version, AND the working tree is clean. A
+ // dirty tree has uncommitted/untracked changes that a commit-to-commit
+ // diff cannot see, so we fall back to the full sha256 scan (which reads
+ // the working tree) to avoid silent staleness.
+ if (oldSha && newSha && manifestIsCurrent && (await isWorkingTreeClean(root))) {
const gitChanges = await gitDiffNameStatus(root, oldSha, newSha);
if (gitChanges !== null) {
const { added, changed, deleted } = gitChanges;
@@ -44,8 +51,10 @@ export async function detectCodeIncrementalChanges(
}
}
- // Fallback: full sha256 comparison when git is unavailable or no baseline
- const current = await collectCode({ root });
+ // Fallback: full sha256 comparison when git is unavailable or no baseline.
+ // Honors the caller's --max-files: a default-capped scan against a larger
+ // prior extraction would classify the tail as deleted and prune it.
+ const current = await collectCode({ root, maxFiles });
const previousByPath = new Map((previous.files ?? []).map((file) => [file.relativePath, file.sha256]));
const currentByPath = new Map(current.manifest.files.map((file) => [file.relativePath, file.sha256]));
const added = [...currentByPath.keys()].filter((file) => !previousByPath.has(file)).sort();
diff --git a/src/wiki-engine/code-knowledge/extractors/index.ts b/src/wiki-engine/code-knowledge/extractors/index.ts
index 913e9e6f1..11ff8c8ac 100644
--- a/src/wiki-engine/code-knowledge/extractors/index.ts
+++ b/src/wiki-engine/code-knowledge/extractors/index.ts
@@ -5,10 +5,23 @@ import { extractGo } from "./go.js";
import { extractJava } from "./java.js";
import { extractPython } from "./python.js";
import { extractRust } from "./rust.js";
+import { extractScala } from "./scala.js";
import { extractSwift } from "./swift.js";
import { extractTypescript } from "./typescript.js";
-type LanguageExtractor = (files: CodeCollectedFile[]) => CodeFact[];
+/** What an extractor may need beyond its own language batch. */
+export interface ExtractorContext {
+ /** Every collected file of the run, all languages — a Scala wildcard imports Java files just as freely. */
+ allFiles: CodeCollectedFile[];
+ /**
+ * The symbol names each file declares, from the previous run's facts. An
+ * incremental run re-extracts only changed files, so unchanged files are
+ * known by their cached declarations alone.
+ */
+ priorDeclarations: Map>;
+}
+
+type LanguageExtractor = (files: CodeCollectedFile[], context?: ExtractorContext) => CodeFact[];
/**
* Registry mapping language identifiers to their specialized extractors.
@@ -20,6 +33,7 @@ const EXTRACTOR_REGISTRY: Record = {
python: extractPython,
java: extractJava,
rust: extractRust,
+ scala: extractScala,
swift: extractSwift,
toml: extractToml,
sql: extractSql,
@@ -27,14 +41,16 @@ const EXTRACTOR_REGISTRY: Record = {
/**
* Dispatch extraction to the appropriate language-specific extractor.
+ * `context` — the run's full file list and the previous run's declarations —
+ * lets an extractor resolve cross-language and cross-file constructs.
* Falls back to an empty array for unsupported languages (json, yaml, text, etc.).
*/
-export function extractForLanguage(language: string, files: CodeCollectedFile[]): CodeFact[] {
+export function extractForLanguage(language: string, files: CodeCollectedFile[], context?: ExtractorContext): CodeFact[] {
const extractor = EXTRACTOR_REGISTRY[language];
if (!extractor) {
return [];
}
- return extractor(files);
+ return extractor(files, context);
}
/**
@@ -48,5 +64,6 @@ export { extractGo } from "./go.js";
export { extractJava } from "./java.js";
export { extractPython } from "./python.js";
export { extractRust } from "./rust.js";
+export { extractScala, SCALA_DECL_PREFIX, SCALA_WILDCARD_PREFIX } from "./scala.js";
export { extractSwift } from "./swift.js";
export { extractTypescript } from "./typescript.js";
diff --git a/src/wiki-engine/code-knowledge/extractors/scala.ts b/src/wiki-engine/code-knowledge/extractors/scala.ts
new file mode 100644
index 000000000..0dc858800
--- /dev/null
+++ b/src/wiki-engine/code-knowledge/extractors/scala.ts
@@ -0,0 +1,416 @@
+import { type CodeCollectedFile } from "../code-collector.js";
+import { type CodeFact, type CodeFactKind, mapKindToEvidenceType } from "../code-extractors.js";
+import { type ExtractorContext } from "./index.js";
+
+const TYPE_DECL_PATTERN = /^(class|object|enum)\s+([A-Z]\w*)/u;
+const DEF_DECL_PATTERN = /^def\s+([a-z_]\w*)/u;
+const TRAIT_DECL_PATTERN = /^trait\s+([A-Z]\w*)/u;
+
+/**
+ * Marks a wildcard relation: `scala-wildcard:com/demo/core`. The name matches
+ * no file, so no consumer resolves it; the incremental layer reads these
+ * markers to re-extract importers whose package membership changed.
+ */
+export const SCALA_WILDCARD_PREFIX = "scala-wildcard:";
+
+/**
+ * Marks a file's top-level declaration names, comma-joined:
+ * `scala-decl:Invoice,Order`. Like the wildcard marker it is metadata for the
+ * incremental layer — the next run rebuilds the declarations index from these
+ * instead of component facts, which cannot tell a nested member from a
+ * package-level name.
+ */
+export const SCALA_DECL_PREFIX = "scala-decl:";
+
+/**
+ * Scala extractor.
+ * Extracts classes, objects, traits, enums, defs, configs, errors, and import relations.
+ *
+ * `context.allFiles` — every collected file, all languages — resolves wildcard
+ * imports in a mixed project, where a Scala wildcard imports Java files just as
+ * freely as Scala ones. `context.priorDeclarations` — the previous run's
+ * declarations — keeps symbol resolution working in an incremental run, which
+ * re-extracts only changed files.
+ */
+export function extractScala(files: CodeCollectedFile[], context?: ExtractorContext): CodeFact[] {
+ const facts: CodeFact[] = [];
+ // An incremental run re-parses only changed files; everything else is known
+ // by its cached declarations, which the fresh parse then overrides.
+ const declarations = new Map(context?.priorDeclarations ?? []);
+ for (const file of files) {
+ declarations.set(file.relativePath, declarationNames(file.content));
+ }
+ const extractContext: ExtractContext = { allFiles: context?.allFiles ?? files, declarations };
+
+ for (const file of files) {
+ const lines = file.content.split(/\r?\n/);
+ const declared = declarations.get(file.relativePath);
+ if (declared && declared.size > 0) {
+ facts.push(makeFact("relation", `${SCALA_DECL_PREFIX}${[...declared].join(",")}`, file.relativePath, 1, "", "EXTRACTED"));
+ }
+ // A brace selector scalafmt wraps across lines stays open until its `}`.
+ let openImport: { line: number; prefix: string; selector: string } | undefined;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const lineNumber = i + 1;
+
+ if (openImport) {
+ openImport.selector += ` ${line}`;
+ if (openImport.selector.includes("}")) {
+ const { line: start, prefix, selector } = openImport;
+ openImport = undefined;
+ pushRelations(facts, extractContext, prefix, selector.split("}")[0], file.relativePath, start, line);
+ }
+ continue;
+ }
+
+ // Annotations (`@deprecated`), modifiers (`final case`), and Scala 3
+ // modifiers (`transparent inline`) precede the keyword on the same line.
+ const decl = stripLeadingModifiers(line);
+
+ // --- Components ---
+ const typeDecl = TYPE_DECL_PATTERN.exec(decl);
+ if (typeDecl) {
+ facts.push(makeFact("component", typeDecl[2], file.relativePath, lineNumber, line, "EXTRACTED"));
+ }
+
+ const defDecl = DEF_DECL_PATTERN.exec(decl);
+ if (defDecl) {
+ facts.push(makeFact("component", defDecl[1], file.relativePath, lineNumber, line, "EXTRACTED"));
+ }
+
+ // --- Interfaces ---
+ const traitDecl = TRAIT_DECL_PATTERN.exec(decl);
+ if (traitDecl) {
+ facts.push(makeFact("interface", traitDecl[1], file.relativePath, lineNumber, line, "EXTRACTED"));
+ }
+
+ // --- Configs ---
+ const envRead = /\b(?:sys\.env|System\.getenv)\s*\(\s*"([A-Z][A-Z0-9_]+)"\s*\)/u.exec(line);
+ if (envRead) {
+ facts.push(makeFact("config", envRead[1], file.relativePath, lineNumber, line, "EXTRACTED"));
+ }
+
+ // --- Errors ---
+ const typeName = typeDecl?.[2] ?? traitDecl?.[1];
+ if (typeName && /(?:Error|Exception)$/u.test(typeName)) {
+ facts.push(makeFact("error", typeName, file.relativePath, lineNumber, line, "INFERRED"));
+ }
+
+ // --- Relations ---
+ // One clause per imported symbol, as a path both relation consumers can
+ // match: buildCodeGraph compares file paths, and the call-chain tracer
+ // looks files up by path and basename. Several imports may share one
+ // line (`import a.B, c.D`); a brace selector may continue on the lines
+ // below, in which case it opens and waits for its `}`.
+ const importHead = /^import\s+(.+)$/u.exec(decl);
+ if (importHead) {
+ let rest = importHead[1];
+ while (rest.length > 0) {
+ const clause = /^([A-Za-z_]\w*(?:\.\w+)*(?:\.\*)?)(\s*\.\s*\{([^}]*)\})?/u.exec(rest);
+ if (!clause) {
+ break;
+ }
+ // A plain clause may carry a Scala 3 rename (`a.B as Alias`) — the
+ // imported name is the part before it.
+ const after = rest.slice(clause[0].length);
+ const plainRename = /^\s+as\s+[A-Za-z_]\w*/u.exec(after)?.[0].length ?? 0;
+ const consumed = after.slice(plainRename);
+ if (/^\s*\.\s*\{/u.test(consumed)) {
+ openImport = { line: lineNumber, prefix: clause[1], selector: consumed.replace(/^\s*\.\s*\{/u, "") };
+ break;
+ }
+ pushRelations(facts, extractContext, clause[1], clause[3], file.relativePath, lineNumber, line);
+ rest = consumed.replace(/^\s*,\s*/u, "");
+ }
+ }
+ }
+ }
+
+ // Two imports of one file (a wildcard plus a named symbol, say) name the
+ // same dependency twice — keep one relation per target per file.
+ const seenRelations = new Set();
+ const uniqueFacts: CodeFact[] = [];
+ for (const fact of facts) {
+ if (fact.kind !== "relation") {
+ uniqueFacts.push(fact);
+ continue;
+ }
+ const key = `${fact.file}|${fact.name}`;
+ if (seenRelations.has(key)) {
+ continue;
+ }
+ seenRelations.add(key);
+ uniqueFacts.push(fact);
+ }
+ return uniqueFacts;
+}
+
+/**
+ * `case` is the one token that is both a declaration modifier (`case class`)
+ * and a `match` clause (`case Invoice(id) =>`). It is stripped either way, but
+ * a clause line then starts with a capitalized name — no declaration keyword —
+ * so it cannot be mistaken for a declaration.
+ */
+const MODIFIER_PATTERN =
+ /^\s*(?:@\w+(?:\[[^\]]*\])?(?:\([^)]*\))?\s+)*(?:(?:private|protected)(?:\[[^\]]*\])?\s+|(?:final|sealed|abstract|case|implicit|lazy|override|open|transparent|inline|infix|erased)\s+)*/u;
+
+/** Anything after the leading annotations/modifiers is the declaration itself. */
+function stripLeadingModifiers(line: string): string {
+ return line.replace(MODIFIER_PATTERN, "");
+}
+
+interface ExtractContext {
+ /** Every collected file of the run — a wildcard imports Java files as freely as Scala ones. */
+ allFiles: CodeCollectedFile[];
+ /** relativePath → the symbol names the file declares, this run or cached. */
+ declarations: Map>;
+}
+
+/**
+ * Emit one relation per imported symbol, as a path both relation consumers
+ * match — a bare package path would tie the importer to every file in the
+ * directory. A named symbol resolves to the file in the package that declares
+ * it (Scala freely puts many types in one `Models.scala`), or to the
+ * conventional path when no collected file declares it. A wildcard (a
+ * trailing `._`/`.*` or a `_`/`*` entry) names the direct members of the
+ * package directory, minus the names the selector hides; with no collected
+ * file to name (an external package) the package path is all that is left to
+ * say.
+ */
+function pushRelations(
+ facts: CodeFact[],
+ context: ExtractContext,
+ dottedPrefix: string,
+ selector: string | undefined,
+ file: string,
+ lineNumber: number,
+ rawLine: string
+): void {
+ const packagePath = toPath(dottedPrefix);
+ const selection = selector !== undefined ? parseSelectors(selector) : undefined;
+ const wildcard = /[._*]$/u.test(dottedPrefix) || (selection?.wildcard ?? false);
+ let targets: string[];
+ if (wildcard) {
+ facts.push(makeFact("relation", `${SCALA_WILDCARD_PREFIX}${packagePath}`, file, lineNumber, rawLine, "EXTRACTED"));
+ const hidden = new Set(selection?.hidden ?? []);
+ const expanded = expandWildcard(packagePath, context, hidden);
+ if (expanded.length > 0) {
+ targets = expanded;
+ } else {
+ // `com.demo.Models.*` wildcards an object, not a package — resolve it to
+ // the file that declares the object, as a named import would.
+ const resolved = resolveSymbolPath(toModulePath(packagePath), file, context);
+ targets = resolved !== undefined ? [resolved] : [packagePath];
+ }
+ } else if (selection && selection.symbols.length > 0) {
+ targets = [
+ ...new Set(
+ selection.symbols.map((symbol) => resolveSymbolPath(`${packagePath}/${symbol}`, file, context) ?? toModulePath(`${packagePath}/${symbol}`)),
+ ),
+ ];
+ } else if (selection) {
+ // only hidden names: the clause imports nothing, so no relation at all
+ targets = [];
+ } else {
+ // A plain import names a symbol (`com.foo.Bar` — or a Scala 3 top-level
+ // `def validate`) preceded by its package; the conventional path is the
+ // fallback when no collected file declares the symbol.
+ const modulePath = toModulePath(packagePath);
+ targets = [resolveSymbolPath(modulePath, file, context) ?? modulePath];
+ }
+ for (const target of targets) {
+ facts.push(makeFact("relation", target, file, lineNumber, rawLine, "EXTRACTED"));
+ }
+}
+
+interface ImportSelection {
+ /** A `_` or `*` entry imports everything in the package. */
+ wildcard: boolean;
+ /** The symbols the selector names (`Invoice as Inv` imports `Invoice`). */
+ symbols: string[];
+ /** Names renamed to `_` — imported only to be hidden. */
+ hidden: string[];
+}
+
+/**
+ * `{Invoice as Inv, Order => O}` → symbols `["Invoice", "Order"]`: the name an
+ * entry imports is the part before the rename (`=>` in Scala 2, `as` in
+ * Scala 3). An alias of `_` hides its name, and `_` or `*` alone imports the
+ * whole package. Anything that is not a plain identifier (nested selectors)
+ * is dropped; the package covers it.
+ */
+function parseSelectors(selector: string): ImportSelection {
+ const selection: ImportSelection = { wildcard: false, symbols: [], hidden: [] };
+ for (const entry of selector.split(",")) {
+ const item = entry.trim();
+ const renamed = /^([A-Za-z_]\w*)\s*(?:=>|\bas\b)\s*(.+)$/u.exec(item);
+ const alias = renamed?.[2].trim();
+ if (item === "_" || item === "*") {
+ selection.wildcard = true;
+ } else if (renamed && alias === "_") {
+ selection.hidden.push(renamed[1]);
+ } else if (renamed && alias && /^[A-Za-z_]\w*$/u.test(alias)) {
+ selection.symbols.push(renamed[1]);
+ } else if (!renamed && /^[A-Za-z_]\w*$/u.test(item)) {
+ selection.symbols.push(item);
+ }
+ }
+ selection.symbols = [...new Set(selection.symbols)];
+ return selection;
+}
+
+/** `_root_.com.foo.Bar`/`com.foo.Bar` → `com/foo/Bar`; a trailing wildcard (`_`, `*`) names the whole package and drops off. */
+function toPath(dotted: string): string {
+ return dotted.replace(/^_root_\./u, "").replace(/[._*]+$/u, "").replace(/\./gu, "/");
+}
+
+/**
+ * `com/demo/core/Invoice/apply` → `com/demo/core/Invoice`: a lowercase tail
+ * after a type names a member of it, and the file that defines the type is
+ * the file the consumers can match.
+ */
+function toModulePath(path: string): string {
+ const segments = path.split("/");
+ let lastType = -1;
+ for (let i = 0; i < segments.length; i++) {
+ if (/^[A-Z]/u.test(segments[i])) {
+ lastType = i;
+ }
+ }
+ return lastType === -1 ? path : segments.slice(0, lastType + 1).join("/");
+}
+
+/**
+ * The files a wildcard import of `packagePath` brings in: the package's
+ * JVM-importable members, minus those hidden by file name and those whose
+ * every declared symbol is hidden.
+ */
+function expandWildcard(packagePath: string, context: ExtractContext, hidden: ReadonlySet): string[] {
+ return packageMembers(packagePath, context.allFiles)
+ .filter((candidate) => {
+ // Exclusion is declaration-based when the names are known: a file with
+ // any live declaration still carries its other symbols. Only a file
+ // with no known declarations falls back to its file name.
+ const declared = context.declarations.get(candidate.relativePath);
+ if (declared && declared.size > 0) {
+ return ![...declared].every((name) => hidden.has(name));
+ }
+ return !hidden.has(fileName(candidate.relativePath));
+ })
+ .map((candidate) => candidate.relativePath);
+}
+
+/**
+ * The file a dotted import path resolves to: the package member that declares
+ * the last type, or — failing that — an earlier one (`com/demo/Models/Invoice`
+ * names a member of `object Models`, which lives in Models' own declaring
+ * file). Undefined when no collected file declares any of them.
+ */
+function resolveSymbolPath(modulePath: string, importer: string, context: ExtractContext): string | undefined {
+ const segments = modulePath.split("/");
+ for (let i = segments.length - 1; i >= 1; i--) {
+ const symbol = segments[i];
+ const packagePath = segments.slice(0, i).join("/");
+ for (const candidate of packageMembers(packagePath, context.allFiles)) {
+ if (candidate.relativePath !== importer && context.declarations.get(candidate.relativePath)?.has(symbol)) {
+ return candidate.relativePath;
+ }
+ }
+ }
+ return undefined;
+}
+
+/**
+ * The JVM-importable direct members of the package directory — a subpackage's
+ * files are not among them, and neither is a resource like a `.sql` schema
+ * that happens to sit beside the sources.
+ */
+function packageMembers(packagePath: string, files: CodeCollectedFile[]): CodeCollectedFile[] {
+ return files
+ .filter((candidate) => /\.(?:scala|java)$/u.test(candidate.relativePath))
+ .filter((candidate) => {
+ const tail = afterPackage(candidate.relativePath, packagePath);
+ return tail !== undefined && !tail.includes("/");
+ });
+}
+
+/** The part of `relativePath` below `com/demo/core`, or undefined when the file is elsewhere. */
+function afterPackage(relativePath: string, packagePath: string): string | undefined {
+ if (relativePath.startsWith(`${packagePath}/`)) {
+ return relativePath.slice(packagePath.length + 1);
+ }
+ const marker = `/${packagePath}/`;
+ const at = relativePath.indexOf(marker);
+ return at === -1 ? undefined : relativePath.slice(at + marker.length);
+}
+
+/**
+ * The top-level type, trait and def names a file declares — the names another
+ * file can import from the package. A member sits inside braces or, in
+ * significant-indentation Scala 3, deeper than its package block's member
+ * indent (Scala 3 nests package blocks, each with its own level); either
+ * way it is not a package-level name.
+ */
+function declarationNames(content: string): Set {
+ const names = new Set();
+ let depth = 0;
+ const packageMemberIndents: number[] = []; // open package blocks (`x:` or `x {`), innermost last; -1 until its member indent is seen
+ for (const rawLine of content.split(/\r?\n/)) {
+ const trimmed = rawLine.trim();
+ const indent = rawLine.length - rawLine.trimStart().length;
+ const last = packageMemberIndents.length - 1;
+ if (trimmed !== "" && last >= 0 && packageMemberIndents[last] === -1) {
+ packageMemberIndents[last] = indent; // the first content line fixes the block's member indent
+ }
+ // `package x {` opens a block, not a declaration nest: its brace does not
+ // count toward the depth that hides members.
+ const packageOpener = /^package\s+[\w.]*\s*[:{]/u.test(trimmed);
+ if (packageOpener) {
+ packageMemberIndents.push(-1);
+ } else if (trimmed !== "") {
+ while (packageMemberIndents.length > 1 && packageMemberIndents[packageMemberIndents.length - 1] > indent) {
+ packageMemberIndents.pop(); // dedented out of the inner block
+ }
+ const memberIndent = packageMemberIndents[packageMemberIndents.length - 1];
+ const topLevel = depth === 0 && (memberIndent === undefined ? indent === 0 : indent === memberIndent);
+ if (topLevel) {
+ const decl = stripLeadingModifiers(rawLine);
+ const typeDecl = TYPE_DECL_PATTERN.exec(decl);
+ if (typeDecl) {
+ names.add(typeDecl[2]);
+ }
+ const traitDecl = TRAIT_DECL_PATTERN.exec(decl);
+ if (traitDecl) {
+ names.add(traitDecl[1]);
+ }
+ const defDecl = DEF_DECL_PATTERN.exec(decl);
+ if (defDecl) {
+ names.add(defDecl[1]);
+ }
+ }
+ // A package block's closing brace has no matching opener in the count
+ // (openers are deliberately skipped) — clamp so later blocks still sit
+ // at depth 0.
+ depth = Math.max(0, depth + (rawLine.match(/\{/gu) ?? []).length - (rawLine.match(/\}/gu) ?? []).length);
+ }
+ }
+ return names;
+}
+
+/** `src/main/scala/core/Invoice.scala` → `Invoice`. */
+function fileName(relativePath: string): string {
+ return (relativePath.split("/").pop() ?? relativePath).replace(/\.\w+$/u, "");
+}
+
+function makeFact(
+ kind: CodeFactKind,
+ name: string,
+ file: string,
+ lineStart: number,
+ rawLine: string,
+ confidence: CodeFact["confidence"]
+): CodeFact {
+ return { kind, name, file, lineStart, detail: rawLine.trim(), confidence, evidenceType: mapKindToEvidenceType(kind) };
+}