diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2846f9a..b105b8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,8 @@ jobs: run: npm ci - name: Install Chromium - if: ${{ hashFiles('vitest.test.browser.config.ts', 'playwright.config.*', 'tests/verify-hydration.test.ts') != '' }} - run: npx playwright-core install chromium + if: ${{ hashFiles('vitest.test.browser.config.ts', 'playwright.config.*') != '' }} + run: npx playwright install chromium - name: Format check run: npm run fmt --if-present -- --check @@ -52,8 +52,6 @@ jobs: - name: Test run: npm run test:coverage - env: - ASKR_BROWSER_CHANNEL: playwright - name: Type checks run: | diff --git a/README.md b/README.md index 38fa569..4aeca92 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,12 @@ unless you opt out with `--no-skills`. - `askr doctor [--cwd ] [--workspace ]... [--json]` - `askr repair [--cwd ] [--workspace ]... [--json]` - `askr check [--cwd ] [--workspace ]... [--json]` +- `askr database validate|generate [--database ] [--json]` +- `askr database migration create|status|plan|apply|resolve ...` - `askr skills list` - `askr skills install [--cwd ] [--force]` - `askr skills sync [--cwd ]` - `askr ssg --config --output [--incremental]` -- `askr verify-hydration [--output ./dist] [--route ]...` - `askr openapi [--entry ./src/api.ts] [--output ./openapi.yml] [--check]` - `askr outdated [packages...] [--workspace ] [--tag ] [--json]` - `askr update [packages...] [--workspace ] [--tag ] [--json]` @@ -74,10 +75,8 @@ 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 recommends `` only when a `.map()` result is -rendered directly as JSX children, so ordinary data transforms remain valid. -It reports eager ``/``/`` controls behind changing ternaries -while accepting conditionally mounted components with their own render scope. +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 @@ -99,7 +98,17 @@ askr check 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`. +scripts in order. When `database/index.ts` exists, `check` first delegates +database validation to the project's installed `@askrjs/orm` tooling. +Generated projects expose that final gate as `npm run check`. + +## Database tooling + +`askr database ...` is a lazy front end. The CLI resolves +`@askrjs/orm/tooling` from the target project and delegates the complete +command, so schema generation and migration semantics always match the +project's installed ORM version. See the +[database command reference](./docs/database.md). See the [project guardrails reference](./docs/guardrails.md) for command and JSON contracts. @@ -141,24 +150,6 @@ owned files so stale chunks and previous output paths are removed. Full and incremental builds publish through a sibling staging directory, so route output, metadata, assets, and sitemap artifacts change together or not at all. -## Hydration verification - -`askr verify-hydration` builds SSG output, serves the generated route set, and -loads every successful metadata route in a real headless browser both with and -without JavaScript. It compares normalized tag-and-child topology under `#app` -after hydration, so text, classes, and mutable ARIA state do not create noise -while nodes migrating into the wrong sibling container fail with an actionable -static-versus-hydrated path diff. - -```bash -askr verify-hydration -askr verify-hydration --route / --route /docs -askr verify-hydration --no-build --output ./dist -``` - -See the [hydration verification reference](./docs/verify-hydration.md) for -browser installation, timeout, route, and root-selector options. - ## OpenAPI artifacts `askr openapi` loads a TypeScript module whose default export exposes diff --git a/benchmarks/analyze-budget-reporter.ts b/benchmarks/analyze-budget-reporter.ts index 523d9d1..f84a085 100644 --- a/benchmarks/analyze-budget-reporter.ts +++ b/benchmarks/analyze-budget-reporter.ts @@ -4,11 +4,6 @@ const ANALYZE_BUDGETS_MS: Readonly> = { "50-file workspace": 100, "250-file workspace": 250, "5-workspace monorepo with 250 files": 300, - "diagnostic-heavy registered-rule sweep": 5, - "deep cyclic wrapper graph through barrels": 6, - "deep cyclic wrapper full analysis": 100, - "lifecycle cleanup matching": 5, - "realistic shipped startkit workspace": 100, }; interface BenchmarkMeta { @@ -21,35 +16,19 @@ interface BenchmarkMeta { export class AnalyzeBudgetReporter implements Reporter { onTestRunEnd(testModules: readonly TestModule[]): void { const failures: string[] = []; - const measurements: string[] = []; - const seen = new Set(); for (const testModule of testModules) { for (const test of testModule.children.allTests()) { - const meta = test.meta() as BenchmarkMeta; - if (!meta.benchmark) continue; - seen.add(test.name); const budget = ANALYZE_BUDGETS_MS[test.name]; - if (budget === undefined) { - failures.push(`${test.name}: missing performance budget`); - continue; - } + if (budget === undefined) continue; + const meta = test.meta() as BenchmarkMeta; const mean = meta.result?.mean; - if (mean === undefined || !Number.isFinite(mean) || mean <= 0) { + if (!meta.benchmark || mean === undefined) { failures.push(`${test.name}: missing benchmark result`); - } else { - measurements.push(`${test.name}: ${mean.toFixed(2)} ms / ${budget} ms`); - if (mean > budget) { - failures.push(`${test.name}: mean ${mean.toFixed(1)} ms exceeds ${budget} ms`); - } + } else if (mean > budget) { + failures.push(`${test.name}: mean ${mean.toFixed(1)} ms exceeds ${budget} ms`); } } } - for (const name of Object.keys(ANALYZE_BUDGETS_MS)) { - if (!seen.has(name)) failures.push(`${name}: stale performance budget`); - } - if (measurements.length > 0) { - console.log(`Analyzer performance budgets:\n${measurements.join("\n")}`); - } if (failures.length > 0) { throw new Error(`Analyzer performance budget failed:\n${failures.join("\n")}`); } diff --git a/benchmarks/analyze-workloads.ts b/benchmarks/analyze-workloads.ts deleted file mode 100644 index c328ea6..0000000 --- a/benchmarks/analyze-workloads.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const ANALYZE_BENCHMARK_WORKLOADS = [ - "clean-full-workspace", - "deep-cyclic-wrapper-graph", - "diagnostic-heavy-rule-sweep", - "lifecycle-cleanup-matching", - "realistic-multi-file-workspace", -] as const; - -export type AnalyzeBenchmarkWorkload = (typeof ANALYZE_BENCHMARK_WORKLOADS)[number]; - -export const ANALYZE_RULE_BENCHMARK_COVERAGE: Readonly< - Record -> = { - "askr/action-contract": ["diagnostic-heavy-rule-sweep"], - "askr/action-promise": ["diagnostic-heavy-rule-sweep"], - "askr/boot-registry": ["diagnostic-heavy-rule-sweep"], - "askr/control-contract": ["diagnostic-heavy-rule-sweep"], - "askr/data-cancellation": ["diagnostic-heavy-rule-sweep"], - "askr/data-contract": ["diagnostic-heavy-rule-sweep"], - "askr/execution-model": ["diagnostic-heavy-rule-sweep"], - "askr/for-contract": ["diagnostic-heavy-rule-sweep"], - "askr/framework-config": ["clean-full-workspace", "diagnostic-heavy-rule-sweep"], - "askr/invalidation-contract": ["diagnostic-heavy-rule-sweep"], - "askr/island-contract": ["diagnostic-heavy-rule-sweep"], - "askr/lifecycle-contract": ["diagnostic-heavy-rule-sweep"], - "askr/no-async-component": ["diagnostic-heavy-rule-sweep"], - "askr/parse-error": ["diagnostic-heavy-rule-sweep"], - "askr/prefer-for": ["diagnostic-heavy-rule-sweep"], - "askr/render-allocation": ["diagnostic-heavy-rule-sweep"], - "askr/render-side-effect": ["diagnostic-heavy-rule-sweep", "lifecycle-cleanup-matching"], - "askr/resource-cancellation": ["diagnostic-heavy-rule-sweep"], - "askr/route-path-syntax": ["diagnostic-heavy-rule-sweep"], - "askr/route-registry": ["diagnostic-heavy-rule-sweep"], - "askr/ssr-browser-global": ["diagnostic-heavy-rule-sweep"], - "askr/stable-dependencies": ["diagnostic-heavy-rule-sweep"], - "askr/stable-key": ["diagnostic-heavy-rule-sweep"], - "askr/stable-render-call": ["deep-cyclic-wrapper-graph", "diagnostic-heavy-rule-sweep"], - "askr/state-access": ["diagnostic-heavy-rule-sweep"], - "askr/state-render-write": ["diagnostic-heavy-rule-sweep"], - "askr/stream-contract": ["diagnostic-heavy-rule-sweep"], -}; - -export function uncoveredAnalyzeRuleIds(registeredRuleIds: readonly string[]): { - readonly missing: readonly string[]; - readonly stale: readonly string[]; -} { - const registered = new Set(registeredRuleIds); - const covered = new Set(Object.keys(ANALYZE_RULE_BENCHMARK_COVERAGE)); - return { - missing: [...registered].filter((id) => !covered.has(id)).sort(), - stale: [...covered].filter((id) => !registered.has(id)).sort(), - }; -} diff --git a/benchmarks/analyze.bench.ts b/benchmarks/analyze.bench.ts index 7e34f72..7ddadd5 100644 --- a/benchmarks/analyze.bench.ts +++ b/benchmarks/analyze.bench.ts @@ -2,11 +2,7 @@ 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 { createWorkspaceAnalysisContext, readAnalyzeConfiguration } from "../src/analyze/project"; -import { ANALYZE_RULES } from "../src/analyze/rules"; import { runAnalysis } from "../src/analyze/runner"; -import { discoverWorkspaceProject } from "../src/update/discovery"; -import { uncoveredAnalyzeRuleIds } from "./analyze-workloads"; const roots: string[] = []; const benchOptions = { @@ -61,11 +57,6 @@ async function createWorkspace( include: ["src"], }); await fs.mkdir(path.join(directory, "src"), { recursive: true }); - await fs.mkdir(path.join(directory, ".askr", "client", "assets"), { recursive: true }); - await fs.writeFile( - path.join(directory, ".askr", "client", "assets", "generated.js"), - "export function BundledPage() { const value = new Set(); return value.size; }\n", - ); await Promise.all( Array.from({ length: sourceCount }, (_, index) => fs.writeFile( @@ -106,210 +97,16 @@ async function createMonorepoFixture( return root; } -async function createDiagnosticFixture(): Promise { - const root = await createSingleWorkspaceFixture(0); - await writeJson(path.join(root, "package.json"), { - name: "diagnostic-heavy", - dependencies: { - "@askrjs/askr": "^0.0.70", - "@askrjs/vite": "^0.0.6", - }, - }); - await fs.writeFile( - path.join(root, "src", "app.tsx"), - ` - import { Case, For, Match, Show, derive, resource, state } from "@askrjs/askr"; - import { action, ActionForm, defineAction } from "@askrjs/askr/actions"; - import { createIsland, createSPA } from "@askrjs/askr/boot"; - import { - createMutation, - createQuery, - invalidate, - invalidateOnInterval, - queryScope, - } from "@askrjs/askr/data"; - import { on, stream, task, timer } from "@askrjs/askr/resources"; - import { createRouteRegistry, route } from "@askrjs/askr/router"; - declare const schema: unknown; - declare const root: Element; - const moduleState = state(0); - const save = defineAction({ id: "", input: schema, invalidates: [""] }); - route("/outside/:id", () =>
); - export const registry = createRouteRegistry(async () => { - route("/inside/:id", async () =>
); - }); - export async function AsyncPage() { - return
; - } - export function EarlyControl(props: { skip: boolean }) { - if (props.skip) return null; - return item}>{(item) => {item}}; - } - export function Page(props: { enabled: boolean; items: Array<{ id: string }> }) { - const [count, setCount] = state(0); - if (props.enabled) derive(() => count()); - setCount(); - resource(() => fetch("/api"), [{}]); - createQuery({ key: "users", fetch: () => fetch("/api") }); - createQuery(null); - createMutation({ action: () => fetch("/api", { method: "POST" }) }); - on(null, "", 1); - timer(0, "invalid"); - task(); - stream("invalid", { deps: {} }); - invalidate(""); - queryScope(" "); - invalidateOnInterval("", { intervalMs: 0 }); - const handle = setInterval(() => setCount(1), 1000); - new Map(); - action(save).submit({}); - return
- {count} - {props.items.map((item) => {item.id})} - index} /> - outside case - -
; - } - void moduleState; - void createSPA({ root, routes: [] }); - createIsland({ routes: [] }); - `, - ); - await fs.writeFile( - path.join(root, "src", "entry-server.tsx"), - ` - import { renderToString } from "@askrjs/askr/ssr"; - export const html = renderToString(() =>
{document.title}
); - `, - ); - await fs.writeFile(path.join(root, "src", "broken.ts"), "export function broken( {"); - await fs.writeFile( - path.join(root, "vite.config.ts"), - 'import { defineConfig } from "vite"; export default defineConfig({ plugins: [] });\n', - ); - return root; -} - -async function createDeepGraphFixture(depth: number, callCount: number): Promise { - const root = await createSingleWorkspaceFixture(0); - const hooks = [ - 'import { createQuery } from "@askrjs/askr/data";', - `export function hook0() { - if (false) hook${depth}(); - return createQuery({ key: "deep", fetch: async () => [] }); - }`, - ...Array.from({ length: depth }, (_, index) => { - const current = index + 1; - const previous = current - 1; - return `export function hook${current}() { return hook${previous}(); }`; - }), - ].join("\n"); - await fs.writeFile(path.join(root, "src", "hooks.ts"), hooks); - let previous = "./hooks"; - for (let index = 0; index < 12; index += 1) { - const current = `barrel-${index}`; - await fs.writeFile( - path.join(root, "src", `${current}.ts`), - `export * from ${JSON.stringify(previous)};\n`, - ); - previous = `./${current}`; - } - await fs.writeFile( - path.join(root, "src", "page.tsx"), - ` - import { hook${depth} } from ${JSON.stringify(previous)}; - export function Page(props: { enabled: boolean }) { - ${Array.from( - { length: callCount }, - (_, index) => `const result${index} = props.enabled ? hook${depth}() : null;`, - ).join("\n")} - return
{String(result0)}
; - } - `, - ); - return root; -} - -async function createLifecycleFixture(count: number): Promise { - const root = await createSingleWorkspaceFixture(0); - await fs.writeFile( - path.join(root, "src", "page.tsx"), - ` - import { state, task } from "@askrjs/askr"; - export function Page() { - const [, setNow] = state(Date.now()); - ${Array.from({ length: count }, (_, index) => - index % 2 === 0 - ? `task(() => { - const handle${index} = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(handle${index}); - });` - : `task(() => { - const handle${index} = setTimeout(() => setNow(Date.now()), 1000); - return () => clearInterval(handle${index}); - });`, - ).join("\n")} - return
; - } - `, - ); - return root; -} - -async function analysisContext(root: string) { - const project = await discoverWorkspaceProject({ cwd: root, workspacePatterns: [] }); - const workspace = project.selectedWorkspaces[0]!; - return ( - await createWorkspaceAnalysisContext( - project.root, - workspace, - readAnalyzeConfiguration(workspace.manifest), - ) - ).context; -} - let mediumWorkspace: string; let largeWorkspace: string; let monorepo: string; -let diagnosticWorkspace: string; -let deepGraphWorkspace: string; -let lifecycleWorkspace: string; -let diagnosticContext: Awaited>; -let deepGraphContext: Awaited>; -let lifecycleContext: Awaited>; beforeAll(async () => { - [ - mediumWorkspace, - largeWorkspace, - monorepo, - diagnosticWorkspace, - deepGraphWorkspace, - lifecycleWorkspace, - ] = await Promise.all([ + [mediumWorkspace, largeWorkspace, monorepo] = await Promise.all([ createSingleWorkspaceFixture(50), createSingleWorkspaceFixture(250), createMonorepoFixture(5, 50), - createDiagnosticFixture(), - createDeepGraphFixture(200, 100), - createLifecycleFixture(100), - ]); - [diagnosticContext, deepGraphContext, lifecycleContext] = await Promise.all([ - analysisContext(diagnosticWorkspace), - analysisContext(deepGraphWorkspace), - analysisContext(lifecycleWorkspace), ]); - const registered = ANALYZE_RULES.map((rule) => rule.id); - expect(uncoveredAnalyzeRuleIds(registered)).toEqual({ missing: [], stale: [] }); - const diagnosticReport = await runAnalysis({ - cwd: diagnosticWorkspace, - workspacePatterns: [], - check: true, - }); - expect(new Set(diagnosticReport.diagnostics.map((entry) => entry.ruleId))).toEqual( - new Set(registered), - ); }); afterAll(async () => { @@ -332,58 +129,4 @@ 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); - bench( - "diagnostic-heavy registered-rule sweep", - () => { - const count = ANALYZE_RULES.reduce( - (sum, rule) => sum + rule.analyze(diagnosticContext).length, - 0, - ); - expect(count).toBeGreaterThan(ANALYZE_RULES.length); - }, - benchOptions, - ); - bench( - "deep cyclic wrapper graph through barrels", - () => { - const rule = ANALYZE_RULES.find((candidate) => candidate.id === "askr/stable-render-call")!; - expect(rule.analyze(deepGraphContext)).toHaveLength(100); - }, - benchOptions, - ); - bench( - "deep cyclic wrapper full analysis", - async () => { - const report = await runAnalysis({ - cwd: deepGraphWorkspace, - workspacePatterns: [], - check: true, - }); - expect( - report.diagnostics.filter((entry) => entry.ruleId === "askr/stable-render-call"), - ).toHaveLength(100); - }, - benchOptions, - ); - bench( - "lifecycle cleanup matching", - () => { - const rule = ANALYZE_RULES.find((candidate) => candidate.id === "askr/render-side-effect")!; - expect(rule.analyze(lifecycleContext)).toHaveLength(50); - }, - benchOptions, - ); - bench( - "realistic shipped startkit workspace", - async () => { - const report = await runAnalysis({ - cwd: path.resolve("templates/startkit"), - workspacePatterns: [], - check: true, - }); - expect(report.summary.diagnostics).toBe(0); - expect(report.workspaces[0]?.files).toBeGreaterThan(20); - }, - benchOptions, - ); }); diff --git a/benchmarks/cli.mjs b/benchmarks/cli.mjs index 8bc0de2..8798c52 100644 --- a/benchmarks/cli.mjs +++ b/benchmarks/cli.mjs @@ -150,7 +150,6 @@ for (const command of [ "outdated", "update", "upgrade", - "verify-hydration", ]) { const samples = commandSample([command, "--help"]); rows.push({ @@ -163,9 +162,10 @@ for (const command of [ 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, + // TypeScript startup is materially slower on hosted Linux/Windows runners + // than on the local development platform. Keep the strict local budget while + // allowing the supported CI platforms their measured cold-start envelope. + budgetMs: process.platform === "darwin" ? 350 : 1000, p95Ms: percentile(analyze, 0.95), samples: analyze, }); diff --git a/docs/README.md b/docs/README.md index dc8c247..637a34b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,8 +14,8 @@ The unified CLI for the Askr platform. - [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 +- [database](./database.md) - Validate, generate, and apply project-owned database history - [ssg](./ssg.md) - Atomically publish static routes and sitemap artifacts -- [verify-hydration](./verify-hydration.md) - Compare static and hydrated SSG DOM topology - [update](./update.md) - Plan and apply manifest-only dependency updates - [Workflows](./workflows.md) - End-to-end CLI workflows diff --git a/docs/analyze.md b/docs/analyze.md index 034e9fd..804e4a8 100644 --- a/docs/analyze.md +++ b/docs/analyze.md @@ -34,22 +34,9 @@ another package or local module is not treated as an Askr API. 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. Local - wrapper summaries follow aliases, namespace imports, re-export barrels, - nested calls, and cycles, so conditionally calling a helper that transitively - claims a render slot is reported at the helper call. It also - reports eager control primitives such as ``, ``, and `` - placed directly behind a non-constant ternary or logical expression, or - reached only after a conditional early return. - Conditionally mounted components are not reported because each component - owns a separate render scope. -- `askr/render-side-effect` reports high-confidence platform timers, observers, - event listeners, and subscriptions started during component render, including - starts hidden behind local wrappers. A `task()` callback is accepted only - when its returned cleanup matches the timer handle, observer instance, or - listener tuple it started. -- `askr/state-access` reports state getters used without calling them in JSX - expressions or direct returns, and setters called without a value or updater. + mutations where the AST establishes a component render context. +- `askr/state-access` reports state getters used without calling them 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 @@ -70,9 +57,9 @@ another package or local module is not treated as an Askr API. ### Performance -- `askr/prefer-for` reports `.map()` when its array result flows directly into - JSX children. Data transforms outside JSX, JSX attribute values, joined text, - and transforms passed to `` remain valid. +- `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. @@ -115,16 +102,12 @@ 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. Its cold -full-analysis workloads cover 50 files, 250 files, five workspaces containing -250 files, a 200-function cyclic wrapper graph reached through 12 re-export -barrels, and the shipped startkit template. Hot rule workloads cover every -registered diagnostic, cyclic summary propagation, and exact lifecycle cleanup -matching. A coverage contract fails when a rule or benchmark is added without -classification and an explicit budget. The enforced mean budgets are 100 ms, -250 ms, 300 ms, 100 ms, 100 ms, 5 ms, 6 ms, and 5 ms for those workloads. The -general `npm run bench` gate separately checks the installed CLI, including a -cold startkit scan against a 1000 ms p95 budget. +`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 @@ -146,11 +129,8 @@ Configure the analyzer in the workspace root `package.json`: ``` Rule values are `error`, `warning`, `info`, or `off`. Exclusions are applied -relative to each workspace and extend the built-in defaults. The analyzer -always ignores dependency, VCS, coverage, generated, and common build-output -directories by default, including `.askr/**`, `dist/**`, and `build/**`. -TypeScript `include` entries do not re-enable those generated directories; -analyze the original source that produced an artifact instead. +relative to each workspace. The analyzer always ignores dependency, VCS, +coverage, generated, and common build-output directories by default. ## CI diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..8b4b19a --- /dev/null +++ b/docs/database.md @@ -0,0 +1,25 @@ +# `askr database` + +The unified CLI intentionally contains no ORM implementation. It resolves +`@askrjs/orm/tooling` from the selected project only when `database` is invoked, +then forwards the original arguments and IO. This prevents a global or +transitive CLI version from changing project migration semantics. + +```text +askr database validate [--database ] +askr database generate [--database ] +askr database migration create [--database ] +askr database migration status --database +askr database migration plan --database +askr database migration apply --database [--yes] +askr database migration resolve --database --applied +``` + +Flat single-database projects may omit `--database`. Named registries require +`--database`; use `--all` only when every configured database is deliberately +selected. All commands support `--cwd` and `--json`. + +`validate` is read-only with respect to source and the target database. +`generate` writes migrations and generated artifacts but never applies them. +`migration apply` displays the pending ids and risk markers before prompting. +Programmatic application belongs to `@askrjs/orm` and never prompts. diff --git a/docs/overview.md b/docs/overview.md index eb29e16..25b20a7 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -43,10 +43,11 @@ 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 database validate # Replay and byte-check generated database artifacts +askr database migration plan # Inspect target migration history askr skills install # Install bundled Askr agent skills askr skills sync # Update bundled Askr agent skills askr ssg --config --output # Run static site generation -askr verify-hydration # Verify SSG topology in a real browser askr outdated # Report available dependency updates askr update # Apply safe dependency manifest updates askr upgrade # Apply latest peer-compatible upgrades diff --git a/docs/verify-hydration.md b/docs/verify-hydration.md deleted file mode 100644 index 4d19638..0000000 --- a/docs/verify-hydration.md +++ /dev/null @@ -1,83 +0,0 @@ -# Hydration verification - -`askr verify-hydration` catches SSG defects that are invisible in generated -HTML but move, insert, or remove elements after the browser mounts the client -application. - -## Default workflow - -From an SSG project: - -```bash -askr verify-hydration -``` - -The command: - -1. runs `npm run build`; -2. reads successful routes from `dist/metadata.json`; -3. serves the exact generated route files and assets on a loopback-only, - no-cache HTTP server; -4. loads each route in headless Chrome with JavaScript disabled; -5. loads it again with JavaScript enabled, waits for the document and module - scripts to complete, then waits two animation frames; -6. compares tag-and-child topology below `#app`. - -Text, attributes, comments, and non-structural execution artifacts (`script`, -`style`, `link`, `meta`, `noscript`, and `template`) are excluded from the -structural snapshot. Normal text, class, ARIA, and JavaScript-fallback changes -after hydration therefore remain valid. A node moving from one sibling -container to another changes its normalized path and fails the check. Browser -page errors and `console.error` messages also fail closed. The verifier does not -wait for network idleness, so healthy polling, streaming, and SSE connections -cannot hold the check open. - -## Route sets and existing output - -Repeat `--route` to select a subset of concrete metadata routes: - -```bash -askr verify-hydration --route / --route /docs -``` - -Without `--route`, every successful route in `metadata.json` is verified. -Unknown routes, invalid metadata, output paths escaping the generated -directory, missing roots, HTTP failures, browser errors, and timeouts are -reported as failures. - -Use an existing output directory without rebuilding: - -```bash -askr verify-hydration --no-build --output ./.askr/site -``` - -Use `--build-script ` when the owning package exposes SSG through a script -other than `build`. Use `--root ` when the hydrated application root -is not `#app`. `--timeout ` controls the per-route browser timeout. - -## Browser setup - -The command uses `playwright-core` without downloading a browser into the CLI -package. It defaults to the system Chrome channel on macOS, Linux, and Windows. -Microsoft Edge is also supported: - -```bash -askr verify-hydration --browser-channel msedge -``` - -For a Playwright-managed Chromium installation: - -```bash -npx playwright-core install chromium -askr verify-hydration --browser-channel playwright -``` - -Set `ASKR_BROWSER_CHANNEL` to choose the default channel in CI. A command-line -`--browser-channel` value takes precedence. - -## Cleanup and failures - -The browser, browser contexts, pages, and loopback server close in `finally` -paths on success, DOM divergence, page error, timeout, and launch failure. -Build subprocesses have a five-minute ceiling and receive termination before -the command reports a timeout. diff --git a/docs/workflows.md b/docs/workflows.md index 71a299c..d52f047 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -33,6 +33,25 @@ 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. +If the project has `database/index.ts`, the same check also runs +`askr database validate` through the installed `@askrjs/orm`. + +### Evolve a Postgres schema + +- `askr-agent-execution` +- `askr-testing-determinism` + +```bash +askr database validate +askr database generate +git diff -- database/migrations database/generated +askr database migration plan --database app +askr database migration apply --database app +``` + +Generation uses only the isolated scratch database and never changes the +target. Applying shows the exact plan and requires confirmation unless `--yes` +is supplied. ### Add a page or route diff --git a/package-lock.json b/package-lock.json index e39f747..9275d53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "js-yaml": "^5.2.1", "minimatch": "^10.2.5", "npm-registry-fetch": "^19.1.1", - "playwright-core": "^1.62.0", "semver": "^7.8.5", "tsx": "^4.23.1", "typescript": "^6.0.3" @@ -2994,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": { @@ -3383,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", @@ -4234,18 +4233,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/playwright-core": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", - "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/pngjs": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", diff --git a/package.json b/package.json index 615a042..2fd1dbf 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "clean": "npx rimraf dist node_modules", "build": "vp pack", "dev": "vp pack --watch", - "analyze": "tsx src/bin/cli.ts analyze --check", "test": "vp test run -c vitest.config.ts", "test:coverage": "vp test run -c vitest.config.ts --coverage", "fmt": "vp fmt .", @@ -48,7 +47,7 @@ "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 analyze && npm run lint && npm run typecheck && npm run test:coverage && npm run build && npm run test:publint && npm run pack:check", + "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", "prepublishOnly": "npm run check && npm run test:templates" }, @@ -57,7 +56,6 @@ "js-yaml": "^5.2.1", "minimatch": "^10.2.5", "npm-registry-fetch": "^19.1.1", - "playwright-core": "^1.62.0", "semver": "^7.8.5", "tsx": "^4.23.1", "typescript": "^6.0.3" @@ -88,12 +86,5 @@ }, "engines": { "node": "^20.19.0 || >=22.12.0" - }, - "askr": { - "analyze": { - "exclude": [ - "templates/**" - ] - } } } diff --git a/skills/askr-ssr-ssg/SKILL.md b/skills/askr-ssr-ssg/SKILL.md index f748ea9..7bce84d 100644 --- a/skills/askr-ssr-ssg/SKILL.md +++ b/skills/askr-ssr-ssg/SKILL.md @@ -34,9 +34,7 @@ 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(() => { +registerRoutes(() => { page("/docs/{slug}", DocsPage, { entries: async () => [{ slug: "getting-started" }, { slug: "routing" }], }); diff --git a/src/analyze/call-graph.ts b/src/analyze/call-graph.ts deleted file mode 100644 index 53e6b15..0000000 --- a/src/analyze/call-graph.ts +++ /dev/null @@ -1,395 +0,0 @@ -import path from "node:path"; -import ts from "typescript"; -import type { WorkspaceAnalysisContext } from "./types"; - -export type LocalFunction = ts.SignatureDeclaration & { readonly body: ts.ConciseBody }; - -export interface LocalCallSite { - readonly node: ts.CallExpression; - readonly owner: LocalFunction | null; - readonly target: LocalFunction | null; -} - -export interface LocalCallGraph { - readonly calls: readonly LocalCallSite[]; - readonly constructions: readonly { - readonly node: ts.NewExpression; - readonly owner: LocalFunction | null; - }[]; - readonly functions: ReadonlySet; - functionForCall(call: ts.CallExpression): LocalFunction | null; - functionForExpression(expression: ts.Expression): LocalFunction | null; -} - -export interface DirectCallFact { - readonly value: Value; - readonly unstable?: boolean; -} - -export interface FunctionCallSummary { - readonly values: ReadonlySet; - readonly unstableValues: ReadonlySet; -} - -export interface TransitiveCallSummary { - readonly byFunction: ReadonlyMap>; - forCall(call: ts.CallExpression): FunctionCallSummary | null; -} - -const CALL_GRAPH_CACHE = new WeakMap(); - -function isLocalFunction(node: ts.Node | undefined): node is LocalFunction { - return Boolean( - node && - ts.isFunctionLike(node) && - "body" in node && - (node as { readonly body?: ts.ConciseBody }).body, - ); -} - -function containingLocalFunction(node: ts.Node): LocalFunction | null { - for (let current = node.parent; current; current = current.parent) { - if (isLocalFunction(current)) return current; - } - return null; -} - -function declarationFunction(declaration: ts.Declaration | undefined): LocalFunction | null { - if (!declaration) return null; - if (isLocalFunction(declaration)) return declaration; - if ( - ts.isVariableDeclaration(declaration) && - declaration.initializer && - isLocalFunction(declaration.initializer) - ) { - return declaration.initializer; - } - if (ts.isPropertyAssignment(declaration) && isLocalFunction(declaration.initializer)) { - return declaration.initializer; - } - return null; -} - -function symbolForExpression( - checker: ts.TypeChecker, - expression: ts.Expression, -): ts.Symbol | undefined { - const location = ts.isPropertyAccessExpression(expression) ? expression.name : expression; - let symbol = checker.getSymbolAtLocation(location) ?? checker.getSymbolAtLocation(expression); - if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) { - symbol = checker.getAliasedSymbol(symbol); - } - return symbol; -} - -function resolveLocalFunction( - checker: ts.TypeChecker, - sourceFiles: ReadonlySet, - expression: ts.Expression, -): LocalFunction | null { - const symbol = symbolForExpression(checker, expression); - const declarations = [symbol?.valueDeclaration, ...(symbol?.declarations ?? [])]; - for (const declaration of declarations) { - const fn = declarationFunction(declaration); - if (fn && sourceFiles.has(fn.getSourceFile())) return fn; - } - return null; -} - -function localNamedFunction(sourceFile: ts.SourceFile, name: string): LocalFunction | null { - for (const statement of sourceFile.statements) { - if (ts.isFunctionDeclaration(statement) && statement.name?.text === name) { - return isLocalFunction(statement) ? statement : null; - } - if (!ts.isVariableStatement(statement)) continue; - for (const declaration of statement.declarationList.declarations) { - if ( - ts.isIdentifier(declaration.name) && - declaration.name.text === name && - declaration.initializer && - isLocalFunction(declaration.initializer) - ) { - return declaration.initializer; - } - } - } - return null; -} - -export function localCallGraph(context: WorkspaceAnalysisContext): LocalCallGraph { - const cached = CALL_GRAPH_CACHE.get(context.program); - if (cached) return cached; - - const sourceFiles = new Set(context.sourceFiles); - const sourceByPath = new Map( - context.sourceFiles.map( - (sourceFile) => [path.resolve(sourceFile.fileName), sourceFile] as const, - ), - ); - const moduleCache = new Map(); - const resolveModule = ( - containingFile: ts.SourceFile, - specifier: string, - ): ts.SourceFile | null => { - if (!specifier.startsWith(".")) return null; - const cacheKey = `${containingFile.fileName}\0${specifier}`; - if (moduleCache.has(cacheKey)) return moduleCache.get(cacheKey) ?? null; - const base = path.resolve(path.dirname(containingFile.fileName), specifier); - const withoutJsExtension = base.replace(/\.(?:c|m)?js$/, ""); - const candidates = [ - base, - `${base}.ts`, - `${base}.tsx`, - `${base}.mts`, - `${base}.cts`, - `${base}.js`, - `${base}.jsx`, - `${withoutJsExtension}.ts`, - `${withoutJsExtension}.tsx`, - path.join(base, "index.ts"), - path.join(base, "index.tsx"), - path.join(base, "index.js"), - ]; - for (const candidate of candidates) { - const resolved = sourceByPath.get(candidate); - if (resolved) { - moduleCache.set(cacheKey, resolved); - return resolved; - } - } - moduleCache.set(cacheKey, null); - return null; - }; - const exportCache = new Map(); - const exportedFunction = ( - sourceFile: ts.SourceFile, - name: string, - seen = new Set(), - ): LocalFunction | null => { - const key = `${sourceFile.fileName}\0${name}`; - if (exportCache.has(key)) return exportCache.get(key) ?? null; - if (seen.has(key)) return null; - seen.add(key); - const direct = localNamedFunction(sourceFile, name); - if (direct) { - exportCache.set(key, direct); - return direct; - } - exportCache.set(key, null); - for (const statement of sourceFile.statements) { - if ( - name === "default" && - ts.isFunctionDeclaration(statement) && - statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) && - isLocalFunction(statement) - ) { - exportCache.set(key, statement); - return statement; - } - if (!ts.isExportDeclaration(statement)) continue; - const target = - statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) - ? resolveModule(sourceFile, statement.moduleSpecifier.text) - : sourceFile; - if (!target) continue; - if (!statement.exportClause) { - const resolved = exportedFunction(target, name, seen); - if (resolved) { - exportCache.set(key, resolved); - return resolved; - } - continue; - } - if (!ts.isNamedExports(statement.exportClause)) continue; - const element = statement.exportClause.elements.find( - (candidate) => candidate.name.text === name, - ); - if (!element) continue; - const imported = element.propertyName?.text ?? element.name.text; - const resolved = - target === sourceFile - ? localNamedFunction(sourceFile, imported) - : exportedFunction(target, imported, seen); - exportCache.set(key, resolved); - return resolved; - } - return null; - }; - const importBindings = new Map< - ts.SourceFile, - { - named: Map; - namespaces: Map; - } - >(); - for (const sourceFile of context.sourceFiles) { - const named = new Map(); - const namespaces = new Map(); - for (const statement of sourceFile.statements) { - if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) { - continue; - } - const target = resolveModule(sourceFile, statement.moduleSpecifier.text); - if (!target) continue; - const clause = statement.importClause; - if (clause?.name) { - named.set(clause.name.text, { source: target, imported: "default" }); - } - const bindings = clause?.namedBindings; - if (bindings && ts.isNamedImports(bindings)) { - for (const element of bindings.elements) { - named.set(element.name.text, { - source: target, - imported: element.propertyName?.text ?? element.name.text, - }); - } - } else if (bindings && ts.isNamespaceImport(bindings)) { - namespaces.set(bindings.name.text, target); - } - } - importBindings.set(sourceFile, { named, namespaces }); - } - const resolveImportedFunction = (expression: ts.Expression): LocalFunction | null => { - const imports = importBindings.get(expression.getSourceFile()); - if (!imports) return null; - if (ts.isIdentifier(expression)) { - const imported = imports.named.get(expression.text); - return imported ? exportedFunction(imported.source, imported.imported) : null; - } - if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression)) { - const source = imports.namespaces.get(expression.expression.text); - return source ? exportedFunction(source, expression.name.text) : null; - } - return null; - }; - const targetByExpression = new WeakMap(); - const resolveFunctionExpression = (expression: ts.Expression): LocalFunction | null => { - if (targetByExpression.has(expression)) return targetByExpression.get(expression) ?? null; - const target = - resolveLocalFunction(context.checker, sourceFiles, expression) ?? - resolveImportedFunction(expression); - targetByExpression.set(expression, target); - return target; - }; - const functions = new Set(); - const callNodes: ts.CallExpression[] = []; - const constructionNodes: ts.NewExpression[] = []; - const walk = (node: ts.Node): void => { - if (isLocalFunction(node)) functions.add(node); - if (ts.isCallExpression(node)) callNodes.push(node); - if (ts.isNewExpression(node)) constructionNodes.push(node); - ts.forEachChild(node, walk); - }; - for (const sourceFile of context.sourceFiles) walk(sourceFile); - - const targetByCall = new Map(); - const calls = callNodes.map((node): LocalCallSite => { - const target = resolveFunctionExpression(node.expression); - targetByCall.set(node, target); - return { - node, - owner: containingLocalFunction(node), - target, - }; - }); - const graph: LocalCallGraph = { - calls, - constructions: constructionNodes.map((node) => ({ - node, - owner: containingLocalFunction(node), - })), - functions, - functionForCall(call) { - return targetByCall.get(call) ?? null; - }, - functionForExpression(expression) { - return resolveFunctionExpression(expression); - }, - }; - CALL_GRAPH_CACHE.set(context.program, graph); - return graph; -} - -function addAll(target: Set, source: ReadonlySet): boolean { - let changed = false; - for (const value of source) { - if (target.has(value)) continue; - target.add(value); - changed = true; - } - return changed; -} - -export function summarizeTransitiveCalls( - context: WorkspaceAnalysisContext, - classifyDirectOperation: ( - node: ts.CallExpression | ts.NewExpression, - ) => DirectCallFact | null, - callIsUnstable: (call: ts.CallExpression, owner: LocalFunction) => boolean, -): TransitiveCallSummary { - const graph = localCallGraph(context); - const mutable = new Map; unstableValues: Set }>(); - const reverseEdges = new Map>(); - const ensure = (fn: LocalFunction) => { - const existing = mutable.get(fn); - if (existing) return existing; - const created = { values: new Set(), unstableValues: new Set() }; - mutable.set(fn, created); - return created; - }; - for (const fn of graph.functions) ensure(fn); - - for (const site of graph.calls) { - if (!site.owner) continue; - const direct = classifyDirectOperation(site.node); - if (direct) { - const summary = ensure(site.owner); - summary.values.add(direct.value); - if (direct.unstable) summary.unstableValues.add(direct.value); - } - if (!site.target) continue; - const incoming = reverseEdges.get(site.target) ?? []; - incoming.push({ - owner: site.owner, - unstable: callIsUnstable(site.node, site.owner), - }); - reverseEdges.set(site.target, incoming); - } - for (const site of graph.constructions) { - if (!site.owner) continue; - const direct = classifyDirectOperation(site.node); - if (!direct) continue; - const summary = ensure(site.owner); - summary.values.add(direct.value); - if (direct.unstable) summary.unstableValues.add(direct.value); - } - - const queue = [...graph.functions]; - const queued = new Set(queue); - for (let index = 0; index < queue.length; index += 1) { - const callee = queue[index]!; - queued.delete(callee); - const calleeSummary = ensure(callee); - for (const edge of reverseEdges.get(callee) ?? []) { - const ownerSummary = ensure(edge.owner); - let changed = addAll(ownerSummary.values, calleeSummary.values); - changed = - addAll( - ownerSummary.unstableValues, - edge.unstable ? calleeSummary.values : calleeSummary.unstableValues, - ) || changed; - if (changed && !queued.has(edge.owner)) { - queue.push(edge.owner); - queued.add(edge.owner); - } - } - } - - return { - byFunction: mutable, - forCall(call) { - const target = graph.functionForCall(call); - return target ? (mutable.get(target) ?? null) : null; - }, - }; -} diff --git a/src/analyze/project.ts b/src/analyze/project.ts index ff041b6..bd77d12 100644 --- a/src/analyze/project.ts +++ b/src/analyze/project.ts @@ -11,7 +11,6 @@ export const DEFAULT_ANALYZE_EXCLUDES = [ "**/build/**", "**/coverage/**", "**/.git/**", - "**/.askr/**", "**/.next/**", "**/.output/**", "**/.turbo/**", diff --git a/src/analyze/rules.ts b/src/analyze/rules.ts index 2b9425f..02785c6 100644 --- a/src/analyze/rules.ts +++ b/src/analyze/rules.ts @@ -6,7 +6,6 @@ import { RENDER_SCOPED_CONCEPTS, type ImportedAskrBinding, } from "./catalog"; -import { localCallGraph, summarizeTransitiveCalls } from "./call-graph"; import { workspaceRelativeFile } from "./project"; import type { AnalyzeDiagnostic, @@ -177,11 +176,9 @@ function containingFunction(node: ts.Node): ts.SignatureDeclaration | 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) && !isConstantCondition(current.expression)) { - return true; - } if ( - (ts.isConditionalExpression(current) && !isConstantCondition(current.condition)) || + ts.isIfStatement(current) || + ts.isConditionalExpression(current) || ts.isSwitchStatement(current) || ts.isForStatement(current) || ts.isForInStatement(current) || @@ -196,54 +193,11 @@ function isControlFlowAncestor(node: ts.Node, boundary: ts.Node): boolean { ts.isBinaryExpression(current) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes( current.operatorToken.kind, - ) && - !isConstantCondition(current.left) + ) ) { return true; } } - return hasConditionalEarlyExitBefore(node, boundary); -} - -function containsFunctionExit(node: ts.Node): boolean { - let found = false; - const walk = (candidate: ts.Node): void => { - if (found || (candidate !== node && ts.isFunctionLike(candidate))) return; - if (ts.isReturnStatement(candidate) || ts.isThrowStatement(candidate)) { - found = true; - return; - } - ts.forEachChild(candidate, walk); - }; - walk(node); - return found; -} - -function hasConditionalEarlyExitBefore(node: ts.Node, boundary: ts.Node): boolean { - let child: ts.Node = node; - for (let current = node.parent; current && current !== boundary; current = current.parent) { - if (ts.isBlock(current)) { - const statement = current.statements.find( - (candidate) => - candidate === child || (candidate.pos <= child.pos && child.end <= candidate.end), - ); - const index = statement ? current.statements.indexOf(statement) : -1; - if ( - index > 0 && - current.statements - .slice(0, index) - .some( - (candidate) => - ts.isIfStatement(candidate) && - !isConstantCondition(candidate.expression) && - containsFunctionExit(candidate), - ) - ) { - return true; - } - } - child = current; - } return false; } @@ -256,55 +210,6 @@ function functionName(node: ts.SignatureDeclaration): string | null { return null; } -const EAGER_CONTROL_CONCEPTS = new Set(["Case", "For", "Show"]); - -function isConstantCondition(expression: ts.Expression): boolean { - if (ts.isParenthesizedExpression(expression)) return isConstantCondition(expression.expression); - return ( - expression.kind === ts.SyntaxKind.TrueKeyword || - expression.kind === ts.SyntaxKind.FalseKeyword || - expression.kind === ts.SyntaxKind.NullKeyword || - ts.isNumericLiteral(expression) || - ts.isStringLiteral(expression) || - ts.isNoSubstitutionTemplateLiteral(expression) - ); -} - -function unstableControlConditional( - node: ts.JsxOpeningLikeElement, - bindings: SourceBindings, -): ts.ConditionalExpression | ts.BinaryExpression | null { - const ownElement = - ts.isJsxOpeningElement(node) && ts.isJsxElement(node.parent) ? node.parent : null; - let child: ts.Node = node; - for (let current = node.parent; current; current = current.parent) { - if (ts.isFunctionLike(current)) return null; - if (current !== ownElement && ts.isJsxElement(current)) { - const name = canonicalJsxName(current.openingElement.tagName, bindings); - if (name && EAGER_CONTROL_CONCEPTS.has(name)) return null; - } - if ( - ts.isConditionalExpression(current) && - (current.whenTrue === child || current.whenFalse === child) && - !isConstantCondition(current.condition) - ) { - return current; - } - if ( - ts.isBinaryExpression(current) && - [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes( - current.operatorToken.kind, - ) && - current.right === child && - !isConstantCondition(current.left) - ) { - return current; - } - child = current; - } - return null; -} - const stableRenderRule: AnalyzeRule = { id: "askr/stable-render-call", category: "correctness", @@ -312,56 +217,12 @@ const stableRenderRule: AnalyzeRule = { description: "Render-scoped Askr primitives must have stable top-level call order.", analyze(context) { const diagnostics: AnalyzeDiagnostic[] = []; - const wrapperSummary = summarizeTransitiveCalls( - context, - (call) => { - if (!ts.isCallExpression(call)) return null; - const name = canonicalCallName(call.expression, sourceBindings(call.getSourceFile())); - return name && (RENDER_SCOPED_CONCEPTS.has(name) || POSITIONAL_DATA_CONCEPTS.has(name)) - ? { - value: name, - unstable: Boolean( - containingFunction(call) && isControlFlowAncestor(call, containingFunction(call)!), - ), - } - : null; - }, - isControlFlowAncestor, - ); 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))) { - const summary = wrapperSummary.forCall(node); - const owner = containingFunction(node); - if ( - !summary || - summary.values.size === 0 || - !owner || - (!/^[A-Z]/.test(functionName(owner) ?? "") && !containsJsx(owner)) || - (!isControlFlowAncestor(node, owner) && summary.unstableValues.size === 0) - ) { - return; - } - const conditionalCallSite = isControlFlowAncestor(node, owner); - const conditionalWrapperInternals = summary.unstableValues.size > 0; - const concepts = [ - ...(conditionalWrapperInternals ? summary.unstableValues : summary.values), - ] - .sort() - .join(", "); - const message = - conditionalCallSite && conditionalWrapperInternals - ? `${node.expression.getText()}() is called conditionally and transitively contains conditionally executed render-owned Askr APIs (${concepts}).` - : conditionalCallSite - ? `${node.expression.getText()}() is called conditionally and transitively calls render-owned Askr APIs (${concepts}).` - : `${node.expression.getText()}() transitively contains conditionally executed render-owned Askr APIs (${concepts}).`; - const remediation = conditionalCallSite - ? "Call the wrapper unconditionally at the top level and branch on its result or inputs." - : "Make the render-owned call unconditional inside the wrapper and branch on its result or inputs."; - diagnostics.push(diagnostic(context, node.expression, this, message, remediation)); return; } const owner = containingFunction(node); @@ -395,400 +256,6 @@ const stableRenderRule: AnalyzeRule = { ); } }); - visit(sourceFile, (node) => { - if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return; - const name = canonicalJsxName(node.tagName, bindings); - if (!name || !EAGER_CONTROL_CONCEPTS.has(name)) return; - const conditional = unstableControlConditional(node, bindings); - const owner = containingFunction(node); - const followsEarlyReturn = Boolean(owner && hasConditionalEarlyExitBefore(node, owner)); - if (!conditional && !followsEarlyReturn) return; - const remediation = followsEarlyReturn - ? name === "For" - ? "Keep mounted and represent the unavailable case with an empty each source." - : name === "Show" - ? "Keep mounted and move the condition into its when prop." - : "Keep mounted and express the condition in its branches." - : name === "Show" - ? "Mount unconditionally and move the condition into its when prop." - : "Replace the ternary or logical expression with a boundary."; - diagnostics.push( - diagnostic( - context, - node.tagName, - this, - followsEarlyReturn - ? `<${name}> is reached after a conditional early return, so its render position is unstable.` - : `<${name}> is mounted behind a changing conditional, so its render position is unstable.`, - remediation, - ), - ); - }); - } - return diagnostics; - }, -}; - -type RenderSideEffect = "listener" | "observer" | "subscription" | "timer"; - -const TIMER_STARTS = new Set(["requestAnimationFrame", "setInterval", "setTimeout"]); -const OBSERVER_CONSTRUCTORS = new Set([ - "IntersectionObserver", - "MutationObserver", - "ResizeObserver", -]); -const SUBSCRIPTION_CONSTRUCTORS = new Set(["BroadcastChannel", "EventSource", "WebSocket"]); -const PLATFORM_EVENT_TARGET_TYPES = - /^(?:AbortSignal|Document|EventTarget|HTMLElement|MediaQueryList|Window)(?:<.*>)?$/; -const PROJECT_SOURCE_FILES = new WeakMap>(); -const CLEANUP_CALLS = new WeakMap(); - -function projectDeclaredIdentifier( - identifier: ts.Identifier, - context: WorkspaceAnalysisContext, -): boolean { - let symbol = context.checker.getSymbolAtLocation(identifier); - if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) { - symbol = context.checker.getAliasedSymbol(symbol); - } - let sourceFiles = PROJECT_SOURCE_FILES.get(context.program); - if (!sourceFiles) { - sourceFiles = new Set(context.sourceFiles); - PROJECT_SOURCE_FILES.set(context.program, sourceFiles); - } - return ( - symbol?.declarations?.some((declaration) => sourceFiles.has(declaration.getSourceFile())) ?? - false - ); -} - -function knownGlobalObject(expression: ts.Expression, context: WorkspaceAnalysisContext): boolean { - return ( - ts.isIdentifier(expression) && - ["document", "globalThis", "window"].includes(expression.text) && - !projectDeclaredIdentifier(expression, context) - ); -} - -function typedPlatformEventTarget( - expression: ts.Expression, - context: WorkspaceAnalysisContext, -): boolean { - if (knownGlobalObject(expression, context)) return true; - if (!ts.isIdentifier(expression)) return false; - const symbol = context.checker.getSymbolAtLocation(expression); - return Boolean( - symbol?.declarations?.some((declaration) => { - if ( - !ts.isVariableDeclaration(declaration) && - !ts.isParameter(declaration) && - !ts.isPropertyDeclaration(declaration) - ) { - return false; - } - return Boolean( - declaration.type && PLATFORM_EVENT_TARGET_TYPES.test(declaration.type.getText()), - ); - }), - ); -} - -function directGlobalCall( - call: ts.CallExpression, - names: ReadonlySet, - context: WorkspaceAnalysisContext, -): string | null { - if ( - ts.isIdentifier(call.expression) && - names.has(call.expression.text) && - !projectDeclaredIdentifier(call.expression, context) - ) { - return call.expression.text; - } - if ( - ts.isPropertyAccessExpression(call.expression) && - names.has(call.expression.name.text) && - knownGlobalObject(call.expression.expression, context) - ) { - return call.expression.name.text; - } - return null; -} - -function platformConstructorName( - expression: ts.LeftHandSideExpression, - context: WorkspaceAnalysisContext, -): string | null { - if (ts.isIdentifier(expression) && !projectDeclaredIdentifier(expression, context)) { - return expression.text; - } - if ( - ts.isPropertyAccessExpression(expression) && - knownGlobalObject(expression.expression, context) - ) { - return expression.name.text; - } - return null; -} - -function renderSideEffect( - node: ts.CallExpression | ts.NewExpression, - context: WorkspaceAnalysisContext, -): RenderSideEffect | null { - if (ts.isNewExpression(node)) { - const name = platformConstructorName(node.expression, context); - if (name && OBSERVER_CONSTRUCTORS.has(name)) return "observer"; - if (name && SUBSCRIPTION_CONSTRUCTORS.has(name)) return "subscription"; - return null; - } - if (directGlobalCall(node, TIMER_STARTS, context)) return "timer"; - if ( - ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === "addEventListener" && - typedPlatformEventTarget(node.expression.expression, context) - ) { - return "listener"; - } - if ( - ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === "watchPosition" && - ts.isPropertyAccessExpression(node.expression.expression) && - node.expression.expression.name.text === "geolocation" && - ts.isIdentifier(node.expression.expression.expression) && - node.expression.expression.expression.text === "navigator" && - !projectDeclaredIdentifier(node.expression.expression.expression, context) - ) { - return "subscription"; - } - return null; -} - -function terminalCleanupFunction( - callback: ts.SignatureDeclaration, - context: WorkspaceAnalysisContext, -): ts.SignatureDeclaration | null { - if (!("body" in callback) || !callback.body) return null; - let returned: ts.Expression | undefined; - if (ts.isArrowFunction(callback) && !ts.isBlock(callback.body)) { - returned = callback.body; - } else if (ts.isBlock(callback.body)) { - const last = callback.body.statements.at(-1); - if (last && ts.isReturnStatement(last)) returned = last.expression; - } - return resolvedFunction(returned, context); -} - -function cleanupCalls( - callback: ts.SignatureDeclaration, - context: WorkspaceAnalysisContext, -): readonly ts.CallExpression[] { - const cached = CLEANUP_CALLS.get(callback); - if (cached) return cached; - const cleanup = terminalCleanupFunction(callback, context); - if (!cleanup) { - CLEANUP_CALLS.set(callback, []); - return []; - } - const calls: ts.CallExpression[] = []; - const walk = (node: ts.Node): void => { - if (node !== cleanup && ts.isFunctionLike(node)) return; - if (ts.isCallExpression(node)) calls.push(node); - ts.forEachChild(node, walk); - }; - walk(cleanup); - CLEANUP_CALLS.set(callback, calls); - return calls; -} - -function assignedEffectReference(node: ts.CallExpression | ts.NewExpression): ts.Expression | null { - const parent = node.parent; - if (ts.isVariableDeclaration(parent) && parent.initializer === node) { - return ts.isIdentifier(parent.name) ? parent.name : null; - } - if ( - ts.isBinaryExpression(parent) && - parent.right === node && - parent.operatorToken.kind === ts.SyntaxKind.EqualsToken - ) { - return parent.left; - } - return null; -} - -function sameReference( - left: ts.Expression | undefined, - right: ts.Expression | undefined, - context: WorkspaceAnalysisContext, -): boolean { - if (!left || !right) return false; - let leftSymbol = context.checker.getSymbolAtLocation(left); - let rightSymbol = context.checker.getSymbolAtLocation(right); - if (leftSymbol && (leftSymbol.flags & ts.SymbolFlags.Alias) !== 0) { - leftSymbol = context.checker.getAliasedSymbol(leftSymbol); - } - if (rightSymbol && (rightSymbol.flags & ts.SymbolFlags.Alias) !== 0) { - rightSymbol = context.checker.getAliasedSymbol(rightSymbol); - } - return leftSymbol && rightSymbol - ? leftSymbol === rightSymbol - : left.getText() === right.getText(); -} - -function taskCleansDirectEffect( - callback: ts.SignatureDeclaration, - node: ts.CallExpression | ts.NewExpression, - effect: RenderSideEffect, - context: WorkspaceAnalysisContext, -): boolean { - const cleanup = cleanupCalls(callback, context); - if (effect === "listener" && ts.isCallExpression(node)) { - if ( - !ts.isPropertyAccessExpression(node.expression) || - node.expression.name.text !== "addEventListener" - ) { - return false; - } - const startExpression = node.expression; - return cleanup.some( - (call) => - ts.isPropertyAccessExpression(call.expression) && - call.expression.name.text === "removeEventListener" && - sameReference(call.expression.expression, startExpression.expression, context) && - sameReference(call.arguments[0], node.arguments[0], context) && - sameReference(call.arguments[1], node.arguments[1], context) && - (node.arguments[2] === undefined || - sameReference(call.arguments[2], node.arguments[2], context)), - ); - } - - const handle = assignedEffectReference(node); - if (!handle) return false; - if (effect === "timer" && ts.isCallExpression(node)) { - const start = directGlobalCall(node, TIMER_STARTS, context); - const expected = - start === "setInterval" - ? "clearInterval" - : start === "setTimeout" - ? "clearTimeout" - : start === "requestAnimationFrame" - ? "cancelAnimationFrame" - : null; - const expectedSet = expected ? new Set([expected]) : new Set(); - return cleanup.some( - (call) => - Boolean(directGlobalCall(call, expectedSet, context)) && - sameReference(call.arguments[0], handle, context), - ); - } - if (effect === "observer") { - return cleanup.some( - (call) => - ts.isPropertyAccessExpression(call.expression) && - call.expression.name.text === "disconnect" && - sameReference(call.expression.expression, handle, context), - ); - } - if (effect === "subscription") { - return cleanup.some((call) => { - if ( - ts.isPropertyAccessExpression(call.expression) && - (call.expression.name.text === "close" || call.expression.name.text === "unsubscribe") && - sameReference(call.expression.expression, handle, context) - ) { - return true; - } - return ( - ts.isCallExpression(node) && - ts.isPropertyAccessExpression(call.expression) && - call.expression.name.text === "clearWatch" && - ts.isPropertyAccessExpression(call.expression.expression) && - call.expression.expression.name.text === "geolocation" && - ts.isIdentifier(call.expression.expression.expression) && - call.expression.expression.expression.text === "navigator" && - !projectDeclaredIdentifier(call.expression.expression.expression, context) && - sameReference(call.arguments[0], handle, context) - ); - }); - } - return false; -} - -function isRenderFunction(owner: ts.SignatureDeclaration): boolean { - return /^[A-Z]/.test(functionName(owner) ?? "") || containsJsx(owner); -} - -const renderSideEffectRule: AnalyzeRule = { - id: "askr/render-side-effect", - category: "correctness", - severity: "error", - description: "Platform side effects started during render require lifecycle-owned cleanup.", - analyze(context) { - const diagnostics: AnalyzeDiagnostic[] = []; - const graph = localCallGraph(context); - const summary = summarizeTransitiveCalls( - context, - (node) => { - const effect = renderSideEffect(node, context); - return effect ? { value: effect } : null; - }, - () => false, - ); - const lifecycleCallbacks = new Set(); - for (const site of graph.calls) { - const name = canonicalCallName( - site.node.expression, - sourceBindings(site.node.getSourceFile()), - ); - if (name !== "task") continue; - const argument = site.node.arguments[0]; - const callback = - resolvedFunction(argument, context) ?? - (argument ? graph.functionForExpression(argument) : null); - if (callback) lifecycleCallbacks.add(callback); - } - - const report = ( - node: ts.CallExpression | ts.NewExpression, - effects: ReadonlySet, - direct = false, - ): void => { - const owner = containingFunction(node); - if (!owner) return; - const managed = lifecycleCallbacks.has(owner); - if ( - managed && - direct && - [...effects].every((effect) => taskCleansDirectEffect(owner, node, effect, context)) - ) { - return; - } - if (!managed && !isRenderFunction(owner)) return; - const effectNames = [...effects].sort().join(", "); - diagnostics.push( - diagnostic( - context, - node.expression, - this, - managed - ? `This task starts ${effectNames} side effects without returning matching cleanup.` - : `${node.expression.getText()} starts unmanaged ${effectNames} side effects during render.`, - "Start the effect in task() and return cleanup that clears, disconnects, removes, closes, or unsubscribes it.", - ), - ); - }; - - for (const site of graph.calls) { - const direct = renderSideEffect(site.node, context); - if (direct) { - report(site.node, new Set([direct]), true); - continue; - } - const wrapper = summary.forCall(site.node); - if (wrapper && wrapper.values.size > 0) report(site.node, wrapper.values); - } - for (const site of graph.constructions) { - const direct = renderSideEffect(site.node, context); - if (direct) report(site.node, new Set([direct]), true); } return diagnostics; }, @@ -1230,59 +697,40 @@ const stableKeyRule: AnalyzeRule = { }, }; -function mapResultIsJsxChild(node: ts.CallExpression): boolean { - let current: ts.Expression = node; - for (;;) { - const parent = current.parent; - if (ts.isJsxExpression(parent)) return !ts.isJsxAttribute(parent.parent); - if ( - (ts.isParenthesizedExpression(parent) || - ts.isAsExpression(parent) || - ts.isTypeAssertionExpression(parent) || - ts.isNonNullExpression(parent) || - ts.isSatisfiesExpression(parent)) && - parent.expression === current - ) { - current = parent; - continue; - } - if ( - ts.isConditionalExpression(parent) && - (parent.whenTrue === current || parent.whenFalse === current) - ) { - current = parent; - continue; - } - if ( - ts.isBinaryExpression(parent) && - ((parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && - parent.right === current) || - (parent.operatorToken.kind === ts.SyntaxKind.BarBarToken && - (parent.left === current || parent.right === current)) || - (parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && - (parent.left === current || parent.right === current))) - ) { - current = parent; - continue; - } - return false; +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: "Collection arrays rendered as JSX children should use For.", + 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" || - !mapResultIsJsxChild(node) + !reactiveMapReceiver(node.expression.expression, state.getters) || + !node.parent || + !ts.isJsxExpression(node.parent) ) { return; } @@ -1291,7 +739,7 @@ const preferForRule: AnalyzeRule = { context, node.expression.name, this, - "A collection array is rendered with .map(), bypassing keyed reconciliation.", + "A reactive collection is rendered with .map(), bypassing keyed reconciliation.", "Render it with . This semantic rewrite is report-only.", ), ); @@ -1301,11 +749,7 @@ const preferForRule: AnalyzeRule = { }, }; -const CONTAINS_JSX = new WeakMap(); - function containsJsx(node: ts.Node): boolean { - const cached = CONTAINS_JSX.get(node); - if (cached !== undefined) return cached; let found = false; const walk = (candidate: ts.Node): void => { if (found) return; @@ -1316,7 +760,6 @@ function containsJsx(node: ts.Node): boolean { ts.forEachChild(candidate, walk); }; walk(node); - CONTAINS_JSX.set(node, found); return found; } @@ -2713,7 +2156,6 @@ const parseErrorRule: AnalyzeRule = { export const ANALYZE_RULES: readonly AnalyzeRule[] = [ parseErrorRule, stableRenderRule, - renderSideEffectRule, stateAccessRule, stateRenderWriteRule, resourceCancellationRule, diff --git a/src/bin/cli.ts b/src/bin/cli.ts index f6903a7..612ca07 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -16,12 +16,12 @@ function printHelp(io: CliIo = console): void { 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(" database Generate, validate, and migrate project databases"); 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(" verify-hydration Verify SSG DOM structure in a real browser"); 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"); @@ -40,11 +40,11 @@ function printHelp(io: CliIo = console): void { io.log(" askr doctor"); io.log(" askr repair"); io.log(" askr check"); + io.log(" askr database validate"); io.log(" askr skills install"); io.log(" askr openapi --check"); io.log(" askr skills review foundation --cwd ./candidate-app"); io.log(" askr ssg --config ./ssg.config.ts --output ./dist/static"); - io.log(" askr verify-hydration --output ./dist --route /"); io.log(" askr outdated"); io.log(" askr update"); io.log(" askr upgrade"); @@ -76,6 +76,11 @@ export async function runCli( return runAddCli(args.slice(1), io); } + if (command === "database") { + const { runDatabaseCommand } = await import("./database"); + return runDatabaseCommand(args.slice(1), io); + } + if (command === "analyze") { const { runAnalyzeCli } = await import("./analyze"); return runAnalyzeCli(args.slice(1), io); @@ -96,11 +101,6 @@ export async function runCli( return runSsgCli(args.slice(1), undefined, io); } - if (command === "verify-hydration") { - const { runVerifyHydrationCli } = await import("./verify-hydration"); - return runVerifyHydrationCli(args.slice(1), undefined, io); - } - if (command === "openapi") { const { runOpenApiCli } = await import("./openapi"); return runOpenApiCli(args.slice(1), io); diff --git a/src/bin/database.ts b/src/bin/database.ts new file mode 100644 index 0000000..94ef0fe --- /dev/null +++ b/src/bin/database.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +import { createRequire } from "node:module"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +type CliIo = Pick; + +export interface OrmTooling { + runDatabaseCli( + args: readonly string[], + options: { readonly cwd: string; readonly io: CliIo }, + ): Promise; +} + +export async function loadOrmTooling(cwd: string): Promise { + const require = createRequire(path.join(path.resolve(cwd), "package.json")); + let entry: string; + try { + const manifestPath = require.resolve("@askrjs/orm/package.json"); + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as { + exports?: { + "./tooling"?: string | { readonly import?: string }; + }; + }; + const toolingExport = manifest.exports?.["./tooling"]; + const relative = typeof toolingExport === "string" ? toolingExport : toolingExport?.import; + if (!relative) throw new Error("Missing import export for @askrjs/orm/tooling."); + entry = path.resolve(path.dirname(manifestPath), relative); + } catch (error) { + throw new Error( + "This project does not have @askrjs/orm installed. Install it before running `askr database`.", + { cause: error }, + ); + } + const tooling = (await import(pathToFileURL(entry).href)) as Partial; + if (typeof tooling.runDatabaseCli !== "function") { + throw new Error( + "The installed @askrjs/orm package does not expose compatible database tooling.", + ); + } + return tooling as OrmTooling; +} + +export async function runDatabaseCommand( + args: readonly string[], + io: CliIo = console, + cwd = process.cwd(), + loader: (cwd: string) => Promise = loadOrmTooling, +): Promise { + try { + const tooling = await loader(cwd); + return tooling.runDatabaseCli(args, { cwd, io }); + } catch (error) { + io.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +export async function runDatabaseValidation( + cwd: string, + loader: (cwd: string) => Promise = loadOrmTooling, +): Promise<{ + readonly status: "passed" | "failed"; + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}> { + const stdout: string[] = []; + const stderr: string[] = []; + const code = await runDatabaseCommand( + ["validate", "--cwd", cwd], + { + log: (...values: unknown[]) => stdout.push(values.join(" ")), + error: (...values: unknown[]) => stderr.push(values.join(" ")), + }, + cwd, + loader, + ); + return { + status: code === 0 ? "passed" : "failed", + exitCode: code, + stdout: stdout.join("\n"), + stderr: stderr.join("\n"), + }; +} diff --git a/src/bin/verify-hydration.ts b/src/bin/verify-hydration.ts deleted file mode 100644 index 4f68a7b..0000000 --- a/src/bin/verify-hydration.ts +++ /dev/null @@ -1,482 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process"; -import fs from "node:fs/promises"; -import http, { type Server } from "node:http"; -import path from "node:path"; -import type { Browser, BrowserContext, Page } from "playwright-core"; -import { isDirectExecution } from "./is-direct-execution"; - -type CliIo = Pick; - -interface ParsedVerifyHydrationArgs { - cwd: string; - outputDir: string; - routes: string[]; - rootSelector: string; - buildScript: string; - build: boolean; - timeoutMs: number; - browserChannel?: string; - help: boolean; - errors: string[]; -} - -interface RouteMetadata { - path: string; - filePath: string; - status?: string; -} - -interface StaticOutputServer { - origin: string; - close(): Promise; -} - -interface VerifyHydrationDeps { - runBuild?: (cwd: string, script: string) => Promise; - launchBrowser?: (channel?: string) => Promise; - startServer?: ( - outputDir: string, - routes: readonly RouteMetadata[], - ) => Promise; -} - -interface DomSnapshot { - lines: string[]; -} - -const helpText = ` -askr verify-hydration - Verify SSG DOM structure in a real browser - -Usage: - askr verify-hydration [--output ] [--route ...] - -Options: - --cwd Project directory (default: current directory) - --output Generated SSG output (default: dist) - --route Route to verify; repeat to select a route set - --root Hydrated application root (default: #app) - --build-script npm script that builds SSG output (default: build) - --no-build Verify existing output without running a build - --timeout Per-route browser timeout (default: 10000) - --browser-channel Browser channel: chrome, msedge, or playwright - --help Show this help message - -When --route is omitted, routes are read from /metadata.json. -`; - -function parsePositiveInteger(value: string, option: string, errors: string[]): number | undefined { - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - errors.push(`${option} must be a positive integer`); - return undefined; - } - return parsed; -} - -export function parseVerifyHydrationArgs( - args: string[], - defaultCwd = process.cwd(), -): ParsedVerifyHydrationArgs { - const parsed: ParsedVerifyHydrationArgs = { - cwd: defaultCwd, - outputDir: "dist", - routes: [], - rootSelector: "#app", - buildScript: "build", - build: true, - timeoutMs: 10_000, - help: false, - errors: [], - }; - const takeValue = (index: number, option: string): string | undefined => { - const value = args[index + 1]; - if (!value || value.startsWith("-")) { - parsed.errors.push(`Missing value for ${option}`); - return undefined; - } - return value; - }; - - for (let index = 0; index < args.length; index += 1) { - const argument = args[index]; - if ( - argument === "--cwd" || - argument === "--output" || - argument === "--route" || - argument === "--root" || - argument === "--build-script" || - argument === "--timeout" || - argument === "--browser-channel" - ) { - const value = takeValue(index, argument); - if (!value) continue; - index += 1; - if (argument === "--cwd") parsed.cwd = value; - else if (argument === "--output") parsed.outputDir = value; - else if (argument === "--route") parsed.routes.push(value); - else if (argument === "--root") parsed.rootSelector = value; - else if (argument === "--build-script") parsed.buildScript = value; - else if (argument === "--browser-channel") parsed.browserChannel = value; - else { - const timeout = parsePositiveInteger(value, "--timeout", parsed.errors); - if (timeout) parsed.timeoutMs = timeout; - } - } else if (argument === "--no-build") parsed.build = false; - else if (argument === "--help" || argument === "-h") parsed.help = true; - else parsed.errors.push(`Unknown option: ${argument}`); - } - - parsed.cwd = path.resolve(defaultCwd, parsed.cwd); - parsed.outputDir = path.resolve(parsed.cwd, parsed.outputDir); - return parsed; -} - -function normalizeRoute(route: string): string { - const pathname = new URL(route, "http://askr.local").pathname; - return pathname === "/" ? pathname : pathname.replace(/\/+$/, ""); -} - -async function readRouteMetadata( - outputDir: string, - selectedRoutes: readonly string[], -): Promise { - const metadataPath = path.join(outputDir, "metadata.json"); - let metadata: unknown; - try { - metadata = JSON.parse(await fs.readFile(metadataPath, "utf8")); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Could not read SSG route metadata at ${metadataPath}: ${detail}`); - } - const routes = (metadata as { routes?: unknown }).routes; - if (!Array.isArray(routes)) { - throw new Error(`Invalid SSG route metadata at ${metadataPath}: routes must be an array.`); - } - const valid = routes.filter((entry): entry is RouteMetadata => - Boolean( - entry && - typeof entry === "object" && - typeof (entry as RouteMetadata).path === "string" && - typeof (entry as RouteMetadata).filePath === "string" && - (entry as RouteMetadata).status !== "error" && - (entry as RouteMetadata).status !== "removed", - ), - ); - const byPath = new Map(valid.map((entry) => [normalizeRoute(entry.path), entry])); - if (selectedRoutes.length === 0) { - return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path)); - } - - return [...new Set(selectedRoutes.map(normalizeRoute))].map((route) => { - const entry = byPath.get(route); - if (!entry) throw new Error(`Route ${route} is not present in ${metadataPath}.`); - return entry; - }); -} - -function contentType(filePath: string): string { - const extension = path.extname(filePath).toLowerCase(); - if (extension === ".html") return "text/html; charset=utf-8"; - if (extension === ".js" || extension === ".mjs") return "text/javascript; charset=utf-8"; - if (extension === ".css") return "text/css; charset=utf-8"; - if (extension === ".json") return "application/json; charset=utf-8"; - if (extension === ".svg") return "image/svg+xml"; - if (extension === ".png") return "image/png"; - if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg"; - if (extension === ".webp") return "image/webp"; - return "application/octet-stream"; -} - -function closeServer(server: Server): Promise { - return new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); -} - -export async function startStaticOutputServer( - outputDir: string, - routes: readonly RouteMetadata[], -): Promise { - const outputRoot = path.resolve(outputDir); - const routeFiles = new Map( - routes.map((entry) => [normalizeRoute(entry.path), path.resolve(outputRoot, entry.filePath)]), - ); - for (const [route, filePath] of routeFiles) { - const relative = path.relative(outputRoot, filePath); - if (relative.startsWith("..") || path.isAbsolute(relative)) { - throw new Error(`Route ${route} resolves outside the SSG output directory.`); - } - } - const server = http.createServer(async (request, response) => { - try { - const pathname = normalizeRoute(new URL(request.url ?? "/", "http://askr.local").pathname); - const candidate = routeFiles.get(pathname) ?? path.resolve(outputRoot, `.${pathname}`); - const relative = path.relative(outputRoot, candidate); - if (relative.startsWith("..") || path.isAbsolute(relative)) { - response.writeHead(404).end("Not found"); - return; - } - const stat = await fs.stat(candidate).catch(() => null); - const filePath = stat?.isDirectory() ? path.join(candidate, "index.html") : candidate; - const content = await fs.readFile(filePath); - response.writeHead(200, { - "cache-control": "no-store", - "content-type": contentType(filePath), - }); - response.end(content); - } catch { - response.writeHead(404).end("Not found"); - } - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - if (!address || typeof address === "string") { - await closeServer(server); - throw new Error("Hydration verification server did not bind a TCP port."); - } - return { - origin: `http://127.0.0.1:${address.port}`, - close: () => closeServer(server), - }; -} - -async function runNpmBuild(cwd: string, script: string): Promise { - await new Promise((resolve, reject) => { - const executable = process.platform === "win32" ? "npm.cmd" : "npm"; - const child = spawn(executable, ["run", script], { cwd, stdio: "inherit" }); - const timer = setTimeout(() => { - child.kill("SIGTERM"); - reject(new Error(`npm run ${script} timed out after 300000ms.`)); - }, 300_000); - child.once("error", (error) => { - clearTimeout(timer); - reject(error); - }); - child.once("exit", (code, signal) => { - clearTimeout(timer); - if (code === 0) resolve(); - else { - reject( - new Error( - `npm run ${script} failed${ - signal ? ` with signal ${signal}` : ` with exit code ${code}` - }.`, - ), - ); - } - }); - }); -} - -async function launchChromium(channel?: string): Promise { - const { chromium } = await import("playwright-core"); - const requested = channel ?? process.env.ASKR_BROWSER_CHANNEL ?? "chrome"; - try { - return requested === "playwright" - ? await chromium.launch({ headless: true }) - : await chromium.launch({ channel: requested, headless: true }); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error( - `Could not launch the ${requested} browser channel. Install Chrome, pass ` + - `--browser-channel msedge, or run "npx playwright-core install chromium" and pass ` + - `--browser-channel playwright. ${detail}`, - ); - } -} - -async function snapshotRoot(page: Page, selector: string): Promise { - return page.evaluate((rootSelector) => { - interface BrowserNode { - readonly nodeType: number; - readonly childNodes: Iterable; - matches(selector: string): boolean; - readonly tagName: string; - } - const browserGlobal = globalThis as unknown as { - document: { querySelector(selector: string): BrowserNode | null }; - }; - const root = browserGlobal.document.querySelector(rootSelector); - if (!root) throw new Error(`Hydration root not found: ${rootSelector}`); - const lines: string[] = []; - const pending: Array<{ node: BrowserNode; path: string }> = [ - { node: root, path: rootSelector }, - ]; - while (pending.length > 0) { - const current = pending.pop(); - if (!current) break; - const { node, path: nodePath } = current; - if (node.nodeType !== 1) continue; - if (node.matches("script, style, link, meta, noscript, template")) continue; - lines.push(`${nodePath} <${node.tagName.toLowerCase()}>`); - const children = [...node.childNodes].filter( - (child) => - child.nodeType === 1 && !child.matches("script, style, link, meta, noscript, template"), - ); - for (let index = children.length - 1; index >= 0; index -= 1) { - pending.push({ node: children[index]!, path: `${nodePath}/${index}` }); - } - } - return { lines }; - }, selector); -} - -function firstDifference( - expected: DomSnapshot, - actual: DomSnapshot, -): { index: number; expected: string; actual: string } | null { - const length = Math.max(expected.lines.length, actual.lines.length); - for (let index = 0; index < length; index += 1) { - if (expected.lines[index] !== actual.lines[index]) { - return { - index, - expected: expected.lines[index] ?? "", - actual: actual.lines[index] ?? "", - }; - } - } - return null; -} - -async function loadSnapshot( - context: BrowserContext, - url: string, - selector: string, - timeoutMs: number, - settleHydration: boolean, -): Promise<{ snapshot: DomSnapshot; errors: string[] }> { - const page = await context.newPage(); - const errors: string[] = []; - page.on("pageerror", (error) => errors.push(error.message)); - page.on("console", (message) => { - if (message.type() === "error") errors.push(`console: ${message.text()}`); - }); - try { - page.setDefaultTimeout(timeoutMs); - const response = await page.goto(url, { waitUntil: "load", timeout: timeoutMs }); - if (!response?.ok()) { - throw new Error(`HTTP ${response?.status() ?? "failure"} loading ${url}`); - } - if (settleHydration) { - let timer: ReturnType | undefined; - try { - await Promise.race([ - page.evaluate(() => { - const animationFrame = ( - globalThis as unknown as { requestAnimationFrame(callback: () => void): number } - ).requestAnimationFrame; - return new Promise((resolve) => - animationFrame(() => animationFrame(() => resolve())), - ); - }), - new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new Error(`Hydration timeout: did not settle within ${timeoutMs}ms.`)), - timeoutMs, - ); - }), - ]); - } finally { - if (timer) clearTimeout(timer); - } - } - return { snapshot: await snapshotRoot(page, selector), errors }; - } finally { - await page.close(); - } -} - -export async function verifyHydrationRoutes( - browser: Browser, - origin: string, - routes: readonly RouteMetadata[], - rootSelector: string, - timeoutMs: number, -): Promise { - const failures: string[] = []; - const staticContext = await browser.newContext({ javaScriptEnabled: false }); - const hydratedContext = await browser.newContext({ javaScriptEnabled: true }); - try { - for (const route of routes) { - const url = `${origin}${normalizeRoute(route.path)}`; - try { - const expected = await loadSnapshot(staticContext, url, rootSelector, timeoutMs, false); - const actual = await loadSnapshot(hydratedContext, url, rootSelector, timeoutMs, true); - const difference = firstDifference(expected.snapshot, actual.snapshot); - if (difference) { - failures.push( - `${route.path}: DOM diverged at normalized entry ${difference.index}\n` + - ` static: ${difference.expected}\n` + - ` hydrated: ${difference.actual}`, - ); - } - for (const error of actual.errors) failures.push(`${route.path}: browser error: ${error}`); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - failures.push(`${route.path}: ${detail}`); - } - } - } finally { - await Promise.all([staticContext.close(), hydratedContext.close()]); - } - return failures; -} - -export async function runVerifyHydrationCli( - args: string[] = process.argv.slice(2), - deps: VerifyHydrationDeps = {}, - io: CliIo = console, -): Promise { - const parsed = parseVerifyHydrationArgs(args); - if (parsed.help) { - io.log(helpText); - return 0; - } - if (parsed.errors.length > 0) { - for (const error of parsed.errors) io.error(`Error: ${error}`); - return 1; - } - - let server: StaticOutputServer | undefined; - let browser: Browser | undefined; - try { - if (parsed.build) await (deps.runBuild ?? runNpmBuild)(parsed.cwd, parsed.buildScript); - const routes = await readRouteMetadata(parsed.outputDir, parsed.routes); - if (routes.length === 0) - throw new Error("SSG metadata contains no successful routes to verify."); - server = await (deps.startServer ?? startStaticOutputServer)(parsed.outputDir, routes); - browser = await (deps.launchBrowser ?? launchChromium)(parsed.browserChannel); - const failures = await verifyHydrationRoutes( - browser, - server.origin, - routes, - parsed.rootSelector, - parsed.timeoutMs, - ); - if (failures.length > 0) { - for (const failure of failures) io.error(`Hydration verification failed: ${failure}`); - return 1; - } - io.log(`Verified hydration DOM for ${routes.length} route(s).`); - return 0; - } catch (error) { - io.error(`Error: ${error instanceof Error ? error.message : String(error)}`); - return 1; - } finally { - await browser?.close().catch(() => undefined); - await server?.close().catch(() => undefined); - } -} - -async function main(): Promise { - process.exit(await runVerifyHydrationCli()); -} - -if (isDirectExecution(import.meta.url)) { - void main(); -} diff --git a/src/generate/generator.ts b/src/generate/generator.ts index 7d46320..3b1fee4 100644 --- a/src/generate/generator.ts +++ b/src/generate/generator.ts @@ -13,7 +13,7 @@ import { lookup } from "node:dns/promises"; import { request as httpsRequest } from "node:https"; import type { IncomingHttpHeaders, IncomingMessage } from "node:http"; import { BlockList, isIP } from "node:net"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { load } from "js-yaml"; @@ -448,17 +448,22 @@ async function fetchSource(uri: string, options: ResolvedLoadOptions): Promise { if (uri.startsWith("http://") || uri.startsWith("https://")) return fetchSource(uri, options); - const path = fileURLToPath(uri); + let path: string; + try { + path = fileURLToPath(uri); + } catch { + throw new GenerationError( + `Local OpenAPI reference escapes the specification directory: ${uri}`, + ); + } const canonical = await realpath(path); - const relativePath = options.localRootDirectory - ? relative(options.localRootDirectory, canonical) - : ".."; - if ( - !options.localRootDirectory || - relativePath === ".." || - relativePath.startsWith(`..${sep}`) || - isAbsolute(relativePath) - ) + if (!options.localRootDirectory) { + throw new GenerationError( + `Local OpenAPI reference escapes the specification directory: ${path}`, + ); + } + const relativePath = relative(options.localRootDirectory, canonical); + if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new GenerationError( `Local OpenAPI reference escapes the specification directory: ${path}`, ); diff --git a/src/guardrails/runner.ts b/src/guardrails/runner.ts index e24507b..578d833 100644 --- a/src/guardrails/runner.ts +++ b/src/guardrails/runner.ts @@ -31,6 +31,7 @@ export interface GuardrailRuntime { args: readonly string[], cwd: string, ) => Promise>; + readonly runDatabaseValidation?: (cwd: string) => Promise>; } function summary(findings: readonly GuardrailFinding[]): GuardrailSummary { @@ -310,13 +311,18 @@ export async function runCheck( const lockfiles = await existingLockfiles(project.root); const manager = managerCommand(lockfiles, rootWorkspace.manifest); const results: ScriptResult[] = []; + const hasDatabase = Boolean( + await fs.stat(path.join(project.root, "database", "index.ts")).catch(() => null), + ); + const selectedChecks = [...(hasDatabase ? (["database"] as const) : []), ...selected]; if (analysisHasBlockingFindings(analysis)) { - for (const name of selected) { + for (const name of selectedChecks) { results.push({ name, status: "skipped", - command: `${manager.executable} run ${name}`, + command: + name === "database" ? "askr database validate" : `${manager.executable} run ${name}`, exitCode: null, stdout: "", stderr: "", @@ -324,7 +330,32 @@ export async function runCheck( }); } } else { - for (const [index, name] of selected.entries()) { + for (const [index, name] of selectedChecks.entries()) { + if (name === "database") { + const runDatabase = + runtime.runDatabaseValidation ?? (await import("../bin/database")).runDatabaseValidation; + const result = await runDatabase(project.root); + results.push({ + name, + command: "askr database validate", + ...result, + }); + if (result.status === "failed") { + for (const skipped of selectedChecks.slice(index + 1)) { + results.push({ + name: skipped, + status: "skipped", + command: `${manager.executable} run ${skipped}`, + exitCode: null, + stdout: "", + stderr: "", + reason: "database validation failed", + }); + } + break; + } + continue; + } const args = manager.args(name); const result = await (runtime.runScript ?? defaultRunScript)( manager.executable, @@ -337,7 +368,7 @@ export async function runCheck( ...result, }); if (result.status === "failed") { - for (const skipped of selected.slice(index + 1)) { + for (const skipped of selectedChecks.slice(index + 1)) { results.push({ name: skipped, status: "skipped", diff --git a/src/update/planner.ts b/src/update/planner.ts index f00271f..305d012 100644 --- a/src/update/planner.ts +++ b/src/update/planner.ts @@ -403,11 +403,11 @@ function solveWorkspace( left.length - right.length || leftName.localeCompare(rightName), )[0]; const signature = component - .map((name) => { - const assignedVersion = assigned.get(name); - const domain = pruned.get(name) ?? []; - return `${name}:assigned=${JSON.stringify(assignedVersion ?? null)}:domain=${JSON.stringify(domain)}`; - }) + .map((name) => + assigned.has(name) + ? `${name}=assigned:${assigned.get(name)}` + : `${name}=domain:${(pruned.get(name) ?? []).join(",")}`, + ) .join("|"); if (memo.has(signature)) return; memo.add(signature); diff --git a/templates/spa/src/pages/app/_layout.tsx b/templates/spa/src/pages/app/_layout.tsx index aacd738..5ab26fe 100644 --- a/templates/spa/src/pages/app/_layout.tsx +++ b/templates/spa/src/pages/app/_layout.tsx @@ -6,7 +6,6 @@ import { SettingsIcon, SunIcon, } from '@askrjs/lucide'; -import { For } from '@askrjs/askr/control'; import { Link, navigate } from '@askrjs/askr/router'; import { Button } from '@askrjs/themes/components'; import { Container, Inline, Stack } from '@askrjs/themes/components'; @@ -43,16 +42,14 @@ export default function AppLayout({ children }: { children?: unknown }) { - item.href}> - {(item) => ( - - - {icons[item.icon]} - {item.label} - - - )} - + {appNavItems.map((item) => ( + + + {icons[item.icon]} + {item.label} + + + ))} diff --git a/templates/spa/src/pages/app/admin-home.tsx b/templates/spa/src/pages/app/admin-home.tsx index 763d8e5..74729b1 100644 --- a/templates/spa/src/pages/app/admin-home.tsx +++ b/templates/spa/src/pages/app/admin-home.tsx @@ -1,5 +1,4 @@ import { resource } from '@askrjs/askr/resources'; -import { For, Show } from '@askrjs/askr/control'; import { createPlot } from '@askrjs/charts'; import { AlertCircleIcon, RefreshCwIcon } from '@askrjs/lucide'; import { Button } from '@askrjs/themes/components'; @@ -27,154 +26,145 @@ export default function AdminHomePage() { const operations = resource(({ signal }) => loadOperations({ signal }), []); const snapshot = operations.value; - const errorState = ( -
-
- ); + if (operations.error && !snapshot) { + return ( +
+
+ ); + } return ( - - -
- - projection v{snapshot?.version ?? '...'} -

Workspace home

-

- A consistency-aware dashboard for agent runs, queue health, and - event-sourced read models. -

-
- - {operations.pending && snapshot ? refreshing : null} - - -
+ +
+ + projection v{snapshot?.version ?? '...'} +

Workspace home

+

+ A consistency-aware dashboard for agent runs, queue health, and + event-sourced read models. +

+
+ + {operations.pending && snapshot ? refreshing : null} + + +
+ + {operations.pending && !snapshot ? ( + + + + + + ) : null} - {operations.pending && !snapshot ? ( - - - - + {snapshot ? ( + <> + + {snapshot.metrics.map((metric) => ( + + ))} - ) : null} - - {(currentSnapshot) => ( - <> - - metric.label} + + + + Run throughput + + Accepted commands by work type. + + + + - {(metric) => ( - - )} - - - - - - - Run throughput - - Accepted commands by work type. - - - - - - - - - - - Projection lag - - Lower is better; stale states stay visible. - - - - - - - - - - + + + + + + + Projection lag + + Lower is better; stale states stay visible. + + + + + + + + + + - {currentSnapshot.consistency !== 'fresh' ? ( - - Read models are {currentSnapshot.consistency}. Last processed - event is {currentSnapshot.lastEventId}. - - ) : null} + {snapshot.consistency !== 'fresh' ? ( + + Read models are {snapshot.consistency}. Last processed event is{' '} + {snapshot.lastEventId}. + + ) : null} - - - Recent agent runs - - Run state is modeled as product state, not a single loading - boolean. - - - -
- - - - - - - - - - - run.id}> - {(run) => ( - - - - - - - )} - - -
RunStatusOwnerUpdated
- {run.title} - {run.id} - - - {run.owner}{formatRelativeTime(run.updatedAt)}
-
-
-
- - )} -
- - + + + Recent agent runs + + Run state is modeled as product state, not a single loading + boolean. + + + +
+ + + + + + + + + + + {snapshot.runs.map((run) => ( + + + + + + + ))} + +
RunStatusOwnerUpdated
+ {run.title} + {run.id} + + + {run.owner}{formatRelativeTime(run.updatedAt)}
+
+
+
+ + ) : null} + ); } diff --git a/templates/spa/src/pages/app/agent-runs.tsx b/templates/spa/src/pages/app/agent-runs.tsx index 4cb01ad..3d87336 100644 --- a/templates/spa/src/pages/app/agent-runs.tsx +++ b/templates/spa/src/pages/app/agent-runs.tsx @@ -4,7 +4,6 @@ import { Clock3Icon, ShieldAlertIcon, } from '@askrjs/lucide'; -import { For } from '@askrjs/askr/control'; import { Badge, Card, @@ -19,14 +18,12 @@ import StatusBadge, { } from '../../components/shared/status-badge'; const runs: Array<{ - id: string; title: string; status: RunStatus; event: string; description: string; }> = [ { - id: 'reconcile-billing-projection', title: 'Reconcile billing projection', status: 'running', event: 'tool call: compare-ledger', @@ -34,7 +31,6 @@ const runs: Array<{ 'Streaming events are appended to the timeline and reconciled by event id.', }, { - id: 'approve-enterprise-workspace', title: 'Approve enterprise workspace', status: 'requires-action', event: 'approval requested', @@ -42,7 +38,6 @@ const runs: Array<{ 'Human gates are explicit product states, not hidden inside generated text.', }, { - id: 'refresh-onboarding-cohort', title: 'Refresh onboarding cohort', status: 'succeeded', event: 'projection caught up', @@ -66,34 +61,32 @@ export default function AgentRunsPage() { - run.id}> - {(run) => ( - - - - - {run.status === 'succeeded' ? ( - - - - {run.title} - {run.description} - - - - - - - )} - + {runs.map((run) => ( + + + + + {run.status === 'succeeded' ? ( + + + + {run.title} + {run.description} + + + + + + + ))} ); diff --git a/templates/spa/src/pages/public/home.tsx b/templates/spa/src/pages/public/home.tsx index 20124dc..8881448 100644 --- a/templates/spa/src/pages/public/home.tsx +++ b/templates/spa/src/pages/public/home.tsx @@ -5,7 +5,6 @@ import { CheckCircle2Icon, ShieldCheckIcon, } from '@askrjs/lucide'; -import { For } from '@askrjs/askr/control'; import { Link } from '@askrjs/askr/router'; import { Button } from '@askrjs/themes/components'; import { @@ -112,17 +111,15 @@ export default function HomePage() {
- item.title}> - {(item) => ( - - - {item.icon} - {item.title} - {item.description} - - - )} - + {capabilities.map((item) => ( + + + {item.icon} + {item.title} + {item.description} + + + ))}
diff --git a/templates/ssg/src/components/site-shell.tsx b/templates/ssg/src/components/site-shell.tsx index 5f45e16..29ba9b9 100644 --- a/templates/ssg/src/components/site-shell.tsx +++ b/templates/ssg/src/components/site-shell.tsx @@ -1,4 +1,3 @@ -import { For } from '@askrjs/askr/control'; import { Link } from '@askrjs/askr/router'; import { Header } from '@askrjs/themes/components'; import { @@ -36,9 +35,9 @@ export function SiteHeader() { class="navbar-group" data-align="end" > - item.href}> - {(item) => {item.label}} - + {navItems.map((item) => ( + {item.label} + ))} diff --git a/templates/ssg/src/pages/about.tsx b/templates/ssg/src/pages/about.tsx index 2045aec..aa8bef0 100644 --- a/templates/ssg/src/pages/about.tsx +++ b/templates/ssg/src/pages/about.tsx @@ -1,4 +1,3 @@ -import { For } from '@askrjs/askr/control'; import { Link } from '@askrjs/askr/router'; import { Button } from '@askrjs/ui'; import { @@ -55,19 +54,17 @@ export default function Workflow() { /> - step.number}> - {(step) => ( - -

- {step.command} -

-
- )} -
+ {steps.map((step) => ( + +

+ {step.command} +

+
+ ))}
- route.path}> - {(route) => ( - - )} - + {routeMap.map((route) => ( + + ))} ); diff --git a/templates/ssg/src/pages/home.tsx b/templates/ssg/src/pages/home.tsx index 6cf0eb0..5f9d196 100644 --- a/templates/ssg/src/pages/home.tsx +++ b/templates/ssg/src/pages/home.tsx @@ -1,4 +1,3 @@ -import { For } from '@askrjs/askr/control'; import { Link } from '@askrjs/askr/router'; import { Button } from '@askrjs/ui'; import { @@ -43,11 +42,9 @@ export default function Home() { /> - highlight.title}> - {(highlight) => ( - - )} - + {highlights.map((highlight) => ( + + ))} ); diff --git a/templates/startkit/src/components/app-sidebar.tsx b/templates/startkit/src/components/app-sidebar.tsx index 08ddd86..9c3f7d8 100644 --- a/templates/startkit/src/components/app-sidebar.tsx +++ b/templates/startkit/src/components/app-sidebar.tsx @@ -1,4 +1,3 @@ -import { For } from '@askrjs/askr/control'; import { Link } from '@askrjs/askr/router'; import { LayoutDashboardIcon, @@ -68,31 +67,27 @@ export default function AppSidebar() { - item.href}> - {(item) => { - const Icon = item.icon; - return ( - - - {item.label} - - ); - }} - + {primaryNav.map((item) => { + const Icon = item.icon; + return ( + + + {item.label} + + ); + })} - item.href}> - {(item) => { - const Icon = item.icon; - return ( - - - {item.label} - - ); - }} - + {secondaryNav.map((item) => { + const Icon = item.icon; + return ( + + + {item.label} + + ); + })} diff --git a/templates/startkit/src/components/data-table.tsx b/templates/startkit/src/components/data-table.tsx index dbea503..afcdd9f 100644 --- a/templates/startkit/src/components/data-table.tsx +++ b/templates/startkit/src/components/data-table.tsx @@ -1,4 +1,4 @@ -import { Case, For, Match } from '@askrjs/askr/control'; +import { For } from '@askrjs/askr/control'; import { Skeleton } from '@askrjs/themes/components'; import EmptyState from './empty-state'; import { joinClasses } from '../utils/join-classes'; @@ -22,26 +22,54 @@ export default function DataTable(props: { emptyTitle?: string; emptyDescription?: string; }) { - const rows = props.rows(); - const table = ( + if (props.errorText) { + return ( + + ); + } + + if (props.isLoading) { + return ( + + ); + } + + if (props.rows().length === 0) { + return ( + + ); + } + + return (
- column.key}> - {(column) => } - + {props.columns.map((column) => ( + + ))} {(row: Row) => ( - column.key}> - {(column) => ( - - )} - + {props.columns.map((column) => ( + + ))} )} @@ -49,34 +77,4 @@ export default function DataTable(props: {
{column.header}{column.header}
{column.render(row)}{column.render(row)}
); - - return ( - - - - - - - - - - - - ); } diff --git a/templates/startkit/src/pages/workspace/dashboard.tsx b/templates/startkit/src/pages/workspace/dashboard.tsx index 8577693..4d0eac8 100644 --- a/templates/startkit/src/pages/workspace/dashboard.tsx +++ b/templates/startkit/src/pages/workspace/dashboard.tsx @@ -1,5 +1,4 @@ import { state } from '@askrjs/askr'; -import { For } from '@askrjs/askr/control'; import { resource } from '@askrjs/askr/resources'; import { Button } from '@askrjs/ui/button'; import { @@ -62,21 +61,19 @@ export default function DashboardPage() { />
- stat.key}> - {(stat) => { - const Icon = - iconByStatKey[stat.key as keyof typeof iconByStatKey] ?? - BarChart3Icon; - return ( - - ); - }} - + {stats().map((stat) => { + const Icon = + iconByStatKey[stat.key as keyof typeof iconByStatKey] ?? + BarChart3Icon; + return ( + + ); + })}
diff --git a/tests/analyze.benchmark-contract.test.ts b/tests/analyze.benchmark-contract.test.ts deleted file mode 100644 index 8a4c1b3..0000000 --- a/tests/analyze.benchmark-contract.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - ANALYZE_RULE_BENCHMARK_COVERAGE, - uncoveredAnalyzeRuleIds, -} from "../benchmarks/analyze-workloads"; -import { ANALYZE_RULES } from "../src/analyze/rules"; - -describe("analyzer benchmark contract", () => { - it("classifies every registered analyzer rule in a benchmark workload", () => { - const registered = ANALYZE_RULES.map((rule) => rule.id); - expect(uncoveredAnalyzeRuleIds(registered)).toEqual({ - missing: [], - stale: [], - }); - expect( - Object.values(ANALYZE_RULE_BENCHMARK_COVERAGE).every((entries) => entries.length > 0), - ).toBe(true); - }); -}); diff --git a/tests/analyze.cli.test.ts b/tests/analyze.cli.test.ts index 2ab887e..12b23a9 100644 --- a/tests/analyze.cli.test.ts +++ b/tests/analyze.cli.test.ts @@ -98,58 +98,6 @@ describe("analyze CLI", () => { expect(selected.diagnostics.map((entry) => entry.workspace)).toEqual(["b"]); }); - it("excludes generated Askr client output even when tsconfig includes the workspace", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-analyze-generated-")); - roots.push(root); - await fs.mkdir(path.join(root, "src"), { recursive: true }); - await fs.mkdir(path.join(root, ".askr", "client", "assets"), { recursive: true }); - await fs.mkdir(path.join(root, "vendor"), { recursive: true }); - await fs.writeFile( - path.join(root, "package.json"), - `${JSON.stringify( - { - name: "fixture", - askr: { analyze: { exclude: ["vendor/**"] } }, - dependencies: { "@askrjs/askr": "^0.0.70" }, - }, - 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: ["."], - }, - null, - 2, - )}\n`, - ); - await fs.writeFile( - path.join(root, "src", "page.tsx"), - "export function Page() { return
; }\n", - ); - const generatedAllocation = - "export function BundledPage() { const value = new Set(); return
{value.size}
; }\n"; - await fs.writeFile( - path.join(root, ".askr", "client", "assets", "bundle.js"), - generatedAllocation, - ); - await fs.writeFile(path.join(root, "vendor", "bundle.js"), generatedAllocation); - - const report = await runAnalysis({ cwd: root, workspacePatterns: [], check: true }); - - expect(report.workspaces).toEqual([expect.objectContaining({ files: 1 })]); - expect(report.diagnostics).toEqual([]); - }); - it("emits deterministic JSON and a blocking exit code", async () => { const root = await workspaceFixture(); const output = io(); diff --git a/tests/analyze.rules.test.ts b/tests/analyze.rules.test.ts index 9b61322..9ae5e30 100644 --- a/tests/analyze.rules.test.ts +++ b/tests/analyze.rules.test.ts @@ -96,201 +96,6 @@ describe("analyzer rules", () => { }); }); - it("reports conditional calls through local render-owned API wrappers", async () => { - const root = await fixture({ - "src/data.ts": ` - import { createMutation, createQuery as query } from "@askrjs/askr/data"; - import * as Askr from "@askrjs/askr"; - - export function createRowsQuery() { - return query({ key: "rows", fetch: async () => [] }); - } - export const createNestedRowsQuery = () => createRowsQuery(); - export function createSaveMutation() { - return createMutation({ action: async () => ({}) }); - } - export function cyclicResourceA() { - return cyclicResourceB(); - } - function cyclicResourceB() { - if (false) cyclicResourceA(); - return Askr.resource(async () => [], []); - } - export function ordinaryHelper() { - return "ordinary"; - } - `, - "src/page.tsx": ` - import { - createNestedRowsQuery, - createSaveMutation as save, - cyclicResourceA, - ordinaryHelper, - } from "./data"; - - export function Page(props: { enabled: boolean }) { - const stable = createNestedRowsQuery(); - const stableCycle = cyclicResourceA(); - const rows = props.enabled ? createNestedRowsQuery() : null; - const mutation = props.enabled && save(); - if (props.enabled) cyclicResourceA(); - const ordinary = props.enabled ? ordinaryHelper() : "safe"; - return
{String(stable && stableCycle && rows && mutation && ordinary)}
; - } - - export const nestedComponentBody = (props: { enabled: boolean }) => { - if (!props.enabled) return
disabled
; - const rows = createNestedRowsQuery(); - return
{String(rows)}
; - } - `, - }); - - const found = await diagnostics(root); - const unstable = found.filter((entry) => entry.ruleId === "askr/stable-render-call"); - expect(unstable).toHaveLength(4); - expect(unstable.map((entry) => entry.line)).toEqual([12, 13, 14, 21]); - expect(unstable.every((entry) => /transitively calls/.test(entry.message))).toBe(true); - }); - - it("distinguishes unconditional wrappers with conditional render-owned internals", async () => { - const root = await fixture({ - "src/data.ts": ` - import { createQuery } from "@askrjs/askr/data"; - export function createOptionalRows(enabled: boolean) { - if (enabled) { - return createQuery({ key: "rows", fetch: async () => [] }); - } - return null; - } - `, - "src/page.tsx": ` - import { createOptionalRows } from "./data"; - export function Page(props: { enabled: boolean }) { - const rows = createOptionalRows(props.enabled); - return
{String(rows)}
; - } - `, - }); - - const found = await diagnostics(root); - expect(found.filter((entry) => entry.ruleId === "askr/stable-render-call")).toEqual([ - expect.objectContaining({ - file: "src/page.tsx", - line: 4, - message: expect.stringContaining("contains conditionally executed render-owned Askr APIs"), - remediation: expect.stringContaining("unconditional inside the wrapper"), - }), - ]); - }); - - it("reports unmanaged render side effects through wrappers and requires task cleanup", async () => { - const root = await fixture({ - "src/effects.ts": ` - export function startClock(setNow: (value: number) => void) { - window.setInterval(() => setNow(Date.now()), 1000); - } - export const startNestedClock = (setNow: (value: number) => void) => - startClock(setNow); - export function startClockWithCleanup(setNow: (value: number) => void) { - const handle = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(handle); - } - export function managedTimerTask() { - const handle = setInterval(() => {}, 1000); - return () => clearInterval(handle); - } - export function unmanagedTimerTask() { - setTimeout(() => {}, 1000); - } - export function startObserverCycle() { - return observerCycle(); - } - function observerCycle() { - if (false) startObserverCycle(); - return new MutationObserver(() => {}); - } - export const ordinaryHelper = { - subscribe() {}, - addEventListener() {}, - }; - `, - "src/page.tsx": ` - import { state, task } from "@askrjs/askr"; - import { - managedTimerTask, - ordinaryHelper, - startClockWithCleanup, - startNestedClock, - startObserverCycle, - unmanagedTimerTask, - } from "./effects"; - declare const unrelatedHandle: ReturnType; - declare const otherObserver: { disconnect(): void }; - - export function Page() { - const [, setNow] = state(Date.now()); - startNestedClock(setNow); - startObserverCycle(); - task(() => { - setTimeout(() => setNow(Date.now()), 1000); - }); - task(() => { - const handle = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(handle); - }); - task(() => { - window.addEventListener("resize", setNow); - return () => window.removeEventListener("resize", setNow); - }); - task(() => { - window.addEventListener("scroll", setNow); - }); - task(() => startClockWithCleanup(setNow)); - task(() => { - const observer = new ResizeObserver(() => {}); - observer.observe(document.body); - return () => observer.disconnect(); - }); - task(() => { - const handle = setInterval(() => setNow(Date.now()), 1000); - return () => clearTimeout(handle); - }); - task(() => { - const handle = setTimeout(() => setNow(Date.now()), 1000); - return () => clearTimeout(unrelatedHandle); - }); - task(() => { - const observer = new MutationObserver(() => {}); - observer.observe(document.body); - return () => otherObserver.disconnect(); - }); - task(managedTimerTask); - task(unmanagedTimerTask); - ordinaryHelper.subscribe(); - ordinaryHelper.addEventListener(); - const onClick = () => setTimeout(() => setNow(Date.now()), 1000); - return ; - } - `, - }); - - const found = await diagnostics(root); - const sideEffects = found.filter((entry) => entry.ruleId === "askr/render-side-effect"); - expect(sideEffects).toHaveLength(9); - expect( - sideEffects.filter((entry) => entry.file === "src/page.tsx").map((entry) => entry.line), - ).toEqual([16, 17, 19, 30, 32, 39, 43, 47]); - expect(sideEffects.filter((entry) => entry.file === "src/effects.ts")).toEqual([ - expect.objectContaining({ - message: expect.stringMatching( - /task starts timer side effects without returning matching cleanup/, - ), - }), - ]); - expect(sideEffects.some((entry) => /cleanup/.test(entry.remediation ?? ""))).toBe(true); - }); - it("checks resource cancellation and stable dependencies while accepting forwarded signals", async () => { const root = await fixture({ "src/page.tsx": ` @@ -311,7 +116,7 @@ describe("analyzer rules", () => { expect(found.filter((entry) => entry.ruleId === "askr/stable-dependencies")).toHaveLength(1); }); - it("checks For contracts, positional keys, and direct JSX map calls", async () => { + 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"; @@ -330,119 +135,11 @@ describe("analyzer rules", () => { }); const found = await diagnostics(root); - expect(found.filter((entry) => entry.ruleId === "askr/prefer-for")).toHaveLength(2); + 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 eager controls behind changing conditionals but accepts scoped components", async () => { - const root = await fixture({ - "src/page.tsx": ` - import { For, Show, state } from "@askrjs/askr"; - function Dialog({ items }: { items: string[] }) { - return
    - item}>{(item) =>
  • {item}
  • }
    -
; - } - export function Page(props: { visible: boolean }) { - const [open] = state(false); - return
- {open() ? item}>{(item) =>

{item}

}
: null} - {open() && visible} - {open() ? : null} - {props.visible ? item}>{(item) =>

{item}

}
: null} - {true ? item}>{(item) =>

{item}

}
: null} - { item}>{(item) =>

{item}

}
&& open()} - { item}>{(item) =>

{item}

}
?

yes

: null} - item}>{(item) =>

{item}

}
-
; - } - `, - }); - - const found = await diagnostics(root); - const unstable = found.filter((entry) => entry.ruleId === "askr/stable-render-call"); - expect(unstable).toHaveLength(3); - expect(unstable.map((entry) => entry.line)).toEqual([11, 12, 14]); - expect(unstable.every((entry) => //.test(entry.remediation ?? ""))).toBe(true); - expect(unstable[1]?.remediation).toMatch(/Mount unconditionally/); - }); - - it("reports eager controls reached after conditional early returns", async () => { - const root = await fixture({ - "src/page.tsx": ` - import { Case, For, Show } from "@askrjs/askr"; - - export function ForPage(props: { skip: boolean }) { - if (props.skip) return null; - return item}>{(item) =>

{item}

}
; - } - export function ShowPage(props: { skip: boolean }) { - if (props.skip) return
skipped
; - return shown; - } - export function CasePage(props: { skip: boolean }) { - if (props.skip) return null; - return matched; - } - export function ConstantEarlyReturn() { - if (false) return null; - return item}>{(item) =>

{item}

}
; - } - export function ReturnAfterControl(props: { skip: boolean }) { - const control = shown; - if (props.skip) return null; - return control; - } - export function NestedReturn(props: { skip: boolean }) { - const helper = () => { - if (props.skip) return null; - return "ready"; - }; - helper(); - return item}>{(item) =>

{item}

}
; - } - export function OrdinaryJsx(props: { skip: boolean }) { - if (props.skip) return null; - return
ordinary
; - } - `, - }); - - const found = await diagnostics(root); - const unstable = found.filter((entry) => entry.ruleId === "askr/stable-render-call"); - expect(unstable).toHaveLength(3); - expect(unstable.map((entry) => entry.line)).toEqual([6, 10, 14]); - expect(unstable.every((entry) => /early return/.test(entry.message))).toBe(true); - }); - - it("reports map arrays rendered as JSX children but accepts data transformations", async () => { - const root = await fixture({ - "src/page.tsx": ` - import { For } from "@askrjs/askr"; - export function Page({ items }: { items: Array<{ id: string; label: string }> }) { - const labels = items.map((item) => item.label); - return
item.label)}> - {items.map((item) =>

{item.label}

)} - {items.map((item) => item.label) &&

always

} - {items.map((item) =>

{item.label}

) ?? null} - {null ?? items.map((item) =>

{item.label}

)} - {items.map((item) => item.label).join(", ")} - item.id)} by={(item) => item}> - {(item) => {item}} - - {labels.join(", ")} -
; - } - `, - }); - - const found = await diagnostics(root); - expect( - found.filter((entry) => entry.ruleId === "askr/prefer-for").map((entry) => entry.line), - ).toEqual([6, 8, 9]); - }); - it("reports async components, bad boot wiring, and SSR browser globals", async () => { const root = await fixture({ "src/client.tsx": ` diff --git a/tests/database.test.ts b/tests/database.test.ts new file mode 100644 index 0000000..bb5e2be --- /dev/null +++ b/tests/database.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { + loadOrmTooling, + type OrmTooling, + runDatabaseCommand, + runDatabaseValidation, +} from "../src/bin/database"; + +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, + }; +} + +type Loader = typeof loadOrmTooling; + +describe("database command routing", () => { + it("forwards all semantics to the project-installed ORM tooling", async () => { + const output = io(); + const runDatabaseCli = vi.fn(async () => 0); + const loader: Loader = vi.fn(async () => ({ runDatabaseCli })); + + await expect( + runDatabaseCommand( + ["migration", "plan", "--database", "accounts"], + output.value, + "/project", + loader, + ), + ).resolves.toBe(0); + expect(loader).toHaveBeenCalledWith("/project"); + expect(runDatabaseCli).toHaveBeenCalledWith(["migration", "plan", "--database", "accounts"], { + cwd: "/project", + io: output.value, + }); + }); + + it("reports a focused install error when tooling is unavailable", async () => { + const output = io(); + const loader: Loader = vi.fn(async () => { + throw new Error("missing"); + }); + expect(await runDatabaseCommand(["validate"], output.value, "/project", loader)).toBe(1); + expect(output.errors).toEqual(["missing"]); + }); + + it("captures lazy validation output for askr check", async () => { + const loader: Loader = vi.fn(async () => ({ + runDatabaseCli: async ( + _args: readonly string[], + options: Parameters[1], + ) => { + options.io.log("valid"); + return 0; + }, + })); + await expect(runDatabaseValidation("/project", loader)).resolves.toEqual({ + status: "passed", + exitCode: 0, + stdout: "valid", + stderr: "", + }); + }); +}); diff --git a/tests/guardrails.test.ts b/tests/guardrails.test.ts index bcfcf16..d5626ed 100644 --- a/tests/guardrails.test.ts +++ b/tests/guardrails.test.ts @@ -168,6 +168,34 @@ describe("guardrail commands", () => { ]); }); + it("automatically validates a discovered database before project scripts", async () => { + const root = await fixture(); + await fs.mkdir(path.join(root, "database"), { recursive: true }); + await fs.writeFile(path.join(root, "database", "index.ts"), "export default {};\n"); + const order: string[] = []; + const report = await runCheck( + { cwd: root, workspacePatterns: [] }, + { + runDatabaseValidation: vi.fn(async () => { + order.push("database"); + return { status: "passed" as const, exitCode: 0, stdout: "", stderr: "" }; + }), + runScript: vi.fn(async (_executable, args) => { + order.push(String(args.at(-1))); + return { status: "passed" as const, exitCode: 0, stdout: "", stderr: "" }; + }), + }, + ); + + expect(report.status).toBe("passed"); + expect(order).toEqual(["database", "lint", "typecheck", "test", "build"]); + expect(report.scripts[0]).toMatchObject({ + name: "database", + command: "askr database validate", + status: "passed", + }); + }); + 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( diff --git a/tests/update.cli.test.ts b/tests/update.cli.test.ts index 2d01e3c..c7cfe0e 100644 --- a/tests/update.cli.test.ts +++ b/tests/update.cli.test.ts @@ -384,7 +384,6 @@ describe("update CLI", () => { "js-yaml", "minimatch", "npm-registry-fetch", - "playwright-core", "semver", "tsx", "typescript", @@ -401,9 +400,8 @@ describe("update CLI", () => { const source = await fs.readFile(new URL("../src/bin/cli.ts", import.meta.url), "utf8"); expect(source).toContain('await import("./update")'); - expect(source).toContain('await import("./verify-hydration")'); expect(source).not.toMatch( - /^import .* from "\.\/(?:add|analyze|create|generate|openapi|skills|ssg|update|verify-hydration)";/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 2b3bf5f..22bba56 100644 --- a/tests/update.range.test.ts +++ b/tests/update.range.test.ts @@ -339,5 +339,5 @@ describe("update range planner", () => { decision.occurrences[0].reason.includes("50,000-state budget"), ), ).toBe(true); - }, 15_000); + }, 30_000); }); diff --git a/tests/verify-hydration.test.ts b/tests/verify-hydration.test.ts deleted file mode 100644 index 1b99cda..0000000 --- a/tests/verify-hydration.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -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 { runCli } from "../src/bin/cli"; -import { parseVerifyHydrationArgs, runVerifyHydrationCli } from "../src/bin/verify-hydration"; - -const roots: string[] = []; -const browserTestTimeout = 30_000; - -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 outputFixture( - documents: Record, -): Promise<{ root: string; outputDir: string }> { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-verify-hydration-")); - roots.push(root); - const outputDir = path.join(root, "dist"); - const routes = []; - for (const [route, document] of Object.entries(documents)) { - const filePath = route === "/" ? "index.html" : `${route.replace(/^\/+/, "")}/index.html`; - const absolute = path.join(outputDir, filePath); - await fs.mkdir(path.dirname(absolute), { recursive: true }); - await fs.writeFile(absolute, document); - routes.push({ path: route, filePath, status: "success" }); - } - await fs.writeFile( - path.join(outputDir, "metadata.json"), - `${JSON.stringify({ routes }, null, 2)}\n`, - ); - return { root, outputDir }; -} - -function document(body: string, script = ""): string { - return ` - - - -
${body}
- ${script ? `` : ""} - - `; -} - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); -}); - -describe("verify hydration", () => { - it("parses route sets and rejects invalid timeouts", () => { - const defaultCwd = path.resolve("/workspace"); - expect( - parseVerifyHydrationArgs( - [ - "--cwd", - "fixture", - "--output", - ".askr/site", - "--route", - "/", - "--route", - "/docs", - "--root", - "#root", - "--no-build", - "--timeout", - "2500", - ], - defaultCwd, - ), - ).toMatchObject({ - cwd: path.resolve(defaultCwd, "fixture"), - outputDir: path.resolve(defaultCwd, "fixture", ".askr", "site"), - routes: ["/", "/docs"], - rootSelector: "#root", - build: false, - timeoutMs: 2500, - errors: [], - }); - expect(parseVerifyHydrationArgs(["--timeout", "0"], "/workspace").errors).toEqual([ - "--timeout must be a positive integer", - ]); - }); - - it("dispatches help through the unified CLI", async () => { - const output = io(); - expect(await runCli(["verify-hydration", "--help"], output.value)).toBe(0); - expect(output.errors).toEqual([]); - expect(output.logs.join("\n")).toContain("askr verify-hydration"); - }); - - it( - "accepts structural matches across a metadata route set", - async () => { - const fixture = await outputFixture({ - "/": document( - `

static text

- `, - `document.querySelector("p").textContent = "hydrated text"; - document.querySelector("p").className = "ready"; - document.querySelector("p").setAttribute("aria-live", "polite");`, - ), - "/docs": document("

Docs

"), - }); - const output = io(); - const runBuild = vi.fn(async () => undefined); - - const code = await runVerifyHydrationCli( - ["--cwd", fixture.root, "--output", fixture.outputDir, "--timeout", "2500"], - { runBuild }, - output.value, - ); - - expect(code, output.errors.join("\n")).toBe(0); - expect(runBuild).toHaveBeenCalledWith(fixture.root, "build"); - expect(output.errors).toEqual([]); - expect(output.logs).toEqual(["Verified hydration DOM for 2 route(s)."]); - }, - browserTestTimeout, - ); - - it( - "fails when hydration migrates a node into the wrong sibling container", - async () => { - const fixture = await outputFixture({ - "/": document("

Stable

"), - "/broken": document( - `
-
Owned by section
- -
`, - `const span = document.querySelector("section span"); - document.querySelector("aside").append(span);`, - ), - }); - const output = io(); - - const code = await runVerifyHydrationCli( - [ - "--cwd", - fixture.root, - "--output", - fixture.outputDir, - "--route", - "/broken", - "--no-build", - "--timeout", - "2500", - ], - {}, - output.value, - ); - - expect(code).toBe(1); - expect(output.errors.join("\n")).toContain("/broken: DOM diverged"); - expect(output.errors.join("\n")).toContain("static:"); - expect(output.errors.join("\n")).toContain("hydrated:"); - expect(output.errors.join("\n")).not.toContain("/: DOM diverged"); - }, - browserTestTimeout, - ); - - it( - "fails closed on browser errors and hydration timeouts", - async () => { - const failed = await outputFixture({ - "/error": document( - "
", - `console.error("hydration console failure"); - throw new Error("hydration exploded");`, - ), - }); - const errorOutput = io(); - expect( - await runVerifyHydrationCli( - ["--cwd", failed.root, "--output", failed.outputDir, "--no-build", "--timeout", "2500"], - {}, - errorOutput.value, - ), - ).toBe(1); - expect(errorOutput.errors.join("\n")).toContain("hydration exploded"); - expect(errorOutput.errors.join("\n")).toContain("hydration console failure"); - - const stalled = await outputFixture({ - "/stalled": document("
", "globalThis.requestAnimationFrame = () => 0;"), - }); - const timeoutOutput = io(); - expect( - await runVerifyHydrationCli( - ["--cwd", stalled.root, "--output", stalled.outputDir, "--no-build", "--timeout", "100"], - {}, - timeoutOutput.value, - ), - ).toBe(1); - expect(timeoutOutput.errors.join("\n")).toMatch(/timeout|Timeout/i); - }, - browserTestTimeout, - ); - - it("closes the static server when browser launch fails", async () => { - const fixture = await outputFixture({ "/": document("
") }); - const close = vi.fn(async () => undefined); - const output = io(); - - expect( - await runVerifyHydrationCli( - ["--cwd", fixture.root, "--output", fixture.outputDir, "--no-build"], - { - startServer: async () => ({ origin: "http://127.0.0.1:1", close }), - launchBrowser: async () => { - throw new Error("browser unavailable"); - }, - }, - output.value, - ), - ).toBe(1); - expect(close).toHaveBeenCalledOnce(); - expect(output.errors).toEqual(["Error: browser unavailable"]); - }); - - it("rejects route metadata that escapes the output directory", async () => { - const fixture = await outputFixture({ "/": document("
") }); - await fs.writeFile( - path.join(fixture.outputDir, "metadata.json"), - `${JSON.stringify({ routes: [{ path: "/", filePath: "../outside.html" }] })}\n`, - ); - const output = io(); - - expect( - await runVerifyHydrationCli( - ["--cwd", fixture.root, "--output", fixture.outputDir, "--no-build"], - {}, - output.value, - ), - ).toBe(1); - expect(output.errors.join("\n")).toContain("outside the SSG output directory"); - }); -}); diff --git a/vite.config.ts b/vite.config.ts index 1b6b08d..a3d9db7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,13 +7,13 @@ export default defineConfig({ analyze: "src/bin/analyze.ts", cli: "src/bin/cli.ts", create: "src/bin/create.ts", + database: "src/bin/database.ts", generate: "src/bin/generate.ts", openapi: "src/bin/openapi.ts", skills: "src/bin/skills.ts", ssg: "src/bin/ssg.ts", "ssg-config": "src/ssg.ts", update: "src/bin/update.ts", - "verify-hydration": "src/bin/verify-hydration.ts", }, format: ["esm"], outDir: "dist", @@ -24,7 +24,7 @@ export default defineConfig({ sourcemap: false, copy: ["templates", "skills"], deps: { - neverBundle: [/^@askrjs\/askr(?:\/.*)?$/, "playwright-core", "tsx/esm/api"], + neverBundle: [/^@askrjs\/askr(?:\/.*)?$/, "tsx/esm/api"], }, }, });