From 51c4124daddd788f622f140abf69ea5abad3f956 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 23 Oct 2026 08:47:14 +0000 Subject: [PATCH] fix(cfml): a dotted extends finds its component when a test suite mirrors its path (#2835) A CFML component that extends another by its dotted path, like extends="contenscms.core.basecore", got no extends edge when a test suite held a file of the same name in a folder of the same name, such as a TestBox spec at tests/testbox/tests/specs/unit/core/basecore.cfc. resolveCfmlComponentPath counts the path's folders that match a candidate's, from the right. contenscms is a server mapping onto contens/, so only core matched, for the component and the spec alike, and a tie gives no edge. The rule the resolver applies elsewhere since PR #2209, that production code never names a symbol in a test suite, never ran on this path: it returns before the name matcher's visibility gate. Fix: - A component in a test suite is no candidate for a reference from production code (isTestSuitePath, now exported from the name matcher). This also keeps application code from extending a test file that happens to match more folders. - A test's tie between a component and a suite file that mirrors its path goes to the component outside the test suites. Two production components still tie. Validation: - cfml-inheritance-resolution.test.ts: 2 new cases (the issue's tree in script and tag form, a test extending the component, a mirror that matches more folders, a path only a test suite holds, a test naming its own suite) fail on main's resolver; all 12 pass with the fix. - The issue's repro: page and tagpage now extend contens/core/basecore.cfc (qualified-name 0.9); on main both stayed unresolved. - A/B vs main 8fdc8a1e (CFML has no kernel path): FW/1 (305 CFML files), BlogCFC (167), ColdBox (668), ContentBox (726), Preside (1,791) and CFWheels (the repo, and its vendor/wheels core of 1,528 CFML files as a project of its own): no node, edge, reference or file error changed. None has a component and a test-suite file that tie on a dotted path. express and flask controls: full dumps byte-identical (kernel path). - Index time, median of 3 interleaved runs, CPU with workers, main vs fix: Preside 63.6 / 60.4 s, Wheels core 35.5 / 32.7 s. Within noise (main alone ranged 59-67 s and 35-38 s). Fixes #2650 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cfml-inheritance-resolution.test.ts | 34 +++++++++++++++++++ src/resolution/index.ts | 27 ++++++++++----- src/resolution/name-matcher.ts | 2 +- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aaca65a..b879cfdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now link a route to the page or layout it loads lazily when that file is added after the router was indexed, comes back after being deleted, or gets the component the route renders in a later edit, whether or not the import names the file's extension: React Router's `lazy: () => import('./pages/Team')`, Vue Router's `component: () => import('@/views/Login')` and Angular's `loadComponent: () => import('./home/home.component')`. Before, the route stayed unlinked until the router file changed or the project was re-indexed, so the page looked unused and `codegraph affected` missed the tests that reach it through the router. Re-index after upgrading to pick up links an earlier version missed. - A file named the way Google names its tests, like `wire_format_unittest.cc` in protocolbuffers/protobuf, `logging_unittest.cc` in google/glog or a Python `foo_unittest.py`, now counts as a test, as `wire_format_test.cc` already did. Before, it was taken for production code: search and `codegraph_explore` ranked it alongside the code it tests, `codegraph affected` never listed it, and calls in production code could link to a class or type a unittest declares for itself, as google/breakpad's `stack_frame_entries_.size()` did to a unittest's `StackHelper`. Re-index projects that have such files after upgrading. - A file in a `unittests/` folder, where llvm/llvm-project and google/breakpad keep their unit tests, or in a folder named like a unittest, such as `dcheck_unittest/` in google/glog, now counts as a test whatever its own name says, like LLVM's `CountCopyAndMove.h` helper. Before, such a file was taken for production code unless its name said test, and none of them were kept apart from the program the way a `tests/` folder is: search and `codegraph_explore` ranked them alongside production code, `codegraph affected` missed them, and production code linked to a unit test's own declarations, as LLVM's `Ctx` and `OpInfo` variables did to ones its unit tests declare. A folder named just `unittest/`, like CPython's `Lib/unittest/`, is still production code. Re-index projects that have such folders after upgrading. +- In CFML, a dotted `extends` like `extends="contenscms.core.basecore"` now links to the component it names when a test suite mirrors that component's folder and file name, such as a TestBox spec at `tests/specs/unit/core/basecore.cfc`, and application code is never linked to a component inside a test suite. Before, the two files tied, so the base class was missing from the graph, and so was everything the component inherits from it. Thanks @HarryMuc for the report. (#2650) - `codegraph_explore` now leaves out of its answers every file that search already treats as a test or an example: a C++ `FooTest.cpp` or `foo_test.cc`, a Google `foo_unittest.cc`, anything in an LLVM-style `unittests/` folder or an `e2e/` or `testing/` folder, and a project's `examples/`, `samples/` and `benchmarks/`. Before, explore went by a shorter list of its own, so a unit test or an example that called the code you asked about could take a place in the answer meant for that code: in protocolbuffers/protobuf, a question about `DescriptorPool` came back with three of its unit tests. The same files are no longer taken for a project's main routes file either, such as an end-to-end suite's mock server. No re-index is needed. - `codegraph sync` and the file watcher now link a file to a module it imports under a name of its own when the module is added after the file was indexed, comes back after being deleted, or gets the export in a later edit: a default import like `import tagsController from './tag/tag.controller'`, a namespace import like `import * as api from './api'`, or an aliased one like `import { Component as GroupsPageWrapper } from './groups_page'`, in JavaScript, TypeScript, Vue, Svelte and Astro. Before, the import and every use of the name, such as a call, `new`, a base class, a JSX or template tag or an Express `app.use` mount, stayed unlinked until the importing file changed or the project was re-indexed, so the module looked unused and `codegraph affected` missed the files that use it. Re-index after upgrading to pick up links an earlier version missed. - `codegraph sync` and the file watcher now also link a call or read on a name a file imports, like `NODE_PATH_EXCLUDELIST.includes(path)` after `import { NODE_PATH_EXCLUDELIST } from './constants'`, `store.get(key)` through a default import or `api.get()` through a namespace import, when the module gets the export in a later edit, is added after the importing file was indexed, or comes back after being deleted, in JavaScript, TypeScript, Vue, Svelte and Astro. Before, such a call stayed unlinked until the importing file changed or the project was re-indexed, because sync looked it up only by its method's name, which the module seldom declares, and skipped a name as common as `includes`, `get` or `map` altogether. Re-index after upgrading to pick up links an earlier version missed. diff --git a/__tests__/cfml-inheritance-resolution.test.ts b/__tests__/cfml-inheritance-resolution.test.ts index e1a815eb..879da7f9 100644 --- a/__tests__/cfml-inheritance-resolution.test.ts +++ b/__tests__/cfml-inheritance-resolution.test.ts @@ -90,6 +90,40 @@ describe('CFML component-path inheritance resolution (#1152)', () => { expect(edges.filter((e) => e.src === 'Main')).toHaveLength(0); }); + it('passes over a test suite that mirrors the component path (#2650)', async () => { + // `contenscms` is a server mapping onto `contens/`, so only `core` + // corroborates either file: the component and the TestBox spec named + // after it tied, and a tie yields no edge. + write('contens/core/basecore.cfc', `component {\n function load() { return 1; }\n}\n`); + write('tests/testbox/tests/specs/unit/core/basecore.cfc', `component extends="testbox.system.BaseSpec" {}\n`); + write('contens/model/page.cfc', `component extends="contenscms.core.basecore" {}\n`); + write('contens/model/tagpage.cfc', `\n\n\n`); + // A test that names the component means the component too. + write('tests/testbox/tests/specs/unit/model/fakecore.cfc', `component extends="contenscms.core.basecore" {}\n`); + const edges = await load(); + expect(has(edges, 'page', 'basecore', 'contens/core/basecore.cfc')).toBe(true); + expect(has(edges, 'tagpage', 'basecore', 'contens/core/basecore.cfc')).toBe(true); + expect(has(edges, 'fakecore', 'basecore', 'contens/core/basecore.cfc')).toBe(true); + expect(edges.filter((e) => e.tgtFile.startsWith('tests/'))).toHaveLength(0); + }); + + it('never resolves production code into a test suite, even on a closer path (#2650)', async () => { + write('contens/core/basecore.cfc', `component {}\n`); + // The mirror repeats `contenscms/core`, so it corroborates more segments. + write('tests/unit/contenscms/core/basecore.cfc', `component {}\n`); + write('contens/model/page.cfc', `component extends="contenscms.core.basecore" {}\n`); + // Only a test suite holds `contenscms.core.queue`: no edge from production code. + write('tests/specs/core/queue/worker.cfc', `component {}\n`); + write('contens/model/job.cfc', `component extends="contenscms.core.queue.worker" {}\n`); + // A test naming the suite's own path still reaches it. + write('tests/specs/core/queue/workerTest.cfc', `component extends="tests.specs.core.queue.worker" {}\n`); + const edges = await load(); + expect(has(edges, 'page', 'basecore', 'contens/core/basecore.cfc')).toBe(true); + expect(edges.filter((e) => e.src === 'page')).toHaveLength(1); + expect(edges.filter((e) => e.src === 'job')).toHaveLength(0); + expect(has(edges, 'workerTest', 'worker', 'tests/specs/core/queue/worker.cfc')).toBe(true); + }); + it('resolves a relative path against the referencing file (the FW/1 shape)', async () => { write('examples/base.cfc', `component {\n function shared() { return 1; }\n}\n`); write('examples/sub/app.cfc', `component extends="../base" {}\n`); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 816b020d..01e363ca 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -3498,6 +3498,12 @@ export class ReferenceResolver { * edge. Directory comparison is case-insensitive (CFML path resolution is); * the class segment itself is matched exactly, which real code satisfies — * dotted paths are written to match the on-disk file name. + * + * A test suite is no candidate for production code (#2209's rule), and on a + * tie it yields to the one component outside the test suites (#2650): a + * TestBox spec mirroring the component's path (`tests/specs/unit/core/ + * basecore.cfc` beside `core/basecore.cfc`) corroborates the same segments, + * and a mapping root like `contenscms` (onto `contens/`) matches neither. */ private resolveCfmlComponentPath(ref: UnresolvedRef): ResolvedRef | null { const cfmlCandidates = (name: string): Node[] => @@ -3541,10 +3547,13 @@ export class ReferenceResolver { const className = segments[segments.length - 1]!; const dirSegments = segments.slice(0, -1); - let best: Node | null = null; + // Production code never names a component in a test suite; a test can + // name one, by the path to it. + const fromTest = isTestPath(ref.filePath); let bestScore = 0; - let tie = false; + let tied: Node[] = []; for (const cand of cfmlCandidates(className)) { + if (!fromTest && isTestSuitePath(cand.filePath)) continue; const dirs = cand.filePath.replace(/\\/g, '/').split('/').slice(0, -1); // Count matching directory segments right-to-left: for // `coldbox.system.web.Controller` vs `system/web/Controller.cfc`, @@ -3559,15 +3568,17 @@ export class ReferenceResolver { score++; } if (score > bestScore) { - best = cand; bestScore = score; - tie = false; + tied = [cand]; } else if (score === bestScore && score > 0) { - tie = true; + tied.push(cand); } } - if (!best || bestScore === 0 || tie) return null; - return { original: ref, targetNodeId: best.id, confidence: 0.9, resolvedBy: 'qualified-name' }; + // A test's tie between a component and a suite mirroring its path: the + // path names the component, as it does from production code. + if (tied.length > 1) tied = tied.filter((c) => !isTestSuitePath(c.filePath)); + if (bestScore === 0 || tied.length !== 1) return null; + return { original: ref, targetNodeId: tied[0]!.id, confidence: 0.9, resolvedBy: 'qualified-name' }; } /** diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 122dece6..1eb0a6a6 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1724,7 +1724,7 @@ const JVM_TYPE_KINDS: ReadonlySet = new Set(['class', 'interface', 'enum * its own code may use. A `foo_unittest/` directory is a suite, as a * `foo_unittest.cc` file is: glog builds each one as a test program of its own. */ -function isTestSuitePath(filePath: string): boolean { +export function isTestSuitePath(filePath: string): boolean { if (!isTestPath(filePath)) return false; const lower = filePath.toLowerCase(); const name = lower.slice(lower.lastIndexOf('/') + 1);