From 9e4d3cbd98c6bf9ae1390e2e6a7d7c1b73df655a Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 16:03:28 -0700 Subject: [PATCH 1/5] feat(cli): Add deterministic detect command for rule routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `taskless detect --json`: an offline, deterministic scan of the repo for configured linters (config files, pyproject tool tables, package deps), inferred languages/frameworks, and the repo's own rule styles. No LLM, no network, no auth — it emits stable signal JSON to feed the routing recipe. Includes a focused scanner module, output schema, and integration tests covering detection, language/framework inference, no-packaged-rule-claims, and the offline/no-auth contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/local-rule-routing/tasks.md | 39 +++ packages/cli/src/commands/detect.ts | 80 ++++++ packages/cli/src/detect/scan.ts | 276 +++++++++++++++++++ packages/cli/src/index.ts | 2 + packages/cli/src/schemas/detect.ts | 42 +++ packages/cli/test/detect.test.ts | 154 +++++++++++ 6 files changed, 593 insertions(+) create mode 100644 openspec/changes/local-rule-routing/tasks.md create mode 100644 packages/cli/src/commands/detect.ts create mode 100644 packages/cli/src/detect/scan.ts create mode 100644 packages/cli/src/schemas/detect.ts create mode 100644 packages/cli/test/detect.test.ts diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md new file mode 100644 index 00000000..db5cb431 --- /dev/null +++ b/openspec/changes/local-rule-routing/tasks.md @@ -0,0 +1,39 @@ +## 1. Detect command (cli-detect) + +- [x] 1.1 Add a `detect` output schema under `packages/cli/src/schemas/` (linters, languages/frameworks, existing rule styles) +- [x] 1.2 Implement `packages/cli/src/commands/detect.ts`: deterministic, offline scan of linter configs, languages/frameworks, and the repo's own rule styles — no LLM, no network, no auth +- [x] 1.3 Register `detect` in `packages/cli/src/index.ts` subCommands with `--json` and `--dir`/`-d` +- [x] 1.4 Emit `cli_detect` telemetry consistent with other commands +- [x] 1.5 Add unit tests covering: eslint/ruff/rubocop/biome/stylelint config detection, language/framework inference, repo-rule-style surfacing, and JSON-shape validation against the schema +- [x] 1.6 Add a test asserting `detect` produces no packaged-rule-catalog claims and runs without network/auth + +## 2. Routing recipes (cli-rule-routing) + +- [ ] 2.1 Author `packages/cli/src/help/route.txt`: run `detect`; require the agent to WRITE its rationale first (detect signals, existing-linter coverage, ast-grep expressibility, local-solvability confidence) and name a destination only as a conclusion of that rationale; commit to the believed-correct path on REASONABLE confidence (existing/static/remote), route remote directly when not reasonably confident; when multiple paths fit, ask the user and explain trade-offs (note `remote` consumes a generation + needs login); never use a deliberate fail-first probe to select `remote` +- [ ] 2.1a In `route.txt`, specify the try-verify-escalate FALLBACK: when a believed-local `static` path fails verification, inform the user the local rule couldn't capture the cases and PROMPT-AND-CONFIRM before calling the service — never silently fall through to `remote` +- [ ] 2.2 Author `packages/cli/src/help/existing.txt`: author in the detected linter's dialect; repo-first knowledge sourcing then WebFetch; explicit author-only (user's toolchain runs it; `taskless check` does not run the external linter) +- [ ] 2.3 Author `packages/cli/src/help/static.txt`: local ast-grep authoring with verification against success/failure cases; canonical on-disk shape and paths; working candidate written under `.taskless/.tmp-*` with guaranteed cleanup on BOTH success and failure (mirror the `rule create` pattern); on verification failure, hand back to the `route` prompt-and-confirm fallback rather than escalating directly +- [ ] 2.4 Author `packages/cli/src/help/remote.txt`: collect inputs, require auth, invoke the existing `rule create` backend; service decides static vs runtime; never decide that locally +- [ ] 2.5 Ensure all four recipes follow the embedded help-text format (header, sprintf escaping) and reference `detect`/`route` consistently + +## 3. Help registration + telemetry (cli-help) + +- [ ] 3.1 Confirm the four `.txt` recipes are picked up by the `import.meta.glob` embedding and resolve via `taskless help ` +- [ ] 3.2 Ensure `route`, `existing`, `static`, `remote` appear in the `taskless help` (no-arg) topic index +- [ ] 3.3 Verify `help_` intent telemetry fires for each routing topic +- [ ] 3.4 Add tests for topic resolution, index listing, and telemetry capture + +## 4. Skill routing posture (skill-taskless) + +- [ ] 4.1 Update `skills/taskless/SKILL.md` `description`: replace the named-tool suppression clause so naming a linter engages routing via `taskless help route`; tighten (reword shorter) rather than append trigger text +- [ ] 4.1a Measure the resulting `description` length and assert it is ≤ 1024 chars (Agent Skills ceiling); treat overflow as a blocking failure and trim trigger wording until it fits +- [ ] 4.2 Update the skill body to route authoring requests through `taskless help route` (not `rule create` directly); remove the "quiet suggestion" suppression path; keep the skill a thin router with no linter knowledge +- [ ] 4.3 Bump the skill `metadata.version` per the file conventions +- [ ] 4.4 Verify the skill change against the updated `skill-taskless` scenarios (routing on named tool, no suppression wording, local-first before login) + +## 5. Validation + quality gate + +- [ ] 5.1 Run `pnpm openspec validate local-rule-routing` and resolve any issues +- [ ] 5.2 Add/curate the honesty eval fixtures (labeled request → expected route) and assert the `route` heuristic against both failure directions: under-confident (escalating a locally-solvable request to login) and over-confident (claiming local for a request that needs the service). Use the fixtures to calibrate the "confident enough for local" threshold +- [ ] 5.3 Run `pnpm typecheck` and `pnpm lint`; fix all failures +- [ ] 5.4 Manual smoke: `taskless detect --json`, then `taskless help route`/`existing`/`static`/`remote` resolve and read coherently end-to-end diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts new file mode 100644 index 00000000..adbe1d5c --- /dev/null +++ b/packages/cli/src/commands/detect.ts @@ -0,0 +1,80 @@ +import { resolve } from "node:path"; + +import { defineCommand } from "citty"; + +import { detectRepository } from "../detect/scan"; +import { outputSchema as detectOutputSchema } from "../schemas/detect"; +import { getTelemetry } from "../telemetry"; +import { makeErrorEnvelope } from "../types/errors"; + +export const detectCommand = defineCommand({ + meta: { + name: "detect", + description: + "Scan the repo for configured linters, languages/frameworks, and existing rule styles (offline, deterministic)", + }, + args: { + dir: { + type: "string", + alias: "d", + description: "Working directory", + }, + json: { + type: "boolean", + description: "Output as JSON", + default: false, + }, + }, + async run({ args }) { + const cwd = resolve(args.dir ?? process.cwd()); + const telemetry = await getTelemetry(cwd); + telemetry.capture("cli_detect"); + + const result = { + success: true as const, + ...(await detectRepository(cwd)), + }; + + if (args.json) { + const parsed = detectOutputSchema.safeParse(result); + if (!parsed.success) { + console.log( + JSON.stringify( + makeErrorEnvelope( + "INTERNAL_ERROR", + "Internal schema validation failed" + ) + ) + ); + process.exitCode = 1; + return; + } + console.log(JSON.stringify(parsed.data)); + return; + } + + // Human-readable output + if (result.linters.length === 0) { + console.log("Linters: none detected"); + } else { + console.log("Linters:"); + for (const linter of result.linters) { + console.log(` ${linter.name}: ${linter.configFiles.join(", ")}`); + } + } + + console.log( + `\nLanguages: ${result.languages.length > 0 ? result.languages.join(", ") : "none detected"}` + ); + console.log( + `Frameworks: ${result.frameworks.length > 0 ? result.frameworks.join(", ") : "none detected"}` + ); + + if (result.ruleStyles.length > 0) { + console.log("\nExisting rule styles:"); + for (const style of result.ruleStyles) { + console.log(` ${style.source}: ${style.description}`); + } + } + }, +}); diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts new file mode 100644 index 00000000..ea2e9a64 --- /dev/null +++ b/packages/cli/src/detect/scan.ts @@ -0,0 +1,276 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export interface DetectedLinter { + name: string; + configFiles: string[]; +} + +export interface RuleStyle { + source: string; + description: string; +} + +export interface DetectResult { + linters: DetectedLinter[]; + languages: string[]; + frameworks: string[]; + ruleStyles: RuleStyle[]; +} + +/** + * A linter is evidenced by any of: + * - a fixed config filename present on disk + * - a `[tool.]` table in pyproject.toml + * - a dependency name in package.json (dev/prod/peer) + * + * The list is curated to well-known tools. New entries are deterministic + * signal additions, not inference — detect never matches a request against a + * catalog of packaged rules; that judgment lives in the `existing` recipe. + */ +interface LinterSignal { + name: string; + configFiles?: string[]; + pyprojectTables?: string[]; + packageDeps?: string[]; +} + +const LINTER_SIGNALS: readonly LinterSignal[] = [ + { + name: "eslint", + configFiles: [ + ".eslintrc", + ".eslintrc.js", + ".eslintrc.cjs", + ".eslintrc.mjs", + ".eslintrc.json", + ".eslintrc.yml", + ".eslintrc.yaml", + "eslint.config.js", + "eslint.config.mjs", + "eslint.config.cjs", + "eslint.config.ts", + ], + packageDeps: ["eslint"], + }, + { + name: "biome", + configFiles: ["biome.json", "biome.jsonc"], + packageDeps: ["@biomejs/biome"], + }, + { + name: "stylelint", + configFiles: [ + ".stylelintrc", + ".stylelintrc.js", + ".stylelintrc.cjs", + ".stylelintrc.json", + ".stylelintrc.yml", + ".stylelintrc.yaml", + "stylelint.config.js", + "stylelint.config.cjs", + "stylelint.config.mjs", + ], + packageDeps: ["stylelint"], + }, + { + name: "prettier", + configFiles: [ + ".prettierrc", + ".prettierrc.js", + ".prettierrc.cjs", + ".prettierrc.json", + ".prettierrc.yml", + ".prettierrc.yaml", + "prettier.config.js", + "prettier.config.cjs", + "prettier.config.mjs", + ], + packageDeps: ["prettier"], + }, + { + name: "ruff", + configFiles: ["ruff.toml", ".ruff.toml"], + pyprojectTables: ["ruff"], + }, + { name: "flake8", configFiles: [".flake8"] }, + { + name: "pylint", + configFiles: [".pylintrc", "pylintrc"], + pyprojectTables: ["pylint"], + }, + { name: "black", pyprojectTables: ["black"] }, + { name: "rubocop", configFiles: [".rubocop.yml", ".rubocop.yaml"] }, + { name: "clang-tidy", configFiles: [".clang-tidy"] }, + { name: "swiftlint", configFiles: [".swiftlint.yml", ".swiftlint.yaml"] }, + { name: "checkstyle", configFiles: ["checkstyle.xml"] }, +]; + +/** Languages inferred from the presence of a manifest or marker file. */ +const LANGUAGE_MARKERS: ReadonlyArray<{ language: string; files: string[] }> = [ + { + language: "Python", + files: [ + "pyproject.toml", + "requirements.txt", + "setup.cfg", + "setup.py", + "Pipfile", + ], + }, + { language: "Ruby", files: ["Gemfile"] }, + { language: "Go", files: ["go.mod"] }, + { language: "Rust", files: ["Cargo.toml"] }, + { language: "PHP", files: ["composer.json"] }, + { language: "Java", files: ["pom.xml", "build.gradle", "build.gradle.kts"] }, + { language: "Swift", files: ["Package.swift"] }, +]; + +/** Frameworks inferred from a package.json dependency name. */ +const JS_FRAMEWORK_DEPS: ReadonlyArray<{ framework: string; dep: string }> = [ + { framework: "Next.js", dep: "next" }, + { framework: "React", dep: "react" }, + { framework: "Vue", dep: "vue" }, + { framework: "Nuxt", dep: "nuxt" }, + { framework: "Svelte", dep: "svelte" }, + { framework: "Angular", dep: "@angular/core" }, + { framework: "Express", dep: "express" }, + { framework: "Fastify", dep: "fastify" }, + { framework: "NestJS", dep: "@nestjs/core" }, +]; + +/** Frameworks inferred from a Python dependency token. */ +const PY_FRAMEWORK_TOKENS: ReadonlyArray<{ framework: string; token: string }> = + [ + { framework: "Django", token: "django" }, + { framework: "Flask", token: "flask" }, + { framework: "FastAPI", token: "fastapi" }, + ]; + +async function readFileSafe(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return undefined; + } +} + +interface PackageJson { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + eslintConfig?: unknown; +} + +/** Collect all declared dependency names from a parsed package.json. */ +function allDependencyNames(packageJson: PackageJson): Set { + return new Set([ + ...Object.keys(packageJson.dependencies ?? {}), + ...Object.keys(packageJson.devDependencies ?? {}), + ...Object.keys(packageJson.peerDependencies ?? {}), + ]); +} + +/** + * Deterministically scan `cwd` for linter configs, languages/frameworks, and + * the repo's own rule styles. Pure filesystem reads — no network, no auth, no + * LLM. Unreadable or malformed files are skipped rather than failing the scan. + */ +export async function detectRepository(cwd: string): Promise { + const root = resolve(cwd); + const has = (name: string): boolean => existsSync(resolve(root, name)); + + const packageRaw = await readFileSafe(resolve(root, "package.json")); + let packageJson: PackageJson | undefined; + if (packageRaw) { + try { + packageJson = JSON.parse(packageRaw) as PackageJson; + } catch { + packageJson = undefined; + } + } + const deps = packageJson + ? allDependencyNames(packageJson) + : new Set(); + + const pyproject = (await readFileSafe(resolve(root, "pyproject.toml"))) ?? ""; + + // Linters + const linters: DetectedLinter[] = []; + for (const signal of LINTER_SIGNALS) { + const evidence: string[] = []; + for (const file of signal.configFiles ?? []) { + if (has(file)) evidence.push(file); + } + for (const table of signal.pyprojectTables ?? []) { + if (pyproject.includes(`[tool.${table}`)) + evidence.push(`pyproject.toml [tool.${table}]`); + } + for (const dep of signal.packageDeps ?? []) { + if (deps.has(dep)) evidence.push(`package.json (${dep})`); + } + // eslintConfig key in package.json is an additional eslint signal + if (signal.name === "eslint" && packageJson?.eslintConfig !== undefined) { + evidence.push("package.json (eslintConfig)"); + } + if (evidence.length > 0) { + linters.push({ name: signal.name, configFiles: evidence }); + } + } + + // Languages + const languages: string[] = []; + if (packageJson || has("tsconfig.json")) languages.push("JavaScript"); + if (has("tsconfig.json") || deps.has("typescript")) + languages.push("TypeScript"); + for (const marker of LANGUAGE_MARKERS) { + if (marker.files.some((f) => has(f))) languages.push(marker.language); + } + + // Frameworks + const frameworks: string[] = []; + for (const { framework, dep } of JS_FRAMEWORK_DEPS) { + if (deps.has(dep)) frameworks.push(framework); + } + const pyText = ( + pyproject + + "\n" + + ((await readFileSafe(resolve(root, "requirements.txt"))) ?? "") + ).toLowerCase(); + for (const { framework, token } of PY_FRAMEWORK_TOKENS) { + if (pyText.includes(token)) frameworks.push(framework); + } + + // Rule styles + const ruleStyles: RuleStyle[] = []; + if (existsSync(resolve(root, ".taskless", "rules"))) { + ruleStyles.push({ + source: ".taskless/rules", + description: + "Existing Taskless ast-grep rules — match their structure and conventions.", + }); + } + for (const directory of [ + "eslint-rules", + "eslint-local-rules", + "tools/eslint-rules", + ]) { + if (existsSync(resolve(root, directory))) { + ruleStyles.push({ + source: directory, + description: + "Custom ESLint rules — follow the house style when authoring new ones.", + }); + } + } + if (deps.has("eslint-plugin-local") || deps.has("eslint-local-rules")) { + ruleStyles.push({ + source: "package.json", + description: + "Local ESLint rule plugin in use — author new rules to match it.", + }); + } + + return { linters, languages, frameworks, ruleStyles }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2b9060cd..df43fef2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import { defineCommand, runCommand, showUsage } from "citty"; import { authCommand } from "./commands/auth"; import { checkCommand } from "./commands/check"; +import { detectCommand } from "./commands/detect"; import { initCommand, updateCommand } from "./commands/init"; import { infoCommand } from "./commands/info"; import { createHelpCommand } from "./commands/help"; @@ -14,6 +15,7 @@ const subCommands = { init: initCommand, update: updateCommand, info: infoCommand, + detect: detectCommand, check: checkCommand, auth: authCommand, onboard: onboardCommand, diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts new file mode 100644 index 00000000..6817d553 --- /dev/null +++ b/packages/cli/src/schemas/detect.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +/** A linter detected from configuration on disk */ +const detectedLinterSchema = z.object({ + name: z.string().describe("Linter identifier, e.g. eslint, ruff, rubocop"), + configFiles: z + .array(z.string()) + .describe("Repo-relative config paths that evidenced this linter"), +}); + +/** A surfaced style of the repo's own existing rules */ +const ruleStyleSchema = z.object({ + source: z + .string() + .describe("Where the existing rules live, e.g. .taskless/rules"), + description: z + .string() + .describe("How the repo authors rules of this kind, for downstream reuse"), +}); + +/** Output schema for `taskless detect --json` on success */ +export const outputSchema = z.object({ + success: z.literal(true), + linters: z + .array(detectedLinterSchema) + .describe("Linters configured in the working directory"), + languages: z + .array(z.string()) + .describe("Languages inferred from manifests and source signals"), + frameworks: z + .array(z.string()) + .describe("Frameworks inferred from dependency manifests"), + ruleStyles: z + .array(ruleStyleSchema) + .describe("Styles of the repo's own existing rules"), +}); + +/** Error schema for `taskless detect --json` on failure */ +export const errorSchema = z.object({ + success: z.literal(false), + error: z.string().describe("Error message"), +}); diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts new file mode 100644 index 00000000..59b3d7f4 --- /dev/null +++ b/packages/cli/test/detect.test.ts @@ -0,0 +1,154 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const binPath = resolve(import.meta.dirname, "../dist/index.js"); + +interface ExecError extends Error { + stdout?: string; + stderr?: string; + code?: number; +} + +async function runCli( + args: string[], + cwd: string +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const { stdout, stderr } = await execFileAsync("node", [binPath, ...args], { + cwd, + }); + return { stdout, stderr, exitCode: 0 }; + } catch (error) { + const error_ = error as ExecError; + return { + stdout: error_.stdout ?? "", + stderr: error_.stderr ?? "", + exitCode: error_.code ?? 1, + }; + } +} + +interface DetectJson { + success: boolean; + linters: { name: string; configFiles: string[] }[]; + languages: string[]; + frameworks: string[]; + ruleStyles: { source: string; description: string }[]; +} + +async function detect(cwd: string): Promise { + const { stdout, exitCode } = await runCli( + ["detect", "--json", "-d", cwd], + cwd + ); + expect(exitCode).toBe(0); + return JSON.parse(stdout.trim()) as DetectJson; +} + +function linterNames(result: DetectJson): string[] { + return result.linters.map((l) => l.name); +} + +describe("taskless detect", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "taskless-detect-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it("detects eslint from a config file", async () => { + await writeFile(join(cwd, ".eslintrc.json"), "{}", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("eslint"); + }); + + it("detects ruff from a pyproject [tool.ruff] table", async () => { + await writeFile( + join(cwd, "pyproject.toml"), + "[tool.ruff]\nline-length = 88\n", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("ruff"); + expect(result.languages).toContain("Python"); + }); + + it("detects rubocop from .rubocop.yml", async () => { + await writeFile(join(cwd, ".rubocop.yml"), "", "utf8"); + await writeFile(join(cwd, "Gemfile"), "", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("rubocop"); + expect(result.languages).toContain("Ruby"); + }); + + it("detects biome from biome.json", async () => { + await writeFile(join(cwd, "biome.json"), "{}", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("biome"); + }); + + it("detects stylelint from a package.json devDependency", async () => { + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ devDependencies: { stylelint: "^16.0.0" } }), + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("stylelint"); + }); + + it("infers languages and frameworks from package.json", async () => { + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ + dependencies: { react: "^18", next: "^14" }, + devDependencies: { typescript: "^5" }, + }), + "utf8" + ); + const result = await detect(cwd); + expect(result.languages).toEqual( + expect.arrayContaining(["JavaScript", "TypeScript"]) + ); + expect(result.frameworks).toEqual( + expect.arrayContaining(["React", "Next.js"]) + ); + }); + + it("surfaces the repo's own Taskless rule styles", async () => { + await mkdir(join(cwd, ".taskless", "rules"), { recursive: true }); + const result = await detect(cwd); + expect(result.ruleStyles.some((s) => s.source === ".taskless/rules")).toBe( + true + ); + }); + + it("emits a stable JSON shape with only signal keys (no packaged-rule claims)", async () => { + await writeFile(join(cwd, ".eslintrc.json"), "{}", "utf8"); + const result = await detect(cwd); + expect(Object.keys(result).toSorted()).toEqual( + ["frameworks", "languages", "linters", "ruleStyles", "success"].toSorted() + ); + // A linter entry exposes only name + config evidence, never a rule-name claim. + for (const linter of result.linters) { + expect(Object.keys(linter).toSorted()).toEqual( + ["configFiles", "name"].toSorted() + ); + } + }); + + it("runs successfully with no linters, no network, and no auth", async () => { + const result = await detect(cwd); + expect(result.success).toBe(true); + expect(result.linters).toEqual([]); + }); +}); From 0903037688b704e8f702873e2a9aeb74dd43dd36 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Thu, 11 Jun 2026 17:50:11 -0700 Subject: [PATCH 2/5] fix(cli): Harden detect against malformed manifests and false positives Address review on PR #24: - Guard package.json dependency parsing: a malformed field (array/string/ null) no longer yields bogus dependency names via Object.keys. - Match pyproject tool tables exactly: `[tool.ruff]` or a nested `[tool.ruff.lint]`, never a similarly-prefixed sibling like `[tool.ruff-lsp]`. Anchored at line start to avoid value matches. - Rename the linter `configFiles` output field to `evidence`: it holds config paths AND non-path markers (pyproject tables, package deps), so the old name/contract was misleading. - Drop the divergent unused detect errorSchema; the error path emits the standard `{ ok, code, message }` envelope via makeErrorEnvelope. - Run detect tests with telemetry disabled (DO_NOT_TRACK), keeping the offline scan path hermetic, and add coverage for the table-prefix and malformed-manifest cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/commands/detect.ts | 2 +- packages/cli/src/detect/scan.ts | 43 ++++++++++++++++++++++---- packages/cli/src/schemas/detect.ts | 14 ++++----- packages/cli/test/detect.test.ts | 47 +++++++++++++++++++++++++++-- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index adbe1d5c..82e779e2 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -59,7 +59,7 @@ export const detectCommand = defineCommand({ } else { console.log("Linters:"); for (const linter of result.linters) { - console.log(` ${linter.name}: ${linter.configFiles.join(", ")}`); + console.log(` ${linter.name}: ${linter.evidence.join(", ")}`); } } diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index ea2e9a64..d9006328 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -4,7 +4,12 @@ import { resolve } from "node:path"; export interface DetectedLinter { name: string; - configFiles: string[]; + /** + * On-disk evidence for this linter: config-file paths, a `pyproject.toml` + * table marker, or a `package.json` dependency marker. Not all entries are + * file paths. + */ + evidence: string[]; } export interface RuleStyle { @@ -163,12 +168,34 @@ interface PackageJson { eslintConfig?: unknown; } +/** Object keys, but only for a plain object (a malformed manifest field that + * is an array, string, or null contributes no dependency names). */ +function plainObjectKeys(value: unknown): string[] { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return []; + } + return Object.keys(value as Record); +} + +/** + * Whether a `pyproject.toml` declares the `[tool.]` table or a nested + * table under it (`[tool.
.]`), without matching a similarly-named + * sibling like `[tool.
-lsp]`. Matches at a line start so a value + * containing the literal text doesn't trigger a false positive. + */ +function hasPyprojectTable(pyproject: string, table: string): boolean { + const escaped = table.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + return new RegExp(String.raw`^\s*\[tool\.${escaped}(\]|\.)`, "m").test( + pyproject + ); +} + /** Collect all declared dependency names from a parsed package.json. */ function allDependencyNames(packageJson: PackageJson): Set { return new Set([ - ...Object.keys(packageJson.dependencies ?? {}), - ...Object.keys(packageJson.devDependencies ?? {}), - ...Object.keys(packageJson.peerDependencies ?? {}), + ...plainObjectKeys(packageJson.dependencies), + ...plainObjectKeys(packageJson.devDependencies), + ...plainObjectKeys(packageJson.peerDependencies), ]); } @@ -204,8 +231,12 @@ export async function detectRepository(cwd: string): Promise { if (has(file)) evidence.push(file); } for (const table of signal.pyprojectTables ?? []) { - if (pyproject.includes(`[tool.${table}`)) + // Match the exact table `[tool.ruff]` or a nested table + // `[tool.ruff.lint]`, but NOT a similarly-prefixed table like + // `[tool.ruff-lsp]`. The char after the table name must be `]` or `.`. + if (hasPyprojectTable(pyproject, table)) { evidence.push(`pyproject.toml [tool.${table}]`); + } } for (const dep of signal.packageDeps ?? []) { if (deps.has(dep)) evidence.push(`package.json (${dep})`); @@ -215,7 +246,7 @@ export async function detectRepository(cwd: string): Promise { evidence.push("package.json (eslintConfig)"); } if (evidence.length > 0) { - linters.push({ name: signal.name, configFiles: evidence }); + linters.push({ name: signal.name, evidence }); } } diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts index 6817d553..fb54e955 100644 --- a/packages/cli/src/schemas/detect.ts +++ b/packages/cli/src/schemas/detect.ts @@ -3,9 +3,11 @@ import { z } from "zod"; /** A linter detected from configuration on disk */ const detectedLinterSchema = z.object({ name: z.string().describe("Linter identifier, e.g. eslint, ruff, rubocop"), - configFiles: z + evidence: z .array(z.string()) - .describe("Repo-relative config paths that evidenced this linter"), + .describe( + "On-disk evidence: config-file paths, a pyproject table marker, or a package.json dependency marker (not all entries are file paths)" + ), }); /** A surfaced style of the repo's own existing rules */ @@ -35,8 +37,6 @@ export const outputSchema = z.object({ .describe("Styles of the repo's own existing rules"), }); -/** Error schema for `taskless detect --json` on failure */ -export const errorSchema = z.object({ - success: z.literal(false), - error: z.string().describe("Error message"), -}); +// On the (internal-only) error path `detect` emits the standard +// `{ ok: false, code, message }` envelope via makeErrorEnvelope — there is no +// command-specific error schema to keep in sync. diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 59b3d7f4..1f572a25 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -21,6 +21,14 @@ async function runCli( try { const { stdout, stderr } = await execFileAsync("node", [binPath, ...args], { cwd, + // Keep detect hermetic: telemetry is best-effort and would otherwise + // write an anonymous-id file and attempt network I/O on shutdown, which + // must never be part of detect's offline scan path. + env: { + ...process.env, + DO_NOT_TRACK: "1", + TASKLESS_TELEMETRY_DISABLED: "1", + }, }); return { stdout, stderr, exitCode: 0 }; } catch (error) { @@ -35,7 +43,7 @@ async function runCli( interface DetectJson { success: boolean; - linters: { name: string; configFiles: string[] }[]; + linters: { name: string; evidence: string[] }[]; languages: string[]; frameworks: string[]; ruleStyles: { source: string; description: string }[]; @@ -138,14 +146,47 @@ describe("taskless detect", () => { expect(Object.keys(result).toSorted()).toEqual( ["frameworks", "languages", "linters", "ruleStyles", "success"].toSorted() ); - // A linter entry exposes only name + config evidence, never a rule-name claim. + // A linter entry exposes only name + evidence, never a rule-name claim. for (const linter of result.linters) { expect(Object.keys(linter).toSorted()).toEqual( - ["configFiles", "name"].toSorted() + ["evidence", "name"].toSorted() ); } }); + it("does not false-positive a pyproject table on a similarly-prefixed sibling", async () => { + // `[tool.ruff-lsp]` must NOT be read as the `ruff` tool table. + await writeFile( + join(cwd, "pyproject.toml"), + "[tool.ruff-lsp]\nfoo = 1\n", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("ruff"); + }); + + it("detects ruff from a nested pyproject table ([tool.ruff.lint])", async () => { + await writeFile( + join(cwd, "pyproject.toml"), + '[tool.ruff.lint]\nselect = ["E"]\n', + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("ruff"); + }); + + it("ignores a malformed package.json dependency field without crashing", async () => { + // `dependencies` as an array (not an object) must not yield bogus deps. + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ dependencies: ["react"], devDependencies: null }), + "utf8" + ); + const result = await detect(cwd); + expect(result.success).toBe(true); + expect(result.frameworks).toEqual([]); + }); + it("runs successfully with no linters, no network, and no auth", async () => { const result = await detect(cwd); expect(result.success).toBe(true); From 9be20005b23812ff32616c496a45812ce794d3ef Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 20:26:11 -0700 Subject: [PATCH 3/5] =?UTF-8?q?feat(cli):=20Make=20detect=20monorepo-aware?= =?UTF-8?q?;=20languages=20=E2=86=92=20linters;=20drop=20frameworks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the detect scan from a root-only probe into a bounded, monorepo-aware tree walk and restructure how signals are derived, addressing review feedback on detect/scan.ts. - Monorepo-aware discovery: a single fs.glob walk (curated IGNORED_DIRECTORIES + MAX_DIRECTORY_DEPTH cap) finds configs and manifests anywhere in the tree; evidence carries the path it was found at (e.g. packages/api/.eslintrc.json). - languages -> linters: a linter is tagged with the language it serves, so its dependency evidence is read only from that language's manifest (node from package.json, Python from pyproject.toml/requirements.txt) instead of conflating ecosystems. Config-file presence stays unconditional per the spec. - Real parsers: pyproject.toml via smol-toml (graceful on malformed input, dropping only its own signal); brittle table regex removed. - Dropped the frameworks signal — the route recipe never consumed it. - Fold rule-style detection into a named helper; readFileNode import alias. Requires Node 22+ (Node 20 is EOL; fs.glob is the walker). Minor changeset. Co-Authored-By: Claude Opus 4.8 --- .changeset/detect-monorepo-node22.md | 20 + openspec/changes/local-rule-routing/design.md | 25 +- .../changes/local-rule-routing/proposal.md | 6 +- .../specs/cli-detect/spec.md | 38 +- openspec/changes/local-rule-routing/tasks.md | 6 +- packages/cli/package.json | 3 +- packages/cli/src/commands/detect.ts | 5 +- packages/cli/src/detect/scan.ts | 510 +++++++++++++----- packages/cli/src/schemas/detect.ts | 7 +- packages/cli/test/detect.test.ts | 84 ++- pnpm-lock.yaml | 9 + 11 files changed, 555 insertions(+), 158 deletions(-) create mode 100644 .changeset/detect-monorepo-node22.md diff --git a/.changeset/detect-monorepo-node22.md b/.changeset/detect-monorepo-node22.md new file mode 100644 index 00000000..020068d2 --- /dev/null +++ b/.changeset/detect-monorepo-node22.md @@ -0,0 +1,20 @@ +--- +"@taskless/cli": minor +--- + +Require Node.js 22+ and make `taskless detect` monorepo-aware. + +- **Node floor raised to 22+.** Node 20 reached end-of-life, and detect now uses + the built-in `fs.glob` walker (Node 22+). This is a breaking engine change, + which pre-1.0 is a minor bump. +- **`detect` is monorepo-aware.** A single bounded tree walk (curated ignore + list + depth cap) finds linter configs and language manifests anywhere in the + repo, not just the root, so a linter configured in a sub-package is detected + with its path as evidence. +- **languages → linters flow.** A linter's dependency evidence is read only from + its own language's manifest (`package.json` for node, `pyproject.toml` / + `requirements.txt` for Python), parsed with real parsers (`smol-toml`, + `yaml`), instead of conflating ecosystems. A malformed manifest drops only its + own signal. +- **Dropped the `frameworks` field** from `detect` output. The routing recipe + never consumed it; the contract now matches its sole consumer. diff --git a/openspec/changes/local-rule-routing/design.md b/openspec/changes/local-rule-routing/design.md index 1a0d8493..d74d09d3 100644 --- a/openspec/changes/local-rule-routing/design.md +++ b/openspec/changes/local-rule-routing/design.md @@ -28,7 +28,7 @@ tools (`check`, `improve`, `verify`) see a single dialect regardless of origin. **Goals:** - A deterministic, offline `taskless detect --json` that reports repo signals - (linters configured, languages/frameworks, the repo's own rule styles). + (linters configured, languages, the repo's own rule styles). - A local routing recipe (`route`) that reasons first, then commits to the believed-correct destination (`existing | static | remote`) on reasonable confidence, biased to stay local. @@ -135,15 +135,32 @@ failure) drives the choice. ### D3 — `detect` emits pure repo signals only; no rule-pattern detection -`detect` reports linters present, languages/frameworks, and the repo's own -rule-authoring styles. It does **not** try to match a request against known -packaged rules (e.g. "this is `no-console`"). +`detect` reports linters present, languages, and the repo's own rule-authoring +styles. It does **not** try to match a request against known packaged rules +(e.g. "this is `no-console`"). _Why:_ The space of packaged-rule detections is effectively infinite and changes constantly across ecosystems — a poor determinism target that would drag a maintained catalog back into the CLI. That judgment is cheap for the LLM in the `existing` recipe (repo + WebFetch) and expensive to keep correct in code. +_Why no `frameworks` signal:_ An earlier cut also reported detected frameworks +(React, Django, …). It was dropped: the sole consumer is `route`, whose rationale +(D2 step 0) reads linters, languages, and repo rule styles — never frameworks. +Framework presence does not change which authoring destination fits, so emitting +it was unused surface. Dropping it aligns the contract with its consumer. + +_Detection shape (languages → linters):_ languages are inferred first (manifest +and marker files), then each linter is probed. A linter is tagged with the +language(s) it serves, so its dependency evidence is read from that language's +own manifest — a node dependency from `package.json`, a Python dependency from +`pyproject.toml`/`requirements.txt` — instead of conflating ecosystems. Config +files are parsed with real parsers (`smol-toml` for `pyproject.toml`, alongside +the existing `yaml`); a malformed manifest drops only its own derived signal and +never fails the scan, because other files in the repo are independent tells. A +config file present on disk is honored regardless of inferred language, per the +"Linter configs are detected from disk" requirement. + ### D4 — Knowledge is sourced at author time, not maintained by Taskless The `existing` recipe instructs the agent to mine the repo's own rules of the diff --git a/openspec/changes/local-rule-routing/proposal.md b/openspec/changes/local-rule-routing/proposal.md index 63e2c2ea..10e1fd58 100644 --- a/openspec/changes/local-rule-routing/proposal.md +++ b/openspec/changes/local-rule-routing/proposal.md @@ -15,8 +15,8 @@ failed and the user confirms spending a generation. ## What Changes - **NEW `taskless detect --json`** — a deterministic, offline repo-signal scan: - which linters are configured, languages/frameworks present, and the styles of - the repo's own existing rules. No LLM, no network. Feeds the routing recipe. + which linters are configured, languages present, and the styles of the repo's + own existing rules. No LLM, no network. Feeds the routing recipe. - **NEW routing recipe layer** under `help`, replacing the rule-type-agnostic `rule create` entry as the front door for "author a rule": - `route` — the lightweight **local classifier**. Biased to stay local; @@ -49,7 +49,7 @@ failed and the user confirms spending a generation. ### New Capabilities - `cli-detect`: A deterministic `taskless detect --json` command that scans the - working directory for linter configs, languages/frameworks, and the repo's own + working directory for linter configs, languages, and the repo's own rule-authoring styles, emitting structured signals for downstream routing. No inference, no network. - `cli-rule-routing`: The `route` / `existing` / `static` / `remote` recipe layer diff --git a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md index ddd68199..f3e43ec2 100644 --- a/openspec/changes/local-rule-routing/specs/cli-detect/spec.md +++ b/openspec/changes/local-rule-routing/specs/cli-detect/spec.md @@ -15,9 +15,16 @@ flag. ### Requirement: Detect scans deterministic repo signals only The `detect` command SHALL emit only deterministic signals derived from files on -disk: configured linters, detected languages/frameworks, and the styles of the -repo's own existing rules. It SHALL NOT perform any LLM inference and SHALL NOT -match the request against any catalog of known packaged linter rules. +disk: configured linters, detected languages, and the styles of the repo's own +existing rules. It SHALL NOT perform any LLM inference and SHALL NOT match the +request against any catalog of known packaged linter rules. + +Detection follows a languages → linters flow: languages are inferred first, and +a linter's dependency evidence is then read from the manifest of that linter's +own language (a node dependency from `package.json`, a Python dependency from +`pyproject.toml`/`requirements.txt`) rather than conflating ecosystems. A +recognized linter config file on disk is honored regardless of the languages +inferred. #### Scenario: Linter configs are detected from disk @@ -26,11 +33,27 @@ match the request against any catalog of known packaged linter rules. `pyproject.toml`, `.rubocop.yml`, `biome.json`, or `stylelint` config) - **THEN** `detect --json` SHALL report each configured linter it found -#### Scenario: Languages and frameworks are reported +#### Scenario: Languages are reported - **WHEN** `detect --json` runs in a repository -- **THEN** the output SHALL include the languages and frameworks inferred from - manifest and source signals present on disk +- **THEN** the output SHALL include the languages inferred from manifest and + marker files present on disk and from the linters detected + +#### Scenario: A linter dependency is sourced from its own language's manifest + +- **WHEN** a dependency-evidenced linter (for example `ruff`) is named only in a + manifest belonging to a different language (for example `package.json`) +- **THEN** `detect --json` SHALL NOT report that linter from the mismatched + manifest + +#### Scenario: Configs in monorepo sub-packages are detected + +- **WHEN** a linter config or language manifest lives in a sub-package rather + than the repository root (for example `packages/api/.eslintrc.json`) +- **THEN** `detect --json` SHALL detect it and SHALL carry the path it was found + at in the linter's evidence +- **AND** the scan SHALL prune a curated set of ignored directories (for example + `node_modules`, `.git`, build output) and SHALL bound traversal depth #### Scenario: The repo's own rule styles are surfaced @@ -68,5 +91,4 @@ published artifact, and `detect` does not expose a `--schema` mode. - **WHEN** `detect --json` succeeds - **THEN** stdout SHALL be a single JSON object that the command has validated - against its internal output schema (linters, languages/frameworks, existing - rule styles) + against its internal output schema (linters, languages, existing rule styles) diff --git a/openspec/changes/local-rule-routing/tasks.md b/openspec/changes/local-rule-routing/tasks.md index db5cb431..ecc20967 100644 --- a/openspec/changes/local-rule-routing/tasks.md +++ b/openspec/changes/local-rule-routing/tasks.md @@ -1,10 +1,10 @@ ## 1. Detect command (cli-detect) -- [x] 1.1 Add a `detect` output schema under `packages/cli/src/schemas/` (linters, languages/frameworks, existing rule styles) -- [x] 1.2 Implement `packages/cli/src/commands/detect.ts`: deterministic, offline scan of linter configs, languages/frameworks, and the repo's own rule styles — no LLM, no network, no auth +- [x] 1.1 Add a `detect` output schema under `packages/cli/src/schemas/` (linters, languages, existing rule styles) +- [x] 1.2 Implement `packages/cli/src/commands/detect.ts`: deterministic, offline scan of linter configs, languages, and the repo's own rule styles — no LLM, no network, no auth; languages → linters flow with dependency evidence read from each linter's own manifest (`smol-toml` for `pyproject.toml`) - [x] 1.3 Register `detect` in `packages/cli/src/index.ts` subCommands with `--json` and `--dir`/`-d` - [x] 1.4 Emit `cli_detect` telemetry consistent with other commands -- [x] 1.5 Add unit tests covering: eslint/ruff/rubocop/biome/stylelint config detection, language/framework inference, repo-rule-style surfacing, and JSON-shape validation against the schema +- [x] 1.5 Add unit tests covering: eslint/ruff/rubocop/biome/stylelint config detection, language inference, per-language dependency sourcing, graceful malformed-manifest handling, repo-rule-style surfacing, and JSON-shape validation against the schema - [x] 1.6 Add a test asserting `detect` produces no packaged-rule-catalog claims and runs without network/auth ## 2. Routing recipes (cli-rule-routing) diff --git a/packages/cli/package.json b/packages/cli/package.json index b0fdddd4..20e80f4d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -25,7 +25,7 @@ "dist" ], "engines": { - "node": "^20.20.0 || >=22.22.0" + "node": ">=22.22.0" }, "dependencies": { "@ast-grep/cli": "^0.41.0", @@ -36,6 +36,7 @@ "openapi-fetch": "^0.17.0", "picocolors": "^1.1.1", "posthog-node": "^5.28.11", + "smol-toml": "^1.6.1", "sprintf-js": "^1.1.3", "yaml": "^2.8.2", "zod": "^4.3.6" diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index 82e779e2..c32756fe 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -11,7 +11,7 @@ export const detectCommand = defineCommand({ meta: { name: "detect", description: - "Scan the repo for configured linters, languages/frameworks, and existing rule styles (offline, deterministic)", + "Scan the repo for configured linters, languages, and existing rule styles (offline, deterministic)", }, args: { dir: { @@ -66,9 +66,6 @@ export const detectCommand = defineCommand({ console.log( `\nLanguages: ${result.languages.length > 0 ? result.languages.join(", ") : "none detected"}` ); - console.log( - `Frameworks: ${result.frameworks.length > 0 ? result.frameworks.join(", ") : "none detected"}` - ); if (result.ruleStyles.length > 0) { console.log("\nExisting rule styles:"); diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index d9006328..2d1d71f9 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -1,13 +1,17 @@ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { existsSync, globSync } from "node:fs"; +import { readFile as readFileNode } from "node:fs/promises"; import { resolve } from "node:path"; +import { parse as parseToml } from "smol-toml"; + export interface DetectedLinter { name: string; /** - * On-disk evidence for this linter: config-file paths, a `pyproject.toml` - * table marker, or a `package.json` dependency marker. Not all entries are - * file paths. + * On-disk evidence for this linter: a config-file path, a `pyproject.toml` + * table marker, or a dependency marker from the language's package file. Each + * entry carries the path it was found at, so monorepo evidence + * (`packages/api/.eslintrc.json`) is attributable. Not all entries are file + * paths. */ evidence: string[]; } @@ -20,30 +24,79 @@ export interface RuleStyle { export interface DetectResult { linters: DetectedLinter[]; languages: string[]; - frameworks: string[]; ruleStyles: RuleStyle[]; } /** - * A linter is evidenced by any of: - * - a fixed config filename present on disk - * - a `[tool.]` table in pyproject.toml - * - a dependency name in package.json (dev/prod/peer) + * Directory names pruned from the repo walk. Held as a curated list so the scan + * never descends into dependency trees, build output, or VCS metadata — the + * places a real linter config never lives and where traversal cost explodes. + * Recursive `fs.glob` does not honor `.gitignore`, so we prune explicitly; an + * explicit list is also more deterministic than whatever each repo ignores. + */ +const IGNORED_DIRECTORIES: ReadonlySet = new Set([ + "node_modules", + ".git", + ".hg", + ".svn", + "dist", + "build", + "out", + "coverage", + "vendor", + "target", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + ".cache", + ".parcel-cache", + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".pytest_cache", + ".tox", + ".gradle", +]); + +/** + * Maximum directory depth (levels below the scan root) the walk descends. + * Bounds traversal on pathological trees; monorepo manifests live well within + * this — root → workspace group → package → nested package is four. + */ +const MAX_DIRECTORY_DEPTH = 8; + +/** + * A linter is curated, deterministic signal — never inference. detect never + * matches a request against a catalog of packaged rules; that judgment lives in + * the `existing` recipe. * - * The list is curated to well-known tools. New entries are deterministic - * signal additions, not inference — detect never matches a request against a - * catalog of packaged rules; that judgment lives in the `existing` recipe. + * Each linter is tagged with the language(s) it serves so dependency evidence + * is read from the right package file: a node dependency lives in + * `package.json`, a Python dependency in `pyproject.toml`/`requirements.txt`. + * Tagging by language lets the scan look for a linter's dependency only in its + * own ecosystem's manifest instead of conflating the two. Config-file presence + * is honored unconditionally, per the detect spec ("a recognized linter config + * ... SHALL report each configured linter it found"), so a lone `.eslintrc.json` + * still detects eslint. */ interface LinterSignal { name: string; + /** Languages this linter serves; a detected linter contributes these. */ + languages: string[]; + /** Fixed config filenames; presence on disk is direct evidence. */ configFiles?: string[]; + /** `[tool.]` tables in `pyproject.toml` (Python linters). */ pyprojectTables?: string[]; - packageDeps?: string[]; + /** Dependency names, matched against the manifest of this linter's language. */ + deps?: string[]; } const LINTER_SIGNALS: readonly LinterSignal[] = [ { name: "eslint", + languages: ["JavaScript", "TypeScript"], configFiles: [ ".eslintrc", ".eslintrc.js", @@ -57,15 +110,17 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ "eslint.config.cjs", "eslint.config.ts", ], - packageDeps: ["eslint"], + deps: ["eslint"], }, { name: "biome", + languages: ["JavaScript", "TypeScript"], configFiles: ["biome.json", "biome.jsonc"], - packageDeps: ["@biomejs/biome"], + deps: ["@biomejs/biome"], }, { name: "stylelint", + languages: ["JavaScript", "TypeScript"], configFiles: [ ".stylelintrc", ".stylelintrc.js", @@ -77,10 +132,11 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ "stylelint.config.cjs", "stylelint.config.mjs", ], - packageDeps: ["stylelint"], + deps: ["stylelint"], }, { name: "prettier", + languages: ["JavaScript", "TypeScript"], configFiles: [ ".prettierrc", ".prettierrc.js", @@ -92,27 +148,53 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ "prettier.config.cjs", "prettier.config.mjs", ], - packageDeps: ["prettier"], + deps: ["prettier"], }, { name: "ruff", + languages: ["Python"], configFiles: ["ruff.toml", ".ruff.toml"], pyprojectTables: ["ruff"], + deps: ["ruff"], + }, + { + name: "flake8", + languages: ["Python"], + configFiles: [".flake8"], + deps: ["flake8"], }, - { name: "flake8", configFiles: [".flake8"] }, { name: "pylint", + languages: ["Python"], configFiles: [".pylintrc", "pylintrc"], pyprojectTables: ["pylint"], + deps: ["pylint"], + }, + { + name: "black", + languages: ["Python"], + pyprojectTables: ["black"], + deps: ["black"], + }, + { + name: "rubocop", + languages: ["Ruby"], + configFiles: [".rubocop.yml", ".rubocop.yaml"], + }, + { name: "clang-tidy", languages: ["C", "C++"], configFiles: [".clang-tidy"] }, + { + name: "swiftlint", + languages: ["Swift"], + configFiles: [".swiftlint.yml", ".swiftlint.yaml"], }, - { name: "black", pyprojectTables: ["black"] }, - { name: "rubocop", configFiles: [".rubocop.yml", ".rubocop.yaml"] }, - { name: "clang-tidy", configFiles: [".clang-tidy"] }, - { name: "swiftlint", configFiles: [".swiftlint.yml", ".swiftlint.yaml"] }, - { name: "checkstyle", configFiles: ["checkstyle.xml"] }, + { name: "checkstyle", languages: ["Java"], configFiles: ["checkstyle.xml"] }, ]; -/** Languages inferred from the presence of a manifest or marker file. */ +/** + * Languages inferred from the presence of a manifest or marker file anywhere in + * the tree. JavaScript and TypeScript are resolved separately (they share + * `package.json`). + */ const LANGUAGE_MARKERS: ReadonlyArray<{ language: string; files: string[] }> = [ { language: "Python", @@ -132,30 +214,19 @@ const LANGUAGE_MARKERS: ReadonlyArray<{ language: string; files: string[] }> = [ { language: "Swift", files: ["Package.swift"] }, ]; -/** Frameworks inferred from a package.json dependency name. */ -const JS_FRAMEWORK_DEPS: ReadonlyArray<{ framework: string; dep: string }> = [ - { framework: "Next.js", dep: "next" }, - { framework: "React", dep: "react" }, - { framework: "Vue", dep: "vue" }, - { framework: "Nuxt", dep: "nuxt" }, - { framework: "Svelte", dep: "svelte" }, - { framework: "Angular", dep: "@angular/core" }, - { framework: "Express", dep: "express" }, - { framework: "Fastify", dep: "fastify" }, - { framework: "NestJS", dep: "@nestjs/core" }, +/** Every basename the walk needs to find, deduped for a single glob pass. */ +const DISCOVERABLE_FILES: readonly string[] = [ + ...new Set([ + "package.json", + "tsconfig.json", + ...LANGUAGE_MARKERS.flatMap((marker) => marker.files), + ...LINTER_SIGNALS.flatMap((signal) => signal.configFiles ?? []), + ]), ]; -/** Frameworks inferred from a Python dependency token. */ -const PY_FRAMEWORK_TOKENS: ReadonlyArray<{ framework: string; token: string }> = - [ - { framework: "Django", token: "django" }, - { framework: "Flask", token: "flask" }, - { framework: "FastAPI", token: "fastapi" }, - ]; - async function readFileSafe(path: string): Promise { try { - return await readFile(path, "utf8"); + return await readFileNode(path, "utf8"); } catch { return undefined; } @@ -177,21 +248,8 @@ function plainObjectKeys(value: unknown): string[] { return Object.keys(value as Record); } -/** - * Whether a `pyproject.toml` declares the `[tool.
]` table or a nested - * table under it (`[tool.
.]`), without matching a similarly-named - * sibling like `[tool.
-lsp]`. Matches at a line start so a value - * containing the literal text doesn't trigger a false positive. - */ -function hasPyprojectTable(pyproject: string, table: string): boolean { - const escaped = table.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); - return new RegExp(String.raw`^\s*\[tool\.${escaped}(\]|\.)`, "m").test( - pyproject - ); -} - /** Collect all declared dependency names from a parsed package.json. */ -function allDependencyNames(packageJson: PackageJson): Set { +function nodeDependencyNames(packageJson: PackageJson): Set { return new Set([ ...plainObjectKeys(packageJson.dependencies), ...plainObjectKeys(packageJson.devDependencies), @@ -200,80 +258,137 @@ function allDependencyNames(packageJson: PackageJson): Set { } /** - * Deterministically scan `cwd` for linter configs, languages/frameworks, and - * the repo's own rule styles. Pure filesystem reads — no network, no auth, no - * LLM. Unreadable or malformed files are skipped rather than failing the scan. + * Parse `pyproject.toml` with a real TOML parser. A malformed file yields + * `undefined` rather than throwing — detect degrades gracefully, losing only + * the pyproject-derived signals (the file's mere presence still marks Python, + * and config files like `ruff.toml` are independent tells). */ -export async function detectRepository(cwd: string): Promise { - const root = resolve(cwd); - const has = (name: string): boolean => existsSync(resolve(root, name)); +function parsePyproject( + raw: string | undefined +): Record | undefined { + if (raw === undefined) return undefined; + try { + return parseToml(raw) as Record; + } catch { + return undefined; + } +} - const packageRaw = await readFileSafe(resolve(root, "package.json")); - let packageJson: PackageJson | undefined; - if (packageRaw) { - try { - packageJson = JSON.parse(packageRaw) as PackageJson; - } catch { - packageJson = undefined; - } +function asRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; } - const deps = packageJson - ? allDependencyNames(packageJson) - : new Set(); + return value as Record; +} - const pyproject = (await readFileSafe(resolve(root, "pyproject.toml"))) ?? ""; +/** + * Whether a parsed `pyproject.toml` declares a `[tool.
]` table (or a + * nested table under it, e.g. `[tool.ruff.lint]`). A real parser makes this + * exact: a similarly-named sibling like `[tool.ruff-lsp]` is a distinct key and + * does not match. + */ +function pyprojectHasTable( + pyproject: Record | undefined, + table: string +): boolean { + const tool = asRecord(pyproject?.tool); + return tool !== undefined && Object.hasOwn(tool, table); +} - // Linters - const linters: DetectedLinter[] = []; - for (const signal of LINTER_SIGNALS) { - const evidence: string[] = []; - for (const file of signal.configFiles ?? []) { - if (has(file)) evidence.push(file); - } - for (const table of signal.pyprojectTables ?? []) { - // Match the exact table `[tool.ruff]` or a nested table - // `[tool.ruff.lint]`, but NOT a similarly-prefixed table like - // `[tool.ruff-lsp]`. The char after the table name must be `]` or `.`. - if (hasPyprojectTable(pyproject, table)) { - evidence.push(`pyproject.toml [tool.${table}]`); - } - } - for (const dep of signal.packageDeps ?? []) { - if (deps.has(dep)) evidence.push(`package.json (${dep})`); - } - // eslintConfig key in package.json is an additional eslint signal - if (signal.name === "eslint" && packageJson?.eslintConfig !== undefined) { - evidence.push("package.json (eslintConfig)"); +/** Strip a PEP 508 requirement string down to its package name. */ +function requirementName(requirement: string): string { + return requirement + .trim() + .split(/[\s<>=!~;[\],()]/)[0]! + .toLowerCase(); +} + +/** Python dependency names declared in a parsed `pyproject.toml` (PEP 621 + Poetry). */ +function pythonDepsFromPyproject( + pyproject: Record | undefined +): string[] { + if (pyproject === undefined) return []; + const names: string[] = []; + + const project = asRecord(pyproject.project); + const projectDeps = project?.dependencies; + if (Array.isArray(projectDeps)) { + for (const entry of projectDeps) { + if (typeof entry === "string") names.push(requirementName(entry)); } - if (evidence.length > 0) { - linters.push({ name: signal.name, evidence }); + } + const optional = asRecord(project?.["optional-dependencies"]); + for (const group of Object.values(optional ?? {})) { + if (!Array.isArray(group)) continue; + for (const entry of group) { + if (typeof entry === "string") names.push(requirementName(entry)); } } - // Languages - const languages: string[] = []; - if (packageJson || has("tsconfig.json")) languages.push("JavaScript"); - if (has("tsconfig.json") || deps.has("typescript")) - languages.push("TypeScript"); - for (const marker of LANGUAGE_MARKERS) { - if (marker.files.some((f) => has(f))) languages.push(marker.language); + const poetry = asRecord(asRecord(pyproject.tool)?.poetry); + for (const key of ["dependencies", "dev-dependencies", "group"]) { + const table = asRecord(poetry?.[key]); + if (table) { + names.push(...Object.keys(table).map((name) => name.toLowerCase())); + } } - // Frameworks - const frameworks: string[] = []; - for (const { framework, dep } of JS_FRAMEWORK_DEPS) { - if (deps.has(dep)) frameworks.push(framework); - } - const pyText = ( - pyproject + - "\n" + - ((await readFileSafe(resolve(root, "requirements.txt"))) ?? "") - ).toLowerCase(); - for (const { framework, token } of PY_FRAMEWORK_TOKENS) { - if (pyText.includes(token)) frameworks.push(framework); + return names; +} + +/** Python dependency names declared in a `requirements.txt`. */ +function pythonDepsFromRequirements(raw: string | undefined): string[] { + if (raw === undefined) return []; + const names: string[] = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("-")) { + continue; + } + names.push(requirementName(trimmed)); } + return names; +} + +/** The basename (last path segment) of a `/`-or-`\`-separated relative path. */ +function basenameOf(relativePath: string): string { + return relativePath.split(/[/\\]/).at(-1) ?? ""; +} + +/** + * Prune the walk: skip the curated ignore directories and anything past the + * depth cap. `fs.glob` calls this on each candidate as it descends, so a `true` + * here stops traversal into that directory. + */ +function shouldExclude(relativePath: string): boolean { + const segments = relativePath.split(/[/\\]/); + if (segments.length > MAX_DIRECTORY_DEPTH) return true; + return IGNORED_DIRECTORIES.has(segments.at(-1) ?? ""); +} - // Rule styles +interface NodeManifest { + path: string; + deps: Set; + hasEslintConfigKey: boolean; +} + +interface PythonManifest { + path: string; + parsed?: Record; + deps: Set; +} + +/** + * Surface the styles of the repo's own existing rules so the authoring recipe + * can match house conventions. `.taskless/rules` is the repo-root, polyglot + * Taskless convention, so it is read at the scan root; the custom-ESLint-rule + * tells (house rule directories and the local-rules plugin dependency) describe + * how this repo already authors lint rules. + */ +function detectRuleStyles( + root: string, + nodeManifests: NodeManifest[] +): RuleStyle[] { const ruleStyles: RuleStyle[] = []; if (existsSync(resolve(root, ".taskless", "rules"))) { ruleStyles.push({ @@ -295,13 +410,162 @@ export async function detectRepository(cwd: string): Promise { }); } } - if (deps.has("eslint-plugin-local") || deps.has("eslint-local-rules")) { + const localRulesManifest = nodeManifests.find( + (manifest) => + manifest.deps.has("eslint-plugin-local") || + manifest.deps.has("eslint-local-rules") + ); + if (localRulesManifest) { ruleStyles.push({ - source: "package.json", + source: localRulesManifest.path, description: "Local ESLint rule plugin in use — author new rules to match it.", }); } + return ruleStyles; +} + +/** + * Deterministically scan `cwd` for the languages present, the linters + * configured for those languages, and the repo's own rule styles. Pure + * filesystem reads — no network, no auth, no LLM. Unreadable or malformed files + * are skipped rather than failing the scan. + * + * The scan is monorepo-aware: a single bounded `fs.glob` walk (curated ignore + * list + depth cap) finds manifests and configs anywhere in the tree, so a + * linter configured in a sub-package is detected with its path as evidence. The + * flow is languages → linters: a linter's dependency is looked up only in its + * own language's manifests. + */ +export async function detectRepository(cwd: string): Promise { + const root = resolve(cwd); + + const foundPaths = globSync(`**/{${DISCOVERABLE_FILES.join(",")}}`, { + cwd: root, + exclude: shouldExclude, + }); + + const pathsByBasename = new Map(); + for (const relativePath of foundPaths) { + const basename = basenameOf(relativePath); + const list = pathsByBasename.get(basename); + if (list) list.push(relativePath); + else pathsByBasename.set(basename, [relativePath]); + } + const pathsFor = (basename: string): string[] => + pathsByBasename.get(basename) ?? []; + + // Node manifests → JS/TS dependency names, per location. + const nodeManifests: NodeManifest[] = []; + for (const relativePath of pathsFor("package.json")) { + const raw = await readFileSafe(resolve(root, relativePath)); + if (raw === undefined) continue; + let parsed: PackageJson; + try { + parsed = JSON.parse(raw) as PackageJson; + } catch { + continue; + } + nodeManifests.push({ + path: relativePath, + deps: nodeDependencyNames(parsed), + hasEslintConfigKey: parsed.eslintConfig !== undefined, + }); + } + + // Python manifests → Python dependency names, per location. + const pythonManifests: PythonManifest[] = []; + for (const relativePath of pathsFor("pyproject.toml")) { + const parsed = parsePyproject( + await readFileSafe(resolve(root, relativePath)) + ); + pythonManifests.push({ + path: relativePath, + parsed, + deps: new Set(pythonDepsFromPyproject(parsed)), + }); + } + for (const relativePath of pathsFor("requirements.txt")) { + pythonManifests.push({ + path: relativePath, + deps: new Set( + pythonDepsFromRequirements( + await readFileSafe(resolve(root, relativePath)) + ) + ), + }); + } + + // Languages: manifest/marker files first, then JS/TS from node manifests. + const languages = new Set(); + if ( + pathsFor("package.json").length > 0 || + pathsFor("tsconfig.json").length > 0 + ) { + languages.add("JavaScript"); + } + if ( + pathsFor("tsconfig.json").length > 0 || + nodeManifests.some((manifest) => manifest.deps.has("typescript")) + ) { + languages.add("TypeScript"); + } + for (const marker of LANGUAGE_MARKERS) { + if (marker.files.some((file) => pathsFor(file).length > 0)) { + languages.add(marker.language); + } + } + + // Linters: config files unconditionally; dependency evidence from the + // manifests of the linter's own language. A detected linter contributes its + // language(s). + const linters: DetectedLinter[] = []; + for (const signal of LINTER_SIGNALS) { + const evidence: string[] = []; + const servesPython = signal.languages.includes("Python"); + const servesNode = + signal.languages.includes("JavaScript") || + signal.languages.includes("TypeScript"); + + for (const configFile of signal.configFiles ?? []) { + evidence.push(...pathsFor(configFile)); + } + for (const table of signal.pyprojectTables ?? []) { + for (const manifest of pythonManifests) { + if (pyprojectHasTable(manifest.parsed, table)) { + evidence.push(`${manifest.path} [tool.${table}]`); + } + } + } + for (const dep of signal.deps ?? []) { + const manifests = servesPython + ? pythonManifests + : servesNode + ? nodeManifests + : []; + for (const manifest of manifests) { + if (manifest.deps.has(dep)) { + evidence.push(`dependency ${dep} (${manifest.path})`); + } + } + } + if (signal.name === "eslint") { + for (const manifest of nodeManifests) { + if (manifest.hasEslintConfigKey) { + evidence.push(`${manifest.path} (eslintConfig)`); + } + } + } + + if (evidence.length > 0) { + linters.push({ name: signal.name, evidence }); + for (const language of signal.languages) languages.add(language); + } + } - return { linters, languages, frameworks, ruleStyles }; + return { + linters, + languages: [...languages], + ruleStyles: detectRuleStyles(root, nodeManifests), + }; } diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts index fb54e955..1fd59e76 100644 --- a/packages/cli/src/schemas/detect.ts +++ b/packages/cli/src/schemas/detect.ts @@ -6,7 +6,7 @@ const detectedLinterSchema = z.object({ evidence: z .array(z.string()) .describe( - "On-disk evidence: config-file paths, a pyproject table marker, or a package.json dependency marker (not all entries are file paths)" + "On-disk evidence: config-file paths, a pyproject table marker, or a dependency marker from the language's package file (not all entries are file paths)" ), }); @@ -28,10 +28,7 @@ export const outputSchema = z.object({ .describe("Linters configured in the working directory"), languages: z .array(z.string()) - .describe("Languages inferred from manifests and source signals"), - frameworks: z - .array(z.string()) - .describe("Frameworks inferred from dependency manifests"), + .describe("Languages inferred from manifests and detected linters"), ruleStyles: z .array(ruleStyleSchema) .describe("Styles of the repo's own existing rules"), diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 1f572a25..9baf0161 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -45,7 +45,6 @@ interface DetectJson { success: boolean; linters: { name: string; evidence: string[] }[]; languages: string[]; - frameworks: string[]; ruleStyles: { source: string; description: string }[]; } @@ -114,7 +113,7 @@ describe("taskless detect", () => { expect(linterNames(result)).toContain("stylelint"); }); - it("infers languages and frameworks from package.json", async () => { + it("infers languages from package.json", async () => { await writeFile( join(cwd, "package.json"), JSON.stringify({ @@ -127,9 +126,79 @@ describe("taskless detect", () => { expect(result.languages).toEqual( expect.arrayContaining(["JavaScript", "TypeScript"]) ); - expect(result.frameworks).toEqual( - expect.arrayContaining(["React", "Next.js"]) + }); + + it("detects ruff from a pyproject [project] dependency", async () => { + await writeFile( + join(cwd, "pyproject.toml"), + '[project]\nname = "x"\ndependencies = ["ruff>=0.4", "requests"]\n', + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("ruff"); + expect(result.languages).toContain("Python"); + }); + + it("detects flake8 from a requirements.txt entry", async () => { + await writeFile( + join(cwd, "requirements.txt"), + "# linting\nflake8==7.0.0\nrequests>=2\n", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("flake8"); + expect(result.languages).toContain("Python"); + }); + + it("does not look up a Python linter dependency in package.json", async () => { + // A node manifest naming `ruff` must not register the Python linter — deps + // are sourced from the language's own manifest, never conflated. + await writeFile( + join(cwd, "package.json"), + JSON.stringify({ dependencies: { ruff: "^1.0.0" } }), + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("ruff"); + }); + + it("degrades gracefully on a malformed pyproject.toml", async () => { + // A TOML parse failure drops only the pyproject-derived signal; the file's + // presence still marks Python and a config file is an independent tell. + await writeFile( + join(cwd, "pyproject.toml"), + "this is = = not valid toml [[[\n", + "utf8" ); + await writeFile(join(cwd, "ruff.toml"), "line-length = 88\n", "utf8"); + const result = await detect(cwd); + expect(result.success).toBe(true); + expect(result.languages).toContain("Python"); + expect(linterNames(result)).toContain("ruff"); + }); + + it("detects a linter configured in a sub-package (monorepo)", async () => { + await mkdir(join(cwd, "packages", "api"), { recursive: true }); + await writeFile( + join(cwd, "packages", "api", ".eslintrc.json"), + "{}", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).toContain("eslint"); + const eslint = result.linters.find((l) => l.name === "eslint"); + expect(eslint?.evidence).toContain("packages/api/.eslintrc.json"); + }); + + it("ignores linter configs inside node_modules", async () => { + await mkdir(join(cwd, "node_modules", "some-dep"), { recursive: true }); + await writeFile( + join(cwd, "node_modules", "some-dep", ".eslintrc.json"), + "{}", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("eslint"); }); it("surfaces the repo's own Taskless rule styles", async () => { @@ -144,7 +213,7 @@ describe("taskless detect", () => { await writeFile(join(cwd, ".eslintrc.json"), "{}", "utf8"); const result = await detect(cwd); expect(Object.keys(result).toSorted()).toEqual( - ["frameworks", "languages", "linters", "ruleStyles", "success"].toSorted() + ["languages", "linters", "ruleStyles", "success"].toSorted() ); // A linter entry exposes only name + evidence, never a rule-name claim. for (const linter of result.linters) { @@ -179,12 +248,13 @@ describe("taskless detect", () => { // `dependencies` as an array (not an object) must not yield bogus deps. await writeFile( join(cwd, "package.json"), - JSON.stringify({ dependencies: ["react"], devDependencies: null }), + JSON.stringify({ dependencies: ["eslint"], devDependencies: null }), "utf8" ); const result = await detect(cwd); expect(result.success).toBe(true); - expect(result.frameworks).toEqual([]); + // The array form yields no dependency names, so no dep-based linter. + expect(linterNames(result)).not.toContain("eslint"); }); it("runs successfully with no linters, no network, and no auth", async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ae84903..bbbc1243 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: posthog-node: specifier: ^5.28.11 version: 5.28.11 + smol-toml: + specifier: ^1.6.1 + version: 1.6.1 sprintf-js: specifier: ^1.1.3 version: 1.1.3 @@ -2024,6 +2027,10 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4195,6 +4202,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smol-toml@1.6.1: {} + source-map-js@1.2.1: {} spawndamnit@3.0.1: From b4c50eb1b43fe7548c6129b6f017942b0b02491c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 21:26:02 -0700 Subject: [PATCH 4/5] feat(cli): Add linters for already-detected Go, Rust, and PHP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect recognized these languages but reported zero linters for them. Fill the obvious gaps with config-file signals: golangci-lint (Go), Clippy (Rust), and PHPStan / PHP_CodeSniffer / Psalm (PHP). Still tool-level signal, no rule catalog — consistent with the D3 non-goal. Co-Authored-By: Claude Opus 4.8 --- .changeset/detect-monorepo-node22.md | 3 +++ packages/cli/src/detect/scan.ts | 35 ++++++++++++++++++++++++++++ packages/cli/test/detect.test.ts | 24 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/.changeset/detect-monorepo-node22.md b/.changeset/detect-monorepo-node22.md index 020068d2..0a780825 100644 --- a/.changeset/detect-monorepo-node22.md +++ b/.changeset/detect-monorepo-node22.md @@ -18,3 +18,6 @@ Require Node.js 22+ and make `taskless detect` monorepo-aware. own signal. - **Dropped the `frameworks` field** from `detect` output. The routing recipe never consumed it; the contract now matches its sole consumer. +- **Filled obvious linter gaps** for languages detect already recognizes: + golangci-lint (Go), Clippy (Rust), and PHPStan / PHP_CodeSniffer / Psalm + (PHP). diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 2d1d71f9..8577d32b 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -181,6 +181,41 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ languages: ["Ruby"], configFiles: [".rubocop.yml", ".rubocop.yaml"], }, + { + name: "golangci-lint", + languages: ["Go"], + configFiles: [ + ".golangci.yml", + ".golangci.yaml", + ".golangci.toml", + ".golangci.json", + ], + }, + { + name: "clippy", + languages: ["Rust"], + configFiles: ["clippy.toml", ".clippy.toml"], + }, + { + name: "phpstan", + languages: ["PHP"], + configFiles: ["phpstan.neon", "phpstan.neon.dist", "phpstan.dist.neon"], + }, + { + name: "php_codesniffer", + languages: ["PHP"], + configFiles: [ + "phpcs.xml", + "phpcs.xml.dist", + ".phpcs.xml", + ".phpcs.xml.dist", + ], + }, + { + name: "psalm", + languages: ["PHP"], + configFiles: ["psalm.xml", "psalm.xml.dist"], + }, { name: "clang-tidy", languages: ["C", "C++"], configFiles: [".clang-tidy"] }, { name: "swiftlint", diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 9baf0161..924d44bf 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -97,6 +97,30 @@ describe("taskless detect", () => { expect(result.languages).toContain("Ruby"); }); + it("detects golangci-lint for a Go repo", async () => { + await writeFile(join(cwd, "go.mod"), "module example.com/x\n", "utf8"); + await writeFile(join(cwd, ".golangci.yml"), "", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("golangci-lint"); + expect(result.languages).toContain("Go"); + }); + + it("detects phpstan for a PHP repo", async () => { + await writeFile(join(cwd, "composer.json"), "{}", "utf8"); + await writeFile(join(cwd, "phpstan.neon"), "", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("phpstan"); + expect(result.languages).toContain("PHP"); + }); + + it("detects clippy for a Rust repo", async () => { + await writeFile(join(cwd, "Cargo.toml"), '[package]\nname = "x"\n', "utf8"); + await writeFile(join(cwd, "clippy.toml"), "", "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).toContain("clippy"); + expect(result.languages).toContain("Rust"); + }); + it("detects biome from biome.json", async () => { await writeFile(join(cwd, "biome.json"), "{}", "utf8"); const result = await detect(cwd); From e69d88e56318afcf6b78ec52dd5854e561596e67 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sat, 13 Jun 2026 22:11:14 -0700 Subject: [PATCH 5/5] chore(cli): Drop cli_detect on the detect branch (telemetry taxonomy) Conform detect.ts to the cli_ taxonomy now on main: detect is read-only and rides on cli_run, so the bespoke cli_detect capture and its getTelemetry import go. Matches the tip's #39 resolution so the branch is green/consistent. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/detect.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/detect.ts b/packages/cli/src/commands/detect.ts index c32756fe..f0be6b32 100644 --- a/packages/cli/src/commands/detect.ts +++ b/packages/cli/src/commands/detect.ts @@ -4,7 +4,6 @@ import { defineCommand } from "citty"; import { detectRepository } from "../detect/scan"; import { outputSchema as detectOutputSchema } from "../schemas/detect"; -import { getTelemetry } from "../telemetry"; import { makeErrorEnvelope } from "../types/errors"; export const detectCommand = defineCommand({ @@ -27,8 +26,9 @@ export const detectCommand = defineCommand({ }, async run({ args }) { const cwd = resolve(args.dir ?? process.cwd()); - const telemetry = await getTelemetry(cwd); - telemetry.capture("cli_detect"); + // detect is read-only with no state transition, so it emits no bespoke + // event — the per-invocation cli_run denominator (emitted by the runner) + // covers it, consistent with info under the cli_ telemetry taxonomy. const result = { success: true as const,