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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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/",
Expand Down
2 changes: 1 addition & 1 deletion .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
2 changes: 1 addition & 1 deletion .github/plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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/",
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gemini-extension.json
Original file line number Diff line number Diff line change
@@ -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"
}
49 changes: 49 additions & 0 deletions internal/lint/ts7_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
110 changes: 94 additions & 16 deletions internal/lint/tsbaseline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion plugin.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Loading