@lariocpt/flawless-react 0.0.0-stage → 0.3.1
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/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- 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
|
+
}
|