diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a8377da..ff0c7c36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now link an import to the file it names when that file is added after the importing file was indexed, or comes back after being deleted: `import 'package:app/b.dart';` in Dart, `import './side'` in JavaScript or TypeScript, `#include "net/socket.h"` in C and C++, `require 'inc/db.php'` in PHP, `import pkg.mod` in Python, a Lua `require`, or a C# `using` of a namespace declared later. Before, the import stayed unlinked until the importing file itself changed or the project was re-indexed, so `codegraph affected` and the files listed as using the new file missed it. Re-index after upgrading. - JSON files in a `templates` or `sections` folder are now read as Shopify Liquid only inside a Shopify theme, where the folder that contains `templates` or `sections` also has `layout/theme.liquid` or `config/settings_schema.json`. Before, all such JSON was, so a .NET project template's `template.json`, a schematic or a CMS's content showed up in the index as an empty Liquid file, and one that lists `sections` could be linked to an unrelated Liquid file of the same name. Shopify themes, including one kept in a subfolder of a repository, are indexed as before, and the next sync removes JSON that an earlier version indexed this way. - In C, C++ and Objective-C, a struct or class member declared as a pointer, reference, array or function, like `SharedState* shared;`, `jv elements[];` or `virtual Status Put(…) = 0;`, no longer makes the struct look like it inherits from the member's type. Before, `codegraph_explore` and impact listed those types as base classes, every struct holding a pointer to a type as one of its subclasses, and a method of that struct as an override of the type's method with the same name. Real base classes like `class Derived : public Base`, Objective-C superclasses and Go's embedded fields are unchanged. Re-index C, C++ and Objective-C projects after upgrading. +- Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/package-deps.test.ts b/__tests__/package-deps.test.ts new file mode 100644 index 00000000..1bfa7b66 --- /dev/null +++ b/__tests__/package-deps.test.ts @@ -0,0 +1,246 @@ +/** + * Which `package.json` files framework detection reads + * (`src/resolution/frameworks/package-deps.ts`). + * + * The index lists source files, never manifests, so the candidates are + * directories probed on disk. An ASP.NET solution keeps its single-page app + * three levels down: jasontaylordev/CleanArchitecture's Angular app is + * `src/Web/ClientApp/` and its React twin `src/Web/ClientApp-React/`. + * prometheus keeps its React apps in `web/ui/mantine-ui/` and + * `web/ui/react-app/`, under a workspace root at `web/ui/` that declares only + * tooling. Read two levels deep, none of those manifests was read, so Angular + * Router, the Angular template pass and React Router's navigation never ran, + * and every template-bound handler was listed as dead code. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { buildDeadCodeReport } from '../src/graph/dead-code'; +import { declaredDependencies, dependsOn } from '../src/resolution/frameworks/package-deps'; +import type { ResolutionContext } from '../src/resolution/types'; + +/** A context over an indexed file list and the manifests on disk, recording every probe. */ +function tree(files: string[], manifests: Record>>) { + const probes: string[] = []; + const context = { + getAllFiles: () => files, + fileExists: (p: string) => { + probes.push(p); + return p in manifests; + }, + readFile: (p: string) => (p in manifests ? JSON.stringify(manifests[p]) : null), + } as unknown as ResolutionContext; + return { context, probes }; +} + +describe('declaredDependencies: manifests below the first two levels', () => { + it.each([ + ['src/Web/ClientApp/', 'an ASP.NET solution'], + ['src/Presentation/Web/ClientApp/', 'a solution folder deeper still'], + ])('reads %s, where %s keeps its app', (app) => { + const { context } = tree( + ['src/Web/Program.cs', `${app}karma.conf.js`, `${app}src/main.ts`, `${app}src/app/app.module.ts`], + { [`${app}package.json`]: { dependencies: { '@angular/core': '^21.1.5', '@angular/router': '^21.1.5' } } } + ); + expect(dependsOn(context, '@angular/router')).toBe(true); + }); + + it('reads a workspace root two levels down and the apps a level below it', () => { + const { context } = tree( + ['cmd/prometheus/main.go', 'web/ui/mantine-ui/src/main.tsx', 'web/ui/react-app/src/App.tsx', 'web/ui/module/lezer-promql/src/index.ts'], + { + 'web/ui/package.json': { devDependencies: { typescript: '*', vite: '*' } }, + 'web/ui/mantine-ui/package.json': { dependencies: { react: '*', 'react-router-dom': '*' } }, + 'web/ui/react-app/package.json': { dependencies: { react: '*', 'react-router': '*' } }, + } + ); + const names = declaredDependencies(context); + expect([...names].sort()).toEqual(['react', 'react-router', 'react-router-dom', 'typescript', 'vite']); + }); + + it('never reads a manifest inside node_modules', () => { + const { context, probes } = tree( + ['tools/site/node_modules/framework/dist/index.js', 'tools/site/src/index.ts'], + { 'tools/site/node_modules/framework/package.json': { dependencies: { next: '*' } } } + ); + expect(dependsOn(context, 'next')).toBe(false); + expect(probes.filter((p) => p.includes('node_modules'))).toEqual([]); + }); + + it('probes only above JS/TS code, and a bounded number of directories however many hold it', () => { + const files = ['src/Api/Tools/Generator.cs']; + for (let i = 0; i < 3000; i++) files.push(`src/app/feature${i}/parts/widget.component.ts`); + const { context, probes } = tree(files, { 'src/Api/Tools/package.json': { dependencies: { express: '*' } } }); + expect(dependsOn(context, 'express')).toBe(false); + // `src/`, `src/Api/`, `src/app/` from the first two levels; the rest below them. + expect(probes.length).toBeLessThanOrEqual(3 + 192); + }); + + it('reads the same manifests whatever order the files are listed in', () => { + const files: string[] = []; + const manifests: Record>> = {}; + for (let i = 0; i < 40; i++) { + files.push(`clients/web/app${i}/src/index.ts`); + manifests[`clients/web/app${i}/package.json`] = { dependencies: { [`dep-${i}`]: '*' } }; + } + const scanned = declaredDependencies(tree(files, manifests).context); + const reversed = declaredDependencies(tree([...files].reverse(), manifests).context); + expect([...reversed].sort()).toEqual([...scanned].sort()); + // A sample, as above two levels: the root and 24 more. + expect(scanned.size).toBe(24); + }); +}); + +// --------------------------------------------------------------------------- +// End to end: CleanArchitecture's shape +// --------------------------------------------------------------------------- + +const ngComponent = (name: string, selector: string, templateUrl: string, body = '', imports = '') => `import { Component } from '@angular/core'; +${imports} +@Component({ + standalone: false, + selector: '${selector}', + templateUrl: '${templateUrl}' +}) +export class ${name} { +${body} +} +`; + +describe('an ASP.NET solution with its single-page apps three levels down, indexed', () => { + let root: string; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deep-manifest-')); + const files: Record = { + 'src/Web/Program.cs': 'var app = WebApplication.CreateBuilder(args).Build();\napp.Run();\n', + 'src/Web/ClientApp/package.json': JSON.stringify({ dependencies: { '@angular/core': '^21.1.5', '@angular/router': '^21.1.5' } }), + 'src/Web/ClientApp/angular.json': '{}', + 'src/Web/ClientApp/src/app/app.module.ts': `import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { HomeComponent } from './home/home.component'; +import { CounterComponent } from './counter/counter.component'; +import { LoginComponent } from './login/login.component'; + +@NgModule({ + imports: [ + RouterModule.forRoot([ + { path: '', component: HomeComponent, pathMatch: 'full' }, + { path: 'counter', component: CounterComponent }, + { path: 'login', component: LoginComponent } + ]) + ] +}) +export class AppModule {} +`, + 'src/Web/ClientApp/src/app/home/home.component.ts': ngComponent('HomeComponent', 'app-home', './home.component.html'), + 'src/Web/ClientApp/src/app/home/home.component.html': '

