@pyreon/mcp 0.12.12 → 0.12.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/mcp",
3
- "version": "0.12.12",
3
+ "version": "0.12.14",
4
4
  "description": "MCP server for Pyreon — AI-powered framework assistance",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/mcp#readme",
6
6
  "bugs": {
@@ -46,9 +46,12 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.29.0",
49
- "@pyreon/compiler": "^0.12.12",
49
+ "@pyreon/compiler": "^0.12.14",
50
50
  "zod": "^4.3.6"
51
51
  },
52
+ "devDependencies": {
53
+ "@pyreon/manifest": "0.0.0"
54
+ },
52
55
  "peerDependencies": {
53
56
  "typescript": ">=5.0.0"
54
57
  }
@@ -747,9 +747,10 @@ await doc.toNotion() // Notion blocks`,
747
747
  // @pyreon/flow
748
748
  // ═══════════════════════════════════════════════════════════════════════════
749
749
 
750
+ // <gen-docs:api-reference:start @pyreon/flow>
751
+
750
752
  'flow/createFlow': {
751
- signature:
752
- 'createFlow<TData = Record<string, unknown>>(config: FlowConfig<TData>): FlowInstance<TData>',
753
+ signature: '<TData = Record<string, unknown>>(config: FlowConfig<TData>) => FlowInstance<TData>',
753
754
  example: `// Generic over node data shape — typed consumers get strong narrowing
754
755
  interface WorkflowData {
755
756
  kind: 'trigger' | 'filter' | 'transform' | 'notify'
@@ -768,13 +769,52 @@ const flow = createFlow<WorkflowData>({
768
769
  const trigger = flow.findNodes((n) => n.data.kind === 'trigger')
769
770
 
770
771
  flow.addNode({ id: '3', type: 'custom', position: { x: 100, y: 200 }, data: { kind: 'transform', label: 'New' } })
771
- await flow.layout('layered', { direction: 'RIGHT', nodeSpacing: 50, layerSpacing: 100 }) // auto-layout via lazy-loaded elkjs
772
- // LayoutOptions applicability: direction/layerSpacing/edgeRouting apply to layered/tree only;
772
+ await flow.layout('layered', { direction: 'RIGHT', nodeSpacing: 50, layerSpacing: 100 })
773
+ // LayoutOptions applicability: direction / layerSpacing / edgeRouting apply to layered/tree only;
773
774
  // force/stress/radial/box/rectpacking silently ignore them. nodeSpacing applies to all algorithms.
774
- const json = flow.toJSON(); flow.fromJSON(json) // round-trip serialization
775
+ const json = flow.toJSON(); flow.fromJSON(json) // round-trip serialization`,
776
+ notes: 'Create a reactive flow instance. Generic over node data shape — `createFlow<MyData>(...)` returns `FlowInstance<MyData>` so `node.data.kind` narrows correctly without an `[key: string]: unknown` index signature on consumer types. Defaults to `Record<string, unknown>` when no generic is supplied. The returned instance owns signal-native nodes / edges and exposes CRUD, selection, viewport (zoom / pan / fitView), and auto-layout via lazy-loaded elkjs (first `.layout()` call fetches a ~1.4MB chunk). Pan / zoom uses pointer events + CSS transforms — no D3. See also: useFlow, FlowInstance, Flow.',
777
+ mistakes: `- Forgetting to declare \`@pyreon/runtime-dom\` in consumer app deps — flow's JSX emits \`_tpl()\` which needs runtime-dom imports
778
+ - Reading \`NodeComponentProps.data\` / \`.selected\` / \`.dragging\` as plain values — all three are REACTIVE ACCESSORS: \`props.data()\`, \`props.selected()\`, \`props.dragging()\`
779
+ - Calling \`props.data()\` OUTSIDE a reactive scope — captures the value once at component setup, defeating the per-node reactivity. Read it inside JSX expression thunks, \`effect\`, or \`computed\`
780
+ - Adding \`[key: string]: unknown\` index signature to your node data interface — no longer needed now that \`createFlow\` is generic. Pass \`createFlow<MyData>(...)\` instead
781
+ - Setting \`LayoutOptions.direction\` (or \`layerSpacing\`, or \`edgeRouting\`) on a force / stress / radial / box / rectpacking layout and expecting a directional result — these options are namespaced under ELK's layered / tree pipelines and silently ignored by the geometric algorithms. Dev-mode \`console.warn\` fires when this happens
782
+ - Missing \`<Flow nodeTypes={{ key: Component }}>\` registration — \`node.type\` strings dispatch to that map, unregistered types fall through to the default renderer
783
+ - Using \`createFlow\` inside a component body without \`onUnmount(() => flow.dispose())\` — prefer \`useFlow\` which auto-disposes
784
+ - Using \`direction: 'row'\` on flow's containing Element layout — Pyreon \`Element\` accepts \`'inline'\` / \`'rows'\` / \`'reverseInline'\` / \`'reverseRows'\`, not CSS flex-direction values like \`'row'\` or \`'column'\``,
785
+ },
786
+
787
+ 'flow/useFlow': {
788
+ signature: '<TData = Record<string, unknown>>(config: FlowConfig<TData>) => FlowInstance<TData>',
789
+ example: `// Component-scoped flow — auto-disposes when the component unmounts.
790
+ // Identical shape to createFlow, plus an implicit onUnmount(() => flow.dispose()).
791
+ const MyDiagram = () => {
792
+ const flow = useFlow<WorkflowData>({
793
+ nodes: [{ id: '1', position: { x: 0, y: 0 }, data: { kind: 'trigger', label: 'Start' } }],
794
+ edges: [],
795
+ })
796
+ return (
797
+ <Flow instance={flow}>
798
+ <Background />
799
+ </Flow>
800
+ )
801
+ }`,
802
+ notes: `Component-scoped wrapper around \`createFlow\` — identical shape plus an implicit \`onUnmount(() => flow.dispose())\`. Prefer inside component bodies; use \`createFlow\` directly only for flows owned outside the component tree (app stores, singletons, SSR-shared state) where you'll dispose at the correct lifecycle point yourself. See also: createFlow.`,
803
+ mistakes: `- Using \`useFlow\` outside a component body — the \`onUnmount\` hook registration requires an active component setup context, same constraint as every \`useX\` hook
804
+ - Using \`createFlow\` inside a component and forgetting \`onUnmount(() => flow.dispose())\` — that was the footgun \`useFlow\` exists to prevent
805
+ - Storing the returned instance in a module-level variable — bypasses the auto-dispose guarantee; use \`createFlow\` for that pattern`,
806
+ },
807
+
808
+ 'flow/Flow': {
809
+ signature: '(props: FlowComponentProps) => VNodeChild',
810
+ example: `<Flow instance={flow} nodeTypes={{ custom: MyNode }} edgeTypes={{ arrow: ArrowEdge }}>
811
+ <Background variant="dots" gap={20} />
812
+ <Controls position="bottom-left" />
813
+ <MiniMap nodeColor={(node) => '#6366f1'} />
814
+ </Flow>
775
815
 
776
816
  // Custom node renderer — every prop except id is a REACTIVE ACCESSOR
777
- function CustomNode(props: NodeComponentProps<WorkflowData>) {
817
+ function MyNode(props: NodeComponentProps<WorkflowData>) {
778
818
  return (
779
819
  <div
780
820
  class={() => (props.selected() ? 'selected' : '')}
@@ -783,23 +823,68 @@ function CustomNode(props: NodeComponentProps<WorkflowData>) {
783
823
  {() => props.data().label}
784
824
  </div>
785
825
  )
826
+ }`,
827
+ notes: 'Main flow container. Accepts a `FlowInstance` via the `instance` prop plus optional `nodeTypes` / `edgeTypes` maps for custom renderers. Internally uses `<For>` keyed by `node.id` plus per-node reactive accessors that read live state from `instance.nodes()` — each node mounts EXACTLY ONCE across the lifetime of the graph regardless of drags, selection clicks, or `updateNode` mutations. A 60fps drag in a 1000-node graph stays O(1) per frame. JSX components are NOT generic at the call site (`<Flow<MyData> />` is invalid JSX); `FlowProps.instance` is typed as `FlowInstance<any>` so typed consumers can pass `FlowInstance<MyData>` without casting. See also: createFlow, Background, Controls, MiniMap, Handle.',
828
+ mistakes: `- \`<Flow<MyData> />\` is invalid JSX — the component is not generic at the call site; pass a typed \`FlowInstance<MyData>\` via \`instance\` prop
829
+ - Missing \`nodeTypes\` entry for a \`node.type\` string — falls through to the default renderer
830
+ - Mutating \`instance.nodes()\` return value directly — use \`instance.addNode\` / \`updateNode\` / \`removeNode\` so the internal signals fire`,
831
+ },
832
+
833
+ 'flow/Background': {
834
+ signature: '(props: { variant?: "dots" | "lines"; gap?: number; color?: string }) => VNodeChild',
835
+ example: `<Flow instance={flow}>
836
+ <Background variant="dots" gap={24} color="#e5e7eb" />
837
+ </Flow>`,
838
+ notes: 'Dot or line grid background inside a `<Flow>`. Place as a direct child. `variant` defaults to `"dots"`, `gap` controls pattern spacing, `color` sets the pattern color. Renders as an SVG pattern at the back of the z-order. See also: Flow, Controls, MiniMap.',
839
+ },
840
+
841
+ 'flow/Controls': {
842
+ signature: '(props?: { position?: "top-left" | "top-right" | "bottom-left" | "bottom-right" }) => VNodeChild',
843
+ example: `<Flow instance={flow}>
844
+ <Controls position="bottom-left" />
845
+ </Flow>`,
846
+ notes: 'Zoom in / zoom out / fit-view button cluster. Renders absolutely inside the flow viewport at the configured corner (default `"bottom-right"`). Each button dispatches to the corresponding `FlowInstance` viewport method. See also: Flow, Background, MiniMap.',
847
+ },
848
+
849
+ 'flow/MiniMap': {
850
+ signature: '(props?: { nodeColor?: (node: FlowNode) => string; maskColor?: string }) => VNodeChild',
851
+ example: `<Flow instance={flow}>
852
+ <MiniMap nodeColor={(node) => node.data.highlighted ? '#f59e0b' : '#6366f1'} />
853
+ </Flow>`,
854
+ notes: 'Overview minimap of the full graph. `nodeColor` is a per-node color function (default grey), `maskColor` fills the area outside the current viewport (default semi-transparent black). Clicks on the minimap recenter the main viewport. See also: Flow, Background, Controls.',
855
+ },
856
+
857
+ 'flow/Handle': {
858
+ signature: '(props: { type: "source" | "target"; position: Position; id?: string }) => VNodeChild',
859
+ example: `function CustomNode(props: NodeComponentProps<MyData>) {
860
+ return (
861
+ <div>
862
+ <Handle type="target" position={Position.Left} />
863
+ {() => props.data().label}
864
+ <Handle type="source" position={Position.Right} id="out-primary" />
865
+ <Handle type="source" position={Position.Bottom} id="out-fallback" />
866
+ </div>
867
+ )
786
868
  }
787
869
 
788
- <Flow instance={flow} nodeTypes={{ custom: CustomNode }}>
789
- <Background variant="dots" />
790
- <Controls />
791
- <MiniMap />
870
+ // Edge referencing a specific source handle by id
871
+ flow.addEdge({ source: '1', sourceHandle: 'out-primary', target: '2' })`,
872
+ notes: 'Connection handle on a custom node — exposes a connectable point that edges attach to. `type` picks direction (`"source"` emits edges, `"target"` receives), `position` is a `Position` enum (`Top` / `Right` / `Bottom` / `Left`). Provide a distinct `id` when a node has multiple source or target handles so edges can reference the specific one via `edge.sourceHandle` / `edge.targetHandle`. See also: Flow, Position.',
873
+ mistakes: `- Multiple \`source\` / \`target\` handles on one node without distinct \`id\` values — edges cannot disambiguate which handle they connect to
874
+ - Nesting a \`<Handle>\` inside a non-node component (a \`<Background>\` child, a \`<Panel>\`, etc.) — the connection machinery expects handles to live inside a node renderer`,
875
+ },
876
+
877
+ 'flow/Panel': {
878
+ signature: '(props: { position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; children: VNodeChild }) => VNodeChild',
879
+ example: `<Flow instance={flow}>
880
+ <Panel position="top-right">
881
+ <button onClick={() => flow.fitView()}>Fit</button>
882
+ <button onClick={() => flow.toJSON()}>Export</button>
883
+ </Panel>
792
884
  </Flow>`,
793
- notes:
794
- "Signal-native nodes/edges. Generic over node data shape: createFlow<TData> returns FlowInstance<TData> so node.data.kind narrows correctly. Defaults to Record<string, unknown> if no generic supplied. NodeComponentProps has THREE reactive accessors — data: () => TData, selected: () => boolean, dragging: () => boolean — read inside reactive scopes so the node patches in place when ANY underlying state changes. Each node mounts EXACTLY ONCE across the lifetime of the graph regardless of how many drags, selection clicks, or updateNode mutations happen. Internally <Flow> uses <For> keyed by node.id plus per-node accessors that read live state from instance.nodes() — so a 60fps drag in a 1000-node graph is O(1) instead of O(N) per frame. Auto-layout via elkjs (lazy-loaded, ~1.4MB chunk only on first .layout() call). Pan/zoom via pointer events + CSS transforms. No D3. JSX components are NOT generic at the call site (<Flow<MyData> /> is invalid JSX) — FlowProps.instance is typed as FlowInstance<any> so typed consumers can pass FlowInstance<MyData> without casting.",
795
- mistakes: `- Forgetting to declare @pyreon/runtime-dom in consumer app deps — flow's JSX emits _tpl() which needs runtime-dom imports
796
- - Reading props.data, props.selected, or props.dragging as plain values — they're ALL accessors, call them: props.data().kind, props.selected(), props.dragging()
797
- - Calling props.data() OUTSIDE a reactive scope — captures the value once at component setup, defeating reactivity. Read it inside JSX expression thunks, effect, or computed: {() => props.data().label}
798
- - Adding [key: string]: unknown index signature to your node data interface — no longer needed now that createFlow is generic. Just pass createFlow<MyData>(...)
799
- - Using direction: 'row' on flow's containing layout — Pyreon Element accepts 'inline'|'rows'|'reverseInline'|'reverseRows', not 'row'
800
- - Setting LayoutOptions.direction (or layerSpacing, or edgeRouting) on a force/stress/radial/box/rectpacking layout and expecting a directional result — these options are namespaced under ELK's layered/tree pipelines and silently ignored by the geometric algorithms. Switch the algorithm to 'layered' or 'tree' if you need a directional layout.
801
- - Missing the <Flow nodeTypes={{ key: Component }}> registration — node.type strings dispatch to that map`,
885
+ notes: 'Overlay panel positioned absolutely relative to the flow viewport. Use for toolbars, legend badges, or contextual action buttons. Pass any JSX as children — the panel is a plain positioned container, not a predefined chrome component. See also: Flow, Controls.',
802
886
  },
