@lariocpt/flawless-react 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +608 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,720 @@
1
+ // The read plane's analysis tools: the analyser and the static rules, run
2
+ // in-process over files they only OPEN.
3
+ //
4
+ // Why in-process: these are pure over the filesystem — no suite execution, no
5
+ // browser, no child — so an agent asking "what is this component, and what does
6
+ // the static engine say about it" gets an answer without paying for a process,
7
+ // and, more importantly, without any possibility of writing into the project.
8
+ // The same `analyseFile()` the generation tasks consume is what these call, so
9
+ // an answer here is the analyser's real answer, not a summary of it.
10
+ //
11
+ // Why `root` changes the working directory for the duration of a call (see
12
+ // withRoot): the glob engine and the default excludes match BOTH absolute and
13
+ // project-relative paths, and the analyser resolves tsconfig-based specifiers
14
+ // from the working directory. Running the call with `root` as cwd is what makes
15
+ // a tool answer match what the same command typed in that project would produce
16
+ // — the alternative, rewriting every pattern into an absolute one, silently
17
+ // weakens the excludes and would scan a target project's node_modules.
18
+
19
+ import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
20
+ import path from "node:path";
21
+ import { analyseFile, createAnalyseSharedContext } from "../analyse/index.ts";
22
+ import { expandGlob } from "../core.ts";
23
+ import { detectExpo } from "../expo/detect.ts";
24
+ import { detectNextjs } from "../nextjs/detect.ts";
25
+ import { hasFlawlessSkills } from "../skills/data.ts";
26
+ import { EXPO_SCRIPT, NEXT_SCRIPT, SUITE_SCRIPT } from "../suiteScripts.ts";
27
+ import { analyseTree } from "../tasks/staticPerfAnalysis.ts";
28
+ import type { ComponentTree, RootMode } from "../types.ts";
29
+ import type { JsonObject, JsonValue } from "./protocol.ts";
30
+ import {
31
+ DEFAULT_MAX_ENTRIES,
32
+ MAX_ENTRIES_LIMIT,
33
+ okJson,
34
+ optBool,
35
+ optEnum,
36
+ optEnumArray,
37
+ optInt,
38
+ optStr,
39
+ relativeTo,
40
+ reqStr,
41
+ requireKeys,
42
+ resolveIn,
43
+ rootArg,
44
+ type ToolDef,
45
+ ToolError,
46
+ } from "./support.ts";
47
+
48
+ const ROOT_MODES: readonly RootMode[] = ["primary", "exported"];
49
+ const SEVERITIES = ["error", "warn"] as const;
50
+
51
+ /** Run `fn` with the process working directory set to `root`.
52
+ *
53
+ * Safe because the server serialises tool calls (one in flight, see server.ts)
54
+ * and restored in `finally` because a throw inside the call must not leave the
55
+ * next call — or the next project — reading a stranger's files. */
56
+ async function withRoot<T>(root: string, fn: () => Promise<T>): Promise<T> {
57
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
58
+ throw new ToolError(`invalid "root": not a directory: ${root}`);
59
+ }
60
+ const prev = process.cwd();
61
+ if (prev === root) return fn();
62
+ process.chdir(root);
63
+ try {
64
+ return await fn();
65
+ } finally {
66
+ process.chdir(prev);
67
+ }
68
+ }
69
+
70
+ /** One entry pattern → the files it selects, capped, with the cut ANNOUNCED. */
71
+ async function entryFiles(
72
+ root: string,
73
+ entry: string,
74
+ max: number,
75
+ ): Promise<{ files: string[]; matched: number; skipped: number }> {
76
+ const abs = resolveIn(root, entry);
77
+ // Prefer the project-relative spelling: that is the form the default excludes
78
+ // are written against, and the form the CLI sees when a user runs the same glob.
79
+ const rel = relativeTo(root, abs);
80
+ const all = await expandGlob(rel === "" ? abs : rel);
81
+ return { files: all.slice(0, max), matched: all.length, skipped: Math.max(0, all.length - max) };
82
+ }
83
+
84
+ /**
85
+ * One component, described.
86
+ *
87
+ * Field-for-field the analyser's own vocabulary, with the bulky derivation
88
+ * (variant tables, module import graphs, positions) left out and paths made
89
+ * project-relative. Positions are kept where they are the actionable part — a
90
+ * prop used at line 41 is a different fix from one used at line 8.
91
+ */
92
+ function describeComponent(t: ComponentTree, root: string, nested = false): JsonObject {
93
+ const e = t.entry;
94
+ const out: JsonObject = {
95
+ component: e.componentName,
96
+ file: relativeTo(root, e.filePath),
97
+ export_kind: e.exportKind ?? null,
98
+ exported_as: e.exportedAs ?? null,
99
+ host_element: e.hostElement ?? null,
100
+ host_dialect: e.hostDialect ?? "dom",
101
+ root_tag: e.rootTag ?? null,
102
+ render_mode: e.renderMode ?? null,
103
+ render_notes: e.renderNotes ?? [],
104
+ render_guarded: e.renderGuarded ?? false,
105
+ render_undecidable: e.renderUndecidable ?? false,
106
+ error_boundary: e.errorBoundary ? { fallback_text: e.errorBoundary.fallbackText } : null,
107
+ is_async: e.isAsync ?? false,
108
+ is_client_component: e.isClientComponent ?? false,
109
+ wrappers: e.wrappers ?? [],
110
+ accepts_children: e.acceptsChildren ?? false,
111
+ children_flow: e.childrenFlow ?? false,
112
+ class_name_flow: e.classNameFlow ?? false,
113
+ children_slotted: e.childrenSlotted ?? false,
114
+ logs_errors: e.logsErrors ?? null,
115
+ prop_names: Object.keys(e.propsShape ?? {}),
116
+ prop_usage:
117
+ e.propUsage?.map((u) => ({
118
+ name: u.propName,
119
+ kind: u.kind,
120
+ line: u.pos.line,
121
+ ...(u.tag ? { tag: u.tag } : {}),
122
+ ...(u.attr ? { attr: u.attr } : {}),
123
+ ...(u.hook ? { hook: u.hook } : {}),
124
+ ...(u.member ? { member: u.member } : {}),
125
+ })) ?? [],
126
+ hooks: e.hooks,
127
+ state:
128
+ e.state?.map((s) => ({
129
+ name: s.varName,
130
+ kind: s.kind,
131
+ type: s.typeKind,
132
+ ...(s.setterName ? { setter: s.setterName } : {}),
133
+ line: s.pos.line,
134
+ })) ?? [],
135
+ fetches: [...(e.fetchCalls ?? []), ...(e.apiCalls ?? [])].map((f) => ({
136
+ method: f.method,
137
+ url: f.urlTemplate,
138
+ in_effect: f.inEffect,
139
+ line: f.pos.line,
140
+ })),
141
+ guards:
142
+ e.guards?.map((g) => ({
143
+ effect: g.effect,
144
+ callee: g.callee,
145
+ condition: g.conditionRaw,
146
+ condition_kind: g.conditionKind,
147
+ line: g.pos.line,
148
+ })) ?? [],
149
+ unresolved_props: e.unresolvedProps,
150
+ unresolved_types: e.unresolvedTypes ?? [],
151
+ promise_props: e.promiseProps ?? [],
152
+ static_text: (e.staticText ?? []).slice(0, 25),
153
+ compound_family: e.compoundFamily ?? null,
154
+ children: t.children.map((c) => ({
155
+ component: c.entry.componentName,
156
+ file: relativeTo(root, c.entry.filePath),
157
+ render_mode: c.entry.renderMode ?? null,
158
+ props: c.entry.unresolvedProps.length + Object.keys(c.entry.propsShape ?? {}).length,
159
+ })),
160
+ descendants: countDescendants(t),
161
+ };
162
+ if (nested) {
163
+ out.children_trees = t.children.map((c) => describeComponent(c, root, false));
164
+ }
165
+ return out;
166
+ }
167
+
168
+ function countDescendants(t: ComponentTree): number {
169
+ return t.children.reduce((n, c) => n + 1 + countDescendants(c), 0);
170
+ }
171
+
172
+ /** Shared args of the two analysis tools, minus their own extras. */
173
+ const ANALYSIS_ARGS = [
174
+ "entry",
175
+ "root",
176
+ "depth",
177
+ "include_children",
178
+ "roots",
179
+ "max_entries",
180
+ ] as const;
181
+
182
+ function analysisOptions(args: JsonObject, extra: readonly string[]) {
183
+ requireKeys(args, [...ANALYSIS_ARGS, ...extra]);
184
+ return {
185
+ entry: reqStr(args, "entry"),
186
+ root: rootArg(args),
187
+ depth: optInt(args, "depth", 1, 20) ?? 3,
188
+ includeChildren: optBool(args, "include_children") ?? true,
189
+ roots: optEnum(args, "roots", ROOT_MODES),
190
+ maxEntries: optInt(args, "max_entries", 1, MAX_ENTRIES_LIMIT) ?? DEFAULT_MAX_ENTRIES,
191
+ };
192
+ }
193
+
194
+ function entrySchema(): JsonObject {
195
+ return {
196
+ type: "string",
197
+ description:
198
+ "Component file or glob, relative to `root` (absolute accepted). The default excludes apply, " +
199
+ "so generated specs, stories and fixtures are skipped as they are on the command line.",
200
+ };
201
+ }
202
+
203
+ const ROOT_SCHEMA: JsonObject = {
204
+ type: "string",
205
+ description:
206
+ "Project directory this call reads. Every path argument resolves against it. Default: the " +
207
+ "server's working directory.",
208
+ };
209
+
210
+ // ---------------------------------------------------------- flawless_analyse ----
211
+
212
+ const flawlessAnalyse: ToolDef = {
213
+ name: "flawless_analyse",
214
+ description:
215
+ "What the analyser knows about a component: declared props and where each one is used, hooks, " +
216
+ "state, fetch calls, render guards, the render path it could not decide, unresolved prop and " +
217
+ "type names, the child tree, and the literal text it renders. This is the same analysis the " +
218
+ "generator consumes, so it answers 'why did the generated spec look thin here' — an unresolved " +
219
+ "prop type is exactly what --props fixes. Parses TypeScript; writes nothing and spawns nothing. " +
220
+ "A glob is capped by max_entries and the cut is reported as entries_skipped.",
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: {
224
+ entry: entrySchema(),
225
+ root: ROOT_SCHEMA,
226
+ depth: {
227
+ type: "integer",
228
+ minimum: 1,
229
+ maximum: 20,
230
+ description: "Component-tree depth to follow. Default 3.",
231
+ },
232
+ include_children: {
233
+ type: "boolean",
234
+ description: "Follow JSX children to leaf content. Default true.",
235
+ },
236
+ roots: {
237
+ type: "string",
238
+ enum: [...ROOT_MODES],
239
+ description:
240
+ "exported describes every exported component in each file, not just the primary one.",
241
+ },
242
+ max_entries: {
243
+ type: "integer",
244
+ minimum: 1,
245
+ maximum: MAX_ENTRIES_LIMIT,
246
+ description: `Files to analyse in one call. Default ${DEFAULT_MAX_ENTRIES}, ceiling ${MAX_ENTRIES_LIMIT}.`,
247
+ },
248
+ full: {
249
+ type: "boolean",
250
+ description:
251
+ "Include each child's own description, not just its name and file. Bigger output.",
252
+ },
253
+ },
254
+ required: ["entry"],
255
+ additionalProperties: false,
256
+ },
257
+ async run(args) {
258
+ const o = analysisOptions(args, ["full"]);
259
+ const full = optBool(args, "full") ?? false;
260
+ return withRoot(o.root, async () => {
261
+ const { files, skipped } = await entryFiles(o.root, o.entry, o.maxEntries);
262
+ if (files.length === 0) {
263
+ throw new ToolError(
264
+ `no .tsx files matched "${o.entry}" under ${o.root} — a directory is not an entry; ` +
265
+ `try a glob like "src/**/*.tsx"`,
266
+ );
267
+ }
268
+ const shared = createAnalyseSharedContext();
269
+ const components: JsonValue[] = [];
270
+ const errors: JsonValue[] = [];
271
+ for (const file of files) {
272
+ try {
273
+ const trees = await analyseFile(
274
+ {
275
+ entry: file,
276
+ depth: o.depth,
277
+ includeChildren: o.includeChildren,
278
+ ...(o.roots ? { roots: o.roots } : {}),
279
+ },
280
+ shared,
281
+ );
282
+ for (const t of trees) components.push(describeComponent(t, o.root, full));
283
+ } catch (err) {
284
+ // Per file, and reported: one unreadable file in a 50-file glob must
285
+ // not erase the other 49 answers, and must not be silent either.
286
+ errors.push({ file: relativeTo(o.root, file), error: (err as Error).message });
287
+ }
288
+ }
289
+ const out: JsonObject = {
290
+ root: o.root,
291
+ files_read: files.length,
292
+ components,
293
+ errors,
294
+ };
295
+ if (skipped > 0) {
296
+ out.entries_skipped = skipped;
297
+ out.note = `${skipped} file(s) beyond max_entries were not analysed — narrow the glob or raise max_entries`;
298
+ }
299
+ return okJson(out);
300
+ });
301
+ },
302
+ };
303
+
304
+ // --------------------------------------------------------- flawless_findings ----
305
+
306
+ const FINDINGS_ARGS = ["severities", "max_findings"] as const;
307
+ const DEFAULT_MAX_FINDINGS = 300;
308
+
309
+ const flawlessFindings: ToolDef = {
310
+ name: "flawless_findings",
311
+ description:
312
+ "Run the static analysis engine (the perf-static task) over a file or glob and return its " +
313
+ "findings as file:line records — missing memoisation, unstable child props, state that could " +
314
+ "be derived, effects that could be handlers — plus the spec-quality count of props no fixture " +
315
+ "could synthesise. Writes nothing: this is the report the task would file, without generating " +
316
+ "a suite. NOT the whole picture: the a11y and react-rules tasks are separate engines (axe and " +
317
+ "Biome) that run only in a real run, so zero findings here is not an accessible, lint-clean " +
318
+ "component. Counts are per call and bounded by max_entries and max_findings, both announced.",
319
+ inputSchema: {
320
+ type: "object",
321
+ properties: {
322
+ entry: entrySchema(),
323
+ root: ROOT_SCHEMA,
324
+ depth: {
325
+ type: "integer",
326
+ minimum: 1,
327
+ maximum: 20,
328
+ description: "Component-tree depth to follow. Default 3.",
329
+ },
330
+ include_children: {
331
+ type: "boolean",
332
+ description: "Follow JSX children to leaf content. Default true.",
333
+ },
334
+ roots: {
335
+ type: "string",
336
+ enum: [...ROOT_MODES],
337
+ description: "exported covers every export per file.",
338
+ },
339
+ max_entries: {
340
+ type: "integer",
341
+ minimum: 1,
342
+ maximum: MAX_ENTRIES_LIMIT,
343
+ description: `Files to analyse in one call. Default ${DEFAULT_MAX_ENTRIES}.`,
344
+ },
345
+ severities: {
346
+ type: "array",
347
+ items: { type: "string", enum: [...SEVERITIES] },
348
+ description: "Only these severities. Default both.",
349
+ },
350
+ max_findings: {
351
+ type: "integer",
352
+ minimum: 1,
353
+ maximum: 5000,
354
+ description: `Findings to return before truncating. Default ${DEFAULT_MAX_FINDINGS}. Counts above it are still reported in full.`,
355
+ },
356
+ },
357
+ required: ["entry"],
358
+ additionalProperties: false,
359
+ },
360
+ async run(args) {
361
+ const o = analysisOptions(args, FINDINGS_ARGS);
362
+ const severities = optEnumArray(args, "severities", SEVERITIES);
363
+ const maxFindings = optInt(args, "max_findings", 1, 5000) ?? DEFAULT_MAX_FINDINGS;
364
+ return withRoot(o.root, async () => {
365
+ const { files, skipped } = await entryFiles(o.root, o.entry, o.maxEntries);
366
+ if (files.length === 0) {
367
+ throw new ToolError(`no .tsx files matched "${o.entry}" under ${o.root}`);
368
+ }
369
+ const shared = createAnalyseSharedContext();
370
+ const all: JsonValue[] = [];
371
+ const specQuality: JsonValue[] = [];
372
+ const errors: JsonValue[] = [];
373
+ let total = 0;
374
+ for (const file of files) {
375
+ try {
376
+ const trees = await analyseFile(
377
+ {
378
+ entry: file,
379
+ depth: o.depth,
380
+ includeChildren: o.includeChildren,
381
+ ...(o.roots ? { roots: o.roots } : {}),
382
+ },
383
+ shared,
384
+ );
385
+ for (const t of trees) {
386
+ const report = analyseTree(t);
387
+ for (const f of report.findings) {
388
+ total++;
389
+ if (severities && !severities.includes(f.severity)) continue;
390
+ all.push({
391
+ rule: f.rule,
392
+ severity: f.severity,
393
+ file: relativeTo(o.root, f.file),
394
+ line: f.line,
395
+ column: f.column,
396
+ message: f.message,
397
+ component: report.component,
398
+ });
399
+ }
400
+ if (report.specQuality.length > 0) {
401
+ specQuality.push({
402
+ component: report.component,
403
+ file: relativeTo(o.root, report.file),
404
+ // Emitted as the engine produced it. SpecQualityBucket is an
405
+ // internal shape; re-deriving it here would be a second copy of
406
+ // a contract that is allowed to change.
407
+ gaps: report.specQuality as unknown as JsonValue,
408
+ });
409
+ }
410
+ }
411
+ } catch (err) {
412
+ errors.push({ file: relativeTo(o.root, file), error: (err as Error).message });
413
+ }
414
+ }
415
+ const byRule: Record<string, number> = {};
416
+ const bySeverity: Record<string, number> = {};
417
+ for (const f of all) {
418
+ const o2 = f as { rule: string; severity: string };
419
+ byRule[o2.rule] = (byRule[o2.rule] ?? 0) + 1;
420
+ bySeverity[o2.severity] = (bySeverity[o2.severity] ?? 0) + 1;
421
+ }
422
+ const truncated = all.length > maxFindings;
423
+ const out: JsonObject = {
424
+ root: o.root,
425
+ engine:
426
+ "perf-static — static analysis of the component tree. The a11y (axe) and react-rules " +
427
+ "(Biome) engines are separate and run only in a real run.",
428
+ files_read: files.length,
429
+ findings_total: total,
430
+ findings_returned: truncated ? maxFindings : all.length,
431
+ by_severity: bySeverity,
432
+ by_rule: byRule,
433
+ findings: truncated ? all.slice(0, maxFindings) : all,
434
+ spec_quality: specQuality,
435
+ errors,
436
+ };
437
+ if (truncated) {
438
+ out.note = `${all.length - maxFindings} finding(s) beyond max_findings omitted from the list; findings_total counts them`;
439
+ }
440
+ if (skipped > 0) {
441
+ out.entries_skipped = skipped;
442
+ }
443
+ return okJson(out);
444
+ });
445
+ },
446
+ };
447
+
448
+ // --------------------------------------------------- flawless_project_status ----
449
+
450
+ const STATUS_ARGS = ["root", "suite_dir", "max_files"] as const;
451
+
452
+ /** Directories no inventory should walk. Not a policy about projects — these are
453
+ * build and dependency trees, where the answer is always "thousands". */
454
+ const SKIP_DIRS = new Set([
455
+ "node_modules",
456
+ ".git",
457
+ ".next",
458
+ "dist",
459
+ "build",
460
+ "coverage",
461
+ ".cache",
462
+ ".turbo",
463
+ "out",
464
+ ]);
465
+
466
+ interface Inventory {
467
+ colocated_specs: number;
468
+ todo_markers: number;
469
+ todo_files: number;
470
+ scanned_files: number;
471
+ hit_limit: boolean;
472
+ }
473
+
474
+ /**
475
+ * Walk the project counting generated specs and `TODO(flawless)` markers.
476
+ *
477
+ * This is the question an agent actually has after a run — "how much of this
478
+ * suite is still hand-work" — and it is a bounded walk because a repo is not an
479
+ * invitation to read every byte: `max_files` stops it and `hit_limit` says it did.
480
+ */
481
+ function inventory(root: string, maxFiles: number): Inventory {
482
+ const inv: Inventory = {
483
+ colocated_specs: 0,
484
+ todo_markers: 0,
485
+ todo_files: 0,
486
+ scanned_files: 0,
487
+ hit_limit: false,
488
+ };
489
+ const stack = [root];
490
+ while (stack.length > 0) {
491
+ const dir = stack.pop() as string;
492
+ let entries: Dirent[];
493
+ try {
494
+ entries = readdirSync(dir, { withFileTypes: true });
495
+ } catch {
496
+ continue; // unreadable directory: skipped, and the scan is still honest
497
+ }
498
+ for (const ent of entries) {
499
+ if (inv.scanned_files >= maxFiles) {
500
+ inv.hit_limit = true;
501
+ return inv;
502
+ }
503
+ const full = path.join(dir, ent.name);
504
+ if (ent.isDirectory()) {
505
+ if (SKIP_DIRS.has(ent.name) || ent.name === "flawless" || ent.name === ".flawless")
506
+ continue;
507
+ stack.push(full);
508
+ continue;
509
+ }
510
+ if (!/\.(tsx|ts|jsx|js)$/.test(ent.name)) continue;
511
+ inv.scanned_files++;
512
+ const isSpec = /\.flawless\.test\.[tj]sx?$/.test(ent.name);
513
+ if (isSpec) inv.colocated_specs++;
514
+ let text: string;
515
+ try {
516
+ text = readFileSync(full, "utf8");
517
+ } catch {
518
+ continue;
519
+ }
520
+ const markers = text.split("TODO(flawless)").length - 1;
521
+ if (markers > 0) {
522
+ inv.todo_markers += markers;
523
+ inv.todo_files++;
524
+ }
525
+ }
526
+ }
527
+ return inv;
528
+ }
529
+
530
+ /** What the suite directory holds, or that it does not exist. */
531
+ function suiteInventory(root: string, rel: string): JsonObject {
532
+ const dir = resolveIn(root, rel);
533
+ if (!existsSync(dir)) return { dir: rel, exists: false };
534
+ const reportsDir = path.join(dir, "reports");
535
+ let reports: string[] = [];
536
+ if (existsSync(reportsDir)) {
537
+ try {
538
+ reports = readdirSync(reportsDir)
539
+ .filter((f) => f.endsWith(".json"))
540
+ .sort();
541
+ } catch {
542
+ reports = [];
543
+ }
544
+ }
545
+ const summaryFile = path.join(reportsDir, "summary.json");
546
+ let lastRun: JsonValue = null;
547
+ if (existsSync(summaryFile)) {
548
+ try {
549
+ // The last recorded run per task, which is the status an agent can act on.
550
+ lastRun = JSON.parse(readFileSync(summaryFile, "utf8")) as JsonValue;
551
+ } catch {
552
+ lastRun = "reports/summary.json exists but is not valid JSON";
553
+ }
554
+ }
555
+ let files = 0;
556
+ try {
557
+ files = readdirSync(dir).length;
558
+ } catch {
559
+ files = 0;
560
+ }
561
+ return {
562
+ dir: rel,
563
+ exists: true,
564
+ entries: files,
565
+ report_md: existsSync(path.join(dir, "REPORT.md")),
566
+ reports,
567
+ last_run: lastRun,
568
+ };
569
+ }
570
+
571
+ function readPackage(root: string): JsonObject | null {
572
+ const file = path.join(root, "package.json");
573
+ if (!existsSync(file)) return null;
574
+ try {
575
+ const pkg = JSON.parse(readFileSync(file, "utf8")) as {
576
+ name?: string;
577
+ scripts?: Record<string, string>;
578
+ dependencies?: Record<string, string>;
579
+ devDependencies?: Record<string, string>;
580
+ };
581
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
582
+ return {
583
+ ...(pkg.name ? { name: pkg.name } : {}),
584
+ scripts: Object.keys(pkg.scripts ?? {}).sort(),
585
+ relevant_deps: Object.entries(deps)
586
+ .filter(([k]) =>
587
+ [
588
+ "react",
589
+ "react-dom",
590
+ "next",
591
+ "expo",
592
+ "react-native",
593
+ "expo-router",
594
+ "playwright",
595
+ "@playwright/test",
596
+ "vitest",
597
+ "jest-expo",
598
+ "axe-core",
599
+ ].includes(k),
600
+ )
601
+ .reduce<Record<string, string>>((acc, [k, v]) => {
602
+ acc[k] = v;
603
+ return acc;
604
+ }, {}),
605
+ };
606
+ } catch {
607
+ return { error: "package.json is not valid JSON" };
608
+ }
609
+ }
610
+
611
+ const flawlessProjectStatus: ToolDef = {
612
+ name: "flawless_project_status",
613
+ description:
614
+ "What is already true about a project before generating anything: framework detection (Next.js " +
615
+ "router and API-route count, Expo SDK/router majors, React Compiler, NativeWind, the web port), " +
616
+ "which suite directory exists and whether it has a REPORT.md and reports/*.json, the scripts a " +
617
+ "suite of that mode is expected to have declared and which are missing, how many colocated " +
618
+ "*.flawless.test.* specs there are and how many TODO(flawless) markers are still open, and " +
619
+ "whether the agent skills are installed. Reads only, spawns nothing. Run it first: a run " +
620
+ "without --force leaves an existing suite alone, and --force regenerates it wholesale.",
621
+ inputSchema: {
622
+ type: "object",
623
+ properties: {
624
+ root: {
625
+ ...ROOT_SCHEMA,
626
+ description: "Project directory to inspect. Default: the server's working directory.",
627
+ },
628
+ suite_dir: {
629
+ type: "string",
630
+ description:
631
+ "Check this suite directory in addition to the mode defaults (flawless/, .flawless).",
632
+ },
633
+ max_files: {
634
+ type: "integer",
635
+ minimum: 1,
636
+ maximum: 50000,
637
+ description:
638
+ "Stop the spec/TODO inventory after this many source files. Default 8000, and hit_limit is reported.",
639
+ },
640
+ },
641
+ additionalProperties: false,
642
+ },
643
+ async run(args) {
644
+ requireKeys(args, STATUS_ARGS);
645
+ const root = rootArg(args);
646
+ const maxFiles = optInt(args, "max_files", 1, 50000) ?? 8000;
647
+ const extraSuite = optStr(args, "suite_dir");
648
+ return withRoot(root, async () => {
649
+ // Both detections are attempted and a refusal is data, not a failure: a
650
+ // plain component library is neither, and the agent needs the same shape
651
+ // either way. detectNextjs/detectExpo throw UsageError when the project is
652
+ // not that framework — which is a normal answer here, not an error result.
653
+ type Detected = { ok: true; facts: JsonObject } | { ok: false; reason: string };
654
+ const detection = async (fn: () => Promise<JsonObject>): Promise<Detected> => {
655
+ try {
656
+ return { ok: true, facts: await fn() };
657
+ } catch (err) {
658
+ return { ok: false, reason: (err as Error).message };
659
+ }
660
+ };
661
+ const nextRes = await detection(async () => {
662
+ const ctx = await detectNextjs(root);
663
+ return {
664
+ router: ctx.router,
665
+ app_resolver: ctx.appResolver ?? null,
666
+ api_routes: ctx.apiRoutes.length,
667
+ };
668
+ });
669
+ const expoRes = await detection(async () => {
670
+ const ctx = await detectExpo(root);
671
+ return {
672
+ router_root: ctx.routerRoot ? relativeTo(root, ctx.routerRoot) : null,
673
+ sdk_major: ctx.sdkMajor ?? null,
674
+ router_major: ctx.routerMajor ?? null,
675
+ react_compiler: ctx.reactCompiler,
676
+ nativewind: ctx.nativewind,
677
+ web_port: ctx.webPort,
678
+ };
679
+ });
680
+ const next: JsonValue = nextRes.ok
681
+ ? { ok: true, ...nextRes.facts }
682
+ : { ok: false, reason: nextRes.reason };
683
+ const expo: JsonValue = expoRes.ok
684
+ ? { ok: true, ...expoRes.facts }
685
+ : { ok: false, reason: expoRes.reason };
686
+ const mode = expoRes.ok ? "expo" : nextRes.ok ? "nextjs" : "standalone";
687
+ const scripts = expoRes.ok ? EXPO_SCRIPT : nextRes.ok ? NEXT_SCRIPT : SUITE_SCRIPT;
688
+ const expected = Object.values(scripts);
689
+ const pkg = readPackage(root);
690
+ // In framework mode the TARGET's package.json carries the scripts, so they
691
+ // are checkable here. A standalone suite keeps its own package.json inside
692
+ // the suite directory, which `suites[].last_run` covers instead — claiming
693
+ // "missing" from the project's scripts there would be a false finding.
694
+ const scriptsHome = mode === "standalone" ? ".flawless/package.json" : "package.json";
695
+ const pkgScripts = (pkg as { scripts?: Record<string, string> } | null)?.scripts ?? {};
696
+ const declared = new Set<string>(Object.keys(pkgScripts));
697
+ const suiteDirs = ["flawless", ".flawless", ...(extraSuite ? [extraSuite] : [])];
698
+ return okJson({
699
+ root,
700
+ mode,
701
+ package: pkg ?? null,
702
+ nextjs: next,
703
+ expo,
704
+ expected_scripts: expected,
705
+ scripts_declared_in: scriptsHome,
706
+ scripts_missing: mode === "standalone" ? [] : expected.filter((s) => !declared.has(s)),
707
+ suites: suiteDirs
708
+ // Dedupe: `--suite flawless` names a directory the default list already has.
709
+ .filter((d, i) => suiteDirs.indexOf(d) === i)
710
+ .map((d) => suiteInventory(root, d)),
711
+ // Spread, not the value itself: `Inventory` is an interface, so it has no
712
+ // implicit index signature and is not a JsonValue. The literal is.
713
+ inventory: { ...inventory(root, maxFiles) },
714
+ skills_installed: hasFlawlessSkills(false, root) || hasFlawlessSkills(true, root),
715
+ });
716
+ });
717
+ },
718
+ };
719
+
720
+ export const ANALYSIS_TOOLS: ToolDef[] = [flawlessAnalyse, flawlessFindings, flawlessProjectStatus];