@barefootjs/cli 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +237 -6
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -20959,6 +20959,76 @@ function turnToEventBinding(graph, events) {
20959
20959
  }
20960
20960
  return out;
20961
20961
  }
20962
+ function nextCommandsForSubscriber(fallbackComponent, subscriber) {
20963
+ const cmds = [];
20964
+ const parsed = parseProfilerId(subscriber);
20965
+ const component = parsed?.component ?? fallbackComponent;
20966
+ if (parsed) {
20967
+ if (parsed.kind === "memo" || parsed.kind === "signal") {
20968
+ cmds.push(`bf debug trace ${component} ${parsed.rest} --json`);
20969
+ } else if (parsed.kind === "binding") {
20970
+ cmds.push(`bf debug why-update ${component} ${parsed.rest} --json`);
20971
+ }
20972
+ }
20973
+ cmds.push(`bf debug graph ${component} --json`);
20974
+ return cmds;
20975
+ }
20976
+ function buildAgentFindings(component, hotSubscribers, wastedReReruns, batchAdvisor, unattributed) {
20977
+ const findings = [];
20978
+ for (const s of hotSubscribers.subscribers) {
20979
+ if (!s.hot) continue;
20980
+ findings.push({
20981
+ kind: "hot-subscriber",
20982
+ severity: "warning",
20983
+ actionable: s.loc !== void 0,
20984
+ subscriber: s.subscriber,
20985
+ loc: s.loc,
20986
+ message: `${s.name ?? s.subscriber} ran ${s.runsPerTurn.toFixed(1)}\xD7/turn \u2014 re-run pressure (split or batch).`,
20987
+ nextCommands: nextCommandsForSubscriber(component, s.subscriber)
20988
+ });
20989
+ }
20990
+ for (const s of wastedReReruns.subscribers) {
20991
+ if (!s.wasted) continue;
20992
+ findings.push({
20993
+ kind: "wasted-re-run",
20994
+ severity: "warning",
20995
+ actionable: s.loc !== void 0,
20996
+ subscriber: s.subscriber,
20997
+ loc: s.loc,
20998
+ message: `${s.name ?? s.subscriber} produced identical output in ${Math.round(s.wastedRatio * 100)}% of runs \u2014 finer split.`,
20999
+ nextCommands: nextCommandsForSubscriber(component, s.subscriber)
21000
+ });
21001
+ }
21002
+ for (const c of batchAdvisor.candidates) {
21003
+ findings.push({
21004
+ kind: "batch-candidate",
21005
+ // Only a *proven-safe* batch is advised as actionable warning; an
21006
+ // unverified one is surfaced as info so an agent doesn't apply a wrap that
21007
+ // could change behavior (mirrors the batch advisor's own safety gate).
21008
+ severity: c.safety === "safe" ? "warning" : "info",
21009
+ actionable: c.safety === "safe" && c.loc !== void 0,
21010
+ subscriber: c.turn,
21011
+ loc: c.loc,
21012
+ message: `${c.handler ?? c.turn} re-ran shared effects ${c.savings}\xD7 extra across ${c.writes} writes \u2014 batch() candidate (${c.safety}).`,
21013
+ // The turn id is `<Component>#handler:…` — for a scenario-file run it can be
21014
+ // a composed child, so route through the helper to target the right one.
21015
+ nextCommands: nextCommandsForSubscriber(component, c.turn)
21016
+ });
21017
+ }
21018
+ for (const u of unattributed) {
21019
+ findings.push({
21020
+ kind: "coverage-gap",
21021
+ severity: "warning",
21022
+ actionable: true,
21023
+ subscriber: u.id,
21024
+ message: `Unresolved subscriber id "${u.id}" \u2014 could not map to source (scope caveat).`,
21025
+ // `u.id` is shaped `<Component>#…` and may name a composed child — route
21026
+ // through the helper so the command targets that component, not the root.
21027
+ nextCommands: nextCommandsForSubscriber(component, u.id)
21028
+ });
21029
+ }
21030
+ return findings;
21031
+ }
20962
21032
  function buildProfileReport(input) {
20963
21033
  const { source, filePath, componentName, scenario, events } = input;
20964
21034
  const primary = buildComponentAnalysis(source, filePath, componentName).graph;
@@ -21037,6 +21107,27 @@ function buildProfileReport(input) {
21037
21107
  handlerIds.add(e.turn);
21038
21108
  }
21039
21109
  }
21110
+ const findings = buildAgentFindings(primary.componentName, hotSubscribers, wastedReReruns, batchAdvisor, unattributed);
21111
+ const status = findings.some((f) => f.severity === "warning" || f.severity === "error") ? "warning" : "ok";
21112
+ const ratio = handlersTotal > 0 ? Math.min(1, handlerIds.size / handlersTotal) : 1;
21113
+ let guidance;
21114
+ if (turnSeqs.size === 0) {
21115
+ guidance = handlersTotal === 0 ? {
21116
+ reason: "no-handlers",
21117
+ message: "No event handlers \u2014 use the static budget instead of a dynamic run.",
21118
+ nextCommands: [`bf debug profile ${primary.componentName} --json`]
21119
+ } : {
21120
+ reason: "no-interactions",
21121
+ message: "Handlers exist but none fired \u2014 they likely live in composed children. Drive the component with a story/scenario file.",
21122
+ nextCommands: [`bf debug profile ${primary.componentName} --scenario <story.tsx> --json`]
21123
+ };
21124
+ } else if (ratio < 1) {
21125
+ guidance = {
21126
+ reason: "partial-coverage",
21127
+ message: `Only ${handlerIds.size}/${handlersTotal} handlers exercised \u2014 a story/scenario file can cover the rest.`,
21128
+ nextCommands: [`bf debug profile ${primary.componentName} --scenario <story.tsx> --json`]
21129
+ };
21130
+ }
21040
21131
  return {
21041
21132
  kind: "profile",
21042
21133
  schemaVersion: PROFILE_SCHEMA_VERSION,
@@ -21051,12 +21142,16 @@ function buildProfileReport(input) {
21051
21142
  coverage: {
21052
21143
  handlersFired: handlerIds.size,
21053
21144
  handlersTotal,
21145
+ ratio,
21054
21146
  unattributed,
21055
21147
  // Roll the (potentially hundreds of) bookkeeping ids up to a count + a
21056
21148
  // small sample so JSON consumers aren't flooded (#1849 B7). `diagnostics`
21057
21149
  // is already sorted hottest-first by `joinProfilerEvents`.
21058
21150
  diagnostics: { count: diagnostics.length, sample: diagnostics.slice(0, 3).map((d) => d.id) }
21059
- }
21151
+ },
21152
+ status,
21153
+ findings,
21154
+ ...guidance ? { guidance } : {}
21060
21155
  };
21061
21156
  }
