Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/cachecov/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ var versionCoverage = map[int][]string{
100: {"TestExtractTestRefs_InPackageCallResolvesToProductionSymbol", "TestExtractTestRefs_ExternalTestPackageResolvesThroughImport", "TestExtractTestRefs_SkipsBuiltinsAndEmitsNoSymbols", "TestExtractTestRefs_IgnoresFilesItDoesNotOwn", "TestExtractTestRefs_ReferenceFreeFileYieldsNoFact", "TestDefaultTestGlobsCoverGoAndStayIgnored", "TestGolden"}, // Go test_ref facts: both gates, so a function called only from its _test.go is no longer high-confidence dead
101: {"TestGoHTTPClient_PackageConstAbsoluteBaseURL_TaggedExternalWithHost", "TestGoHTTPClient_PackageScopedConstFromSiblingFile_TaggedExternal", "TestGoHTTPClient_StructFieldAbsoluteBaseURL_TaggedExternalNoHostWhenAmbiguous", "TestGoHTTPClient_ConfigInjectedBaseURL_NotTaggedExternal", "TestGoHTTPClient_RelativeLiteralBaseURL_NotTaggedExternal", "TestGoHTTPClient_MixedAbsoluteAndRelativeBindings_NotTaggedExternal", "TestGoHTTPClient_RouteNameUnchangedByExternalTagging", "TestComputeLinks_ExternalClientStillMatchesLoadedServer", "TestGolden"}, // Go base-URL host recovery: third-party client calls tagged external, hardcoded internal host still resolves
102: {"TestTypeAlias_EmitsReferenceEdgeToUnderlyingType", "TestTypeAlias_FunctionTypeRHSEmitsNoEdge", "TestGolden"}, // Swift typealias folds the aliased type in as a RelInstantiates edge, so a type reached only through its alias name is not mis-reported as dead code
103: {"TestExtractTestRefs_ImportedCallResolvesToProductionSymbol", "TestExtractTestRefs_AliasAndNamespaceImportsResolve", "TestExtractTestRefs_EmitsOnlyTestRefNoSymbols", "TestExtractTestRefs_IgnoresNonTestFiles", "TestExtractTestRefs_ReferenceFreeFileYieldsNoFact", "TestDefaultTestGlobsCoverTypeScriptAndStayIgnored", "TestGolden"}, // TS test_ref facts: both gates, so a function called only from its *.test.ts(x)/*.spec.ts(x) is no longer high-confidence dead
}

func TestCacheVersionCoverage(t *testing.T) {
Expand Down
13 changes: 9 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,15 @@ func Default() *Config {
// production symbol is exercised by a test and not mis-report it as dead.
// A glob here without an extractor implementing plugin.TestRefExtractor is a
// no-op (engine.runTestRefExtractors skips non-implementers), so extend this
// list only alongside the matching extractor. Go's bare suffix is correct:
// the toolchain defines any *_test.go as a test file, so — unlike Ruby's
// _test.rb (v97) — no production file can collide with it.
TestGlobs: []string{"**/*_test.go", "**/spec/**/*_spec.rb", "**/test/**/*_test.rb"},
// list only alongside the matching extractor. Go's and TypeScript's dotted
// suffixes are correct: the toolchain/convention reserves *_test.go and
// *.test.ts(x)/*.spec.ts(x) for tests, so — unlike Ruby's _test.rb (v97) — no
// production file can collide with them.
TestGlobs: []string{
"**/*_test.go",
"**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx",
"**/spec/**/*_spec.rb", "**/test/**/*_test.rb",
},
Extractors: []string{"cpp", "go", "grpc", "java", "kotlin", "openapi", "php", "python", "typescript", "swift", "ruby"},
Explainers: []string{"cycles", "layers", "crossrepo", "coverage", "unused-routes", "god-class", "hotspots", "dependency-depth", "exported-surface", "complexity-outliers"},
Renderers: []string{"llm_context"},
Expand Down
22 changes: 22 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,25 @@ func TestDefaultTestGlobsCoverGoAndStayIgnored(t *testing.T) {
}
}
}