887
+ // <gen-docs:api-reference:end @pyreon/flow>
803
888
 
804
889
  // ═══════════════════════════════════════════════════════════════════════════
805
890
  // @pyreon/code
@@ -958,34 +1043,45 @@ Posts.useTable() // TanStack Table config`,
958
1043
 
959
1044
  const result = lint({ paths: ["src/"], preset: "recommended" })
960
1045
  console.log(result.totalErrors, result.totalWarnings)
1046
+ // Config-level diagnostics (malformed rule options, etc.)
1047
+ for (const d of result.configDiagnostics) console.log(d.ruleId, d.message)
961
1048
 
962
- // With config file auto-loading + rule overrides
963
- lint({ paths: ["."], ruleOverrides: { "pyreon/no-classname": "off" } })`,
1049
+ // Severity overrides + per-rule options overrides
1050
+ lint({
1051
+ paths: ["."],
1052
+ ruleOverrides: { "pyreon/no-classname": "off" },
1053
+ ruleOptionsOverrides: {
1054
+ "pyreon/no-window-in-ssr": { exemptPaths: ["src/foundation/"] },
1055
+ },
1056
+ })`,
964
1057
  notes:
965
- 'Programmatic API. 58 rules across 12 categories. Auto-loads .pyreonlintrc.json. Presets: recommended, strict, app, lib. Uses oxc-parser with AST caching.',
1058
+ 'Programmatic API. 59 rules across 12 categories. Auto-loads .pyreonlintrc.json. Presets: recommended, strict, app, lib. Per-rule options via tuple form in config (`["error", { exemptPaths: [...] }]`) or `ruleOptionsOverrides`. Wrong-typed options surface on `result.configDiagnostics`. Uses oxc-parser with AST caching.',
966
1059
  },
