diff --git a/.changeset/detect-monorepo-node22.md b/.changeset/detect-monorepo-node22.md new file mode 100644 index 00000000..0a780825 --- /dev/null +++ b/.changeset/detect-monorepo-node22.md @@ -0,0 +1,23 @@ +--- +"@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. +- **Filled obvious linter gaps** for languages detect already recognizes: + golangci-lint (Go), Clippy (Rust), and PHPStan / PHP_CodeSniffer / Psalm + (PHP). 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 new file mode 100644 index 00000000..ecc20967 --- /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, 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 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) + +- [ ] 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/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 new file mode 100644 index 00000000..f0be6b32 --- /dev/null +++ b/packages/cli/src/commands/detect.ts @@ -0,0 +1,77 @@ +import { resolve } from "node:path"; + +import { defineCommand } from "citty"; + +import { detectRepository } from "../detect/scan"; +import { outputSchema as detectOutputSchema } from "../schemas/detect"; +import { makeErrorEnvelope } from "../types/errors"; + +export const detectCommand = defineCommand({ + meta: { + name: "detect", + description: + "Scan the repo for configured linters, languages, 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()); + // 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, + ...(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.evidence.join(", ")}`); + } + } + + console.log( + `\nLanguages: ${result.languages.length > 0 ? result.languages.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..8577d32b --- /dev/null +++ b/packages/cli/src/detect/scan.ts @@ -0,0 +1,606 @@ +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: 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[]; +} + +export interface RuleStyle { + source: string; + description: string; +} + +export interface DetectResult { + linters: DetectedLinter[]; + languages: string[]; + ruleStyles: RuleStyle[]; +} + +/** + * 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. + * + * 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[]; + /** 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", + ".eslintrc.cjs", + ".eslintrc.mjs", + ".eslintrc.json", + ".eslintrc.yml", + ".eslintrc.yaml", + "eslint.config.js", + "eslint.config.mjs", + "eslint.config.cjs", + "eslint.config.ts", + ], + deps: ["eslint"], + }, + { + name: "biome", + languages: ["JavaScript", "TypeScript"], + configFiles: ["biome.json", "biome.jsonc"], + deps: ["@biomejs/biome"], + }, + { + name: "stylelint", + languages: ["JavaScript", "TypeScript"], + configFiles: [ + ".stylelintrc", + ".stylelintrc.js", + ".stylelintrc.cjs", + ".stylelintrc.json", + ".stylelintrc.yml", + ".stylelintrc.yaml", + "stylelint.config.js", + "stylelint.config.cjs", + "stylelint.config.mjs", + ], + deps: ["stylelint"], + }, + { + name: "prettier", + languages: ["JavaScript", "TypeScript"], + configFiles: [ + ".prettierrc", + ".prettierrc.js", + ".prettierrc.cjs", + ".prettierrc.json", + ".prettierrc.yml", + ".prettierrc.yaml", + "prettier.config.js", + "prettier.config.cjs", + "prettier.config.mjs", + ], + deps: ["prettier"], + }, + { + name: "ruff", + languages: ["Python"], + configFiles: ["ruff.toml", ".ruff.toml"], + pyprojectTables: ["ruff"], + deps: ["ruff"], + }, + { + name: "flake8", + languages: ["Python"], + configFiles: [".flake8"], + deps: ["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: "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", + languages: ["Swift"], + configFiles: [".swiftlint.yml", ".swiftlint.yaml"], + }, + { name: "checkstyle", languages: ["Java"], configFiles: ["checkstyle.xml"] }, +]; + +/** + * 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", + 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"] }, +]; + +/** 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 ?? []), + ]), +]; + +async function readFileSafe(path: string): Promise { + try { + return await readFileNode(path, "utf8"); + } catch { + return undefined; + } +} + +interface PackageJson { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + 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); +} + +/** Collect all declared dependency names from a parsed package.json. */ +function nodeDependencyNames(packageJson: PackageJson): Set { + return new Set([ + ...plainObjectKeys(packageJson.dependencies), + ...plainObjectKeys(packageJson.devDependencies), + ...plainObjectKeys(packageJson.peerDependencies), + ]); +} + +/** + * 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). + */ +function parsePyproject( + raw: string | undefined +): Record | undefined { + if (raw === undefined) return undefined; + try { + return parseToml(raw) as Record; + } catch { + return undefined; + } +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +/** + * 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); +} + +/** 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)); + } + } + 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)); + } + } + + 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())); + } + } + + 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) ?? ""); +} + +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({ + 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.", + }); + } + } + const localRulesManifest = nodeManifests.find( + (manifest) => + manifest.deps.has("eslint-plugin-local") || + manifest.deps.has("eslint-local-rules") + ); + if (localRulesManifest) { + ruleStyles.push({ + 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: [...languages], + ruleStyles: detectRuleStyles(root, nodeManifests), + }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ae895704..418eedbc 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"; @@ -19,6 +20,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..1fd59e76 --- /dev/null +++ b/packages/cli/src/schemas/detect.ts @@ -0,0 +1,39 @@ +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"), + evidence: z + .array(z.string()) + .describe( + "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)" + ), +}); + +/** 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 detected linters"), + ruleStyles: z + .array(ruleStyleSchema) + .describe("Styles of the repo's own existing rules"), +}); + +// 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 new file mode 100644 index 00000000..924d44bf --- /dev/null +++ b/packages/cli/test/detect.test.ts @@ -0,0 +1,289 @@ +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, + // 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) { + const error_ = error as ExecError; + return { + stdout: error_.stdout ?? "", + stderr: error_.stderr ?? "", + exitCode: error_.code ?? 1, + }; + } +} + +interface DetectJson { + success: boolean; + linters: { name: string; evidence: string[] }[]; + languages: 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 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); + 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 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"]) + ); + }); + + 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 () => { + 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( + ["languages", "linters", "ruleStyles", "success"].toSorted() + ); + // A linter entry exposes only name + evidence, never a rule-name claim. + for (const linter of result.linters) { + expect(Object.keys(linter).toSorted()).toEqual( + ["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: ["eslint"], devDependencies: null }), + "utf8" + ); + const result = await detect(cwd); + expect(result.success).toBe(true); + // 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 () => { + const result = await detect(cwd); + expect(result.success).toBe(true); + expect(result.linters).toEqual([]); + }); +}); 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: