@pyreon/compiler 0.22.0 → 0.24.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/src/lpih.ts ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Live Program Inlay Hints (LPIH) — merge runtime fire data onto static
3
+ * Reactivity-Lens findings.
4
+ *
5
+ * This is a PURE function. The runtime side (`@pyreon/reactivity`)
6
+ * captures source locations at signal/computed/effect creation and emits
7
+ * fire counts via `getFireSummaries()`. The editor/LSP side calls
8
+ * `analyzeReactivity()` to get static findings. This module bridges them:
9
+ * given findings + fires, produces enriched findings whose `detail` field
10
+ * carries the live fire count.
11
+ *
12
+ * No I/O, no devtools dependency. The LSP transport is the consumer's
13
+ * responsibility (read fires from a cache file, an IPC bridge, or
14
+ * the editor's devtools panel — whatever the editor extension wants).
15
+ *
16
+ * ## The category
17
+ *
18
+ * Editors today show STATIC errors, types, and lint warnings at the
19
+ * cursor. They do NOT show LIVE program data — "this signal fires 240×
20
+ * per second", "this effect re-runs 3× per render", "this computed has
21
+ * 12 downstream subscribers". That data lives in a separate devtools
22
+ * panel that the developer has to context-switch to.
23
+ *
24
+ * LPIH closes that gap: live runtime data appears AT THE SOURCE LINE
25
+ * via LSP inlay hints, like a type annotation or a TypeScript error.
26
+ * No category like this exists for any reactive framework today.
27
+ *
28
+ * @example
29
+ * import { analyzeReactivity, mergeFireDataIntoFindings } from '@pyreon/compiler'
30
+ * import { getFireSummaries } from '@pyreon/reactivity'
31
+ *
32
+ * const code = `const count = signal(0)\nreturn <div>{count()}</div>`
33
+ * const { findings } = analyzeReactivity(code, 'app.tsx')
34
+ * const fires = getFireSummaries().map(s => ({
35
+ * file: s.loc.file, line: s.loc.line, count: s.count, kind: s.kind,
36
+ * }))
37
+ * const enriched = mergeFireDataIntoFindings(findings, fires, 'app.tsx')
38
+ * // enriched[0].detail might now be "live — signal fired 240×"
39
+ */
40
+
41
+ import type { ReactivityFinding, ReactivityFindingKind } from './reactivity-lens'
42
+
43
+ /**
44
+ * Runtime fire data carried into the merge function. Shape mirrors
45
+ * `@pyreon/reactivity`'s `FireSummary` but is duplicated here to keep
46
+ * `@pyreon/compiler` free of a runtime-package import. The consumer
47
+ * adapts the shape at the call site.
48
+ */
49
+ export interface LPIHFireDatum {
50
+ /** Source file path captured from `new Error().stack`. */
51
+ file: string
52
+ /** 1-based line number (V8 stack format). */
53
+ line: number
54
+ /** Total fires recorded at this location. */
55
+ count: number
56
+ /** `performance.now()` of most recent fire, or null. */
57
+ lastFire?: number | null | undefined
58
+ /** Node kind that fired (signal / derived / effect). */
59
+ kind?: 'signal' | 'derived' | 'effect' | undefined
60
+ /**
61
+ * Exponentially-decayed fire rate, fires/sec (1s time constant). 0
62
+ * when the node has been idle longer than several time constants.
63
+ * Used by the default formatter to add a "12/s" suffix when active.
64
+ * See `@pyreon/reactivity`'s `FireSummary.rate1s` for the math.
65
+ */
66
+ rate1s?: number | undefined
67
+ }
68
+
69
+ /** Options for `mergeFireDataIntoFindings`. */
70
+ export interface LPIHMergeOptions {
71
+ /**
72
+ * Optional file-path normalizer. Used for both the analyzed source
73
+ * file and each fire's `file` field. Useful when fires come from
74
+ * runtime stacks (absolute paths) but the source file is identified
75
+ * relative (e.g. workspace-rooted). Defaults to identity.
76
+ */
77
+ normalizeFile?: (path: string) => string
78
+ /**
79
+ * Optional formatter for the enriched detail. Receives the original
80
+ * detail + the matched fire datum. Defaults to:
81
+ * `${detail} — ${kind ? kind + ' ' : ''}fired ${count}×`
82
+ */
83
+ formatDetail?: (detail: string, fire: LPIHFireDatum) => string
84
+ }
85
+
86
+ /**
87
+ * Threshold below which the rate suffix is omitted. A long-dormant node
88
+ * decays toward 0; showing "0/s" or "0.001/s" is noise. The 0.5 cutoff
89
+ * means "less than once every 2 seconds at steady state" — at that
90
+ * rate, the cumulative count is the more useful signal.
91
+ *
92
+ * @internal — exported for tests + tunability.
93
+ */
94
+ export const _LPIH_RATE_VISIBLE_THRESHOLD = 0.5
95
+
96
+ function _formatRate(rate1s: number): string {
97
+ if (rate1s < _LPIH_RATE_VISIBLE_THRESHOLD) return ''
98
+ // < 10/s: 1 decimal place. ≥ 10/s: rounded integer.
99
+ return rate1s < 10
100
+ ? ` (${rate1s.toFixed(1)}/s)`
101
+ : ` (${Math.round(rate1s)}/s)`
102
+ }
103
+
104
+ const DEFAULT_FORMAT = (detail: string, fire: LPIHFireDatum): string => {
105
+ const kindLabel = fire.kind ? `${fire.kind} ` : ''
106
+ const rate = typeof fire.rate1s === 'number' ? _formatRate(fire.rate1s) : ''
107
+ return `${detail} — ${kindLabel}fired ${fire.count}×${rate}`
108
+ }
109
+
110
+ /**
111
+ * Merge runtime fire data onto static reactivity findings. Pure function,
112
+ * deterministic, input not mutated.
113
+ *
114
+ * Matching rules:
115
+ * - Only fires whose normalized `file` matches the analyzed source file
116
+ * are considered (cross-file fires are silently skipped).
117
+ * - Line-level matching only (column is ignored). V8 stack columns
118
+ * differ from compiler-emitted span columns by 1+ chars in practice,
119
+ * and the user-visible affordance is "this signal at this line is
120
+ * firing" — line precision is sufficient.
121
+ * - Multiple fires at the same `line` are summed; latest `lastFire`
122
+ * and corresponding `kind` win.
123
+ * - Findings of kind `footgun`, `hoisted-static`, or `static-text` are
124
+ * passed through unchanged — they're not runtime-active reactive
125
+ * reads, so a fire count at their location is unrelated to them.
126
+ */
127
+ export function mergeFireDataIntoFindings(
128
+ findings: ReactivityFinding[],
129
+ fires: readonly LPIHFireDatum[],
130
+ sourceFile: string,
131
+ options: LPIHMergeOptions = {},
132
+ ): ReactivityFinding[] {
133
+ if (fires.length === 0) return findings
134
+ const norm = options.normalizeFile ?? ((p) => p)
135
+ const format = options.formatDetail ?? DEFAULT_FORMAT
136
+ const targetFile = norm(sourceFile)
137
+
138
+ // Build line-keyed index. Sum counts at the same line; latest wins for
139
+ // lastFire + kind.
140
+ const byLine = new Map<number, LPIHFireDatum>()
141
+ for (const f of fires) {
142
+ if (norm(f.file) !== targetFile) continue
143
+ const existing = byLine.get(f.line)
144
+ if (existing) {
145
+ existing.count += f.count
146
+ if (typeof f.rate1s === 'number') {
147
+ existing.rate1s = (existing.rate1s ?? 0) + f.rate1s
148
+ }
149
+ const incomingLast = f.lastFire ?? -Infinity
150
+ const existingLast = existing.lastFire ?? -Infinity
151
+ if (incomingLast > existingLast) {
152
+ existing.lastFire = f.lastFire
153
+ existing.kind = f.kind ?? existing.kind
154
+ }
155
+ } else {
156
+ byLine.set(f.line, { ...f })
157
+ }
158
+ }
159
+
160
+ if (byLine.size === 0) return findings
161
+
162
+ return findings.map((finding) => {
163
+ // Footguns + static spans are NOT enriched — fire data at those lines
164
+ // belongs to a SEPARATE reactive expression on the same line, and
165
+ // attributing it to the footgun would be misleading.
166
+ if (
167
+ finding.kind === 'footgun' ||
168
+ finding.kind === 'hoisted-static' ||
169
+ finding.kind === 'static-text'
170
+ ) {
171
+ return finding
172
+ }
173
+ const fire = byLine.get(finding.line)
174
+ if (!fire) return finding
175
+ return {
176
+ ...finding,
177
+ detail: format(finding.detail, fire),
178
+ }
179
+ })
180
+ }
181
+
182
+ /**
183
+ * Synthesize "creation-site" inlay-hint findings directly from fire data.
184
+ *
185
+ * `analyzeReactivity()` produces findings at REACTIVE READ sites (JSX
186
+ * expressions). But the runtime captures fires at CREATION sites
187
+ * (`signal(0)`, `computed(...)`, `effect(...)`). These are usually
188
+ * different source lines — so the merge function above only helps when
189
+ * they happen to coincide.
190
+ *
191
+ * The simpler, more useful editor surface is: show fire counts AT THE
192
+ * CREATION LINE. The user writes `const count = signal(0)` and sees
193
+ * `(signal fired 129×)` as ghost text on that line, the same way
194
+ * TypeScript shows the inferred type.
195
+ *
196
+ * This function turns each fire datum into a synthetic finding the LSP
197
+ * can serve as an inlay hint. No static analysis required — pure runtime
198
+ * data → editor hint.
199
+ *
200
+ * Returns findings sorted by (line, column). Files that don't match
201
+ * `sourceFile` (after normalization) are skipped.
202
+ *
203
+ * @example
204
+ * import { firesToCreationSiteFindings } from '@pyreon/compiler'
205
+ * import { getFireSummaries } from '@pyreon/reactivity'
206
+ *
207
+ * const fires = getFireSummaries().map(s => ({
208
+ * file: s.loc.file, line: s.loc.line, count: s.count, kind: s.kind,
209
+ * }))
210
+ * const findings = firesToCreationSiteFindings(fires, 'app.tsx')
211
+ * // [{ kind: 'live-fire', line: 5, detail: 'signal fired 129×', ... }]
212
+ */
213
+ export function firesToCreationSiteFindings(
214
+ fires: readonly LPIHFireDatum[],
215
+ sourceFile: string,
216
+ options: LPIHMergeOptions = {},
217
+ ): ReactivityFinding[] {
218
+ if (fires.length === 0) return []
219
+ const norm = options.normalizeFile ?? ((p) => p)
220
+ const targetFile = norm(sourceFile)
221
+
222
+ // Per-line aggregation (multiple nodes on the same line — rare but
223
+ // possible: `const [a, b] = [signal(0), signal(0)]`).
224
+ const byLine = new Map<number, LPIHFireDatum>()
225
+ for (const f of fires) {
226
+ if (norm(f.file) !== targetFile) continue
227
+ const existing = byLine.get(f.line)
228
+ if (existing) {
229
+ existing.count += f.count
230
+ // Sum rates at the same line (e.g. destructured signal pair).
231
+ if (typeof f.rate1s === 'number') {
232
+ existing.rate1s = (existing.rate1s ?? 0) + f.rate1s
233
+ }
234
+ const incomingLast = f.lastFire ?? -Infinity
235
+ const existingLast = existing.lastFire ?? -Infinity
236
+ if (incomingLast > existingLast) {
237
+ existing.lastFire = f.lastFire
238
+ existing.kind = f.kind ?? existing.kind
239
+ }
240
+ } else {
241
+ byLine.set(f.line, { ...f })
242
+ }
243
+ }
244
+
245
+ const format = options.formatDetail ?? ((_: string, fire: LPIHFireDatum) => {
246
+ const kindLabel = fire.kind ?? 'node'
247
+ const rate = typeof fire.rate1s === 'number' ? _formatRate(fire.rate1s) : ''
248
+ return `${kindLabel} fired ${fire.count}×${rate}`
249
+ })
250
+
251
+ // 'live-fire' is a new finding kind — synthetic, not produced by
252
+ // `analyzeReactivity()`. The LSP renders it as an inlay hint the same
253
+ // way as the structural kinds (reactive/static-text/etc).
254
+ const LIVE_KIND = 'live-fire' as ReactivityFindingKind
255
+
256
+ const out: ReactivityFinding[] = []
257
+ for (const [line, fire] of byLine) {
258
+ out.push({
259
+ kind: LIVE_KIND,
260
+ line,
261
+ column: 0,
262
+ endLine: line,
263
+ // 9999 = "end of line" sentinel; the LSP can clamp to actual line length.
264
+ endColumn: 9999,
265
+ detail: format('', fire),
266
+ })
267
+ }
268
+ out.sort((a, b) => a.line - b.line || a.column - b.column)
269
+ return out
270
+ }
@@ -993,16 +993,27 @@ export function hasPyreonPatterns(code: string): boolean {
993
993
  /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) ||
994
994
  /\b(?:add|remove)EventListener\s*\(/.test(code) ||
995
995
  (/\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code)) ||
996
- /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) ||
997
- /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) ||
998
- // props-destructured-body: `const { } = <ident>` anywhere. Loose
999
- // on purpose the AST walker is the precise gate; this only has to
1000
- // avoid skipping the full walk.
1001
- /\b(?:const|let|var)\s+\{[^}]*\}\s*=\s*[A-Za-z_$]/.test(code) ||
996
+ // Bounded `\w{0,60}` cap on the handler identifier — real `on*`
997
+ // names are at most ~25 chars (`onPointerLeaveCapture`); 60 leaves
998
+ // headroom. The unbounded `\w*` form was flagged by CodeQL
999
+ // `js/polynomial-redos` (alert #65) as polynomial-time on inputs
1000
+ // like `onAAAA…` (long runs of `[A-Z]`): per starting position
1001
+ // the greedy `\w*` consumes O(N) chars before the trailing `=`
1002
+ // fails to match, giving O(N²) overall on N starting positions.
1003
+ // The cap keeps the regex linear regardless of input shape.
1004
+ /on[A-Z]\w{0,60}\s*=\s*\{\s*undefined\s*\}/.test(code) ||
1005
+ // Bounded `{0,500}` / `{1,500}` quantifiers — this is a pre-filter
1006
+ // scan before the precise AST walker, so losing detector recall on
1007
+ // a pathologically long single-line input is acceptable.
1008
+ /=\s*\(\s*\{[^}]{1,500}\}\s*[:)]/.test(code) ||
1009
+ // props-destructured-body: `const { … } = <ident>` anywhere.
1010
+ /\b(?:const|let|var)\s+\{[^}]{0,500}\}\s*=\s*[A-Za-z_$]/.test(code) ||
1002
1011
  // signal-write-as-call: `const X = signal(` declaration anywhere
