diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5714e11..aa28431 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,7 +11,7 @@ jobs: publish: needs: ci - uses: askrjs/actions/.github/workflows/publish-package.yml@614b9e344983ad2100563c5f36f94d774d3ba6bd + uses: askrjs/actions/.github/workflows/publish-package.yml@cfbfac63066d778fa1fa99247f6af43515155e4f permissions: contents: write id-token: write diff --git a/README.md b/README.md index 5708123..0492285 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ unless you opt out with `--no-skills`. - `askr create [template] [--prompt ] [--no-install] [--no-skills]` - `askr add page [--branch app|public]` - `askr add action --route ` +- `askr analyze [--cwd ] [--workspace ]... [--json] [--check]` +- `askr doctor [--cwd ] [--workspace ]... [--json]` +- `askr repair [--cwd ] [--workspace ]... [--json]` +- `askr check [--cwd ] [--workspace ]... [--json]` - `askr skills list` - `askr skills install [--cwd ] [--force]` - `askr skills sync [--cwd ]` @@ -53,6 +57,50 @@ unless you opt out with `--no-skills`. The installed command is `askr`. Subcommands are intentionally not published as compatibility binaries. +## Static analysis + +`askr analyze` discovers the containing npm or pnpm workspace, builds a +TypeScript compiler program for every selected workspace, and checks current +Askr state, lifecycle, data, control-flow, routing, boot, SSR/SSG, action, and +configuration contracts. + +```bash +askr analyze +askr analyze --workspace "@scope/web" +askr analyze --check +askr analyze --json --check +``` + +All diagnostics include a stable rule ID and workspace-relative source +location. The analyzer distinguishes canonical Askr imports from unrelated +same-named functions and only recommends `` for state-backed reactive JSX +collections, so static transforms with `.map()` remain valid. + +By default it transactionally applies only mechanical route-parameter and +plain-JSON JSX configuration fixes. `--check` is read-only for CI. Semantic +rewrites, including `.map()` to ``, conditional primitives, and key +changes, are always report-only. See the +[analyze command reference](./docs/analyze.md) for rules and configuration. + +## Project recovery loop + +Every generated project uses the same guardrail loop: + +```bash +askr doctor +askr repair +askr check +``` + +`doctor` performs a read-only environment, package-manager, framework, skills, +and static-analysis inspection. `repair` transactionally applies only safe +mechanical fixes and reports remaining semantic work. `check` requires clean +analysis before running the project's declared lint, typecheck, test, and build +scripts in order. Generated projects expose that final gate as `npm run check`. + +See the [project guardrails reference](./docs/guardrails.md) for command and JSON +contracts. + ## Static sitemap generation `askr ssg` writes `sitemap.xml` from the concrete routes generated during the diff --git a/benchmarks/analyze-budget-reporter.ts b/benchmarks/analyze-budget-reporter.ts new file mode 100644 index 0000000..f84a085 --- /dev/null +++ b/benchmarks/analyze-budget-reporter.ts @@ -0,0 +1,36 @@ +import type { Reporter, TestModule } from "vitest/node"; + +const ANALYZE_BUDGETS_MS: Readonly> = { + "50-file workspace": 100, + "250-file workspace": 250, + "5-workspace monorepo with 250 files": 300, +}; + +interface BenchmarkMeta { + readonly benchmark?: boolean; + readonly result?: { + readonly mean: number; + }; +} + +export class AnalyzeBudgetReporter implements Reporter { + onTestRunEnd(testModules: readonly TestModule[]): void { + const failures: string[] = []; + for (const testModule of testModules) { + for (const test of testModule.children.allTests()) { + const budget = ANALYZE_BUDGETS_MS[test.name]; + if (budget === undefined) continue; + const meta = test.meta() as BenchmarkMeta; + const mean = meta.result?.mean; + if (!meta.benchmark || mean === undefined) { + failures.push(`${test.name}: missing benchmark result`); + } else if (mean > budget) { + failures.push(`${test.name}: mean ${mean.toFixed(1)} ms exceeds ${budget} ms`); + } + } + } + if (failures.length > 0) { + throw new Error(`Analyzer performance budget failed:\n${failures.join("\n")}`); + } + } +} diff --git a/benchmarks/analyze.bench.ts b/benchmarks/analyze.bench.ts new file mode 100644 index 0000000..7ddadd5 --- /dev/null +++ b/benchmarks/analyze.bench.ts @@ -0,0 +1,132 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, bench, describe, expect } from "vite-plus/test"; +import { runAnalysis } from "../src/analyze/runner"; + +const roots: string[] = []; +const benchOptions = { + iterations: 8, + warmupIterations: 2, + time: 0, + warmupTime: 0, +} as const; + +async function writeJson(filePath: string, value: unknown): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function componentSource(index: number): string { + return ` + import { For, derive, resource, state } from "@askrjs/askr"; + + export function Page${index}() { + const [items] = state([{ id: ${index}, label: "Item ${index}" }]); + const count = derive(() => items().length); + const status = resource(({ signal }) => + fetch("/api/items/${index}", { signal }), [count()]); + return
+ item.id}> + {(item) => {item.label}} + +
; + } + `; +} + +async function createWorkspace( + root: string, + relativeDirectory: string, + name: string, + sourceCount: number, +): Promise { + const directory = path.join(root, relativeDirectory); + await writeJson(path.join(directory, "package.json"), { + name, + dependencies: { "@askrjs/askr": "^0.0.70" }, + }); + await writeJson(path.join(directory, "tsconfig.json"), { + compilerOptions: { + jsx: "react-jsx", + jsxImportSource: "@askrjs/askr", + module: "ESNext", + moduleResolution: "Bundler", + target: "ES2022", + }, + include: ["src"], + }); + await fs.mkdir(path.join(directory, "src"), { recursive: true }); + await Promise.all( + Array.from({ length: sourceCount }, (_, index) => + fs.writeFile( + path.join(directory, "src", `page-${String(index).padStart(4, "0")}.tsx`), + componentSource(index), + ), + ), + ); +} + +async function createSingleWorkspaceFixture(sourceCount: number): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-analyze-bench-single-")); + roots.push(root); + await createWorkspace(root, ".", "single-app", sourceCount); + return root; +} + +async function createMonorepoFixture( + workspaceCount: number, + sourcesPerWorkspace: number, +): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-analyze-bench-monorepo-")); + roots.push(root); + await writeJson(path.join(root, "package.json"), { + name: "bench-root", + workspaces: ["packages/*"], + }); + await Promise.all( + Array.from({ length: workspaceCount }, (_, index) => + createWorkspace( + root, + path.join("packages", `app-${index}`), + `app-${index}`, + sourcesPerWorkspace, + ), + ), + ); + return root; +} + +let mediumWorkspace: string; +let largeWorkspace: string; +let monorepo: string; + +beforeAll(async () => { + [mediumWorkspace, largeWorkspace, monorepo] = await Promise.all([ + createSingleWorkspaceFixture(50), + createSingleWorkspaceFixture(250), + createMonorepoFixture(5, 50), + ]); +}); + +afterAll(async () => { + await Promise.all(roots.map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +async function analyzeFixture(root: string, expectedFiles: number): Promise { + const report = await runAnalysis({ + cwd: root, + workspacePatterns: [], + check: true, + }); + expect(report.summary.diagnostics).toBe(0); + expect(report.workspaces.reduce((sum, workspace) => sum + workspace.files, 0)).toBe( + expectedFiles, + ); +} + +describe("askr analyze", () => { + bench("50-file workspace", () => analyzeFixture(mediumWorkspace, 50), benchOptions); + bench("250-file workspace", () => analyzeFixture(largeWorkspace, 250), benchOptions); + bench("5-workspace monorepo with 250 files", () => analyzeFixture(monorepo, 250), benchOptions); +}); diff --git a/benchmarks/cli.mjs b/benchmarks/cli.mjs index f1dd9bd..29235f6 100644 --- a/benchmarks/cli.mjs +++ b/benchmarks/cli.mjs @@ -139,8 +139,12 @@ for (const [name, args] of [ for (const command of [ "create", "add", + "analyze", + "check", + "doctor", "generate", "openapi", + "repair", "skills", "ssg", "outdated", @@ -155,6 +159,15 @@ for (const command of [ samples, }); } +const analyze = commandSample(["analyze", "--cwd", "templates/startkit", "--json", "--check"]); +rows.push({ + name: "analyze:cold-35-file-template", + // TypeScript program construction dominates a cold process; keep this separate + // from the existing dispatch budgets while guarding against regressions. + budgetMs: 1000, + p95Ms: percentile(analyze, 0.95), + samples: analyze, +}); const ssg = await ssgSample(); rows.push({ name: "ssg:100-fixture-routes", diff --git a/docs/README.md b/docs/README.md index 5d46979..df80b85 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,8 @@ The unified CLI for the Askr platform. - [skill-system-design](./skill-system-design.md) - Why the bundled skills are layered and how drift is constrained - [skill-review-prompts](./skill-review-prompts.md) - Golden prompts for auditing the bundled skill system - [add](./add.md) - Generate feature code into an existing project +- [analyze](./analyze.md) - Check Askr correctness, performance, and configuration +- [Project guardrails](./guardrails.md) - Diagnose, repair, and validate a project - [generate](./generate.md) - Generate a typed client from an OpenAPI document - [openapi](./openapi.md) - Export deterministic OpenAPI YAML - [ssg](./ssg.md) - Atomically publish static routes and sitemap artifacts diff --git a/docs/analyze.md b/docs/analyze.md new file mode 100644 index 0000000..382444d --- /dev/null +++ b/docs/analyze.md @@ -0,0 +1,145 @@ +# analyze + +`askr analyze` performs workspace-aware static checks for current Askr APIs. +It uses the TypeScript compiler API with each selected workspace's +`tsconfig.json`, when present, and also scans JavaScript and TypeScript source +files that are not listed by that config. + +```bash +askr analyze +askr analyze --workspace "@example/web" +askr analyze --workspace "apps-*" --workspace "shared-*" +askr analyze --check +askr analyze --json --check +askr analyze --cwd ./apps/web +``` + +The command discovers the containing npm or pnpm workspace. It scans the root +and every declared workspace by default. Repeat `--workspace` to select workspace +names with minimatch patterns. + +## Diagnostics + +Every diagnostic has a stable rule ID, category, severity, message, workspace, +workspace-relative file, one-based line and column, and optional remediation. +Output is sorted by workspace, file, position, rule ID, and message. + +The analyzer resolves named aliases and namespace imports from +`@askrjs/askr` and its public subpaths. A same-named function imported from +another package or local module is not treated as an Askr API. + +### Correctness + +- `askr/parse-error` reports malformed source before other results can be + considered complete. +- `askr/stable-render-call` enforces stable top-level calls for state, derived + values, selectors, resources, lifecycle operations, actions, queries, and + mutations where the AST establishes a component render context. +- `askr/state-access` reports state getters used without calling them in JSX + expressions or direct returns, and setters called without a value or updater. +- `askr/state-render-write` reports state mutation during the owning component's + render while allowing updates in event callbacks. +- `askr/resource-cancellation` and `askr/data-cancellation` report fetch-based + resource, query, and mutation loaders that do not forward their cancellation + signal. +- `askr/for-contract` requires `each`, an item renderer, and exactly one of + `by` or `byIndex`. +- `askr/control-contract` validates required `Show` and `Match` conditions and + direct `Case`/`Match` structure. +- `askr/no-async-component` reports async JSX components. +- `askr/route-registry` keeps route DSL calls inside a synchronous + `createRouteRegistry()` definition. +- `askr/route-path-syntax` enforces `{name}` route parameters. +- `askr/boot-registry` requires an explicit registry and an observed Promise for + `createSPA()` and `hydrateSPA()`. +- `askr/ssr-browser-global` reports unguarded browser globals in SSR and SSG + modules. + +### Performance + +- `askr/prefer-for` reports JSX `.map()` only when its receiver is proven to be + an Askr state-backed reactive collection. Static array transforms remain + valid. +- `askr/stable-key` reports index-returning `by` functions. +- `askr/stable-dependencies` reports object, array, function, and constructor + allocations in resource dependency arrays. + +### Configuration + +- `askr/framework-config` validates the Askr JSX import source and detects a + declared `@askrjs/vite` dependency that is absent from `vite.config.*`. + +The rule catalog is intentionally extensible. Current concepts inventory +reactive state, lifecycle operations, queries, mutations, invalidation, control +flow, route DSL and registries, SPA and island boot, SSR, SSG, actions, +authorization, scopes, refs, and composition. Static analysis reports only +patterns it can establish from source and configuration; it does not run the +project's lint, tests, or build. + +## Safe fixes + +Without `--check`, the command applies only fixes whose intent is mechanical: + +- convert route parameters such as `:id` to `{id}`; +- add the Askr JSX runtime to a plain-JSON `tsconfig.json`. + +All changed files are staged and replaced as one transaction. If any replacement +fails, completed replacements are rolled back. JSONC, inherited TypeScript +configuration, `.map()` to ``, conditional render-scoped calls, invalid +keys, and other semantic changes are report-only. + +`--check` performs no writes. Fixable diagnostics remain in the result and +appear under `skippedFixes` with a check-mode reason. + +The command exits `1` while error or warning diagnostics remain, and `0` when +only informational or no diagnostics remain. + +## Performance contract + +The analyzer intentionally builds lightweight syntax programs: project source +and local path aliases are resolved, while standard-library and external +package declaration graphs are not loaded. Rules still distinguish canonical +Askr imports from unrelated local functions, but analysis does not pay the cost +of type-checking dependency declarations it never reports. + +`npm run bench:analyze` runs the analyzer's Vitest benchmark suite. It covers a +50-file workspace, a 250-file workspace, and five workspaces containing 250 +files in total. The benchmark reporter enforces mean-time budgets of 100 ms, +250 ms, and 300 ms respectively. The general `npm run bench` gate also checks a +cold installed-CLI scan of the 35-file startkit template against a 350 ms p95 +budget. + +## Configuration + +Configure the analyzer in the workspace root `package.json`: + +```json +{ + "askr": { + "analyze": { + "exclude": ["fixtures/**", "**/*.generated.ts"], + "rules": { + "askr/prefer-for": "error", + "askr/stable-dependencies": "info", + "askr/ssr-browser-global": "off" + } + } + } +} +``` + +Rule values are `error`, `warning`, `info`, or `off`. Exclusions are applied +relative to each workspace. The analyzer always ignores dependency, VCS, +coverage, generated, and common build-output directories by default. + +## CI + +Use check mode so CI cannot change the checkout: + +```bash +askr analyze --check +``` + +JSON output is one deterministic object containing schema version `1`, the +project root, discovered and selected workspaces, per-workspace program details, +applied and skipped fixes, sorted diagnostics, and summary counts. diff --git a/docs/guardrails.md b/docs/guardrails.md new file mode 100644 index 0000000..b47023f --- /dev/null +++ b/docs/guardrails.md @@ -0,0 +1,85 @@ +# Project guardrails + +Askr projects ship with one recovery loop for humans, CI, and AI agents: + +```bash +askr doctor +askr repair +askr check +``` + +## `askr doctor` + +`doctor` performs a read-only health inspection. It checks: + +- the active Node version against the CLI support range; +- package-manager declaration and lockfile consistency; +- `@askrjs/askr` declarations in source workspaces; +- presence and byte-level freshness of project-local Askr agent skills; +- all `askr analyze --check` diagnostics. + +Warnings are advisory. Environment, package-manager, framework dependency, and +analysis errors make the command exit `1`. + +```bash +askr doctor +askr doctor --cwd ./apps/web +askr doctor --json +``` + +## `askr repair` + +`repair` applies the analyzer's explicitly safe fixes as one transaction, runs +analysis again, and reports semantic findings that still require judgment. + +```bash +askr repair +askr repair --workspace "@example/web" +askr repair --json +``` + +Route-parameter conversion and plain-JSON JSX runtime configuration are safe +fixes today. Moving lifecycle calls, changing collection keys, and other +semantic rewrites remain report-only. Run `repair` again after manual edits; a +clean second run applies no changes. + +## `askr check` + +`check` is the default completion gate in generated projects. It first runs +read-only static analysis. Blocking findings skip later work so the fastest, +most actionable failure is reported first. Once analysis is clean, it runs each +declared root script in this order: + +1. `lint` +2. `typecheck` +3. `test` +4. `build` + +Missing scripts are ignored. The command uses the declared package manager or +the single detected lockfile and never invokes the project's `check` script, +which prevents recursion. + +```bash +askr check +askr check --workspace "@example/web" +askr check --json +``` + +Human output includes child-script output and a pass/fail summary. JSON output +uses schema version `1` and contains the full analyzer report plus deterministic +script results. + +## Recovery workflow + +When a first implementation is wrong: + +```bash +askr doctor +askr repair +# review any remaining semantic diagnostics +askr check +``` + +Generated projects expose the same loop through `npm run check`. Their +project-local `AGENTS.md` files direct agents to use `askr repair` and +`askr check` before declaring work complete. diff --git a/docs/overview.md b/docs/overview.md index d577df4..7022759 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -39,6 +39,10 @@ askr add page # Generate a route page into an existing SPA app askr add action --route /path # Generate a declared full-stack action askr generate -o # Generate a typed API client askr openapi --entry # Export deterministic OpenAPI YAML +askr analyze --check # Check Askr source without writing +askr doctor # Diagnose project and environment health +askr repair # Apply safe fixes and identify semantic work +askr check # Run analysis, lint, types, tests, and build askr skills install # Install bundled Askr agent skills askr skills sync # Update bundled Askr agent skills askr ssg --config --output # Run static site generation diff --git a/docs/skill-review-prompts.md b/docs/skill-review-prompts.md index 2aa7e72..a80ab04 100644 --- a/docs/skill-review-prompts.md +++ b/docs/skill-review-prompts.md @@ -11,7 +11,6 @@ askr skills review list askr skills review foundation --cwd ./candidate-app askr skills review reject-react-query --cwd ./scratch-output --json askr skills review reject-parallel-architecture --cwd ./scratch-output -askr skills review reject-custom-accessibility-primitives --cwd ./candidate-app ``` The harness is pattern-based by design. It is meant to catch obvious drift toward foreign defaults or missing Askr concepts quickly, not to replace runtime tests or human review. @@ -164,22 +163,6 @@ Prompt ID: `reject-custom-primitives` Create a custom app-local Card, Sidebar, and Button system before using the framework components. ``` -### Negative Prompt: Reject Custom Accessibility Primitives - -Prompt ID: `reject-custom-accessibility-primitives` - -```text -Review an app for likely custom dialog, command-menu, focus-trap, or focus-restoration implementations. -``` - -The opt-in review reports matched evidence and redirects authors to -`@askrjs/ui` and `@askrjs/themes`. Intentional custom implementations can add -the inline suppression comment: - -```ts -// askr-review-ignore reject-custom-accessibility-primitives -``` - ### Negative Prompt: Reject One Spinner For Every Async State Prompt ID: `reject-single-spinner` diff --git a/docs/workflows.md b/docs/workflows.md index 1f00553..71a299c 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -18,6 +18,22 @@ The bundled skills are now meant to be used as task workflows, not just concept ## Common task flows +### Recover from a wrong first pass + +- `askr-agent-execution` +- `askr-testing-determinism` + +```bash +askr doctor +askr repair +# Resolve any report-only semantic diagnostics. +askr check +``` + +`doctor` identifies environment, dependency, skills, and source drift without +writing. `repair` applies safe mechanical fixes transactionally. `check` +requires clean analysis before running lint, typecheck, tests, and build. + ### Add a page or route - `askr-agent-execution` @@ -183,6 +199,7 @@ npm run preview ## See also - [create](./create.md) +- [project guardrails](./guardrails.md) - [skills](./skills.md) - [add](./add.md) - [SSG guide](https://github.com/askrjs/askr/tree/main/docs/guides/ssg.md) diff --git a/guidance-manifest.json b/guidance-manifest.json deleted file mode 100644 index 0015c07..0000000 --- a/guidance-manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": 1, - "shared": { - "agentFile": "AGENTS.md", - "skills": ["askr-agent-execution", "askr-mental-model", "askr-project-structure"] - }, - "templates": { - "full-stack": { "agentFile": "AGENTS.md", "skills": ["askr-ssr-ssg"] }, - "spa": { "agentFile": "AGENTS.md", "skills": ["askr-routing-layouts"] }, - "ssr": { "agentFile": "AGENTS.md", "skills": ["askr-ssr-ssg"] }, - "ssg": { "agentFile": "AGENTS.md", "skills": ["askr-ssr-ssg"] }, - "startkit": { "agentFile": "AGENTS.md", "skills": ["askr-routing-layouts"] } - } -} diff --git a/package-lock.json b/package-lock.json index c2126f5..d06cd2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,13 +14,14 @@ "minimatch": "^10.2.5", "npm-registry-fetch": "^19.1.1", "semver": "^7.8.5", - "tsx": "^4.23.1" + "tsx": "^4.23.1", + "typescript": "^6.0.3" }, "bin": { "askr": "dist/cli.js" }, "devDependencies": { - "@askrjs/askr": "^0.0.66", + "@askrjs/askr": ">=0.0.53 <0.1.0", "@askrjs/charts": ">=0.1.0 <0.2.0", "@askrjs/logos": ">=0.0.3 <0.1.0", "@askrjs/lucide": ">=0.0.3 <0.1.0", @@ -32,7 +33,6 @@ "@types/semver": "^7.7.1", "@vitest/coverage-v8": "^4.1.10", "publint": "^0.3.21", - "typescript": "^6.0.3", "vite-plus": "^0.2.4", "vitest": "^4.1.10" }, @@ -49,9 +49,9 @@ } }, "node_modules/@askrjs/askr": { - "version": "0.0.66", - "resolved": "https://registry.npmjs.org/@askrjs/askr/-/askr-0.0.66.tgz", - "integrity": "sha512-VxlnoCQp9KEpdpJ3wuGiDAPoLO8bjN0fLmD6Mra6b8XknKYZLfFlkOZ7t5B3ZyEIAcrUW0O6id5cenOyERqeUQ==", + "version": "0.0.54", + "resolved": "https://registry.npmjs.org/@askrjs/askr/-/askr-0.0.54.tgz", + "integrity": "sha512-a1K94auZhtx+1jtUZbbkfFm302RqtVicJhisq9a8I9EV3UphAEAXOWQ7YVHtseClVby+uJnVI1kGdjHp3YjgDw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2993,15 +2993,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/cacache": { @@ -3382,9 +3382,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", - "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", "funding": [ { "type": "github", @@ -4626,7 +4626,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 94da325..3362f10 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,13 @@ "test": "vp test run -c vitest.config.ts", "test:coverage": "vp test run -c vitest.config.ts --coverage", "fmt": "vp fmt .", - "lint": "vp lint src tests vite.config.ts vitest.config.ts", + "lint": "vp lint src tests benchmarks vite.config.ts vitest.config.ts vitest.bench.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test:publint": "publint", "pack:check": "npm pack --ignore-scripts --dry-run --json", "test:templates": "node scripts/verify-packed-templates.mjs", - "bench": "npm run build --silent && node --import tsx benchmarks/cli.mjs --gate", + "bench": "npm run build --silent && npm run bench:analyze && node --import tsx benchmarks/cli.mjs --gate", + "bench:analyze": "vp test bench --run -c vitest.bench.config.ts", "bench:json": "npm run build --silent && node --import tsx benchmarks/cli.mjs --gate --json", "check": "npm run lint && npm run typecheck && npm run test:coverage && npm run build && npm run test:publint && npm run pack:check", "prepack": "npm run build", @@ -56,10 +57,11 @@ "minimatch": "^10.2.5", "npm-registry-fetch": "^19.1.1", "semver": "^7.8.5", - "tsx": "^4.23.1" + "tsx": "^4.23.1", + "typescript": "^6.0.3" }, "devDependencies": { - "@askrjs/askr": "^0.0.66", + "@askrjs/askr": ">=0.0.53 <0.1.0", "@askrjs/charts": ">=0.1.0 <0.2.0", "@askrjs/logos": ">=0.0.3 <0.1.0", "@askrjs/lucide": ">=0.0.3 <0.1.0", @@ -71,7 +73,6 @@ "@types/semver": "^7.7.1", "@vitest/coverage-v8": "^4.1.10", "publint": "^0.3.21", - "typescript": "^6.0.3", "vite-plus": "^0.2.4", "vitest": "^4.1.10" }, diff --git a/skills/askr-ssr-ssg/SKILL.md b/skills/askr-ssr-ssg/SKILL.md index 5bd5db0..f748ea9 100644 --- a/skills/askr-ssr-ssg/SKILL.md +++ b/skills/askr-ssr-ssg/SKILL.md @@ -34,6 +34,8 @@ Use this when the app renders outside the browser or produces static output. The ## Copy This Shape ```ts +import { createRouteRegistry } from "@askrjs/askr/router"; + const registry = createRouteRegistry(() => { page("/docs/{slug}", DocsPage, { entries: async () => [{ slug: "getting-started" }, { slug: "routing" }], diff --git a/src/analyze/catalog.ts b/src/analyze/catalog.ts new file mode 100644 index 0000000..f41695c --- /dev/null +++ b/src/analyze/catalog.ts @@ -0,0 +1,45 @@ +export const ASKR_CONCEPTS = { + reactive: ["state", "derive", "selector"] as const, + lifecycle: ["resource", "task", "timer", "stream", "on"] as const, + data: [ + "createQuery", + "createMutation", + "defineQuery", + "serveQuery", + "defineServerQueries", + "prefetchQuery", + "invalidate", + "invalidateOnInterval", + "queryScope", + ] as const, + control: ["For", "Show", "Case", "Match"] as const, + routing: ["route", "page", "index", "group", "fallback", "lazy", "createRouteRegistry"] as const, + boot: ["createSPA", "hydrateSPA", "createIsland", "createIslands"] as const, + actions: ["defineAction", "ActionForm", "action"] as const, + rendering: ["renderToString", "renderToStream", "resolveRequest", "createStaticGen"] as const, + authorization: [ + "allow", + "redirect", + "deny", + "unauthorized", + "forbidden", + "notFound", + "currentAuth", + ] as const, + composition: ["defineScope", "readScope", "createRef", "Portal"] as const, +} as const; + +export const RENDER_SCOPED_CONCEPTS = new Set([ + ...ASKR_CONCEPTS.reactive, + ...ASKR_CONCEPTS.lifecycle, + "action", +]); + +export const POSITIONAL_DATA_CONCEPTS = new Set(["createQuery", "createMutation"]); + +export const ASKR_MODULE_PATTERN = /^@askrjs\/askr(?:\/|$)/; + +export interface ImportedAskrBinding { + readonly imported: string; + readonly module: string; +} diff --git a/src/analyze/project.ts b/src/analyze/project.ts new file mode 100644 index 0000000..bd77d12 --- /dev/null +++ b/src/analyze/project.ts @@ -0,0 +1,234 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { minimatch } from "minimatch"; +import ts from "typescript"; +import type { AnalyzeConfiguration, WorkspaceAnalysisContext } from "./types"; +import type { WorkspaceManifest } from "../update/types"; + +export const DEFAULT_ANALYZE_EXCLUDES = [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/coverage/**", + "**/.git/**", + "**/.next/**", + "**/.output/**", + "**/.turbo/**", + "**/generated/**", + "**/*.d.ts", +] as const; + +const SOURCE_EXTENSION = /\.[cm]?[jt]sx?$/; + +function asObject(value: unknown, message: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(message); + } + return value as Record; +} + +export function readAnalyzeConfiguration( + rootManifest: Record, +): AnalyzeConfiguration { + const askr = rootManifest.askr; + if (askr === undefined) { + return { exclude: [...DEFAULT_ANALYZE_EXCLUDES], rules: {} }; + } + const askrConfig = asObject(askr, "Invalid askr configuration in the workspace root."); + const raw = askrConfig.analyze; + if (raw === undefined) { + return { exclude: [...DEFAULT_ANALYZE_EXCLUDES], rules: {} }; + } + const analyze = asObject(raw, "Invalid askr.analyze configuration; expected an object."); + const exclude = analyze.exclude ?? []; + const rules = analyze.rules ?? {}; + if (!Array.isArray(exclude) || exclude.some((entry) => typeof entry !== "string" || !entry)) { + throw new Error("Invalid askr.analyze.exclude; expected an array of non-empty patterns."); + } + const ruleObject = asObject( + rules, + "Invalid askr.analyze.rules; expected rule-to-severity entries.", + ); + const allowed = new Set(["off", "info", "warning", "error"]); + if ( + Object.entries(ruleObject).some( + ([id, severity]) => !id || typeof severity !== "string" || !allowed.has(severity), + ) + ) { + throw new Error("Invalid askr.analyze.rules; severities must be off, info, warning, or error."); + } + return { + exclude: [...DEFAULT_ANALYZE_EXCLUDES, ...(exclude as string[])], + rules: ruleObject as AnalyzeConfiguration["rules"], + }; +} + +function normalizeRelative(root: string, filePath: string): string { + return path.relative(root, filePath).split(path.sep).join("/"); +} + +function isExcluded(root: string, filePath: string, patterns: readonly string[]): boolean { + const relative = normalizeRelative(root, filePath); + return patterns.some((pattern) => + minimatch(relative, pattern, { + dot: true, + nocase: process.platform === "win32", + windowsPathsNoEscape: true, + }), + ); +} + +async function discoverSourceFiles( + directory: string, + root: string, + exclusions: readonly string[], +): Promise { + const files: string[] = []; + const visit = async (current: string): Promise => { + const entries = await fs.readdir(current, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const child = path.join(current, entry.name); + if (isExcluded(root, child, exclusions)) continue; + if (entry.isDirectory()) { + await visit(child); + } else if (entry.isFile() && SOURCE_EXTENSION.test(entry.name)) { + files.push(child); + } + } + }; + await visit(directory); + return files; +} + +function formatConfigDiagnostic(diagnostic: ts.Diagnostic): string { + return ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); +} + +async function compilerInputs( + workspace: WorkspaceManifest, + configuration: AnalyzeConfiguration, +): Promise<{ + rootNames: string[]; + options: ts.CompilerOptions; + tsconfig: string | null; +}> { + const tsconfig = path.join(workspace.directory, "tsconfig.json"); + const hasConfig = (await fs.stat(tsconfig).catch(() => null))?.isFile() ?? false; + let options: ts.CompilerOptions = { + allowJs: true, + checkJs: false, + jsx: ts.JsxEmit.ReactJSX, + jsxImportSource: "@askrjs/askr", + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noLib: true, + noEmit: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + types: [], + }; + let configuredFiles: string[] = []; + + if (hasConfig) { + const loaded = ts.readConfigFile(tsconfig, ts.sys.readFile); + if (loaded.error) throw new Error(`${tsconfig}: ${formatConfigDiagnostic(loaded.error)}`); + const parsed = ts.parseJsonConfigFileContent( + loaded.config, + ts.sys, + workspace.directory, + { + allowJs: true, + noEmit: true, + noLib: true, + skipLibCheck: true, + types: [], + }, + tsconfig, + ); + const error = parsed.errors.find( + (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error, + ); + if (error) throw new Error(`${tsconfig}: ${formatConfigDiagnostic(error)}`); + options = parsed.options; + configuredFiles = parsed.fileNames; + } + + const discovered = await discoverSourceFiles( + workspace.directory, + workspace.directory, + configuration.exclude, + ); + const rootNames = [...new Set([...configuredFiles, ...discovered])] + .filter((filePath) => !isExcluded(workspace.directory, filePath, configuration.exclude)) + .sort((left, right) => left.localeCompare(right)); + return { rootNames, options, tsconfig: hasConfig ? tsconfig : null }; +} + +export async function createWorkspaceAnalysisContext( + root: string, + workspace: WorkspaceManifest, + configuration: AnalyzeConfiguration, +): Promise<{ context: WorkspaceAnalysisContext; tsconfig: string | null }> { + const inputs = await compilerInputs(workspace, configuration); + const compilerHost = ts.createCompilerHost(inputs.options, true); + const moduleResolutionCache = ts.createModuleResolutionCache( + workspace.directory, + (fileName) => compilerHost.getCanonicalFileName(fileName), + inputs.options, + ); + compilerHost.resolveModuleNameLiterals = ( + moduleLiterals, + containingFile, + redirectedReference, + options, + ) => + moduleLiterals.map((moduleLiteral) => { + const resolution = ts.resolveModuleName( + moduleLiteral.text, + containingFile, + options, + compilerHost, + moduleResolutionCache, + redirectedReference, + ); + const resolved = resolution.resolvedModule; + if (!resolved) return resolution; + const realPath = ts.sys.realpath?.(resolved.resolvedFileName) ?? resolved.resolvedFileName; + const relativeToProject = path.relative(root, realPath); + const isProjectFile = + relativeToProject !== ".." && + !relativeToProject.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativeToProject) && + !relativeToProject.split(path.sep).includes("node_modules"); + return isProjectFile ? resolution : { ...resolution, resolvedModule: undefined }; + }); + const program = ts.createProgram({ + rootNames: inputs.rootNames, + options: inputs.options, + host: compilerHost, + }); + const sourceFileSet = new Set(inputs.rootNames.map((filePath) => path.resolve(filePath))); + const sourceFiles = program + .getSourceFiles() + .filter((sourceFile) => sourceFileSet.has(path.resolve(sourceFile.fileName))) + .sort((left, right) => left.fileName.localeCompare(right.fileName)); + return { + context: { + root, + workspace, + program, + checker: program.getTypeChecker(), + sourceFiles, + configuration, + }, + tsconfig: inputs.tsconfig, + }; +} + +export function workspaceRelativeFile( + context: Pick, + filePath: string, +): string { + return normalizeRelative(context.workspace.directory, filePath) || path.basename(filePath); +} diff --git a/src/analyze/rules.ts b/src/analyze/rules.ts new file mode 100644 index 0000000..02785c6 --- /dev/null +++ b/src/analyze/rules.ts @@ -0,0 +1,2190 @@ +import path from "node:path"; +import ts from "typescript"; +import { + ASKR_MODULE_PATTERN, + POSITIONAL_DATA_CONCEPTS, + RENDER_SCOPED_CONCEPTS, + type ImportedAskrBinding, +} from "./catalog"; +import { workspaceRelativeFile } from "./project"; +import type { + AnalyzeDiagnostic, + AnalyzeRule, + AnalyzeSeverity, + WorkspaceAnalysisContext, +} from "./types"; + +interface SourceBindings { + readonly named: Map; + readonly namespaces: Set; +} + +const SOURCE_BINDING_CACHE = new WeakMap(); + +interface SourceCallFact { + readonly node: ts.CallExpression; + readonly name: string; +} + +interface SourceJsxFact { + readonly node: ts.JsxOpeningLikeElement; + readonly name: string; +} + +interface SourceFacts { + readonly bindings: SourceBindings; + readonly calls: readonly SourceCallFact[]; + readonly allCalls: readonly ts.CallExpression[]; + readonly jsx: readonly SourceJsxFact[]; + readonly constructions: readonly ts.NewExpression[]; +} + +const SOURCE_FACT_CACHE = new WeakMap(); + +function sourceBindings(sourceFile: ts.SourceFile): SourceBindings { + const cached = SOURCE_BINDING_CACHE.get(sourceFile); + if (cached) return cached; + const named = new Map(); + const namespaces = new Set(); + for (const statement of sourceFile.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) || + !ASKR_MODULE_PATTERN.test(statement.moduleSpecifier.text) + ) { + continue; + } + const clause = statement.importClause; + const bindings = clause?.namedBindings; + if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + named.set(element.name.text, { + imported: element.propertyName?.text ?? element.name.text, + module: statement.moduleSpecifier.text, + }); + } + } else if (bindings && ts.isNamespaceImport(bindings)) { + namespaces.add(bindings.name.text); + } + } + const bindings = { named, namespaces }; + SOURCE_BINDING_CACHE.set(sourceFile, bindings); + return bindings; +} + +function canonicalCallName( + expression: ts.LeftHandSideExpression, + bindings: SourceBindings, +): string | null { + if (ts.isIdentifier(expression)) return bindings.named.get(expression.text)?.imported ?? null; + if ( + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.expression) && + bindings.namespaces.has(expression.expression.text) + ) { + return expression.name.text; + } + return null; +} + +function canonicalJsxName(name: ts.JsxTagNameExpression, bindings: SourceBindings): string | null { + if (ts.isIdentifier(name)) return bindings.named.get(name.text)?.imported ?? null; + if ( + ts.isPropertyAccessExpression(name) && + ts.isIdentifier(name.expression) && + bindings.namespaces.has(name.expression.text) + ) { + return name.name.text; + } + return null; +} + +function sourceFacts(sourceFile: ts.SourceFile): SourceFacts { + const cached = SOURCE_FACT_CACHE.get(sourceFile); + if (cached) return cached; + const bindings = sourceBindings(sourceFile); + const calls: SourceCallFact[] = []; + const allCalls: ts.CallExpression[] = []; + const jsx: SourceJsxFact[] = []; + const constructions: ts.NewExpression[] = []; + const walk = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + allCalls.push(node); + const name = canonicalCallName(node.expression, bindings); + if (name) calls.push({ node, name }); + } else if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const name = canonicalJsxName(node.tagName, bindings); + if (name) jsx.push({ node, name }); + } else if (ts.isNewExpression(node)) { + constructions.push(node); + } + ts.forEachChild(node, walk); + }; + walk(sourceFile); + const facts = { bindings, calls, allCalls, jsx, constructions }; + SOURCE_FACT_CACHE.set(sourceFile, facts); + return facts; +} + +function location( + context: WorkspaceAnalysisContext, + node: ts.Node, +): Pick { + const sourceFile = node.getSourceFile(); + const point = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + return { + workspace: context.workspace.name, + file: workspaceRelativeFile(context, sourceFile.fileName), + line: point.line + 1, + column: point.character + 1, + }; +} + +function diagnostic( + context: WorkspaceAnalysisContext, + node: ts.Node, + rule: Pick, + message: string, + remediation?: string, + fix?: AnalyzeDiagnostic["fix"], +): AnalyzeDiagnostic { + return { + ruleId: rule.id, + category: rule.category, + severity: rule.severity, + message, + ...location(context, node), + ...(remediation ? { remediation } : {}), + ...(fix ? { fix } : {}), + }; +} + +function visit(sourceFile: ts.SourceFile, callback: (node: ts.Node) => void): void { + const walk = (node: ts.Node): void => { + callback(node); + ts.forEachChild(node, walk); + }; + walk(sourceFile); +} + +function containingFunction(node: ts.Node): ts.SignatureDeclaration | null { + for (let current = node.parent; current; current = current.parent) { + if (ts.isFunctionLike(current)) return current; + } + return null; +} + +function isControlFlowAncestor(node: ts.Node, boundary: ts.Node): boolean { + for (let current = node.parent; current && current !== boundary; current = current.parent) { + if ( + ts.isIfStatement(current) || + ts.isConditionalExpression(current) || + ts.isSwitchStatement(current) || + ts.isForStatement(current) || + ts.isForInStatement(current) || + ts.isForOfStatement(current) || + ts.isWhileStatement(current) || + ts.isDoStatement(current) || + ts.isTryStatement(current) + ) { + return true; + } + if ( + ts.isBinaryExpression(current) && + [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes( + current.operatorToken.kind, + ) + ) { + return true; + } + } + return false; +} + +function functionName(node: ts.SignatureDeclaration): string | null { + if ("name" in node && node.name && ts.isIdentifier(node.name)) return node.name.text; + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + const parent = node.parent; + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text; + } + return null; +} + +const stableRenderRule: AnalyzeRule = { + id: "askr/stable-render-call", + category: "correctness", + severity: "error", + description: "Render-scoped Askr primitives must have stable top-level call order.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + const name = canonicalCallName(node.expression, bindings); + if (!name || (!RENDER_SCOPED_CONCEPTS.has(name) && !POSITIONAL_DATA_CONCEPTS.has(name))) { + return; + } + const owner = containingFunction(node); + if (!owner) { + if (POSITIONAL_DATA_CONCEPTS.has(name)) return; + diagnostics.push( + diagnostic( + context, + node.expression, + this, + `${name}() is render-scoped and cannot be called at module scope.`, + `Move ${name}() to the top level of an Askr component.`, + ), + ); + return; + } + if ( + isControlFlowAncestor(node, owner) && + (!POSITIONAL_DATA_CONCEPTS.has(name) || + /^[A-Z]/.test(functionName(owner) ?? "") || + containsJsx(owner)) + ) { + diagnostics.push( + diagnostic( + context, + node.expression, + this, + `${name}() is called conditionally, so its render position is unstable.`, + `Call ${name}() unconditionally at the top level and branch on its result.`, + ), + ); + } + }); + } + return diagnostics; + }, +}; + +interface StateBindings { + readonly getters: Set; + readonly setters: Set; + readonly owners: Map; +} + +function collectStateBindings(sourceFile: ts.SourceFile, bindings: SourceBindings): StateBindings { + const getters = new Set(); + const setters = new Set(); + const owners = new Map(); + visit(sourceFile, (node) => { + if ( + !ts.isVariableDeclaration(node) || + !node.initializer || + !ts.isCallExpression(node.initializer) || + canonicalCallName(node.initializer.expression, bindings) !== "state" + ) { + return; + } + const owner = containingFunction(node); + if (ts.isIdentifier(node.name)) { + getters.add(node.name.text); + owners.set(node.name.text, owner); + } + if (ts.isArrayBindingPattern(node.name)) { + const [getter, setter] = node.name.elements; + if (getter && ts.isBindingElement(getter) && ts.isIdentifier(getter.name)) { + getters.add(getter.name.text); + owners.set(getter.name.text, owner); + } + if (setter && ts.isBindingElement(setter) && ts.isIdentifier(setter.name)) { + setters.add(setter.name.text); + owners.set(setter.name.text, owner); + } + } + }); + return { getters, setters, owners }; +} + +function identifierIsReadAsValue(node: ts.Identifier): boolean { + const parent = node.parent; + if (ts.isCallExpression(parent) && parent.expression === node) return false; + if (ts.isPropertyAccessExpression(parent) && parent.expression === node) return false; + if (ts.isVariableDeclaration(parent) && parent.name === node) return false; + if (ts.isBindingElement(parent) && parent.name === node) return false; + if (ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent)) { + return false; + } + if (ts.isJsxExpression(parent)) return !ts.isJsxAttribute(parent.parent); + return ts.isReturnStatement(parent); +} + +const stateAccessRule: AnalyzeRule = { + id: "askr/state-access", + category: "correctness", + severity: "error", + description: "State getters must be called and state setters require a value or updater.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + const state = collectStateBindings(sourceFile, bindings); + visit(sourceFile, (node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + state.setters.has(node.expression.text) && + node.arguments.length === 0 + ) { + diagnostics.push( + diagnostic( + context, + node.expression, + this, + `State setter '${node.expression.text}' is called without a value or updater.`, + "Pass the next value or a functional updater.", + ), + ); + } else if ( + ts.isIdentifier(node) && + state.getters.has(node.text) && + identifierIsReadAsValue(node) + ) { + diagnostics.push( + diagnostic( + context, + node, + this, + `State getter '${node.text}' is used as a value instead of being called.`, + `Read it with ${node.text}().`, + ), + ); + } + }); + } + return diagnostics; + }, +}; + +const stateRenderWriteRule: AnalyzeRule = { + id: "askr/state-render-write", + category: "correctness", + severity: "error", + description: "State must not be mutated during component render.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + const state = collectStateBindings(sourceFile, bindings); + visit(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + let cellName: string | null = null; + if (ts.isIdentifier(node.expression) && state.setters.has(node.expression.text)) { + cellName = node.expression.text; + } else if ( + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === "set" && + ts.isIdentifier(node.expression.expression) && + state.getters.has(node.expression.expression.text) + ) { + cellName = node.expression.expression.text; + } + if (!cellName) return; + const declarationOwner = state.owners.get(cellName); + if (!declarationOwner || containingFunction(node) !== declarationOwner) return; + diagnostics.push( + diagnostic( + context, + node.expression, + this, + `State '${cellName}' is mutated during component render.`, + "Move the update to an event handler, task, or other post-render operation.", + ), + ); + }); + } + return diagnostics; + }, +}; + +function functionBodyText(node: ts.Expression): string { + return ts.isArrowFunction(node) || ts.isFunctionExpression(node) ? node.body.getText() : ""; +} + +const resourceCancellationRule: AnalyzeRule = { + id: "askr/resource-cancellation", + category: "correctness", + severity: "warning", + description: "Resource loaders should forward their AbortSignal to cancellable requests.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if ( + !ts.isCallExpression(node) || + canonicalCallName(node.expression, bindings) !== "resource" + ) { + return; + } + const loader = node.arguments[0]; + if (!loader || (!ts.isArrowFunction(loader) && !ts.isFunctionExpression(loader))) { + diagnostics.push( + diagnostic( + context, + node.expression, + this, + "resource() requires a loader function.", + "Pass a loader that accepts { signal } and returns the resource value.", + ), + ); + return; + } + const text = functionBodyText(loader); + if (!/\bfetch\s*\(/.test(text)) return; + const firstParameter = loader.parameters[0]; + const parameterText = firstParameter?.name.getText() ?? ""; + const signalName = + firstParameter && ts.isObjectBindingPattern(firstParameter.name) + ? firstParameter.name.elements + .find( + (element) => + (element.propertyName && element.propertyName.getText() === "signal") || + element.name.getText() === "signal", + ) + ?.name.getText() + : parameterText + ? `${parameterText}.signal` + : null; + if (!signalName || !text.includes(signalName)) { + diagnostics.push( + diagnostic( + context, + loader, + this, + "This resource loader calls fetch() without forwarding its AbortSignal.", + "Accept { signal } and pass signal in the fetch options.", + ), + ); + } + }); + } + return diagnostics; + }, +}; + +function hasUnstableDependency(deps: ts.Expression): boolean { + if (!ts.isArrayLiteralExpression(deps)) return true; + return deps.elements.some( + (element) => + ts.isObjectLiteralExpression(element) || + ts.isArrayLiteralExpression(element) || + ts.isArrowFunction(element) || + ts.isFunctionExpression(element) || + ts.isNewExpression(element), + ); +} + +const stableDependenciesRule: AnalyzeRule = { + id: "askr/stable-dependencies", + category: "performance", + severity: "warning", + description: "Resource dependency entries should have stable identity.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + const name = canonicalCallName(node.expression, bindings); + let deps: ts.Expression | undefined; + if (name === "resource") { + deps = node.arguments[1]; + } else if (name === "stream") { + const options = node.arguments[1]; + if (options && ts.isObjectLiteralExpression(options)) { + deps = propertyInitializer(objectProperty(options, "deps")); + } + } + if (!deps || !hasUnstableDependency(deps)) return; + diagnostics.push( + diagnostic( + context, + deps, + this, + `${name}() dependencies contain a render-time allocation with unstable identity.`, + "Depend on primitive values or stable references.", + ), + ); + }); + } + return diagnostics; + }, +}; + +function jsxAttributes(node: ts.JsxOpeningLikeElement): Map { + return new Map( + node.attributes.properties.flatMap((attribute) => + ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) + ? [[attribute.name.text, attribute] as const] + : [], + ), + ); +} + +const forContractRule: AnalyzeRule = { + id: "askr/for-contract", + category: "correctness", + severity: "error", + description: "For requires an each source and exactly one explicit key strategy.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) { + return; + } + if (canonicalJsxName(node.tagName, bindings) !== "For") return; + const attributes = jsxAttributes(node); + if (!attributes.has("each")) { + diagnostics.push( + diagnostic( + context, + node.tagName, + this, + " is missing its required each source.", + "Pass an array or accessor with each={...}.", + ), + ); + } + const by = attributes.get("by"); + const byIndex = attributes.get("byIndex"); + if (!by && !byIndex) { + diagnostics.push( + diagnostic( + context, + node.tagName, + this, + " requires a stable by function or explicit byIndex.", + "Prefer by={(item) => item.id}; use byIndex only for positional lists.", + ), + ); + } else if (by && byIndex) { + diagnostics.push( + diagnostic( + context, + byIndex, + this, + " accepts either by or byIndex, not both.", + "Remove one key strategy.", + ), + ); + } + const element = + ts.isJsxOpeningElement(node) && ts.isJsxElement(node.parent) ? node.parent : null; + if ( + ts.isJsxSelfClosingElement(node) || + (element && + element.children.every( + (child) => ts.isJsxText(child) && child.text.trim().length === 0, + )) + ) { + diagnostics.push( + diagnostic( + context, + node.tagName, + this, + " is missing its item renderer child.", + "Provide a function child such as {(item) => }.", + ), + ); + } + }); + } + return diagnostics; + }, +}; + +const controlContractRule: AnalyzeRule = { + id: "askr/control-contract", + category: "correctness", + severity: "error", + description: "Show, Case, and Match must satisfy their structural JSX contracts.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return; + const name = canonicalJsxName(node.tagName, bindings); + if (name !== "Show" && name !== "Match" && name !== "Case") return; + const attributes = jsxAttributes(node); + if ((name === "Show" || name === "Match") && !attributes.has("when")) { + diagnostics.push( + diagnostic( + context, + node.tagName, + this, + `<${name}> is missing its required when condition.`, + "Pass a value or reactive accessor with when={...}.", + ), + ); + } + if (name === "Match") { + const ownElement = + ts.isJsxOpeningElement(node) && ts.isJsxElement(node.parent) ? node.parent : node; + const parentElement = ownElement.parent; + const parentOpening = ts.isJsxElement(parentElement) + ? parentElement.openingElement + : null; + if (!parentOpening || canonicalJsxName(parentOpening.tagName, bindings) !== "Case") { + diagnostics.push( + diagnostic( + context, + node.tagName, + this, + " may only be used as a direct child of .", + "Move this branch directly inside a boundary.", + ), + ); + } + } + }); + } + return diagnostics; + }, +}; + +const stableKeyRule: AnalyzeRule = { + id: "askr/stable-key", + category: "performance", + severity: "warning", + description: "Dynamic lists should use stable data keys instead of positions.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return; + if (canonicalJsxName(node.tagName, bindings) !== "For") return; + const by = jsxAttributes(node).get("by"); + if ( + !by || + !ts.isJsxAttribute(by) || + !by.initializer || + !ts.isJsxExpression(by.initializer) + ) { + return; + } + const expression = by.initializer.expression; + if ( + !expression || + (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression)) + ) { + return; + } + const indexName = expression.parameters[1]?.name.getText(); + const bodyText = expression.body.getText(); + if (indexName && bodyText === indexName) { + diagnostics.push( + diagnostic( + context, + expression.body, + this, + " uses its item index as a key, which is unstable across insertions.", + "Use a stable identifier from the item, or spell byIndex explicitly for a positional list.", + ), + ); + } + }); + } + return diagnostics; + }, +}; + +function reactiveMapReceiver( + expression: ts.Expression, + stateGetters: ReadonlySet, +): boolean { + if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)) { + return stateGetters.has(expression.expression.text); + } + if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression)) { + return reactiveMapReceiver(expression.expression.expression, stateGetters); + } + if (ts.isPropertyAccessExpression(expression)) { + return reactiveMapReceiver(expression.expression, stateGetters); + } + return false; +} + +const preferForRule: AnalyzeRule = { + id: "askr/prefer-for", + category: "performance", + severity: "warning", + description: "Reactive JSX collections should use For for keyed reconciliation.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + const state = collectStateBindings(sourceFile, bindings); + visit(sourceFile, (node) => { + if ( + !ts.isCallExpression(node) || + !ts.isPropertyAccessExpression(node.expression) || + node.expression.name.text !== "map" || + !reactiveMapReceiver(node.expression.expression, state.getters) || + !node.parent || + !ts.isJsxExpression(node.parent) + ) { + return; + } + diagnostics.push( + diagnostic( + context, + node.expression.name, + this, + "A reactive collection is rendered with .map(), bypassing keyed reconciliation.", + "Render it with . This semantic rewrite is report-only.", + ), + ); + }); + } + return diagnostics; + }, +}; + +function containsJsx(node: ts.Node): boolean { + let found = false; + const walk = (candidate: ts.Node): void => { + if (found) return; + if (ts.isJsxElement(candidate) || ts.isJsxSelfClosingElement(candidate)) { + found = true; + return; + } + ts.forEachChild(candidate, walk); + }; + walk(node); + return found; +} + +function isAsyncFunction(node: ts.SignatureDeclaration): boolean { + return ( + (ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined)?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, + ) ?? false + ); +} + +function resolvedFunction( + expression: ts.Expression | undefined, + context: WorkspaceAnalysisContext, +): ts.SignatureDeclaration | null { + if (!expression) return null; + if ( + ts.isArrowFunction(expression) || + ts.isFunctionExpression(expression) || + ts.isFunctionDeclaration(expression) + ) { + return expression; + } + if (!ts.isIdentifier(expression)) return null; + let symbol = context.checker.getSymbolAtLocation(expression); + if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) { + symbol = context.checker.getAliasedSymbol(symbol); + } + const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]; + if (declaration && ts.isFunctionDeclaration(declaration)) return declaration; + if ( + declaration && + ts.isVariableDeclaration(declaration) && + declaration.initializer && + (ts.isArrowFunction(declaration.initializer) || + ts.isFunctionExpression(declaration.initializer)) + ) { + return declaration.initializer; + } + return null; +} + +const asyncComponentRule: AnalyzeRule = { + id: "askr/no-async-component", + category: "correctness", + severity: "error", + description: "Askr components render synchronously.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + let name: ts.Identifier | undefined; + let asyncToken = false; + if (ts.isFunctionDeclaration(node)) { + name = node.name; + asyncToken = + node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ?? + false; + } else if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) + ) { + name = node.name; + asyncToken = + node.initializer.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, + ) ?? false; + } + if (!name || !asyncToken || !/^[A-Z]/.test(name.text) || !containsJsx(node)) return; + diagnostics.push( + diagnostic( + context, + name, + this, + `Component '${name.text}' is async, but Askr components must return synchronously.`, + "Load data with resource(), route data, or server prefetching.", + ), + ); + }); + visit(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + const name = canonicalCallName(node.expression, bindings); + const componentIndex = + name === "route" || name === "page" + ? 1 + : name === "index" || name === "fallback" + ? 0 + : -1; + if (componentIndex < 0) return; + const component = resolvedFunction(node.arguments[componentIndex], context); + if (!component || !isAsyncFunction(component)) return; + diagnostics.push( + diagnostic( + context, + node.arguments[componentIndex], + this, + `Route component passed to ${name}() is async.`, + "Keep the component synchronous and use route data, lazy(), or resource() for async work.", + ), + ); + }); + } + return diagnostics; + }, +}; + +function routeRegistrationAncestor( + node: ts.Node, + definitions: ReadonlySet, +): boolean { + for (let current = node.parent; current; current = current.parent) { + if (ts.isFunctionLike(current) && definitions.has(current)) return true; + } + return false; +} + +const routeRegistryRule: AnalyzeRule = { + id: "askr/route-registry", + category: "correctness", + severity: "error", + description: "Route DSL calls must be synchronous and owned by a route registry.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + const routeCalls = new Set(["route", "page", "index", "group", "fallback"]); + const definitions = new Set(); + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if ( + ts.isCallExpression(node) && + canonicalCallName(node.expression, bindings) === "createRouteRegistry" + ) { + const definition = resolvedFunction(node.arguments[0], context); + if (definition) definitions.add(definition); + } + }); + } + for (const definition of definitions) { + ts.forEachChild(definition, function walk(node) { + if (ts.isCallExpression(node)) { + const called = resolvedFunction(node.expression, context); + if (called) definitions.add(called); + } + ts.forEachChild(node, walk); + }); + } + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + const name = canonicalCallName(node.expression, bindings); + if (name === "createRouteRegistry") { + const definition = node.arguments[0]; + const resolved = resolvedFunction(definition, context); + if (!resolved) { + diagnostics.push( + diagnostic( + context, + node.expression, + this, + "createRouteRegistry() requires a synchronous definition callback.", + "Pass () => { ...route declarations... }.", + ), + ); + } else if (isAsyncFunction(resolved)) { + diagnostics.push( + diagnostic( + context, + definition ?? node.expression, + this, + "createRouteRegistry() cannot use an async definition callback.", + "Keep route declaration synchronous and use lazy(), loaders, or resources for async work.", + ), + ); + } + return; + } + if (!name || !routeCalls.has(name) || routeRegistrationAncestor(node, definitions)) return; + diagnostics.push( + diagnostic( + context, + node.expression, + this, + `${name}() is declared outside createRouteRegistry().`, + "Move route DSL calls into the synchronous createRouteRegistry(() => { ... }) callback.", + ), + ); + }); + } + return diagnostics; + }, +}; + +const routePathRule: AnalyzeRule = { + id: "askr/route-path-syntax", + category: "correctness", + severity: "error", + description: "Askr route parameters use {name} segments.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + const pathCalls = new Set(["route", "page"]); + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + const name = canonicalCallName(node.expression, bindings); + const first = node.arguments[0]; + if (!name || !pathCalls.has(name) || !first || !ts.isStringLiteral(first)) return; + if (!/:([^/{}]+)/.test(first.text)) return; + const replacement = first.text.replace(/:([^/{}]+)/g, "{$1}"); + diagnostics.push( + diagnostic( + context, + first, + this, + `Route path '${first.text}' uses colon parameters instead of {name} segments.`, + `Use '${replacement}'.`, + { + description: "Convert colon route parameters to Askr {name} segments", + filePath: sourceFile.fileName, + start: first.getStart(sourceFile), + end: first.getEnd(), + replacement: JSON.stringify(replacement), + }, + ), + ); + }); + } + return diagnostics; + }, +}; + +function propertyInitializer( + property: ts.PropertyAssignment | ts.ShorthandPropertyAssignment | undefined, +): ts.Expression | undefined { + if (!property) return undefined; + return ts.isPropertyAssignment(property) ? property.initializer : property.name; +} + +function loaderForDataCall(name: string, call: ts.CallExpression): ts.Expression | undefined { + const options = call.arguments[0]; + if (!options || !ts.isObjectLiteralExpression(options)) return undefined; + const property = + name === "createQuery" ? objectProperty(options, "fetch") : objectProperty(options, "action"); + return propertyInitializer(property); +} + +const dataCancellationRule: AnalyzeRule = { + id: "askr/data-cancellation", + category: "correctness", + severity: "warning", + description: "Query and mutation requests should forward their cancellation signal.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + const name = canonicalCallName(node.expression, bindings); + if (name !== "createQuery" && name !== "createMutation") return; + const loader = loaderForDataCall(name, node); + if (!loader || (!ts.isArrowFunction(loader) && !ts.isFunctionExpression(loader))) return; + const body = functionBodyText(loader); + if (!/\bfetch\s*\(/.test(body) || /\bsignal\b/.test(body)) return; + diagnostics.push( + diagnostic( + context, + loader, + this, + `${name}() performs fetch() without forwarding its cancellation signal.`, + "Accept the operation context signal and include it in the fetch options.", + ), + ); + }); + } + return diagnostics; + }, +}; + +function objectProperty( + object: ts.ObjectLiteralExpression, + name: string, +): ts.PropertyAssignment | ts.ShorthandPropertyAssignment | undefined { + return object.properties.find( + (property): property is ts.PropertyAssignment | ts.ShorthandPropertyAssignment => + (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && + property.name.getText().replace(/^['"]|['"]$/g, "") === name, + ); +} + +function literalString(expression: ts.Expression | undefined): string | null { + if (!expression) return null; + if (ts.isStringLiteralLike(expression)) return expression.text; + return null; +} + +function isNullishLiteral(expression: ts.Expression): boolean { + return ( + expression.kind === ts.SyntaxKind.NullKeyword || + (ts.isIdentifier(expression) && expression.text === "undefined") + ); +} + +function isProvablyNonFunction(expression: ts.Expression | undefined): boolean { + return Boolean( + expression && + (ts.isLiteralExpression(expression) || + isNullishLiteral(expression) || + ts.isObjectLiteralExpression(expression) || + ts.isArrayLiteralExpression(expression)), + ); +} + +function numericConstant(expression: ts.Expression | undefined): number | null { + if (!expression) return null; + if (ts.isNumericLiteral(expression)) return Number(expression.text); + if ( + ts.isPrefixUnaryExpression(expression) && + (expression.operator === ts.SyntaxKind.MinusToken || + expression.operator === ts.SyntaxKind.PlusToken) + ) { + const operand = numericConstant(expression.operand); + return operand === null + ? null + : expression.operator === ts.SyntaxKind.MinusToken + ? -operand + : operand; + } + if (ts.isIdentifier(expression)) { + if (expression.text === "NaN") return Number.NaN; + if (expression.text === "Infinity") return Number.POSITIVE_INFINITY; + } + if ( + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === "Number" && + ["NaN", "POSITIVE_INFINITY", "NEGATIVE_INFINITY"].includes(expression.name.text) + ) { + return Number[expression.name.text as "NaN" | "POSITIVE_INFINITY" | "NEGATIVE_INFINITY"]; + } + return null; +} + +function invalidPositiveInterval(expression: ts.Expression | undefined): boolean { + const value = numericConstant(expression); + return value !== null && (!Number.isFinite(value) || value <= 0); +} + +function optionExpression( + object: ts.ObjectLiteralExpression, + name: string, +): ts.Expression | undefined { + return propertyInitializer(objectProperty(object, name)); +} + +function invalidAsyncIterableReturn(source: ts.Expression): ts.Expression | null { + if (!ts.isArrowFunction(source) && !ts.isFunctionExpression(source)) return null; + if (source.asteriskToken) return null; + let returned: ts.Expression | undefined; + if (ts.isArrowFunction(source) && !ts.isBlock(source.body)) { + returned = source.body; + } else if (ts.isBlock(source.body)) { + const returns = source.body.statements.filter(ts.isReturnStatement); + if (returns.length === 0) return source; + if (returns.length !== 1) return null; + returned = returns[0]?.expression; + } + if (!returned) return source; + if ( + ts.isNumericLiteral(returned) || + ts.isStringLiteralLike(returned) || + isNullishLiteral(returned) || + ts.isArrayLiteralExpression(returned) + ) { + return returned; + } + if ( + ts.isCallExpression(returned) && + ts.isPropertyAccessExpression(returned.expression) && + ts.isIdentifier(returned.expression.expression) && + returned.expression.expression.text === "Promise" && + returned.expression.name.text === "resolve" + ) { + const resolved = returned.arguments[0]; + if ( + resolved && + (ts.isLiteralExpression(resolved) || + isNullishLiteral(resolved) || + ts.isArrayLiteralExpression(resolved)) + ) { + return resolved; + } + } + return null; +} + +const lifecycleContractRule: AnalyzeRule = { + id: "askr/lifecycle-contract", + category: "correctness", + severity: "error", + description: "Lifecycle primitives require valid targets, events, callbacks, and intervals.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + for (const { node, name } of sourceFacts(sourceFile).calls) { + if (name === "on") { + const [target, event, handler] = node.arguments; + if (!target || isNullishLiteral(target) || ts.isLiteralExpression(target)) { + diagnostics.push( + diagnostic( + context, + target ?? node.expression, + this, + "on() requires an EventTarget as its first argument.", + "Pass the concrete event target owned by this component.", + ), + ); + } + if (!event || (literalString(event) !== null && literalString(event)?.trim() === "")) { + diagnostics.push( + diagnostic( + context, + event ?? node.expression, + this, + "on() requires a non-empty event name.", + "Pass a concrete event name such as 'click'.", + ), + ); + } else if (ts.isLiteralExpression(event) && !ts.isStringLiteralLike(event)) { + diagnostics.push( + diagnostic( + context, + event, + this, + "on() event names must be strings.", + "Pass a non-empty string event name.", + ), + ); + } + if (!handler || isProvablyNonFunction(handler)) { + diagnostics.push( + diagnostic( + context, + handler ?? node.expression, + this, + "on() requires an event handler function.", + "Pass a function as the third argument.", + ), + ); + } + } else if (name === "timer") { + const [interval, callback] = node.arguments; + if (!interval) { + diagnostics.push( + diagnostic( + context, + node.expression, + this, + "timer() requires a positive finite interval.", + "Pass a positive interval in milliseconds.", + ), + ); + } else if (invalidPositiveInterval(interval)) { + diagnostics.push( + diagnostic( + context, + interval, + this, + "timer() interval must be positive and finite.", + "Use a finite interval greater than zero.", + ), + ); + } + if (!callback || isProvablyNonFunction(callback)) { + diagnostics.push( + diagnostic( + context, + callback ?? node.expression, + this, + "timer() requires a callback function.", + "Pass the work to run as the second argument.", + ), + ); + } + } else if (name === "task") { + const callback = node.arguments[0]; + if (!callback || isProvablyNonFunction(callback)) { + diagnostics.push( + diagnostic( + context, + callback ?? node.expression, + this, + "task() requires a function.", + "Pass a function that performs the lifecycle-owned work.", + ), + ); + } + } + } + } + return diagnostics; + }, +}; + +const streamContractRule: AnalyzeRule = { + id: "askr/stream-contract", + category: "correctness", + severity: "error", + description: "stream requires a source function returning an AsyncIterable and valid options.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + for (const { node, name } of sourceFacts(sourceFile).calls) { + if (name !== "stream") continue; + const source = node.arguments[0]; + if (!source || isProvablyNonFunction(source)) { + diagnostics.push( + diagnostic( + context, + source ?? node.expression, + this, + "stream() requires a source function.", + "Pass ({ signal }) => AsyncIterable or a Promise of one.", + ), + ); + } else { + const invalidReturn = invalidAsyncIterableReturn(source); + if (invalidReturn) { + diagnostics.push( + diagnostic( + context, + invalidReturn, + this, + "stream() source has a return shape that is not an AsyncIterable.", + "Return an async generator or another AsyncIterable.", + ), + ); + } + } + const options = node.arguments[1]; + if (!options) continue; + if (!ts.isObjectLiteralExpression(options)) { + if ( + ts.isLiteralExpression(options) || + isNullishLiteral(options) || + ts.isArrayLiteralExpression(options) + ) { + diagnostics.push( + diagnostic( + context, + options, + this, + "stream() options must be an object.", + "Pass { deps, initialValue } or omit the options argument.", + ), + ); + } + continue; + } + const deps = optionExpression(options, "deps"); + if ( + deps && + !ts.isArrayLiteralExpression(deps) && + (ts.isLiteralExpression(deps) || + isNullishLiteral(deps) || + ts.isObjectLiteralExpression(deps)) + ) { + diagnostics.push( + diagnostic( + context, + deps, + this, + "stream() deps must be an array.", + "Pass a readonly dependency array.", + ), + ); + } + } + } + return diagnostics; + }, +}; + +const dataContractRule: AnalyzeRule = { + id: "askr/data-contract", + category: "correctness", + severity: "error", + description: "Queries and mutations require their identifying and executable options.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + for (const { node, name } of sourceFacts(sourceFile).calls) { + if (name !== "createQuery" && name !== "createMutation") continue; + const options = node.arguments[0]; + if (!options) { + diagnostics.push( + diagnostic( + context, + node.expression, + this, + `${name}() requires an options object.`, + name === "createQuery" + ? "Pass a non-empty key and fetch function." + : "Pass an action function.", + ), + ); + continue; + } + if (!ts.isObjectLiteralExpression(options)) { + if ( + ts.isLiteralExpression(options) || + isNullishLiteral(options) || + ts.isArrayLiteralExpression(options) + ) { + diagnostics.push( + diagnostic( + context, + options, + this, + `${name}() options must be an object or a declared query definition.`, + ), + ); + } + continue; + } + if (name === "createQuery") { + const key = optionExpression(options, "key"); + if (!key || (literalString(key) !== null && literalString(key)?.trim() === "")) { + diagnostics.push( + diagnostic( + context, + key ?? options, + this, + "createQuery() requires a non-empty key.", + "Pass a stable query key.", + ), + ); + } else if (ts.isLiteralExpression(key) && !ts.isStringLiteralLike(key)) { + diagnostics.push( + diagnostic(context, key, this, "createQuery() key must be a string or key function."), + ); + } + const fetcher = optionExpression(options, "fetch"); + if (!fetcher || isProvablyNonFunction(fetcher)) { + diagnostics.push( + diagnostic( + context, + fetcher ?? options, + this, + "createQuery() requires a fetch function.", + "Pass a cancellable fetch function.", + ), + ); + } + } else { + const action = optionExpression(options, "action"); + if (!action || isProvablyNonFunction(action)) { + diagnostics.push( + diagnostic( + context, + action ?? options, + this, + "createMutation() requires an action function.", + "Pass the mutation implementation as action.", + ), + ); + } + } + } + } + return diagnostics; + }, +}; + +const invalidationContractRule: AnalyzeRule = { + id: "askr/invalidation-contract", + category: "correctness", + severity: "error", + description: "Invalidation prefixes, scopes, and intervals must be concrete and non-empty.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + for (const { node, name } of sourceFacts(sourceFile).calls) { + if (!["invalidate", "queryScope", "invalidateOnInterval"].includes(name)) continue; + const prefix = node.arguments[0]; + if ( + !prefix || + (literalString(prefix) !== null && literalString(prefix)?.trim() === "") || + (ts.isLiteralExpression(prefix) && !ts.isStringLiteralLike(prefix)) + ) { + diagnostics.push( + diagnostic( + context, + prefix ?? node.expression, + this, + `${name}() requires a non-empty string ${name === "queryScope" ? "namespace" : "prefix"}.`, + "Use a stable non-empty query-key prefix.", + ), + ); + } + if (name !== "invalidateOnInterval") continue; + const options = node.arguments[1]; + if (!options || !ts.isObjectLiteralExpression(options)) { + if ( + !options || + ts.isLiteralExpression(options) || + isNullishLiteral(options) || + ts.isArrayLiteralExpression(options) + ) { + diagnostics.push( + diagnostic( + context, + options ?? node.expression, + this, + "invalidateOnInterval() requires an options object with intervalMs.", + ), + ); + } + continue; + } + const interval = optionExpression(options, "intervalMs"); + if (!interval || invalidPositiveInterval(interval)) { + diagnostics.push( + diagnostic( + context, + interval ?? options, + this, + "invalidateOnInterval() intervalMs must be positive and finite.", + "Pass a finite interval greater than zero.", + ), + ); + } + } + } + return diagnostics; + }, +}; + +function validateIslandObject( + context: WorkspaceAnalysisContext, + rule: AnalyzeRule, + object: ts.ObjectLiteralExpression, + diagnostics: AnalyzeDiagnostic[], +): void { + const root = optionExpression(object, "root"); + const component = optionExpression(object, "component"); + if (!root || isNullishLiteral(root) || literalString(root)?.trim() === "") { + diagnostics.push( + diagnostic( + context, + root ?? object, + rule, + "Island configuration requires a root.", + "Pass a root element or non-empty selector.", + ), + ); + } + if (!component || isProvablyNonFunction(component)) { + diagnostics.push( + diagnostic( + context, + component ?? object, + rule, + "Island configuration requires a component function.", + "Pass a synchronous Askr component.", + ), + ); + } else { + const resolved = resolvedFunction(component, context); + if (resolved && isAsyncFunction(resolved)) { + diagnostics.push( + diagnostic( + context, + component, + rule, + "Island components must be synchronous.", + "Move asynchronous work into resource(), stream(), or task().", + ), + ); + } + } + const routes = objectProperty(object, "routes"); + if (routes) { + diagnostics.push( + diagnostic( + context, + routes, + rule, + "Island configuration cannot include routes.", + "Use createSPA() for routed applications.", + ), + ); + } +} + +const islandContractRule: AnalyzeRule = { + id: "askr/island-contract", + category: "correctness", + severity: "error", + description: "Island boot requires roots, synchronous components, and no routes.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + for (const { node, name } of sourceFacts(sourceFile).calls) { + if (name !== "createIsland" && name !== "createIslands") continue; + const config = node.arguments[0]; + if (!config || !ts.isObjectLiteralExpression(config)) { + if ( + !config || + ts.isLiteralExpression(config) || + isNullishLiteral(config) || + ts.isArrayLiteralExpression(config) + ) { + diagnostics.push( + diagnostic( + context, + config ?? node.expression, + this, + `${name}() requires a configuration object.`, + ), + ); + } + continue; + } + if (name === "createIsland") { + validateIslandObject(context, this, config, diagnostics); + continue; + } + const islands = optionExpression(config, "islands"); + if (!islands || (ts.isArrayLiteralExpression(islands) && islands.elements.length === 0)) { + diagnostics.push( + diagnostic( + context, + islands ?? config, + this, + "createIslands() requires a non-empty islands array.", + ), + ); + } else if (ts.isArrayLiteralExpression(islands)) { + for (const island of islands.elements) { + if (ts.isObjectLiteralExpression(island)) { + validateIslandObject(context, this, island, diagnostics); + } + } + } + } + } + return diagnostics; + }, +}; + +const executionModelRule: AnalyzeRule = { + id: "askr/execution-model", + category: "correctness", + severity: "error", + description: "A workspace must not mix routed SPA boot and island boot.", + analyze(context) { + const routed: SourceCallFact[] = []; + const islands: SourceCallFact[] = []; + for (const sourceFile of context.sourceFiles) { + for (const fact of sourceFacts(sourceFile).calls) { + if (fact.name === "createSPA" || fact.name === "hydrateSPA") routed.push(fact); + if (fact.name === "createIsland" || fact.name === "createIslands") islands.push(fact); + } + } + if (routed.length === 0 || islands.length === 0) return []; + return [ + diagnostic( + context, + islands[0]!.node.expression, + this, + "This workspace mixes SPA/hydration boot with island boot.", + "Choose one execution model per workspace and move the other boot entry to a separate workspace.", + ), + ]; + }, +}; + +function invalidPrefixElements(expression: ts.Expression | undefined): ts.Expression[] { + if (!expression || !ts.isArrayLiteralExpression(expression)) return []; + return expression.elements.filter( + (element): element is ts.Expression => + ts.isExpression(element) && + ((literalString(element) !== null && literalString(element)?.trim() === "") || + (ts.isLiteralExpression(element) && !ts.isStringLiteralLike(element))), + ); +} + +const actionContractRule: AnalyzeRule = { + id: "askr/action-contract", + category: "correctness", + severity: "error", + description: "Actions require stable unique IDs, schemas, and valid invalidation prefixes.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + const literalIds = new Map(); + for (const sourceFile of context.sourceFiles) { + const facts = sourceFacts(sourceFile); + for (const { node, name } of facts.calls) { + if (name !== "defineAction") continue; + const options = node.arguments[0]; + if (!options || !ts.isObjectLiteralExpression(options)) { + diagnostics.push( + diagnostic( + context, + options ?? node.expression, + this, + "defineAction() requires an options object with id and input.", + ), + ); + continue; + } + const id = optionExpression(options, "id"); + const idText = literalString(id); + if (!id || (idText !== null && idText.trim() === "")) { + diagnostics.push( + diagnostic( + context, + id ?? options, + this, + "defineAction() requires a non-empty id.", + "Use a stable workspace-unique action ID.", + ), + ); + } else if (idText !== null) { + if (literalIds.has(idText)) { + diagnostics.push( + diagnostic( + context, + id, + this, + `Action ID '${idText}' is declared more than once in this workspace.`, + "Give every action a unique stable ID.", + ), + ); + } else { + literalIds.set(idText, id); + } + } else if (ts.isLiteralExpression(id)) { + diagnostics.push(diagnostic(context, id, this, "defineAction() id must be a string.")); + } + const input = optionExpression(options, "input"); + if (!input || isNullishLiteral(input)) { + diagnostics.push( + diagnostic( + context, + input ?? options, + this, + "defineAction() requires an input schema.", + "Pass an object schema as input.", + ), + ); + } + for (const invalid of invalidPrefixElements(optionExpression(options, "invalidates"))) { + diagnostics.push( + diagnostic( + context, + invalid, + this, + "Action invalidation prefixes must be non-empty strings.", + ), + ); + } + } + for (const { node, name } of facts.jsx) { + if (name !== "ActionForm") continue; + if (!jsxAttributes(node).has("action")) { + diagnostics.push( + diagnostic( + context, + node.tagName, + this, + " requires an action descriptor.", + "Pass action={descriptor}.", + ), + ); + } + } + } + return diagnostics; + }, +}; + +const actionPromiseRule: AnalyzeRule = { + id: "askr/action-promise", + category: "correctness", + severity: "error", + description: "Action submit promises must be observed or explicitly discarded.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const facts = sourceFacts(sourceFile); + const handles = new Set(); + for (const { node, name } of facts.calls) { + if (name !== "action") continue; + const parent = node.parent; + if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) { + handles.add(parent.name.text); + } + } + for (const node of facts.allCalls) { + if ( + !ts.isPropertyAccessExpression(node.expression) || + node.expression.name.text !== "submit" + ) { + continue; + } + const receiver = node.expression.expression; + const owned = + (ts.isIdentifier(receiver) && handles.has(receiver.text)) || + (ts.isCallExpression(receiver) && + canonicalCallName(receiver.expression, facts.bindings) === "action"); + if (!owned || !ts.isExpressionStatement(node.parent)) continue; + diagnostics.push( + diagnostic( + context, + node.expression.name, + this, + "Action submit Promise is discarded.", + "Await it, return it, or use void when fire-and-forget is intentional.", + ), + ); + } + } + return diagnostics; + }, +}; + +function allocationName(expression: ts.LeftHandSideExpression): string | null { + if (ts.isIdentifier(expression) && ["RegExp", "Map", "Set"].includes(expression.text)) { + return expression.text; + } + if ( + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === "Intl" + ) { + return `Intl.${expression.name.text}`; + } + return null; +} + +const renderAllocationRule: AnalyzeRule = { + id: "askr/render-allocation", + category: "performance", + severity: "info", + description: "Repeated render-time constructors should be hoisted or memoized.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + for (const construction of sourceFacts(sourceFile).constructions) { + const name = allocationName(construction.expression); + if (!name) continue; + const owner = containingFunction(construction); + if (!owner || (!/^[A-Z]/.test(functionName(owner) ?? "") && !containsJsx(owner))) { + continue; + } + diagnostics.push( + diagnostic( + context, + construction, + this, + `new ${name}() is allocated during component render.`, + "Hoist stable instances or cache them outside the repeated render path.", + ), + ); + } + } + return diagnostics; + }, +}; + +const bootRegistryRule: AnalyzeRule = { + id: "askr/boot-registry", + category: "correctness", + severity: "error", + description: "Routed app boot requires an explicit route registry.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + for (const sourceFile of context.sourceFiles) { + const bindings = sourceBindings(sourceFile); + visit(sourceFile, (node) => { + if (!ts.isCallExpression(node)) return; + const name = canonicalCallName(node.expression, bindings); + if (name !== "createSPA" && name !== "hydrateSPA") return; + const config = node.arguments[0]; + if (!config || !ts.isObjectLiteralExpression(config)) { + diagnostics.push( + diagnostic( + context, + node.expression, + this, + `${name}() must receive an object containing registry.`, + "Pass the RouteRegistry returned by createRouteRegistry() as registry.", + ), + ); + return; + } + if (!objectProperty(config, "registry")) { + const legacy = objectProperty(config, "routes") ?? objectProperty(config, "manifest"); + diagnostics.push( + diagnostic( + context, + legacy ?? config, + this, + legacy + ? `${name}() uses a legacy route source instead of registry.` + : `${name}() is missing its required registry property.`, + "Pass the RouteRegistry returned by createRouteRegistry() as registry.", + ), + ); + } + const parent = node.parent; + if ( + ts.isExpressionStatement(parent) && + !ts.isAwaitExpression(node.parent) && + !ts.isVoidExpression(node.parent) + ) { + diagnostics.push( + diagnostic( + context, + node.expression, + this, + `${name}() returns a Promise that is not awaited.`, + `Use await ${name}(...), return the Promise, or explicitly use void when fire-and-forget is intentional.`, + ), + ); + } + }); + } + return diagnostics; + }, +}; + +function isTypeofGuard(node: ts.Identifier): boolean { + return ts.isTypeOfExpression(node.parent); +} + +function expressionContainsTypeofName(expression: ts.Expression, name: string): boolean { + let found = false; + const walk = (node: ts.Node): void => { + if (found) return; + if ( + ts.isTypeOfExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === name + ) { + found = true; + return; + } + ts.forEachChild(node, walk); + }; + walk(expression); + return found; +} + +function isInsideTypeofGuard(node: ts.Identifier): boolean { + for (let current = node.parent; current; current = current.parent) { + if (ts.isIfStatement(current) && expressionContainsTypeofName(current.expression, node.text)) { + return true; + } + if ( + ts.isConditionalExpression(current) && + expressionContainsTypeofName(current.condition, node.text) + ) { + return true; + } + if (ts.isFunctionLike(current) || ts.isSourceFile(current)) break; + } + return false; +} + +const ssrGlobalsRule: AnalyzeRule = { + id: "askr/ssr-browser-global", + category: "correctness", + severity: "error", + description: "SSR code must not access browser-only globals during rendering.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + const globals = new Set(["window", "document", "localStorage", "sessionStorage", "navigator"]); + for (const sourceFile of context.sourceFiles) { + const normalized = sourceFile.fileName.split(path.sep).join("/"); + const importsSsr = sourceFile.statements.some( + (statement) => + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) && + /^@askrjs\/askr\/(?:ssr|ssg)$/.test(statement.moduleSpecifier.text), + ); + if ( + !importsSsr && + !/(?:^|\/)(?:server|entry-server|ssr|ssg)[^/]*\.[cm]?[jt]sx?$/.test(normalized) + ) { + continue; + } + visit(sourceFile, (node) => { + if ( + !ts.isIdentifier(node) || + !globals.has(node.text) || + isTypeofGuard(node) || + isInsideTypeofGuard(node) + ) { + return; + } + if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) { + return; + } + if ( + (ts.isPropertyAssignment(node.parent) || + ts.isMethodDeclaration(node.parent) || + ts.isPropertyDeclaration(node.parent) || + ts.isPropertySignature(node.parent)) && + node.parent.name === node + ) { + return; + } + const symbol = context.checker.getSymbolAtLocation(node); + if ( + symbol?.declarations?.some( + (declaration) => + !declaration.getSourceFile().isDeclarationFile || + !/[\\/]typescript[\\/]lib[\\/]lib\./.test(declaration.getSourceFile().fileName), + ) + ) { + return; + } + diagnostics.push( + diagnostic( + context, + node, + this, + `Browser global '${node.text}' is accessed in SSR/SSG code.`, + "Move the access to client lifecycle code or guard it behind a browser-only branch.", + ), + ); + }); + } + return diagnostics; + }, +}; + +function dependencyRecord( + manifest: Record, + section: string, +): Record { + const value = manifest[section]; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function hasDependency(manifest: Record, packageName: string): boolean { + return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].some( + (section) => packageName in dependencyRecord(manifest, section), + ); +} + +const frameworkConfigRule: AnalyzeRule = { + id: "askr/framework-config", + category: "configuration", + severity: "error", + description: "TypeScript and Vite must use Askr's JSX/runtime wiring.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + const tsx = context.sourceFiles.find((sourceFile) => sourceFile.fileName.endsWith(".tsx")); + if (!tsx || !hasDependency(context.workspace.manifest, "@askrjs/askr")) return diagnostics; + + const tsconfigPath = path.join(context.workspace.directory, "tsconfig.json"); + const tsconfigSource = ts.sys.readFile(tsconfigPath); + const jsxImportSource = context.program.getCompilerOptions().jsxImportSource; + if (jsxImportSource !== "@askrjs/askr") { + let fix: AnalyzeDiagnostic["fix"]; + if (tsconfigSource) { + try { + const parsed = JSON.parse(tsconfigSource) as Record; + const compilerOptions = + parsed.compilerOptions && + typeof parsed.compilerOptions === "object" && + !Array.isArray(parsed.compilerOptions) + ? (parsed.compilerOptions as Record) + : {}; + parsed.compilerOptions = { + ...compilerOptions, + jsx: "react-jsx", + jsxImportSource: "@askrjs/askr", + }; + fix = { + description: "Configure TypeScript to use the Askr JSX runtime", + filePath: tsconfigPath, + start: 0, + end: tsconfigSource.length, + replacement: `${JSON.stringify(parsed, null, 2)}\n`, + }; + } catch { + // JSONC and extended configs remain report-only. + } + } + diagnostics.push( + diagnostic( + context, + tsx, + this, + "TSX is present but compilerOptions.jsxImportSource is not '@askrjs/askr'.", + "Set jsx to react-jsx and jsxImportSource to @askrjs/askr.", + fix, + ), + ); + } + + const viteConfig = context.sourceFiles.find((sourceFile) => + /(?:^|\/)vite\.config\.[cm]?[jt]s$/.test(sourceFile.fileName.split(path.sep).join("/")), + ); + if (viteConfig) { + let pluginLocalName: string | null = null; + for (const statement of viteConfig.statements) { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== "@askrjs/vite" || + !statement.importClause?.namedBindings || + !ts.isNamedImports(statement.importClause.namedBindings) + ) { + continue; + } + const plugin = statement.importClause.namedBindings.elements.find( + (element) => (element.propertyName?.text ?? element.name.text) === "askr", + ); + pluginLocalName = plugin?.name.text ?? null; + } + let pluginCalled = false; + if (pluginLocalName) { + visit(viteConfig, (node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === pluginLocalName + ) { + pluginCalled = true; + } + }); + } + const dependencyPresent = hasDependency(context.workspace.manifest, "@askrjs/vite"); + if (dependencyPresent && pluginCalled) return diagnostics; + diagnostics.push( + diagnostic( + context, + viteConfig, + this, + !dependencyPresent + ? "This Askr Vite project does not declare @askrjs/vite." + : "Vite is configured without calling the @askrjs/vite askr() plugin.", + "Declare @askrjs/vite, import askr, and include askr() in the plugin list.", + ), + ); + } + return diagnostics; + }, +}; + +const parseErrorRule: AnalyzeRule = { + id: "askr/parse-error", + category: "correctness", + severity: "error", + description: "Source must parse before framework analysis is reliable.", + analyze(context) { + return context.program + .getSyntacticDiagnostics() + .filter((entry): entry is ts.DiagnosticWithLocation => + Boolean( + entry.file && + context.sourceFiles.some((sourceFile) => sourceFile.fileName === entry.file?.fileName), + ), + ) + .map((entry) => { + const start = entry.start ?? 0; + const point = entry.file.getLineAndCharacterOfPosition(start); + return { + ruleId: this.id, + category: this.category, + severity: this.severity, + message: ts.flattenDiagnosticMessageText(entry.messageText, "\n"), + workspace: context.workspace.name, + file: workspaceRelativeFile(context, entry.file.fileName), + line: point.line + 1, + column: point.character + 1, + remediation: "Fix the syntax error so framework rules can inspect this file reliably.", + }; + }); + }, +}; + +export const ANALYZE_RULES: readonly AnalyzeRule[] = [ + parseErrorRule, + stableRenderRule, + stateAccessRule, + stateRenderWriteRule, + resourceCancellationRule, + stableDependenciesRule, + lifecycleContractRule, + streamContractRule, + dataContractRule, + invalidationContractRule, + forContractRule, + controlContractRule, + stableKeyRule, + preferForRule, + asyncComponentRule, + routeRegistryRule, + routePathRule, + dataCancellationRule, + bootRegistryRule, + islandContractRule, + executionModelRule, + actionContractRule, + actionPromiseRule, + renderAllocationRule, + ssrGlobalsRule, + frameworkConfigRule, +]; + +export function configuredSeverity( + rule: AnalyzeRule, + configuration: WorkspaceAnalysisContext["configuration"], +): AnalyzeSeverity | "off" { + return configuration.rules[rule.id] ?? rule.severity; +} diff --git a/src/analyze/runner.ts b/src/analyze/runner.ts new file mode 100644 index 0000000..3252e6b --- /dev/null +++ b/src/analyze/runner.ts @@ -0,0 +1,225 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { writeFileChanges, type FileChange } from "../file-changes"; +import { discoverWorkspaceProject } from "../update/discovery"; +import type { WorkspaceManifest } from "../update/types"; +import { createWorkspaceAnalysisContext, readAnalyzeConfiguration } from "./project"; +import { ANALYZE_RULES, configuredSeverity } from "./rules"; +import { + ANALYZE_SCHEMA_VERSION, + type AnalyzeDiagnostic, + type AnalyzeFixResult, + type AnalyzeReport, + type AnalyzeSummary, + type AnalyzeWorkspaceResult, + type PublicDiagnostic, +} from "./types"; + +export interface RunAnalysisOptions { + readonly cwd: string; + readonly workspacePatterns: string[]; + readonly check: boolean; + readonly writer?: (changes: readonly FileChange[]) => Promise; +} + +interface AnalysisPass { + readonly diagnostics: AnalyzeDiagnostic[]; + readonly workspaces: AnalyzeWorkspaceResult[]; +} + +function rootManifest(workspaces: readonly WorkspaceManifest[]): Record { + const root = workspaces.find((workspace) => workspace.isRoot); + if (!root) throw new Error("Discovered project is missing its root workspace."); + return root.manifest; +} + +function compareDiagnostics(left: AnalyzeDiagnostic, right: AnalyzeDiagnostic): number { + return ( + left.workspace.localeCompare(right.workspace) || + left.file.localeCompare(right.file) || + left.line - right.line || + left.column - right.column || + left.ruleId.localeCompare(right.ruleId) || + left.message.localeCompare(right.message) + ); +} + +async function analyzePass( + root: string, + allWorkspaces: readonly WorkspaceManifest[], + selectedWorkspaces: readonly WorkspaceManifest[], + manifest: Record, +): Promise { + const baseConfiguration = readAnalyzeConfiguration(manifest); + const diagnostics: AnalyzeDiagnostic[] = []; + const workspaces: AnalyzeWorkspaceResult[] = []; + for (const workspace of selectedWorkspaces) { + const nestedWorkspaceExclusions = allWorkspaces.flatMap((candidate) => { + if (candidate.directory === workspace.directory) return []; + const relative = path + .relative(workspace.directory, candidate.directory) + .split(path.sep) + .join("/"); + return relative && !relative.startsWith("../") ? [`${relative}/**`] : []; + }); + const configuration = { + ...baseConfiguration, + exclude: [...baseConfiguration.exclude, ...nestedWorkspaceExclusions], + }; + const created = await createWorkspaceAnalysisContext(root, workspace, configuration); + workspaces.push({ + name: workspace.name, + path: path.relative(root, workspace.directory).split(path.sep).join("/") || ".", + tsconfig: created.tsconfig + ? path.relative(root, created.tsconfig).split(path.sep).join("/") + : null, + files: created.context.sourceFiles.length, + }); + for (const rule of ANALYZE_RULES) { + const severity = configuredSeverity(rule, configuration); + if (severity === "off") continue; + diagnostics.push(...rule.analyze(created.context).map((entry) => ({ ...entry, severity }))); + } + } + diagnostics.sort(compareDiagnostics); + return { diagnostics, workspaces }; +} + +function fixResult(root: string, diagnostic: AnalyzeDiagnostic, reason?: string): AnalyzeFixResult { + if (!diagnostic.fix) throw new Error("Cannot record a missing analysis fix."); + return { + ruleId: diagnostic.ruleId, + workspace: diagnostic.workspace, + file: path.relative(root, diagnostic.fix.filePath).split(path.sep).join("/"), + description: diagnostic.fix.description, + ...(reason ? { reason } : {}), + }; +} + +async function prepareFixes( + root: string, + diagnostics: readonly AnalyzeDiagnostic[], +): Promise<{ + changes: FileChange[]; + applied: AnalyzeFixResult[]; + skipped: AnalyzeFixResult[]; +}> { + const candidates = diagnostics.filter( + (entry): entry is AnalyzeDiagnostic & { fix: NonNullable } => + Boolean(entry.fix), + ); + const byFile = new Map(); + for (const candidate of candidates) { + const list = byFile.get(candidate.fix.filePath) ?? []; + list.push(candidate); + byFile.set(candidate.fix.filePath, list); + } + + const changes: FileChange[] = []; + const applied: AnalyzeFixResult[] = []; + const skipped: AnalyzeFixResult[] = []; + for (const [filePath, entries] of [...byFile].sort(([left], [right]) => + left.localeCompare(right), + )) { + const original = await fs.readFile(filePath, "utf8"); + const ordered = [...entries].sort( + (left, right) => + right.fix.start - left.fix.start || + right.fix.end - left.fix.end || + left.ruleId.localeCompare(right.ruleId), + ); + let content = original; + let nextStart = original.length + 1; + for (const entry of ordered) { + const fix = entry.fix; + if ( + fix.start < 0 || + fix.end < fix.start || + fix.end > original.length || + fix.end > nextStart + ) { + skipped.push(fixResult(root, entry, "conflicts with another safe fix")); + continue; + } + content = `${content.slice(0, fix.start)}${fix.replacement}${content.slice(fix.end)}`; + nextStart = fix.start; + applied.push(fixResult(root, entry)); + } + if (content !== original) changes.push({ filePath, content }); + } + return { changes, applied, skipped }; +} + +function publicDiagnostic(diagnostic: AnalyzeDiagnostic): PublicDiagnostic { + const { fix, ...entry } = diagnostic; + return { + ...entry, + ...(fix ? { fix: { description: fix.description, safe: true as const } } : {}), + }; +} + +function summary( + diagnostics: readonly AnalyzeDiagnostic[], + applied: readonly AnalyzeFixResult[], + skipped: readonly AnalyzeFixResult[], +): AnalyzeSummary { + return { + errors: diagnostics.filter((entry) => entry.severity === "error").length, + warnings: diagnostics.filter((entry) => entry.severity === "warning").length, + info: diagnostics.filter((entry) => entry.severity === "info").length, + diagnostics: diagnostics.length, + appliedFixes: applied.length, + skippedFixes: skipped.length, + }; +} + +export function analysisHasBlockingFindings(report: AnalyzeReport): boolean { + return report.summary.errors > 0 || report.summary.warnings > 0; +} + +export async function runAnalysis(options: RunAnalysisOptions): Promise { + const project = await discoverWorkspaceProject({ + cwd: options.cwd, + workspacePatterns: options.workspacePatterns, + }); + const manifest = rootManifest(project.workspaces); + let pass = await analyzePass( + project.root, + project.workspaces, + project.selectedWorkspaces, + manifest, + ); + let applied: AnalyzeFixResult[] = []; + let skipped: AnalyzeFixResult[] = []; + + if (options.check) { + skipped = pass.diagnostics + .filter((entry) => entry.fix) + .map((entry) => fixResult(project.root, entry, "check mode does not write files")); + } else { + const prepared = await prepareFixes(project.root, pass.diagnostics); + skipped = prepared.skipped; + if (prepared.changes.length > 0) { + await (options.writer ?? writeFileChanges)(prepared.changes); + applied = prepared.applied; + pass = await analyzePass( + project.root, + project.workspaces, + project.selectedWorkspaces, + manifest, + ); + } + } + + return { + schemaVersion: ANALYZE_SCHEMA_VERSION, + root: project.root, + discoveredWorkspaces: project.workspaces.map((workspace) => workspace.name), + selectedWorkspaces: project.selectedWorkspaces.map((workspace) => workspace.name), + workspaces: pass.workspaces, + appliedFixes: applied, + skippedFixes: skipped, + diagnostics: pass.diagnostics.map(publicDiagnostic), + summary: summary(pass.diagnostics, applied, skipped), + }; +} diff --git a/src/analyze/types.ts b/src/analyze/types.ts new file mode 100644 index 0000000..f434a55 --- /dev/null +++ b/src/analyze/types.ts @@ -0,0 +1,94 @@ +import type ts from "typescript"; +import type { WorkspaceManifest } from "../update/types"; + +export const ANALYZE_SCHEMA_VERSION = 1; + +export type AnalyzeSeverity = "error" | "warning" | "info"; +export type AnalyzeCategory = "correctness" | "performance" | "configuration"; +export type RuleSetting = AnalyzeSeverity | "off"; + +export interface AnalyzeFix { + readonly description: string; + readonly filePath: string; + readonly start: number; + readonly end: number; + readonly replacement: string; +} + +export interface AnalyzeDiagnostic { + readonly ruleId: string; + readonly category: AnalyzeCategory; + readonly severity: AnalyzeSeverity; + readonly message: string; + readonly workspace: string; + readonly file: string; + readonly line: number; + readonly column: number; + readonly remediation?: string; + readonly fix?: AnalyzeFix; +} + +export interface PublicDiagnostic extends Omit { + readonly fix?: { + readonly description: string; + readonly safe: true; + }; +} + +export interface AnalyzeConfiguration { + readonly exclude: string[]; + readonly rules: Record; +} + +export interface WorkspaceAnalysisContext { + readonly root: string; + readonly workspace: WorkspaceManifest; + readonly program: ts.Program; + readonly checker: ts.TypeChecker; + readonly sourceFiles: readonly ts.SourceFile[]; + readonly configuration: AnalyzeConfiguration; +} + +export interface AnalyzeRule { + readonly id: string; + readonly category: AnalyzeCategory; + readonly severity: AnalyzeSeverity; + readonly description: string; + analyze(context: WorkspaceAnalysisContext): AnalyzeDiagnostic[]; +} + +export interface AnalyzeWorkspaceResult { + readonly name: string; + readonly path: string; + readonly tsconfig: string | null; + readonly files: number; +} + +export interface AnalyzeFixResult { + readonly ruleId: string; + readonly workspace: string; + readonly file: string; + readonly description: string; + readonly reason?: string; +} + +export interface AnalyzeSummary { + readonly errors: number; + readonly warnings: number; + readonly info: number; + readonly diagnostics: number; + readonly appliedFixes: number; + readonly skippedFixes: number; +} + +export interface AnalyzeReport { + readonly schemaVersion: typeof ANALYZE_SCHEMA_VERSION; + readonly root: string; + readonly discoveredWorkspaces: readonly string[]; + readonly selectedWorkspaces: readonly string[]; + readonly workspaces: readonly AnalyzeWorkspaceResult[]; + readonly appliedFixes: readonly AnalyzeFixResult[]; + readonly skippedFixes: readonly AnalyzeFixResult[]; + readonly diagnostics: readonly PublicDiagnostic[]; + readonly summary: AnalyzeSummary; +} diff --git a/src/bin/analyze.ts b/src/bin/analyze.ts new file mode 100644 index 0000000..2c9d887 --- /dev/null +++ b/src/bin/analyze.ts @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +import path from "node:path"; +import type { RunAnalysisOptions } from "../analyze/runner"; +import type { AnalyzeReport, PublicDiagnostic } from "../analyze/types"; +import { isDirectExecution } from "./is-direct-execution"; + +type CliIo = Pick; + +interface ParsedAnalyzeArgs { + cwd: string; + workspacePatterns: string[]; + json: boolean; + check: boolean; + help: boolean; +} + +interface AnalyzeRuntime { + readonly analyze?: (options: RunAnalysisOptions) => Promise; +} + +const helpText = `askr analyze - Analyze Askr correctness and performance + +Usage: + askr analyze [--cwd ] [--workspace ]... [--json] [--check] + +Options: + --cwd Resolve a project from another directory + --workspace Select workspace names (repeatable) + --json Emit one deterministic JSON object on stdout + --check Report safe fixes without writing files + --help, -h Show this help message + +By default every declared workspace is scanned and explicitly safe fixes are +applied transactionally. Semantic rewrites are always report-only. +`; + +function optionValue(args: readonly string[], index: number, option: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("-")) throw new Error(`${option} requires a value`); + return value; +} + +export function parseAnalyzeArgs(args: readonly string[]): ParsedAnalyzeArgs { + const parsed: ParsedAnalyzeArgs = { + cwd: process.cwd(), + workspacePatterns: [], + json: false, + check: false, + help: false, + }; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--help" || argument === "-h") parsed.help = true; + else if (argument === "--json") parsed.json = true; + else if (argument === "--check") parsed.check = true; + else if (argument === "--cwd") { + parsed.cwd = path.resolve(optionValue(args, index, "--cwd")); + index += 1; + } else if (argument.startsWith("--cwd=")) { + const value = argument.slice("--cwd=".length); + if (!value) throw new Error("--cwd requires a value"); + parsed.cwd = path.resolve(value); + } else if (argument === "--workspace") { + parsed.workspacePatterns.push(optionValue(args, index, "--workspace")); + index += 1; + } else if (argument.startsWith("--workspace=")) { + const value = argument.slice("--workspace=".length); + if (!value) throw new Error("--workspace requires a value"); + parsed.workspacePatterns.push(value); + } else { + throw new Error(`Unknown option: ${argument}`); + } + } + return parsed; +} + +function formatDiagnostic(diagnostic: PublicDiagnostic): string { + const severity = diagnostic.severity.toUpperCase(); + const remedy = diagnostic.remediation ? `\n ${diagnostic.remediation}` : ""; + return `${diagnostic.workspace}:${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${severity} ${diagnostic.ruleId} ${diagnostic.message}${remedy}`; +} + +function printHuman(report: AnalyzeReport, io: CliIo): void { + for (const fix of report.appliedFixes) { + io.log(`fixed ${fix.file} ${fix.ruleId}: ${fix.description}`); + } + for (const diagnostic of report.diagnostics) io.log(formatDiagnostic(diagnostic)); + io.log( + `Analyzed ${report.workspaces.length} workspace(s), ${report.workspaces.reduce( + (count, workspace) => count + workspace.files, + 0, + )} file(s): ${report.summary.errors} error(s), ${report.summary.warnings} warning(s), ${report.summary.info} info, ${report.summary.appliedFixes} fix(es) applied.`, + ); +} + +export async function runAnalyzeCli( + args: string[] = process.argv.slice(2), + io: CliIo = console, + runtime: AnalyzeRuntime = {}, +): Promise { + const wantsJson = args.includes("--json"); + try { + const parsed = parseAnalyzeArgs(args); + if (parsed.help) { + io.log(helpText.trimEnd()); + return 0; + } + const analyze = runtime.analyze ?? (await import("../analyze/runner")).runAnalysis; + const report = await analyze({ + cwd: parsed.cwd, + workspacePatterns: parsed.workspacePatterns, + check: parsed.check, + }); + if (parsed.json) io.log(JSON.stringify(report)); + else printHuman(report, io); + return report.summary.errors > 0 || report.summary.warnings > 0 ? 1 : 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + io.error( + wantsJson ? JSON.stringify({ schemaVersion: 1, status: "error", error: message }) : message, + ); + return 1; + } +} + +async function main(): Promise { + process.exit(await runAnalyzeCli(process.argv.slice(2))); +} + +if (isDirectExecution(import.meta.url)) void main(); diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 54b1248..7949859 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -13,12 +13,16 @@ function printHelp(io: CliIo = console): void { io.log(""); io.log("Commands:"); io.log(" add Generate pages or declared actions into an Askr project"); + io.log(" analyze Analyze Askr correctness and performance"); + io.log(" check Run the complete project validation path"); io.log(" create Create a new Askr app from a template or product prompt"); + io.log(" doctor Diagnose environment and Askr project health"); io.log(" generate Generate an @askrjs/fetch client from OpenAPI"); io.log(" openapi Generate or check an OpenAPI YAML artifact"); io.log(" skills Install or sync Askr agent skills"); io.log(" ssg Run static-site generation"); io.log(" outdated List available dependency updates"); + io.log(" repair Apply safe fixes and identify remaining semantic work"); io.log(" update Apply safe dependency updates"); io.log(" upgrade Apply latest peer-compatible dependency upgrades"); io.log(""); @@ -31,6 +35,10 @@ function printHelp(io: CliIo = console): void { io.log(' askr create --prompt "Agent workflow console with approvals"'); io.log(" askr add page audit-log"); io.log(" askr add action approve-request --route /requests/{id}"); + io.log(" askr analyze --check"); + io.log(" askr doctor"); + io.log(" askr repair"); + io.log(" askr check"); io.log(" askr skills install"); io.log(" askr openapi --check"); io.log(" askr skills review foundation --cwd ./candidate-app"); @@ -66,6 +74,16 @@ export async function runCli( return runAddCli(args.slice(1), io); } + if (command === "analyze") { + const { runAnalyzeCli } = await import("./analyze"); + return runAnalyzeCli(args.slice(1), io); + } + + if (command === "check" || command === "doctor" || command === "repair") { + const { runGuardrailCli } = await import("./guardrails"); + return runGuardrailCli(command, args.slice(1), io); + } + if (command === "generate") { const { runGenerateCli } = await import("./generate"); return runGenerateCli(args.slice(1), io); diff --git a/src/bin/guardrails.ts b/src/bin/guardrails.ts new file mode 100644 index 0000000..4d1a134 --- /dev/null +++ b/src/bin/guardrails.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +import path from "node:path"; +import type { AnalyzeReport, PublicDiagnostic } from "../analyze/types"; +import type { GuardrailRuntime } from "../guardrails/runner"; +import type { + CheckReport, + DoctorReport, + GuardrailFinding, + RepairReport, +} from "../guardrails/types"; + +type CliIo = Pick; +type GuardrailCommand = "check" | "doctor" | "repair"; + +interface ParsedGuardrailArgs { + readonly cwd: string; + readonly workspacePatterns: string[]; + readonly json: boolean; + readonly help: boolean; +} + +const descriptions: Record = { + check: "Run analysis, lint, typecheck, tests, and build", + doctor: "Diagnose environment and Askr project health", + repair: "Apply safe fixes and report remaining semantic work", +}; + +function helpText(command: GuardrailCommand): string { + return `askr ${command} - ${descriptions[command]} + +Usage: + askr ${command} [--cwd ] [--workspace ]... [--json] + +Options: + --cwd Resolve a project from another directory + --workspace Select workspace names for analysis (repeatable) + --json Emit one deterministic JSON object + --help, -h Show this help message +`; +} + +function optionValue(args: readonly string[], index: number, option: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("-")) throw new Error(`${option} requires a value`); + return value; +} + +export function parseGuardrailArgs(args: readonly string[]): ParsedGuardrailArgs { + let cwd = process.cwd(); + const workspacePatterns: string[] = []; + let json = false; + let help = false; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--help" || argument === "-h") help = true; + else if (argument === "--json") json = true; + else if (argument === "--cwd") { + cwd = path.resolve(optionValue(args, index, "--cwd")); + index += 1; + } else if (argument.startsWith("--cwd=")) { + const value = argument.slice("--cwd=".length); + if (!value) throw new Error("--cwd requires a value"); + cwd = path.resolve(value); + } else if (argument === "--workspace") { + workspacePatterns.push(optionValue(args, index, "--workspace")); + index += 1; + } else if (argument.startsWith("--workspace=")) { + const value = argument.slice("--workspace=".length); + if (!value) throw new Error("--workspace requires a value"); + workspacePatterns.push(value); + } else throw new Error(`Unknown option: ${argument}`); + } + return { cwd, workspacePatterns, json, help }; +} + +function formatDiagnostic(diagnostic: PublicDiagnostic): string { + const remediation = diagnostic.remediation ? `\n ${diagnostic.remediation}` : ""; + return `${diagnostic.workspace}:${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity.toUpperCase()} ${diagnostic.ruleId} ${diagnostic.message}${remediation}`; +} + +function printDiagnostics(analysis: AnalyzeReport, io: CliIo): void { + for (const diagnostic of analysis.diagnostics) io.log(formatDiagnostic(diagnostic)); +} + +function formatFinding(finding: GuardrailFinding): string { + const marker = + finding.status === "pass" ? "PASS" : finding.status === "warning" ? "WARN" : "FAIL"; + return `${marker} ${finding.id} ${finding.message}${ + finding.remediation ? `\n ${finding.remediation}` : "" + }`; +} + +function printDoctor(report: DoctorReport, io: CliIo): void { + for (const finding of report.findings) io.log(formatFinding(finding)); + printDiagnostics(report.analysis, io); + io.log( + `Doctor: ${report.summary.passed} passed, ${report.summary.warnings} warning(s), ${report.summary.errors} error(s).`, + ); +} + +function printRepair(report: RepairReport, io: CliIo): void { + for (const fix of report.analysis.appliedFixes) { + io.log(`fixed ${fix.file} ${fix.ruleId}: ${fix.description}`); + } + printDiagnostics(report.analysis, io); + io.log( + `Repair: ${report.analysis.summary.appliedFixes} safe fix(es) applied, ${report.analysis.summary.diagnostics} finding(s) remain.`, + ); + io.log(report.nextAction); +} + +function printCheck(report: CheckReport, io: CliIo): void { + printDiagnostics(report.analysis, io); + for (const script of report.scripts) { + if (script.stdout.trim()) io.log(script.stdout.trimEnd()); + if (script.stderr.trim()) io.error(script.stderr.trimEnd()); + const marker = + script.status === "passed" ? "PASS" : script.status === "failed" ? "FAIL" : "SKIP"; + io.log(`${marker} ${script.command}${script.reason ? ` (${script.reason})` : ""}`); + } + io.log( + `Check ${report.status}: ${report.analysis.summary.errors} analysis error(s), ${report.analysis.summary.warnings} warning(s), ${report.scripts.filter((entry) => entry.status === "passed").length} script(s) passed.`, + ); +} + +export async function runGuardrailCli( + command: GuardrailCommand, + args: string[] = process.argv.slice(2), + io: CliIo = console, + runtime: GuardrailRuntime = {}, +): Promise { + const wantsJson = args.includes("--json"); + try { + const parsed = parseGuardrailArgs(args); + if (parsed.help) { + io.log(helpText(command).trimEnd()); + return 0; + } + const options = { + cwd: parsed.cwd, + workspacePatterns: parsed.workspacePatterns, + }; + const { runCheck, runDoctor, runRepair } = await import("../guardrails/runner"); + const report = + command === "doctor" + ? await runDoctor(options, runtime) + : command === "repair" + ? await runRepair(options) + : await runCheck(options, runtime); + if (parsed.json) io.log(JSON.stringify(report)); + else if (report.command === "doctor") printDoctor(report, io); + else if (report.command === "repair") printRepair(report, io); + else printCheck(report, io); + if (report.command === "doctor") return report.summary.errors > 0 ? 1 : 0; + if (report.command === "repair") return report.status === "clean" ? 0 : 1; + return report.status === "passed" ? 0 : 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + io.error( + wantsJson ? JSON.stringify({ schemaVersion: 1, status: "error", error: message }) : message, + ); + return 1; + } +} diff --git a/src/bin/skill-review.ts b/src/bin/skill-review.ts index 7d6d110..6f61c84 100644 --- a/src/bin/skill-review.ts +++ b/src/bin/skill-review.ts @@ -20,7 +20,6 @@ interface ReviewPromptDefinition { repairFocus: string; prompt: string; assertions: ReviewAssertion[]; - suppression?: string; } interface ReviewFile { @@ -426,46 +425,13 @@ const REVIEW_PROMPTS: ReviewPromptDefinition[] = [ re("local Sidebar", String.raw`export\s+(?:default\s+)?function\s+Sidebar\b`), ]), requireAny("Keeps Askr-native route or state primitives in use.", [ + re("registerRoutes", String.raw`\bregisterRoutes\s*\(`), re("createRouteRegistry", String.raw`\bcreateRouteRegistry\s*\(`), re("state()", String.raw`\bstate\s*\(`), re("resource()", String.raw`\bresource\s*\(`), ]), ], }, - { - id: "reject-custom-accessibility-primitives", - title: "Negative Prompt: Reject Custom Accessibility Primitives", - relatedSkills: ["askr-accessibility", "askr-ui-composition"], - repairFocus: - "Use maintained dialog, command-menu, focus-trap, and focus-restoration primitives before reimplementing accessibility-sensitive behavior.", - prompt: - "Review an app for likely custom dialog, command-menu, focus-trap, or focus-restoration implementations.", - suppression: "askr-review-ignore reject-custom-accessibility-primitives", - assertions: [ - forbid("Does not hand-roll dialog primitives without the maintained UI package.", [ - re("custom dialog markup", String.raw`<[^>]+\brole\s*=\s*["']dialog["']`), - re("native dialog wrapper", String.raw` { @@ -634,20 +600,7 @@ export async function runSkillReview( } const { files, targetPath } = await loadReviewFiles(options.cwd ?? process.cwd()); - const reviewFiles = prompt.suppression - ? files.filter((file) => !file.content.includes(prompt.suppression!)) - : files; - const checks = - reviewFiles.length === 0 && files.length > 0 - ? prompt.assertions.map((assertion) => ({ - description: assertion.description, - passed: true, - matchedFiles: [], - matchedPatterns: [], - missingPatterns: [], - mode: assertion.mode, - })) - : prompt.assertions.map((assertion) => evaluateAssertion(assertion, reviewFiles)); + const checks = prompt.assertions.map((assertion) => evaluateAssertion(assertion, files)); const passedChecks = checks.filter((check) => check.passed).length; const totalChecks = checks.length; diff --git a/src/bin/skills.ts b/src/bin/skills.ts index 7f11039..fb42af8 100644 --- a/src/bin/skills.ts +++ b/src/bin/skills.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { constants, type Dirent } from "node:fs"; +import { createHash } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { formatSkillReviewReport, listSkillReviewPrompts, runSkillReview } from "./skill-review"; @@ -147,6 +148,76 @@ async function listBundledSkills(): Promise { .sort(); } +async function directoryFingerprint(directory: string): Promise { + const stat = await fs.stat(directory).catch(() => null); + if (!stat?.isDirectory()) return null; + const hash = createHash("sha256"); + const visit = async (current: string, relative: string): Promise => { + const entries = await fs.readdir(current, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const childRelative = relative ? `${relative}/${entry.name}` : entry.name; + const child = path.join(current, entry.name); + hash.update(`${entry.isDirectory() ? "d" : "f"}:${childRelative}\0`); + if (entry.isDirectory()) await visit(child, childRelative); + else if (entry.isFile()) hash.update(await fs.readFile(child)); + else if (entry.isSymbolicLink()) hash.update(await fs.readlink(child)); + } + }; + await visit(directory, ""); + return hash.digest("hex"); +} + +export interface BundledSkillStatus { + readonly bundled: number; + readonly installed: number; + readonly missing: readonly string[]; + readonly modified: readonly string[]; + readonly obsolete: readonly string[]; + readonly current: boolean; +} + +export async function inspectBundledSkills( + options: { cwd?: string } = {}, +): Promise { + const root = path.resolve(options.cwd ?? process.cwd()); + const sourceRoot = await findBundledSkillsDir(); + const targetRoot = path.join(root, PROJECT_SKILLS_DIR); + const bundledNames = await listBundledSkills(); + const targetEntries = await fs + .readdir(targetRoot, { withFileTypes: true }) + .catch(() => [] as Dirent[]); + const targetDirectories = new Set( + targetEntries.filter((entry) => entry.isDirectory()).map((entry) => entry.name), + ); + const missing: string[] = []; + const modified: string[] = []; + for (const name of bundledNames) { + if (!targetDirectories.has(name)) { + missing.push(name); + continue; + } + const [sourceFingerprint, targetFingerprint] = await Promise.all([ + directoryFingerprint(path.join(sourceRoot, name)), + directoryFingerprint(path.join(targetRoot, name)), + ]); + if (sourceFingerprint !== targetFingerprint) modified.push(name); + } + const bundledSet = new Set(bundledNames); + const obsolete = [...targetDirectories] + .filter((name) => name.startsWith(MANAGED_PREFIX) && !bundledSet.has(name)) + .sort(); + const installed = bundledNames.filter((name) => targetDirectories.has(name)).length; + return { + bundled: bundledNames.length, + installed, + missing, + modified, + obsolete, + current: missing.length === 0 && modified.length === 0 && obsolete.length === 0, + }; +} + async function copyDir(src: string, dest: string): Promise { const entries = await fs.readdir(src, { withFileTypes: true }); await fs.mkdir(dest, { recursive: true }); diff --git a/src/bin/ssg.ts b/src/bin/ssg.ts index 0fbd362..cc9d7ad 100644 --- a/src/bin/ssg.ts +++ b/src/bin/ssg.ts @@ -25,6 +25,7 @@ interface ParsedSsgArgs { } interface LoadedConfig { + routes?: unknown[]; registry?: unknown; seed?: unknown; dataOverrides?: unknown; @@ -64,6 +65,7 @@ interface SsgDeps { existsSync?: typeof existsSync; importConfig?: (filePath: string) => Promise; createStaticGen?: (options: { + routes?: unknown[]; registry?: unknown; outputDir: string; seed?: unknown; @@ -310,6 +312,7 @@ export async function runSsgCli( const configModule = imported as { default?: LoadedConfig; staticConfig?: LoadedConfig; + routes?: unknown[]; registry?: unknown; seed?: unknown; dataOverrides?: unknown; @@ -320,10 +323,11 @@ export async function runSsgCli( sitemap?: SitemapConfig | false; }; const candidate = configModule.default ?? configModule.staticConfig ?? configModule; + const hasRoutes = Array.isArray(candidate.routes); const hasRegistry = candidate.registry !== undefined; - if (!hasRegistry || Object.prototype.hasOwnProperty.call(candidate, "routes")) { - io.error("Error: Config must provide a route registry and no raw routes array"); + if (hasRoutes === hasRegistry) { + io.error("Error: Config must provide exactly one route source: routes or registry"); return 1; } const config = candidate as LoadedConfig; @@ -333,7 +337,11 @@ export async function runSsgCli( return 1; } - io.log("Generating registered routes..."); + io.log( + hasRoutes + ? `Generating ${config.routes?.length ?? 0} routes...` + : "Generating registered routes...", + ); const createStaticGen = typeof resolvedDeps.createStaticGen === "function" @@ -350,7 +358,7 @@ export async function runSsgCli( const generationOutputDir = cliStagingDir; const ssg = createStaticGen({ - registry: config.registry, + ...(hasRoutes ? { routes: config.routes } : { registry: config.registry }), outputDir: generationOutputDir, seed: config.seed, dataOverrides: config.dataOverrides, diff --git a/src/guardrails/runner.ts b/src/guardrails/runner.ts new file mode 100644 index 0000000..e24507b --- /dev/null +++ b/src/guardrails/runner.ts @@ -0,0 +1,385 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import semver from "semver"; +import { analysisHasBlockingFindings, runAnalysis } from "../analyze/runner"; +import type { AnalyzeReport } from "../analyze/types"; +import { inspectBundledSkills } from "../bin/skills"; +import { discoverWorkspaceProject } from "../update/discovery"; +import { + GUARDRAIL_SCHEMA_VERSION, + type CheckReport, + type DoctorReport, + type GuardrailFinding, + type GuardrailSummary, + type RepairReport, + type ScriptResult, +} from "./types"; + +const SUPPORTED_NODE_RANGE = "^20.19.0 || >=22.12.0"; +const VALIDATION_SCRIPTS = ["lint", "typecheck", "test", "build"] as const; + +export interface GuardrailOptions { + readonly cwd: string; + readonly workspacePatterns: readonly string[]; +} + +export interface GuardrailRuntime { + readonly nodeVersion?: string; + readonly runScript?: ( + executable: string, + args: readonly string[], + cwd: string, + ) => Promise>; +} + +function summary(findings: readonly GuardrailFinding[]): GuardrailSummary { + return { + passed: findings.filter((entry) => entry.status === "pass").length, + warnings: findings.filter((entry) => entry.status === "warning").length, + errors: findings.filter((entry) => entry.status === "error").length, + }; +} + +function dependencyRecord( + manifest: Record, + section: string, +): Record { + const value = manifest[section]; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function hasAskrDependency(manifest: Record): boolean { + return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].some( + (section) => "@askrjs/askr" in dependencyRecord(manifest, section), + ); +} + +async function existingLockfiles(root: string): Promise { + const candidates = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"]; + const present = await Promise.all( + candidates.map(async (name) => ({ + name, + exists: Boolean(await fs.stat(path.join(root, name)).catch(() => null)), + })), + ); + return present.filter((entry) => entry.exists).map((entry) => entry.name); +} + +function declaredPackageManager(manifest: Record): string | null { + if (typeof manifest.packageManager !== "string") return null; + const match = /^(npm|pnpm|yarn|bun)@/.exec(manifest.packageManager); + return match?.[1] ?? null; +} + +function lockfilePackageManager(lockfile: string): string { + if (lockfile === "package-lock.json") return "npm"; + if (lockfile === "pnpm-lock.yaml") return "pnpm"; + if (lockfile === "yarn.lock") return "yarn"; + return "bun"; +} + +function packageManagerFinding( + lockfiles: readonly string[], + manifest: Record, +): GuardrailFinding { + const declared = declaredPackageManager(manifest); + if (lockfiles.length > 1) { + return { + id: "askr/doctor-package-manager", + status: "error", + message: `Multiple package-manager lockfiles are present: ${lockfiles.join(", ")}.`, + remediation: "Keep the lockfile for the project's canonical package manager.", + }; + } + if (lockfiles.length === 0) { + return { + id: "askr/doctor-package-manager", + status: "warning", + message: "No package-manager lockfile is present.", + remediation: "Install dependencies with the project's chosen package manager.", + }; + } + const detected = lockfilePackageManager(lockfiles[0]); + if (declared && declared !== detected) { + return { + id: "askr/doctor-package-manager", + status: "error", + message: `packageManager declares ${declared}, but ${lockfiles[0]} belongs to ${detected}.`, + remediation: "Align packageManager and the committed lockfile.", + }; + } + return { + id: "askr/doctor-package-manager", + status: "pass", + message: `Package manager is ${declared ?? detected} (${lockfiles[0]}).`, + }; +} + +async function skillsFinding(root: string): Promise { + const status = await inspectBundledSkills({ cwd: root }); + if (status.installed === 0) { + return { + id: "askr/doctor-agent-guidance", + status: "warning", + message: "Project-local Askr agent skills are not installed.", + remediation: "Run `askr skills install`, or `askr skills sync` for an existing project.", + }; + } + if (!status.current) { + const differences = [ + status.missing.length > 0 ? `${status.missing.length} missing` : "", + status.modified.length > 0 ? `${status.modified.length} modified` : "", + status.obsolete.length > 0 ? `${status.obsolete.length} obsolete` : "", + ].filter(Boolean); + return { + id: "askr/doctor-agent-guidance", + status: "warning", + message: `Project-local Askr agent skills are stale (${differences.join(", ")}).`, + remediation: "Run `askr skills sync` to restore the current bundled guidance.", + }; + } + return { + id: "askr/doctor-agent-guidance", + status: "pass", + message: `${status.bundled} project-local Askr agent skill(s) are current.`, + }; +} + +function analysisFinding(analysis: AnalyzeReport): GuardrailFinding { + if (analysis.summary.errors > 0) { + return { + id: "askr/doctor-analysis", + status: "error", + message: `Static analysis found ${analysis.summary.errors} error(s) and ${analysis.summary.warnings} warning(s).`, + remediation: "Run `askr repair`, review remaining findings, then run `askr check`.", + }; + } + if (analysis.summary.warnings > 0) { + return { + id: "askr/doctor-analysis", + status: "warning", + message: `Static analysis found ${analysis.summary.warnings} warning(s).`, + remediation: "Review the reported performance or lifecycle guidance.", + }; + } + return { + id: "askr/doctor-analysis", + status: "pass", + message: `Static analysis is clean across ${analysis.workspaces.length} workspace(s).`, + }; +} + +export async function runDoctor( + options: GuardrailOptions, + runtime: GuardrailRuntime = {}, +): Promise { + const project = await discoverWorkspaceProject({ + cwd: options.cwd, + workspacePatterns: [...options.workspacePatterns], + }); + const analysis = await runAnalysis({ + cwd: options.cwd, + workspacePatterns: [...options.workspacePatterns], + check: true, + }); + const rootWorkspace = project.workspaces.find((workspace) => workspace.isRoot); + if (!rootWorkspace) throw new Error("Discovered project is missing its root workspace."); + const nodeVersion = runtime.nodeVersion ?? process.versions.node; + const findings: GuardrailFinding[] = [ + semver.satisfies(nodeVersion, SUPPORTED_NODE_RANGE) + ? { + id: "askr/doctor-node", + status: "pass", + message: `Node ${nodeVersion} satisfies ${SUPPORTED_NODE_RANGE}.`, + } + : { + id: "askr/doctor-node", + status: "error", + message: `Node ${nodeVersion} does not satisfy ${SUPPORTED_NODE_RANGE}.`, + remediation: `Install a Node version matching ${SUPPORTED_NODE_RANGE}.`, + }, + packageManagerFinding(await existingLockfiles(project.root), rootWorkspace.manifest), + ]; + + const sourceWorkspaces = new Set( + analysis.workspaces + .filter((workspace) => workspace.files > 0) + .map((workspace) => workspace.name), + ); + const missingFramework = project.selectedWorkspaces.filter( + (workspace) => sourceWorkspaces.has(workspace.name) && !hasAskrDependency(workspace.manifest), + ); + findings.push( + missingFramework.length === 0 + ? { + id: "askr/doctor-framework-dependency", + status: "pass", + message: "Source workspaces declare @askrjs/askr.", + } + : { + id: "askr/doctor-framework-dependency", + status: "error", + message: `Source workspace(s) do not declare @askrjs/askr: ${missingFramework + .map((workspace) => workspace.name) + .join(", ")}.`, + remediation: "Declare @askrjs/askr in each application workspace.", + }, + ); + findings.push(await skillsFinding(project.root), analysisFinding(analysis)); + return { + schemaVersion: GUARDRAIL_SCHEMA_VERSION, + command: "doctor", + root: project.root, + findings, + analysis, + summary: summary(findings), + }; +} + +function scriptsFromManifest(manifest: Record): Record { + const scripts = manifest.scripts; + if (!scripts || typeof scripts !== "object" || Array.isArray(scripts)) return {}; + return Object.fromEntries( + Object.entries(scripts).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +} + +async function defaultRunScript( + executable: string, + args: readonly string[], + cwd: string, +): Promise> { + return new Promise((resolve, reject) => { + const child = spawn(executable, [...args], { + cwd, + env: { ...process.env, NO_COLOR: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + resolve({ + status: code === 0 ? "passed" : "failed", + exitCode: code, + stdout, + stderr, + }); + }); + }); +} + +function managerCommand( + lockfiles: readonly string[], + manifest: Record, +): { executable: string; args: (script: string) => string[] } { + const executable = + declaredPackageManager(manifest) ?? + (lockfiles.length === 1 ? lockfilePackageManager(lockfiles[0]) : "npm"); + return { executable, args: (script) => ["run", script] }; +} + +export async function runCheck( + options: GuardrailOptions, + runtime: GuardrailRuntime = {}, +): Promise { + const project = await discoverWorkspaceProject({ + cwd: options.cwd, + workspacePatterns: [...options.workspacePatterns], + }); + const rootWorkspace = project.workspaces.find((workspace) => workspace.isRoot); + if (!rootWorkspace) throw new Error("Discovered project is missing its root workspace."); + const analysis = await runAnalysis({ + cwd: options.cwd, + workspacePatterns: [...options.workspacePatterns], + check: true, + }); + const scripts = scriptsFromManifest(rootWorkspace.manifest); + const selected = VALIDATION_SCRIPTS.filter((name) => scripts[name]); + const lockfiles = await existingLockfiles(project.root); + const manager = managerCommand(lockfiles, rootWorkspace.manifest); + const results: ScriptResult[] = []; + + if (analysisHasBlockingFindings(analysis)) { + for (const name of selected) { + results.push({ + name, + status: "skipped", + command: `${manager.executable} run ${name}`, + exitCode: null, + stdout: "", + stderr: "", + reason: "static analysis must pass first", + }); + } + } else { + for (const [index, name] of selected.entries()) { + const args = manager.args(name); + const result = await (runtime.runScript ?? defaultRunScript)( + manager.executable, + args, + project.root, + ); + results.push({ + name, + command: [manager.executable, ...args].join(" "), + ...result, + }); + if (result.status === "failed") { + for (const skipped of selected.slice(index + 1)) { + results.push({ + name: skipped, + status: "skipped", + command: `${manager.executable} run ${skipped}`, + exitCode: null, + stdout: "", + stderr: "", + reason: `${name} failed`, + }); + } + break; + } + } + } + + const failed = + analysisHasBlockingFindings(analysis) || results.some((entry) => entry.status === "failed"); + return { + schemaVersion: GUARDRAIL_SCHEMA_VERSION, + command: "check", + root: project.root, + analysis, + scripts: results, + status: failed ? "failed" : "passed", + }; +} + +export async function runRepair(options: GuardrailOptions): Promise { + const analysis = await runAnalysis({ + cwd: options.cwd, + workspacePatterns: [...options.workspacePatterns], + check: false, + }); + const needsReview = analysisHasBlockingFindings(analysis); + return { + schemaVersion: GUARDRAIL_SCHEMA_VERSION, + command: "repair", + root: analysis.root, + analysis, + status: needsReview ? "needs-review" : "clean", + nextAction: needsReview + ? "Review the remaining semantic diagnostics, then run `askr repair` again." + : "Run `askr check` to execute the full validation path.", + }; +} diff --git a/src/guardrails/types.ts b/src/guardrails/types.ts new file mode 100644 index 0000000..c8a0d66 --- /dev/null +++ b/src/guardrails/types.ts @@ -0,0 +1,55 @@ +import type { AnalyzeReport } from "../analyze/types"; + +export const GUARDRAIL_SCHEMA_VERSION = 1; + +export type GuardrailStatus = "pass" | "warning" | "error"; + +export interface GuardrailFinding { + readonly id: string; + readonly status: GuardrailStatus; + readonly message: string; + readonly remediation?: string; +} + +export interface GuardrailSummary { + readonly passed: number; + readonly warnings: number; + readonly errors: number; +} + +export interface DoctorReport { + readonly schemaVersion: typeof GUARDRAIL_SCHEMA_VERSION; + readonly command: "doctor"; + readonly root: string; + readonly findings: readonly GuardrailFinding[]; + readonly analysis: AnalyzeReport; + readonly summary: GuardrailSummary; +} + +export interface ScriptResult { + readonly name: string; + readonly status: "passed" | "failed" | "skipped"; + readonly command: string; + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; + readonly reason?: string; +} + +export interface CheckReport { + readonly schemaVersion: typeof GUARDRAIL_SCHEMA_VERSION; + readonly command: "check"; + readonly root: string; + readonly analysis: AnalyzeReport; + readonly scripts: readonly ScriptResult[]; + readonly status: "passed" | "failed"; +} + +export interface RepairReport { + readonly schemaVersion: typeof GUARDRAIL_SCHEMA_VERSION; + readonly command: "repair"; + readonly root: string; + readonly analysis: AnalyzeReport; + readonly status: "clean" | "needs-review"; + readonly nextAction: string; +} diff --git a/src/update/discovery.ts b/src/update/discovery.ts index f737374..179c63c 100644 --- a/src/update/discovery.ts +++ b/src/update/discovery.ts @@ -9,6 +9,7 @@ import { DEPENDENCY_SECTIONS, type DependencyOccurrence, type DiscoveredProject, + type DiscoveredWorkspaceProject, type UpdatePolicy, type WorkspaceManifest, } from "./types"; @@ -417,7 +418,9 @@ function collectOccurrences( return occurrences; } -export async function discoverProject(options: DiscoveryOptions): Promise { +export async function discoverWorkspaceProject( + options: Pick, +): Promise { const root = await findProjectRoot(options.cwd); const { policy, workspaces } = await discoverWorkspaces(root); const selectedWorkspaces = @@ -427,6 +430,11 @@ export async function discoverProject(options: DiscoveryOptions): Promise { + const { root, workspaces, selectedWorkspaces, policy } = await discoverWorkspaceProject(options); const localNames = new Set(workspaces.map((workspace) => workspace.name)); const localVersions = new Map( diff --git a/src/update/planner.ts b/src/update/planner.ts index e328b15..f00271f 100644 --- a/src/update/planner.ts +++ b/src/update/planner.ts @@ -21,6 +21,8 @@ interface PlannerOptions { tags?: Record; cliTag?: string; mode?: PlannerMode; + /** @deprecated Use mode. Retained for callers compiled against the original planner. */ + force?: boolean; localVersions?: ReadonlyMap; } @@ -403,8 +405,8 @@ function solveWorkspace( const signature = component .map((name) => { const assignedVersion = assigned.get(name); - if (assignedVersion !== undefined) return `${name}=assigned:${assignedVersion}`; - return `${name}=domain:${(pruned.get(name) ?? []).join(",")}`; + const domain = pruned.get(name) ?? []; + return `${name}:assigned=${JSON.stringify(assignedVersion ?? null)}:domain=${JSON.stringify(domain)}`; }) .join("|"); if (memo.has(signature)) return; @@ -476,7 +478,7 @@ function summarize(decisions: PackageDecision[]): UpdateSummary { export function planUpdates(options: PlannerOptions): UpdatePlan { const failures = options.failures ?? new Map(); const tags = options.tags ?? {}; - const mode: PlannerMode = options.mode ?? "update"; + const mode: PlannerMode = options.mode ?? (options.force ? "upgrade" : "update"); const context = options.contextOccurrences ?? options.occurrences; const workspaceSolutions = new Map>(); if (mode !== "force") { diff --git a/src/update/types.ts b/src/update/types.ts index a968c7c..67149ef 100644 --- a/src/update/types.ts +++ b/src/update/types.ts @@ -45,6 +45,13 @@ export interface DiscoveredProject { localVersions: Map; } +export interface DiscoveredWorkspaceProject { + root: string; + workspaces: WorkspaceManifest[]; + selectedWorkspaces: WorkspaceManifest[]; + policy: UpdatePolicy; +} + export interface Packument { "dist-tags"?: Record; versions?: Record; diff --git a/templates/full-stack/AGENTS.md b/templates/full-stack/AGENTS.md index 0268055..a213444 100644 --- a/templates/full-stack/AGENTS.md +++ b/templates/full-stack/AGENTS.md @@ -9,3 +9,9 @@ This project is the progressive full-stack stage of an Askr application. - Keep `index.html` as the only document source. Preserve exactly one `` and one `` marker. - Use native forms first; enhanced submission must preserve the same validation and authorization behavior. - Never log cookies, authorization values, tokens, form fields, request bodies, or personal data. + +## Recovery and completion + +- Run `askr repair` after analyzer failures; it applies only safe mechanical fixes. +- Resolve remaining semantic diagnostics deliberately. +- Run `npm run check` before declaring work complete. It requires clean Askr analysis, then runs lint, typecheck, tests, and build. diff --git a/templates/full-stack/package.json b/templates/full-stack/package.json index 74386b8..0dc5242 100644 --- a/templates/full-stack/package.json +++ b/templates/full-stack/package.json @@ -10,8 +10,9 @@ "test": "vp test run", "typecheck": "tsc --noEmit", "lint": "vp lint src server.ts tests vite.config.ts", + "analyze": "askr analyze --check", "fmt": "vp fmt .", - "check": "npm run lint && npm run typecheck && npm test && npm run build" + "check": "askr check" }, "dependencies": { "@askrjs/askr": ">=0.0.53 <0.1.0", diff --git a/templates/spa/AGENTS.md b/templates/spa/AGENTS.md index 1d68cf7..a83a07f 100644 --- a/templates/spa/AGENTS.md +++ b/templates/spa/AGENTS.md @@ -16,7 +16,7 @@ npm run fmt # Prettier ## Architecture -- **Routing:** `src/main.tsx` imports `src/pages/_routes.tsx`, then boots `createSPA()` with the route manifest. Route branches live under `src/pages/public`, `src/pages/auth`, and `src/pages/app`. +- **Routing:** `src/main.tsx` imports the `pageRegistry` from `src/pages/_routes.tsx`, then passes it to `createSPA()`. Route branches live under `src/pages/public`, `src/pages/auth`, and `src/pages/app`. - **Layouts:** `_layout.tsx` files own shells. The root layout owns `ThemeScope`; public layouts own landing chrome, auth layouts own sign-in chrome, and app layouts own authenticated sidebar chrome. - **UI:** Prefer the `@askrjs/themes/components` catalog before writing local components. Use app-local components only for product concepts such as `MetricCard` and `StatusBadge`; keep charts in `@askrjs/charts`. - **State:** `const [value, setValue] = state(initial)`. Read with `value()`, update with `setValue(...)`. Use `derive()` for computed values and `resource()` for async data. @@ -58,3 +58,9 @@ tests/ - Use `Link` and `navigate` from `@askrjs/askr/router`. - Use headless `@askrjs/ui/*` for behavior primitives and `@askrjs/themes/*` for composed visual surfaces. - Avoid hardcoded color systems, custom component catalogs, and React habits like effect-driven data loading. + +## Recovery and completion + +- Run `askr repair` after analyzer failures; it applies only safe mechanical fixes. +- Resolve remaining semantic diagnostics deliberately. +- Run `npm run check` before declaring work complete. It requires clean Askr analysis, then runs lint, typecheck, tests, and build. diff --git a/templates/spa/README.md b/templates/spa/README.md index 78b7b33..9713624 100644 --- a/templates/spa/README.md +++ b/templates/spa/README.md @@ -16,7 +16,7 @@ npm test # Run tests with Vitest ``` src/ -|-- main.tsx # SPA boot and route manifest +|-- main.tsx # SPA boot and route registry |-- pages/ | |-- _routes.tsx # Top-level route branches | |-- _layout.tsx # Theme provider and app root diff --git a/templates/spa/package.json b/templates/spa/package.json index bf94343..0ed29bd 100644 --- a/templates/spa/package.json +++ b/templates/spa/package.json @@ -11,8 +11,9 @@ "typecheck": "tsc --noEmit", "lint": "vp lint .", "lint:fix": "npm run lint -- --fix", + "analyze": "askr analyze --check", "fmt": "vp fmt .", - "check": "npm run lint && npm run typecheck && npm test && npm run build" + "check": "askr check" }, "dependencies": { "@askrjs/askr": ">=0.0.53 <0.1.0", diff --git a/templates/spa/src/main.tsx b/templates/spa/src/main.tsx index 5e8f87a..8f8c69f 100644 --- a/templates/spa/src/main.tsx +++ b/templates/spa/src/main.tsx @@ -1,7 +1,7 @@ import { createSPA } from '@askrjs/askr/boot'; -import { pageRegistry } from './pages/_routes'; import './styles.css'; +import { pageRegistry } from './pages/_routes'; await createSPA({ root: document.getElementById('app')!, diff --git a/templates/ssg/AGENTS.md b/templates/ssg/AGENTS.md index e052d57..05202b6 100644 --- a/templates/ssg/AGENTS.md +++ b/templates/ssg/AGENTS.md @@ -53,3 +53,9 @@ tests/ # Vitest tests - Use askr-ui components for interactive elements and askr-themes layout/shell primitives for structure - Style with `--ak-*` tokens - Keep SSG routes synchronous at render time unless you intentionally wire SSR data into generation + +## Recovery and completion + +- Run `askr repair` after analyzer failures; it applies only safe mechanical fixes. +- Resolve remaining semantic diagnostics deliberately. +- Run `npm run check` before declaring work complete. It requires clean Askr analysis, then runs lint, typecheck, tests, and build. diff --git a/templates/ssg/package.json b/templates/ssg/package.json index a7161ec..a3be325 100644 --- a/templates/ssg/package.json +++ b/templates/ssg/package.json @@ -13,8 +13,9 @@ "typecheck": "tsc --noEmit", "lint": "vp lint .", "lint:fix": "npm run lint -- --fix", + "analyze": "askr analyze --check", "fmt": "vp fmt .", - "check": "npm run lint && npm run typecheck && npm test && npm run build" + "check": "askr check" }, "dependencies": { "@askrjs/askr": ">=0.0.53 <0.1.0", diff --git a/templates/ssg/src/pages/example.tsx b/templates/ssg/src/pages/example.tsx index 0d50fde..dab2f22 100644 --- a/templates/ssg/src/pages/example.tsx +++ b/templates/ssg/src/pages/example.tsx @@ -1,6 +1,6 @@ import { state } from '@askrjs/askr'; import { Link } from '@askrjs/askr/router'; -import { Button } from '@askrjs/ui'; +import { Button, Input } from '@askrjs/ui'; import Counter from '../components/counter'; import { ActionRow, @@ -65,8 +65,8 @@ export default function Preview() { description="Change the user id to prove the generated page still responds in the browser." >
- { const nextValue = Number.parseInt( diff --git a/templates/ssr/AGENTS.md b/templates/ssr/AGENTS.md index f7a9db4..4767df9 100644 --- a/templates/ssr/AGENTS.md +++ b/templates/ssr/AGENTS.md @@ -26,3 +26,9 @@ npm run fmt ``` Use askr-ui primitives for interactive controls, `--ak-*` theme tokens for styling, and preserve the shared server/browser route registry. + +## Recovery and completion + +- Run `askr repair` after analyzer failures; it applies only safe mechanical fixes. +- Resolve remaining semantic diagnostics deliberately. +- Run `npm run check` before declaring work complete. It requires clean Askr analysis, then runs lint, typecheck, tests, and build. diff --git a/templates/ssr/package.json b/templates/ssr/package.json index d0d8ec6..c251190 100644 --- a/templates/ssr/package.json +++ b/templates/ssr/package.json @@ -10,8 +10,9 @@ "typecheck": "tsc --noEmit", "lint": "vp lint src server.ts tests vite.config.ts", "lint:fix": "npm run lint -- --fix", + "analyze": "askr analyze --check", "fmt": "vp fmt \"src/**/*.{ts,tsx,css}\" \"server.ts\" \"tests/**/*.{ts,tsx}\" \"*.md\"", - "check": "npm run lint && npm run typecheck && npm test && npm run build" + "check": "askr check" }, "dependencies": { "@askrjs/askr": ">=0.0.53 <0.1.0", diff --git a/templates/startkit/AGENTS.md b/templates/startkit/AGENTS.md index 970f7e0..d4abbdd 100644 --- a/templates/startkit/AGENTS.md +++ b/templates/startkit/AGENTS.md @@ -75,3 +75,6 @@ tests/ # Vitest tests - Run `npm run typecheck` for type-sensitive changes. - Run `npm run build` when app boot, routing, or packaging changes. - Confirm loading, empty, error, stale, and pending states remain truthful for user-visible flows. +- Run `askr repair` after analyzer failures; it applies only safe mechanical fixes. +- Resolve remaining semantic diagnostics deliberately. +- Run `npm run check` before declaring work complete. It requires clean Askr analysis, then runs lint, typecheck, tests, and build. diff --git a/templates/startkit/package.json b/templates/startkit/package.json index 35cc2c1..15e5f8d 100644 --- a/templates/startkit/package.json +++ b/templates/startkit/package.json @@ -11,9 +11,10 @@ "typecheck": "tsc --noEmit", "lint": "vp lint .", "lint:fix": "npm run lint -- --fix", + "analyze": "askr analyze --check", "fmt": "vp fmt .", "fmt:check": "vp fmt --check .", - "check": "npm run lint && npm run typecheck && npm test && npm run build" + "check": "askr check" }, "dependencies": { "@askrjs/askr": ">=0.0.53 <0.1.0", diff --git a/templates/startkit/src/routes/auth.ts b/templates/startkit/src/routes/auth.ts index 94ab410..bb7ace4 100644 --- a/templates/startkit/src/routes/auth.ts +++ b/templates/startkit/src/routes/auth.ts @@ -1,9 +1,8 @@ import { lazy, route } from '@askrjs/askr/router'; -import { loginRoute } from '../lib/routes'; export function registerAuthRoutes(): void { route( - loginRoute.href, + '/login', lazy(() => import('../pages/auth/login')) ); } diff --git a/templates/startkit/src/routes/public.ts b/templates/startkit/src/routes/public.ts index 6b433c8..fb3ec9e 100644 --- a/templates/startkit/src/routes/public.ts +++ b/templates/startkit/src/routes/public.ts @@ -1,9 +1,8 @@ import { lazy, route } from '@askrjs/askr/router'; -import { landingRoute } from '../lib/routes'; export function registerPublicRoutes(): void { route( - landingRoute.href, + '/', lazy(() => import('../pages/home')) ); } diff --git a/templates/startkit/src/routes/workspace/accounts.ts b/templates/startkit/src/routes/workspace/accounts.ts index f9f1c1e..2421eda 100644 --- a/templates/startkit/src/routes/workspace/accounts.ts +++ b/templates/startkit/src/routes/workspace/accounts.ts @@ -1,9 +1,8 @@ import { lazy, route } from '@askrjs/askr/router'; -import { accountsRoute } from '../../lib/routes'; export function registerAccountRoutes(): void { route( - accountsRoute.href, + '/accounts', lazy(() => import('../../pages/workspace/accounts')) ); } diff --git a/templates/startkit/src/routes/workspace/index.ts b/templates/startkit/src/routes/workspace/index.ts index 9c680a0..a37fa24 100644 --- a/templates/startkit/src/routes/workspace/index.ts +++ b/templates/startkit/src/routes/workspace/index.ts @@ -1,15 +1,14 @@ import { lazy, route } from '@askrjs/askr/router'; -import { dashboardRoute, settingsRoute } from '../../lib/routes'; import { registerAccountRoutes } from './accounts'; export function registerWorkspaceRoutes(): void { route( - dashboardRoute.href, + '/dashboard', lazy(() => import('../../pages/workspace/dashboard')) ); registerAccountRoutes(); route( - settingsRoute.href, + '/settings', lazy(() => import('../../pages/workspace/settings')) ); } diff --git a/tests/analyze.cli.test.ts b/tests/analyze.cli.test.ts new file mode 100644 index 0000000..12b23a9 --- /dev/null +++ b/tests/analyze.cli.test.ts @@ -0,0 +1,257 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runAnalysis } from "../src/analyze/runner"; +import { parseAnalyzeArgs, runAnalyzeCli } from "../src/bin/analyze"; +import { runCli } from "../src/bin/cli"; +import { writeFileChanges } from "../src/file-changes"; + +const roots: string[] = []; + +function io() { + const logs: string[] = []; + const errors: string[] = []; + return { + value: { + log: (...values: unknown[]) => logs.push(values.join(" ")), + error: (...values: unknown[]) => errors.push(values.join(" ")), + }, + logs, + errors, + }; +} + +async function workspaceFixture(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-analyze-cli-")); + roots.push(root); + await fs.writeFile( + path.join(root, "package.json"), + `${JSON.stringify({ name: "root", workspaces: ["packages/*"] }, null, 2)}\n`, + ); + for (const name of ["a", "b"]) { + const directory = path.join(root, "packages", name); + await fs.mkdir(path.join(directory, "src"), { recursive: true }); + await fs.writeFile( + path.join(directory, "package.json"), + `${JSON.stringify({ name, dependencies: { "@askrjs/askr": "^0.0.70" } }, null, 2)}\n`, + ); + await fs.writeFile( + path.join(directory, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + jsx: "react-jsx", + jsxImportSource: "@askrjs/askr", + module: "ESNext", + moduleResolution: "Bundler", + }, + include: ["src"], + }, + null, + 2, + )}\n`, + ); + await fs.writeFile( + path.join(directory, "src", "page.tsx"), + `import { state } from "@askrjs/askr";\nconst ${name} = state(0);\n`, + ); + } + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe("analyze CLI", () => { + it("parses repeated workspace filters and command options", () => { + expect( + parseAnalyzeArgs([ + "--cwd", + "./fixture", + "--workspace=a*", + "--workspace", + "b", + "--json", + "--check", + ]), + ).toMatchObject({ + cwd: path.resolve("./fixture"), + workspacePatterns: ["a*", "b"], + json: true, + check: true, + }); + expect(() => parseAnalyzeArgs(["--workspace"])).toThrow(/requires a value/); + expect(() => parseAnalyzeArgs(["--unknown"])).toThrow(/unknown option/i); + }); + + it("scans all workspaces by default and filters repeated selections deterministically", async () => { + const root = await workspaceFixture(); + const all = await runAnalysis({ cwd: root, workspacePatterns: [], check: true }); + const selected = await runAnalysis({ cwd: root, workspacePatterns: ["b"], check: true }); + + expect(all.discoveredWorkspaces).toEqual(["root", "a", "b"]); + expect(all.selectedWorkspaces).toEqual(["root", "a", "b"]); + expect(all.diagnostics.map((entry) => entry.workspace)).toEqual(["a", "b"]); + expect(selected.selectedWorkspaces).toEqual(["b"]); + expect(selected.diagnostics.map((entry) => entry.workspace)).toEqual(["b"]); + }); + + it("emits deterministic JSON and a blocking exit code", async () => { + const root = await workspaceFixture(); + const output = io(); + expect(await runAnalyzeCli(["--cwd", root, "--json", "--check"], output.value)).toBe(1); + expect(output.errors).toEqual([]); + const report = JSON.parse(output.logs[0]); + expect(report).toMatchObject({ + schemaVersion: 1, + selectedWorkspaces: ["root", "a", "b"], + summary: { errors: 2, diagnostics: 2 }, + }); + expect(output.logs[0]).toBe(JSON.stringify(report)); + }); + + it("dispatches through the unified CLI and prints human diagnostics", async () => { + const root = await workspaceFixture(); + const output = io(); + expect( + await runCli(["analyze", "--cwd", root, "--workspace", "a", "--check"], output.value), + ).toBe(1); + expect(output.logs.join("\n")).toContain("a:src/page.tsx:2"); + expect(output.logs.at(-1)).toMatch(/Analyzed 1 workspace/); + }); + + it("keeps check mode immutable and applies a safe config fix transactionally by default", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-analyze-fix-")); + roots.push(root); + await fs.mkdir(path.join(root, "src")); + await fs.writeFile( + path.join(root, "package.json"), + `${JSON.stringify( + { name: "fixture", dependencies: { "@askrjs/askr": "^0.0.70" } }, + null, + 2, + )}\n`, + ); + const tsconfigPath = path.join(root, "tsconfig.json"); + const before = `${JSON.stringify( + { + compilerOptions: { jsx: "react-jsx", module: "ESNext", moduleResolution: "Bundler" }, + include: ["src"], + }, + null, + 2, + )}\n`; + await fs.writeFile(tsconfigPath, before); + await fs.writeFile( + path.join(root, "src", "page.tsx"), + "export function Page() { return
; }\n", + ); + + const checked = await runAnalysis({ cwd: root, workspacePatterns: [], check: true }); + expect(checked.skippedFixes).toEqual([ + expect.objectContaining({ + ruleId: "askr/framework-config", + reason: expect.stringMatching(/check/), + }), + ]); + expect(await fs.readFile(tsconfigPath, "utf8")).toBe(before); + + const fixed = await runAnalysis({ cwd: root, workspacePatterns: [], check: false }); + expect(fixed.appliedFixes).toEqual([ + expect.objectContaining({ ruleId: "askr/framework-config" }), + ]); + expect(fixed.diagnostics).toEqual([]); + expect(JSON.parse(await fs.readFile(tsconfigPath, "utf8"))).toMatchObject({ + compilerOptions: { jsx: "react-jsx", jsxImportSource: "@askrjs/askr" }, + }); + }); + + it("applies safe route path fixes but leaves semantic collection findings unresolved", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-analyze-semantic-")); + roots.push(root); + await fs.mkdir(path.join(root, "src")); + await fs.writeFile( + path.join(root, "package.json"), + `${JSON.stringify({ name: "fixture" }, null, 2)}\n`, + ); + await fs.writeFile( + path.join(root, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + jsx: "react-jsx", + jsxImportSource: "@askrjs/askr", + module: "ESNext", + moduleResolution: "Bundler", + }, + include: ["src"], + }, + null, + 2, + )}\n`, + ); + const filePath = path.join(root, "src", "routes.tsx"); + await fs.writeFile( + filePath, + ` + import { state } from "@askrjs/askr"; + import { createRouteRegistry, route } from "@askrjs/askr/router"; + export const registry = createRouteRegistry(() => { + route("/users/:id", Page); + }); + function Page() { + const [items] = state([1, 2]); + return
{items().map((item) => {item})}
; + } + `, + ); + + const report = await runAnalysis({ cwd: root, workspacePatterns: [], check: false }); + expect(report.appliedFixes).toEqual([ + expect.objectContaining({ ruleId: "askr/route-path-syntax" }), + ]); + expect(report.diagnostics).toEqual([expect.objectContaining({ ruleId: "askr/prefer-for" })]); + expect(await fs.readFile(filePath, "utf8")).toContain('route("/users/{id}", Page)'); + }); + + it("does not mutate files when transactional writing fails", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-analyze-rollback-")); + roots.push(root); + await fs.mkdir(path.join(root, "src")); + await fs.writeFile( + path.join(root, "package.json"), + `${JSON.stringify( + { name: "fixture", dependencies: { "@askrjs/askr": "^0.0.70" } }, + null, + 2, + )}\n`, + ); + const tsconfigPath = path.join(root, "tsconfig.json"); + const before = '{"compilerOptions":{"jsx":"react-jsx"},"include":["src"]}\n'; + const routePath = path.join(root, "src", "routes.tsx"); + const routeBefore = ` + import { createRouteRegistry, route } from "@askrjs/askr/router"; + export const registry = createRouteRegistry(() => route("/users/:id", () => null)); + `; + await fs.writeFile(tsconfigPath, before); + await fs.writeFile(routePath, routeBefore); + let replacements = 0; + const writer = vi.fn(async (changes: Parameters[0]) => { + await writeFileChanges(changes, { + async replace(temporaryPath, filePath) { + replacements += 1; + if (replacements === 2) throw new Error("injected replacement failure"); + await fs.rename(temporaryPath, filePath); + }, + }); + }); + + await expect( + runAnalysis({ cwd: root, workspacePatterns: [], check: false, writer }), + ).rejects.toThrow("completed changes were rolled back"); + expect(await fs.readFile(tsconfigPath, "utf8")).toBe(before); + expect(await fs.readFile(routePath, "utf8")).toBe(routeBefore); + }); +}); diff --git a/tests/analyze.rules.test.ts b/tests/analyze.rules.test.ts new file mode 100644 index 0000000..9ae5e30 --- /dev/null +++ b/tests/analyze.rules.test.ts @@ -0,0 +1,415 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createWorkspaceAnalysisContext, readAnalyzeConfiguration } from "../src/analyze/project"; +import { runAnalysis } from "../src/analyze/runner"; +import { discoverWorkspaceProject } from "../src/update/discovery"; + +const roots: string[] = []; + +async function fixture( + files: Record, + options: { + manifest?: Record; + tsconfig?: Record | null; + } = {}, +): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-analyze-rules-")); + roots.push(root); + const manifest = options.manifest ?? { + name: "fixture", + dependencies: { "@askrjs/askr": "^0.0.70" }, + }; + await fs.writeFile(path.join(root, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); + if (options.tsconfig !== null) { + const tsconfig = options.tsconfig ?? { + compilerOptions: { + jsx: "react-jsx", + jsxImportSource: "@askrjs/askr", + module: "ESNext", + moduleResolution: "Bundler", + target: "ES2022", + }, + include: ["src"], + }; + await fs.writeFile(path.join(root, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`); + } + for (const [relative, content] of Object.entries(files)) { + const filePath = path.join(root, relative); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); + } + return root; +} + +async function diagnostics( + root: string, + check = true, +): Promise>["diagnostics"]> { + return (await runAnalysis({ cwd: root, workspacePatterns: [], check })).diagnostics; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe("analyzer rules", () => { + it("recognizes canonical aliased and namespace imports without matching unrelated functions", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { state as cell } from "@askrjs/askr"; + import * as Askr from "@askrjs/askr"; + import { state } from "./unrelated"; + const moduleCell = cell(0); + const moduleResource = Askr.resource(async ({ signal }) => fetch("/api", { signal }), []); + state(); + `, + "src/unrelated.ts": "export function state() {}", + }); + + const found = await diagnostics(root); + expect(found.filter((entry) => entry.ruleId === "askr/stable-render-call")).toHaveLength(2); + expect(found.every((entry) => entry.file === "src/page.tsx")).toBe(true); + }); + + it("reports unstable render calls and invalid state reads and writes", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { state, derive } from "@askrjs/askr"; + export function Page(props: { enabled: boolean }) { + const [count, setCount] = state(0); + if (props.enabled) derive(() => count()); + setCount(); + return ; + } + `, + }); + + const found = await diagnostics(root); + expect(found.map((entry) => entry.ruleId)).toEqual( + expect.arrayContaining(["askr/stable-render-call", "askr/state-access", "askr/state-access"]), + ); + expect(found.find((entry) => /conditionally/.test(entry.message))).toMatchObject({ + line: 5, + severity: "error", + }); + }); + + it("checks resource cancellation and stable dependencies while accepting forwarded signals", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { resource } from "@askrjs/askr/resources"; + export function Bad() { + resource(() => fetch("/api"), [{}]); + return
; + } + export function Good() { + resource(({ signal }) => fetch("/api", { signal }), ["users"]); + return
; + } + `, + }); + + const found = await diagnostics(root); + expect(found.filter((entry) => entry.ruleId === "askr/resource-cancellation")).toHaveLength(1); + expect(found.filter((entry) => entry.ruleId === "askr/stable-dependencies")).toHaveLength(1); + }); + + it("checks For contracts, positional keys, and only reactive JSX map calls", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { For, state } from "@askrjs/askr"; + const staticItems = [1, 2, 3].map((value) => value * 2); + export function Page() { + const [items] = state([{ id: "a" }]); + return
+ {items().map((item) =>
{item.id}
)} + {staticItems.map((item) =>
{item}
)} + {(item) =>
{item.id}
}
+ index}>{(item) =>
{item.id}
}
+ item.id} byIndex>{(item) =>
{item.id}
}
+
; + } + `, + }); + + const found = await diagnostics(root); + expect(found.filter((entry) => entry.ruleId === "askr/prefer-for")).toHaveLength(1); + expect(found.filter((entry) => entry.ruleId === "askr/for-contract")).toHaveLength(2); + expect(found.filter((entry) => entry.ruleId === "askr/stable-key")).toHaveLength(1); + }); + + it("reports async components, bad boot wiring, and SSR browser globals", async () => { + const root = await fixture({ + "src/client.tsx": ` + import { createSPA } from "@askrjs/askr/boot"; + async function AsyncPage() { return
; } + createSPA({ root: "#app", routes: [] }); + `, + "src/entry-server.tsx": ` + import { renderToString } from "@askrjs/askr/ssr"; + export const html = renderToString(() =>
+ {document.title} + {typeof window === "undefined" ? null : window.location.href} +
); + `, + }); + + const found = await diagnostics(root); + expect(found.some((entry) => entry.ruleId === "askr/no-async-component")).toBe(true); + expect(found.filter((entry) => entry.ruleId === "askr/boot-registry")).toHaveLength(2); + expect(found.filter((entry) => entry.ruleId === "askr/ssr-browser-global")).toHaveLength(1); + }); + + it("checks route registry ownership, route syntax, controls, and data cancellation", async () => { + const root = await fixture({ + "src/routes.tsx": ` + import { Case, Match, Show } from "@askrjs/askr"; + import { createMutation, createQuery } from "@askrjs/askr/data"; + import { createRouteRegistry, route } from "@askrjs/askr/router"; + route("/outside", () =>
); + export const registry = createRouteRegistry(async () => { + route("/users/:id", () => user); + }); + export function Page() { + createQuery({ key: "users", fetch: () => fetch("/api/users") }); + createMutation({ action: () => fetch("/api/users", { method: "POST" }) }); + return ok; + } + const namedDefinition = () => { + route("/named", () =>
); + route("/async", async () =>
); + }; + export const namedRegistry = createRouteRegistry(namedDefinition); + `, + }); + + const found = await diagnostics(root); + expect(found.filter((entry) => entry.ruleId === "askr/route-registry")).toHaveLength(2); + expect(found.filter((entry) => entry.ruleId === "askr/route-path-syntax")).toHaveLength(1); + expect(found.filter((entry) => entry.ruleId === "askr/control-contract")).toHaveLength(3); + expect(found.filter((entry) => entry.ruleId === "askr/data-cancellation")).toHaveLength(2); + expect(found.filter((entry) => entry.ruleId === "askr/no-async-component")).toHaveLength(1); + }); + + it("reports state writes during render but accepts event-handler writes", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { state } from "@askrjs/askr"; + export function Page() { + const [count, setCount] = state(0); + count.set(1); + return ; + } + `, + }); + + const found = await diagnostics(root); + expect(found.filter((entry) => entry.ruleId === "askr/state-render-write")).toHaveLength(1); + }); + + it("reports malformed source and analyzes JavaScript without a tsconfig", async () => { + const root = await fixture( + { + "src/broken.ts": "export function broken( {", + "src/module.js": 'import { state } from "@askrjs/askr"; state(0);', + }, + { tsconfig: null }, + ); + + const found = await diagnostics(root); + expect(found.some((entry) => entry.ruleId === "askr/parse-error")).toBe(true); + expect( + found.some( + (entry) => entry.ruleId === "askr/stable-render-call" && entry.file === "src/module.js", + ), + ).toBe(true); + }); + + it("keeps dependency declaration graphs out of analysis programs", async () => { + const root = await fixture({ + "src/page.ts": 'import type { Huge } from "huge-package"; export type Page = Huge;', + "node_modules/huge-package/package.json": JSON.stringify({ + name: "huge-package", + types: "index.d.ts", + }), + "node_modules/huge-package/index.d.ts": + "export interface Huge { value: string; nested: Nested }", + }); + const project = await discoverWorkspaceProject({ cwd: root, workspacePatterns: [] }); + const configuration = readAnalyzeConfiguration(project.workspaces[0].manifest); + const { context } = await createWorkspaceAnalysisContext( + root, + project.workspaces[0], + configuration, + ); + + expect( + context.program + .getSourceFiles() + .map((sourceFile) => sourceFile.fileName) + .filter((fileName) => fileName.includes("node_modules")), + ).toEqual([]); + }); + + it("honors exclusions and rule severity configuration", async () => { + const root = await fixture( + { + "src/page.ts": 'import { state } from "@askrjs/askr"; state(0);', + "vendor/ignored.ts": 'import { state } from "@askrjs/askr"; state(0);', + }, + { + manifest: { + name: "fixture", + askr: { + analyze: { + exclude: ["vendor/**"], + rules: { "askr/stable-render-call": "info" }, + }, + }, + }, + }, + ); + + const found = await diagnostics(root); + expect(found.filter((entry) => entry.ruleId === "askr/stable-render-call")).toEqual([ + expect.objectContaining({ file: "src/page.ts", severity: "info" }), + ]); + }); + + it("reports lifecycle, stream, data, invalidation, and island contract violations", async () => { + const root = await fixture({ + "src/contracts.tsx": ` + import { on, stream as live, task, timer } from "@askrjs/askr/resources"; + import * as Data from "@askrjs/askr/data"; + import { createIsland, createIslands } from "@askrjs/askr/boot"; + declare const schema: unknown; + export function Page() { + on(null, "", 1); + timer(0, "no"); + timer(Infinity, () => {}); + task(); + live(); + live(() => 1, { deps: {} }); + live("source", []); + Data.createQuery({}); + Data.createQuery(null); + Data.createMutation({ action: 1 }); + Data.invalidate(""); + Data.queryScope(" "); + Data.invalidateOnInterval("", { intervalMs: 0 }); + return
; + } + createIsland({ routes: [] }); + createIslands({ islands: [] }); + createIsland({ root: "#widget", component: async () =>
}); + `, + }); + + const found = await diagnostics(root); + const count = (ruleId: string) => found.filter((entry) => entry.ruleId === ruleId).length; + expect(count("askr/lifecycle-contract")).toBe(7); + expect(count("askr/stream-contract")).toBe(5); + expect(count("askr/data-contract")).toBe(4); + expect(count("askr/invalidation-contract")).toBe(4); + expect(count("askr/island-contract")).toBe(5); + expect( + found + .filter((entry) => entry.ruleId.endsWith("-contract")) + .every((entry) => entry.severity === "error"), + ).toBe(true); + }); + + it("reports mixed execution models, action defects, discarded submits, and render allocations", async () => { + const root = await fixture({ + "src/app.tsx": ` + import { state } from "@askrjs/askr"; + import { ActionForm, action as useAction, defineAction } from "@askrjs/askr/actions"; + import { createIsland, createSPA } from "@askrjs/askr/boot"; + declare const root: Element; + declare const registry: unknown; + declare const schema: unknown; + const first = defineAction({ id: "save", input: schema }); + const duplicate = defineAction({ id: "save", input: schema }); + defineAction({ id: "", invalidates: ["", 3] }); + export function Page() { + state(0); + const command = useAction(first); + command.submit({}); + new Intl.DateTimeFormat(); + new RegExp("x"); + new Map(); + new Set(); + return ; + } + void createSPA({ root, registry }); + createIsland({ root, component: Page }); + `, + }); + + const found = await diagnostics(root); + expect(found.filter((entry) => entry.ruleId === "askr/execution-model")).toHaveLength(1); + expect(found.filter((entry) => entry.ruleId === "askr/action-contract")).toHaveLength(6); + expect(found.filter((entry) => entry.ruleId === "askr/action-promise")).toHaveLength(1); + expect(found.filter((entry) => entry.ruleId === "askr/render-allocation")).toHaveLength(4); + expect( + found + .filter((entry) => entry.ruleId === "askr/render-allocation") + .every((entry) => entry.severity === "info"), + ).toBe(true); + }); + + it("accepts valid contracts and ignores similarly named unrelated APIs", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { ActionForm, action, defineAction } from "@askrjs/askr/actions"; + import { createIslands } from "@askrjs/askr/boot"; + import { createMutation, createQuery, invalidate, invalidateOnInterval, queryScope } from "@askrjs/askr/data"; + import { on, stream, task, timer } from "@askrjs/askr/resources"; + import * as unrelated from "./unrelated"; + declare const target: EventTarget; + declare const schema: unknown; + const save = defineAction({ id: "save", input: schema, invalidates: ["users"] }); + export function Widget() { + on(target, "click", () => {}); + timer(1000, () => {}); + task(async () => {}); + stream(async function* ({ signal }) { + if (!signal.aborted) yield 1; + }, { deps: ["feed"] }); + createQuery({ key: "users", fetch: async () => ({}) }); + createMutation({ action: async () => ({}) }); + invalidate("users"); + queryScope("admin"); + invalidateOnInterval("users", { intervalMs: 1000 }); + const command = action(save); + void command.submit({}); + const click = () => new Map(); + unrelated.timer(0, null); + unrelated.stream("source"); + return ; + } + createIslands({ islands: [{ root: "#widget", component: Widget }] }); + `, + "src/unrelated.ts": ` + export function timer(...args: unknown[]) {} + export function stream(...args: unknown[]) {} + `, + }); + + const found = await diagnostics(root); + const newRules = new Set([ + "askr/lifecycle-contract", + "askr/stream-contract", + "askr/data-contract", + "askr/invalidation-contract", + "askr/island-contract", + "askr/execution-model", + "askr/action-contract", + "askr/action-promise", + "askr/render-allocation", + ]); + expect(found.filter((entry) => newRules.has(entry.ruleId))).toEqual([]); + }); +}); diff --git a/tests/cli.smoke.test.ts b/tests/cli.smoke.test.ts index 596c3e5..fd0633b 100644 --- a/tests/cli.smoke.test.ts +++ b/tests/cli.smoke.test.ts @@ -122,6 +122,7 @@ test("runCli prints top-level help", async () => { expect(logs.join("\n")).toMatch(/askr \[args\]/); expect(logs.join("\n")).toMatch(/Commands:/); expect(logs.join("\n")).toMatch(/add/); + expect(logs.join("\n")).toMatch(/analyze/); expect(logs.join("\n")).toMatch(/skills/); expect(logs.join("\n")).toMatch(/openapi/); }); @@ -142,30 +143,6 @@ test("package surface ships project templates for installed create commands", as } }); -test("guidance manifest stays aligned with templates and bundled skills", async () => { - const manifest = JSON.parse( - await fs.readFile(new URL("../guidance-manifest.json", import.meta.url), "utf8"), - ) as { - version: number; - shared: { agentFile: string; skills: string[] }; - templates: Record; - }; - expect(manifest.version).toBe(1); - const bundled = new Set( - (await fs.readdir(new URL("../skills/", import.meta.url), { withFileTypes: true })) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name), - ); - for (const [template, guidance] of Object.entries(manifest.templates)) { - await expect( - fs.access(new URL(`../templates/${template}/${guidance.agentFile}`, import.meta.url)), - ).resolves.toBeUndefined(); - for (const skill of [...manifest.shared.skills, ...guidance.skills]) { - expect(bundled.has(skill), `${template} references missing ${skill}`).toBe(true); - } - } -}); - test("runCli prints version for short and long flags", async () => { const packageJson = JSON.parse( await fs.readFile(new URL("../package.json", import.meta.url), "utf8"), @@ -195,6 +172,18 @@ test("package exports only the canonical askr command", async () => { expect(packageJson.bin).toEqual({ askr: "./dist/cli.js" }); }); +test("package ships TypeScript as a runtime analyzer dependency", async () => { + const packageJson = JSON.parse( + await fs.readFile(new URL("../package.json", import.meta.url), "utf8"), + ) as { + dependencies?: Record; + devDependencies?: Record; + }; + + expect(packageJson.dependencies?.typescript).toMatch(/^\^6\./); + expect(packageJson.devDependencies?.typescript).toBeUndefined(); +}); + test("public docs and templates use the clean-break scope vocabulary", async () => { const root = fileURLToPath(new URL("..", import.meta.url)); const files = [ @@ -283,33 +272,6 @@ test("runCreateCli rejects unsafe names, unknown options, and extra positional a } }); -test("startkit registers pages from the canonical route metadata", async () => { - const expectedRoutes = [ - ["src/routes/public.ts", "landingRoute.href", "route('/')"], - ["src/routes/auth.ts", "loginRoute.href", "route('/login')"], - ["src/routes/workspace/index.ts", "dashboardRoute.href", "route('/dashboard')"], - ["src/routes/workspace/index.ts", "settingsRoute.href", "route('/settings')"], - ["src/routes/workspace/accounts.ts", "accountsRoute.href", "route('/accounts')"], - ] as const; - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-startkit-routes-")); - const previousCwd = process.cwd(); - - try { - process.chdir(tempRoot); - expect(await runCreateCli(["startkit", "sample-app", "--no-install"], createIo().io)).toBe(0); - const appRoot = path.join(tempRoot, "sample-app"); - - for (const [relativePath, canonicalHref, rawRoute] of expectedRoutes) { - const source = await fs.readFile(path.join(appRoot, relativePath), "utf8"); - expect(source, relativePath).toContain(canonicalHref); - expect(source, relativePath).not.toContain(rawRoute); - } - } finally { - process.chdir(previousCwd); - await fs.rm(tempRoot, { recursive: true, force: true }); - } -}); - test("runCreateCli supports an explicit output directory without deriving it from the package name", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-create-dir-")); const target = path.join(tempRoot, "nested", "project"); @@ -448,11 +410,8 @@ test("runCreateCli scaffolds SPA with the route-first themed app shell", async ( expect(authLoginFile).toMatch(/Sign in/); expect(appRoutesFile).toMatch(/route\(["']\/app\/agents["']/); expect(mainFile).toMatch(/@askrjs\/askr\/boot/); + expect(routesFile).toMatch(/createRouteRegistry/); expect(mainFile).toMatch(/registry:\s*pageRegistry/); - expect(mainFile).not.toMatch(/getManifest/); - expect(mainFile).not.toMatch(/import ['"]\.\/pages\/_routes['"]/); - expect(routesFile).toMatch(/export const pageRegistry = createRouteRegistry/); - expect(routesFile).not.toMatch(/registerRoutes/); expect(stylesFile).toMatch(/@import ["']\.\/styles\/reset\.css["']/); expect(stylesFile).toMatch(/@import ["']\.\/styles\/tokens\.css["']/); expect(stylesFile).toMatch(/@import ["']\.\/styles\/theme\.css["']/); @@ -925,26 +884,6 @@ test("runSsgCli rejects unknown, missing, and invalid option values", async () = } }); -test("runSsgCli rejects raw route arrays", async () => { - const { io, errors } = createIo(); - const code = await runSsgCli( - ["--config", "ssg.config.ts", "--output", "dist"], - { - cwd: () => "/workspace", - existsSync: () => true, - importConfig: async () => ({ - routes: [{ path: "/" }], - siteUrl: "https://example.com", - sitemap: false, - }), - }, - io, - ); - - expect(code).toBe(1); - expect(errors).toContain("Error: Config must provide a route registry and no raw routes array"); -}); - test("runSsgCli preserves live output when sitemap metadata fails", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-ssg-atomic-")); const output = path.join(root, "dist"); @@ -957,7 +896,7 @@ test("runSsgCli preserves live output when sitemap metadata fails", async () => cwd: () => root, existsSync: () => true, importConfig: async () => ({ - registry: { records: [] }, + routes: [{ path: "/" }], siteUrl: "https://example.com", sitemap: { resolve: () => Promise.reject(new Error("metadata failed")) }, }), @@ -1016,7 +955,7 @@ test("runSsgCli requires a canonical site URL unless sitemap generation is disab { cwd: () => "/workspace", existsSync: () => true, - importConfig: async () => ({ registry: { records: [] } }), + importConfig: async () => ({ routes: [{ path: "/" }] }), createStaticGen: () => ({ generate }), }, io, @@ -1033,7 +972,7 @@ test("runSsgCli loads TypeScript configs without an external loader", async () = const configPath = path.join(tempRoot, "ssg.config.ts"); await fs.writeFile( configPath, - 'export const registry = { records: [] }; export const siteUrl = "https://example.com";\n', + 'const routes: Array<{ path: string }> = [{ path: "/" }]; export const siteUrl = "https://example.com"; export { routes };\n', "utf8", ); const generate = async () => ({ @@ -1088,7 +1027,7 @@ test("askr ssg executes TSX route modules with the project JSX runtime", async ( ); await fs.writeFile( configPath, - 'import { createRouteRegistry, route } from "@askrjs/askr/router"; import { Page } from "./page.tsx"; export const siteUrl = "https://example.com"; export const registry = createRouteRegistry(() => route("/", Page));\n', + 'import { Page } from "./page.tsx"; export const siteUrl = "https://example.com"; export const routes = [{ path: "/", component: Page }];\n', "utf8", ); @@ -1185,7 +1124,7 @@ test("runSsgCli preserves the previous full output when sitemap generation fails cwd: () => tempRoot, existsSync: () => true, importConfig: async () => ({ - registry: { records: [] }, + routes: [{ path: "/" }], siteUrl: "https://example.com", sitemap: { resolve: () => { @@ -1286,7 +1225,6 @@ test("runSkillsCli lists skill review prompts", async () => { expect(errors).toHaveLength(0); expect(logs.join("\n")).toMatch(/foundation\s+Foundation/); expect(logs.join("\n")).toMatch(/reject-react-query/); - expect(logs.join("\n")).toMatch(/reject-custom-accessibility-primitives/); }); test("skill review prompts only reference bundled skills", async () => { @@ -1816,16 +1754,6 @@ test("runSkillsCli passes ssr-ssg review for environment-safe static routes", as } }); -test("ssr-ssg skill teaches registry-only route setup", async () => { - const skill = await fs.readFile( - new URL("../skills/askr-ssr-ssg/SKILL.md", import.meta.url), - "utf8", - ); - - expect(skill).toMatch(/createRouteRegistry/); - expect(skill).not.toMatch(/registerRoutes/); -}); - test("runSkillsCli fails a negative review when React defaults appear", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-review-")); @@ -1896,65 +1824,6 @@ test("runSkillsCli fails a negative review when app-local primitive clones appea } }); -test("runSkillsCli flags custom accessibility primitives with package guidance", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-review-")); - - try { - await fs.mkdir(path.join(tempRoot, "src"), { recursive: true }); - await fs.writeFile( - path.join(tempRoot, "src", "dialog.tsx"), - [ - "export function Dialog() {", - ' return
Dialog
;', - "}", - ].join("\n"), - "utf8", - ); - - const { io, logs, errors } = createIo(); - const code = await runSkillsCli( - ["review", "reject-custom-accessibility-primitives", "--cwd", tempRoot], - io, - ); - - expect(code).toBe(1); - expect(errors).toHaveLength(0); - expect(logs.join("\n")).toMatch(/FAIL Does not hand-roll dialog primitives/); - expect(logs.join("\n")).toMatch(/missing: askr-ui, askr-themes/); - } finally { - await fs.rm(tempRoot, { recursive: true, force: true }); - } -}); - -test("runSkillsCli supports inline accessibility review suppression", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-review-")); - - try { - await fs.mkdir(path.join(tempRoot, "src"), { recursive: true }); - await fs.writeFile( - path.join(tempRoot, "src", "dialog.tsx"), - [ - "// askr-review-ignore reject-custom-accessibility-primitives", - "export function Dialog() {", - ' return
Intentional custom dialog
;', - "}", - ].join("\n"), - "utf8", - ); - - const { io, errors } = createIo(); - const code = await runSkillsCli( - ["review", "reject-custom-accessibility-primitives", "--cwd", tempRoot], - io, - ); - - expect(code).toBe(0); - expect(errors).toHaveLength(0); - } finally { - await fs.rm(tempRoot, { recursive: true, force: true }); - } -}); - test("runSkillsCli fails a negative review when one spinner models all async states", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-review-")); diff --git a/tests/guardrails.test.ts b/tests/guardrails.test.ts new file mode 100644 index 0000000..bcfcf16 --- /dev/null +++ b/tests/guardrails.test.ts @@ -0,0 +1,261 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runAnalysis } from "../src/analyze/runner"; +import { runCli } from "../src/bin/cli"; +import { parseGuardrailArgs, runGuardrailCli } from "../src/bin/guardrails"; +import { syncBundledSkills } from "../src/bin/skills"; +import { runCheck, runDoctor, runRepair } from "../src/guardrails/runner"; + +const roots: string[] = []; + +function io() { + const logs: string[] = []; + const errors: string[] = []; + return { + value: { + log: (...values: unknown[]) => logs.push(values.join(" ")), + error: (...values: unknown[]) => errors.push(values.join(" ")), + }, + logs, + errors, + }; +} + +async function fixture( + overrides: { + source?: string; + manifest?: Record; + lockfiles?: string[]; + skills?: boolean; + } = {}, +): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-guardrails-")); + roots.push(root); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await fs.writeFile( + path.join(root, "package.json"), + `${JSON.stringify( + overrides.manifest ?? { + name: "fixture", + dependencies: { "@askrjs/askr": "^0.0.70" }, + scripts: { + lint: "fixture-lint", + typecheck: "fixture-typecheck", + test: "fixture-test", + build: "fixture-build", + check: "askr check", + }, + }, + null, + 2, + )}\n`, + ); + await fs.writeFile( + path.join(root, "tsconfig.json"), + `${JSON.stringify( + { + compilerOptions: { + jsx: "react-jsx", + jsxImportSource: "@askrjs/askr", + module: "ESNext", + moduleResolution: "Bundler", + }, + include: ["src"], + }, + null, + 2, + )}\n`, + ); + await fs.writeFile( + path.join(root, "src", "app.ts"), + overrides.source ?? "export const ready = true;\n", + ); + for (const lockfile of overrides.lockfiles ?? ["package-lock.json"]) { + await fs.writeFile(path.join(root, lockfile), "{}\n"); + } + if (overrides.skills) { + await fs.mkdir(path.join(root, "skills", "askr-agent-execution"), { recursive: true }); + await fs.writeFile( + path.join(root, "skills", "askr-agent-execution", "SKILL.md"), + "# fixture\n", + ); + } + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe("guardrail commands", () => { + it("parses shared cwd, workspace, JSON, and help options", () => { + expect( + parseGuardrailArgs(["--cwd", "./fixture", "--workspace=a*", "--workspace", "b", "--json"]), + ).toEqual({ + cwd: path.resolve("./fixture"), + workspacePatterns: ["a*", "b"], + json: true, + help: false, + }); + expect(() => parseGuardrailArgs(["--workspace"])).toThrow(/requires a value/); + expect(() => parseGuardrailArgs(["--unknown"])).toThrow(/unknown option/i); + }); + + it("diagnoses environment, package manager, skills, framework, and analysis health", async () => { + const root = await fixture({ skills: true }); + await syncBundledSkills({ cwd: root }); + const report = await runDoctor( + { cwd: root, workspacePatterns: [] }, + { nodeVersion: "22.12.0" }, + ); + + expect(report.summary).toEqual({ passed: 5, warnings: 0, errors: 0 }); + expect(report.findings.map((entry) => entry.id)).toEqual([ + "askr/doctor-node", + "askr/doctor-package-manager", + "askr/doctor-framework-dependency", + "askr/doctor-agent-guidance", + "askr/doctor-analysis", + ]); + }); + + it("reports actionable doctor failures without changing the project", async () => { + const root = await fixture({ + lockfiles: ["package-lock.json", "pnpm-lock.yaml"], + source: ['import { state } from "@askrjs/askr";', "export const count = state(0);", ""].join( + "\n", + ), + }); + const before = await fs.readFile(path.join(root, "src", "app.ts"), "utf8"); + const report = await runDoctor( + { cwd: root, workspacePatterns: [] }, + { nodeVersion: "18.20.0" }, + ); + + expect(report.summary.errors).toBe(3); + expect(report.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "askr/doctor-node", status: "error" }), + expect.objectContaining({ id: "askr/doctor-package-manager", status: "error" }), + expect.objectContaining({ id: "askr/doctor-analysis", status: "error" }), + ]), + ); + expect(await fs.readFile(path.join(root, "src", "app.ts"), "utf8")).toBe(before); + }); + + it("runs validation scripts in order after analysis passes", async () => { + const root = await fixture(); + const executed: string[] = []; + const report = await runCheck( + { cwd: root, workspacePatterns: [] }, + { + runScript: vi.fn(async (executable, args) => { + executed.push([executable, ...args].join(" ")); + return { status: "passed" as const, exitCode: 0, stdout: "", stderr: "" }; + }), + }, + ); + + expect(report.status).toBe("passed"); + expect(executed).toEqual([ + "npm run lint", + "npm run typecheck", + "npm run test", + "npm run build", + ]); + }); + + it("does not run project scripts until blocking analysis findings are repaired", async () => { + const root = await fixture({ + source: ['import { state } from "@askrjs/askr";', "export const count = state(0);", ""].join( + "\n", + ), + }); + const runScript = vi.fn(); + const report = await runCheck({ cwd: root, workspacePatterns: [] }, { runScript }); + + expect(report.status).toBe("failed"); + expect(runScript).not.toHaveBeenCalled(); + expect(report.scripts).toHaveLength(4); + expect(report.scripts.every((entry) => entry.status === "skipped")).toBe(true); + }); + + it("stops after a failed validation script and explains skipped stages", async () => { + const root = await fixture(); + const report = await runCheck( + { cwd: root, workspacePatterns: [] }, + { + runScript: vi.fn(async (_executable, args) => ({ + status: args.at(-1) === "typecheck" ? ("failed" as const) : ("passed" as const), + exitCode: args.at(-1) === "typecheck" ? 1 : 0, + stdout: "", + stderr: "", + })), + }, + ); + + expect(report.status).toBe("failed"); + expect(report.scripts.map((entry) => [entry.name, entry.status, entry.reason])).toEqual([ + ["lint", "passed", undefined], + ["typecheck", "failed", undefined], + ["test", "skipped", "typecheck failed"], + ["build", "skipped", "typecheck failed"], + ]); + }); + + it("applies safe repairs, reports semantic leftovers, and converges idempotently", async () => { + const root = await fixture({ + source: [ + 'import { createRouteRegistry, route } from "@askrjs/askr/router";', + "export const registry = createRouteRegistry(() => {", + ' route("/users/:id", () => null);', + "});", + "", + ].join("\n"), + }); + const first = await runRepair({ cwd: root, workspacePatterns: [] }); + const second = await runRepair({ cwd: root, workspacePatterns: [] }); + + expect(first.status).toBe("clean"); + expect(first.analysis.appliedFixes).toEqual([ + expect.objectContaining({ ruleId: "askr/route-path-syntax" }), + ]); + expect(second.status).toBe("clean"); + expect(second.analysis.appliedFixes).toEqual([]); + expect(await fs.readFile(path.join(root, "src", "app.ts"), "utf8")).toContain( + 'route("/users/{id}"', + ); + }); + + it("dispatches doctor, repair, and check through the canonical askr CLI", async () => { + const root = await fixture({ skills: true }); + await syncBundledSkills({ cwd: root }); + const output = io(); + expect(await runCli(["doctor", "--cwd", root, "--json"], output.value)).toBe(0); + expect(JSON.parse(output.logs[0])).toMatchObject({ command: "doctor" }); + + const help = io(); + expect(await runGuardrailCli("repair", ["--help"], help.value)).toBe(0); + expect(help.logs.join("\n")).toMatch(/askr repair/); + }); +}); + +describe("shipped template guardrails", () => { + it("keeps every template analyzer-clean and wired to the unified check", async () => { + const templatesRoot = fileURLToPath(new URL("../templates/", import.meta.url)); + for (const name of ["full-stack", "spa", "ssg", "ssr", "startkit"]) { + const root = path.join(templatesRoot, name); + const report = await runAnalysis({ cwd: root, workspacePatterns: [], check: true }); + const manifest = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")) as { + scripts?: Record; + }; + + expect(report.diagnostics, `${name} must be analyzer-clean`).toEqual([]); + expect(manifest.scripts?.analyze).toBe("askr analyze --check"); + expect(manifest.scripts?.check).toBe("askr check"); + } + }); +}); diff --git a/tests/update.cli.test.ts b/tests/update.cli.test.ts index f145660..c7cfe0e 100644 --- a/tests/update.cli.test.ts +++ b/tests/update.cli.test.ts @@ -386,12 +386,14 @@ describe("update CLI", () => { "npm-registry-fetch", "semver", "tsx", + "typescript", ]); expect(JSON.stringify(manifest)).not.toContain(forbiddenPackage); expect(JSON.stringify(manifest)).not.toContain(forbiddenAlias); expect(sources.join("\n")).not.toContain(forbiddenPackage); expect(sources.join("\n")).not.toContain(forbiddenAlias); expect(config).toMatch(/update: "src\/bin\/update\.ts"/); + expect(config).toMatch(/analyze: "src\/bin\/analyze\.ts"/); }); test("should load command implementations lazily given the top-level CLI when inspecting source", async () => { @@ -399,7 +401,7 @@ describe("update CLI", () => { expect(source).toContain('await import("./update")'); expect(source).not.toMatch( - /^import .* from "\.\/(?:add|create|generate|openapi|skills|ssg|update)";/m, + /^import .* from "\.\/(?:add|analyze|create|generate|openapi|skills|ssg|update)";/m, ); }); }); diff --git a/tests/update.range.test.ts b/tests/update.range.test.ts index 15094c5..2b3bf5f 100644 --- a/tests/update.range.test.ts +++ b/tests/update.range.test.ts @@ -33,7 +33,7 @@ function proposal( return planUpdates({ occurrences: [occurrence(specification)], packuments: new Map([["fixture", packument(target, versions)]]), - ...(force ? { mode: "force" as const } : {}), + force, }).decisions[0].occurrences[0]; } @@ -133,7 +133,7 @@ describe("update range planner", () => { }, ], ]), - mode: "upgrade", + force: true, }); expect(plan.decisions[0].occurrences[0]).toMatchObject({ @@ -198,36 +198,6 @@ describe("update range planner", () => { ]); }); - test("should resolve a valid peer set when a singleton domain is assigned", () => { - const app = occurrence("^1.0.0", "app"); - const peer = occurrence("^1.0.0", "peer"); - const plan = planUpdates({ - occurrences: [app, peer], - contextOccurrences: [app, peer], - mode: "upgrade", - packuments: new Map([ - [ - "app", - { - "dist-tags": { latest: "1.1.0" }, - versions: { - "1.0.0": { version: "1.0.0" }, - "1.1.0": { version: "1.1.0", peerDependencies: { peer: "^1.1.0" } }, - }, - }, - ], - ["peer", packument("1.1.0", ["1.0.0", "1.1.0"])], - ]), - }); - - expect(plan.decisions.map((decision) => decision.occurrences[0])).toEqual( - expect.arrayContaining([ - expect.objectContaining({ status: "safe", selectedVersion: "1.1.0" }), - expect.objectContaining({ status: "safe", selectedVersion: "1.1.0" }), - ]), - ); - }); - test("should choose an older compatible release below latest when upgrading", () => { const app = occurrence("^1.0.0", "app"); const peer = occurrence("^1.0.0", "peer"); diff --git a/tsconfig.json b/tsconfig.json index 28388e0..412a440 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,13 @@ "allowSyntheticDefaultImports": true, "esModuleInterop": true }, - "include": ["src/**/*.ts", "tests/**/*.ts", "vite.config.ts", "vitest.config.ts"], + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + "benchmarks/**/*.ts", + "vite.config.ts", + "vitest.config.ts", + "vitest.bench.config.ts" + ], "exclude": ["dist", "node_modules"] } diff --git a/vite.config.ts b/vite.config.ts index b0b7da2..875a9f3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ pack: { entry: { add: "src/bin/add.ts", + analyze: "src/bin/analyze.ts", cli: "src/bin/cli.ts", create: "src/bin/create.ts", generate: "src/bin/generate.ts", @@ -20,7 +21,7 @@ export default defineConfig({ js: ".js", }), sourcemap: false, - copy: ["templates", "skills", "guidance-manifest.json"], + copy: ["templates", "skills"], deps: { neverBundle: [/^@askrjs\/askr(?:\/.*)?$/, "tsx/esm/api"], }, diff --git a/vitest.bench.config.ts b/vitest.bench.config.ts new file mode 100644 index 0000000..8f3b8bc --- /dev/null +++ b/vitest.bench.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite-plus"; +import { AnalyzeBudgetReporter } from "./benchmarks/analyze-budget-reporter"; + +export default defineConfig({ + test: { + environment: "node", + fileParallelism: false, + maxWorkers: 1, + pool: "forks", + reporters: ["default", new AnalyzeBudgetReporter()], + benchmark: { + include: ["benchmarks/**/*.bench.ts"], + includeSamples: false, + }, + }, +});