Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:

publish:
needs: ci
uses: askrjs/actions/.github/workflows/publish-package.yml@614b9e344983ad2100563c5f36f94d774d3ba6bd
uses: askrjs/actions/.github/workflows/publish-package.yml@cfbfac63066d778fa1fa99247f6af43515155e4f
permissions:
contents: write
id-token: write
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ unless you opt out with `--no-skills`.
- `askr create [template] <name> [--prompt <text>] [--no-install] [--no-skills]`
- `askr add page <name> [--branch app|public]`
- `askr add action <name> --route <path>`
- `askr analyze [--cwd <dir>] [--workspace <pattern>]... [--json] [--check]`
- `askr doctor [--cwd <dir>] [--workspace <pattern>]... [--json]`
- `askr repair [--cwd <dir>] [--workspace <pattern>]... [--json]`
- `askr check [--cwd <dir>] [--workspace <pattern>]... [--json]`
- `askr skills list`
- `askr skills install [--cwd <dir>] [--force]`
- `askr skills sync [--cwd <dir>]`
Expand All @@ -53,6 +57,50 @@ unless you opt out with `--no-skills`.
The installed command is `askr`. Subcommands are intentionally not published
as compatibility binaries.

## Static analysis

`askr analyze` discovers the containing npm or pnpm workspace, builds a
TypeScript compiler program for every selected workspace, and checks current
Askr state, lifecycle, data, control-flow, routing, boot, SSR/SSG, action, and
configuration contracts.

```bash
askr analyze
askr analyze --workspace "@scope/web"
askr analyze --check
askr analyze --json --check
```

All diagnostics include a stable rule ID and workspace-relative source
location. The analyzer distinguishes canonical Askr imports from unrelated
same-named functions and only recommends `<For>` for state-backed reactive JSX
collections, so static transforms with `.map()` remain valid.

By default it transactionally applies only mechanical route-parameter and
plain-JSON JSX configuration fixes. `--check` is read-only for CI. Semantic
rewrites, including `.map()` to `<For>`, conditional primitives, and key
changes, are always report-only. See the
[analyze command reference](./docs/analyze.md) for rules and configuration.

## Project recovery loop

Every generated project uses the same guardrail loop:

```bash
askr doctor
askr repair
askr check
```

`doctor` performs a read-only environment, package-manager, framework, skills,
and static-analysis inspection. `repair` transactionally applies only safe
mechanical fixes and reports remaining semantic work. `check` requires clean
analysis before running the project's declared lint, typecheck, test, and build
scripts in order. Generated projects expose that final gate as `npm run check`.

See the [project guardrails reference](./docs/guardrails.md) for command and JSON
contracts.

## Static sitemap generation

`askr ssg` writes `sitemap.xml` from the concrete routes generated during the
Expand Down
36 changes: 36 additions & 0 deletions benchmarks/analyze-budget-reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Reporter, TestModule } from "vitest/node";

const ANALYZE_BUDGETS_MS: Readonly<Record<string, number>> = {
"50-file workspace": 100,
"250-file workspace": 250,
"5-workspace monorepo with 250 files": 300,
};

interface BenchmarkMeta {
readonly benchmark?: boolean;
readonly result?: {
readonly mean: number;
};
}

export class AnalyzeBudgetReporter implements Reporter {
onTestRunEnd(testModules: readonly TestModule[]): void {
const failures: string[] = [];
for (const testModule of testModules) {
for (const test of testModule.children.allTests()) {
const budget = ANALYZE_BUDGETS_MS[test.name];
if (budget === undefined) continue;
const meta = test.meta() as BenchmarkMeta;
const mean = meta.result?.mean;
if (!meta.benchmark || mean === undefined) {
failures.push(`${test.name}: missing benchmark result`);
} else if (mean > budget) {
failures.push(`${test.name}: mean ${mean.toFixed(1)} ms exceeds ${budget} ms`);
}
}
}
if (failures.length > 0) {
throw new Error(`Analyzer performance budget failed:\n${failures.join("\n")}`);
}
}
}
132 changes: 132 additions & 0 deletions benchmarks/analyze.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, bench, describe, expect } from "vite-plus/test";
import { runAnalysis } from "../src/analyze/runner";

const roots: string[] = [];
const benchOptions = {
iterations: 8,
warmupIterations: 2,
time: 0,
warmupTime: 0,
} as const;

async function writeJson(filePath: string, value: unknown): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
}

