@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.
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 +611 -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,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
+ }
@@ -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
+ }