@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
package/src/mcp/spec.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The `flawless-react mcp` subcommand's grammar and help, in one importless
|
|
2
|
+
// module — the same reason `skills/spec.ts` and `docLine.ts` exist.
|
|
3
|
+
//
|
|
4
|
+
// main() dispatches on it, and `--check` has to parse the `flawless-react mcp`
|
|
5
|
+
// lines in the README, the skills and the website, or a renamed subcommand
|
|
6
|
+
// survives in documentation. Without this file that line would fall through to
|
|
7
|
+
// parseArgs, read `mcp` as an entry glob, and PASS FOR THE WRONG REASON — the
|
|
8
|
+
// failure mode skills/spec.ts exists to prevent. And `--help`/`--check` must
|
|
9
|
+
// answer without importing core.ts and the TypeScript compiler behind it
|
|
10
|
+
// (FR-031), so the server's own module cannot be the source of its help text.
|
|
11
|
+
//
|
|
12
|
+
// The tool names below are prose here, not a second registry: tests/mcp.test.ts
|
|
13
|
+
// asserts this text and ALL_TOOLS name the same set, so the drift is a red test
|
|
14
|
+
// rather than a stale help screen.
|
|
15
|
+
|
|
16
|
+
export const MCP_SUBCOMMAND = "mcp";
|
|
17
|
+
|
|
18
|
+
/** True when argv is an MCP-server invocation rather than a generation run. */
|
|
19
|
+
export function isMcpArgv(argv: readonly string[]): boolean {
|
|
20
|
+
return argv[0] === MCP_SUBCOMMAND;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface McpCommand {
|
|
24
|
+
/** "serve" speaks the protocol on stdio; "help" prints the help and exits. */
|
|
25
|
+
verb: "serve" | "help";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse the arguments AFTER the `mcp` word (main() strips the subcommand).
|
|
30
|
+
*
|
|
31
|
+
* It takes no options, and says so rather than ignoring them: a host config
|
|
32
|
+
* carrying a typo'd flag would otherwise start a server that quietly did not
|
|
33
|
+
* honour it. The transport is fixed — stdio, one JSON-RPC 2.0 message per line —
|
|
34
|
+
* because it is the only one an MCP host configures without a plugin.
|
|
35
|
+
*/
|
|
36
|
+
export function parseMcpCommand(argv: readonly string[]): McpCommand {
|
|
37
|
+
const first = argv[0];
|
|
38
|
+
if (first === undefined) return { verb: "serve" };
|
|
39
|
+
if (first === "help" || first === "-h" || first === "--help") return { verb: "help" };
|
|
40
|
+
throw new Error(
|
|
41
|
+
`'mcp' takes no arguments — it serves JSON-RPC 2.0 on stdin/stdout. Got '${first}'. ` +
|
|
42
|
+
`Run 'flawless-react mcp --help' for the host configuration.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const USAGE = `flawless-react mcp — Model Context Protocol server over stdio
|
|
47
|
+
|
|
48
|
+
Speaks JSON-RPC 2.0, one message per line, on stdin and stdout. There is no
|
|
49
|
+
menu and no prompt: stdin belongs to the protocol, and anything a run prints
|
|
50
|
+
is captured per call so it can never mix with a frame.
|
|
51
|
+
|
|
52
|
+
Configure it in an MCP host (OpenCode, Claude Code, Codex, Cursor, ...):
|
|
53
|
+
|
|
54
|
+
{ "mcpServers": { "flawless-react": { "command": "flawless-react", "args": ["mcp"] } } }
|
|
55
|
+
|
|
56
|
+
tools/list returns eight tools:
|
|
57
|
+
|
|
58
|
+
flawless_run generate or refresh a suite (every CLI option), returning
|
|
59
|
+
the --ci JSON blob and the child's exit code
|
|
60
|
+
flawless_skills the embedded agent skills: status, or install
|
|
61
|
+
flawless_tasks the task registry — ids, what each does, which run under --expo
|
|
62
|
+
flawless_reference flag inventory, task ids, skill names, version
|
|
63
|
+
flawless_analyse a component's analysed facts — props, hooks, state, guards,
|
|
64
|
+
unresolved types — writing nothing
|
|
65
|
+
flawless_findings the static perf and correctness rules over a file or glob,
|
|
66
|
+
as file:line findings, writing nothing
|
|
67
|
+
flawless_project_status Next.js / Expo detection plus what suite already exists
|
|
68
|
+
flawless_check_docs parse documented flawless-react invocations, the way this
|
|
69
|
+
repo's own docs lint does
|
|
70
|
+
|
|
71
|
+
Two planes, and the difference shows up in what a call costs. The run plane
|
|
72
|
+
spawns a flawless-react CHILD per call: the CLI's real exit codes, a killable
|
|
73
|
+
process group, per-call cwd and env, nothing kept between calls. The read plane
|
|
74
|
+
runs in this process, opens no other tool, spawns nothing and writes no files.
|
|
75
|
+
|
|
76
|
+
A nonzero exit is ALWAYS an isError result carrying the child's own output, and
|
|
77
|
+
every cap is announced. This tool does not report a false pass, over MCP included.
|
|
78
|
+
|
|
79
|
+
Options:
|
|
80
|
+
-h, --help Show this help instead of serving.
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
/** The `mcp --help` text. */
|
|
84
|
+
export function renderMcpHelp(): string {
|
|
85
|
+
return USAGE;
|
|
86
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Shared plumbing for the MCP tool surface: result shapes, argument accessors
|
|
2
|
+
// that fail loudly, and output caps that are always ANNOUNCED.
|
|
3
|
+
//
|
|
4
|
+
// Why the readers reject instead of coercing: an agent that typos `run_tests` as
|
|
5
|
+
// `runTests` would otherwise get a run that silently skipped the suites and
|
|
6
|
+
// reported it green — the exact false pass this repo scores itself on removing.
|
|
7
|
+
// Same reasoning as parseArgs' `unknown flag` branch: an option that did nothing
|
|
8
|
+
// is worse than a refusal, because the caller keeps believing it took effect.
|
|
9
|
+
//
|
|
10
|
+
// Why the caps announce themselves: a silent cut turns "the last 256KB of a 4MB
|
|
11
|
+
// run log" into "the whole run log", and the reader has no way to tell.
|
|
12
|
+
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import type { JsonObject, JsonValue } from "./protocol.ts";
|
|
15
|
+
|
|
16
|
+
export interface ToolContent {
|
|
17
|
+
type: "text";
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ToolResult {
|
|
22
|
+
content: ToolContent[];
|
|
23
|
+
isError?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ToolDef {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
inputSchema: JsonObject;
|
|
30
|
+
run(args: JsonObject): Promise<ToolResult>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Argument misuse and honest failures. The server turns these into an `isError`
|
|
34
|
+
* tool result (MCP's contract for "the tool ran and said no"); protocol-layer
|
|
35
|
+
* misuse (unknown tool, malformed params) stays a JSON-RPC error, per spec. */
|
|
36
|
+
export class ToolError extends Error {}
|
|
37
|
+
|
|
38
|
+
export function ok(text: string): ToolResult {
|
|
39
|
+
return { content: [{ type: "text", text }] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function fail(text: string): ToolResult {
|
|
43
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A structured answer. Tool content is a text channel, so the JSON IS the
|
|
47
|
+
* payload — every read-plane tool returns it this way rather than inventing a
|
|
48
|
+
* prose shape the caller would have to regex. */
|
|
49
|
+
export function okJson(value: JsonValue): ToolResult {
|
|
50
|
+
return ok(JSON.stringify(value, null, 2));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Per-stream cap on captured child output. Tail-kept, because the tail of a run
|
|
54
|
+
* is where the failure is, and the marker states the total. */
|
|
55
|
+
export const OUTPUT_CAP = 256 * 1024;
|
|
56
|
+
|
|
57
|
+
export function capText(s: string, cap = OUTPUT_CAP): { text: string; truncated: boolean } {
|
|
58
|
+
if (s.length <= cap) return { text: s, truncated: false };
|
|
59
|
+
return {
|
|
60
|
+
text:
|
|
61
|
+
`… flawless-mcp: output truncated, showing last ${cap} of ${s.length} chars …\n` +
|
|
62
|
+
s.slice(-cap),
|
|
63
|
+
truncated: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How many entry files a read-plane tool analyses in one call, and its ceiling.
|
|
69
|
+
*
|
|
70
|
+
* A glob over a whole app is hundreds of files and each one is a TypeScript
|
|
71
|
+
* parse, so an unbounded read tool is a request that cannot be answered. The
|
|
72
|
+
* default is a generous sample; hitting the cap is REPORTED (`entries_skipped`)
|
|
73
|
+
* rather than quietly narrowed — "findings for 50 components" must never read as
|
|
74
|
+
* "this app has 50 components".
|
|
75
|
+
*/
|
|
76
|
+
export const DEFAULT_MAX_ENTRIES = 50;
|
|
77
|
+
export const MAX_ENTRIES_LIMIT = 500;
|
|
78
|
+
|
|
79
|
+
// ------------------------------------------------------------ arg reading ----
|
|
80
|
+
|
|
81
|
+
function bad(name: string, want: string, got: unknown): never {
|
|
82
|
+
const shown = typeof got === "string" ? JSON.stringify(got) : String(got);
|
|
83
|
+
throw new ToolError(`invalid argument "${name}": expected ${want}, got ${shown}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Required string, non-empty after trimming. */
|
|
87
|
+
export function reqStr(args: JsonObject, name: string): string {
|
|
88
|
+
const v = args[name];
|
|
89
|
+
if (typeof v !== "string" || !v.trim()) bad(name, "non-empty string", v);
|
|
90
|
+
return v as string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function optStr(args: JsonObject, name: string): string | undefined {
|
|
94
|
+
const v = args[name];
|
|
95
|
+
if (v === undefined || v === null) return undefined;
|
|
96
|
+
if (typeof v !== "string" || !v.trim()) bad(name, "non-empty string", v);
|
|
97
|
+
return v as string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function optInt(
|
|
101
|
+
args: JsonObject,
|
|
102
|
+
name: string,
|
|
103
|
+
min: number,
|
|
104
|
+
max: number,
|
|
105
|
+
): number | undefined {
|
|
106
|
+
const v = args[name];
|
|
107
|
+
if (v === undefined || v === null) return undefined;
|
|
108
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max) {
|
|
109
|
+
bad(name, `integer ${min}..${max}`, v);
|
|
110
|
+
}
|
|
111
|
+
return v as number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function optBool(args: JsonObject, name: string): boolean | undefined {
|
|
115
|
+
const v = args[name];
|
|
116
|
+
if (v === undefined || v === null) return undefined;
|
|
117
|
+
if (typeof v !== "boolean") bad(name, "boolean", v);
|
|
118
|
+
return v as boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One value from a closed set. The rejection lists the choices, because a
|
|
122
|
+
* caller that has never seen the schema is exactly the one that needs them. */
|
|
123
|
+
export function optEnum<X extends string>(
|
|
124
|
+
args: JsonObject,
|
|
125
|
+
name: string,
|
|
126
|
+
choices: readonly X[],
|
|
127
|
+
): X | undefined {
|
|
128
|
+
const v = args[name];
|
|
129
|
+
if (v === undefined || v === null) return undefined;
|
|
130
|
+
if (typeof v !== "string" || !choices.includes(v as X)) {
|
|
131
|
+
bad(name, `one of: ${choices.join(", ")}`, v);
|
|
132
|
+
}
|
|
133
|
+
return v as X;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function reqEnum<X extends string>(
|
|
137
|
+
args: JsonObject,
|
|
138
|
+
name: string,
|
|
139
|
+
choices: readonly X[],
|
|
140
|
+
): X {
|
|
141
|
+
const v = optEnum(args, name, choices);
|
|
142
|
+
if (v === undefined) throw new ToolError(`missing required argument "${name}"`);
|
|
143
|
+
return v;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A string array whose members must all belong to a closed set (task ids,
|
|
147
|
+
* severities). Members are checked one by one so the rejection names the bad
|
|
148
|
+
* one instead of the whole array. */
|
|
149
|
+
export function optEnumArray<X extends string>(
|
|
150
|
+
args: JsonObject,
|
|
151
|
+
name: string,
|
|
152
|
+
choices: readonly X[],
|
|
153
|
+
): X[] | undefined {
|
|
154
|
+
const v = args[name];
|
|
155
|
+
if (v === undefined || v === null) return undefined;
|
|
156
|
+
if (!Array.isArray(v)) bad(name, `array of: ${choices.join(", ")}`, v);
|
|
157
|
+
return (v as unknown[]).map((item) => {
|
|
158
|
+
if (typeof item !== "string" || !choices.includes(item as X)) {
|
|
159
|
+
throw new ToolError(
|
|
160
|
+
`invalid "${name}" member: expected one of ${choices.join(", ")}, got ${JSON.stringify(item)}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return item as X;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function optStrArray(args: JsonObject, name: string): string[] | undefined {
|
|
168
|
+
const v = args[name];
|
|
169
|
+
if (v === undefined || v === null) return undefined;
|
|
170
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) bad(name, "array of strings", v);
|
|
171
|
+
return v as string[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Flat string map (env vars). Rejects non-string values loudly: stringifying
|
|
175
|
+
* `{…}` into "[object Object]" would poison a run whose failure the caller
|
|
176
|
+
* then cannot explain. */
|
|
177
|
+
export function optStrMap(args: JsonObject, name: string): Record<string, string> | undefined {
|
|
178
|
+
const v = args[name];
|
|
179
|
+
if (v === undefined || v === null) return undefined;
|
|
180
|
+
if (typeof v !== "object" || Array.isArray(v)) bad(name, "object of string values", v);
|
|
181
|
+
const out: Record<string, string> = {};
|
|
182
|
+
for (const [k, item] of Object.entries(v as JsonObject)) {
|
|
183
|
+
if (typeof item !== "string") {
|
|
184
|
+
throw new ToolError(`invalid "${name}.${k}": expected string, got ${String(item)}`);
|
|
185
|
+
}
|
|
186
|
+
out[k] = item;
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A JSON object, returned as JSON text — used once, for `flawless_reference`,
|
|
193
|
+
* where the point is that the caller parses it.
|
|
194
|
+
*
|
|
195
|
+
* Kept out of the flags mapping on purpose: the value the CLI receives for
|
|
196
|
+
* `--props` is JSON text, and re-serialising a parsed copy would put a second,
|
|
197
|
+
* drift-prone copy of that contract between the agent and parsePropsJson.
|
|
198
|
+
*/
|
|
199
|
+
export function optJsonText(args: JsonObject, name: string): string | undefined {
|
|
200
|
+
const v = args[name];
|
|
201
|
+
if (v === undefined || v === null) return undefined;
|
|
202
|
+
if (typeof v !== "object") bad(name, "JSON object", v);
|
|
203
|
+
return JSON.stringify(v);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* String OR true — the shape `nextjs`/`expo` take, mirroring the flags' own
|
|
208
|
+
* optional value (`--nextjs` alone means the current directory, `--nextjs <p>`
|
|
209
|
+
* names a root). A bare `true` is accepted so a caller that read the schema as
|
|
210
|
+
* "boolean" is not refused for expressing the same intent.
|
|
211
|
+
*/
|
|
212
|
+
export function optStrOrTrue(args: JsonObject, name: string): string | true | undefined {
|
|
213
|
+
const v = args[name];
|
|
214
|
+
if (v === undefined || v === null) return undefined;
|
|
215
|
+
if (v === true) return true;
|
|
216
|
+
if (typeof v !== "string" || !v.trim()) bad(name, "path string or true", v);
|
|
217
|
+
return v as string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Unknown keys are rejected, not ignored, and required keys are checked first.
|
|
222
|
+
*
|
|
223
|
+
* The schema already says `additionalProperties: false`, but a host is free to
|
|
224
|
+
* send what it likes, and a server that quietly drops `{"run_test": true}` has
|
|
225
|
+
* just reported a green suite for a run that never executed one.
|
|
226
|
+
*/
|
|
227
|
+
export function requireKeys(
|
|
228
|
+
args: JsonObject,
|
|
229
|
+
allowed: readonly string[],
|
|
230
|
+
required: readonly string[] = [],
|
|
231
|
+
): void {
|
|
232
|
+
for (const name of required) {
|
|
233
|
+
if (args[name] === undefined || args[name] === null) {
|
|
234
|
+
throw new ToolError(`missing required argument "${name}"`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const key of Object.keys(args)) {
|
|
238
|
+
if (!allowed.includes(key)) {
|
|
239
|
+
throw new ToolError(`unknown argument "${key}" — allowed: ${allowed.join(", ")}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ------------------------------------------------------------- path roots ----
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The project directory a call operates on, absolute.
|
|
248
|
+
*
|
|
249
|
+
* Every path argument is resolved against this instead of the server changing
|
|
250
|
+
* its own working directory: one agent driving two repositories must be able to
|
|
251
|
+
* point calls at different roots without the second call silently reading the
|
|
252
|
+
* first one's files.
|
|
253
|
+
*/
|
|
254
|
+
export function rootArg(args: JsonObject): string {
|
|
255
|
+
return path.resolve(optStr(args, "root") ?? ".");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Resolve a caller-supplied path against the call's root. */
|
|
259
|
+
export function resolveIn(root: string, p: string): string {
|
|
260
|
+
return path.isAbsolute(p) ? path.normalize(p) : path.resolve(root, p);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A path made relative to the call's root, so output is stable across machines
|
|
264
|
+
* and the caller is not asked to read absolute filesystem noise. */
|
|
265
|
+
export function relativeTo(root: string, p: string): string {
|
|
266
|
+
const rel = path.relative(root, p);
|
|
267
|
+
return rel && !rel.startsWith("..") ? rel : p;
|
|
268
|
+
}
|
package/src/mcp/tools.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The tool registry: one list, ordered the way `tools/list` should present it.
|
|
2
|
+
//
|
|
3
|
+
// Why the composition lives here and not in server.ts: the tool set is a claim
|
|
4
|
+
// about the product (docs/mcp.md and the README both name these eight), and
|
|
5
|
+
// tests/mcp.test.ts asserts that claim against THIS array. A registry the server
|
|
6
|
+
// assembled inline would be reachable only by speaking JSON-RPC at a live child,
|
|
7
|
+
// which is a heavier way to learn a name drifted than importing a constant.
|
|
8
|
+
//
|
|
9
|
+
// Order is the documentation order — the two tools that change something first,
|
|
10
|
+
// then the reference trio, then the three that read a project — so a host that
|
|
11
|
+
// lists tools in arrival order shows the same story the docs tell.
|
|
12
|
+
|
|
13
|
+
import { ANALYSIS_TOOLS } from "./analysisTools.ts";
|
|
14
|
+
import { INFO_TOOLS } from "./infoTools.ts";
|
|
15
|
+
import type { JsonObject, JsonValue } from "./protocol.ts";
|
|
16
|
+
import { RUN_TOOLS } from "./runTools.ts";
|
|
17
|
+
import { type ToolDef, ToolError, type ToolResult } from "./support.ts";
|
|
18
|
+
|
|
19
|
+
export const ALL_TOOLS: ToolDef[] = [...RUN_TOOLS, ...INFO_TOOLS, ...ANALYSIS_TOOLS];
|
|
20
|
+
|
|
21
|
+
/** The `tools/list` result body, in the shape MCP prescribes. */
|
|
22
|
+
export function toolsList(): JsonValue {
|
|
23
|
+
return {
|
|
24
|
+
tools: ALL_TOOLS.map((t) => ({
|
|
25
|
+
name: t.name,
|
|
26
|
+
description: t.description,
|
|
27
|
+
inputSchema: t.inputSchema,
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function toolByName(name: string): ToolDef | undefined {
|
|
33
|
+
return ALL_TOOLS.find((t) => t.name === name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run one tool after checking its arguments against the schema it publishes.
|
|
38
|
+
*
|
|
39
|
+
* The check belongs HERE, on the one path every call takes, and not inside each
|
|
40
|
+
* tool: a schema is the contract a host codes against, so a key the schema does
|
|
41
|
+
* not name must not be silently dropped on the floor. `{"run_test": true}` typoed
|
|
42
|
+
* into `flawless_run` would otherwise return an exit 0 for a run that never
|
|
43
|
+
* executed a suite — a false pass reached through the protocol. Checking centrally
|
|
44
|
+
* is also what lets a tool that genuinely takes no arguments (`flawless_tasks`,
|
|
45
|
+
* `flawless_reference`) stay a one-line `run()` and still refuse junk.
|
|
46
|
+
*
|
|
47
|
+
* Keys, presence and shape only. Value semantics — a task id that exists, a heap
|
|
48
|
+
* mode in the closed set, an integer inside its range — stay with the argument
|
|
49
|
+
* readers in support.ts, whose rejections name the allowed set; duplicating that
|
|
50
|
+
* here would put a second, drift-prone copy of each contract in the way.
|
|
51
|
+
*/
|
|
52
|
+
export async function invokeTool(tool: ToolDef, args: JsonObject): Promise<ToolResult> {
|
|
53
|
+
const props = tool.inputSchema.properties;
|
|
54
|
+
const allowed =
|
|
55
|
+
props !== undefined && typeof props === "object" && props !== null
|
|
56
|
+
? Object.keys(props as JsonObject)
|
|
57
|
+
: [];
|
|
58
|
+
const strict = tool.inputSchema.additionalProperties === false;
|
|
59
|
+
const required = Array.isArray(tool.inputSchema.required) ? tool.inputSchema.required : [];
|
|
60
|
+
|
|
61
|
+
for (const name of required) {
|
|
62
|
+
if (typeof name !== "string") continue;
|
|
63
|
+
if (args[name] === undefined || args[name] === null) {
|
|
64
|
+
throw new ToolError(`missing required argument "${name}"`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const key of Object.keys(args)) {
|
|
68
|
+
if (strict && !allowed.includes(key)) {
|
|
69
|
+
throw new ToolError(
|
|
70
|
+
`unknown argument "${key}" for ${tool.name} — allowed: ${allowed.length ? allowed.join(", ") : "none"}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return tool.run(args);
|
|
75
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import type { NextjsApiRoute, NextjsRouter } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
|
|
7
|
+
|
|
8
|
+
export async function walkApiRoutes(
|
|
9
|
+
router: NextjsRouter,
|
|
10
|
+
routerRoot: string,
|
|
11
|
+
): Promise<NextjsApiRoute[]> {
|
|
12
|
+
if (router === "app") return walkAppRouterApi(path.join(routerRoot, "api"));
|
|
13
|
+
return walkPagesRouterApi(path.join(routerRoot, "api"));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function walkAppRouterApi(apiDir: string): Promise<NextjsApiRoute[]> {
|
|
17
|
+
if (!existsSync(apiDir)) return [];
|
|
18
|
+
const out: NextjsApiRoute[] = [];
|
|
19
|
+
const glob = new Bun.Glob("**/route.{ts,tsx,js,jsx,mjs}");
|
|
20
|
+
for await (const rel of glob.scan({ cwd: apiDir })) {
|
|
21
|
+
const abs = path.join(apiDir, rel);
|
|
22
|
+
const segments = path
|
|
23
|
+
.dirname(rel)
|
|
24
|
+
.split(path.sep)
|
|
25
|
+
.filter((s) => s !== ".");
|
|
26
|
+
// Route groups map to an empty segment — drop them or the path grows a `//`.
|
|
27
|
+
const urlPath = `/api/${segments.map(appRouterSegment).filter(Boolean).join("/")}`;
|
|
28
|
+
const methods = extractAppRouterMethods(abs);
|
|
29
|
+
if (methods.length === 0) continue;
|
|
30
|
+
out.push({
|
|
31
|
+
path: stripTrailingSlash(urlPath),
|
|
32
|
+
methods,
|
|
33
|
+
file: abs,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function walkPagesRouterApi(apiDir: string): Promise<NextjsApiRoute[]> {
|
|
40
|
+
if (!existsSync(apiDir)) return [];
|
|
41
|
+
const out: NextjsApiRoute[] = [];
|
|
42
|
+
const glob = new Bun.Glob("**/*.{ts,tsx,js,jsx,mjs}");
|
|
43
|
+
for await (const rel of glob.scan({ cwd: apiDir })) {
|
|
44
|
+
const abs = path.join(apiDir, rel);
|
|
45
|
+
const noExt = rel.replace(/\.(ts|tsx|js|jsx|mjs)$/, "");
|
|
46
|
+
let segments = noExt.split(path.sep);
|
|
47
|
+
if (segments[segments.length - 1] === "index") segments = segments.slice(0, -1);
|
|
48
|
+
const urlPath = `/api/${segments.map(pagesRouterSegment).filter(Boolean).join("/")}`;
|
|
49
|
+
out.push({
|
|
50
|
+
path: stripTrailingSlash(urlPath),
|
|
51
|
+
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
52
|
+
file: abs,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function appRouterSegment(seg: string): string {
|
|
59
|
+
// [id] -> {id}, [...slug] -> {slug} (catch-all collapses to one path-param in swagger).
|
|
60
|
+
if (seg.startsWith("[[...") && seg.endsWith("]]")) {
|
|
61
|
+
return `{${seg.slice(5, -2)}}`;
|
|
62
|
+
}
|
|
63
|
+
if (seg.startsWith("[...") && seg.endsWith("]")) {
|
|
64
|
+
return `{${seg.slice(4, -1)}}`;
|
|
65
|
+
}
|
|
66
|
+
if (seg.startsWith("[") && seg.endsWith("]")) {
|
|
67
|
+
return `{${seg.slice(1, -1)}}`;
|
|
68
|
+
}
|
|
69
|
+
// Route groups (parenthesised) don't appear in the URL.
|
|
70
|
+
if (seg.startsWith("(") && seg.endsWith(")")) return "";
|
|
71
|
+
return seg;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pagesRouterSegment(seg: string): string {
|
|
75
|
+
return appRouterSegment(seg);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stripTrailingSlash(p: string): string {
|
|
79
|
+
return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractAppRouterMethods(file: string): string[] {
|
|
83
|
+
let source = "";
|
|
84
|
+
try {
|
|
85
|
+
const fs = require("node:fs") as typeof import("node:fs");
|
|
86
|
+
source = fs.readFileSync(file, "utf8");
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
91
|
+
const found = new Set<string>();
|
|
92
|
+
const visit = (node: ts.Node): void => {
|
|
93
|
+
// export const GET = ...
|
|
94
|
+
if (ts.isVariableStatement(node) && hasExportModifier(node)) {
|
|
95
|
+
for (const d of node.declarationList.declarations) {
|
|
96
|
+
if (ts.isIdentifier(d.name) && HTTP_METHODS.has(d.name.text)) {
|
|
97
|
+
found.add(d.name.text);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// export function GET() { ... }
|
|
102
|
+
if (ts.isFunctionDeclaration(node) && hasExportModifier(node) && node.name) {
|
|
103
|
+
if (HTTP_METHODS.has(node.name.text)) found.add(node.name.text);
|
|
104
|
+
}
|
|
105
|
+
// export async function GET() { ... }
|
|
106
|
+
// (same node shape; handled above)
|
|
107
|
+
ts.forEachChild(node, visit);
|
|
108
|
+
};
|
|
109
|
+
visit(sf);
|
|
110
|
+
return [...found].sort();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasExportModifier(node: ts.HasModifiers): boolean {
|
|
114
|
+
const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
|
|
115
|
+
return Boolean(mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword));
|
|
116
|
+
}
|