1003
1012
  /\b(?:signal|computed)\s*[<(]/.test(code) ||
1004
- // static-return-null-conditional: `if (...) return null` anywhere
1005
- /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) ||
1013
+ // static-return-null-conditional: `if (...) return null` anywhere.
1014
+ // `[\s{]*` (single class) instead of `\s*\{?\s*` (overlapping
1015
+ // quantifiers) — the latter is polynomial on long whitespace runs.
1016
+ /\bif\s*\([^)]{1,500}\)[\s{]{0,20}return\s+null\b/.test(code) ||
1006
1017
  // as-unknown-as-vnodechild
1007
1018
  /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) ||
1008
1019
  // query-options-as-function: a query hook called with an object literal
package/src/ssg-audit.ts CHANGED
@@ -278,8 +278,8 @@ function detectDynamicRouteMissingGetStaticPaths(
278
278
  const findings: SsgFinding[] = []
279
279
  for (const file of routeFiles) {
280
280
  const base = file.split('/').pop() ?? ''
281
- // Dynamic route iff filename contains `[...]` or `[name]` brackets.
282
- if (!/\[.+\]/.test(base)) continue
281
+ // `[^\]]+` instead of `.+` bounded, no backtrack potential.
282
+ if (!/\[[^\]]{1,200}\]/.test(base)) continue
283
283
  // Skip layouts / errors / 404s — only PAGE files take getStaticPaths.
284
284
  if (/^_(layout|error|loading|404|not-found)\./.test(base)) continue
285
285
  // Skip API routes under `routes/api/` (path-based convention).
@@ -425,7 +425,7 @@ export function auditSsg(rootDir: string): SsgAuditResult {
425
425
  let revalidateExports = 0
426
426
  for (const file of routeFiles) {
427
427
  const base = file.split('/').pop() ?? ''
428
- if (/\[.+\]/.test(base) && !/^_(layout|error|loading|404|not-found)\./.test(base)) {
428
+ if (/\[[^\]]{1,200}\]/.test(base) && !/^_(layout|error|loading|404|not-found)\./.test(base)) {
429
429
  dynamicRoutes++
430
430
  }
431
431
  const source = parseSourceFile(file)
@@ -84,6 +84,13 @@ interface SiteClass {
84
84
  * attrs are string literals AND children are static text (partial-collapse
85
85
  * addressable). */
86
86
  partialAddressable: boolean
87
+ /** dynamic-prop only: true iff EXACTLY ONE dynamic attr is a ternary of
88
+ * two string literals AND every OTHER non-literal attr is an `on*`
89
+ * handler (which compose orthogonally via the handler-combined
90
+ * emit), AND children are static text. Counts the subset addressable
91
+ * by the dynamic-prop collapse PR sequence (PRs #765-#767 plus the
92
+ * handler-combined follow-up). */
93
+ dynamicTernaryAddressable: boolean
87
94
  }
88
95
 
89
96
  const isPascal = (t: string): boolean =>
@@ -113,17 +120,47 @@ function classifySite(node: any): SiteClass {
113
120
  const attrs: any[] = opening.attributes ?? []
114
121
  let sawDynamic = false
115
122
  let everyDynamicIsHandler = true
123
+ // Dynamic-prop addressable tracking: count ternaries + check shape.
124
+ // Exactly one ternary-of-two-literals + every other non-literal attr
125
+ // is either a ternary or an `on*` handler (handlers compose via the
126
+ // combined `_rsCollapseDynH` emit) → addressable. Note no
127
+ // `sawHandler` tracking: the original PR 3 no-handler restriction
128
+ // was lifted by the handler-combined follow-up; handlers no longer
129
+ // disqualify a site from `dynamicTernaryAddressable`.
130
+ let ternaryCount = 0
131
+ let everyDynamicIsTernary = true
116
132
  for (const a of attrs) {
117
- if (a.type === 'JSXSpreadAttribute') return { bucket: 'spread', partialAddressable: false }
133
+ if (a.type === 'JSXSpreadAttribute')
134
+ return { bucket: 'spread', partialAddressable: false, dynamicTernaryAddressable: false }
118
135
  const nm = a.name?.type === 'JSXIdentifier' ? a.name.name : null
119
- if (!nm) return { bucket: 'spread', partialAddressable: false }
136
+ if (!nm)
137
+ return { bucket: 'spread', partialAddressable: false, dynamicTernaryAddressable: false }
120
138
  const v = a.value
121
- if (!v) return { bucket: 'boolean-attr', partialAddressable: false }
139
+ if (!v)
140
+ return {
141
+ bucket: 'boolean-attr',
142
+ partialAddressable: false,
143
+ dynamicTernaryAddressable: false,
144
+ }
122
145
  const isStr =
123
146
  v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
124
147
  if (!isStr) {
125
148
  sawDynamic = true
126
- if (!/^on[A-Z]/.test(nm)) everyDynamicIsHandler = false
149
+ const isHandler = /^on[A-Z]/.test(nm)
150
+ if (!isHandler) everyDynamicIsHandler = false
151
+ // Probe for the ternary-of-two-literals shape (PR 2 detector's
152
+ // structural shape).
153
+ const expr = v.type === 'JSXExpressionContainer' ? v.expression : null
154
+ const isLitStr = (n: any): boolean =>
155
+ n &&
156
+ (n.type === 'StringLiteral' || (n.type === 'Literal' && typeof n.value === 'string'))
157
+ const isTernaryOfLits =
158
+ expr &&
159
+ expr.type === 'ConditionalExpression' &&
160
+ isLitStr(expr.consequent) &&
161
+ isLitStr(expr.alternate)
162
+ if (isTernaryOfLits) ternaryCount++
163
+ else if (!isHandler) everyDynamicIsTernary = false
127
164
  }
128
165
  }
129
166
  // children
@@ -135,22 +172,41 @@ function classifySite(node: any): SiteClass {
135
172
  else staticChildrenOnly = false // JSXExpressionContainer etc.
136
173
  }
137
174
  if (sawDynamic) {
138
- // Every NON-dynamic attr is a string literal by construction: the loop
139
- // above early-returns on spread / missing-name / boolean attrs, so any
140
- // attr that didn't set `sawDynamic` is necessarily `isStr`. Hence the
141
- // partial-addressable condition is just "every dynamic attr is on*" AND
142
- // "children are static text" — no separate literal check needed.
143
175
  const partialAddressable = everyDynamicIsHandler && staticChildrenOnly
144
- return { bucket: 'dynamic-prop', partialAddressable }
176
+ // Dynamic-collapse claims: EXACTLY 1 ternary, every OTHER dynamic
177
+ // attr is either a ternary or an `on*` handler (no plain dynamic
178
+ // shapes like `state={getValue()}`), static children. The
179
+ // handler-combined follow-up (this PR) lifted the no-handler
180
+ // restriction by routing handler-bearing dynamic sites to the
181
+ // `_rsCollapseDynH` runtime helper instead of bailing — closes
182
+ // the bulk of the 15.4% dynamic-prop bucket (previously the
183
+ // strict no-handler scope only addressed 0.2% of sites).
184
+ //
185
+ // The `everyDynamicIsTernary` flag here is computed in the loop
186
+ // above as "every non-handler dynamic attr is a ternary"; combined
187
+ // with `ternaryCount === 1` + `staticChildrenOnly` it precisely
188
+ // matches what `detectDynamicCollapsibleShape` + `tryDynamicCollapse`
189
+ // claim. Handlers are NO LONGER excluded — they compose orthogonally.
190
+ const dynamicTernaryAddressable =
191
+ ternaryCount === 1 && everyDynamicIsTernary && staticChildrenOnly
192
+ return { bucket: 'dynamic-prop', partialAddressable, dynamicTernaryAddressable }
145
193
  }
146
194
  // No spread / boolean / dynamic attr. Bail can now only come from children.
147
195
  for (const c of kids) {
148
196
  if (c.type === 'JSXText') continue
149
197
  if (c.type === 'JSXElement' || c.type === 'JSXFragment')
150
- return { bucket: 'element-child', partialAddressable: false }
151
- return { bucket: 'expression-child', partialAddressable: false }
198
+ return {
199
+ bucket: 'element-child',
200
+ partialAddressable: false,
201
+ dynamicTernaryAddressable: false,
202
+ }
203
+ return {
204
+ bucket: 'expression-child',
205
+ partialAddressable: false,
206
+ dynamicTernaryAddressable: false,
207
+ }
152
208
  }
153
- return { bucket: 'collapsible', partialAddressable: false }
209
+ return { bucket: 'collapsible', partialAddressable: false, dynamicTernaryAddressable: false }
154
210
  }
155
211
 
156
212
  describe('proposal #1 — collapse-tail bail-reason census (measurement, not a build)', () => {
@@ -168,6 +224,7 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
168
224
  }
169
225
  let candidates = 0
170
226
  let partialAddressable = 0
227
+ let dynamicTernaryAddressable = 0
171
228
  let myCollapsible = 0
172
229
  let scannerCollapsible = 0
173
230
 
@@ -191,6 +248,7 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
191
248
  tally[c.bucket]++
192
249
  if (c.bucket === 'collapsible') myCollapsible++
193
250
  if (c.partialAddressable) partialAddressable++
251
+ if (c.dynamicTernaryAddressable) dynamicTernaryAddressable++
194
252
  }
195
253
  }
196
254
  for (const k in node) {
@@ -220,14 +278,27 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
220
278
  ` bail:expression-child : ${tally['expression-child']} (${pct(tally['expression-child'])})`,
221
279
  ` ── partial-collapse ADDRESSABLE : ${partialAddressable} (${pct(partialAddressable)} of all sites)`,
222
280
  ` (dynamic-prop bails where every dynamic attr is on*, all else literal, static children)`,
281
+ ` ── dynamic-collapse ADDRESSABLE : ${dynamicTernaryAddressable} (${pct(dynamicTernaryAddressable)} of all sites)`,
282
+ ` (dynamic-prop bails where EXACTLY ONE attr is a ternary-of-two-string-literals,`,
283
+ ` every other non-literal attr is on* (handlers compose via _rsCollapseDynH),`,
284
+ ` static children — dynamic-prop sequence #765-#767 + handler-combined follow-up)`,
223
285
  '',
224
286
  ].join('\n'),
225
287
  )
226
288
 
227
289
  // ── Trustworthiness gate (bisect-equivalent) ────────────────────────────
228
- // This independent walk's "collapsible" count MUST equal the production
229
- // scanner's truth-set. If they diverge the census is measuring fiction.
230
- expect(myCollapsible).toBe(scannerCollapsible)
290
+ // This independent walk's "collapsible-equivalent" count MUST equal the
291
+ // production scanner's truth-set. If they diverge the census is
292
+ // measuring fiction.
293
+ //
294
+ // Per PR 3 (#767) the scanner emits TWO `CollapsibleSite` entries per
295
+ // dynamic-prop site (one per literal value — the resolver pre-renders
296
+ // both); the compiler emit still produces ONE collapsed call site. So
297
+ // the per-site classifier count + 2× the dynamic-addressable count
298
+ // equals the scanner's per-resolution count. If they diverge, either
299
+ // the classifier and scanner disagree on which dynamic sites are
300
+ // addressable, OR the scanner's expansion drifted from this formula.
301
+ expect(myCollapsible + 2 * dynamicTernaryAddressable).toBe(scannerCollapsible)
231
302
 
232
303
  // ── Lock the headline finding (ratchet record) ──────────────────────────
233
304
  // The corpus is real and large; these are the measured facts as of this
@@ -241,5 +312,19 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
241
312
  // the classifier ran over a non-trivial dynamic-prop population so the
242
313
  // ratio is meaningful, not noise.
243
314
  expect(tally['dynamic-prop'] + tally.collapsible).toBeGreaterThan(0)
315
+ // PR 4 of the dynamic-prop partial-collapse build: lock that the
316
+ // dynamic-collapse classifier ran over a meaningful population
317
+ // (dynamic-prop bucket non-zero). The addressable count is in the
318
+ // log; we DON'T assert it >0 here because the strict no-handler
319
+ // PR 3 scope is honestly small in real-world corpora (real Buttons
320
+ // with `state={cond ? ... : ...}` almost always also carry
321
+ // `onClick` → BAIL until the handler-combined follow-up). The
322
+ // dynamic-prop bucket itself is the size of the future surface;
323
+ // PR 3's no-handler subset is the first measurable step.
324
+ expect(tally['dynamic-prop']).toBeGreaterThan(0)
325
+ // The dynamic-addressable count can be 0 in a clean run (no
326
+ // matching sites in the corpus); just lock that the counter is
327
+ // wired and consistent with the bucket.
328
+ expect(dynamicTernaryAddressable).toBeLessThanOrEqual(tally['dynamic-prop'])
244
329
  })
245
330
  })