diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3683052..dbd56df 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "procoder", - "version": "3.9.1", + "version": "3.9.2", "description": "A harness that gives AI coders the tools and skills to work like a senior developer. All nine domains shipped: security, best practices, maintainability, performance, documentation, clean code, CI/CD, DevOps, GitOps \u2014 plus the code index. Hooks hand the agent results; the agent stays in control.", "author": { "name": "Pascal Watteel" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index cf4175e..d51646c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "procoder", - "version": "3.9.1", + "version": "3.9.2", "description": "A harness that gives AI coders the tools and skills to work like a senior developer", "commands": "./commands/", "skills": "./commands/", diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index a8d8266..240fc9d 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -2,7 +2,7 @@ "name": "procoder", "metadata": { "description": "A harness that gives AI coders the tools and skills to work like a senior developer", - "version": "3.9.1" + "version": "3.9.2" }, "plugins": [ { diff --git a/.github/plugin/plugin.json b/.github/plugin/plugin.json index 834d1a7..6e1c070 100644 --- a/.github/plugin/plugin.json +++ b/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "procoder", - "version": "3.9.1", + "version": "3.9.2", "description": "A harness that gives AI coders the tools and skills to work like a senior developer", "commands": "./commands/", "skills": "./commands/", diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e29b1e..fd74aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,24 @@ Rules that earn their place: handle opened none of what its paragraph cites. --> +## 3.9.2 — 2026-10-26 + +_The commit gate lints TypeScript in a repository whose TypeScript 7 +lives in a subdirectory, such as a `web/` app, instead of handing it to +a parser that refuses it._ + +**Fixed — a TypeScript 7 under a subdirectory decides the parser.** +([#333](https://github.com/azrtydxb/procoder/pull/333)) procoder picks +its own typescript-eslint beside TypeScript 6 when a repository's +TypeScript is newer than typescript-eslint accepts, but it only looked +for that TypeScript at the repository root. A repository with its +frontend in `web/` had TypeScript 7 there, so procoder used the stock +typescript-eslint, which refuses TypeScript 7, and the `.ts` and `.tsx` +files went unlinted. procoder now looks for a TypeScript past the +supported range anywhere in the tree (skipping `node_modules` and dot +directories), so those files are linted by the parser that accepts them, +and `procoder doctor` reports the parser it needs. + ## 3.9.1 — 2026-10-19 _A commit from a linked worktree is judged by that worktree's own ignore diff --git a/README.md b/README.md index 44230fb..c48be77 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ turns every escaped bug into a permanently closed class. The agent stays in control — nothing ever touches your code behind its back. ![CI](https://github.com/azrtydxb/procoder/actions/workflows/ci.yml/badge.svg) -![Version](https://img.shields.io/badge/version-3.9.1-7C3AED) +![Version](https://img.shields.io/badge/version-3.9.2-7C3AED) ![License](https://img.shields.io/badge/license-Apache--2.0-7C3AED) ![Agents](https://img.shields.io/badge/works%20with-20%2B%20agents-7C3AED) diff --git a/docs/index.md b/docs/index.md index b248b3c..784bd28 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,7 +7,7 @@ bug's whole class, and one contract that works across every AI coding agent. The agent stays in control; nothing ever touches your code behind its back. -Current version: **3.9.1** +Current version: **3.9.2** ```mermaid flowchart LR diff --git a/gemini-extension.json b/gemini-extension.json index 606a65c..1c9f64b 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "procoder", - "version": "3.9.1", + "version": "3.9.2", "description": "A harness that gives AI coders the tools and skills to work like a senior developer", "contextFileName": "AGENTS.md" } diff --git a/internal/lint/ts7_test.go b/internal/lint/ts7_test.go index 7a3cc44..877425e 100644 --- a/internal/lint/ts7_test.go +++ b/internal/lint/ts7_test.go @@ -124,3 +124,52 @@ func TestTS7WithoutTheOwnedParserIsNotChecked(t *testing.T) { t.Fatalf("want one blocking refusal naming typescript-eslint, got %+v", got) } } + +// A repository whose TypeScript lives in a subdirectory (an app under web/, +// nothing at the root) is past the range exactly like one with it at the +// root: doctor must check, and init install, procoder's own copy, and the +// copy must resolve for the repo and for a file under that subdirectory. +// proved by: TypescriptEslintFor looked only at root/node_modules — doctor +// reported typescript-eslint missing with the project-local npm install that +// npm refuses, and the owned copy was never resolved for the repo. +func TestTypescriptInASubdirectoryDecidesTheParser(t *testing.T) { + isolateCache(t) + owned := filepath.Join(ownedTSESLintDir(), "node_modules", "typescript-eslint", "dist", "index.js") + if err := os.MkdirAll(filepath.Dir(owned), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(owned, []byte("export default {};\n"), 0o644); err != nil { + t.Fatal(err) + } + + root := t.TempDir() + web := filepath.Join(root, "web") + installTypescript(t, web, "7.0.2") + if err := os.MkdirAll(filepath.Join(web, "src"), 0o755); err != nil { + t.Fatal(err) + } + if got := TypescriptEslintFor(root); got != TypescriptEslintTS7 { + t.Errorf("TypeScript 7 under web/: init would run %v, want procoder's own copy", got.InstallVia) + } + if got := tools.Resolve(TypescriptEslintFor(root), root); got != owned { + t.Errorf("doctor resolved %q for the repo, want %q", got, owned) + } + if got := tsESLintEntry(root, filepath.Join(web, "src", "a.ts")); got != owned { + t.Errorf("a file under web/src resolved %q, want %q", got, owned) + } + + // any location past the range decides, even beside one within it + mixed := t.TempDir() + installTypescript(t, filepath.Join(mixed, "legacy"), "5.9.3") + installTypescript(t, filepath.Join(mixed, "apps", "web"), "7.0.2") + if got := TypescriptEslintFor(mixed); got != TypescriptEslintTS7 { + t.Errorf("one TypeScript 7 location among several: got %v", got.InstallVia) + } + + // subdirectories within the range keep the project-local install + within := t.TempDir() + installTypescript(t, filepath.Join(within, "web"), "6.0.3") + if got := TypescriptEslintFor(within); got != TypescriptEslint { + t.Errorf("TypeScript 6 under web/ must install typescript-eslint itself, got %v", got.InstallVia) + } +} diff --git a/internal/lint/tsbaseline.go b/internal/lint/tsbaseline.go index 1dc5eff..75e9ef2 100644 --- a/internal/lint/tsbaseline.go +++ b/internal/lint/tsbaseline.go @@ -70,8 +70,13 @@ var TypescriptEslintTS7 = &tools.Tool{ // TypescriptEslintFor is the typescript-eslint the project under root // installs: its own, or procoder's copy when its TypeScript is past the // range typescript-eslint supports. +// +// The decision is the repository's, not the root's: TypeScript often lives +// in a subdirectory (an app under web/, nothing at the root), and npm +// refuses the project-local install there just the same. So any TypeScript +// location past the range selects procoder's copy. func TypescriptEslintFor(root string) *tools.Tool { - if tsMajor(filepath.Join(root, "x")) > tsESLintMaxMajor { + if anyTSPastRange(root) { return TypescriptEslintTS7 } return TypescriptEslint @@ -81,9 +86,59 @@ func resolvedTSESLint(root string) string { if root == "" { return "" } + if anyTSPastRange(root) { + if entry := entryIn(ownedTSESLintDir()); entry != "" { + return entry + } + } return tsESLintEntry(root, filepath.Join(root, "x")) } +// tsScanDepth bounds how far below the root anyTSPastRange looks for an +// installed TypeScript; workspaces keep theirs a few levels down. +const tsScanDepth = 6 + +// anyTSPastRange reports whether any installed TypeScript in the repository +// under root — the root's own or one in a subdirectory — is past +// tsESLintMaxMajor. The walk never enters node_modules (a TypeScript there +// is a dependency's, not the project's) or dot directories. +func anyTSPastRange(root string) bool { + if root == "" { + return false + } + if tsMajor(filepath.Join(root, "x")) > tsESLintMaxMajor { + return true + } + found := false + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + if d != nil && d.IsDir() { + return filepath.SkipDir + } + return nil + } + if found { + return filepath.SkipAll + } + if !d.IsDir() || path == root { + return nil + } + name := d.Name() + if name == "node_modules" || strings.HasPrefix(name, ".") { + return filepath.SkipDir + } + if rel, rerr := filepath.Rel(root, path); rerr == nil && strings.Count(rel, string(filepath.Separator)) >= tsScanDepth { + return filepath.SkipDir + } + // node_modules/typescript sits directly under this directory + if dirTSMajor(path) > tsESLintMaxMajor { + found = true + } + return nil + }) + return found +} + // ownedTSESLintDir is where procoder keeps its own typescript-eslint for // projects past tsESLintMaxMajor: the user cache, so it survives a reboot // and is shared by every such repository on the machine. @@ -102,20 +157,8 @@ func ownedTSESLintDir() string { func tsMajor(file string) int { dir := filepath.Dir(file) for { - raw, err := os.ReadFile(filepath.Join(dir, "node_modules", "typescript", "package.json")) - if err == nil { - var manifest struct { - Version string `json:"version"` - } - if json.Unmarshal(raw, &manifest) != nil { - return 0 - } - major, _, _ := strings.Cut(manifest.Version, ".") - n, err := strconv.Atoi(major) - if err != nil { - return 0 - } - return n + if raw, err := os.ReadFile(filepath.Join(dir, "node_modules", "typescript", "package.json")); err == nil { + return manifestMajor(raw) } parent := filepath.Dir(dir) if parent == dir { @@ -125,6 +168,32 @@ func tsMajor(file string) int { } } +// dirTSMajor is the major version of the TypeScript installed directly in +// dir/node_modules, or 0 when there is none. +func dirTSMajor(dir string) int { + raw, err := os.ReadFile(filepath.Join(dir, "node_modules", "typescript", "package.json")) + if err != nil { + return 0 + } + return manifestMajor(raw) +} + +// manifestMajor is the major of a package.json's version, or 0 if unreadable. +func manifestMajor(raw []byte) int { + var manifest struct { + Version string `json:"version"` + } + if json.Unmarshal(raw, &manifest) != nil { + return 0 + } + major, _, _ := strings.Cut(manifest.Version, ".") + n, err := strconv.Atoi(major) + if err != nil { + return 0 + } + return n +} + // tsESLintEntry finds the typescript-eslint entry module for the file. The // absolute path is what the generated config imports: the config lives in // a temp directory, so a bare package name would resolve against that @@ -183,7 +252,16 @@ func lintTSBaseline(root string, files []string, block bool) []gitx.Finding { if bin == "" { return notChecked(files[0], "eslint") } - entry := tsESLintEntry(root, files[0]) + // one config serves the whole run, so a file past the range picks the + // entry: procoder's own copy parses the files within it as well + probe := files[0] + for _, f := range files { + if tsMajor(f) > tsESLintMaxMajor { + probe = f + break + } + } + entry := tsESLintEntry(root, probe) if entry == "" { return notChecked(files[0], TypescriptEslint.Name) } diff --git a/package.json b/package.json index 3b12b97..2bac57b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "procoder", - "version": "3.9.1", + "version": "3.9.2", "description": "A harness that gives AI coders the tools and skills to work like a senior developer", "license": "Apache-2.0", "repository": { diff --git a/plugin.yaml b/plugin.yaml index 3cf01dd..20ed9b2 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: procoder -version: 3.9.1 +version: 3.9.2 description: A harness that gives AI coders the tools and skills to work like a senior developer entry: __init__.py provides_hooks: