@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.
Files changed (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. 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,8 @@
1
+ export {
2
+ renderCheck,
3
+ type IRenderOptions,
4
+ type IRenderExpect,
5
+ type IRenderResult,
6
+ type IStep,
7
+ } from "./oracle";
8
+ export { parseChecks } from "./checks";
@@ -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
+ }
@@ -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
+ }