Hello, world!

\n', + 'src/Web/ClientApp/src/app/login/login.component.ts': ngComponent('LoginComponent', 'app-login', './login.component.html'), + 'src/Web/ClientApp/src/app/login/login.component.html': '

Log in

\n', + 'src/Web/ClientApp/src/app/counter/counter.component.ts': ngComponent( + 'CounterComponent', + 'app-counter-component', + './counter.component.html', + ' public currentCount = 0;\n\n public incrementCounter() {\n this.currentCount++;\n }' + ), + 'src/Web/ClientApp/src/app/counter/counter.component.html': '

Current count: {{ currentCount }}

\n\n', + 'src/Web/ClientApp/src/app/nav-menu/nav-menu.component.ts': ngComponent( + 'NavMenuComponent', + 'app-nav-menu', + './nav-menu.component.html', + " constructor(private router: Router) {}\n\n logout(): void {\n this.router.navigate(['/login']);\n }", + "import { Router } from '@angular/router';" + ), + 'src/Web/ClientApp/src/app/nav-menu/nav-menu.component.html': `Counter +Log out +`, + 'src/Web/ClientApp-React/package.json': JSON.stringify({ dependencies: { react: '^19.1.0', 'react-dom': '^19.1.0', 'react-router-dom': '^7.6.1' } }), + 'src/Web/ClientApp-React/src/App.jsx': `import { Routes, Route } from 'react-router-dom'; +import { Home } from './components/Home'; +import { Counter } from './components/Counter'; +import { LoginPage } from './components/LoginPage'; + +export default function App() { + return ( + + } /> + } /> + } /> + + ); +} +`, + 'src/Web/ClientApp-React/src/components/Home.jsx': 'export function Home() {\n return

Hello, world!

;\n}\n', + 'src/Web/ClientApp-React/src/components/Counter.jsx': 'export function Counter() {\n return

Counter

;\n}\n', + 'src/Web/ClientApp-React/src/components/LoginPage.jsx': 'export function LoginPage() {\n return

Log in

;\n}\n', + 'src/Web/ClientApp-React/src/components/NavMenu.jsx': `import { Link, useNavigate } from 'react-router-dom'; + +export function NavMenu() { + const navigate = useNavigate(); + const handleLogout = (e) => { + e.preventDefault(); + navigate('/login'); + }; + return ( + + ); +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.close(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + const routesUnder = (dir: string) => cg.getNodesByKind('route').filter((r) => r.filePath.startsWith(dir)); + const navigations = (dir: string) => + routesUnder(dir) + .flatMap((r) => + cg.getIncomingEdgesTo([r.id], ['navigates']).map((e) => `${cg.getNode(e.source)?.name} -> ${r.name} (${(e.metadata as Record).navMethod})`) + ) + .sort(); + + it('detects the frameworks the nested manifests declare', () => { + expect(cg.getDetectedFrameworks()).toEqual(expect.arrayContaining(['angular-router', 'react-router'])); + }); + + it("reads the Angular app's routes, bound to their components", () => { + const routes = routesUnder('src/Web/ClientApp/') + .map((r) => `${r.name} -> ${cg.getOutgoingEdgesFrom([r.id], ['references']).map((e) => cg.getNode(e.target)?.name).join(', ')}`) + .sort(); + expect(routes).toEqual(['/ -> HomeComponent', '/counter -> CounterComponent', '/login -> LoginComponent']); + }); + + it("draws the Angular app's navigation from routerLink and router.navigate", () => { + expect(navigations('src/Web/ClientApp/')).toEqual(['NavMenuComponent -> /counter (routerLink)', 'logout -> /login (navigate)']); + }); + + it("links a template's event binding to its handler, so the handler is not dead code", () => { + const counter = cg.getNodesByKind('class').find((c) => c.name === 'CounterComponent')!; + const handlers = cg + .getOutgoingEdgesFrom([counter.id], ['calls']) + .filter((e) => (e.metadata as Record | undefined)?.synthesizedBy === 'angular-event') + .map((e) => cg.getNode(e.target)?.name); + expect(handlers).toEqual(['incrementCounter']); + const dead = buildDeadCodeReport(cg, { limit: 1000 }).entries.map((e) => e.node.name); + expect(dead).not.toContain('incrementCounter'); + expect(dead).not.toContain('logout'); + }); + + it("resolves the React twin's navigate() as well as its ", () => { + expect(navigations('src/Web/ClientApp-React/')).toEqual(['NavMenu -> /counter (link)', 'handleLogout -> /login (navigate)']); + }); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index d4aa5983..cbd66ffe 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -231,6 +231,16 @@ Each of these cost real debugging time; they are not hypothetical. per file-count for exactly this reason — an earlier version cached the empty pre-index answer and every framework whose dependency lived one directory down stayed undetected. +12. **An app's manifest can sit three or more levels down.** An ASP.NET + solution keeps its single-page app in `src/Web/ClientApp/`, and prometheus + keeps its React apps in `web/ui/mantine-ui/` under a workspace root at + `web/ui/` that declares only tooling. `declaredDependencies` reads the root + and the first two levels, then gives the slots left to the directories + above JS/TS code, shallowest first. Read only two levels deep, Angular + Router never ran on jasontaylordev/CleanArchitecture, and every + template-bound handler there was listed as dead code. A new detector that + gates on a dependency should ask `dependsOn`, not read a `package.json` + itself, so it sees the same manifests every other detector does. --- diff --git a/src/resolution/frameworks/package-deps.ts b/src/resolution/frameworks/package-deps.ts index 1793848f..907c6794 100644 --- a/src/resolution/frameworks/package-deps.ts +++ b/src/resolution/frameworks/package-deps.ts @@ -1,10 +1,12 @@ /** - * The dependencies a project declares — in its root `package.json` and in - * the ones one or two directories down (`apps/web/package.json`, - * `frontend/package.json`, `packages/api/package.json`). A framework detector - * that reads only the root misses every monorepo: proshop keeps `react` in - * `frontend/`, a Turborepo keeps `next` in `apps/web/`, and the resolver for - * that framework then never runs, so its routes never exist. + * The dependencies a project declares — in its root `package.json`, in the + * ones one or two directories down (`apps/web/package.json`, + * `frontend/package.json`, `packages/api/package.json`), and in the ones + * deeper still above JS/TS code (`src/Web/ClientApp/package.json`). A + * framework detector that reads only the root misses every monorepo: proshop + * keeps `react` in `frontend/`, a Turborepo keeps `next` in `apps/web/`, an + * ASP.NET solution keeps its Angular app in `src/Web/ClientApp/`, and the + * resolver for that framework then never runs, so its routes never exist. */ import type { ResolutionContext } from '../types'; @@ -12,6 +14,12 @@ import type { ResolutionContext } from '../types'; /** Nested manifests read per project, at most — a monorepo with hundreds of packages is sampled, not scanned. */ const MAX_MANIFESTS = 24; +/** Directories probed for a manifest below the first two levels, at most. */ +const MAX_DEEP_PROBES = MAX_MANIFESTS * 8; + +/** A file a JavaScript framework's app is written in. Its app's manifest is in a directory above it. */ +const SCRIPT_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/; + /** * Cached per context, keyed by how many files are indexed. * @@ -26,7 +34,7 @@ const MAX_MANIFESTS = 24; */ const cache = new WeakMap }>(); -/** Every dependency name declared at the root or up to two directories down, de-duplicated. */ +/** Every dependency name declared in the manifests read (see the file comment), de-duplicated. */ export function declaredDependencies(context: ResolutionContext): Set { const files = context.getAllFiles(); const cached = cache.get(context); @@ -46,6 +54,16 @@ export function declaredDependencies(context: ResolutionContext): Set { if (manifests.length > MAX_MANIFESTS) break; if (!dir.includes('node_modules') && context.fileExists(dir + 'package.json')) manifests.push(dir + 'package.json'); } + // An app can sit deeper: an ASP.NET solution's single-page app in + // `src/Web/ClientApp/`, prometheus's React apps in `web/ui/mantine-ui/` + // under a workspace root at `web/ui/`. The slots left go to the directories + // above JS/TS code, shallowest first. + if (manifests.length <= MAX_MANIFESTS) { + for (const dir of deepScriptDirectories(files)) { + if (manifests.length > MAX_MANIFESTS) break; + if (context.fileExists(dir + 'package.json')) manifests.push(dir + 'package.json'); + } + } for (const manifest of manifests) { const content = context.readFile(manifest); if (!content) continue; @@ -62,6 +80,34 @@ export function declaredDependencies(context: ResolutionContext): Set { return names; } +/** + * The directories three or more levels down that hold indexed JS/TS code or a + * directory that does, never inside `node_modules`: shallowest first, at most + * {@link MAX_DEEP_PROBES} of them. Sorted within a level, so the sample does + * not depend on the order the files come in. Extraction lists them as git or a + * directory walk returns them, the resolver in path order, and each decides + * from its own list which frameworks run. + */ +function deepScriptDirectories(files: readonly string[]): string[] { + // levels[n]: the directories n levels down (`a/b/c/` is 3). + const levels: Set[] = []; + for (const file of files) { + if (!SCRIPT_FILE.test(file) || file.includes('node_modules')) continue; + let depth = 0; + for (let slash = file.indexOf('/'); slash >= 0; slash = file.indexOf('/', slash + 1)) { + if (++depth >= 3) (levels[depth] ??= new Set()).add(file.slice(0, slash + 1)); + } + } + const dirs: string[] = []; + for (let depth = 3; depth < levels.length; depth++) { + for (const dir of [...(levels[depth] ?? [])].sort()) { + if (dirs.length === MAX_DEEP_PROBES) return dirs; + dirs.push(dir); + } + } + return dirs; +} + /** True when any of `deps` is declared anywhere the project's manifests are read. */ export function dependsOn(context: ResolutionContext, ...deps: string[]): boolean { const names = declaredDependencies(context);