@agjs/tsforge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tsforge.js +2 -0
- package/package.json +35 -0
- package/src/agent/agent.constants.ts +382 -0
- package/src/agent/agent.types.ts +34 -0
- package/src/agent/index.ts +4 -0
- package/src/agent/model-agent.ts +297 -0
- package/src/agent/tool-repair.ts +194 -0
- package/src/agent/tools.ts +190 -0
- package/src/browser/checks.ts +96 -0
- package/src/browser/index.ts +8 -0
- package/src/browser/oracle.ts +303 -0
- package/src/classify.ts +48 -0
- package/src/cli.ts +1333 -0
- package/src/config/config.constants.ts +9 -0
- package/src/config/flags.ts +32 -0
- package/src/config/index.ts +8 -0
- package/src/config/tsforge-config.ts +301 -0
- package/src/constitution/baseline.ts +257 -0
- package/src/detect-gate.ts +498 -0
- package/src/eval/eval.types.ts +36 -0
- package/src/eval/index.ts +3 -0
- package/src/eval/judge.ts +62 -0
- package/src/eval/score.ts +39 -0
- package/src/files/create.ts +22 -0
- package/src/files/edit.ts +193 -0
- package/src/files/files.constants.ts +11 -0
- package/src/files/files.types.ts +81 -0
- package/src/files/hashline-format.ts +110 -0
- package/src/files/hashline.ts +689 -0
- package/src/files/index.ts +19 -0
- package/src/index.ts +8 -0
- package/src/inference/index.ts +6 -0
- package/src/inference/inference.constants.ts +34 -0
- package/src/inference/inference.types.ts +123 -0
- package/src/inference/openai-compatible.ts +113 -0
- package/src/inference/stream-guard.ts +161 -0
- package/src/inference/stream.ts +370 -0
- package/src/inference/transport.ts +78 -0
- package/src/inference/wire.ts +0 -0
- package/src/lib/fs/fs.ts +126 -0
- package/src/lib/fs/fs.types.ts +5 -0
- package/src/lib/fs/index.ts +3 -0
- package/src/lib/fs/process.ts +146 -0
- package/src/lib/guards/guards.ts +9 -0
- package/src/lib/guards/index.ts +1 -0
- package/src/lib/json/index.ts +1 -0
- package/src/lib/json/json.ts +12 -0
- package/src/lib/scope/index.ts +2 -0
- package/src/lib/scope/scope.constants.ts +3 -0
- package/src/lib/scope/scope.ts +40 -0
- package/src/loop/astgrep-fix.ts +228 -0
- package/src/loop/feedback/feedback.ts +138 -0
- package/src/loop/feedback/index.ts +8 -0
- package/src/loop/feedback/meta-rule-docs.ts +41 -0
- package/src/loop/feedback/meta-rule-feedback.ts +61 -0
- package/src/loop/feedback/rule-docs.generated.json +112 -0
- package/src/loop/feedback/rule-docs.ts +342 -0
- package/src/loop/index.ts +19 -0
- package/src/loop/loop.constants.ts +68 -0
- package/src/loop/loop.types.ts +99 -0
- package/src/loop/prompt/index.ts +2 -0
- package/src/loop/prompt/project-map.ts +69 -0
- package/src/loop/prompt/prompt.ts +107 -0
- package/src/loop/quality.ts +174 -0
- package/src/loop/rule-docs.generated.json +367 -0
- package/src/loop/run-spec.ts +88 -0
- package/src/loop/run.ts +400 -0
- package/src/loop/session.ts +1410 -0
- package/src/loop/tools/add-dependency.ts +71 -0
- package/src/loop/tools/condense.ts +498 -0
- package/src/loop/tools/edit-hashline.ts +80 -0
- package/src/loop/tools/execute-tool.ts +80 -0
- package/src/loop/tools/file-ops.ts +323 -0
- package/src/loop/tools/index.ts +2 -0
- package/src/loop/tools/lsp-ops.ts +222 -0
- package/src/loop/tools/scaffold-routes.ts +68 -0
- package/src/loop/tools/scaffold-ui.ts +62 -0
- package/src/loop/tools/scaffold-web.ts +35 -0
- package/src/loop/tools/tool-context.ts +126 -0
- package/src/loop/ttsr-defaults.ts +53 -0
- package/src/loop/ttsr.ts +322 -0
- package/src/loop/turn.ts +856 -0
- package/src/lsp/index.ts +2 -0
- package/src/lsp/lsp.types.ts +56 -0
- package/src/lsp/service.ts +500 -0
- package/src/meta-rules/context.ts +195 -0
- package/src/meta-rules/index.ts +9 -0
- package/src/meta-rules/meta-rules.types.ts +47 -0
- package/src/meta-rules/parsers/package-json-parser.ts +51 -0
- package/src/meta-rules/registry.ts +37 -0
- package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
- package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
- package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
- package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
- package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
- package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
- package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
- package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
- package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
- package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
- package/src/meta-rules/runner.ts +64 -0
- package/src/models-config.ts +196 -0
- package/src/render/ansi.ts +289 -0
- package/src/render/banner.ts +113 -0
- package/src/render/box.ts +134 -0
- package/src/render/index.ts +7 -0
- package/src/render/markdown.ts +123 -0
- package/src/render/render.types.ts +21 -0
- package/src/render/stream-markdown.ts +128 -0
- package/src/render/style.ts +26 -0
- package/src/rule-packs/bullmq/index.ts +39 -0
- package/src/rule-packs/bullmq/rules/index.ts +7 -0
- package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
- package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
- package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
- package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
- package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
- package/src/rule-packs/bullmq/utils.ts +334 -0
- package/src/rule-packs/code-flow/index.ts +25 -0
- package/src/rule-packs/code-flow/rules/index.ts +3 -0
- package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
- package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
- package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
- package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
- package/src/rule-packs/comment-hygiene/index.ts +25 -0
- package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
- package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
- package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
- package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
- package/src/rule-packs/create-rule.ts +9 -0
- package/src/rule-packs/drizzle/index.ts +41 -0
- package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
- package/src/rule-packs/drizzle/rules/index.ts +8 -0
- package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
- package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
- package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
- package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
- package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
- package/src/rule-packs/drizzle/utils.ts +115 -0
- package/src/rule-packs/elysia/index.ts +43 -0
- package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
- package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
- package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
- package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
- package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
- package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
- package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
- package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
- package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
- package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
- package/src/rule-packs/env-access/index.ts +23 -0
- package/src/rule-packs/env-access/rules/index.ts +2 -0
- package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
- package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
- package/src/rule-packs/i18n-keys/index.ts +19 -0
- package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
- package/src/rule-packs/index.ts +139 -0
- package/src/rule-packs/jwt-cookies/index.ts +25 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
- package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
- package/src/rule-packs/jwt-cookies/utils.ts +188 -0
- package/src/rule-packs/oauth-security/index.ts +25 -0
- package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
- package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
- package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
- package/src/rule-packs/oauth-security/utils.ts +127 -0
- package/src/rule-packs/react-component-architecture/index.ts +35 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
- package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
- package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
- package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
- package/src/rule-packs/react-component-architecture/utils.ts +47 -0
- package/src/rule-packs/rule-packs.types.ts +18 -0
- package/src/rule-packs/structured-logging/index.ts +26 -0
- package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
- package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
- package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
- package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
- package/src/rule-packs/tanstack-query/index.ts +20 -0
- package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
- package/src/rule-packs/test-conventions/index.ts +23 -0
- package/src/rule-packs/test-conventions/rules/index.ts +2 -0
- package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
- package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
- package/src/rule-packs/utils.ts +142 -0
- package/src/session-store.ts +359 -0
- package/src/spec/generate-tests.ts +213 -0
- package/src/spec/index.ts +5 -0
- package/src/spec/parse.ts +152 -0
- package/src/spec/review-tests.ts +162 -0
- package/src/spec/spec.constants.ts +13 -0
- package/src/spec/spec.types.ts +79 -0
- package/src/stack-detection/detect.ts +246 -0
- package/src/stack-detection/index.ts +3 -0
- package/src/stack-detection/packs.ts +174 -0
- package/src/stack-detection/stack-detection.types.ts +47 -0
- package/src/validate/accept.ts +49 -0
- package/src/validate/errors.ts +35 -0
- package/src/validate/index.ts +12 -0
- package/src/validate/parse.ts +148 -0
- package/src/validate/run-tests.ts +59 -0
- package/src/validate/validate.ts +40 -0
- package/src/validate/validate.types.ts +52 -0
- package/src/web-components.ts +638 -0
- package/src/web-coverage.ts +89 -0
- package/src/web-routes.ts +151 -0
- package/src/web-templates.ts +1011 -0
- package/strict.eslint.config.mjs +84 -0
- package/strict.web.eslint.config.mjs +185 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { isRecord } from "../lib/guards";
|
|
2
|
+
import type { IRenderExpect, IStep } from "./oracle";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse a checks JSON (authored by the model/user) into render expectations +
|
|
6
|
+
* interaction steps, defensively (no `as`/`!` — validates the boundary). Shape:
|
|
7
|
+
* { "expect": { "selector": "#x", "text": "ok" },
|
|
8
|
+
* "steps": [ { "click": "#inc", "expect": { "selector": "#n", "text": "1" } },
|
|
9
|
+
* { "fill": { "selector": "#in", "value": "hi" } } ] }
|
|
10
|
+
*/
|
|
11
|
+
export function parseChecks(raw: unknown): {
|
|
12
|
+
expect?: IRenderExpect;
|
|
13
|
+
steps?: IStep[];
|
|
14
|
+
} {
|
|
15
|
+
if (!isRecord(raw)) {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const expect = toExpect(raw.expect);
|
|
20
|
+
const steps = toSteps(raw.steps);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
...(expect !== undefined ? { expect } : {}),
|
|
24
|
+
...(steps.length > 0 ? { steps } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toExpect(raw: unknown): IRenderExpect | undefined {
|
|
29
|
+
if (!isRecord(raw)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const expect: IRenderExpect = {};
|
|
34
|
+
|
|
35
|
+
if (typeof raw.selector === "string") {
|
|
36
|
+
expect.selector = raw.selector;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof raw.text === "string") {
|
|
40
|
+
expect.text = raw.text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return expect.selector !== undefined || expect.text !== undefined
|
|
44
|
+
? expect
|
|
45
|
+
: undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toStep(raw: unknown): IStep | null {
|
|
49
|
+
if (!isRecord(raw)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const step: IStep = {};
|
|
54
|
+
|
|
55
|
+
if (typeof raw.click === "string") {
|
|
56
|
+
step.click = raw.click;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
isRecord(raw.fill) &&
|
|
61
|
+
typeof raw.fill.selector === "string" &&
|
|
62
|
+
typeof raw.fill.value === "string"
|
|
63
|
+
) {
|
|
64
|
+
step.fill = { selector: raw.fill.selector, value: raw.fill.value };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const expect = toExpect(raw.expect);
|
|
68
|
+
|
|
69
|
+
if (expect !== undefined) {
|
|
70
|
+
step.expect = expect;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return step.click !== undefined ||
|
|
74
|
+
step.fill !== undefined ||
|
|
75
|
+
step.expect !== undefined
|
|
76
|
+
? step
|
|
77
|
+
: null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toSteps(raw: unknown): IStep[] {
|
|
81
|
+
if (!Array.isArray(raw)) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const steps: IStep[] = [];
|
|
86
|
+
|
|
87
|
+
for (const item of raw) {
|
|
88
|
+
const step = toStep(item);
|
|
89
|
+
|
|
90
|
+
if (step !== null) {
|
|
91
|
+
steps.push(step);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return steps;
|
|
96
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { resolve, dirname, basename, join } from "node:path";
|
|
2
|
+
import { chromium, type Page } from "playwright";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The browser oracle — renders a built web page in headless chromium and reports
|
|
6
|
+
* whether it actually WORKS, beyond what tsc/eslint can see: it fails on uncaught
|
|
7
|
+
* exceptions, console errors, and missing expected content. This is the layer
|
|
8
|
+
* that verifies the model's web UI runs, not just that it type-checks. Bundled
|
|
9
|
+
* (tsforge brings its own chromium) so any project can be browser-checked with no
|
|
10
|
+
* per-project setup. `--no-sandbox` so it runs in WSL / a container.
|
|
11
|
+
*/
|
|
12
|
+
export interface IRenderExpect {
|
|
13
|
+
/** A CSS selector that must be present after load. */
|
|
14
|
+
selector?: string;
|
|
15
|
+
/** Text the selector (or the body) must contain. */
|
|
16
|
+
text?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** An interaction step: optionally fill an input and/or click, then assert. */
|
|
20
|
+
export interface IStep {
|
|
21
|
+
click?: string;
|
|
22
|
+
fill?: { selector: string; value: string };
|
|
23
|
+
expect?: IRenderExpect;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IRenderOptions {
|
|
27
|
+
/** Path to an HTML file to open (served over http), OR inline `html`. */
|
|
28
|
+
file?: string;
|
|
29
|
+
html?: string;
|
|
30
|
+
expect?: IRenderExpect;
|
|
31
|
+
/** Interaction steps run after load — click/fill, then assert. Verifies the
|
|
32
|
+
* app actually WORKS (e.g. click increments), not just that it renders. */
|
|
33
|
+
steps?: IStep[];
|
|
34
|
+
/** Generic, app-agnostic behaviour smoke (harness-authored, not model-authored):
|
|
35
|
+
* assert the app actually mounted (root not blank) and that clicking the first
|
|
36
|
+
* few buttons throws no uncaught/console error. Catches apps that crash on
|
|
37
|
+
* interaction without needing per-app checks. */
|
|
38
|
+
smoke?: boolean;
|
|
39
|
+
/** Static URL paths to crawl after the initial load (e.g. "/accounts",
|
|
40
|
+
* "/accounts/create"): each is visited and must render non-blank with no
|
|
41
|
+
* console/page error. Catches routes that EXIST but are stub/broken — a
|
|
42
|
+
* single-page smoke misses them. Served with SPA fallback so the client
|
|
43
|
+
* router handles the path. Empty/undefined → no crawl (unchanged behavior). */
|
|
44
|
+
routes?: string[];
|
|
45
|
+
/** Navigation timeout (default 15s). */
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IRenderResult {
|
|
50
|
+
ok: boolean;
|
|
51
|
+
/** Human-readable failures (console errors, page errors, missing content). */
|
|
52
|
+
errors: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function renderCheck(
|
|
56
|
+
opts: IRenderOptions
|
|
57
|
+
): Promise<IRenderResult> {
|
|
58
|
+
const errors: string[] = [];
|
|
59
|
+
const browser = await chromium.launch({ args: ["--no-sandbox"] });
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const page = await browser.newPage();
|
|
63
|
+
const timeout = opts.timeoutMs ?? 15_000;
|
|
64
|
+
|
|
65
|
+
page.on("console", (message) => {
|
|
66
|
+
if (message.type() === "error") {
|
|
67
|
+
errors.push(`console error: ${message.text()}`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
page.on("pageerror", (error) => {
|
|
71
|
+
errors.push(`uncaught: ${error.message}`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// A file is served over http (NOT file://) so `<script type="module">` and
|
|
75
|
+
// relative fetches load — browsers block ES modules over file://. Inline
|
|
76
|
+
// `html` goes straight to setContent. For a file, the server stays up across
|
|
77
|
+
// ALL checks (incl the route crawl, which needs SPA fallback).
|
|
78
|
+
if (opts.file !== undefined) {
|
|
79
|
+
const abs = resolve(opts.file);
|
|
80
|
+
const server = startStaticServer(dirname(abs));
|
|
81
|
+
const base = `http://localhost:${String(server.port)}`;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await page.goto(`${base}/${basename(abs)}`, {
|
|
85
|
+
waitUntil: "load",
|
|
86
|
+
timeout,
|
|
87
|
+
});
|
|
88
|
+
await runChecks(page, opts, errors);
|
|
89
|
+
|
|
90
|
+
if (opts.routes !== undefined && opts.routes.length > 0) {
|
|
91
|
+
await crawlRoutes(page, base, opts.routes, errors, timeout);
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
await server.stop(true);
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
await page.setContent(opts.html ?? "", { waitUntil: "load", timeout });
|
|
98
|
+
await runChecks(page, opts, errors);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { ok: errors.length === 0, errors };
|
|
102
|
+
} finally {
|
|
103
|
+
await browser.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The expectation + step + smoke checks that run against the loaded page. */
|
|
108
|
+
async function runChecks(
|
|
109
|
+
page: Page,
|
|
110
|
+
opts: IRenderOptions,
|
|
111
|
+
errors: string[]
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
await checkExpectations(page, opts.expect, errors);
|
|
114
|
+
|
|
115
|
+
for (const step of opts.steps ?? []) {
|
|
116
|
+
await runStep(page, step, errors);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (opts.smoke === true) {
|
|
120
|
+
await runSmoke(page, errors);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Serve a directory on an ephemeral localhost port. SPA FALLBACK: an
|
|
125
|
+
* extension-less path that isn't a real file → index.html (so the client router
|
|
126
|
+
* renders that route). Missing ASSETS (paths with a `.`) still 404, so a broken
|
|
127
|
+
* bundle/import surfaces as a real error. */
|
|
128
|
+
function startStaticServer(root: string): ReturnType<typeof Bun.serve> {
|
|
129
|
+
return Bun.serve({
|
|
130
|
+
port: 0,
|
|
131
|
+
fetch: async (req): Promise<Response> => {
|
|
132
|
+
const path = new URL(req.url).pathname;
|
|
133
|
+
const rel =
|
|
134
|
+
path === "/" ? "index.html" : decodeURIComponent(path.slice(1));
|
|
135
|
+
const handle = Bun.file(join(root, rel));
|
|
136
|
+
|
|
137
|
+
if (await handle.exists()) {
|
|
138
|
+
return new Response(handle);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!rel.includes(".")) {
|
|
142
|
+
const index = Bun.file(join(root, "index.html"));
|
|
143
|
+
|
|
144
|
+
if (await index.exists()) {
|
|
145
|
+
return new Response(index);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return new Response("not found", { status: 404 });
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Visit each route and assert it renders non-blank; console/page errors during
|
|
155
|
+
* these navigations are captured by the handlers wired in renderCheck. A route
|
|
156
|
+
* that errors or paints a blank root is a real defect a single-page smoke misses. */
|
|
157
|
+
async function crawlRoutes(
|
|
158
|
+
page: Page,
|
|
159
|
+
base: string,
|
|
160
|
+
routes: readonly string[],
|
|
161
|
+
errors: string[],
|
|
162
|
+
timeout: number
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
for (const route of routes) {
|
|
165
|
+
try {
|
|
166
|
+
await page.goto(`${base}${route}`, { waitUntil: "load", timeout });
|
|
167
|
+
// Let the client router + first paint settle before the blank check (the
|
|
168
|
+
// shell/nav renders immediately, so this only flags genuinely dead routes).
|
|
169
|
+
await page.waitForTimeout(150);
|
|
170
|
+
|
|
171
|
+
const blank = await page.evaluate(() => {
|
|
172
|
+
const root =
|
|
173
|
+
document.querySelector("#root") ??
|
|
174
|
+
document.querySelector("#app") ??
|
|
175
|
+
document.body;
|
|
176
|
+
|
|
177
|
+
return root.children.length === 0 || root.textContent.trim() === "";
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (blank) {
|
|
181
|
+
errors.push(`route ${route} rendered blank`);
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
errors.push(
|
|
185
|
+
`route ${route} failed to load: ${error instanceof Error ? error.message : String(error)}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Max interactive elements the generic smoke will click. */
|
|
192
|
+
const SMOKE_CLICK_LIMIT = 5;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Generic, app-agnostic behaviour smoke. First proves the app MOUNTED (the React
|
|
196
|
+
* root has rendered content — a blank white screen is a silent failure tsc/eslint
|
|
197
|
+
* never catch). Then clicks the first few enabled buttons; any uncaught exception
|
|
198
|
+
* or console error surfaces via the page handlers wired in renderCheck. No per-app
|
|
199
|
+
* knowledge, so it never needs the model to author (flaky) checks.
|
|
200
|
+
*/
|
|
201
|
+
async function runSmoke(page: Page, errors: string[]): Promise<void> {
|
|
202
|
+
const mounted = await page.evaluate(() => {
|
|
203
|
+
const root =
|
|
204
|
+
document.querySelector("#root") ??
|
|
205
|
+
document.querySelector("#app") ??
|
|
206
|
+
document.body;
|
|
207
|
+
|
|
208
|
+
return root.children.length > 0 && root.textContent.trim() !== "";
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (!mounted) {
|
|
212
|
+
errors.push("app did not mount: root is blank after load");
|
|
213
|
+
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Click buttons only (not links — links navigate away from the SPA). The error
|
|
218
|
+
// handlers in renderCheck capture anything an onClick throws.
|
|
219
|
+
const buttons = await page.$$('button:not([disabled]), [role="button"]');
|
|
220
|
+
const limit = Math.min(buttons.length, SMOKE_CLICK_LIMIT);
|
|
221
|
+
|
|
222
|
+
for (let i = 0; i < limit; i++) {
|
|
223
|
+
try {
|
|
224
|
+
const button = buttons[i];
|
|
225
|
+
|
|
226
|
+
if (button !== undefined) {
|
|
227
|
+
await button.click({ timeout: 2000, trial: false });
|
|
228
|
+
await page.waitForTimeout(50);
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// A click that can't land (covered/detached) is not a behaviour failure;
|
|
232
|
+
// only uncaught JS errors (captured by the page handlers) count.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Run one interaction step (fill, then click, then assert) against the page. */
|
|
238
|
+
async function runStep(
|
|
239
|
+
page: Page,
|
|
240
|
+
step: IStep,
|
|
241
|
+
errors: string[]
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
if (step.fill !== undefined) {
|
|
244
|
+
try {
|
|
245
|
+
await page.fill(step.fill.selector, step.fill.value, { timeout: 3000 });
|
|
246
|
+
} catch {
|
|
247
|
+
errors.push(`could not fill ${step.fill.selector}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (step.click !== undefined) {
|
|
252
|
+
try {
|
|
253
|
+
await page.click(step.click, { timeout: 3000 });
|
|
254
|
+
} catch {
|
|
255
|
+
errors.push(`could not click ${step.click}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (step.expect !== undefined) {
|
|
260
|
+
await checkExpectations(page, step.expect, errors);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function checkExpectations(
|
|
265
|
+
page: Page,
|
|
266
|
+
expect: IRenderExpect | undefined,
|
|
267
|
+
errors: string[]
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
if (expect === undefined) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// No selector → an optional whole-page text check.
|
|
274
|
+
if (expect.selector === undefined) {
|
|
275
|
+
if (expect.text !== undefined) {
|
|
276
|
+
const body = (await page.textContent("body")) ?? "";
|
|
277
|
+
|
|
278
|
+
if (!body.includes(expect.text)) {
|
|
279
|
+
errors.push(`page is missing expected text: "${expect.text}"`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const element = await page.$(expect.selector);
|
|
287
|
+
|
|
288
|
+
if (element === null) {
|
|
289
|
+
errors.push(`expected selector not found: ${expect.selector}`);
|
|
290
|
+
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (expect.text !== undefined) {
|
|
295
|
+
const text = (await element.textContent()) ?? "";
|
|
296
|
+
|
|
297
|
+
if (!text.includes(expect.text)) {
|
|
298
|
+
errors.push(
|
|
299
|
+
`${expect.selector} is missing "${expect.text}" (got "${text.slice(0, 60)}")`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
package/src/classify.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { IProvider } from "./inference";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Classify the user's FIRST message so tsforge can route to an opinionated
|
|
5
|
+
* approach instead of improvising every time. `web`/`node` are scaffold (build-
|
|
6
|
+
* new) intents; `existing` modifies the current project; `chat` is a question.
|
|
7
|
+
* This is the lever for quality: a "build me a todo app" gets a structured,
|
|
8
|
+
* tooled scaffold by construction rather than a single-file blob.
|
|
9
|
+
*/
|
|
10
|
+
export type TaskKind = "web" | "node" | "existing" | "chat";
|
|
11
|
+
|
|
12
|
+
const CLASSIFY_SYSTEM = [
|
|
13
|
+
"Classify the user's request into EXACTLY one word:",
|
|
14
|
+
" web — build a browser app or UI (DOM, HTML, a web page/app)",
|
|
15
|
+
" node — build a CLI, library, or script (no browser UI)",
|
|
16
|
+
" existing — modify, fix, debug, or extend the project already in this directory",
|
|
17
|
+
" chat — a question or discussion; not a request to build/change code",
|
|
18
|
+
"Reply with ONLY that one lowercase word, nothing else.",
|
|
19
|
+
].join("\n");
|
|
20
|
+
|
|
21
|
+
export async function classifyIntent(
|
|
22
|
+
provider: IProvider,
|
|
23
|
+
message: string
|
|
24
|
+
): Promise<TaskKind> {
|
|
25
|
+
const res = await provider.complete(
|
|
26
|
+
[
|
|
27
|
+
{ role: "system", content: CLASSIFY_SYSTEM },
|
|
28
|
+
{ role: "user", content: message },
|
|
29
|
+
],
|
|
30
|
+
{ temperature: 0, enableThinking: false }
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const word = res.content.toLowerCase();
|
|
34
|
+
|
|
35
|
+
if (word.includes("web")) {
|
|
36
|
+
return "web";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (word.includes("existing")) {
|
|
40
|
+
return "existing";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (word.includes("node")) {
|
|
44
|
+
return "node";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return "chat";
|
|
48
|
+
}
|