21062
21157
  function formatProfileReport(r2) {
@@ -21083,8 +21178,66 @@ function formatProfileReport(r2) {
21083
21178
  if (c.diagnostics.count > 0) {
21084
21179
  lines.push(` \xB7 ${c.diagnostics.count} anonymous runtime id(s) (non-actionable bookkeeping)`);
21085
21180
  }
21181
+ lines.push("");
21182
+ lines.push(`status: ${r2.status} (${r2.findings.length} finding(s))`);
21183
+ if (r2.guidance) {
21184
+ lines.push(` guidance: ${r2.guidance.message}`);
21185
+ lines.push(` next: ${r2.guidance.nextCommands[0]}`);
21186
+ } else if (r2.findings.length > 0) {
21187
+ lines.push(` next: ${r2.findings[0].nextCommands[0]}`);
21188
+ }
21086
21189
  return lines.join("\n");
21087
21190
  }
21191
+ function evaluateProfileGates(report, config) {
21192
+ const failOn = new Set(config.failOn ?? []);
21193
+ const checks = [];
21194
+ if (failOn.has("coverage") || config.minCoverage !== void 0) {
21195
+ const threshold = config.minCoverage ?? 1;
21196
+ const observed = report.coverage.ratio;
21197
+ checks.push({
21198
+ gate: "coverage",
21199
+ passed: observed >= threshold,
21200
+ observed,
21201
+ threshold,
21202
+ message: `coverage ${(observed * 100).toFixed(0)}% ${observed >= threshold ? "\u2265" : "<"} required ${(threshold * 100).toFixed(0)}%`
21203
+ });
21204
+ }
21205
+ if (failOn.has("unresolved") || config.maxUnresolved !== void 0) {
21206
+ const threshold = config.maxUnresolved ?? 0;
21207
+ const observed = report.coverage.unattributed.length;
21208
+ checks.push({
21209
+ gate: "unresolved",
21210
+ passed: observed <= threshold,
21211
+ observed,
21212
+ threshold,
21213
+ message: `${observed} unresolved id(s) ${observed <= threshold ? "\u2264" : ">"} allowed ${threshold}`
21214
+ });
21215
+ }
21216
+ if (failOn.has("hot") || config.maxRunsPerTurn !== void 0) {
21217
+ const observed = report.hotSubscribers.subscribers.reduce((m, s) => Math.max(m, s.runsPerTurn), 0);
21218
+ if (config.maxRunsPerTurn !== void 0) {
21219
+ const threshold = config.maxRunsPerTurn;
21220
+ checks.push({
21221
+ gate: "hot",
21222
+ passed: observed <= threshold,
21223
+ observed,
21224
+ threshold,
21225
+ message: `max ${observed.toFixed(1)} runs/turn ${observed <= threshold ? "\u2264" : ">"} budget ${threshold}`
21226
+ });
21227
+ } else {
21228
+ const anyHot = report.hotSubscribers.subscribers.some((s) => s.hot);
21229
+ checks.push({
21230
+ gate: "hot",
21231
+ passed: !anyHot,
21232
+ observed,
21233
+ threshold: null,
21234
+ message: anyHot ? `hot subscriber(s) present (max ${observed.toFixed(1)} runs/turn)` : "no hot subscribers"
21235
+ });
21236
+ }
21237
+ }
21238
+ const failed = checks.filter((c) => !c.passed).map((c) => c.gate);
21239
+ return { passed: failed.length === 0, failed, checks };
21240
+ }
21088
21241
  var PROFILE_SCHEMA_VERSION, DEFAULT_FANOUT_THRESHOLD, DEFAULT_HOT_RUNS_PER_TURN, BAR_EIGHTHS, DEFAULT_WASTED_RATIO;
21089
21242
  var init_profiler = __esm({
21090
21243
  "../jsx/src/profiler.ts"() {
@@ -21786,6 +21939,7 @@ __export(src_exports, {
21786
21939
  emitParsedExpr: () => emitParsedExpr,
21787
21940
  enableCompilerInstrumentation: () => enableCompilerInstrumentation,
21788
21941
  evalStringArrayJoin: () => evalStringArrayJoin,
21942
+ evaluateProfileGates: () => evaluateProfileGates,
21789
21943
  exprToString: () => exprToString,
21790
21944
  extractArrowBodyExpression: () => extractArrowBodyExpression,
21791
21945
  extractFunctionParams: () => extractFunctionParams,
@@ -104393,6 +104547,18 @@ __export(debug_profile_exports, {
104393
104547
  import { execFileSync as execFileSync2 } from "node:child_process";
104394
104548
  import { readFileSync as readFileSync19 } from "node:fs";
104395
104549
  import path24 from "node:path";
104550
+ function parseFailOn(raw) {
104551
+ const parts = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
104552
+ if (parts.length === 0) fail("--fail-on requires at least one gate name.");
104553
+ const out = [];
104554
+ for (const p of parts) {
104555
+ if (!GATE_NAMES.includes(p)) {
104556
+ fail(`--fail-on: unknown gate "${p}" (expected ${GATE_NAMES.join(", ")}).`);
104557
+ }
104558
+ if (!out.includes(p)) out.push(p);
104559
+ }
104560
+ return out;
104561
+ }
104396
104562
  function parseFlags2(args2) {
104397
104563
  const flags = { positional: [] };
104398
104564
  const value2 = (args3, i, name2) => {
@@ -104419,6 +104585,10 @@ function parseFlags2(args2) {
104419
104585
  else if (a === "--top") flags.topN = num(args2, ++i, "--top", { integer: true, min: 1 });
104420
104586
  else if (a === "--hot-ms") flags.minMs = num(args2, ++i, "--hot-ms", { min: 0 });
104421
104587
  else if (a === "--wasted-pct") flags.wastedPct = num(args2, ++i, "--wasted-pct", { min: 0, max: 100 });
104588
+ else if (a === "--fail-on") flags.failOn = parseFailOn(value2(args2, ++i, "--fail-on"));
104589
+ else if (a === "--min-coverage") flags.minCoverage = num(args2, ++i, "--min-coverage", { min: 0, max: 1 });
104590
+ else if (a === "--max-runs-per-turn") flags.maxRunsPerTurn = num(args2, ++i, "--max-runs-per-turn", { min: 0 });
104591
+ else if (a === "--max-unresolved") flags.maxUnresolved = num(args2, ++i, "--max-unresolved", { integer: true, min: 0 });
104422
104592
  else if (a.startsWith("-")) fail(`Unknown flag "${a}".`);
104423
104593
  else flags.positional.push(a);
104424
104594
  }
@@ -104429,6 +104599,11 @@ function fail(message) {
104429
104599
  console.error(USAGE);
104430
104600
  process.exit(1);
104431
104601
  }
104602
+ function formatGates(g) {
104603
+ const lines = [`gates: ${g.passed ? "PASS" : "FAIL"}`];
104604
+ for (const c of g.checks) lines.push(` ${c.passed ? "\u2713" : "\u2717"} ${c.gate}: ${c.message}`);
104605
+ return lines.join("\n");
104606
+ }
104432
104607
  async function run22(args2, ctx2) {
104433
104608
  if (args2.includes("--help") || args2.includes("-h")) {
104434
104609
  console.log(HELP2);
@@ -104438,13 +104613,23 @@ async function run22(args2, ctx2) {
104438
104613
  if (flags.scenario && flags.diff) {
104439
104614
  fail("--scenario and --diff cannot be combined: --scenario measures a run, --diff compares two compiles. Pick one.");
104440
104615
  }
104616
+ const wantsRegression = (flags.failOn ?? []).includes("regression");
104617
+ const dynamicFailOn = (flags.failOn ?? []).filter((g) => g !== "regression");
104618
+ const wantsDynamicGate = dynamicFailOn.length > 0 || flags.minCoverage !== void 0 || flags.maxRunsPerTurn !== void 0 || flags.maxUnresolved !== void 0;
104619
+ if (wantsDynamicGate && !flags.scenario) {
104620
+ fail("Coverage / unresolved / hot gates need a measured run \u2014 add --scenario auto|<file>.");
104621
+ }
104622
+ if (wantsRegression && !flags.diff) {
104623
+ fail("--fail-on regression needs a compile comparison \u2014 add --diff <ref>.");
104624
+ }
104441
104625
  const {
104442
104626
  buildStaticBudget: buildStaticBudget2,
104443
104627
  formatStaticBudget: formatStaticBudget2,
104444
104628
  diffStaticBudget: diffStaticBudget2,
104445
104629
  formatBudgetDiff: formatBudgetDiff2,
104446
104630
  buildProfileReport: buildProfileReport2,
104447
- formatProfileReport: formatProfileReport2
104631
+ formatProfileReport: formatProfileReport2,
104632
+ evaluateProfileGates: evaluateProfileGates2
104448
104633
  } = await Promise.resolve().then(() => (init_src2(), src_exports));
104449
104634
  const componentName = flags.positional[0];
104450
104635
  if (!componentName) {
@@ -104488,12 +104673,22 @@ async function run22(args2, ctx2) {
104488
104673
  // `--wasted-pct` is a percentage on the CLI; the analysis takes a [0,1] fraction.
104489
104674
  wastedRatio: flags.wastedPct !== void 0 ? flags.wastedPct / 100 : void 0
104490
104675
  });
104676
+ const gateConfig = {
104677
+ failOn: dynamicFailOn,
104678
+ minCoverage: flags.minCoverage,
104679
+ maxRunsPerTurn: flags.maxRunsPerTurn,
104680
+ maxUnresolved: flags.maxUnresolved
104681
+ };
104682
+ const gates = wantsDynamicGate ? evaluateProfileGates2(report, gateConfig) : void 0;
104683
+ if (gates && !gates.passed) report.status = "error";
104491
104684
  if (ctx2.jsonFlag) {
104492
- console.log(JSON.stringify(report, null, 2));
104685
+ console.log(JSON.stringify(gates ? { ...report, gates } : report, null, 2));
104493
104686
  } else {
104494
104687
  console.log(formatProfileReport2(report));
104495
104688
  if (fired === 0) console.log(" note: no interactive elements were found to fire.");
104689
+ if (gates) console.log("\n" + formatGates(gates));
104496
104690
  }
104691
+ if (gates && !gates.passed) process.exit(1);
104497
104692
  } catch (err) {
104498
104693
  console.error(`Error: ${err.message}`);
104499
104694
  process.exit(1);
@@ -104515,10 +104710,24 @@ async function run22(args2, ctx2) {
104515
104710
  fanOutThreshold: flags.fanOutThreshold
104516
104711
  });
104517
104712
  const diff = diffStaticBudget2(base, head);
104713
+ const gates = wantsRegression ? {
104714
+ passed: !diff.regressed,
104715
+ failed: diff.regressed ? ["regression"] : [],
104716
+ checks: [
104717
+ {
104718
+ gate: "regression",
104719
+ passed: !diff.regressed,
104720
+ observed: diff.regressed ? 1 : 0,
104721
+ threshold: 0,
104722
+ message: diff.regressed ? "a reactivity metric regressed" : "no regression"
104723
+ }
104724
+ ]
104725
+ } : void 0;
104518
104726
  if (ctx2.jsonFlag) {
104519
- console.log(JSON.stringify(diff, null, 2));
104727
+ console.log(JSON.stringify(gates ? { ...diff, gates } : diff, null, 2));
104520
104728
  } else {
104521
104729
  console.log(formatBudgetDiff2(diff));
104730
+ if (gates) console.log("\n" + formatGates(gates));
104522
104731
  }
104523
104732
  if (diff.regressed) process.exit(1);
104524
104733
  return;
@@ -104552,12 +104761,12 @@ function readFileAtRef(filePath, ref) {
104552
104761
  return gitError(err);
104553
104762
  }
104554
104763
  }
104555
- var USAGE, HELP2;
104764
+ var USAGE, HELP2, GATE_NAMES;
104556
104765
  var init_debug_profile2 = __esm({
104557
104766
  "src/commands/debug-profile.ts"() {
104558
104767
  "use strict";
104559
104768
  init_resolve_source();
104560
- USAGE = "Usage: bf debug profile <component> [--diff <ref>] [--scenario auto|<file>] [--fanout <n>] [--top <n>] [--hot-ms <n>] [--wasted-pct <n>] [--json]";
104769
+ USAGE = "Usage: bf debug profile <component> [--diff <ref>] [--scenario auto|<file>] [--fanout <n>] [--top <n>] [--hot-ms <n>] [--wasted-pct <n>] [--fail-on <gates>] [--min-coverage <r>] [--max-runs-per-turn <n>] [--max-unresolved <n>] [--json]";
104561
104770
  HELP2 = `bf debug profile \u2014 reactive performance profiler
104562
104771
 
104563
104772
  Find and fix wasted reactive work: re-runs that produce nothing, fan-out that
@@ -104621,6 +104830,25 @@ FLAGS
104621
104830
  is additive-only. Stable schema with deterministic
104622
104831
  tie-breaking; structural findings reproduce run-to-run
104623
104832
  (wall-clock-timed ranks can shift near rounding boundaries).
104833
+ A dynamic run also carries \`status\`, normalized \`findings\`
104834
+ (severity + actionable + nextCommands), and \u2014 when handlers
104835
+ were under-exercised \u2014 \`guidance\` pointing at a story file.
104836
+
104837
+ AGENT GATES (CI pass/fail with intent)
104838
+ By default a run never fails CI. Opt into a gate and the command exits non-zero
104839
+ when it trips, emitting a \`gates\` block ({passed, failed, checks}) in --json and
104840
+ escalating \`status\` to "error". Gates compose with --json so an agent decides
104841
+ fail-vs-continue without parsing prose.
104842
+
104843
+ --fail-on <gates> Comma-separated: unresolved,hot,coverage (need
104844
+ --scenario) and regression (needs --diff). Each uses its
104845
+ default threshold unless a numeric flag below overrides.
104846
+ --min-coverage <r> Fail when coverage ratio < r (0\u20131). Implies the
104847
+ coverage gate. Needs --scenario.
104848
+ --max-runs-per-turn <n> Fail when the hottest subscriber exceeds n runs/turn.
104849
+ Implies the hot gate. Needs --scenario.
104850
+ --max-unresolved <n> Fail when more than n unresolved (actionable) coverage
104851
+ gaps remain. Implies the unresolved gate. Needs --scenario.
104624
104852
 
104625
104853
  EXAMPLES
104626
104854
  bf debug profile calendar # static budget, no run
@@ -104628,6 +104856,8 @@ EXAMPLES
104628
104856
  bf debug profile calendar --scenario auto --top 5 --hot-ms 1
104629
104857
  bf debug profile checkout --scenario ./stories/checkout.tsx --json
104630
104858
  bf debug profile checkout --diff origin/main # regression gate (CI)
104859
+ bf debug profile calendar --scenario auto --min-coverage 0.8 --fail-on hot --json
104860
+ bf debug profile checkout --diff origin/main --fail-on regression --json
104631
104861
 
104632
104862
  NOTES
104633
104863
  \u2022 Instrumentation is dev-only and is stripped from production builds.
@@ -104635,6 +104865,7 @@ NOTES
104635
104865
  static budget and --diff need no build.
104636
104866
  \u2022 Composes with \`bf debug graph/trace/why-update\`: those say *where to look*,
104637
104867
  profile says *what it cost and what to change*, citing the same source lines.`;
104868
+ GATE_NAMES = ["unresolved", "hot", "coverage", "regression"];
104638
104869
  }
104639
104870
  });
104640
104871
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/cli",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "CLI for agent-driven UI component discovery and scaffolding",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -29,11 +29,11 @@
29
29
  "dependencies": {
30
30
  "esbuild": "^0.25.0",
31
31
  "typescript": "^5.0.0",
32
- "@barefootjs/client": "0.15.2",
33
- "@barefootjs/shared": "0.15.2"
32
+ "@barefootjs/client": "0.16.0",
33
+ "@barefootjs/shared": "0.16.0"
34
34
  },
35
35
  "devDependencies": {
36
- "@barefootjs/jsx": "0.15.2",
36
+ "@barefootjs/jsx": "0.16.0",
37
37
  "@types/node": "^22.0.0",
38
38
  "@happy-dom/global-registrator": "^20.0.11",
39
39
  "happy-dom": "^20.0.11"