@lariocpt/flawless-react 0.0.0-stage → 0.3.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 (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +608 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,135 @@
1
+ // The read plane's reference tools: what the CLI can do, as data, computed from
2
+ // the same tables the CLI renders its help from.
3
+ //
4
+ // These exist because an agent's alternative is guessing a flag name from a
5
+ // conversation and finding out from an exit 2. Each one reads a table that
6
+ // already exists — cliFlags.ts's inventory, the task registry, the embedded skill
7
+ // names — so there is no second copy here to drift, which is the defect this
8
+ // repo's own docs lint was written to catch. All three open no project, spawn
9
+ // nothing, and write nothing.
10
+
11
+ import { checkLines } from "../cli.ts";
12
+ import { FLAGS, type FlagDoc, MAINTENANCE_FLAGS } from "../cliFlags.ts";
13
+ import { EXPO_IMPLEMENTED } from "../expo/refusal.ts";
14
+ import { SKILL_NAMES } from "../skills/spec.ts";
15
+ import { ALL_TASK_IDS, TASKS } from "../tasks/index.ts";
16
+ import { VERSION } from "../version.ts";
17
+ import type { JsonObject, JsonValue } from "./protocol.ts";
18
+ import { okJson, reqStr, requireKeys, type ToolDef } from "./support.ts";
19
+
20
+ // ------------------------------------------------------------ flawless_tasks ----
21
+
22
+ const flawlessTasks: ToolDef = {
23
+ name: "flawless_tasks",
24
+ description:
25
+ "The task registry: every id `tasks` accepts, what it emits, and whether it runs under Expo " +
26
+ "mode or refuses by design (perf-harness is measured-and-declined there — a browser host " +
27
+ "cannot mount a route screen). Read-only, no project needed. Consult this before choosing " +
28
+ "tasks rather than guessing an id: an unknown one is a usage error, exit 2.",
29
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
30
+ async run() {
31
+ return okJson({
32
+ task_ids: [...ALL_TASK_IDS],
33
+ tasks: TASKS.map((t) => ({
34
+ id: t.id,
35
+ label: t.label,
36
+ description: t.description,
37
+ needs_tree: t.needsTree,
38
+ runs_under_expo: EXPO_IMPLEMENTED.includes(t.id),
39
+ })),
40
+ expo_note:
41
+ "A task that is not marked runs_under_expo refuses under --expo with its reason rather " +
42
+ "than emitting DOM artefacts for a React Native project.",
43
+ });
44
+ },
45
+ };
46
+
47
+ // -------------------------------------------------------- flawless_reference ----
48
+
49
+ /** FlagDoc → JSON. `summary` is a string OR continuation lines in the table, and
50
+ * a caller should not have to handle both, so it is always an array here. */
51
+ function flagJson(flags: readonly FlagDoc[]) {
52
+ return flags.map((f) => ({
53
+ name: f.name,
54
+ ...(f.alias ? { alias: f.alias } : {}),
55
+ ...(f.value ? { value: f.value } : {}),
56
+ summary: Array.isArray(f.summary) ? f.summary : [f.summary],
57
+ }));
58
+ }
59
+
60
+ const flawlessReference: ToolDef = {
61
+ name: "flawless_reference",
62
+ description:
63
+ "The CLI as data: every flag with its value form and summary, the maintenance flags " +
64
+ "(--help, --version, --flags, --check), the task ids, the embedded skill names and the " +
65
+ "version. Rendered from the same inventory --help and --flags print, so it cannot disagree " +
66
+ "with the binary. Read-only, no project needed. Use it to build an invocation instead of " +
67
+ "recalling flag names — the closed sets (--roots, --heaps) are where guessed arguments fail.",
68
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
69
+ async run() {
70
+ return okJson({
71
+ version: VERSION,
72
+ usage:
73
+ "flawless-react [entry] [flags] | flawless-react skills <list|install> | flawless-react mcp",
74
+ flags: flagJson(FLAGS),
75
+ maintenance_flags: flagJson(MAINTENANCE_FLAGS),
76
+ task_ids: [...ALL_TASK_IDS],
77
+ skill_names: [...SKILL_NAMES],
78
+ exits: {
79
+ "0": "success",
80
+ "1": "a task failed, or a suite failed under run_tests",
81
+ "2": "usage error — bad flag, unknown task id, unresolvable props",
82
+ },
83
+ ci_contract:
84
+ "With --ci the run is non-interactive and stdout is exactly one JSON document: " +
85
+ "{exitCode, outDir, results, keptScripts, testRun?, errors?}. A usage error exits 2 with " +
86
+ "its reason on stderr and no JSON.",
87
+ });
88
+ },
89
+ };
90
+
91
+ // ------------------------------------------------------- flawless_check_docs ----
92
+
93
+ const CHECK_DOCS_ARGS = ["text"] as const;
94
+
95
+ const flawlessCheckDocs: ToolDef = {
96
+ name: "flawless_check_docs",
97
+ description:
98
+ "Parse documented flawless-react invocations without running them — the same checker this " +
99
+ "repo's docs lint runs against README, the skills and the website. Give it prose or fenced " +
100
+ "shell blocks: every line that invokes flawless-react is parsed for unknown flags, unknown " +
101
+ "task ids and closed-set values, and lines that invoke something else are counted as skipped " +
102
+ "rather than passed. Returns {checked, skipped, problems}. Read-only. A corpus with " +
103
+ "checked=0 checked nothing — that is a gap in the extractor, not a pass.",
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ text: {
108
+ type: "string",
109
+ description:
110
+ "Documentation text. Shell-runner prefixes, env assignments, quotes and trailing # comments are stripped.",
111
+ },
112
+ },
113
+ required: ["text"],
114
+ additionalProperties: false,
115
+ },
116
+ async run(args) {
117
+ requireKeys(args, CHECK_DOCS_ARGS, ["text"]);
118
+ const report = checkLines(reqStr(args, "text"));
119
+ const out: JsonObject = {
120
+ checked: report.checked,
121
+ skipped: report.skipped,
122
+ problems: report.problems.map((p): JsonValue => ({ line: p.line, problem: p.problem })),
123
+ };
124
+ if (report.checked === 0) {
125
+ out.note = "nothing was checked: no line in the text invoked flawless-react";
126
+ }
127
+ // An example that does not parse is a broken documented command, and the
128
+ // caller needs that to be a failure, not a field they might forget to read.
129
+ return report.problems.length === 0
130
+ ? okJson(out)
131
+ : { content: [{ type: "text", text: JSON.stringify(out, null, 2) }], isError: true };
132
+ },
133
+ };
134
+
135
+ export const INFO_TOOLS: ToolDef[] = [flawlessTasks, flawlessReference, flawlessCheckDocs];
@@ -0,0 +1,130 @@
1
+ // JSON-RPC 2.0 message layer for the MCP stdio transport.
2
+ //
3
+ // The wire contract (MCP stdio): one JSON message per line, `\n` delimited, no
4
+ // embedded newlines inside a message. This is the whole protocol surface
5
+ // flawless-react needs — hand-rolled because the shipped artifact is a compiled
6
+ // Bun binary whose bundle stays dependency-free, so the official SDK is not an
7
+ // option inside it (the same rule that keeps src/ free of imports in
8
+ // cliFlags.ts and docLine.ts).
9
+ //
10
+ // Honest framing: a line that is not valid JSON gets a -32700 response with
11
+ // id:null, exactly as JSON-RPC prescribes, and the connection keeps serving.
12
+ // Dropping it silently would let a partially-written message vanish without the
13
+ // client ever learning its call was never parsed — the false pass, in the one
14
+ // place where a pass is a frame the host is about to trust.
15
+
16
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
17
+ export type JsonObject = { [k: string]: JsonValue };
18
+
19
+ export interface RpcRequest {
20
+ jsonrpc: "2.0";
21
+ id: number | string;
22
+ method: string;
23
+ params?: JsonObject;
24
+ }
25
+
26
+ export interface RpcNotification {
27
+ jsonrpc: "2.0";
28
+ method: string;
29
+ params?: JsonObject;
30
+ }
31
+
32
+ /** parseLine's discriminated result: a frame is EITHER a parsed message OR the
33
+ * -32700/-32600 response the server owes the client — never a bag of optional
34
+ * fields the dispatcher has to guess through. */
35
+ export type ParsedLine =
36
+ | { kind: "request"; request: RpcRequest }
37
+ | { kind: "notification"; notification: RpcNotification }
38
+ | { kind: "invalid"; response: RpcResponse };
39
+
40
+ export interface RpcError {
41
+ code: number;
42
+ message: string;
43
+ data?: JsonValue;
44
+ }
45
+
46
+ export interface RpcResponse {
47
+ jsonrpc: "2.0";
48
+ id: number | string | null;
49
+ result?: JsonValue;
50
+ error?: RpcError;
51
+ }
52
+
53
+ // The standard JSON-RPC codes MCP hosts expect, plus the one MCP reserves for
54
+ // "the server understood and refuses" (used here for the not-initialized guard).
55
+ export const PARSE_ERROR = -32700;
56
+ export const INVALID_REQUEST = -32600;
57
+ export const METHOD_NOT_FOUND = -32601;
58
+ export const INVALID_PARAMS = -32602;
59
+ export const INTERNAL_ERROR = -32603;
60
+ export const SERVER_NOT_INITIALIZED = -32002;
61
+
62
+ export function isRequest(v: unknown): v is RpcRequest {
63
+ const o = v as Partial<RpcRequest> | null;
64
+ return (
65
+ typeof v === "object" &&
66
+ o !== null &&
67
+ o.jsonrpc === "2.0" &&
68
+ typeof o.method === "string" &&
69
+ (typeof o.id === "number" || typeof o.id === "string")
70
+ );
71
+ }
72
+
73
+ export function isNotification(v: unknown): v is RpcNotification {
74
+ const o = v as (Partial<RpcNotification> & { id?: unknown }) | null;
75
+ return (
76
+ typeof v === "object" &&
77
+ o !== null &&
78
+ o.jsonrpc === "2.0" &&
79
+ typeof o.method === "string" &&
80
+ o.id === undefined
81
+ );
82
+ }
83
+
84
+ export function reply(id: number | string | null, result: JsonValue): RpcResponse {
85
+ return { jsonrpc: "2.0", id, result };
86
+ }
87
+
88
+ export function fail(id: number | string | null, error: RpcError): RpcResponse {
89
+ return { jsonrpc: "2.0", id, error };
90
+ }
91
+
92
+ /**
93
+ * Parse one framed line into a discriminated result.
94
+ *
95
+ * A frame that is not valid JSON, or JSON without a string method, gets the
96
+ * prescribed error response (id:null) rather than silent loss: the client must
97
+ * learn its call was never understood.
98
+ */
99
+ export function parseLine(line: string): ParsedLine {
100
+ let value: unknown;
101
+ try {
102
+ value = JSON.parse(line);
103
+ } catch {
104
+ return {
105
+ kind: "invalid",
106
+ response: fail(null, { code: PARSE_ERROR, message: "parse error: line is not valid JSON" }),
107
+ };
108
+ }
109
+ if (isRequest(value)) return { kind: "request", request: value };
110
+ if (isNotification(value)) return { kind: "notification", notification: value };
111
+ return {
112
+ kind: "invalid",
113
+ response: fail(null, {
114
+ code: INVALID_REQUEST,
115
+ message: "expected a JSON-RPC 2.0 request or notification with a string method",
116
+ }),
117
+ };
118
+ }
119
+
120
+ export function serialize(message: RpcResponse): string {
121
+ // JSON.stringify NEVER emits a raw newline, so one message is always one line
122
+ // — the only framing rule this transport has.
123
+ return `${JSON.stringify(message)}\n`;
124
+ }
125
+
126
+ // Reserved for genuine dispatch bugs; every handler path currently in server.ts
127
+ // has a defined answer, so nothing reaches it yet. Kept exported-and-void rather
128
+ // than deleted: the next method added needs the code, and a handler that has no
129
+ // answer owes -32603, not a thrown stack.
130
+ void INTERNAL_ERROR;
package/src/mcp/run.ts ADDED
@@ -0,0 +1,269 @@
1
+ // The run plane of the MCP surface: every tool that GENERATES, INSTALLS or runs
2
+ // suites spawns a flawless-react CHILD process per call.
3
+ //
4
+ // Why a child is structural, not cosmetic: core.ts's test runner spawns the
5
+ // generated Playwright/Vitest suites with `stdout: "inherit"` unless the run is
6
+ // quiet, and the scaffold narrates progress to stdout. In-process execution
7
+ // would write raw bytes into the protocol's stdout and corrupt JSON-RPC — the
8
+ // one failure an MCP server cannot survive, because the host reads the stream
9
+ // for framing. The child boundary also buys the three things a long generation
10
+ // job actually needs: the CLI's real exit-code contract (0/1/2, including the
11
+ // `exitCode` inside the --ci blob), a killable process group for a suite that
12
+ // hangs, and per-call cwd/env with nothing kept between calls.
13
+ //
14
+ // Honest-failure contract (the repo's first design rule, ported to MCP): a
15
+ // nonzero child exit or a killed timeout is an isError result carrying the
16
+ // child's own output — never a success that measured nothing. Output caps are
17
+ // always announced, never silent.
18
+
19
+ import path from "node:path";
20
+ import type { JsonObject, JsonValue } from "./protocol.ts";
21
+ import { capText, fail, ok, optInt, optStr, optStrMap, type ToolResult } from "./support.ts";
22
+
23
+ /** Interpreter names. When `process.execPath` is one of these, the CLI was
24
+ * started as `bun <script>`, and re-invoking means naming the script again. */
25
+ const RUNTIMES = new Set(["bun", "bunx", "node", "nodejs", "deno"]);
26
+
27
+ /**
28
+ * How to re-invoke THIS flawless-react from inside itself.
29
+ *
30
+ * Measured on Bun 1.4 for all three shipped shapes (a probe, not an assumption —
31
+ * the compiled binary reports argv differently and a wrong guess here means every
32
+ * run tool spawns the wrong thing):
33
+ *
34
+ * `bun src/cli.ts mcp` execPath=<bun> argv[1]=<abs>/src/cli.ts
35
+ * `bin/flawless-react mcp` execPath=<bun> argv[1]=<abs>/bin/flawless-react
36
+ * (the npm shim is extensionless, so an extension
37
+ * test on argv[1] — the obvious heuristic — picks
38
+ * the WRONG branch here)
39
+ * compiled binary execPath=<the binary> argv[0]="bun"
40
+ * argv[1]=Bun.main="/$bunfs/root/flawless-react"
41
+ *
42
+ * So: the virtual-fs path proves we are the binary itself; otherwise a runtime
43
+ * execPath proves we must re-name the script.
44
+ */
45
+ export function flawlessSpawnArgv(): string[] {
46
+ const script = process.argv[1] ?? "";
47
+ if (script.startsWith("/$bunfs/")) return [process.execPath];
48
+ if (RUNTIMES.has(path.basename(process.execPath))) {
49
+ if (!script) {
50
+ throw new Error(
51
+ "flawless-mcp: cannot re-invoke flawless-react — no script path in argv[1] and " +
52
+ `execPath is the runtime (${process.execPath}). Start the server as a compiled ` +
53
+ "binary or as `bun src/cli.ts mcp`.",
54
+ );
55
+ }
56
+ return [process.execPath, script];
57
+ }
58
+ return [process.execPath];
59
+ }
60
+
61
+ export interface ChildRun {
62
+ code: number;
63
+ stdout: string;
64
+ stderr: string;
65
+ timedOut: boolean;
66
+ }
67
+
68
+ /** Generation over a whole app's glob is analysis + file writing; with
69
+ * `run_tests` it is Playwright and Vitest on top. Ten minutes is the default
70
+ * because that is what a component-level run costs, and the timeout is a
71
+ * watchdog with a named deadline, not a budget the tool silently enforces. */
72
+ export const DEFAULT_TIMEOUT_MS = 600_000;
73
+ export const TIMEOUT_MIN = 1_000;
74
+ export const TIMEOUT_MAX = 4 * 3_600_000;
75
+
76
+ export interface ChildOptions {
77
+ cwd?: string;
78
+ env?: Record<string, string>;
79
+ timeoutMs?: number;
80
+ }
81
+
82
+ /**
83
+ * Children this process spawned and has not reaped.
84
+ *
85
+ * The registry exists for one moment: the host closes stdin or sends SIGTERM
86
+ * while a generation is mid-flight. Without it the child — and under `--run-tests`
87
+ * its dev server and browsers — outlives the server that started it, holding
88
+ * ports in a project whose agent has already gone.
89
+ */
90
+ const LIVE = new Set<ReturnType<typeof Bun.spawn>>();
91
+
92
+ /**
93
+ * Set once the server has begun teardown.
94
+ *
95
+ * It closes the one race the registry would otherwise leave: a call already
96
+ * dequeued when stdin closed spawns its child a moment AFTER the kill pass, and
97
+ * that child — a generation, or under `--run-tests` a dev server and browsers —
98
+ * would then be orphaned with nobody left to reap it. After teardown, registering
99
+ * is killing.
100
+ */
101
+ let TEARDOWN = false;
102
+
103
+ /** Kills performed because the server is going away — not watchdog timeouts, which
104
+ * are the caller's own deadline, not teardown. The stderr note quotes this. */
105
+ let KILLED_TOTAL = 0;
106
+
107
+ function killGroup(proc: ReturnType<typeof Bun.spawn>): void {
108
+ try {
109
+ process.kill(-proc.pid, "SIGKILL");
110
+ } catch {
111
+ try {
112
+ proc.kill("SIGKILL");
113
+ } catch {
114
+ // Already dead. The registry entry is dropped either way.
115
+ }
116
+ }
117
+ }
118
+
119
+ /** Register a live child, or kill it on arrival if teardown already happened. */
120
+ function track(proc: ReturnType<typeof Bun.spawn>): void {
121
+ LIVE.add(proc);
122
+ if (TEARDOWN) {
123
+ KILLED_TOTAL++;
124
+ killGroup(proc);
125
+ }
126
+ }
127
+
128
+ /** SIGKILL every live child's process group. Sticky: children spawned afterwards
129
+ * are killed as they appear, so a call that was dequeued after teardown started
130
+ * cannot orphan its child. */
131
+ export function killLiveChildren(): void {
132
+ TEARDOWN = true;
133
+ KILLED_TOTAL += LIVE.size;
134
+ for (const proc of LIVE) killGroup(proc);
135
+ LIVE.clear();
136
+ }
137
+
138
+ /** How many children teardown has killed, late kills included. */
139
+ export function killedChildCount(): number {
140
+ return KILLED_TOTAL;
141
+ }
142
+
143
+ /**
144
+ * Run `flawless-react <args…>` to completion, or kill its whole process group at
145
+ * the deadline.
146
+ *
147
+ * Children are spawned detached so the kill reaches the grandchildren — a suite
148
+ * under `--run-tests` is a dev server plus browsers plus workers, and killing
149
+ * only the CLI would orphan exactly the processes that hold ports.
150
+ */
151
+ export async function runFlawlessChild(args: string[], opts: ChildOptions = {}): Promise<ChildRun> {
152
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
153
+ let proc: ReturnType<typeof Bun.spawn>;
154
+ try {
155
+ proc = Bun.spawn([...flawlessSpawnArgv(), ...args], {
156
+ stdin: "ignore",
157
+ stdout: "pipe",
158
+ stderr: "pipe",
159
+ detached: true,
160
+ ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
161
+ ...(opts.env !== undefined ? { env: { ...process.env, ...opts.env } } : {}),
162
+ });
163
+ } catch (err) {
164
+ // A bad cwd is Bun.spawn throwing ENOENT before anything ran. Name the
165
+ // argument at fault rather than the syscall.
166
+ const message = (err as Error).message;
167
+ if (opts.cwd !== undefined && message.includes(opts.cwd)) {
168
+ throw new Error(`invalid "cwd": ${message}`);
169
+ }
170
+ throw err;
171
+ }
172
+ track(proc);
173
+ let timedOut = false;
174
+ const killer = setTimeout(() => {
175
+ timedOut = true;
176
+ killGroup(proc);
177
+ }, timeoutMs);
178
+ // No race needed: the watchdog KILLS the child, so `proc.exited` is the one
179
+ // promise that always lands — with or without the deadline.
180
+ const readOut = new Response(proc.stdout as ReadableStream).text();
181
+ const readErr = new Response(proc.stderr as ReadableStream).text();
182
+ await proc.exited;
183
+ LIVE.delete(proc);
184
+ clearTimeout(killer);
185
+ const stdout = await readOut;
186
+ const stderr = await readErr;
187
+ return { code: timedOut ? -1 : (proc.exitCode ?? -1), stdout, stderr, timedOut };
188
+ }
189
+
190
+ /** A stream, capped and labelled; empty streams say so rather than vanishing. */
191
+ function stream(label: string, text: string): string | null {
192
+ const capped = capText(text);
193
+ const body = capped.text.replace(/\n+$/, "");
194
+ if (!body.trim()) return null;
195
+ return `--- ${label}${capped.truncated ? " (truncated, see marker)" : ""} ---\n${body}`;
196
+ }
197
+
198
+ /** Nonzero exit and timeouts are isError; the child's verdict is the verdict. */
199
+ export function renderRun(run: ChildRun, timeoutMs: number): ToolResult {
200
+ const parts = [
201
+ run.timedOut
202
+ ? `timed out after ${timeoutMs}ms — process group killed (exit reported as -1)`
203
+ : `exit ${run.code}`,
204
+ ];
205
+ const out = stream("stdout", run.stdout);
206
+ const err = stream("stderr", run.stderr);
207
+ if (out) parts.push(out);
208
+ if (err) parts.push(err);
209
+ if (!out && !err) parts.push("(no output)");
210
+ return run.timedOut || run.code !== 0 ? fail(parts.join("\n")) : ok(parts.join("\n"));
211
+ }
212
+
213
+ /**
214
+ * A `--ci` child run rendered as a tool result: the JSON blob parsed and
215
+ * re-emitted (the caller gets structure, not a string it has to un-nest from a
216
+ * text field), with the child's stderr diagnostics kept beside it.
217
+ *
218
+ * The parse is allowed to fail, and says so when it does: `--ci`'s contract is
219
+ * that a successful run prints exactly one JSON document, but a usage error
220
+ * exits 2 with its reason on stderr and nothing on stdout, and reporting THAT as
221
+ * "no JSON" would bury the actual message. So an unparseable stdout falls back
222
+ * to the raw streams rather than to a guess.
223
+ */
224
+ export function renderCiRun(run: ChildRun, timeoutMs: number): ToolResult {
225
+ const head = run.timedOut
226
+ ? `timed out after ${timeoutMs}ms — process group killed (exit reported as -1)`
227
+ : `exit ${run.code}`;
228
+ const blob = parseCiBlob(run.stdout);
229
+ const parts = [head];
230
+ if (blob !== null) {
231
+ parts.push(`--- result (--ci --json) ---\n${JSON.stringify(blob, null, 2)}`);
232
+ } else {
233
+ const out = stream("stdout (not a JSON blob)", run.stdout);
234
+ if (out) parts.push(out);
235
+ }
236
+ const err = stream("stderr", run.stderr);
237
+ if (err) parts.push(err);
238
+ if (blob === null && !err) parts.push("(no output)");
239
+ return run.timedOut || run.code !== 0 ? fail(parts.join("\n")) : ok(parts.join("\n"));
240
+ }
241
+
242
+ /** The blob, or null when stdout is not one. Trailing text after the document is
243
+ * a contract break, so it is reported as unparseable rather than ignored. */
244
+ function parseCiBlob(stdout: string): JsonValue | null {
245
+ const trimmed = stdout.trim();
246
+ if (!trimmed) return null;
247
+ try {
248
+ return JSON.parse(trimmed) as JsonValue;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Per-call cwd/env/watchdog, read through the same validating accessors every
256
+ * other argument uses — a timeout of `"60000"` (a string, from a host that
257
+ * stringifies) is rejected by name rather than silently dropped, leaving the
258
+ * caller with a run that had no watchdog at all.
259
+ */
260
+ export function callOpts(args: JsonObject): ChildOptions {
261
+ const timeoutMs = optInt(args, "timeout_ms", TIMEOUT_MIN, TIMEOUT_MAX);
262
+ const cwd = optStr(args, "cwd");
263
+ const env = optStrMap(args, "env");
264
+ return {
265
+ ...(cwd !== undefined ? { cwd } : {}),
266
+ ...(env !== undefined ? { env } : {}),
267
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
268
+ };
269
+ }