967
1060
 
968
1061
  'lint/lintFile': {
969
1062
  signature:
970
- 'lintFile(filePath: string, sourceText: string, rules: Rule[], config: LintConfig, cache?: AstCache): LintFileResult',
1063
+ 'lintFile(filePath: string, sourceText: string, rules: Rule[], config: LintConfig, cache?: AstCache, configDiagnosticsSink?: ConfigDiagnostic[]): LintFileResult',
971
1064
  example: `import { lintFile, allRules, getPreset, AstCache } from "@pyreon/lint"
972
1065
 
973
1066
  const cache = new AstCache()
974
1067
  const config = getPreset("recommended")
975
- const result = lintFile("app.tsx", source, allRules, config, cache)`,
976
- notes: 'Low-level single-file API. Optional AstCache for repeat runs (FNV-1a hash keyed).',
1068
+ const configSink: ConfigDiagnostic[] = []
1069
+ const result = lintFile("app.tsx", source, allRules, config, cache, configSink)`,
1070
+ notes:
1071
+ 'Low-level single-file API. Optional AstCache for repeat runs (FNV-1a hash keyed). Optional `configDiagnosticsSink` collects malformed-option diagnostics; without it they print to stderr.',
977
1072
  },
978
1073
 
979
1074
  'lint/cli': {
980
1075
  signature:
981
- 'pyreon-lint [--preset name] [--fix] [--format text|json|compact] [--quiet] [--watch] [--list] [--config path] [--ignore path] [--rule id=severity] [path...]',
1076
+ 'pyreon-lint [--preset name] [--fix] [--format text|json|compact] [--quiet] [--watch] [--list] [--config path] [--ignore path] [--rule id=severity] [--rule-options id=\'{json}\'] [path...]',
982
1077
  example: `pyreon-lint --preset strict --quiet # CI mode
983
1078
  pyreon-lint --fix # auto-fix
984
1079
  pyreon-lint --watch src/ # watch mode
985
- pyreon-lint --list # list all 58 rules
986
- pyreon-lint --format json # machine-readable`,
1080
+ pyreon-lint --list # list all 59 rules
1081
+ pyreon-lint --format json # machine-readable
1082
+ pyreon-lint --rule-options 'pyreon/no-window-in-ssr={"exemptPaths":["src/foundation/"]}' src/`,
987
1083
  notes:
988
- "CLI entry. Config: .pyreonlintrc.json, package.json 'pyreonlint' field. Ignore: .pyreonlintignore + .gitignore. Watch: fs.watch recursive with 100ms debounce.",
1084
+ "CLI entry. Config: .pyreonlintrc.json (reference schema/pyreonlintrc.schema.json for IDE autocomplete), package.json 'pyreonlint' field. Ignore: .pyreonlintignore + .gitignore. Watch: fs.watch recursive with 100ms debounce. `--rule-options id='{json}'` passes per-rule options on a single run.",
989
1085
  },
990
1086
 
991
1087
  'lint/no-process-dev-gate': {
@@ -1006,6 +1102,39 @@ if (__DEV__) console.warn('hello')`,
1006
1102
  - Using the rule for server-only packages — they're correctly exempt because Node always has \`process\``,
1007
1103
  },
1008
1104
 
1105
+ 'lint/require-browser-smoke-test': {
1106
+ signature: 'rule: pyreon/require-browser-smoke-test (architecture, error in recommended/strict/lib, off in app)',
1107
+ example: `// Per-package config (optional — defaults cover all known browser packages)
1108
+ {
1109
+ "rules": {
1110
+ "pyreon/require-browser-smoke-test": [
1111
+ "error",
1112
+ {
1113
+ "additionalPackages": ["@my-org/my-browser-pkg"],
1114
+ "exemptPaths": ["packages/experimental/"]
1115
+ }
1116
+ ]
1117
+ }
1118
+ }`,
1119
+ notes:
1120
+ "Locks in the durability of the T1.1 browser smoke harness (PRs #224, #227, #229, #231). Every browser-categorized package MUST ship at least one \`*.browser.test.{ts,tsx}\` file under \`src/\`. Without this rule, new browser packages can quietly ship without smoke coverage and we drift back to the world before T1.1 — happy-dom silently masks environment-divergence bugs (PR #197 mock-vnode metadata drop, PR #200 \`typeof process\` dead code, multi-word event delegation bug). Default browser-package list mirrors \`.claude/rules/test-environment-parity.md\`. The rule fires once per package on its \`src/index.ts\`, walks the package directory looking for \`*.browser.test.*\`, and reports if none are found. Off in \`app\` preset because apps don't ship as packages with smoke obligations.",
1121
+ mistakes: `- Adding a new browser-running package without a browser test — the rule will fail your PR
1122
+ - Hardcoding the browser-package list in the rule — the list lives in \`.claude/rules/browser-packages.json\` (single source of truth), not in the rule source
1123
+ - Disabling the rule globally — use \`exemptPaths\` to exempt specific packages still under construction
1124
+ - Shipping a \`sanity.browser.test.ts\` with \`expect(1).toBe(1)\` just to satisfy the rule — it passes but provides zero signal. The rule is a GATE, not a quality check; review actual contents on PR`,
1125
+ },
1126
+
1127
+ 'mcp/get_browser_smoke_status': {
1128
+ signature: 'tool: get_browser_smoke_status — no args',
1129
+ example: `// Ask the MCP server:
1130
+ // "which Pyreon packages are missing browser smoke coverage?"
1131
+ // Tool walks packages/, matches against .claude/rules/browser-packages.json,
1132
+ // returns a coverage report.`,
1133
+ notes:
1134
+ "Companion to the `pyreon/require-browser-smoke-test` lint rule. Reports which browser-categorized Pyreon packages have at least one `*.browser.test.{ts,tsx}` file under `src/`. Uses the same `.claude/rules/browser-packages.json` single source of truth as the rule + the CI script. Lets an AI agent check coverage before writing a new browser package (so it adds a smoke test in the same PR) instead of discovering the failure when CI runs. Falls back with a clear message if the JSON isn't present (e.g. consumer apps that don't ship the Pyreon monorepo layout).",
1135
+ mistakes: `- Using the tool's output as a substitute for running the CI script — this tool only checks file existence, not the self-expiring-exemption check that \`bun run lint:browser-smoke\` performs`,
1136
+ },
1137
+
1009
1138
  // ═══════════════════════════════════════════════════════════════════════════
