@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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +258 -35
- package/lib/index.js.map +1 -1
- package/package.json +5 -2
- package/src/api-reference.ts +158 -29
- package/src/index.ts +169 -6
- package/src/tests/api-reference.test.ts +91 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/mcp",
|
|
3
|
-
"version": "0.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.
|
|
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
|
}
|
package/src/api-reference.ts
CHANGED
|
@@ -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 })
|
|
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
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
//
|
|
963
|
-
lint({
|
|
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.
|
|
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
|
|
976
|
-
|
|
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
|
|
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
|
|
10
|
-
* validate
|
|
11
|
-
* migrate_react
|
|
12
|
-
* diagnose
|
|
13
|
-
* get_routes
|
|
14
|
-
* get_components
|
|
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 {
|
|
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
|
})
|