parseman 0.46.0 → 0.47.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.
- package/README.md +8 -3
- package/dist/analysis/choice-cost-render.d.ts +12 -4
- package/dist/analysis/choice-cost-render.d.ts.map +1 -1
- package/dist/analysis/choice-cost.d.ts +2 -2
- package/dist/analysis/choice-cost.d.ts.map +1 -1
- package/dist/analysis/commitment.d.ts +131 -0
- package/dist/analysis/commitment.d.ts.map +1 -0
- package/dist/analysis/diagnostics.cjs +9929 -7465
- package/dist/analysis/diagnostics.cjs.map +3 -4
- package/dist/analysis/diagnostics.d.ts +2 -2
- package/dist/analysis/diagnostics.js +9929 -7465
- package/dist/analysis/diagnostics.js.map +3 -4
- package/dist/analysis/duplication.d.ts.map +1 -1
- package/dist/analysis/fix.d.ts +3 -3
- package/dist/analysis/fix.d.ts.map +1 -1
- package/dist/analysis/format-number.d.ts +23 -0
- package/dist/analysis/format-number.d.ts.map +1 -0
- package/dist/analysis/gating.d.ts +39 -2
- package/dist/analysis/gating.d.ts.map +1 -1
- package/dist/analysis/rebuild.d.ts.map +1 -1
- package/dist/analysis/terminal.d.ts +5 -2
- package/dist/analysis/terminal.d.ts.map +1 -1
- package/dist/cli/index.cjs +10865 -8535
- package/dist/cli/index.cjs.map +3 -4
- package/dist/cli/index.js +10865 -8535
- package/dist/cli/index.js.map +3 -4
- package/dist/combinators/adjacency.d.ts +100 -0
- package/dist/combinators/adjacency.d.ts.map +1 -0
- package/dist/combinators/choice.d.ts.map +1 -1
- package/dist/combinators/completions.d.ts.map +1 -1
- package/dist/combinators/dispatch.d.ts +8 -1
- package/dist/combinators/dispatch.d.ts.map +1 -1
- package/dist/combinators/expect.d.ts.map +1 -1
- package/dist/combinators/first-set.d.ts.map +1 -1
- package/dist/combinators/gate.d.ts.map +1 -1
- package/dist/combinators/grammar.d.ts +22 -0
- package/dist/combinators/grammar.d.ts.map +1 -1
- package/dist/combinators/literal.d.ts +8 -0
- package/dist/combinators/literal.d.ts.map +1 -1
- package/dist/combinators/map.d.ts +0 -1
- package/dist/combinators/map.d.ts.map +1 -1
- package/dist/combinators/node.d.ts +13 -3
- package/dist/combinators/node.d.ts.map +1 -1
- package/dist/combinators/parser.d.ts +1 -1
- package/dist/combinators/parser.d.ts.map +1 -1
- package/dist/combinators/ref.d.ts.map +1 -1
- package/dist/combinators/regex.d.ts +1 -16
- package/dist/combinators/regex.d.ts.map +1 -1
- package/dist/combinators/repeat.d.ts +30 -2
- package/dist/combinators/repeat.d.ts.map +1 -1
- package/dist/combinators/scanTo.d.ts +71 -1
- package/dist/combinators/scanTo.d.ts.map +1 -1
- package/dist/combinators/sequence.d.ts.map +1 -1
- package/dist/combinators/token.d.ts.map +1 -1
- package/dist/combinators/trivia-boundary.d.ts +26 -0
- package/dist/combinators/trivia-boundary.d.ts.map +1 -0
- package/dist/combinators/trivia-skip.d.ts +40 -1
- package/dist/combinators/trivia-skip.d.ts.map +1 -1
- package/dist/combinators/withCtx.d.ts.map +1 -1
- package/dist/compiler/compile-linkable-table.d.ts +104 -0
- package/dist/compiler/compile-linkable-table.d.ts.map +1 -0
- package/dist/compiler/degradation.d.ts +0 -2
- package/dist/compiler/degradation.d.ts.map +1 -1
- package/dist/compiler/fields.d.ts +13 -0
- package/dist/compiler/fields.d.ts.map +1 -1
- package/dist/compiler/grammar-coverage-ids.d.ts +5 -5
- package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
- package/dist/compiler/ir-serialize.d.ts.map +1 -1
- package/dist/compiler/linker.d.ts +47 -53
- package/dist/compiler/linker.d.ts.map +1 -1
- package/dist/compiler/token-alphabet.d.ts +81 -0
- package/dist/compiler/token-alphabet.d.ts.map +1 -0
- package/dist/compiler/token-dispatch.d.ts +95 -0
- package/dist/compiler/token-dispatch.d.ts.map +1 -0
- package/dist/compiler/token-scanner.d.ts +53 -0
- package/dist/compiler/token-scanner.d.ts.map +1 -0
- package/dist/compiler/value-usage.d.ts.map +1 -1
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.d.ts.map +1 -1
- package/dist/cst/capture-buffer.d.ts +35 -0
- package/dist/cst/capture-buffer.d.ts.map +1 -1
- package/dist/cst/reflection-attach.d.ts +8 -0
- package/dist/cst/reflection-attach.d.ts.map +1 -0
- package/dist/cst/reflection-symbols.d.ts +10 -0
- package/dist/cst/reflection-symbols.d.ts.map +1 -0
- package/dist/cst/reflection.d.ts +2 -4
- package/dist/cst/reflection.d.ts.map +1 -1
- package/dist/cst/root-trivia-scope.d.ts +26 -0
- package/dist/cst/root-trivia-scope.d.ts.map +1 -0
- package/dist/cst/trivia-charscan.d.ts +18 -0
- package/dist/cst/trivia-charscan.d.ts.map +1 -0
- package/dist/cst/trivia-kinds.d.ts +19 -0
- package/dist/cst/trivia-kinds.d.ts.map +1 -1
- package/dist/functional/doc.d.ts.map +1 -1
- package/dist/functional/run-tabled.d.ts +12 -0
- package/dist/functional/run-tabled.d.ts.map +1 -0
- package/dist/functional/run.d.ts +25 -42
- package/dist/functional/run.d.ts.map +1 -1
- package/dist/grammar-metadata.d.ts +3 -0
- package/dist/grammar-metadata.d.ts.map +1 -0
- package/dist/index.cjs +9224 -7203
- package/dist/index.cjs.map +3 -4
- package/dist/index.d.ts +18 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9219 -7198
- package/dist/index.js.map +3 -4
- package/dist/language-service/index.cjs +311 -121
- package/dist/language-service/index.cjs.map +3 -4
- package/dist/language-service/index.js +311 -121
- package/dist/language-service/index.js.map +3 -4
- package/dist/oracle/index.cjs.map +0 -1
- package/dist/oracle/index.js.map +0 -1
- package/dist/parse-context.d.ts +49 -0
- package/dist/parse-context.d.ts.map +1 -0
- package/dist/plugin/evaluator.d.ts +40 -1
- package/dist/plugin/evaluator.d.ts.map +1 -1
- package/dist/plugin/index.cjs +11146 -9593
- package/dist/plugin/index.cjs.map +3 -4
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +11146 -9593
- package/dist/plugin/index.js.map +3 -4
- package/dist/regex/classes.d.ts +28 -0
- package/dist/regex/classes.d.ts.map +1 -1
- package/dist/regex/first-set.d.ts +14 -0
- package/dist/regex/first-set.d.ts.map +1 -1
- package/dist/run/index.cjs +136 -51
- package/dist/run/index.cjs.map +2 -3
- package/dist/run/index.js +136 -51
- package/dist/run/index.js.map +2 -3
- package/dist/spec/index.cjs +2898 -1623
- package/dist/spec/index.cjs.map +3 -4
- package/dist/spec/index.js +2898 -1623
- package/dist/spec/index.js.map +3 -4
- package/dist/spec/model.d.ts +8 -0
- package/dist/spec/model.d.ts.map +1 -1
- package/dist/table/assemble.d.ts +307 -0
- package/dist/table/assemble.d.ts.map +1 -0
- package/dist/table/cell.d.ts +42 -0
- package/dist/table/cell.d.ts.map +1 -0
- package/dist/table/child-slots.d.ts +30 -0
- package/dist/table/child-slots.d.ts.map +1 -0
- package/dist/table/compile-rule-map.d.ts +136 -0
- package/dist/table/compile-rule-map.d.ts.map +1 -0
- package/dist/table/compile.d.ts +56 -0
- package/dist/table/compile.d.ts.map +1 -0
- package/dist/table/duplication-hook.d.ts +32 -0
- package/dist/table/duplication-hook.d.ts.map +1 -0
- package/dist/table/emit-assembly.d.ts +139 -0
- package/dist/table/emit-assembly.d.ts.map +1 -0
- package/dist/table/emit.d.ts +113 -0
- package/dist/table/emit.d.ts.map +1 -0
- package/dist/table/encode-baseline.d.ts +16 -0
- package/dist/table/encode-baseline.d.ts.map +1 -0
- package/dist/table/encode.d.ts +71 -0
- package/dist/table/encode.d.ts.map +1 -0
- package/dist/table/exec-baseline.d.ts +9 -0
- package/dist/table/exec-baseline.d.ts.map +1 -0
- package/dist/table/exec.d.ts +72 -0
- package/dist/table/exec.d.ts.map +1 -0
- package/dist/table/fold.d.ts +25 -0
- package/dist/table/fold.d.ts.map +1 -0
- package/dist/table/index.cjs +11375 -0
- package/dist/table/index.cjs.map +6 -0
- package/dist/table/index.d.ts +100 -0
- package/dist/table/index.d.ts.map +1 -0
- package/dist/table/index.js +11352 -0
- package/dist/table/index.js.map +6 -0
- package/dist/table/inspect.d.ts +17 -0
- package/dist/table/inspect.d.ts.map +1 -0
- package/dist/table/ops.d.ts +446 -0
- package/dist/table/ops.d.ts.map +1 -0
- package/dist/table/program.d.ts +493 -0
- package/dist/table/program.d.ts.map +1 -0
- package/dist/table/run-support.d.ts +38 -0
- package/dist/table/run-support.d.ts.map +1 -0
- package/dist/{compiler/scannable-run.d.ts → table/scan-shapes.d.ts} +14 -13
- package/dist/table/scan-shapes.d.ts.map +1 -0
- package/dist/table/site-labels.d.ts +58 -0
- package/dist/table/site-labels.d.ts.map +1 -0
- package/dist/table/stamp.d.ts +70 -0
- package/dist/table/stamp.d.ts.map +1 -0
- package/dist/types.d.ts +80 -24
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +25 -2
- package/src/analysis/choice-cost-gate.ts +359 -0
- package/src/analysis/choice-cost-render.ts +293 -0
- package/src/analysis/choice-cost.ts +1008 -0
- package/src/analysis/commitment.ts +557 -0
- package/src/analysis/corpus.ts +130 -0
- package/src/analysis/diagnose-render.ts +625 -0
- package/src/analysis/diagnose.ts +317 -0
- package/src/analysis/diagnostics.ts +33 -0
- package/src/analysis/duplication.ts +1955 -0
- package/src/analysis/fix-render.ts +217 -0
- package/src/analysis/fix.ts +669 -0
- package/src/analysis/format-number.ts +31 -0
- package/src/analysis/gating.ts +792 -0
- package/src/analysis/grammar.ts +72 -0
- package/src/analysis/rebuild.ts +287 -0
- package/src/analysis/terminal.ts +336 -0
- package/src/cli/index.ts +413 -0
- package/src/combinators/adjacency.ts +171 -0
- package/src/combinators/attempt.ts +41 -0
- package/src/combinators/case-fold.ts +90 -0
- package/src/combinators/choice.ts +466 -0
- package/src/combinators/completions.ts +64 -0
- package/src/combinators/dispatch.ts +358 -0
- package/src/combinators/expect.ts +159 -0
- package/src/combinators/first-set.ts +322 -0
- package/src/combinators/gate.ts +50 -0
- package/src/combinators/grammar.ts +272 -0
- package/src/combinators/keywords.ts +145 -0
- package/src/combinators/literal.ts +102 -0
- package/src/combinators/map.ts +106 -0
- package/src/combinators/node.ts +345 -0
- package/src/combinators/not.ts +55 -0
- package/src/combinators/parser.ts +266 -0
- package/src/combinators/peek.ts +63 -0
- package/src/combinators/probe.ts +25 -0
- package/src/combinators/ref.ts +109 -0
- package/src/combinators/regex.ts +170 -0
- package/src/combinators/repeat.ts +561 -0
- package/src/combinators/scanTo.ts +393 -0
- package/src/combinators/sequence.ts +221 -0
- package/src/combinators/token.ts +120 -0
- package/src/combinators/trivia-boundary.ts +48 -0
- package/src/combinators/trivia-skip.ts +821 -0
- package/src/combinators/withCtx.ts +45 -0
- package/src/compiler/build-arity.ts +330 -0
- package/src/compiler/compile-linkable-table.ts +209 -0
- package/src/compiler/degradation.ts +266 -0
- package/src/compiler/fields.ts +191 -0
- package/src/compiler/grammar-coverage-ids.ts +129 -0
- package/src/compiler/ir-serialize.ts +615 -0
- package/src/compiler/linker.ts +954 -0
- package/src/compiler/token-alphabet.ts +209 -0
- package/src/compiler/token-dispatch.ts +353 -0
- package/src/compiler/token-scanner.ts +318 -0
- package/src/compiler/value-usage.ts +147 -0
- package/src/coverage.ts +383 -0
- package/src/cst/capture-buffer.ts +297 -0
- package/src/cst/host-mode.ts +69 -0
- package/src/cst/reflection-attach.ts +11 -0
- package/src/cst/reflection-symbols.ts +9 -0
- package/src/cst/reflection.ts +149 -0
- package/src/cst/relative-spans.ts +207 -0
- package/src/cst/root-trivia-scope.ts +30 -0
- package/src/cst/trivia-charscan.ts +312 -0
- package/src/cst/trivia-entries.ts +285 -0
- package/src/cst/trivia-index.ts +131 -0
- package/src/cst/trivia-kinds.ts +358 -0
- package/src/cst/types.ts +76 -0
- package/src/cst/walk.ts +101 -0
- package/src/functional/doc.ts +959 -0
- package/src/functional/run-tabled.ts +153 -0
- package/src/functional/run.ts +558 -0
- package/src/grammar-metadata.ts +2 -0
- package/src/index.ts +189 -0
- package/src/language-service/index.ts +221 -0
- package/src/line-index.ts +131 -0
- package/src/oracle/digest.ts +620 -0
- package/src/oracle/index.ts +55 -0
- package/src/parse-context.ts +91 -0
- package/src/plugin/direct-builder-static.ts +102 -0
- package/src/plugin/evaluator.ts +1505 -0
- package/src/plugin/free-identifiers.ts +338 -0
- package/src/plugin/index.ts +2281 -0
- package/src/plugin/reducer-resolver.ts +606 -0
- package/src/recovery/scan.ts +155 -0
- package/src/regex/classes.ts +183 -0
- package/src/regex/first-set.ts +316 -0
- package/src/run/index.ts +31 -0
- package/src/spec/ebnf.ts +128 -0
- package/src/spec/index.ts +54 -0
- package/src/spec/model.ts +636 -0
- package/src/spec/railroad-lib.ts +559 -0
- package/src/spec/railroad.ts +256 -0
- package/src/table/assemble.ts +2994 -0
- package/src/table/cell.ts +42 -0
- package/src/table/child-slots.ts +113 -0
- package/src/table/compile-rule-map.ts +336 -0
- package/src/table/compile.ts +244 -0
- package/src/table/duplication-hook.ts +89 -0
- package/src/table/emit-assembly.ts +1766 -0
- package/src/table/emit.ts +418 -0
- package/src/table/encode-baseline.ts +340 -0
- package/src/table/encode.ts +1569 -0
- package/src/table/exec-baseline.ts +537 -0
- package/src/table/exec.ts +1672 -0
- package/src/table/fold.ts +38 -0
- package/src/table/index.ts +108 -0
- package/src/table/inspect.ts +52 -0
- package/src/table/ops.ts +459 -0
- package/src/table/program.ts +855 -0
- package/src/table/run-support.ts +66 -0
- package/src/table/scan-shapes.ts +1578 -0
- package/src/table/site-labels.ts +228 -0
- package/src/table/stamp.ts +229 -0
- package/src/types.ts +563 -0
- package/src/version.ts +16 -0
- package/dist/compiler/codegen.d.ts +0 -334
- package/dist/compiler/codegen.d.ts.map +0 -1
- package/dist/compiler/inline-build.d.ts +0 -11
- package/dist/compiler/inline-build.d.ts.map +0 -1
- package/dist/compiler/inline-callback.d.ts +0 -21
- package/dist/compiler/inline-callback.d.ts.map +0 -1
- package/dist/compiler/line-index.d.ts +0 -2
- package/dist/compiler/line-index.d.ts.map +0 -1
- package/dist/compiler/module-hoist.d.ts +0 -106
- package/dist/compiler/module-hoist.d.ts.map +0 -1
- package/dist/compiler/scannable-run.d.ts.map +0 -1
- package/dist/compiler/scannable-terminal.d.ts +0 -23
- package/dist/compiler/scannable-terminal.d.ts.map +0 -1
- package/dist/compiler/trivia-fast-path.d.ts +0 -38
- package/dist/compiler/trivia-fast-path.d.ts.map +0 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHOICE COST — the policy layer.
|
|
3
|
+
* ===============================
|
|
4
|
+
*
|
|
5
|
+
* Analysis says what the grammar costs. This says which of those numbers fail a build.
|
|
6
|
+
* They are separate because they have different consumers and different lifetimes: a
|
|
7
|
+
* developer wants the ranked list on every run, CI wants one bit, and the threshold
|
|
8
|
+
* that produces that bit is a decision the owner makes once and writes down.
|
|
9
|
+
*
|
|
10
|
+
* This module is PURE — it takes a report and a baseline and returns a verdict. It
|
|
11
|
+
* reads no files, spawns nothing, and prints nothing, so it is testable without a
|
|
12
|
+
* fixture checkout and cannot be the reason a gate is flaky.
|
|
13
|
+
*
|
|
14
|
+
* THE BASELINE IS ABSOLUTE, NOT DIFFERENTIAL
|
|
15
|
+
* ------------------------------------------
|
|
16
|
+
* Gating each commit against its parent lets +2% per commit land forever: every
|
|
17
|
+
* individual step is under tolerance and the total is unbounded. So the committed
|
|
18
|
+
* baseline records the ACTUAL byte counts, and drift is measured against those
|
|
19
|
+
* numbers, not against yesterday's.
|
|
20
|
+
*
|
|
21
|
+
* A baseline is committed DATA, reviewed like any other diff. Nothing here writes one.
|
|
22
|
+
* Rebaselining is a deliberate act with a reviewable artifact and owner sign-off; there
|
|
23
|
+
* is no automatic refresh, because a gate that rebaselines itself is a gate that
|
|
24
|
+
* records regressions rather than catching them.
|
|
25
|
+
*
|
|
26
|
+
* AND IT IS TWO-SIDED — A WIN MUST BE BANKED
|
|
27
|
+
* ------------------------------------------
|
|
28
|
+
* Growth past the committed number fails, and so does an unbanked IMPROVEMENT. This is
|
|
29
|
+
* the half usually left to a comment asking a human politely, and it is the half that
|
|
30
|
+
* rots: `bench/grammar-density/config.json` and `bench/workloads/config.json` each
|
|
31
|
+
* carried exactly such a comment and sat unbumped for TEN releases. If a reorder takes
|
|
32
|
+
* a site from 7,846 wasted bytes to 2,000 and the baseline does not move with it, 5,846
|
|
33
|
+
* bytes of fresh headroom silently become budget for the next regression — and the gate
|
|
34
|
+
* that was supposed to catch that regression reads green through the whole of it.
|
|
35
|
+
*
|
|
36
|
+
* So the committed number is a BAND, not a floor: leaving it in either direction fails,
|
|
37
|
+
* and only the remedy differs. Raising a number needs owner sign-off; lowering one is
|
|
38
|
+
* mandatory. Both are the same one-line rebaseline and the same reviewable diff.
|
|
39
|
+
*
|
|
40
|
+
* `bench/size-guard.ts` is the shape this follows — it earned the tightness in 0.45 by
|
|
41
|
+
* catching +0.14% moves a 1% tolerance would have waved through.
|
|
42
|
+
*
|
|
43
|
+
* A BASELINE CANNOT LAUNDER A CEILING
|
|
44
|
+
* -----------------------------------
|
|
45
|
+
* If a ceiling is configured, a baseline that records a number ABOVE it is rejected as
|
|
46
|
+
* invalid rather than honoured. Otherwise the ceiling would be waivable by rebaselining,
|
|
47
|
+
* which makes it a suggestion. (`bench/size-guard.ts` validates its own baseline for
|
|
48
|
+
* exactly this reason; this follows it.)
|
|
49
|
+
*
|
|
50
|
+
* FAILS CLOSED
|
|
51
|
+
* ------------
|
|
52
|
+
* Every way of not having measured is a failure, never a pass: a missing baseline, a
|
|
53
|
+
* baseline of the wrong shape, an empty baseline, a report over zero corpus files, a
|
|
54
|
+
* report where no site was instrumentable, a report whose grammar walk was incomplete
|
|
55
|
+
* (`unresolvedRoots` non-empty), a baselined key that no longer exists, and a measured
|
|
56
|
+
* key with no baseline entry. This repo already contains the alternative — `ratio:
|
|
57
|
+
* ordered.length === 0 ? 1` in src/coverage.ts reports 100% covered when nothing was
|
|
58
|
+
* analysable — and the whole point of a gate is to not be that.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import type { WastedWorkReport } from './choice-cost.ts'
|
|
62
|
+
|
|
63
|
+
export type WastedWorkBaseline = {
|
|
64
|
+
readonly schema: 'parseman.wasted-work-baseline/1'
|
|
65
|
+
/** Informational: which revision produced the numbers. Never compared. */
|
|
66
|
+
gitRev: string
|
|
67
|
+
/** Informational, `YYYY-MM-DD`. Never compared — comparing it would make the
|
|
68
|
+
* verdict depend on the clock. */
|
|
69
|
+
updatedAt: string
|
|
70
|
+
/**
|
|
71
|
+
* Absolute per-corpus totals, keyed by corpus id.
|
|
72
|
+
*
|
|
73
|
+
* `totalWastedBytes` is the INTERPRETED column, and that is deliberate even though
|
|
74
|
+
* `WastedWorkReport` names `totalGatedWastedBytes` as the headline. For BYTES the two
|
|
75
|
+
* are the same number, structurally: the modelled first-char guard is derived from the
|
|
76
|
+
* arm's first SET, which over-approximates what the arm can start with, so wherever the
|
|
77
|
+
* guard rejects, the arm's own leading terminal would have rejected at the same position
|
|
78
|
+
* having consumed nothing. Measured over four dialect grammars, zero arms differ (see the
|
|
79
|
+
* header of `choice-cost.ts`). What the guard removes is ATTEMPTS, not rescanned bytes —
|
|
80
|
+
* so `gatedAttempts`, `gatedFailures` and the inversion ranking DO read the gated columns,
|
|
81
|
+
* and only the byte totals are recorded from the interpreted one, where it is additionally
|
|
82
|
+
* the conservative UPPER bound of the two.
|
|
83
|
+
*
|
|
84
|
+
* That identity is not enforced anywhere it could be relied on silently: `checkWastedWork`
|
|
85
|
+
* judges the compiled column against this same band wherever the two columns part, so a
|
|
86
|
+
* divergence goes red instead of unnoticed.
|
|
87
|
+
*/
|
|
88
|
+
totals: Record<string, { corpusBytes: number; totalWastedBytes: number; instrumentedSites: number }>
|
|
89
|
+
/** Absolute per-site wasted bytes, keyed by `<corpus>::<siteKey>`. Interpreted column,
|
|
90
|
+
* for the reason given on `totals` above. */
|
|
91
|
+
sites: Record<string, number>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type WastedWorkPolicy = {
|
|
95
|
+
/**
|
|
96
|
+
* Maximum wasted bytes per corpus byte. A hard ceiling no rebaseline can waive.
|
|
97
|
+
* Omit for drift-only gating; a project should set it once it knows its own number.
|
|
98
|
+
*/
|
|
99
|
+
ceilingRatio?: number
|
|
100
|
+
/**
|
|
101
|
+
* Half-width of the band around each committed number, as a percentage. Default 1.
|
|
102
|
+
*
|
|
103
|
+
* SYMMETRIC: a measurement more than this ABOVE its baseline is a regression, and a
|
|
104
|
+
* measurement more than this BELOW it is an unbanked win. Both fail.
|
|
105
|
+
*
|
|
106
|
+
* This is not a noise allowance — the metric is a deterministic count of input bytes,
|
|
107
|
+
* so its noise floor is exactly zero and any non-zero value here is pure headroom.
|
|
108
|
+
* It exists only so incidental one-byte churn does not thrash the baseline in both
|
|
109
|
+
* directions. Set it as near zero as the corpus permits; `bench/choice-cost-guard.ts`
|
|
110
|
+
* runs at 0.1, matching `bench/size-guard.ts`'s ratchet slack.
|
|
111
|
+
*/
|
|
112
|
+
driftTolerancePct?: number
|
|
113
|
+
/**
|
|
114
|
+
* Fail on an ordering inversion — an arm that failed every one of its attempts while
|
|
115
|
+
* a later arm at the same site matched. Off by default: on an existing grammar this
|
|
116
|
+
* fires immediately, and a gate that is red on arrival for a pre-existing condition
|
|
117
|
+
* gets disabled rather than fixed. Turn it on once the backlog is empty, to keep it
|
|
118
|
+
* empty.
|
|
119
|
+
*/
|
|
120
|
+
failOnInversions?: boolean
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type GateBreach = {
|
|
124
|
+
/** `drift` is growth past the band; `shrank` is an improvement that was not banked.
|
|
125
|
+
* Both are failures, and the `detail` says which remedy applies. */
|
|
126
|
+
kind: 'ceiling' | 'drift' | 'shrank' | 'unbaselined' | 'stale' | 'inversion' | 'unmeasurable' | 'invalid-baseline'
|
|
127
|
+
key: string
|
|
128
|
+
/** One line. Says what happened and what number to look at — never advice. */
|
|
129
|
+
detail: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type GateVerdict = {
|
|
133
|
+
readonly schema: 'parseman.wasted-work-verdict/1'
|
|
134
|
+
ok: boolean
|
|
135
|
+
/** Ascending by (kind, key). Deterministic, so a verdict is diffable. */
|
|
136
|
+
breaches: readonly GateBreach[]
|
|
137
|
+
checkedCorpora: number
|
|
138
|
+
checkedSites: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const KIND_ORDER: GateBreach['kind'][] = [
|
|
142
|
+
'invalid-baseline', 'unmeasurable', 'ceiling', 'drift', 'shrank', 'inversion', 'unbaselined', 'stale',
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
const pct = (actual: number, base: number): number => base === 0 ? (actual === 0 ? 0 : Infinity) : (actual / base - 1) * 100
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Judge one or more measured corpora against a committed baseline.
|
|
149
|
+
*
|
|
150
|
+
* `reports` is keyed by corpus id — a grammar is usually gated over more than one body
|
|
151
|
+
* of input, and a single blended number would let a regression on one hide behind an
|
|
152
|
+
* improvement on another.
|
|
153
|
+
*/
|
|
154
|
+
export function checkWastedWork(
|
|
155
|
+
reports: Readonly<Record<string, WastedWorkReport>>,
|
|
156
|
+
baseline: unknown,
|
|
157
|
+
policy: WastedWorkPolicy = {},
|
|
158
|
+
): GateVerdict {
|
|
159
|
+
const breaches: GateBreach[] = []
|
|
160
|
+
const tolerance = policy.driftTolerancePct ?? 1
|
|
161
|
+
|
|
162
|
+
const add = (kind: GateBreach['kind'], key: string, detail: string): void => { breaches.push({ kind, key, detail }) }
|
|
163
|
+
|
|
164
|
+
const finish = (checkedCorpora: number, checkedSites: number): GateVerdict => {
|
|
165
|
+
breaches.sort((a, b) =>
|
|
166
|
+
KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind)
|
|
167
|
+
|| (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
168
|
+
return {
|
|
169
|
+
schema: 'parseman.wasted-work-verdict/1',
|
|
170
|
+
ok: breaches.length === 0,
|
|
171
|
+
breaches,
|
|
172
|
+
checkedCorpora,
|
|
173
|
+
checkedSites,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── the baseline must be a baseline ───────────────────────────────────────
|
|
178
|
+
const b = baseline as Partial<WastedWorkBaseline> | null | undefined
|
|
179
|
+
if (b === null || b === undefined || typeof b !== 'object') {
|
|
180
|
+
add('invalid-baseline', '<baseline>', 'no baseline supplied — a gate with no baseline measures nothing, and passing here is how a budget stops being enforced')
|
|
181
|
+
return finish(0, 0)
|
|
182
|
+
}
|
|
183
|
+
if (b.schema !== 'parseman.wasted-work-baseline/1') {
|
|
184
|
+
add('invalid-baseline', '<baseline>', `baseline schema is ${JSON.stringify(b.schema)}, expected "parseman.wasted-work-baseline/1"`)
|
|
185
|
+
return finish(0, 0)
|
|
186
|
+
}
|
|
187
|
+
// `typeof null === 'object'`, so a `"totals": null` would pass a bare `typeof` test
|
|
188
|
+
// and reach `Object.keys` below as an uncaught TypeError. A baseline of the wrong
|
|
189
|
+
// shape is a documented `invalid-baseline` breach, not a crash.
|
|
190
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
191
|
+
typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
192
|
+
if (!isRecord(b.totals) || !isRecord(b.sites)) {
|
|
193
|
+
add('invalid-baseline', '<baseline>', 'baseline `totals` or `sites` is missing or is not an object')
|
|
194
|
+
return finish(0, 0)
|
|
195
|
+
}
|
|
196
|
+
if (Object.keys(b.totals).length === 0) {
|
|
197
|
+
add('invalid-baseline', '<baseline>', 'baseline records ZERO corpora')
|
|
198
|
+
return finish(0, 0)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// EVERY recorded value must be a finite number, checked before any of them is read.
|
|
202
|
+
// `baseline` is `unknown` — parsed JSON off disk — and a non-numeric value does not
|
|
203
|
+
// announce itself at the comparison: `pct` returns NaN, and `NaN > tolerance` and
|
|
204
|
+
// `NaN < -tolerance` are BOTH false, so the entry yields no breach and the gate
|
|
205
|
+
// reports a pass over a number it never compared. That is the fail-open this module
|
|
206
|
+
// exists to not be, and it is silent, so it is caught at the shape check instead.
|
|
207
|
+
const finite = (v: unknown): boolean => typeof v === 'number' && Number.isFinite(v)
|
|
208
|
+
for (const [id, t] of Object.entries(b.totals).sort(([x], [y]) => (x < y ? -1 : 1))) {
|
|
209
|
+
if (!isRecord(t) || !finite(t.corpusBytes) || !finite(t.totalWastedBytes)) {
|
|
210
|
+
add('invalid-baseline', id, 'baseline entry has no finite numeric `corpusBytes` / `totalWastedBytes` — the comparison against it would silently produce NaN and no breach')
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const [key, v] of Object.entries(b.sites).sort(([x], [y]) => (x < y ? -1 : 1))) {
|
|
214
|
+
if (!finite(v)) add('invalid-baseline', key, 'baseline site value is not a finite number — the comparison against it would silently produce NaN and no breach')
|
|
215
|
+
}
|
|
216
|
+
if (breaches.length > 0) return finish(0, 0)
|
|
217
|
+
if (policy.ceilingRatio !== undefined) {
|
|
218
|
+
for (const [id, t] of Object.entries(b.totals).sort(([x], [y]) => (x < y ? -1 : 1))) {
|
|
219
|
+
if (t.corpusBytes > 0 && t.totalWastedBytes / t.corpusBytes > policy.ceilingRatio) {
|
|
220
|
+
add('invalid-baseline', id,
|
|
221
|
+
`baseline records ${t.totalWastedBytes} wasted / ${t.corpusBytes} corpus bytes = `
|
|
222
|
+
+ `${(t.totalWastedBytes / t.corpusBytes).toFixed(3)}x, above the ${policy.ceilingRatio}x ceiling — `
|
|
223
|
+
+ 'a rebaseline cannot accept an over-ceiling number')
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (breaches.length > 0) return finish(0, 0)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (Object.keys(reports).length === 0) {
|
|
230
|
+
add('unmeasurable', '<reports>', 'no corpus was measured — refusing to report a pass on an empty measurement')
|
|
231
|
+
return finish(0, 0)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let checkedSites = 0
|
|
235
|
+
|
|
236
|
+
for (const id of Object.keys(reports).sort()) {
|
|
237
|
+
const r = reports[id]!
|
|
238
|
+
|
|
239
|
+
// ── could this report have measured anything? ───────────────────────────
|
|
240
|
+
if (r.corpusFiles === 0) { add('unmeasurable', id, 'report covers ZERO corpus files'); continue }
|
|
241
|
+
if (r.instrumentedSites === 0) { add('unmeasurable', id, 'report instrumented ZERO choice sites — the total below would be zero for a reason unrelated to the grammar'); continue }
|
|
242
|
+
if (r.unresolvedRoots.length > 0) {
|
|
243
|
+
add('unmeasurable', id,
|
|
244
|
+
`${r.unresolvedRoots.length} rule(s) could not be resolved and were NOT walked `
|
|
245
|
+
+ `(${r.unresolvedRoots.slice(0, 4).join(', ')}${r.unresolvedRoots.length > 4 ? ', …' : ''}) — `
|
|
246
|
+
+ 'the total is a lower bound over an unknown fraction of the grammar')
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const base = b.totals[id]
|
|
251
|
+
if (base === undefined) {
|
|
252
|
+
add('unbaselined', id, `measured but absent from the baseline (${r.totalWastedBytes} wasted bytes over ${r.corpusBytes})`)
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (policy.ceilingRatio !== undefined && r.corpusBytes > 0) {
|
|
257
|
+
const ratio = r.totalWastedBytes / r.corpusBytes
|
|
258
|
+
if (ratio > policy.ceilingRatio) {
|
|
259
|
+
add('ceiling', id, `${r.totalWastedBytes} wasted / ${r.corpusBytes} corpus bytes = ${ratio.toFixed(3)}x, over the ${policy.ceilingRatio}x ceiling`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The corpus itself is part of the measurement. If it changed, the totals are
|
|
264
|
+
// not comparable and a "no drift" verdict would be meaningless.
|
|
265
|
+
if (r.corpusBytes !== base.corpusBytes) {
|
|
266
|
+
add('unmeasurable', id, `corpus changed: baseline ${base.corpusBytes} bytes, measured ${r.corpusBytes} — totals are not comparable until the baseline is refreshed`)
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Two-sided. Growth is a regression; an unbanked win is next quarter's regression
|
|
271
|
+
// budget. Same band, same rebaseline, different sentence.
|
|
272
|
+
const d = pct(r.totalWastedBytes, base.totalWastedBytes)
|
|
273
|
+
if (d > tolerance) {
|
|
274
|
+
add('drift', id, `total wasted ${base.totalWastedBytes} -> ${r.totalWastedBytes} bytes (${d >= 0 ? '+' : ''}${d.toFixed(2)}%, band ±${tolerance}%)`)
|
|
275
|
+
} else if (d < -tolerance) {
|
|
276
|
+
add('shrank', id, `total wasted ${base.totalWastedBytes} -> ${r.totalWastedBytes} bytes (${d.toFixed(2)}%, band ±${tolerance}%) — BANK THE WIN: rebaseline so the ${base.totalWastedBytes - r.totalWastedBytes} bytes you just saved cannot become headroom for the next regression`)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// AND THE COMPILED COLUMN IS HELD TO THE SAME BAND. See `WastedWorkBaseline` for
|
|
280
|
+
// why the INTERPRETED column is the one recorded. While the two agree — which is
|
|
281
|
+
// structural, not a property of these corpora — this adds nothing, so it is only
|
|
282
|
+
// evaluated where they part, and there it says so rather than judging one column
|
|
283
|
+
// and reporting the other.
|
|
284
|
+
if (r.totalGatedWastedBytes !== r.totalWastedBytes) {
|
|
285
|
+
const dg = pct(r.totalGatedWastedBytes, base.totalWastedBytes)
|
|
286
|
+
if (dg > tolerance) {
|
|
287
|
+
add('drift', id, `compiled-model total wasted ${base.totalWastedBytes} -> ${r.totalGatedWastedBytes} bytes (${dg >= 0 ? '+' : ''}${dg.toFixed(2)}%, band ±${tolerance}%) — the compiled and interpreted columns have PARTED (${r.totalGatedWastedBytes} vs ${r.totalWastedBytes}); the baseline records the interpreted one`)
|
|
288
|
+
} else if (dg < -tolerance) {
|
|
289
|
+
add('shrank', id, `compiled-model total wasted ${base.totalWastedBytes} -> ${r.totalGatedWastedBytes} bytes (${dg.toFixed(2)}%, band ±${tolerance}%) — the compiled and interpreted columns have PARTED (${r.totalGatedWastedBytes} vs ${r.totalWastedBytes}); the baseline records the interpreted one`)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
for (const s of r.sites) {
|
|
294
|
+
const key = `${id}::${s.siteKey}`
|
|
295
|
+
const bs = b.sites[key]
|
|
296
|
+
if (bs === undefined) {
|
|
297
|
+
if (s.wastedBytes > 0) add('unbaselined', key, `site measured ${s.wastedBytes} wasted bytes but has no baseline entry`)
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
checkedSites++
|
|
301
|
+
const sd = pct(s.wastedBytes, bs)
|
|
302
|
+
if (sd > tolerance) add('drift', key, `${bs} -> ${s.wastedBytes} bytes (${sd >= 0 ? '+' : ''}${sd.toFixed(2)}%, band ±${tolerance}%)`)
|
|
303
|
+
else if (sd < -tolerance) add('shrank', key, `${bs} -> ${s.wastedBytes} bytes (${sd.toFixed(2)}%, band ±${tolerance}%) — BANK THE WIN: rebaseline, or this site's ${bs - s.wastedBytes} recovered bytes become budget`)
|
|
304
|
+
|
|
305
|
+
if (s.gatedWastedBytes !== s.wastedBytes) {
|
|
306
|
+
const sg = pct(s.gatedWastedBytes, bs)
|
|
307
|
+
if (sg > tolerance) add('drift', key, `compiled-model ${bs} -> ${s.gatedWastedBytes} bytes (${sg >= 0 ? '+' : ''}${sg.toFixed(2)}%, band ±${tolerance}%) — columns PARTED (${s.gatedWastedBytes} vs ${s.wastedBytes})`)
|
|
308
|
+
else if (sg < -tolerance) add('shrank', key, `compiled-model ${bs} -> ${s.gatedWastedBytes} bytes (${sg.toFixed(2)}%, band ±${tolerance}%) — columns PARTED (${s.gatedWastedBytes} vs ${s.wastedBytes})`)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (policy.failOnInversions === true) {
|
|
313
|
+
for (const inv of r.inversions) {
|
|
314
|
+
add('inversion', `${id}::${inv.siteKey}#${inv.arm}`,
|
|
315
|
+
// The GATED columns, because that is what `inversions` was computed from
|
|
316
|
+
// (choice-cost.ts filters on `gatedAttempts`/`gatedFailures`). Printing the
|
|
317
|
+
// interpreted counts here would state "failed all N" with an N the condition
|
|
318
|
+
// never tested — the two differ wherever `firstCharGated` is true.
|
|
319
|
+
`arm ${inv.arm} (${inv.label}) failed all ${inv.gatedAttempts} compiled entries while a later arm matched; ${inv.gatedWastedBytes} bytes re-scanned`)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const measuredKeys = new Set<string>()
|
|
325
|
+
for (const id of Object.keys(reports)) for (const s of reports[id]!.sites) measuredKeys.add(`${id}::${s.siteKey}`)
|
|
326
|
+
for (const key of Object.keys(b.sites).sort()) {
|
|
327
|
+
if (!measuredKeys.has(key)) {
|
|
328
|
+
add('stale', key, 'baselined site matches no measured site — a rule was renamed or removed, and ignoring it silently shrinks the gated set')
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const id of Object.keys(b.totals).sort()) {
|
|
332
|
+
if (reports[id] === undefined) add('stale', id, 'baselined corpus was not measured')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return finish(Object.keys(reports).length, checkedSites)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Build the baseline a passing measurement would record.
|
|
340
|
+
*
|
|
341
|
+
* Deliberately NOT called by the gate. It exists so a rebaseline is one explicit
|
|
342
|
+
* command whose output is a file in the diff — the reviewable record — rather than
|
|
343
|
+
* something the gate can do to itself on a red run.
|
|
344
|
+
*/
|
|
345
|
+
export function buildWastedWorkBaseline(
|
|
346
|
+
reports: Readonly<Record<string, WastedWorkReport>>,
|
|
347
|
+
meta: { gitRev: string; updatedAt: string },
|
|
348
|
+
): WastedWorkBaseline {
|
|
349
|
+
const totals: WastedWorkBaseline['totals'] = {}
|
|
350
|
+
const sites: WastedWorkBaseline['sites'] = {}
|
|
351
|
+
for (const id of Object.keys(reports).sort()) {
|
|
352
|
+
const r = reports[id]!
|
|
353
|
+
totals[id] = { corpusBytes: r.corpusBytes, totalWastedBytes: r.totalWastedBytes, instrumentedSites: r.instrumentedSites }
|
|
354
|
+
for (const s of [...r.sites].sort((a, c) => (a.siteKey < c.siteKey ? -1 : 1))) {
|
|
355
|
+
if (s.wastedBytes > 0) sites[`${id}::${s.siteKey}`] = s.wastedBytes
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { schema: 'parseman.wasted-work-baseline/1', gitRev: meta.gitRev, updatedAt: meta.updatedAt, totals, sites }
|
|
359
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHOICE COST — the human layer.
|
|
3
|
+
* ==============================
|
|
4
|
+
*
|
|
5
|
+
* Three layers, not two:
|
|
6
|
+
*
|
|
7
|
+
* ANALYSIS src/analysis/choice-cost.ts — what the sites are and what they cost.
|
|
8
|
+
* POLICY bench/choice-cost-guard.ts — which of those numbers fail a build.
|
|
9
|
+
* RENDERING this file — how a person reads either of them.
|
|
10
|
+
*
|
|
11
|
+
* The structured report is the source of truth. Nothing here computes a finding, and
|
|
12
|
+
* nothing here decides pass or fail; a gate consumes the data and never this text. If
|
|
13
|
+
* a number appears below that is not in the report, that is a bug in this file.
|
|
14
|
+
*
|
|
15
|
+
* WHAT A PARSER GENERATOR CAN SHOW THAT A COMPILER CANNOT
|
|
16
|
+
* -------------------------------------------------------
|
|
17
|
+
* A type error has one source to point at. A wasted-work finding has TWO, and relating
|
|
18
|
+
* them is the whole diagnostic:
|
|
19
|
+
*
|
|
20
|
+
* - the GRAMMAR site — the ordered choice whose arm ordering costs the time
|
|
21
|
+
* - the COST — measured, over a real corpus, per alternative
|
|
22
|
+
*
|
|
23
|
+
* So the ordering is printed WITH its measured cost beside each arm, which makes the
|
|
24
|
+
* fix self-evident without a paragraph explaining it:
|
|
25
|
+
*
|
|
26
|
+
* StylesheetAtRule › dispatch[0] 1.31 MB rescanned
|
|
27
|
+
* 0 RoutedAtRuleStatement failed 4,182 / 4,271 1.31 MB
|
|
28
|
+
* 1 RoutedLayerBlock matched 4,182
|
|
29
|
+
*
|
|
30
|
+
* Reading that, nobody needs to be told what to do.
|
|
31
|
+
*
|
|
32
|
+
* QUIET BY DEFAULT
|
|
33
|
+
* ----------------
|
|
34
|
+
* Nothing in this module prints. It returns strings. There is no console call and no
|
|
35
|
+
* process-wide default-on channel, deliberately: importing `examples/css/parser.ts`
|
|
36
|
+
* already emits roughly sixty lines of gating advice before any user code runs, and a
|
|
37
|
+
* beautiful diagnostic that nobody reads because it is buried in build noise is not
|
|
38
|
+
* beautiful. This one is available on request — from the CLI gate, or by calling it —
|
|
39
|
+
* and silent otherwise.
|
|
40
|
+
*
|
|
41
|
+
* DETERMINISM BINDS THE RENDERING TOO
|
|
42
|
+
* -----------------------------------
|
|
43
|
+
* The rendering is diffable: it preserves the report's ordering, contains no timings,
|
|
44
|
+
* no dates, no absolute paths, and colour is opt-in rather than auto-detected, so the
|
|
45
|
+
* same report always renders to the same bytes. A renderer that sniffed `isTTY` would
|
|
46
|
+
* produce two different outputs from one report and break exactly that.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import type {
|
|
50
|
+
ChoiceInventoryReport, ChoiceInventoryEntry, WastedWorkReport, ArmDeclineReason, SiteDeclineReason,
|
|
51
|
+
} from './choice-cost.ts'
|
|
52
|
+
import { groupDigits } from './format-number.ts'
|
|
53
|
+
|
|
54
|
+
export type RenderOptions = {
|
|
55
|
+
/** Rows to show. Default 20. The report always holds all of them. */
|
|
56
|
+
limit?: number
|
|
57
|
+
/**
|
|
58
|
+
* ANSI colour. Default false — NOT auto-detected from `isTTY`, because a renderer
|
|
59
|
+
* whose bytes depend on where it is piped cannot be diffed or snapshotted.
|
|
60
|
+
*/
|
|
61
|
+
color?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── formatting primitives ────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Deterministic thousands grouping. `toLocaleString()` is locale-dependent and
|
|
68
|
+
* would make the output differ between machines — the one thing a gateable
|
|
69
|
+
* rendering cannot do.
|
|
70
|
+
*
|
|
71
|
+
* DEFINED IN `./format-number.ts` and re-exported here because this module's own
|
|
72
|
+
* callers import it from here. It was a byte-identical second copy: `./terminal.ts`
|
|
73
|
+
* declared one, this file declared another, and the two were one edit away from
|
|
74
|
+
* disagreeing about how a report reads on two different screens.
|
|
75
|
+
*/
|
|
76
|
+
export { groupDigits }
|
|
77
|
+
|
|
78
|
+
/** Byte counts at a glance. Binary units, fixed to one decimal, so 1,376,256 renders
|
|
79
|
+
* as `1.3 MB` on every machine. */
|
|
80
|
+
export function bytes(n: number): string {
|
|
81
|
+
if (n < 1024) return `${groupDigits(n)} B`
|
|
82
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} kB`
|
|
83
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Written as \u001B escapes, never as raw ESC bytes. A raw control byte makes the
|
|
87
|
+
// whole file BINARY to git: `git diff --numstat` shows `- -`, GitHub declines to
|
|
88
|
+
// render it, and `grep -rn` skips it SILENTLY — no output and exit 0. Enforced by
|
|
89
|
+
// `pnpm check:control-bytes`.
|
|
90
|
+
const ESC = '\u001B'
|
|
91
|
+
const ANSI = {
|
|
92
|
+
dim: `${ESC}[2m`, bold: `${ESC}[1m`, red: `${ESC}[31m`,
|
|
93
|
+
yellow: `${ESC}[33m`, cyan: `${ESC}[36m`, reset: `${ESC}[0m`,
|
|
94
|
+
} as const
|
|
95
|
+
|
|
96
|
+
const paint = (on: boolean) => (code: keyof typeof ANSI, s: string): string =>
|
|
97
|
+
on ? `${ANSI[code]}${s}${ANSI.reset}` : s
|
|
98
|
+
|
|
99
|
+
const pad = (s: string, w: number): string => s.length >= w ? s : s + ' '.repeat(w - s.length)
|
|
100
|
+
const padStart = (s: string, w: number): string => s.length >= w ? s : ' '.repeat(w - s.length) + s
|
|
101
|
+
|
|
102
|
+
// ── why a site declined, in one line each ────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Every entry lands in exactly one of two states — ACTIONABLE (here is the rewrite) or
|
|
106
|
+
* LOCATED (here is the site and the precise reason no rewrite can be offered). There
|
|
107
|
+
* is no third "consider refactoring" state; a sentence of advice with no location and
|
|
108
|
+
* no rewrite is what this table exists to replace.
|
|
109
|
+
*/
|
|
110
|
+
const ARM_DECLINE_LINE: Record<ArmDeclineReason, string> = {
|
|
111
|
+
'not-a-sequence':
|
|
112
|
+
'arm is not a sequence, so there is no leading term to lift out',
|
|
113
|
+
'sequence-shorter-than-2':
|
|
114
|
+
'arm is a one-term sequence; lifting its only term would leave an empty arm',
|
|
115
|
+
'lead-case-insensitive-literal':
|
|
116
|
+
'leading literal is case-insensitive, so the matched text can differ from the literal — a lifted prefix would carry the wrong string',
|
|
117
|
+
'lead-not-concrete-terminal':
|
|
118
|
+
'leading term is not a bare literal or regex; lifting it would change the arm’s value or capture shape',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const SITE_DECLINE_LINE: Record<SiteDeclineReason, string> = {
|
|
122
|
+
'disjoint-dispatch': 'first-char dispatch already selects one arm — nothing is re-scanned',
|
|
123
|
+
'gated-arms': 'an arm carries a runtime gate; per-arm predicates are incompatible with factoring',
|
|
124
|
+
'strategy-preempted': 'a stronger non-backtracking strategy already applies',
|
|
125
|
+
'fewer-than-two-arms': 'fewer than two arms',
|
|
126
|
+
'arms-not-factorable': 'at least one arm cannot contribute a leading term (see below)',
|
|
127
|
+
'leads-differ': 'the arms do not all begin with the same term',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The combinator source a left-factoring would produce, ready to paste.
|
|
132
|
+
*
|
|
133
|
+
* Offered ONLY for the shape where it is mechanical and total: every arm of the choice
|
|
134
|
+
* in one group. A partial group needs the author to decide which arms move and in what
|
|
135
|
+
* order — an ordering decision that changes what the grammar accepts, which no
|
|
136
|
+
* generated rewrite may make silently.
|
|
137
|
+
*
|
|
138
|
+
* This is a PREVIEW. It is not applied, and — until the digest-verification loop
|
|
139
|
+
* described in `docs/` is wired — it is not yet proven behaviour-preserving, so it is
|
|
140
|
+
* labelled a candidate rather than offered as a fix. An unverified rewrite presented
|
|
141
|
+
* as verified is worse than no suggestion at all.
|
|
142
|
+
*/
|
|
143
|
+
export function leftFactorPreview(e: ChoiceInventoryEntry): string | null {
|
|
144
|
+
if (e.factored) return null
|
|
145
|
+
if (e.groups.length !== 1) return null
|
|
146
|
+
const g = e.groups[0]!
|
|
147
|
+
if (g.members.length !== e.arity) return null
|
|
148
|
+
const tails = g.members.map(i => `sequence(…arm[${i}] tail…)`).join(',\n ')
|
|
149
|
+
return `sequence(\n ${g.render === '' ? '<prefix>' : (g.render.startsWith('/') ? `regex(${g.render})` : `literal(${g.render})`)},\n choice(\n ${tails},\n ),\n)`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── static inventory rendering ───────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
export function renderChoiceInventory(r: ChoiceInventoryReport, opts: RenderOptions = {}): string {
|
|
155
|
+
const c = paint(opts.color === true)
|
|
156
|
+
const limit = opts.limit ?? 20
|
|
157
|
+
const out: string[] = []
|
|
158
|
+
|
|
159
|
+
out.push(c('bold', 'shared-prefix inventory'))
|
|
160
|
+
out.push(c('dim', ' Static grammar shape only. A site listed here may already cost nothing at runtime:'))
|
|
161
|
+
out.push(c('dim', ' compiled output gates each arm on its first character. Rank with profileWastedWork().'))
|
|
162
|
+
out.push(` ${groupDigits(r.rules)} rules, ${groupDigits(r.choiceSites)} choice sites`)
|
|
163
|
+
out.push(` ${groupDigits(r.factoredSites)} left-factored by the compiler`)
|
|
164
|
+
out.push(` ${groupDigits(r.backlogSites)} sites where alternatives share a leading term and the compiler DECLINED (${groupDigits(r.backlogArms)} arms)`)
|
|
165
|
+
|
|
166
|
+
const backlog = r.entries
|
|
167
|
+
.filter(e => !e.factored && e.groups.length > 0
|
|
168
|
+
&& e.declineReason !== 'disjoint-dispatch' && e.declineReason !== 'strategy-preempted')
|
|
169
|
+
.sort((a, b) => b.unfactoredArms - a.unfactoredArms || (a.siteKey < b.siteKey ? -1 : 1))
|
|
170
|
+
|
|
171
|
+
if (backlog.length === 0) {
|
|
172
|
+
out.push('')
|
|
173
|
+
out.push(' no declined shared prefixes.')
|
|
174
|
+
return out.join('\n')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
out.push('')
|
|
178
|
+
for (const e of backlog.slice(0, limit)) {
|
|
179
|
+
out.push(` ${c('cyan', e.siteKey)} ${c('dim', `${e.arity} arms · ${e.strategy}`)}`)
|
|
180
|
+
for (const g of e.groups) {
|
|
181
|
+
out.push(` arms ${g.members.join(', ')} all begin with ${c('bold', g.render)}`)
|
|
182
|
+
}
|
|
183
|
+
out.push(` ${c('yellow', 'declined')}: ${SITE_DECLINE_LINE[e.declineReason!]}`)
|
|
184
|
+
for (const a of e.armDeclines) {
|
|
185
|
+
out.push(` arm[${a.arm}] — ${ARM_DECLINE_LINE[a.reason]}`)
|
|
186
|
+
}
|
|
187
|
+
const fix = leftFactorPreview(e)
|
|
188
|
+
if (fix !== null) {
|
|
189
|
+
out.push(` ${c('dim', 'candidate rewrite (preview — not applied, not yet digest-verified):')}`)
|
|
190
|
+
for (const line of fix.split('\n')) out.push(` ${line}`)
|
|
191
|
+
}
|
|
192
|
+
out.push('')
|
|
193
|
+
}
|
|
194
|
+
if (backlog.length > limit) out.push(` … and ${groupDigits(backlog.length - limit)} more (the report holds all of them)`)
|
|
195
|
+
return out.join('\n')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── wasted-work rendering ────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The ranked list, with each top site expanded into its arm ordering.
|
|
202
|
+
*
|
|
203
|
+
* The expansion is the point. A ranked list of site keys tells you WHERE; the arm
|
|
204
|
+
* breakdown beside it tells you WHY, and "arm 0 failed 4,182 of 4,271 times" is an
|
|
205
|
+
* argument for moving arm 0 that needs no prose attached.
|
|
206
|
+
*/
|
|
207
|
+
export function renderWastedWork(r: WastedWorkReport, opts: RenderOptions = {}): string {
|
|
208
|
+
const c = paint(opts.color === true)
|
|
209
|
+
const limit = opts.limit ?? 20
|
|
210
|
+
const out: string[] = []
|
|
211
|
+
const removed = r.arms.reduce((n, a) => n + (a.attempts - a.gatedAttempts), 0)
|
|
212
|
+
const totalAttempts = r.arms.reduce((n, a) => n + a.attempts, 0)
|
|
213
|
+
|
|
214
|
+
out.push(c('bold', 'wasted work — input bytes re-scanned after a failed alternative'))
|
|
215
|
+
out.push(...interpretedBanner(c, removed, totalAttempts))
|
|
216
|
+
out.push(` corpus: ${groupDigits(r.corpusFiles)} files, ${bytes(r.corpusBytes)} (${groupDigits(r.parsedOk)} parsed, ${groupDigits(r.parsedFailed)} failed)`)
|
|
217
|
+
out.push(` sites: ${groupDigits(r.instrumentedSites)} instrumented, ${groupDigits(r.uninstrumentableSites)} not instrumentable`)
|
|
218
|
+
if (r.unresolvedRoots.length > 0) {
|
|
219
|
+
out.push(` ${c('yellow', 'PARTIAL')}: ${groupDigits(r.unresolvedRoots.length)} rule(s) could not be resolved and were NOT walked — the total below is a lower bound`)
|
|
220
|
+
}
|
|
221
|
+
out.push(` total: ${c('bold', bytes(r.totalGatedWastedBytes))} re-scanned` +
|
|
222
|
+
(r.corpusBytes > 0 ? ` — ${(r.totalGatedWastedBytes / r.corpusBytes).toFixed(2)}x the corpus` : '') +
|
|
223
|
+
(r.totalWastedBytes === r.totalGatedWastedBytes ? '' : ` (interpreted: ${bytes(r.totalWastedBytes)})`))
|
|
224
|
+
|
|
225
|
+
const ranked = r.sites.filter(s => s.gatedWastedBytes > 0 || s.wastedBytes > 0)
|
|
226
|
+
if (ranked.length === 0) {
|
|
227
|
+
out.push('')
|
|
228
|
+
out.push(' no alternative failed on this corpus.')
|
|
229
|
+
return out.join('\n')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
out.push('')
|
|
233
|
+
out.push(c('dim', ' per arm: "entered" counts what the COMPILED parser reaches; the parenthesised'))
|
|
234
|
+
out.push(c('dim', ' number is the interpreted count, shown only where the first-char gate differs.'))
|
|
235
|
+
out.push('')
|
|
236
|
+
const w = Math.min(52, Math.max(...ranked.slice(0, limit).map(s => s.siteKey.length)))
|
|
237
|
+
for (const s of ranked.slice(0, limit)) {
|
|
238
|
+
const share = r.totalGatedWastedBytes > 0 ? ` ${padStart((100 * s.gatedWastedBytes / r.totalGatedWastedBytes).toFixed(1) + '%', 7)}` : ''
|
|
239
|
+
out.push(` ${c('cyan', pad(s.siteKey.slice(0, w), w))} ${padStart(bytes(s.gatedWastedBytes), 10)}${c('dim', share)}`)
|
|
240
|
+
const arms = r.arms.filter(a => a.siteKey === s.siteKey).sort((a, b) => a.arm - b.arm)
|
|
241
|
+
for (const a of arms) {
|
|
242
|
+
const entered = a.gatedAttempts === a.attempts
|
|
243
|
+
? groupDigits(a.attempts)
|
|
244
|
+
: `${groupDigits(a.gatedAttempts)} (${groupDigits(a.attempts)} interp)`
|
|
245
|
+
// A 100%-failure arm is the actionable shape, but only if the SHIPPED parser
|
|
246
|
+
// enters it — so the verdict reads the gated columns.
|
|
247
|
+
// PAD FIRST, PAINT SECOND. `pad` measures `s.length`, so padding a string that
|
|
248
|
+
// already carries ANSI escapes spends the column width on invisible bytes and the
|
|
249
|
+
// arm table loses its alignment in colour mode. Every other cell here does it in
|
|
250
|
+
// this order; this one is not allowed to be the exception.
|
|
251
|
+
const verdict: { text: string; color: 'dim' | 'red' | null } = a.gatedAttempts === 0
|
|
252
|
+
? { text: 'never entered when compiled', color: 'dim' }
|
|
253
|
+
: a.gatedFailures === a.gatedAttempts
|
|
254
|
+
? { text: `failed ALL ${entered}`, color: 'red' }
|
|
255
|
+
: a.gatedFailures === 0
|
|
256
|
+
? { text: `matched ${entered}`, color: 'dim' }
|
|
257
|
+
: { text: `failed ${groupDigits(a.gatedFailures)} / ${entered}`, color: null }
|
|
258
|
+
const cell = verdict.color === null ? pad(verdict.text, 34) : c(verdict.color, pad(verdict.text, 34))
|
|
259
|
+
out.push(` ${padStart(String(a.arm), 2)} ${pad(a.label.slice(0, 30), 30)} ${cell} ${a.gatedWastedBytes > 0 ? padStart(bytes(a.gatedWastedBytes), 10) : ''}`)
|
|
260
|
+
}
|
|
261
|
+
out.push('')
|
|
262
|
+
}
|
|
263
|
+
if (ranked.length > limit) out.push(` … and ${groupDigits(ranked.length - limit)} more sites (the report holds all of them)`)
|
|
264
|
+
|
|
265
|
+
if (r.inversions.length > 0) {
|
|
266
|
+
out.push(c('bold', ' ordering inversions — arm failed EVERY compiled entry while a later arm matched'))
|
|
267
|
+
out.push(c('dim', ' ranked by entries, not bytes: an arm can fail 100% of the time and re-scan nothing.'))
|
|
268
|
+
for (const a of r.inversions.slice(0, limit)) {
|
|
269
|
+
out.push(` ${c('cyan', pad(a.siteKey.slice(0, w), w))} arm ${padStart(String(a.arm), 2)} ${pad(a.label.slice(0, 26), 26)} ${padStart(groupDigits(a.gatedAttempts), 8)} entries ${a.gatedWastedBytes > 0 ? padStart(bytes(a.gatedWastedBytes), 10) : padStart('0 B', 10)}`)
|
|
270
|
+
}
|
|
271
|
+
if (r.inversions.length > limit) out.push(` … and ${groupDigits(r.inversions.length - limit)} more`)
|
|
272
|
+
}
|
|
273
|
+
return out.join('\n')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The interpreted-vs-compiled warning, on EVERY rendered report.
|
|
278
|
+
*
|
|
279
|
+
* Not a footnote and not a docs link. A consumer who reads only the ranking must be
|
|
280
|
+
* told, in the ranking, that first-set gating — the largest parse lever this project
|
|
281
|
+
* has — is modelled rather than measured, or they will chase arms codegen already
|
|
282
|
+
* skips for free. The measured share of removed entries is included because "some
|
|
283
|
+
* attempts are gated" is ignorable and "78% of them are" is not.
|
|
284
|
+
*/
|
|
285
|
+
function interpretedBanner(c: (k: 'dim' | 'bold' | 'red' | 'yellow' | 'cyan', s: string) => string, removed: number, total: number): string[] {
|
|
286
|
+
const share = total > 0 ? ` — ${(100 * removed / total).toFixed(0)}% of interpreted arm entries` : ''
|
|
287
|
+
return [
|
|
288
|
+
c('yellow', ' MEASURED IN THE INTERPRETER. Compiled output gates each arm on its first'),
|
|
289
|
+
c('yellow', ` character; ${groupDigits(removed)} entries below never happen in the shipped parser${share}.`),
|
|
290
|
+
c('dim', ' Counts shown are the modelled COMPILED ones. Byte totals are unaffected by the'),
|
|
291
|
+
c('dim', ' gate (a gated-out arm consumes nothing); entry counts and failure RATES are not.'),
|
|
292
|
+
]
|
|
293
|
+
}
|