fux-engine 0.1.2__tar.gz → 0.2.0__tar.gz

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 (141) hide show
  1. {fux_engine-0.1.2/fux_engine.egg-info → fux_engine-0.2.0}/PKG-INFO +1 -1
  2. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/__init__.py +1 -1
  3. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/cli.py +19 -0
  4. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/cligraph.py +29 -0
  5. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/cliquery.py +15 -3
  6. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/community.py +14 -8
  7. fux_engine-0.2.0/fux/components.py +98 -0
  8. fux_engine-0.2.0/fux/feedback.py +65 -0
  9. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/graph.py +24 -0
  10. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/graphquery.py +17 -4
  11. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/hybrid.py +5 -1
  12. fux_engine-0.2.0/fux/impact.py +100 -0
  13. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/mcpserver.py +26 -2
  14. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/report.py +11 -0
  15. fux_engine-0.2.0/fux/uispec.py +64 -0
  16. {fux_engine-0.1.2 → fux_engine-0.2.0/fux_engine.egg-info}/PKG-INFO +1 -1
  17. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux_engine.egg-info/SOURCES.txt +9 -0
  18. fux_engine-0.2.0/tests/test_components.py +57 -0
  19. fux_engine-0.2.0/tests/test_edge_confidence.py +79 -0
  20. fux_engine-0.2.0/tests/test_feedback.py +25 -0
  21. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_hybrid.py +14 -0
  22. fux_engine-0.2.0/tests/test_impact.py +52 -0
  23. fux_engine-0.2.0/tests/test_uispec.py +51 -0
  24. {fux_engine-0.1.2 → fux_engine-0.2.0}/LICENSE +0 -0
  25. {fux_engine-0.1.2 → fux_engine-0.2.0}/README.md +0 -0
  26. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/__main__.py +0 -0
  27. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/assets/fux-icon.svg +0 -0
  28. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/assets/fux-lockup.svg +0 -0
  29. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/assets/fux-mark.svg +0 -0
  30. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/assets/graph_boot.js +0 -0
  31. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/assets/graph_template.html +0 -0
  32. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/astextract.py +0 -0
  33. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/bench.py +0 -0
  34. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/build.py +0 -0
  35. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/capture.py +0 -0
  36. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/check.py +0 -0
  37. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/clicmds.py +0 -0
  38. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/cliutil.py +0 -0
  39. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/config.py +0 -0
  40. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/context.py +0 -0
  41. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/costledger.py +0 -0
  42. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/coverage.py +0 -0
  43. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/copilot/prompts/fux-plan.prompt.md +0 -0
  44. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/copilot/prompts/fux.prompt.md +0 -0
  45. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/global/README.md +0 -0
  46. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/global/rules/async-everywhere.md +0 -0
  47. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/global/rules/doc-per-code-change.md +0 -0
  48. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/global/rules/files-max-100-lines.md +0 -0
  49. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/global/rules/no-secrets-in-vcs.md +0 -0
  50. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/hooks/_common.sh +0 -0
  51. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/hooks/post_tool_use.sh +0 -0
  52. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/hooks/session_start.sh +0 -0
  53. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/hooks/stop.sh +0 -0
  54. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/hooks/user_prompt_submit.sh +0 -0
  55. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/packs/indian-markets-tax/pack.toml +0 -0
  56. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/packs/indian-markets-tax/rules/capital-gains-equity.md +0 -0
  57. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/packs/indian-markets-tax/rules/market-hours-nse.md +0 -0
  58. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/schema.json +0 -0
  59. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/adr/SKILL.md +0 -0
  60. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/distill/SKILL.md +0 -0
  61. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/fetch-rules/SKILL.md +0 -0
  62. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/fux/SKILL.md +0 -0
  63. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/plan/SKILL.md +0 -0
  64. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/savings/SKILL.md +0 -0
  65. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/trace/SKILL.md +0 -0
  66. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/drift.py +0 -0
  67. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/embed.py +0 -0
  68. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/explain.py +0 -0
  69. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/fetchrules.py +0 -0
  70. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/findings.py +0 -0
  71. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/fix.py +0 -0
  72. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/fmwrite.py +0 -0
  73. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/frontmatter.py +0 -0
  74. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/gate.py +0 -0
  75. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/gitutil.py +0 -0
  76. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/globs.py +0 -0
  77. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/governance.py +0 -0
  78. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/graphhtml.py +0 -0
  79. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/hookio.py +0 -0
  80. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/hooks.py +0 -0
  81. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/importer.py +0 -0
  82. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/index.py +0 -0
  83. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/initcmd.py +0 -0
  84. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/lint.py +0 -0
  85. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/loader.py +0 -0
  86. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/mine.py +0 -0
  87. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/model.py +0 -0
  88. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/narrative.py +0 -0
  89. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/pack.py +0 -0
  90. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/parity.py +0 -0
  91. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/paths.py +0 -0
  92. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/recall.py +0 -0
  93. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/savings.py +0 -0
  94. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/scaffold.py +0 -0
  95. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/scalars.py +0 -0
  96. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/schema.py +0 -0
  97. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/seal.py +0 -0
  98. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/serve.py +0 -0
  99. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/settings.py +0 -0
  100. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/stats.py +0 -0
  101. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/templates/formula.md +0 -0
  102. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/templates/spec.md +0 -0
  103. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/touch.py +0 -0
  104. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/tour.py +0 -0
  105. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/usage.py +0 -0
  106. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/verify.py +0 -0
  107. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/vexamples.py +0 -0
  108. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux_engine.egg-info/dependency_links.txt +0 -0
  109. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux_engine.egg-info/entry_points.txt +0 -0
  110. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux_engine.egg-info/requires.txt +0 -0
  111. {fux_engine-0.1.2 → fux_engine-0.2.0}/fux_engine.egg-info/top_level.txt +0 -0
  112. {fux_engine-0.1.2 → fux_engine-0.2.0}/pyproject.toml +0 -0
  113. {fux_engine-0.1.2 → fux_engine-0.2.0}/setup.cfg +0 -0
  114. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_ast_backend.py +0 -0
  115. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_astextract.py +0 -0
  116. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_bm25f_expand.py +0 -0
  117. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_capture_governance.py +0 -0
  118. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_centrality.py +0 -0
  119. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_check_fix.py +0 -0
  120. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_costledger.py +0 -0
  121. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_crossfile_calls.py +0 -0
  122. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_embed_rerank.py +0 -0
  123. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_examples.py +0 -0
  124. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_fetch_rules.py +0 -0
  125. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_frontmatter.py +0 -0
  126. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_fuzz_mine.py +0 -0
  127. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_globs.py +0 -0
  128. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_graphhtml.py +0 -0
  129. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_lint_stats_gate.py +0 -0
  130. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_mcp.py +0 -0
  131. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_mcp_extra.py +0 -0
  132. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_pack.py +0 -0
  133. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_parity_import.py +0 -0
  134. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_recall_build_verify.py +0 -0
  135. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_recall_eval.py +0 -0
  136. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_resolution.py +0 -0
  137. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_savings.py +0 -0
  138. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_schema_scaffold_init.py +0 -0
  139. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_seal.py +0 -0
  140. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_serve_sanitize.py +0 -0
  141. {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_verify_hardening.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fux-engine
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Fux — a portable agent-aware knowledge engine: rules, memory, narrative, and graph in one frontmatter substrate.
5
5
  Author-email: arpit arya <arpitarya.me@gmail.com>
6
6
  License: MIT
@@ -3,4 +3,4 @@
3
3
  One frontmatter substrate → derived index, graph, and memory views.
4
4
  $0 deterministic maintenance; no mandatory LLM calls. See docs/fux-plan.md.
5
5
  """
6
- __version__ = "0.1.2"
6
+ __version__ = "0.2.0"
@@ -120,6 +120,25 @@ def build_parser() -> argparse.ArgumentParser:
120
120
  ex.add_argument("term")
121
121
  ex.set_defaults(fn=cligraph.cmd_explain)
122
122
 
123
+ imp2 = sub.add_parser("impact", help="downstream blast radius of changing a file ($0)")
124
+ imp2.add_argument("file")
125
+ imp2.set_defaults(fn=cligraph.cmd_impact)
126
+
127
+ cmp = sub.add_parser("components", help="design-system registry + data-binding catalog ($0)")
128
+ cmp.add_argument("--kind", choices=["all", "components", "hooks", "dtos"], default="all")
129
+ cmp.add_argument("--scope", help="restrict to files under this path prefix")
130
+ cmp.add_argument("--json", action="store_true", help="machine-readable output (for Orff)")
131
+ cmp.set_defaults(fn=cligraph.cmd_components)
132
+
133
+ vs = sub.add_parser("validate-spec", help="validate a generated UISpec against the registry ($0)")
134
+ vs.add_argument("file")
135
+ vs.add_argument("--json", action="store_true", help="emit {ok, errors} as JSON")
136
+ vs.set_defaults(fn=cligraph.cmd_validate_spec)
137
+
138
+ fb = sub.add_parser("feedback", help="record/summarise on-the-fly generation outcomes ($0)")
139
+ fb.add_argument("--record", metavar="FILE", help="append one outcome from JSON ('-' = stdin)")
140
+ fb.set_defaults(fn=cliquery.cmd_feedback)
141
+
123
142
  sub.add_parser("report", help="write GRAPH_REPORT.md (god nodes + communities)").set_defaults(fn=cligraph.cmd_report)
124
143
 
125
144
  sub.add_parser("setup", help="copy bundled assets (schema, hooks, skills) to ~/.claude/fux/").set_defaults(fn=clicmds.cmd_setup)
@@ -113,6 +113,35 @@ def cmd_explain(args) -> int:
113
113
  return 0
114
114
 
115
115
 
116
+ def cmd_impact(args) -> int:
117
+ from fux import impact
118
+ print(impact.render(impact.run(root(), args.file)), end="")
119
+ return 0
120
+
121
+
122
+ def cmd_components(args) -> int:
123
+ from fux import components
124
+ reg = components.registry(root(), scope=getattr(args, "scope", None))
125
+ if getattr(args, "json", False):
126
+ print(components.render_json(reg), end="")
127
+ else:
128
+ print(components.render(reg, kind=getattr(args, "kind", "all")), end="")
129
+ return 0
130
+
131
+
132
+ def cmd_validate_spec(args) -> int:
133
+ import json
134
+ from pathlib import Path
135
+
136
+ from fux import uispec
137
+ ok, errs = uispec.run(root(), Path(args.file))
138
+ if getattr(args, "json", False):
139
+ print(json.dumps({"ok": ok, "errors": errs}))
140
+ else:
141
+ print(uispec.render(ok, errs), end="")
142
+ return 0 if ok else 2
143
+
144
+
116
145
  def cmd_report(_args) -> int:
117
146
  from fux import paths
118
147
  here = root()
@@ -2,12 +2,24 @@
2
2
  from __future__ import annotations
3
3
  import sys
4
4
 
5
- from fux import (capture, config, costledger, coverage, explain, fetchrules,
6
- lint, loader, mine, parity, paths, recall, savings, scaffold,
7
- seal, stats, tour, verify)
5
+ from fux import (capture, config, costledger, coverage, explain, feedback,
6
+ fetchrules, lint, loader, mine, parity, paths, recall, savings,
7
+ scaffold, seal, stats, tour, verify)
8
8
  from fux.cliutil import root
9
9
 
10
10
 
11
+ def cmd_feedback(args) -> int:
12
+ import json
13
+ here = root()
14
+ if getattr(args, "record", None):
15
+ raw = sys.stdin.read() if args.record == "-" else open(args.record).read()
16
+ feedback.record(here, json.loads(raw))
17
+ print("fux feedback: recorded")
18
+ return 0
19
+ print(feedback.render(feedback.load(here)))
20
+ return 0
21
+
22
+
11
23
  def cmd_recall(args) -> int:
12
24
  hybrid = True if getattr(args, "hybrid", False) else None
13
25
  expand = True if getattr(args, "expand", False) else None
@@ -2,20 +2,24 @@
2
2
 
3
3
  Replaces graphify's clustering. Synchronous label propagation with sorted,
4
4
  tie-broken updates so the result is reproducible across runs (no randomness).
5
+ Votes are **edge-weighted**: a low-confidence `references` edge (weight 0.25) pulls
6
+ a node into a community far less than a precise `calls`/`contains` edge, so the
7
+ loose whole-file xref can't over-fragment or mis-merge clusters by raw count.
5
8
  """
6
9
  from __future__ import annotations
7
10
 
8
- from collections import Counter
11
+ _TIE = 1e-9
9
12
 
10
13
 
11
- def _adjacency(nodes: list[dict], edges: list[dict]) -> dict[str, set[str]]:
14
+ def _adjacency(nodes: list[dict], edges: list[dict]) -> dict[str, dict[str, float]]:
12
15
  ids = {n["id"] for n in nodes}
13
- adj: dict[str, set[str]] = {n["id"]: set() for n in nodes}
16
+ adj: dict[str, dict[str, float]] = {n["id"]: {} for n in nodes}
14
17
  for e in edges:
15
18
  s, t = e.get("source"), e.get("target")
16
19
  if s in ids and t in ids and s != t:
17
- adj[s].add(t)
18
- adj[t].add(s)
20
+ w = float(e.get("weight", 1.0))
21
+ adj[s][t] = adj[s].get(t, 0.0) + w
22
+ adj[t][s] = adj[t].get(s, 0.0) + w
19
23
  return adj
20
24
 
21
25
 
@@ -29,10 +33,12 @@ def detect(nodes: list[dict], edges: list[dict], max_iter: int = 30) -> dict[str
29
33
  for nid in order:
30
34
  if not adj[nid]:
31
35
  continue
32
- votes = Counter(label[n] for n in adj[nid])
36
+ votes: dict[str, float] = {}
37
+ for nbr, w in adj[nid].items():
38
+ votes[label[nbr]] = votes.get(label[nbr], 0.0) + w
33
39
  top = max(votes.values())
34
- # Deterministic tie-break: smallest label among the winners.
35
- best = min(lab for lab, c in votes.items() if c == top)
40
+ # Deterministic tie-break: smallest label among the (near-)winners.
41
+ best = min(lab for lab, c in votes.items() if top - c < _TIE)
36
42
  if label[nid] != best:
37
43
  label[nid] = best
38
44
  changed = True
@@ -0,0 +1,98 @@
1
+ """`fux components` — the design-system registry + data-binding catalog ($0, §18.3).
2
+
3
+ The runtime-generation prerequisite: so Orff composes UI from existing primitives
4
+ and binds to real data instead of inventing either. Pure stdlib analysis over the
5
+ built graph + source files — component names with their prop fields, plus the
6
+ hooks and DTOs a generated component must wire to. Never calls an LLM.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from fux.astextract import sanitize_lines
15
+ from fux.graphquery import load
16
+
17
+ _DECL = re.compile(r"\b(?:interface|type)\s+(\w+)\b")
18
+ _MEMBER = re.compile(r"^\s*(?:readonly\s+)?([A-Za-z_]\w*)\s*(\?)?\s*:\s*(.+?);?\s*$")
19
+ # Source-scan patterns — backend-independent (work with or without tree-sitter).
20
+ _PROPS = re.compile(r"(?:export\s+)?(?:interface|type)\s+(\w+)Props\b")
21
+ _DTO = re.compile(r"(?:export\s+)?(?:interface|type)\s+(\w+DTO)\b")
22
+ _HOOK = re.compile(r"(?:export\s+)?(?:const|(?:async\s+)?function)\s+(use[A-Z]\w*)")
23
+ _TS = (".ts", ".tsx")
24
+
25
+
26
+ def _props_of(text: str, name: str) -> list[dict]:
27
+ """Extract the field list of an `interface/type <name>` block, depth-1 only."""
28
+ san, lines = sanitize_lines(text), text.split("\n")
29
+ start = next((i for i, s in enumerate(san)
30
+ if (m := _DECL.search(s)) and m.group(1) == name), None)
31
+ if start is None:
32
+ return []
33
+ depth, opened, out = 0, False, []
34
+ for k in range(start, len(san)):
35
+ at_start = depth
36
+ if opened and at_start == 1:
37
+ mm = _MEMBER.match(lines[k])
38
+ if mm and not lines[k].lstrip().startswith(("//", "*", "/*")):
39
+ out.append({"name": mm.group(1), "optional": bool(mm.group(2)),
40
+ "type": mm.group(3).strip().rstrip(";")})
41
+ for ch in san[k]:
42
+ if ch == "{":
43
+ depth += 1; opened = True
44
+ elif ch == "}":
45
+ depth -= 1
46
+ if opened and depth <= 0:
47
+ break
48
+ return out
49
+
50
+
51
+ def registry(root: Path, scope: str | None = None) -> dict:
52
+ """Components (name + props), data hooks (use*), and DTOs, by scanning the
53
+ TS/TSX sources that the graph covers. Backend-independent — relies on naming
54
+ conventions in the text, not on tree-sitter symbol nodes."""
55
+ graph = load(root) # raises SystemExit if no graph yet
56
+ files = sorted({n["file"] for n in graph["nodes"]
57
+ if n.get("type") == "code-file" and (n.get("file") or "").endswith(_TS)
58
+ and (not scope or n["file"].startswith(scope))})
59
+ comps, hooks, dtos, seen = [], [], [], set()
60
+ for rel in files:
61
+ try:
62
+ text = (root / rel).read_text(encoding="utf-8")
63
+ except OSError:
64
+ continue
65
+ for i, line in enumerate(sanitize_lines(text), 1):
66
+ if (m := _PROPS.search(line)):
67
+ comps.append({"name": m.group(1), "file": rel, "line": i,
68
+ "props": _props_of(text, m.group(1) + "Props")})
69
+ if (d := _DTO.search(line)):
70
+ dtos.append({"name": d.group(1), "file": rel})
71
+ if (h := _HOOK.search(line)) and (h.group(1), rel) not in seen:
72
+ seen.add((h.group(1), rel))
73
+ hooks.append({"name": h.group(1), "file": rel})
74
+ key = lambda x: x["name"]
75
+ return {"components": sorted(comps, key=key),
76
+ "hooks": sorted(hooks, key=key), "dtos": sorted(dtos, key=key)}
77
+
78
+
79
+ def render(reg: dict, kind: str = "all") -> str:
80
+ out: list[str] = []
81
+ if kind in ("all", "components"):
82
+ out.append(f"## Components ({len(reg['components'])}) — compose from these")
83
+ for c in reg["components"]:
84
+ ps = ", ".join(p["name"] + ("?" if p["optional"] else "") for p in c["props"])
85
+ out.append(f"- **{c['name']}** ({c['file']}) — props: {ps or '—'}")
86
+ out.append("")
87
+ if kind in ("all", "hooks"):
88
+ out.append(f"## Data hooks ({len(reg['hooks'])}) — bind to these, don't refetch")
89
+ out += [f"- {h['name']} ({h['file']})" for h in reg["hooks"]]
90
+ out.append("")
91
+ if kind in ("all", "dtos"):
92
+ out.append(f"## DTOs ({len(reg['dtos'])}) — the data shapes")
93
+ out += [f"- {d['name']} ({d['file']})" for d in reg["dtos"]]
94
+ return "\n".join(out).rstrip() + "\n"
95
+
96
+
97
+ def render_json(reg: dict) -> str:
98
+ return json.dumps(reg, indent=2, ensure_ascii=False) + "\n"
@@ -0,0 +1,65 @@
1
+ """`fux feedback` — record + summarise on-the-fly generation outcomes (§18.4, $0).
2
+
3
+ The brain's learning loop: every Orff compose attempt (valid / rejected / repaired)
4
+ is appended as a JSON line, and `fux feedback` reports the acceptance rate and the
5
+ most common rejection reasons — so a recurring validator failure becomes a signal to
6
+ add a component, a prop, or a contract rule. Deterministic; no LLM, no memory writes.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import datetime as _dt
11
+ import json
12
+ from collections import Counter
13
+ from pathlib import Path
14
+
15
+ from fux import paths
16
+
17
+ _FIELDS = ("prompt", "valid", "errors", "attempts", "provider", "model")
18
+
19
+
20
+ def _file(root: Path) -> Path:
21
+ d = paths.Footprint(root).base / "capture"
22
+ d.mkdir(parents=True, exist_ok=True)
23
+ return d / "feedback.jsonl"
24
+
25
+
26
+ def record(root: Path, data: dict) -> dict:
27
+ rec = {
28
+ "ts": _dt.datetime.now().isoformat(timespec="seconds"),
29
+ "prompt": str(data.get("prompt", ""))[:200],
30
+ "valid": bool(data.get("valid")),
31
+ "errors": list(data.get("errors") or []),
32
+ "attempts": int(data.get("attempts", 1)),
33
+ "provider": data.get("provider"),
34
+ "model": data.get("model"),
35
+ }
36
+ with _file(root).open("a", encoding="utf-8") as fh:
37
+ fh.write(json.dumps(rec) + "\n")
38
+ return rec
39
+
40
+
41
+ def load(root: Path) -> list[dict]:
42
+ f = _file(root)
43
+ return [json.loads(x) for x in f.read_text().splitlines() if x.strip()] if f.exists() else []
44
+
45
+
46
+ def _reason(err: str) -> str:
47
+ for key in ("unknown component", "not on", "missing required prop",
48
+ "data hook", "takes no children", "did not return"):
49
+ if key in err:
50
+ return key
51
+ return err[:40]
52
+
53
+
54
+ def render(rows: list[dict]) -> str:
55
+ if not rows:
56
+ return "fux feedback: no generation outcomes recorded yet."
57
+ ok = sum(1 for r in rows if r["valid"])
58
+ first_try = sum(1 for r in rows if r["valid"] and r["attempts"] == 1)
59
+ reasons = Counter(_reason(e) for r in rows if not r["valid"] for e in r["errors"])
60
+ out = [f"fux feedback — {len(rows)} compose(s): {ok} valid "
61
+ f"({100 * ok // len(rows)}%), {first_try} on first try."]
62
+ if reasons:
63
+ out.append("Top rejection reasons (candidate registry/rule gaps):")
64
+ out += [f" {n}× {r}" for r, n in reasons.most_common(5)]
65
+ return "\n".join(out)
@@ -10,6 +10,29 @@ from fux.model import RuleSet
10
10
 
11
11
  REF_RE = re.compile(r"^([^#]+)(?:#L(\d+)(?:-L?(\d+))?)?$")
12
12
 
13
+ # Per-edge-type confidence + clustering/centrality weight. Structural (`contains`,
14
+ # `calls`) and authored (`governs`, `related`, typed rule↔rule) edges are precise —
15
+ # EXTRACTED, full weight. The looser whole-file `references` xref ([_xref]) matches
16
+ # any identifier against any symbol label, so it is INFERRED and down-weighted: it
17
+ # is the dominant edge by raw count and would otherwise drown the precise signal in
18
+ # community detection + PageRank. graphify carried this confidence label; Fux now does too.
19
+ _EDGE_CONF: dict[str, tuple[str, float]] = {
20
+ "contains": ("EXTRACTED", 1.0),
21
+ "calls": ("EXTRACTED", 1.0),
22
+ "governs": ("EXTRACTED", 1.0),
23
+ "related": ("EXTRACTED", 1.0),
24
+ "references": ("INFERRED", 0.25),
25
+ }
26
+ _DEFAULT_CONF = ("EXTRACTED", 1.0) # authored typed rule↔rule edges
27
+
28
+
29
+ def _stamp_confidence(edges: list[dict]) -> None:
30
+ """Annotate each edge with ``confidence`` (EXTRACTED|INFERRED) + clustering ``weight``."""
31
+ for e in edges:
32
+ conf, weight = _EDGE_CONF.get(e["type"], _DEFAULT_CONF)
33
+ e.setdefault("confidence", conf)
34
+ e.setdefault("weight", weight)
35
+
13
36
 
14
37
  def _iter_sources(root: Path, include: list[str], ignore: list[str]):
15
38
  for path in sorted(root.rglob("*")):
@@ -51,6 +74,7 @@ def build(root: Path, rs: RuleSet, cfg: dict, full: bool = False) -> dict:
51
74
  edges += xcalls
52
75
  edges += _xref(nodes, texts, covered)
53
76
  _add_knowledge(nodes, edges, rs)
77
+ _stamp_confidence(edges) # weight-aware before clustering/centrality
54
78
  comm = community.detect(list(nodes.values()), edges)
55
79
  for nid, c in comm.items():
56
80
  nodes[nid]["community"] = c
@@ -28,6 +28,19 @@ def _adj(graph: dict) -> dict[str, set[str]]:
28
28
  return adj
29
29
 
30
30
 
31
+ def _wadj(graph: dict) -> dict[str, dict[str, float]]:
32
+ """Weighted undirected adjacency for centrality — low-confidence `references`
33
+ edges (weight 0.25) carry proportionally less rank than precise `calls`."""
34
+ adj: dict[str, dict[str, float]] = {n["id"]: {} for n in graph["nodes"]}
35
+ for e in graph["edges"]:
36
+ s, t = e["source"], e["target"]
37
+ if s in adj and t in adj:
38
+ w = float(e.get("weight", 1.0))
39
+ adj[s][t] = adj[s].get(t, 0.0) + w
40
+ adj[t][s] = adj[t].get(s, 0.0) + w
41
+ return adj
42
+
43
+
31
44
  def find(graph: dict, term: str) -> dict | None:
32
45
  """Resolve a free-text term to the best-matching node."""
33
46
  t = term.lower().strip()
@@ -109,8 +122,8 @@ def pagerank(graph: dict, damping: float = 0.85, iterations: int = 100,
109
122
  """
110
123
  ids = sorted(n["id"] for n in graph["nodes"])
111
124
  n = len(ids) or 1
112
- adj = _adj(graph)
113
- deg = {nid: len(adj.get(nid, ())) for nid in ids}
125
+ adj = _wadj(graph)
126
+ deg = {nid: sum(adj.get(nid, {}).values()) for nid in ids} # weighted degree
114
127
  rank = {nid: 1.0 / n for nid in ids}
115
128
  base = (1.0 - damping) / n
116
129
  for _ in range(iterations):
@@ -119,8 +132,8 @@ def pagerank(graph: dict, damping: float = 0.85, iterations: int = 100,
119
132
  for nid in ids:
120
133
  if deg[nid]:
121
134
  share = damping * rank[nid] / deg[nid]
122
- for m in sorted(adj[nid]):
123
- nxt[m] += share
135
+ for m, w in sorted(adj[nid].items()):
136
+ nxt[m] += share * w
124
137
  if sum(abs(nxt[nid] - rank[nid]) for nid in ids) < tol:
125
138
  rank = nxt
126
139
  break
@@ -29,7 +29,11 @@ def fuse(root: Path, query: str, rules: list[Rule], top: int = 6,
29
29
  if semantic:
30
30
  rankings.append(semantic)
31
31
 
32
- graphical = _graph_ranking(root, lexical[:3])
32
+ # Seed graph proximity from the lexical anchors — but fall back to the semantic
33
+ # top when lexical is empty, which is exactly the paraphrase case hybrid exists
34
+ # to rescue (no shared keyword → no lexical hit → the graph leg would otherwise
35
+ # go dark). plan §17.1 / recall-engine.compare.md phase-2 trigger.
36
+ graphical = _graph_ranking(root, (lexical or semantic)[:3])
33
37
  if graphical:
34
38
  rankings.append([rid for rid in graphical if rid in by_id])
35
39
 
@@ -0,0 +1,100 @@
1
+ """`fux impact <file>` — downstream blast radius of changing a file ($0, §18.1).
2
+
3
+ The "maintain code" brain capability: before you touch a file, see what it can
4
+ break — invariants to re-verify, governing rules whose *why* may go stale, and
5
+ the callers that depend on its symbols. Stdlib graph traversal; no LLM.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ from fux import explain, graphquery
13
+ from fux.model import Rule
14
+
15
+
16
+ @dataclass
17
+ class Impact:
18
+ target: str
19
+ symbols: list[str] = field(default_factory=list) # symbols defined here
20
+ governing: list[Rule] = field(default_factory=list) # rules that govern it
21
+ invariants: list[Rule] = field(default_factory=list) # governing ⊃ machine-checkable
22
+ callers: list[str] = field(default_factory=list) # precise `calls` dependents
23
+ referenced_by: list[str] = field(default_factory=list) # loose `references` (INFERRED)
24
+ related: list[str] = field(default_factory=list) # one-hop related rule ids
25
+ in_graph: bool = True
26
+
27
+ @property
28
+ def empty(self) -> bool:
29
+ return not (self.governing or self.callers or self.referenced_by)
30
+
31
+
32
+ def _norm(file_rel: str) -> str:
33
+ return file_rel.replace("\\", "/").lstrip("./").rstrip("/")
34
+
35
+
36
+ def run(root: Path, file_rel: str) -> Impact:
37
+ """Compute the blast radius of editing ``file_rel`` from the built graph."""
38
+ target = _norm(file_rel)
39
+ graph = graphquery.load(root) # raises SystemExit if no graph yet
40
+ ids = {n["id"] for n in graph["nodes"]}
41
+ # Symbols defined in the file (contains edges: file → symbol).
42
+ symbols = sorted(e["target"] for e in graph["edges"]
43
+ if e["type"] == "contains" and e["source"] == target)
44
+ sym_set = set(symbols) | {target}
45
+
46
+ # Split precise `calls` from loose `references` (INFERRED) so real callers
47
+ # aren't drowned by generic-name collisions (value/total/get).
48
+ callers: set[str] = set()
49
+ referenced_by: set[str] = set()
50
+ for e in graph["edges"]:
51
+ if e["target"] not in sym_set:
52
+ continue
53
+ dependent = e["source"].split("::", 1)[0]
54
+ if dependent == target:
55
+ continue
56
+ if e["type"] == "calls":
57
+ callers.add(dependent)
58
+ elif e["type"] == "references":
59
+ referenced_by.add(dependent)
60
+ referenced_by -= callers # a precise call beats a loose ref
61
+
62
+ governing = explain.refs(root, target)
63
+ invariants = [r for r in governing if r.type == "invariant" or r.fm.get("check")]
64
+ related = sorted({rid for r in governing for rid in r.related})
65
+
66
+ return Impact(target=target, symbols=symbols, governing=governing,
67
+ invariants=invariants, callers=sorted(callers),
68
+ referenced_by=sorted(referenced_by),
69
+ related=related, in_graph=target in ids)
70
+
71
+
72
+ def render(im: Impact) -> str:
73
+ """A maintenance checklist, ordered by how expensive it is to get wrong."""
74
+ out = [f"# impact of changing {im.target}"]
75
+ if not im.in_graph:
76
+ out.append("_(not a graphed file — `fux build` may be stale, or the path is wrong)_")
77
+ inv_ids = {r.id for r in im.invariants}
78
+
79
+ def section(title: str, items: list[str], cap: int | None = None) -> None:
80
+ if not items:
81
+ return
82
+ out.extend(["", f"## {title}"])
83
+ out.extend(f"- {x}" for x in (items[:cap] if cap else items))
84
+ if cap and len(items) > cap:
85
+ out.append(f" …and {len(items) - cap} more")
86
+
87
+ section("Invariants that must still hold — run `fux verify`",
88
+ [f"**{r.id}** ({r.type})" + (f" — `{r.fm['check']}`" if r.fm.get("check") else "")
89
+ for r in im.invariants])
90
+ section("Governing rules — update the *why* if behaviour changed",
91
+ [f"{r.id} ({r.type}) — {r.title}" for r in im.governing if r.id not in inv_ids])
92
+ if im.related:
93
+ out += ["", "## Related knowledge to review", " " + ", ".join(im.related)]
94
+ section(f"Downstream callers ({len(im.callers)} file(s) call into this — re-test)", im.callers)
95
+ section(f"Possibly affected ({len(im.referenced_by)} reference these names — lower confidence)",
96
+ im.referenced_by, cap=10)
97
+ if im.empty:
98
+ out += ["", "No governing rules and no downstream callers found — low blast "
99
+ "radius, or this file isn't graphed/governed yet."]
100
+ return "\n".join(out).strip() + "\n"
@@ -16,8 +16,8 @@ import json
16
16
  import sys
17
17
  from pathlib import Path
18
18
 
19
- from fux import (__version__, coverage, explain, graphquery, paths, recall,
20
- savings, scaffold, stats)
19
+ from fux import (__version__, components, coverage, explain, graphquery, impact,
20
+ paths, recall, savings, scaffold, stats, uispec)
21
21
 
22
22
  PROTOCOL = "2024-11-05"
23
23
 
@@ -34,6 +34,23 @@ TOOLS = [
34
34
  "description": "Reverse lookup — which rules govern a given file path.",
35
35
  "inputSchema": {"type": "object", "required": ["file"],
36
36
  "properties": {"file": {"type": "string"}}}},
37
+ {"name": "fux_impact",
38
+ "description": "Downstream blast radius of changing a file: governing rules whose "
39
+ "why may go stale, invariants to re-verify, and caller files that may break.",
40
+ "inputSchema": {"type": "object", "required": ["file"],
41
+ "properties": {"file": {"type": "string"}}}},
42
+ {"name": "fux_components",
43
+ "description": "The design-system registry + data-binding catalog (JSON): UI components "
44
+ "with their prop fields, data hooks (use*), and DTO shapes — so a generated "
45
+ "component composes from real primitives and binds to real data.",
46
+ "inputSchema": {"type": "object", "properties": {
47
+ "scope": {"type": "string"}}}},
48
+ {"name": "fux_validate_spec",
49
+ "description": "Validate a declarative UISpec against the registry before mounting — "
50
+ "rejects unknown components, undeclared props, and unknown data hooks. "
51
+ "Returns 'valid' or the list of violations. The runtime guardrail.",
52
+ "inputSchema": {"type": "object", "required": ["spec"], "properties": {
53
+ "spec": {"type": "object", "description": "the UISpec node tree"}}}},
37
54
  {"name": "fux_coverage",
38
55
  "description": "Percent of important code files that carry a governing rule.",
39
56
  "inputSchema": {"type": "object", "properties": {}}},
@@ -83,6 +100,13 @@ def _call(name: str, args: dict) -> str:
83
100
  hits = explain.refs(root, args["file"])
84
101
  return "\n".join(f"{r.id} ({r.type}) — {r.title}" for r in hits) \
85
102
  or f"(no rules govern {args['file']})"
103
+ if name == "fux_impact":
104
+ return impact.render(impact.run(root, args["file"]))
105
+ if name == "fux_components":
106
+ return components.render_json(components.registry(root, scope=args.get("scope")))
107
+ if name == "fux_validate_spec":
108
+ errs = uispec.validate(components.registry(root), args["spec"])
109
+ return uispec.render(not errs, errs)
86
110
  if name == "fux_coverage":
87
111
  c = coverage.run(root)
88
112
  return f"{c.pct:.0f}% ({c.governed}/{c.total} important files governed)"
@@ -15,6 +15,7 @@ def render(graph: dict) -> str:
15
15
  f"{meta.get('code_files', 0)} code files · {meta.get('rules', 0)} rules · "
16
16
  f"{meta.get('communities', 0)} communities._", ""]
17
17
  lines += _types(nodes)
18
+ lines += _edges(graph["edges"])
18
19
  lines += _god(graph, by_id)
19
20
  lines += _chokepoints(graph, by_id)
20
21
  lines += _communities(nodes)
@@ -28,6 +29,16 @@ def _types(nodes: list[dict]) -> list[str]:
28
29
  return out + [""]
29
30
 
30
31
 
32
+ def _edges(edges: list[dict]) -> list[str]:
33
+ """Edge mix by type + confidence — INFERRED edges are the loose, down-weighted ones."""
34
+ by_type = Counter(e.get("type") for e in edges)
35
+ inferred = sum(1 for e in edges if e.get("confidence") == "INFERRED")
36
+ out = ["## Edges", "", f"_{inferred} of {len(edges)} are INFERRED "
37
+ "(low-confidence `references`, down-weighted in clustering/centrality)._", ""]
38
+ out += [f"- {t}: {c}" for t, c in sorted(by_type.items(), key=lambda kv: -kv[1])]
39
+ return out + [""]
40
+
41
+
31
42
  def _god(graph: dict, by_id: dict) -> list[str]:
32
43
  out = ["## God nodes (highest connectivity)", ""]
33
44
  for nid, deg in graphquery.god_nodes(graph, 12):
@@ -0,0 +1,64 @@
1
+ """`fux validate-spec` — validate a declarative UISpec against the registry ($0, §18.3.3).
2
+
3
+ The mount-time guardrail for Orff's on-the-fly generation: a generated UI may only
4
+ compose **registry** components, with **declared** props, bound to **known** data
5
+ hooks. Anything else is rejected before it can render. This is what makes runtime
6
+ generation safe — the model emits a declarative tree, never code; the frontend
7
+ renders it from a whitelist. Pure structural validation; no code execution, no LLM.
8
+
9
+ UISpec node: {"component": "Card", "props": {...}, "data": "useHoldings",
10
+ "children": [ <node>, {"text": "literal"} ]}
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ from fux import components
18
+
19
+
20
+ def validate(reg: dict, spec: object) -> list[str]:
21
+ """Return a list of violations; empty means the spec is safe to mount."""
22
+ by_name = {c["name"]: c for c in reg["components"]}
23
+ hooks = {h["name"] for h in reg["hooks"]}
24
+ errs: list[str] = []
25
+ _node(spec, "$", by_name, hooks, errs)
26
+ return errs
27
+
28
+
29
+ def _node(node: object, path: str, by_name: dict, hooks: set, errs: list[str]) -> None:
30
+ if isinstance(node, dict) and "text" in node and "component" not in node:
31
+ return # literal text leaf
32
+ if not isinstance(node, dict) or "component" not in node:
33
+ errs.append(f"{path}: node needs a 'component' or 'text'")
34
+ return
35
+ name = node["component"]
36
+ comp = by_name.get(name)
37
+ if comp is None:
38
+ errs.append(f"{path}: unknown component '{name}' — not in the registry")
39
+ return
40
+ prop_names = {p["name"] for p in comp["props"]}
41
+ required = {p["name"] for p in comp["props"] if not p["optional"]} - {"children", "className"}
42
+ here = f"{path}.{name}"
43
+ props = node.get("props") or {}
44
+ errs += [f"{here}: prop '{k}' not on {name}" for k in props if k not in prop_names]
45
+ errs += [f"{here}: missing required prop '{r}'" for r in sorted(required - set(props))]
46
+ if (data := node.get("data")) and data not in hooks:
47
+ errs.append(f"{here}: data hook '{data}' not in the registry")
48
+ children = node.get("children") or []
49
+ if children and "children" not in prop_names:
50
+ errs.append(f"{here}: {name} takes no children")
51
+ for i, ch in enumerate(children):
52
+ _node(ch, f"{here}[{i}]", by_name, hooks, errs)
53
+
54
+
55
+ def run(root: Path, spec_path: Path) -> tuple[bool, list[str]]:
56
+ reg = components.registry(root)
57
+ errs = validate(reg, json.loads(spec_path.read_text(encoding="utf-8")))
58
+ return not errs, errs
59
+
60
+
61
+ def render(ok: bool, errs: list[str]) -> str:
62
+ if ok:
63
+ return "✔ spec valid — every component, prop, and data hook is in the registry\n"
64
+ return "✘ spec rejected:\n" + "\n".join(f" - {e}" for e in errs) + "\n"