diff --git a/internal/codeguard/checks/quality/quality_ai_resolution.go b/internal/codeguard/checks/quality/quality_ai_resolution.go index 40d5ecd..e016ea0 100644 --- a/internal/codeguard/checks/quality/quality_ai_resolution.go +++ b/internal/codeguard/checks/quality/quality_ai_resolution.go @@ -9,9 +9,6 @@ import ( "slices" "strings" "time" - - "github.com/devr-tools/codeguard/internal/codeguard/checks/support" - "github.com/devr-tools/codeguard/internal/codeguard/core" ) type packageManifest struct { @@ -57,25 +54,6 @@ func packageManifestDeps(manifest packageManifest) map[string]struct{} { return deps } -func readWorkspacePackageNames(env support.Context, target core.TargetConfig) map[string]struct{} { - files := listAITargetFiles(env, target, func(rel string) bool { - return filepath.Base(rel) == "package.json" - }) - names := map[string]struct{}{} - for _, rel := range files { - data, err := readAITargetFile(env, target, rel) - if err != nil { - continue - } - manifest, ok := parsePackageManifest(data) - if !ok || strings.TrimSpace(manifest.Name) == "" { - continue - } - names[strings.TrimSpace(manifest.Name)] = struct{}{} - } - return names -} - func readGitHeadMessage(dir string) string { // TODO(harden): thread caller ctx once readGitHeadMessage accepts one. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) diff --git a/internal/codeguard/checks/quality/quality_ai_script_resolution.go b/internal/codeguard/checks/quality/quality_ai_script_resolution.go new file mode 100644 index 0000000..f9c230a --- /dev/null +++ b/internal/codeguard/checks/quality/quality_ai_script_resolution.go @@ -0,0 +1,271 @@ +package quality + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/devr-tools/codeguard/internal/codeguard/checks/support" + "github.com/devr-tools/codeguard/internal/codeguard/core" +) + +// scriptTSConfigPathMap is deliberately limited to resolution-relevant +// tsconfig fields. The check only needs to establish whether an import is +// plausible, not type-check the program. +type scriptTSConfigPathMap struct { + dir string + baseURL string + paths map[string][]string +} + +type scriptTSConfigDocument struct { + CompilerOptions struct { + BaseURL string `json:"baseUrl"` + Paths map[string][]string `json:"paths"` + } `json:"compilerOptions"` +} + +func newScriptImportCatalog(env support.Context, target core.TargetConfig, rootManifest packageManifest) scriptImportCatalog { + catalog := scriptImportCatalog{ + deps: packageManifestDeps(rootManifest), + workspacePackage: map[string]struct{}{}, + packageManifests: map[string]packageManifest{}, + lockPackages: readPNPMLockPackages(target.Path), + } + if _, ok := readPackageManifest(target.Path); ok { + catalog.hasManifest = true + catalog.packageManifests["."] = rootManifest + } + for _, rel := range listAITargetFiles(env, target, func(rel string) bool { + base := filepath.Base(rel) + return base == "package.json" || strings.HasPrefix(base, "tsconfig") && strings.HasSuffix(base, ".json") + }) { + data, err := readAITargetFile(env, target, rel) + if err != nil { + continue + } + switch filepath.Base(rel) { + case "package.json": + manifest, ok := parsePackageManifest(data) + if !ok { + continue + } + catalog.hasManifest = true + dir := filepath.Clean(filepath.Dir(rel)) + catalog.packageManifests[dir] = manifest + if name := strings.TrimSpace(manifest.Name); name != "" { + catalog.workspacePackage[name] = struct{}{} + } + default: + var config scriptTSConfigDocument + if json.Unmarshal(stripScriptJSONC(data), &config) != nil || len(config.CompilerOptions.Paths) == 0 { + continue + } + dir := filepath.Clean(filepath.Dir(rel)) + baseURL := config.CompilerOptions.BaseURL + if baseURL == "" { + baseURL = "." + } + catalog.tsconfigPathMaps = append(catalog.tsconfigPathMaps, scriptTSConfigPathMap{dir: dir, baseURL: baseURL, paths: config.CompilerOptions.Paths}) + } + } + return catalog +} + +// readPNPMLockPackages extracts package roots from pnpm's lockfile without +// depending on a YAML parser. Both the current name@version format and the +// older /name/version format are supported. A lockfile is installation +// evidence, so it is a useful fallback when node_modules is absent in CI. +func readPNPMLockPackages(root string) map[string]struct{} { + data, err := os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) //nolint:gosec // fixed filename under scan target + if err != nil { + return map[string]struct{}{} + } + packages := map[string]struct{}{} + inPackages := false + for _, line := range strings.Split(string(data), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + if !strings.HasPrefix(line, " ") { + inPackages = strings.TrimSpace(line) == "packages:" + continue + } + if !inPackages || !strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") { + continue + } + key := strings.Trim(strings.TrimSuffix(strings.TrimSpace(line), ":"), "'\"") + if name := pnpmLockPackageName(key); name != "" { + packages[name] = struct{}{} + } + } + return packages +} + +func pnpmLockPackageName(key string) string { + key = strings.TrimPrefix(key, "/") + if key == "" || strings.HasPrefix(key, "#") { + return "" + } + if strings.HasPrefix(key, "@") { + if slash := strings.Index(key, "/"); slash > 1 { + if at := strings.Index(key[slash+1:], "@"); at >= 0 { + return key[:slash+1+at] + } + parts := strings.Split(key, "/") + if len(parts) >= 2 { + return parts[0] + "/" + parts[1] + } + } + return "" + } + if at := strings.Index(key, "@"); at > 0 { + return key[:at] + } + if slash := strings.Index(key, "/"); slash > 0 { + return key[:slash] + } + return "" +} + +func (catalog scriptImportCatalog) declaredDependenciesFor(file string) map[string]struct{} { + dir := filepath.Clean(filepath.Dir(file)) + for { + if manifest, ok := catalog.packageManifests[dir]; ok { + return packageManifestDeps(manifest) + } + if dir == "." || dir == string(filepath.Separator) { + break + } + next := filepath.Dir(dir) + if next == dir { + break + } + dir = next + } + return nil +} + +func (catalog scriptImportCatalog) hasInstalledPackage(root, file, packageName string) bool { + dir := filepath.Join(root, filepath.Dir(file)) + root = filepath.Clean(root) + for { + if packageExists(filepath.Join(dir, "node_modules", filepath.FromSlash(packageName))) || + pnpmPackageExists(filepath.Join(dir, "node_modules", ".pnpm"), packageName) { + return true + } + if filepath.Clean(dir) == root { + return false + } + next := filepath.Dir(dir) + if next == dir { + return false + } + dir = next + } +} + +func packageExists(path string) bool { + info, err := os.Stat(path) //nolint:gosec // path is constructed under scan target + return err == nil && info.IsDir() +} + +func pnpmPackageExists(store, packageName string) bool { + entries, err := os.ReadDir(store) //nolint:gosec // path is constructed under scan target + if err != nil { + return false + } + want := filepath.FromSlash(filepath.Join("node_modules", packageName)) + for _, entry := range entries { + if packageExists(filepath.Join(store, entry.Name(), want)) { + return true + } + } + return false +} + +func (catalog scriptImportCatalog) matchesTSConfigPath(root, file, specifier string) bool { + fileDir := filepath.Clean(filepath.Dir(file)) + for _, config := range catalog.tsconfigPathMaps { + if !pathContains(config.dir, fileDir) { + continue + } + for pattern, targets := range config.paths { + wildcard, ok := scriptPathPatternMatch(pattern, specifier) + if !ok { + continue + } + for _, target := range targets { + candidate := strings.ReplaceAll(target, "*", wildcard) + if resolveRelativeScriptImport(root, filepath.Join(config.dir, config.baseURL), candidate) { + return true + } + } + } + } + return false +} + +func pathContains(parent, child string) bool { + parent, child = filepath.Clean(parent), filepath.Clean(child) + return parent == "." || child == parent || strings.HasPrefix(child, parent+string(filepath.Separator)) +} + +func scriptPathPatternMatch(pattern, specifier string) (string, bool) { + if !strings.Contains(pattern, "*") { + return "", pattern == specifier + } + parts := strings.SplitN(pattern, "*", 2) + if !strings.HasPrefix(specifier, parts[0]) || !strings.HasSuffix(specifier, parts[1]) { + return "", false + } + return strings.TrimSuffix(strings.TrimPrefix(specifier, parts[0]), parts[1]), true +} + +// stripScriptJSONC preserves string contents while removing the comments and +// trailing commas commonly accepted by tsconfig.json. +func stripScriptJSONC(data []byte) []byte { + source := string(data) + var b strings.Builder + inString, escaped := false, false + for i := 0; i < len(source); i++ { + ch := source[i] + if inString { + b.WriteByte(ch) + switch { + case escaped: + escaped = false + case ch == '\\': + escaped = true + case ch == '"': + inString = false + } + continue + } + if ch == '"' { + inString = true + b.WriteByte(ch) + continue + } + if ch == '/' && i+1 < len(source) && source[i+1] == '/' { + for i+1 < len(source) && source[i+1] != '\n' { + i++ + } + continue + } + if ch == '/' && i+1 < len(source) && source[i+1] == '*' { + i += 2 + for i < len(source) && (i+1 >= len(source) || source[i] != '*' || source[i+1] != '/') { + if source[i] == '\n' { + b.WriteByte('\n') + } + i++ + } + i++ + continue + } + b.WriteByte(ch) + } + return []byte(strings.ReplaceAll(strings.ReplaceAll(b.String(), ",\n}", "\n}"), ",\n]", "\n]")) +} diff --git a/internal/codeguard/checks/quality/quality_ai_target_script.go b/internal/codeguard/checks/quality/quality_ai_target_script.go index 550f5dd..7c3fd76 100644 --- a/internal/codeguard/checks/quality/quality_ai_target_script.go +++ b/internal/codeguard/checks/quality/quality_ai_target_script.go @@ -20,6 +20,9 @@ type scriptImportCatalog struct { hasManifest bool deps map[string]struct{} workspacePackage map[string]struct{} + packageManifests map[string]packageManifest + tsconfigPathMaps []scriptTSConfigPathMap + lockPackages map[string]struct{} } func typeScriptAITargetFindings(env support.Context, target core.TargetConfig) []core.Finding { @@ -27,12 +30,8 @@ func typeScriptAITargetFindings(env support.Context, target core.TargetConfig) [ if len(files) == 0 { return nil } - manifest, hasManifest := readPackageManifest(target.Path) - catalog := scriptImportCatalog{ - hasManifest: hasManifest, - deps: packageManifestDeps(manifest), - workspacePackage: readWorkspacePackageNames(env, target), - } + manifest, _ := readPackageManifest(target.Path) + catalog := newScriptImportCatalog(env, target, manifest) dominant := dominantScriptTestFramework(env, target, files, manifest) input := scriptFileScanInput{ catalog: catalog, @@ -114,23 +113,44 @@ func scriptImportResolvable(root string, file string, specifier string, catalog if _, ok := catalog.workspacePackage[rootPackage]; ok { return true } + if _, ok := catalog.declaredDependenciesFor(file)[rootPackage]; ok { + return true + } if _, ok := catalog.deps[rootPackage]; ok { return true } + if catalog.hasInstalledPackage(root, file, rootPackage) { + return true + } + if _, ok := catalog.lockPackages[rootPackage]; ok { + return true + } + if catalog.matchesTSConfigPath(root, file, specifier) { + return true + } return !catalog.hasManifest } func resolveRelativeScriptImport(root string, dir string, specifier string) bool { base := filepath.Join(root, dir, filepath.FromSlash(specifier)) - candidates := []string{ - base, base + ".ts", base + ".tsx", base + ".js", base + ".jsx", - base + ".mts", base + ".cts", base + ".mjs", base + ".cjs", - filepath.Join(base, "index.ts"), filepath.Join(base, "index.tsx"), - filepath.Join(base, "index.js"), filepath.Join(base, "index.jsx"), - } - for _, candidate := range candidates { - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { //nolint:gosec // stat-only existence check; candidate is joined under the scan root - return true + // TypeScript's NodeNext/Bundler resolution accepts runtime .js specifiers + // for TypeScript source (for example, ./config.js resolving to config.ts). + // Try the extensionless source path as well as the literal path. + bases := []string{base} + if ext := filepath.Ext(base); ext == ".js" || ext == ".mjs" || ext == ".cjs" { + bases = append(bases, strings.TrimSuffix(base, ext)) + } + for _, base := range bases { + candidates := []string{ + base, base + ".ts", base + ".tsx", base + ".js", base + ".jsx", + base + ".mts", base + ".cts", base + ".mjs", base + ".cjs", + filepath.Join(base, "index.ts"), filepath.Join(base, "index.tsx"), + filepath.Join(base, "index.js"), filepath.Join(base, "index.jsx"), + } + for _, candidate := range candidates { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { //nolint:gosec // stat-only existence check; candidate is joined under the scan root + return true + } } } return false diff --git a/tests/checks/quality_ai_additional_test.go b/tests/checks/quality_ai_additional_test.go index f6a8b9b..51e054f 100644 --- a/tests/checks/quality_ai_additional_test.go +++ b/tests/checks/quality_ai_additional_test.go @@ -45,6 +45,45 @@ export const value = missing; assertFindingRulePresent(t, report, "Code Quality", "quality.ai.hallucinated-import") } +func TestQualityCheckResolvesTypeScriptPNPMMonorepoImports(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "package.json"), `{"name":"repo","private":true}`) + writeFile(t, filepath.Join(dir, "pnpm-workspace.yaml"), "packages:\n - packages/*\n") + writeFile(t, filepath.Join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n\npackages:\n\n lock-only-package@1.0.0:\n resolution: {integrity: sha512-example}\n") + writeFile(t, filepath.Join(dir, "packages", "app", "package.json"), `{ + "name":"@legal-nest/app", + "dependencies":{"react":"18.0.0","next":"15.0.0","@prisma/client":"6.0.0"} +}`) + writeFile(t, filepath.Join(dir, "packages", "shared", "package.json"), `{"name":"@legal-nest/shared"}`) + writeFile(t, filepath.Join(dir, "packages", "app", "tsconfig.json"), `{ + // aliases are valid JSONC in tsconfig files + "compilerOptions": {"baseUrl":".", "paths":{"app/*":["src/*"]}} +}`) + writeFile(t, filepath.Join(dir, "packages", "app", "src", "config.ts"), "export const config = {};\n") + writeFile(t, filepath.Join(dir, "packages", "app", "src", "lib", "value.ts"), "export const value = 1;\n") + writeFile(t, filepath.Join(dir, "node_modules", ".pnpm", "installed-package@1.0.0", "node_modules", "installed-package", "package.json"), `{"name":"installed-package"}`) + writeFile(t, filepath.Join(dir, "packages", "app", "src", "app.ts"), ` +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { prisma } from "@prisma/client"; +import { config } from "./config.js"; +import { value } from "app/lib/value"; +import { shared } from "@legal-nest/shared"; +import installed from "installed-package"; +import lockOnly from "lock-only-package"; +void useState; void useRouter; void prisma; void config; void value; void shared; void installed; void lockOnly; +`) + + cfg := qualityAITestConfig(dir, "quality-ai-ts-pnpm-monorepo") + cfg.Targets[0].Language = "typescript" + report, err := codeguard.Run(context.Background(), cfg) + if err != nil { + t.Fatalf("run: %v", err) + } + + assertFindingRuleAbsent(t, report, "Code Quality", "quality.ai.hallucinated-import") +} + func TestQualityCheckWarnsForDeadCode(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "dead.go"), `package sample