1010
1139
  // @pyreon/ui-core
1011
1140
  // ═══════════════════════════════════════════════════════════════════════════
package/src/index.ts CHANGED
@@ -6,12 +6,13 @@
6
6
  * to generate, validate, and migrate Pyreon code.
7
7
  *
8
8
  * Tools:
9
- * get_api — Look up any Pyreon API: signature, usage, common mistakes
10
- * validate — Check a code snippet for Pyreon anti-patterns
11
- * migrate_react — Convert React code to idiomatic Pyreon
12
- * diagnose — Parse an error message into structured fix information
13
- * get_routes — List all routes in the current project
14
- * get_components — List all components with their props and signals
9
+ * get_api — Look up any Pyreon API: signature, usage, common mistakes
10
+ * validate — Check a code snippet for Pyreon anti-patterns
11
+ * migrate_react — Convert React code to idiomatic Pyreon
12
+ * diagnose — Parse an error message into structured fix information
13
+ * get_routes — List all routes in the current project
14
+ * get_components — List all components with their props and signals
15
+ * get_browser_smoke_status — Report which browser-categorized packages have smoke coverage
15
16
  *
16
17
  * Usage:
17
18
  * bunx @pyreon/mcp # stdio transport (for IDE integration)
@@ -230,6 +231,168 @@ server.tool('get_components', {}, async () => {
230
231
  return textResult(`**Components (${ctx.components.length}):**\n\n${compList}`)
231
232
  })