function componentSource(index: number): string {
return `
import { For, derive, resource, state } from "@askrjs/askr";

export function Page${index}() {
const [items] = state([{ id: ${index}, label: "Item ${index}" }]);
const count = derive(() => items().length);
const status = resource(({ signal }) =>
fetch("/api/items/${index}", { signal }), [count()]);
return <main data-count={count()} data-pending={status.pending}>
<For each={items()} by={(item) => item.id}>
{(item) => <span>{item.label}</span>}
</For>
</main>;
}
`;
}

async function createWorkspace(
root: string,
relativeDirectory: string,
name: string,
sourceCount: number,
): Promise<void> {
const directory = path.join(root, relativeDirectory);
await writeJson(path.join(directory, "package.json"), {
name,
dependencies: { "@askrjs/askr": "^0.0.70" },
});
await writeJson(path.join(directory, "tsconfig.json"), {
compilerOptions: {
jsx: "react-jsx",
jsxImportSource: "@askrjs/askr",
module: "ESNext",
moduleResolution: "Bundler",
target: "ES2022",
},
include: ["src"],
});
await fs.mkdir(path.join(directory, "src"), { recursive: true });
await Promise.all(
Array.from({ length: sourceCount }, (_, index) =>
fs.writeFile(
path.join(directory, "src", `page-${String(index).padStart(4, "0")}.tsx`),
componentSource(index),
),
),
);
}

async function createSingleWorkspaceFixture(sourceCount: number): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-analyze-bench-single-"));
roots.push(root);
await createWorkspace(root, ".", "single-app", sourceCount);
return root;
}

async function createMonorepoFixture(
workspaceCount: number,
sourcesPerWorkspace: number,
): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-analyze-bench-monorepo-"));
roots.push(root);
await writeJson(path.join(root, "package.json"), {
name: "bench-root",
workspaces: ["packages/*"],
});
await Promise.all(
Array.from({ length: workspaceCount }, (_, index) =>
createWorkspace(
root,
path.join("packages", `app-${index}`),
`app-${index}`,
sourcesPerWorkspace,
),
),
);
return root;
}

let mediumWorkspace: string;
let largeWorkspace: string;
let monorepo: string;

beforeAll(async () => {
[mediumWorkspace, largeWorkspace, monorepo] = await Promise.all([
createSingleWorkspaceFixture(50),
createSingleWorkspaceFixture(250),
createMonorepoFixture(5, 50),
]);
});

afterAll(async () => {
await Promise.all(roots.map((root) => fs.rm(root, { recursive: true, force: true })));
});

async function analyzeFixture(root: string, expectedFiles: number): Promise<void> {
const report = await runAnalysis({
cwd: root,
workspacePatterns: [],
check: true,
});
expect(report.summary.diagnostics).toBe(0);
expect(report.workspaces.reduce((sum, workspace) => sum + workspace.files, 0)).toBe(
expectedFiles,
);
}

describe("askr analyze", () => {
bench("50-file workspace", () => analyzeFixture(mediumWorkspace, 50), benchOptions);
bench("250-file workspace", () => analyzeFixture(largeWorkspace, 250), benchOptions);
bench("5-workspace monorepo with 250 files", () => analyzeFixture(monorepo, 250), benchOptions);
});
13 changes: 13 additions & 0 deletions benchmarks/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,12 @@ for (const [name, args] of [
for (const command of [
"create",
"add",
"analyze",
"check",
"doctor",
"generate",
"openapi",
"repair",
"skills",
"ssg",
"outdated",
Expand All @@ -155,6 +159,15 @@ for (const command of [
samples,
});
}
const analyze = commandSample(["analyze", "--cwd", "templates/startkit", "--json", "--check"]);
rows.push({
name: "analyze:cold-35-file-template",
// TypeScript program construction dominates a cold process; keep this separate
// from the existing dispatch budgets while guarding against regressions.
budgetMs: 1000,
p95Ms: percentile(analyze, 0.95),
samples: analyze,
});
const ssg = await ssgSample();
rows.push({
name: "ssg:100-fixture-routes",
Expand Down
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The unified CLI for the Askr platform.
- [skill-system-design](./skill-system-design.md) - Why the bundled skills are layered and how drift is constrained
- [skill-review-prompts](./skill-review-prompts.md) - Golden prompts for auditing the bundled skill system
- [add](./add.md) - Generate feature code into an existing project
- [analyze](./analyze.md) - Check Askr correctness, performance, and configuration
- [Project guardrails](./guardrails.md) - Diagnose, repair, and validate a project
- [generate](./generate.md) - Generate a typed client from an OpenAPI document
- [openapi](./openapi.md) - Export deterministic OpenAPI YAML
- [ssg](./ssg.md) - Atomically publish static routes and sitemap artifacts
Expand Down
Loading