// TestDefaultTestGlobsCoverTypeScriptAndStayIgnored pins both halves of the
// test-ref contract for TypeScript (GAP-XL-02 TS half, v103): the four
// *.test.ts(x)/*.spec.ts(x) globs must be in TestGlobs (so an ignored test file's
// references are recovered) AND stay in Ignore (so test symbols are never indexed
// as production code). Adding to one list and not the other silently drops the file
// or pollutes the production graph.
//
// Like Go's *_test.go, the dotted suffixes are unambiguous test markers, so no
// production file can collide — no directory scoping needed (contrast Ruby, v97).
func TestDefaultTestGlobsCoverTypeScriptAndStayIgnored(t *testing.T) {
cfg := Default()

for _, g := range []string{"**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"} {
if !contains(cfg.TestGlobs, g) {
t.Errorf("Default().TestGlobs missing %q — TS test files are ignored but never recovered", g)
}
if !contains(cfg.Ignore, g) {
t.Errorf("TestGlob %q is not in Default().Ignore; a test glob that is not ignored indexes test symbols as production code", g)
}
}
}
11 changes: 10 additions & 1 deletion internal/engine/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,16 @@ import (
// now emits the edge (guarded like handleInit — system types and function/tuple/
// optional RHS shapes, which have no simple type name, emit nothing). Swift symbol
// facts gain a relation, so cached Swift snapshots must re-extract.
const cacheVersion = "v102"
// v103: the TypeScript extractor implements plugin.TestRefExtractor, and
// config.Default().TestGlobs gains the four *.test.ts(x)/*.spec.ts(x) globs, so a
// production symbol whose only caller is its co-located test keeps an incoming
// edge and is no longer reported dead (GAP-XL-02 TS half — the last language
// affected under config.Default(), after Go at v100). ExtractTestRefs reuses the
// file-ref walk's production resolvers so targets are fully qualified (no bare-name
// over-crediting via orphans' lastSeg fold). TS test files now emit test_ref facts,
// so cached TS snapshots must re-extract; the bump is required because the TS
// extractor is a FileOwner (cached).
const cacheVersion = "v103"

// extractorCache holds per-extractor facts keyed by a content hash of the files
// the extractor depends on. It is loaded from disk at the start of a snapshot and
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/testdata/golden/ts_sample.facts.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
{"kind":"symbol","name":"src.Repo.all","file":"src/repo.ts","line":3,"repo":"ts_sample","props":{"cyclomatic":1,"exported":true,"language":"typescript","receiver":"Repo","symbol_kind":"method"},"relations":[{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.Service","file":"src/svc.ts","line":4,"repo":"ts_sample","props":{"exported":true,"language":"typescript","symbol_kind":"class"},"relations":[{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.Service.list","file":"src/svc.ts","line":7,"repo":"ts_sample","props":{"cyclomatic":1,"exported":true,"language":"typescript","receiver":"Service","symbol_kind":"method"},"relations":[{"kind":"calls","target":"src.Service.repo"},{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.formatTags","file":"src/util.ts","line":4,"repo":"ts_sample","props":{"cyclomatic":2,"exported":true,"language":"typescript","loop_count":1,"loop_depth":1,"scaling_loop_depth":1,"symbol_kind":"function"},"relations":[{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.getPath","file":"src/repo.ts","line":9,"repo":"ts_sample","props":{"calls_in_loop":["src.lookup"],"calls_in_scaling_loop":["src.lookup"],"cyclomatic":2,"exported":true,"language":"typescript","loop_count":1,"loop_depth":1,"scaling_loop_depth":0,"symbol_kind":"function"},"relations":[{"kind":"calls","target":"src.lookup"},{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.lookup","file":"src/repo.ts","line":22,"repo":"ts_sample","props":{"cyclomatic":1,"exported":false,"language":"typescript","symbol_kind":"function"},"relations":[{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.main","file":"src/index.ts","line":5,"repo":"ts_sample","props":{"cyclomatic":1,"exported":true,"language":"typescript","symbol_kind":"function"},"relations":[{"kind":"declares","target":"src"}]}
{"kind":"symbol","name":"src.seed","file":"src/repo.ts","line":16,"repo":"ts_sample","props":{"calls_in_loop":["src.lookup"],"calls_in_scaling_loop":[],"cyclomatic":2,"exported":true,"language":"typescript","loop_count":1,"loop_depth":1,"scaling_loop_depth":0,"symbol_kind":"function"},"relations":[{"kind":"calls","target":"src.lookup"},{"kind":"declares","target":"src"}]}
{"kind":"test_ref","name":"src/util.test.ts","file":"src/util.test.ts","line":1,"repo":"ts_sample","props":{"language":"typescript"},"relations":[{"kind":"calls","target":"src.expect"},{"kind":"calls","target":"src.formatTags"},{"kind":"calls","target":"src.test"}]}
8 changes: 8 additions & 0 deletions internal/engine/testdata/repos/ts_sample/src/util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { formatTags } from "./util";

// A co-located unit test is this helper's only caller. The engine collects it via
// config.TestGlobs and tsextractor.ExtractTestRefs emits a test_ref edge to
// src.formatTags, so it is not mis-reported as dead code.
test("formatTags trims and joins", () => {
expect(formatTags([" a ", "b "])).toBe("a,b");
});
6 changes: 6 additions & 0 deletions internal/engine/testdata/repos/ts_sample/src/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// formatTags is a production helper exercised only by its co-located test. Before
// the TS test-ref gate (v103) it had no incoming edge and was reported dead; the
// test file below now credits it.
export function formatTags(tags: string[]): string {
return tags.map((t) => t.trim()).join(",");
}
4 changes: 2 additions & 2 deletions internal/engine/walk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ func TestWalkRepo_IgnoredDirectoryIsCounted(t *testing.T) {
t.Errorf("pruned subtree leaked into files: %q", got)
}
}
if len(testFiles) != 0 {
t.Errorf("testFiles = %v, want none (a .test.ts matches no default TestGlob)", testFiles)
if len(testFiles) != 1 || filepath.ToSlash(testFiles[0]) != "src/app.test.ts" {
t.Errorf("testFiles = %v, want [src/app.test.ts] (a .test.ts is ignored for indexing but matches the default TestGlob, so it is collected for reference-only extraction)", testFiles)
}
}

Expand Down
154 changes: 154 additions & 0 deletions internal/extractors/tsextractor/testrefs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package tsextractor

import (
"context"
"testing"

"github.com/enola-labs/enola/internal/facts"
)

// testRefTargets returns the RelCalls targets of a single test-ref fact, failing
// the test unless exactly one fact was produced and it has the shape the
// plugin.TestRefExtractor contract demands: kind test_ref, name == file == relFile,
// language "typescript", and no relation other than "calls".
func testRefTargets(t *testing.T, ff []facts.Fact, relFile string) map[string]bool {
t.Helper()
if len(ff) != 1 {
t.Fatalf("want exactly 1 test_ref fact, got %d: %+v", len(ff), ff)
}
f := ff[0]
if f.Kind != facts.KindTestRef {
t.Fatalf("kind = %q, want %q", f.Kind, facts.KindTestRef)
}
if f.Name != relFile || f.File != relFile {
t.Fatalf("name/file = %q/%q, want %q", f.Name, f.File, relFile)
}
if lang, _ := f.Props["language"].(string); lang != "typescript" {
t.Fatalf("props[language] = %v, want \"typescript\"", f.Props["language"])
}
out := map[string]bool{}
for _, r := range f.Relations {
if r.Kind != facts.RelCalls {
t.Fatalf("relation kind = %q, want only %q", r.Kind, facts.RelCalls)
}
out[r.Target] = true
}
return out
}

// TestExtractTestRefs_ImportedCallResolvesToProductionSymbol pins the dominant TS
// idiom: a co-located *.test.ts imports a production function and calls it. The
// emitted target must equal the production symbol's fact name ("<dir>.<name>"),
// which is what the dead-code detector matches against — so a helper exercised only
// by its test keeps an incoming edge. (v103)
func TestExtractTestRefs_ImportedCallResolvesToProductionSymbol(t *testing.T) {
dir := setupTSProject(t, map[string]string{
"src/util.ts": `export function formatTag(s: string): string { return s.trim(); }`,
"src/util.test.ts": `import { formatTag } from './util';` + "\n" + `test('formats', () => { formatTag(' x '); });`,
}, false)

ff, err := New().ExtractTestRefs(context.Background(), dir, []string{"src/util.test.ts"})
if err != nil {
t.Fatal(err)
}
got := testRefTargets(t, ff, "src/util.test.ts")

if !got["src.formatTag"] {
t.Errorf("missing target src.formatTag; got %v", got)
}
}

// TestExtractTestRefs_AliasAndNamespaceImportsResolve proves ExtractTestRefs
// reconstructs the tsconfig path aliases (collectTSAliasRoots) and reuses the
// production namespace-member resolver, so a helper reached through a "~/…" alias or
// an `import * as ns` member access is still credited. (v103)
func TestExtractTestRefs_AliasAndNamespaceImportsResolve(t *testing.T) {
dir := setupTSProject(t, map[string]string{
"tsconfig.json": `{"compilerOptions":{"paths":{"~/*":["./src/*"]}}}`,
"src/helper.ts": `export function helper(): number { return 1; }`,
"src/util/ns.ts": `export function build(): number { return 2; }`,
"tests/thing.test.ts": `import { helper } from '~/helper';` + "\n" + `import * as ns from '~/util/ns';` + "\n" + `test('t', () => { helper(); ns.build(); });`,
}, false)

ff, err := New().ExtractTestRefs(context.Background(), dir, []string{"tests/thing.test.ts"})
if err != nil {
t.Fatal(err)
}
got := testRefTargets(t, ff, "tests/thing.test.ts")

if !got["src.helper"] {
t.Errorf("missing alias-imported target src.helper; got %v", got)
}
if !got["src/util.build"] {
t.Errorf("missing namespace-member target src/util.build; got %v", got)
}
}

// TestExtractTestRefs_EmitsOnlyTestRefNoSymbols guards the two invariants the
// plugin.TestRefExtractor contract states: reference-only facts (never a symbol,
// module or route fact — test code must not become a dead-code candidate), and no
// target for an external import that no production symbol backs. (v103)
func TestExtractTestRefs_EmitsOnlyTestRefNoSymbols(t *testing.T) {
dir := setupTSProject(t, map[string]string{
"src/svc.ts": `export function doWork(): void {}`,
"src/svc.spec.ts": `import { render } from '@testing-library/react';` + "\n" + `import { doWork } from './svc';` + "\n" + `test('w', () => { doWork(); });`,
}, false)

ff, err := New().ExtractTestRefs(context.Background(), dir, []string{"src/svc.spec.ts"})
if err != nil {
t.Fatal(err)
}
for _, f := range ff {
if f.Kind != facts.KindTestRef {
t.Fatalf("emitted a non-test_ref fact: %+v", f)
}
}
got := testRefTargets(t, ff, "src/svc.spec.ts")

if !got["src.doWork"] {
t.Errorf("missing target src.doWork; got %v", got)
}
// `render` is imported from an external module (skipped) and never called, so it
// binds no internal target — an unused external import must not be credited.
if got["render"] || got["src.render"] {
t.Errorf("unused external import render must not become a target; got %v", got)
}
}

// TestExtractTestRefs_IgnoresNonTestFiles is the guard for the engine's hand-off.
// tsextractor IS a plugin.FileOwner (for production caching), so runTestRefExtractors
// scopes files to isTypeScriptFile — which owns non-test .ts too, plus other
// languages' specs may still arrive. ExtractTestRefs must filter to *.test/spec.ts(x)
// itself and must not treat a production .ts file (or a Ruby spec) as a test. (v103)
func TestExtractTestRefs_IgnoresNonTestFiles(t *testing.T) {
dir := setupTSProject(t, map[string]string{
"src/svc.ts": `export function doWork(): void {}`,
}, false)

ff, err := New().ExtractTestRefs(context.Background(), dir, []string{
"src/svc.ts", // production source, not a test
"spec/thing_spec.rb", // Ruby's, not ours
})
if err != nil {
t.Fatal(err)
}
if len(ff) != 0 {
t.Fatalf("want no facts from files tsextractor must not treat as tests, got %+v", ff)
}
}

// TestExtractTestRefs_ReferenceFreeFileYieldsNoFact: a test file that references no
// production code produces no fact at all, rather than an empty one.
func TestExtractTestRefs_ReferenceFreeFileYieldsNoFact(t *testing.T) {
dir := setupTSProject(t, map[string]string{
"src/nothing.test.ts": `const x: number = 1;`,
}, false)

ff, err := New().ExtractTestRefs(context.Background(), dir, []string{"src/nothing.test.ts"})
if err != nil {
t.Fatal(err)
}
if len(ff) != 0 {
t.Fatalf("want no fact for a reference-free test file, got %+v", ff)
}
}
Loading
Loading