232
233
 
234
+ // ═══════════════════════════════════════════════════════════════════════════════
235
+ // Tool: get_browser_smoke_status
236
+ // ═══════════════════════════════════════════════════════════════════════════════
237
+
238
+ server.tool(
239
+ 'get_browser_smoke_status',
240
+ {},
241
+ async () => {
242
+ // Walks the current project, reports which browser-categorized
243
+ // packages have at least one `*.browser.test.{ts,tsx}` file.
244
+ // Mirrors `pyreon/require-browser-smoke-test` / the CI script so an
245
+ // AI agent can check coverage before editing without running lint.
246
+ const fs = await import('node:fs')
247
+ const path = await import('node:path')
248
+
249
+ const cwd = process.cwd()
250
+
251
+ // Discover the browser-packages list by walking up from cwd.
252
+ let browserPackages: string[] = []
253
+ {
254
+ let dir = cwd
255
+ for (let i = 0; i < 30; i++) {
256
+ const candidate = path.join(dir, '.claude', 'rules', 'browser-packages.json')
257
+ if (fs.existsSync(candidate)) {
258
+ try {
259
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8')) as {
260
+ packages?: unknown
261
+ }
262
+ if (Array.isArray(parsed.packages)) {
263
+ browserPackages = parsed.packages.filter((p): p is string => typeof p === 'string')
264
+ }
265
+ } catch {
266
+ // fall through to empty list
267
+ }
268
+ break
269
+ }
270
+ const parent = path.dirname(dir)
271
+ if (parent === dir) break
272
+ dir = parent
273
+ }
274
+ }
275
+
276
+ if (browserPackages.length === 0) {
277
+ return textResult(
278
+ 'No `.claude/rules/browser-packages.json` found in the current project. ' +
279
+ 'This tool reports browser-smoke coverage for Pyreon monorepos that ship ' +
280
+ 'the single-source-of-truth list. Consumer apps can still opt in via the ' +
281
+ "lint rule's `additionalPackages` option.",
282
+ )
283
+ }
284
+
285
+ function hasBrowserTest(dir: string): boolean {
286
+ let entries: string[]
287
+ try {
288
+ entries = fs.readdirSync(dir)
289
+ } catch {
290
+ return false
291
+ }
292
+ for (const name of entries) {
293
+ if (
294
+ name.startsWith('.') ||
295
+ name === 'node_modules' ||
296
+ name === 'lib' ||
297
+ name === 'dist'
298
+ ) {
299
+ continue
300
+ }
301
+ const full = path.join(dir, name)
302
+ let isDir = false
303
+ try {
304
+ isDir = fs.statSync(full).isDirectory()
305
+ } catch {
306
+ continue
307
+ }
308
+ if (isDir) {
309
+ if (hasBrowserTest(full)) return true
310
+ continue
311
+ }
312
+ if (/\.browser\.test\.(?:ts|tsx)$/.test(name)) return true
313
+ }
314
+ return false
315
+ }
316
+
317
+ // Find each browser-categorized package's directory by matching
318
+ // package.json `name` under packages/*.
319
+ const pkgDirs = new Map<string, string>() // name -> dir
320
+ function walkPkgs(dir: string, depth = 0): void {
321
+ if (depth > 4) return
322
+ let entries: string[]
323
+ try {
324
+ entries = fs.readdirSync(dir)
325
+ } catch {
326
+ return
327
+ }
328
+ for (const name of entries) {
329
+ if (name.startsWith('.') || name === 'node_modules') continue
330
+ const full = path.join(dir, name)
331
+ let isDir = false
332
+ try {
333
+ isDir = fs.statSync(full).isDirectory()
334
+ } catch {
335
+ continue
336
+ }
337
+ if (!isDir) continue
338
+ const pkgJsonPath = path.join(full, 'package.json')
339
+ if (fs.existsSync(pkgJsonPath)) {
340
+ try {
341
+ const parsed = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')) as {
342
+ name?: unknown
343
+ }
344
+ if (typeof parsed.name === 'string') {
345
+ pkgDirs.set(parsed.name, full)
346
+ }
347
+ } catch {
348
+ // ignore malformed package.json
349
+ }
350
+ } else {
351
+ walkPkgs(full, depth + 1)
352
+ }
353
+ }
354
+ }
355
+ // Project root = cwd or the ancestor with browser-packages.json.
356
+ walkPkgs(path.join(cwd, 'packages'))
357
+
358
+ const covered: string[] = []
359
+ const missing: string[] = []
360
+ const unknown: string[] = []
361
+ for (const name of browserPackages) {
362
+ const dir = pkgDirs.get(name)
363
+ if (!dir) {
364
+ unknown.push(name)
365
+ continue
366
+ }
367
+ if (hasBrowserTest(dir)) covered.push(name)
368
+ else missing.push(name)
369
+ }
370
+
371
+ const parts: string[] = []
372
+ parts.push(`**Browser smoke coverage** (${covered.length} / ${browserPackages.length}):`)
373
+ parts.push('')
374
+ if (covered.length > 0) {
375
+ parts.push(`✓ Covered (${covered.length}):`)
376
+ for (const n of covered) parts.push(` - ${n}`)
377
+ parts.push('')
378
+ }
379
+ if (missing.length > 0) {
380
+ parts.push(`✗ Missing \`*.browser.test.*\` (${missing.length}):`)
381
+ for (const n of missing) parts.push(` - ${n}`)
382
+ parts.push('')
383
+ parts.push(
384
+ 'Add a `*.browser.test.{ts,tsx}` file under `src/` in each missing package. ' +
385
+ 'See `.claude/rules/test-environment-parity.md` for the setup recipe.',
386
+ )
387
+ }
388
+ if (unknown.length > 0) {
389
+ parts.push(`? Listed in browser-packages.json but not found in this repo (${unknown.length}):`)
390
+ for (const n of unknown) parts.push(` - ${n}`)
391
+ }
392
+ return textResult(parts.join('\n'))
393
+ },
394
+ )
395
+
233
396
  // ═══════════════════════════════════════════════════════════════════════════════
234
397
  // Start server
235
398
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -1,4 +1,27 @@
1
- import { API_REFERENCE } from '../api-reference'
1
+ import type { McpApiReferenceEntry } from '@pyreon/manifest'
2
+ import { type ApiEntry, API_REFERENCE } from '../api-reference'
3
+
4
+ // Compile-time assertion that `McpApiReferenceEntry` (declared in
5
+ // `@pyreon/manifest`) stays structurally identical to the local
6
+ // `ApiEntry` that `API_REFERENCE` is typed against. A drift in
7
+ // either direction — e.g. MCP adds `deprecated?: string`, or the
8
+ // manifest renderer starts emitting a new field — fails typecheck
9
+ // here BEFORE the generated `api-reference.ts` is produced.
10
+ //
11
+ // Why symmetric assertions: a one-sided `extends` only catches drift
12
+ // in one direction. `Equal<A, B>` is precise — fails if either side
13
+ // adds a field the other lacks.
14
+ type Equal<X, Y> =
15
+ (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
16
+ type Assert<T extends true> = T
17
+ // If the types drift (MCP or manifest adds / removes / renames a
18
+ // field), `Equal<...>` resolves to `false`, `Assert<false>` fails
19
+ // the constraint `T extends true`, and `tsc --noEmit` errors on
20
+ // this line. The `void` usage keeps the alias load-bearing at
21
+ // runtime so tree-shaking / unused-symbol lints don't strip it.
22
+ type _McpShapeInSync = Assert<Equal<McpApiReferenceEntry, ApiEntry>>
23
+ const _assertion: _McpShapeInSync = true
24
+ void _assertion
2
25
 
3
26
  describe('api-reference', () => {
4
27
  it('has entries', () => {
@@ -11,4 +34,71 @@ describe('api-reference', () => {
11
34
  expect(entry.example, `${key} missing example`).toBeTruthy()
12
35
  }
13
36
  })
37
+
38
+ // Coverage for the T2.5.1 flip: @pyreon/flow's region now
39
+ // regenerates from its manifest. These tests guard the observable
40
+ // surface that MCP consumers see via the `get_api` tool — if a
41
+ // future manifest refactor drops a key or accidentally renames
42
+ // one, the failure surfaces HERE in addition to the
43
+ // gen-docs --check drift check.
44
+ describe('@pyreon/flow — manifest-driven region', () => {
45
+ const EXPECTED_FLOW_KEYS = [
46
+ 'flow/createFlow',
47
+ 'flow/useFlow',
48
+ 'flow/Flow',
49
+ 'flow/Background',
50
+ 'flow/Controls',
51
+ 'flow/MiniMap',
52
+ 'flow/Handle',
53
+ 'flow/Panel',
54
+ ]
55
+
56
+ it.each(EXPECTED_FLOW_KEYS)('exposes %s with the full MCP shape', (key) => {
57
+ const entry = API_REFERENCE[key]
58
+ expect(entry, `${key} missing from API_REFERENCE`).toBeDefined()
59
+ // Signature + example are required by the manifest renderer
60
+ // (guaranteed non-empty by the manifest type). Notes come
61
+ // from `summary` (always present in the manifest); mistakes
62
+ // are optional per-entry.
63
+ expect(entry!.signature).toBeTruthy()
64
+ expect(entry!.example).toBeTruthy()
65
+ expect(entry!.notes).toBeTruthy()
66
+ })
67
+
68
+ it('createFlow carries the enriched foot-gun catalog including the `direction: row` mistake', () => {
69
+ // Regression guard for a hand-written mistake the first pass
70
+ // of T2.5.1 accidentally dropped. Ensures future manifest
71
+ // edits don't silently lose it again.
72
+ const createFlow = API_REFERENCE['flow/createFlow']
73
+ expect(createFlow?.mistakes).toContain("Using `direction: 'row'`")
74
+ })
75
+
76
+ it('createFlow notes carry the load-bearing architectural claims', () => {
77
+ // Spot-checks that MCP consumers see the signal-native /
78
+ // elkjs / no-D3 framing — the stuff that makes flow's API
79
+ // understandable without opening source. A regression here
80
+ // means the manifest summary lost density.
81
+ const createFlow = API_REFERENCE['flow/createFlow']
82
+ expect(createFlow?.notes).toContain('elkjs')
83
+ expect(createFlow?.notes).toContain('no D3')
84
+ expect(createFlow?.notes).toContain('signal-native')
85
+ })
86
+
87
+ it('Flow (container component) documents the mount-once invariant', () => {
88
+ // Flow is new in the T2.5.1 flip — previously the hand-
89
+ // written surface only covered `createFlow` + `useFlow`.
90
+ // Assert the component + its load-bearing contract are both
91
+ // reachable via MCP.
92
+ const flow = API_REFERENCE['flow/Flow']
93
+ expect(flow?.notes).toContain('mounts EXACTLY ONCE')
94
+ })
95
+
96
+ it('Handle (connection component) documents the distinct-id rule for multiple handles', () => {
97
+ // Another T2.5.1-new entry. The multiple-handle id contract
98
+ // is subtle; surface it for MCP consumers.
99
+ const handle = API_REFERENCE['flow/Handle']
100
+ expect(handle?.notes).toContain('multiple source or target handles')
101
+ expect(handle?.mistakes).toContain('distinct `id`')
102
+ })
103
+ })
14
104
  })