@pyreon/compiler 0.24.5 → 0.24.6
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/package.json +11 -13
- package/src/defer-inline.ts +0 -686
- package/src/event-names.ts +0 -65
- package/src/index.ts +0 -61
- package/src/island-audit.ts +0 -675
- package/src/jsx.ts +0 -2792
- package/src/load-native.ts +0 -156
- package/src/lpih.ts +0 -270
- package/src/manifest.ts +0 -280
- package/src/project-scanner.ts +0 -214
- package/src/pyreon-intercept.ts +0 -1029
- package/src/react-intercept.ts +0 -1217
- package/src/reactivity-lens.ts +0 -190
- package/src/ssg-audit.ts +0 -513
- package/src/test-audit.ts +0 -435
- package/src/tests/backend-parity-r7-r9.test.ts +0 -91
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
- package/src/tests/collapse-bail-census.test.ts +0 -330
- package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
- package/src/tests/component-child-no-wrap.test.ts +0 -204
- package/src/tests/defer-inline.test.ts +0 -387
- package/src/tests/depth-stress.test.ts +0 -16
- package/src/tests/detector-tag-consistency.test.ts +0 -101
- package/src/tests/dynamic-collapse-detector.test.ts +0 -164
- package/src/tests/dynamic-collapse-emit.test.ts +0 -192
- package/src/tests/dynamic-collapse-scan.test.ts +0 -111
- package/src/tests/element-valued-const-child.test.ts +0 -61
- package/src/tests/falsy-child-characterization.test.ts +0 -48
- package/src/tests/island-audit.test.ts +0 -524
- package/src/tests/jsx.test.ts +0 -2908
- package/src/tests/load-native.test.ts +0 -53
- package/src/tests/lpih.test.ts +0 -404
- package/src/tests/malformed-input-resilience.test.ts +0 -50
- package/src/tests/manifest-snapshot.test.ts +0 -55
- package/src/tests/native-equivalence.test.ts +0 -924
- package/src/tests/partial-collapse-detector.test.ts +0 -121
- package/src/tests/partial-collapse-emit.test.ts +0 -104
- package/src/tests/partial-collapse-robustness.test.ts +0 -53
- package/src/tests/project-scanner.test.ts +0 -269
- package/src/tests/prop-derived-shadow.test.ts +0 -96
- package/src/tests/pure-call-reactive-args.test.ts +0 -50
- package/src/tests/pyreon-intercept.test.ts +0 -816
- package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
- package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
- package/src/tests/r15-elemconst-propderived.test.ts +0 -47
- package/src/tests/r19-defer-inline-robust.test.ts +0 -54
- package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
- package/src/tests/react-intercept.test.ts +0 -1104
- package/src/tests/reactivity-lens.test.ts +0 -170
- package/src/tests/rocketstyle-collapse.test.ts +0 -208
- package/src/tests/runtime/control-flow.test.ts +0 -159
- package/src/tests/runtime/dom-properties.test.ts +0 -138
- package/src/tests/runtime/events.test.ts +0 -301
- package/src/tests/runtime/harness.ts +0 -94
- package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
- package/src/tests/runtime/reactive-props.test.ts +0 -81
- package/src/tests/runtime/signals.test.ts +0 -129
- package/src/tests/runtime/whitespace.test.ts +0 -106
- package/src/tests/signal-autocall-shadow.test.ts +0 -86
- package/src/tests/sourcemap-fidelity.test.ts +0 -77
- package/src/tests/ssg-audit.test.ts +0 -402
- package/src/tests/static-text-baking.test.ts +0 -64
- package/src/tests/test-audit.test.ts +0 -549
- package/src/tests/transform-state-isolation.test.ts +0 -49
package/src/test-audit.ts
DELETED
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test-environment audit for the `audit_test_environment` MCP tool (T2.5.7).
|
|
3
|
-
*
|
|
4
|
-
* Scans `*.test.ts` / `*.test.tsx` files under the `packages` tree
|
|
5
|
-
* for **mock-vnode patterns** — tests that construct `{ type, props,
|
|
6
|
-
* children }` object literals (or a custom `vnode(...)` helper) in
|
|
7
|
-
* place of going through the real `h()` from `@pyreon/core`. This
|
|
8
|
-
* class of pattern silently drops rocketstyle / compiler / attrs
|
|
9
|
-
* work from the pipeline, letting bugs through that production
|
|
10
|
-
* would hit immediately (see PR #197 silent metadata drop).
|
|
11
|
-
*
|
|
12
|
-
* The scanner does NOT run the tests or parse TypeScript — a fast
|
|
13
|
-
* regex pass is intentional. Accuracy trades for speed: the false-
|
|
14
|
-
* positive rate is low because the `{ type: ..., props: ...,
|
|
15
|
-
* children: ... }` shape is unusual outside of vnode construction.
|
|
16
|
-
*
|
|
17
|
-
* Output classification:
|
|
18
|
-
* HIGH — mock patterns present, no real `h()` calls and no `h`
|
|
19
|
-
* import from `@pyreon/core`. Most at risk: the file has
|
|
20
|
-
* no pathway to exercise the real pipeline.
|
|
21
|
-
* MEDIUM — mock patterns present, some real `h()` usage — but the
|
|
22
|
-
* mock count is still notable, so a parallel real-`h()`
|
|
23
|
-
* test may be missing for specific scenarios.
|
|
24
|
-
* LOW — either no mocks, or mock count is dwarfed by real usage.
|
|
25
|
-
*
|
|
26
|
-
* Companion to the `validate` and `get_anti_patterns` tools: those
|
|
27
|
-
* tell an agent what to write; this one tells an agent which existing
|
|
28
|
-
* tests need strengthening.
|
|
29
|
-
*/
|
|
30
|
-
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
31
|
-
import { dirname, join, relative, resolve } from 'node:path'
|
|
32
|
-
|
|
33
|
-
export type AuditRisk = 'high' | 'medium' | 'low'
|
|
34
|
-
|
|
35
|
-
export interface TestAuditEntry {
|
|
36
|
-
/** Absolute path to the test file */
|
|
37
|
-
path: string
|
|
38
|
-
/** Path relative to the repo root for readable reporting */
|
|
39
|
-
relPath: string
|
|
40
|
-
/** Count of object-literal `{ type: ..., props: ..., children: ... }` patterns */
|
|
41
|
-
mockVNodeLiteralCount: number
|
|
42
|
-
/** Count of `vnode` / `mockVNode` / `createVNode` helper DEFINITIONS */
|
|
43
|
-
mockHelperCount: number
|
|
44
|
-
/**
|
|
45
|
-
* Count of CALLS to a known mock-helper name. Captures pervasiveness:
|
|
46
|
-
* a file with one helper definition and 50 call-sites has the same
|
|
47
|
-
* `mockHelperCount` (1) as one with zero calls, but very different
|
|
48
|
-
* exposure. This metric surfaces that.
|
|
49
|
-
*/
|
|
50
|
-
mockHelperCallCount: number
|
|
51
|
-
/** Count of lines that look like real `h(...)` calls (`h(Tag, props)` / `h(Component, ...)` shape) */
|
|
52
|
-
realHCallCount: number
|
|
53
|
-
/** True if the file imports `h` from `@pyreon/core` */
|
|
54
|
-
importsH: boolean
|
|
55
|
-
/** Risk classification */
|
|
56
|
-
risk: AuditRisk
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface TestAuditResult {
|
|
60
|
-
/** Repo root discovered by walking up for `packages/` */
|
|
61
|
-
root: string | null
|
|
62
|
-
/** Every test file scanned, sorted by risk (high → low) then path */
|
|
63
|
-
entries: TestAuditEntry[]
|
|
64
|
-
/** Total files scanned */
|
|
65
|
-
totalScanned: number
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
69
|
-
// Discovery
|
|
70
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
71
|
-
|
|
72
|
-
function findMonorepoRoot(startDir: string): string | null {
|
|
73
|
-
let dir = resolve(startDir)
|
|
74
|
-
for (let i = 0; i < 30; i++) {
|
|
75
|
-
try {
|
|
76
|
-
if (statSync(join(dir, 'packages')).isDirectory()) return dir
|
|
77
|
-
} catch {
|
|
78
|
-
// fall through to parent walk
|
|
79
|
-
}
|
|
80
|
-
const parent = dirname(dir)
|
|
81
|
-
if (parent === dir) return null
|
|
82
|
-
dir = parent
|
|
83
|
-
}
|
|
84
|
-
return null
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function walkTestFiles(dir: string, out: string[], depth = 0): void {
|
|
88
|
-
if (depth > 10) return
|
|
89
|
-
let entries: string[]
|
|
90
|
-
try {
|
|
91
|
-
entries = readdirSync(dir)
|
|
92
|
-
} catch {
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
for (const name of entries) {
|
|
96
|
-
if (name.startsWith('.')) continue
|
|
97
|
-
if (name === 'node_modules' || name === 'lib' || name === 'dist') continue
|
|
98
|
-
const full = join(dir, name)
|
|
99
|
-
let isDir = false
|
|
100
|
-
try {
|
|
101
|
-
isDir = statSync(full).isDirectory()
|
|
102
|
-
} catch {
|
|
103
|
-
continue
|
|
104
|
-
}
|
|
105
|
-
if (isDir) {
|
|
106
|
-
walkTestFiles(full, out, depth + 1)
|
|
107
|
-
continue
|
|
108
|
-
}
|
|
109
|
-
if (/\.test\.(ts|tsx)$/.test(name)) {
|
|
110
|
-
out.push(full)
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
116
|
-
// Pattern detection
|
|
117
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Matches an object literal carrying `type`, `props`, AND `children`
|
|
121
|
-
* keys — the canonical mock-vnode shape. The `s` flag spans newlines
|
|
122
|
-
* because vnode literals often wrap across multiple lines.
|
|
123
|
-
*/
|
|
124
|
-
const MOCK_VNODE_LITERAL_PATTERN =
|
|
125
|
-
/\{\s*type\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?props\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?children\s*:[^{}]*?(?:(?:\{[^{}]*\})[^{}]*?)*?\}/gs
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Matches a helper definition that produces a mock vnode. Recognises:
|
|
129
|
-
* const vnode = (...) => ({ type, props, children })
|
|
130
|
-
* const mockVNode = ({ type, props, children })
|
|
131
|
-
* function createVNode(type, props, children)
|
|
132
|
-
*
|
|
133
|
-
* Does NOT match bindings that merely STORE a real VNode with a
|
|
134
|
-
* `vnode`-like name, which are common in component tests:
|
|
135
|
-
* const vnode = defaultRender(...) // real render result
|
|
136
|
-
* const vnode = <span>cell content</span> // real JSX expression
|
|
137
|
-
* const vnode = h('div', null, 'x') // real h() call
|
|
138
|
-
*
|
|
139
|
-
* Distinguisher: a mock helper definition either
|
|
140
|
-
* (a) starts an arrow function / function — RHS begins with `(` or the
|
|
141
|
-
* keyword `function`, OR
|
|
142
|
-
* (b) is itself an inline object literal — RHS begins with `{`.
|
|
143
|
-
* `const vnode = <anything else>` is a binding, not a definition.
|
|
144
|
-
*/
|
|
145
|
-
const MOCK_HELPER_PATTERN =
|
|
146
|
-
/(?:(?:const|let)\s+(?:mockV[Nn]ode|vnode|createV[Nn]ode|V[Nn]odeMock|makeV[Nn]ode)\s*=\s*(?:\(|\{|function\b|async\s))|(?:function\s+(?:mockV[Nn]ode|vnode|createV[Nn]ode|V[Nn]odeMock|makeV[Nn]ode)\s*\()/g
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Matches CALLS to a known mock-helper name:
|
|
150
|
-
* vnode('div', props, children)
|
|
151
|
-
* mockVNode(Component, props)
|
|
152
|
-
* createVNode(...)
|
|
153
|
-
*
|
|
154
|
-
* Non-word boundary before the name avoids hits inside other
|
|
155
|
-
* identifiers (`hasVNode`, `myVnodeImpl`). The helper-def pattern
|
|
156
|
-
* above ALSO matches definitions' own `<name>(` arg list, so the
|
|
157
|
-
* caller should subtract definition count from call count to get
|
|
158
|
-
* usage-only density — but for risk classification, the combined
|
|
159
|
-
* signal (any mock-helper activity) is what we want.
|
|
160
|
-
*/
|
|
161
|
-
const MOCK_HELPER_CALL_PATTERN =
|
|
162
|
-
/(?:^|[^a-zA-Z0-9_])(?:mockV[Nn]ode|vnode|createV[Nn]ode|V[Nn]odeMock|makeV[Nn]ode)\s*\(/g
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Matches calls to `h(…)` where the first arg is an uppercase
|
|
166
|
-
* identifier (component) or a lowercase string tag — the two real
|
|
167
|
-
* shapes. Avoids matching:
|
|
168
|
-
* hasSomething(...) — h followed by [a-z]
|
|
169
|
-
* ch() — single h as substring of another name
|
|
170
|
-
* hash() — same
|
|
171
|
-
* The `(?:^|\W)` boundary plus `[A-Z'"\s]` arg requirement handles both.
|
|
172
|
-
*/
|
|
173
|
-
const REAL_H_CALL_PATTERN = /(?:^|\W)h\s*\(\s*["'A-Z]/g
|
|
174
|
-
|
|
175
|
-
const IMPORT_H_PATTERN = /import\s*(?:type\s*)?\{[^}]*\bh\b[^}]*\}\s*from\s*['"]@pyreon\/core['"]/
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Predicate: does the `{type, props, children}` literal at this
|
|
179
|
-
* position appear as an argument to a type-guard-like call
|
|
180
|
-
* (`isDocNode(...)`, `hasVNode(...)`, `assertVNode(...)`, etc.)?
|
|
181
|
-
*
|
|
182
|
-
* Type guards take any object shape and return boolean — passing a
|
|
183
|
-
* `{type, props, children}` literal there is testing the guard's
|
|
184
|
-
* duck-typing, not building a mock vnode for a rendering pipeline.
|
|
185
|
-
* False-positive coverage for `utils-coverage.test.ts` and similar.
|
|
186
|
-
*/
|
|
187
|
-
function isLiteralInsideTypeGuardCall(source: string, literalStart: number): boolean {
|
|
188
|
-
// Scan back ~60 chars from the literal for `(\b(?:is|has|assert|validate|check)[A-Z]\w*\s*\()`.
|
|
189
|
-
// We're looking for a function-call opening paren that directly
|
|
190
|
-
// contains this literal (no closer `)` in between).
|
|
191
|
-
const window = source.slice(Math.max(0, literalStart - 60), literalStart)
|
|
192
|
-
// The nearest `(` before the literal — count unmatched parens.
|
|
193
|
-
let unmatched = 0
|
|
194
|
-
let openAt = -1
|
|
195
|
-
for (let i = window.length - 1; i >= 0; i--) {
|
|
196
|
-
const ch = window[i]
|
|
197
|
-
if (ch === ')') unmatched++
|
|
198
|
-
else if (ch === '(') {
|
|
199
|
-
if (unmatched === 0) {
|
|
200
|
-
openAt = i
|
|
201
|
-
break
|
|
202
|
-
}
|
|
203
|
-
unmatched--
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
if (openAt < 0) return false
|
|
207
|
-
// Is the token immediately before `openAt` an is*/has*/assert*/check*/validate* identifier?
|
|
208
|
-
const head = window.slice(0, openAt)
|
|
209
|
-
return /\b(?:is|has|assert|validate|check)[A-Z]\w*\s*$/.test(head)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Mask the inside of every backtick-delimited template-literal with
|
|
214
|
-
* spaces. Preserves length so positions/lines/columns stay aligned.
|
|
215
|
-
* Used to keep the literal scanner from counting `{type,props,children}`
|
|
216
|
-
* patterns that live inside test FIXTURE strings (the `cli/doctor.test.ts`
|
|
217
|
-
* case — those are fixtures for the audit tool itself, not actual code).
|
|
218
|
-
*
|
|
219
|
-
* Limitations: doesn't parse `${...}` interpolations precisely. If a
|
|
220
|
-
* fixture contains a balanced `${ ... }` with code we'd want scanned,
|
|
221
|
-
* the surrounding template string still masks it. In practice, mock-
|
|
222
|
-
* vnode literals are never interpolation expressions, so this is fine.
|
|
223
|
-
*/
|
|
224
|
-
function maskTemplateStrings(source: string): string {
|
|
225
|
-
return source.replace(/`(?:\\.|[^`\\])*`/g, (m) => `\`${' '.repeat(m.length - 2)}\``)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function countMatches(source: string, pattern: RegExp): number {
|
|
229
|
-
let count = 0
|
|
230
|
-
pattern.lastIndex = 0
|
|
231
|
-
while (pattern.exec(source) !== null) count++
|
|
232
|
-
pattern.lastIndex = 0
|
|
233
|
-
return count
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* Counts `{type, props, children}` literals, skipping those that
|
|
238
|
-
* appear inside a type-guard-looking call OR inside a template-literal
|
|
239
|
-
* (which is fixture text, not code). Dedicated because the existing
|
|
240
|
-
* `countMatches` helper has no context-aware skip.
|
|
241
|
-
*/
|
|
242
|
-
function countMockVNodeLiterals(source: string): number {
|
|
243
|
-
// First mask template-literal contents — fixtures inside backticks
|
|
244
|
-
// (e.g. `\`const v = { type, props, children }\`` written via
|
|
245
|
-
// writeFile in audit's own test) shouldn't count. The mask
|
|
246
|
-
// preserves positions, so the type-guard skip logic still works.
|
|
247
|
-
const masked = maskTemplateStrings(source)
|
|
248
|
-
const pattern = MOCK_VNODE_LITERAL_PATTERN
|
|
249
|
-
let count = 0
|
|
250
|
-
pattern.lastIndex = 0
|
|
251
|
-
let m: RegExpExecArray | null
|
|
252
|
-
while (true) {
|
|
253
|
-
m = pattern.exec(masked)
|
|
254
|
-
if (m === null) break
|
|
255
|
-
if (!isLiteralInsideTypeGuardCall(masked, m.index)) count++
|
|
256
|
-
}
|
|
257
|
-
pattern.lastIndex = 0
|
|
258
|
-
return count
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function classifyRisk(entry: Omit<TestAuditEntry, 'risk'>): AuditRisk {
|
|
262
|
-
const mocks =
|
|
263
|
-
entry.mockVNodeLiteralCount + entry.mockHelperCount + entry.mockHelperCallCount
|
|
264
|
-
if (mocks === 0) return 'low'
|
|
265
|
-
if (!entry.importsH && entry.realHCallCount === 0) return 'high'
|
|
266
|
-
if (entry.realHCallCount >= mocks) return 'low'
|
|
267
|
-
return 'medium'
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
271
|
-
// Public API
|
|
272
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
273
|
-
|
|
274
|
-
export function auditTestEnvironment(startDir: string): TestAuditResult {
|
|
275
|
-
// Caller-supplied `startDir` (no default) — `runtime-dom` transitively
|
|
276
|
-
// pulls this file via the `@pyreon/compiler` JSX runtime entry, and its
|
|
277
|
-
// tsconfig narrows `process` to `{ env: ... }` only. Calling
|
|
278
|
-
// `process.cwd()` here breaks that typecheck. MCP / CLI both have full
|
|
279
|
-
// node types; let them resolve cwd at the call site.
|
|
280
|
-
const root = findMonorepoRoot(startDir)
|
|
281
|
-
if (!root) return { root: null, entries: [], totalScanned: 0 }
|
|
282
|
-
|
|
283
|
-
const files: string[] = []
|
|
284
|
-
walkTestFiles(join(root, 'packages'), files)
|
|
285
|
-
|
|
286
|
-
const entries: TestAuditEntry[] = []
|
|
287
|
-
for (const path of files) {
|
|
288
|
-
let source: string
|
|
289
|
-
try {
|
|
290
|
-
source = readFileSync(path, 'utf8')
|
|
291
|
-
} catch {
|
|
292
|
-
continue
|
|
293
|
-
}
|
|
294
|
-
// Skip the scanner's own test fixtures so `audit_test_environment`
|
|
295
|
-
// doesn't report itself.
|
|
296
|
-
if (path.includes('test-audit.test.ts') || path.includes('test-audit-fixture')) {
|
|
297
|
-
continue
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Mask template-literal contents once, then run every counter
|
|
301
|
-
// against the masked source. Patterns inside backticks are
|
|
302
|
-
// FIXTURE strings (the audit tool's own test fixtures, doctest
|
|
303
|
-
// examples, etc.) — they shouldn't count toward any metric.
|
|
304
|
-
// `countMockVNodeLiterals` already does its own masking and runs
|
|
305
|
-
// on `source` so it can do its own work; we pass `source` to
|
|
306
|
-
// keep that contract intact.
|
|
307
|
-
const masked = maskTemplateStrings(source)
|
|
308
|
-
const mockVNodeLiteralCount = countMockVNodeLiterals(source)
|
|
309
|
-
const mockHelperCount = countMatches(masked, MOCK_HELPER_PATTERN)
|
|
310
|
-
const mockHelperCallCount = countMatches(masked, MOCK_HELPER_CALL_PATTERN)
|
|
311
|
-
const realHCallCount = countMatches(masked, REAL_H_CALL_PATTERN)
|
|
312
|
-
const importsH = IMPORT_H_PATTERN.test(masked)
|
|
313
|
-
|
|
314
|
-
const base = {
|
|
315
|
-
path,
|
|
316
|
-
relPath: relative(root, path),
|
|
317
|
-
mockVNodeLiteralCount,
|
|
318
|
-
mockHelperCount,
|
|
319
|
-
mockHelperCallCount,
|
|
320
|
-
realHCallCount,
|
|
321
|
-
importsH,
|
|
322
|
-
}
|
|
323
|
-
entries.push({ ...base, risk: classifyRisk(base) })
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const riskRank = { high: 0, medium: 1, low: 2 }
|
|
327
|
-
entries.sort((a, b) => {
|
|
328
|
-
const cmp = riskRank[a.risk] - riskRank[b.risk]
|
|
329
|
-
if (cmp !== 0) return cmp
|
|
330
|
-
return a.relPath.localeCompare(b.relPath)
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
return { root, entries, totalScanned: files.length }
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
337
|
-
// Formatter
|
|
338
|
-
// ═══════════════════════════════════════════════════════════════════════════════
|
|
339
|
-
|
|
340
|
-
export interface AuditFormatOptions {
|
|
341
|
-
/** Only include entries at or above this risk level. Default 'medium'. */
|
|
342
|
-
minRisk?: AuditRisk | undefined
|
|
343
|
-
/** Maximum entries to show per risk group. Default 20. */
|
|
344
|
-
limit?: number | undefined
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function riskAtOrAbove(risk: AuditRisk, min: AuditRisk): boolean {
|
|
348
|
-
const rank = { high: 0, medium: 1, low: 2 }
|
|
349
|
-
return rank[risk] <= rank[min]
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
export function formatTestAudit(
|
|
353
|
-
result: TestAuditResult,
|
|
354
|
-
{ minRisk = 'medium', limit = 20 }: AuditFormatOptions = {},
|
|
355
|
-
): string {
|
|
356
|
-
if (!result.root) {
|
|
357
|
-
return (
|
|
358
|
-
'No monorepo root found. This tool scans `packages/**/*.test.{ts,tsx}` ' +
|
|
359
|
-
'for mock-vnode patterns. Run the MCP from the Pyreon repo root to ' +
|
|
360
|
-
'get useful output.'
|
|
361
|
-
)
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const relevant = result.entries.filter((e) => riskAtOrAbove(e.risk, minRisk))
|
|
365
|
-
const counts = {
|
|
366
|
-
high: result.entries.filter((e) => e.risk === 'high').length,
|
|
367
|
-
medium: result.entries.filter((e) => e.risk === 'medium').length,
|
|
368
|
-
low: result.entries.filter((e) => e.risk === 'low').length,
|
|
369
|
-
}
|
|
370
|
-
const withMocks = result.entries.filter(
|
|
371
|
-
(e) => e.mockVNodeLiteralCount + e.mockHelperCount > 0,
|
|
372
|
-
).length
|
|
373
|
-
|
|
374
|
-
const parts: string[] = []
|
|
375
|
-
parts.push(`# Test environment audit — ${result.totalScanned} test files scanned`)
|
|
376
|
-
parts.push('')
|
|
377
|
-
parts.push(
|
|
378
|
-
`**Mock-vnode exposure**: ${withMocks} / ${result.totalScanned} files construct \`{ type, props, children }\` literals or a custom \`vnode()\` helper instead of going through the real \`h()\` from \`@pyreon/core\`. This is the bug class that caused PR #197's silent metadata drop — mock-only tests pass while the real pipeline (rocketstyle attrs, compiler transforms, props forwarding) stays unexercised.`,
|
|
379
|
-
)
|
|
380
|
-
parts.push('')
|
|
381
|
-
parts.push(`**Risk counts**: ${counts.high} high · ${counts.medium} medium · ${counts.low} low`)
|
|
382
|
-
parts.push('')
|
|
383
|
-
|
|
384
|
-
if (relevant.length === 0) {
|
|
385
|
-
parts.push(`No files at risk level "${minRisk}" or above. Every test file either avoids mocks entirely or pairs them with real-\`h()\` coverage.`)
|
|
386
|
-
return parts.join('\n')
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const byRisk = new Map<AuditRisk, TestAuditEntry[]>()
|
|
390
|
-
for (const entry of relevant) {
|
|
391
|
-
if (!byRisk.has(entry.risk)) byRisk.set(entry.risk, [])
|
|
392
|
-
byRisk.get(entry.risk)!.push(entry)
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
for (const [risk, group] of byRisk) {
|
|
396
|
-
const shown = group.slice(0, limit)
|
|
397
|
-
parts.push(`## ${risk.toUpperCase()} — ${group.length} file${group.length === 1 ? '' : 's'}${shown.length < group.length ? ` (showing ${shown.length})` : ''}`)
|
|
398
|
-
parts.push('')
|
|
399
|
-
parts.push(describeRisk(risk))
|
|
400
|
-
parts.push('')
|
|
401
|
-
for (const entry of shown) {
|
|
402
|
-
const mocks =
|
|
403
|
-
entry.mockVNodeLiteralCount + entry.mockHelperCount + entry.mockHelperCallCount
|
|
404
|
-
const breakdown: string[] = []
|
|
405
|
-
if (entry.mockVNodeLiteralCount > 0) breakdown.push(`${entry.mockVNodeLiteralCount} literal${entry.mockVNodeLiteralCount === 1 ? '' : 's'}`)
|
|
406
|
-
if (entry.mockHelperCount > 0) breakdown.push(`${entry.mockHelperCount} helper${entry.mockHelperCount === 1 ? '' : 's'}`)
|
|
407
|
-
if (entry.mockHelperCallCount > 0) breakdown.push(`${entry.mockHelperCallCount} helper call${entry.mockHelperCallCount === 1 ? '' : 's'}`)
|
|
408
|
-
const hSide =
|
|
409
|
-
entry.realHCallCount > 0
|
|
410
|
-
? `${entry.realHCallCount} real h() call${entry.realHCallCount === 1 ? '' : 's'}`
|
|
411
|
-
: entry.importsH
|
|
412
|
-
? `imports h but 0 calls found`
|
|
413
|
-
: `no h import`
|
|
414
|
-
parts.push(`- ${entry.relPath} — ${mocks} mock signal${mocks === 1 ? '' : 's'} (${breakdown.join(' + ')}), ${hSide}`)
|
|
415
|
-
}
|
|
416
|
-
parts.push('')
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
parts.push('---')
|
|
420
|
-
parts.push('')
|
|
421
|
-
parts.push(
|
|
422
|
-
'Fix: for each HIGH file, add at least one test that imports `h` from `@pyreon/core` and renders the actual component through `h(RealComponent, props)`. The mock version can stay for speed — it is the LACK of a real-`h()` parallel that blocks bug surfacing.',
|
|
423
|
-
)
|
|
424
|
-
return parts.join('\n')
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function describeRisk(risk: AuditRisk): string {
|
|
428
|
-
if (risk === 'high') {
|
|
429
|
-
return 'Mock patterns present, no real `h()` calls, and no `h` import from `@pyreon/core`. The file has no pathway to exercise the real pipeline — bugs like PR #197 would slip through.'
|
|
430
|
-
}
|
|
431
|
-
if (risk === 'medium') {
|
|
432
|
-
return 'Mock patterns present AND some real `h()` usage — but mocks outnumber real calls, so specific scenarios may be mock-only. Spot-check that each contract the tests assert on goes through at least one real-`h()` path.'
|
|
433
|
-
}
|
|
434
|
-
return 'Mocks dwarfed by real usage OR no mocks at all — low risk.'
|
|
435
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* JS↔Rust backend parity — fixes the two compiler bugs scoped out of the
|
|
3
|
-
* 10-round hardening sweep (#686), in BOTH backends, 1:1.
|
|
4
|
-
*
|
|
5
|
-
* R7 — prop-derived inlining inside callback-nested JSX. Pre-fix the native
|
|
6
|
-
* backend's `collect_prop_derived_idents` had
|
|
7
|
-
* `Arrow|FunctionExpression => {}` (+ no JSX arm), so
|
|
8
|
-
* `const cls=props.t; items.map(i => <li class={cls}/>)` kept `class={cls}`
|
|
9
|
-
* (frozen const → reactivity SILENTLY LOST under the production-preferred
|
|
10
|
-
* native backend) while JS inlined `class={(props.t)}`. Fixed: the Rust arms
|
|
11
|
-
* recurse into fn bodies + JSX with a `pd_minus` scope filter that is
|
|
12
|
-
* byte-equivalent to the JS pass's enter/leave `shadowed` set — so recursing
|
|
13
|
-
* does NOT reintroduce the param-clobber the JS scope-aware pass guards. The
|
|
14
|
-
* JS scope-aware pass is included here too (origin/main lacked it), so a
|
|
15
|
-
* shadowing arrow param is not clobbered on EITHER backend.
|
|
16
|
-
*
|
|
17
|
-
* R9 — an element-valued `const`/`let` (`const h=<h1/>`) used as a bare JSX
|
|
18
|
-
* child was text-coerced (`createTextNode(h)` → "[object Object]") instead of
|
|
19
|
-
* mounted, on both backends. Fixed: both backends track element-valued
|
|
20
|
-
* bindings and route a bare `{h}` child through `_mountSlot` (the path
|
|
21
|
-
* `props.children` already used).
|
|
22
|
-
*
|
|
23
|
-
* Bisect (PR body): (a) revert the Rust `ArrowFunctionExpression`/
|
|
24
|
-
* `FunctionExpression` arms in native/src/lib.rs to `=> {}` + rebuild
|
|
25
|
-
* (`bun run build:native`) → the R7 cross-backend specs fail (Rust reverts to
|
|
26
|
-
* `class={cls}`). (b) Revert the `!shadowed.has(node.name)` guard in
|
|
27
|
-
* `resolveIdentifiersInText` → SHADOW_PARAM JS emits `(props.x) =>`
|
|
28
|
-
* (un-parseable) and diverges. (c) Revert the `isElementValuedIdent` clause
|
|
29
|
-
* in `processOneChild` (+ the Rust `is_element_valued_ident`) → R9 specs
|
|
30
|
-
* fail. Restore + rebuild → all pass.
|
|
31
|
-
*/
|
|
32
|
-
import { parseSync } from 'oxc-parser'
|
|
33
|
-
import { describe, expect, it } from 'vitest'
|
|
34
|
-
import { transformJSX, transformJSX_JS } from '../jsx'
|
|
35
|
-
|
|
36
|
-
const js = (c: string): string => transformJSX_JS(c, 'c.tsx').code ?? ''
|
|
37
|
-
const rust = (c: string): string => transformJSX(c, 'c.tsx').code ?? ''
|
|
38
|
-
const parses = (s: string): boolean => {
|
|
39
|
-
try {
|
|
40
|
-
return (parseSync('o.tsx', s).errors?.length ?? 0) === 0
|
|
41
|
-
} catch {
|
|
42
|
-
return false
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const R7_CALLBACK = `function C(props){ const cls = props.theme + '-btn'; return <ul>{props.items.map(i => <li class={cls}>{i}</li>)}</ul> }`
|
|
47
|
-
const R7_TRANSITIVE = `function C(props){ const a = props.x; const b = a + 1; return <ul>{props.items.map(i => <li>{b}</li>)}</ul> }`
|
|
48
|
-
const R7_SHADOW = `function C(props){ const a = props.x; return <ul>{props.items.map(a => <li>{a}</li>)}</ul> }`
|
|
49
|
-
const R7_DIRECT = `function C(props){ const a = props.x; return <div>{a}</div> }`
|
|
50
|
-
const R9_CONST = `function C(){ const header = <h1>T</h1>; return <div>{header}<p>x</p></div> }`
|
|
51
|
-
const R9_LET = `function C(){ let el = <a/>; return <div>{el}</div> }`
|
|
52
|
-
const R9_STR_CTRL = `function C(){ const t = 'T'; return <div>{t}<p/></div> }`
|
|
53
|
-
|
|
54
|
-
describe('Round 7 — callback-nested prop-derived inlining is 1:1 JS≡Rust', () => {
|
|
55
|
-
it('callback-nested prop-derived inlines on BOTH backends, identically', () => {
|
|
56
|
-
expect(rust(R7_CALLBACK)).toBe(js(R7_CALLBACK))
|
|
57
|
-
expect(js(R7_CALLBACK)).toContain("class={(props.theme + '-btn')}")
|
|
58
|
-
expect(rust(R7_CALLBACK)).toContain("class={(props.theme + '-btn')}")
|
|
59
|
-
})
|
|
60
|
-
it('transitive prop-derived chain inlines in a callback on both backends', () => {
|
|
61
|
-
expect(rust(R7_TRANSITIVE)).toBe(js(R7_TRANSITIVE))
|
|
62
|
-
expect(rust(R7_TRANSITIVE)).toContain('{((props.x) + 1)}')
|
|
63
|
-
})
|
|
64
|
-
it('a shadowing arrow param is NOT clobbered on either backend (parseable + identical)', () => {
|
|
65
|
-
expect(parses(js(R7_SHADOW))).toBe(true)
|
|
66
|
-
expect(parses(rust(R7_SHADOW))).toBe(true)
|
|
67
|
-
expect(js(R7_SHADOW)).not.toContain('(props.x) =>')
|
|
68
|
-
expect(rust(R7_SHADOW)).toBe(js(R7_SHADOW))
|
|
69
|
-
})
|
|
70
|
-
it('direct (non-callback) prop-derived unchanged + identical', () => {
|
|
71
|
-
expect(rust(R7_DIRECT)).toBe(js(R7_DIRECT))
|
|
72
|
-
})
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
describe('Round 9 — element-valued binding child mounts (1:1 JS≡Rust)', () => {
|
|
76
|
-
it('const element child mounts via _mountSlot on both backends, identically', () => {
|
|
77
|
-
expect(rust(R9_CONST)).toBe(js(R9_CONST))
|
|
78
|
-
expect(js(R9_CONST)).not.toContain('createTextNode(header)')
|
|
79
|
-
expect(js(R9_CONST)).toMatch(/_mountSlot\(\s*header\b/)
|
|
80
|
-
expect(rust(R9_CONST)).toMatch(/_mountSlot\(\s*header\b/)
|
|
81
|
-
})
|
|
82
|
-
it('let element child mounts on both backends', () => {
|
|
83
|
-
expect(rust(R9_LET)).toBe(js(R9_LET))
|
|
84
|
-
expect(js(R9_LET)).toMatch(/_mountSlot\(\s*el\b/)
|
|
85
|
-
})
|
|
86
|
-
it('CONTROL: string-valued const still text-coerced (fast path intact) + identical', () => {
|
|
87
|
-
expect(rust(R9_STR_CTRL)).toBe(js(R9_STR_CTRL))
|
|
88
|
-
expect(js(R9_STR_CTRL)).toContain('createTextNode(t)')
|
|
89
|
-
expect(js(R9_STR_CTRL)).not.toContain('_mountSlot')
|
|
90
|
-
})
|
|
91
|
-
})
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Compiler hardening — Round 7 (cross-backend bug, FIXED + bisect-verified).
|
|
3
|
-
*
|
|
4
|
-
* The JS and native (Rust) backends MUST emit byte-identical output (the
|
|
5
|
-
* `native-equivalence.test.ts` contract — 180 such tests). This file pinned a
|
|
6
|
-
* GAP that suite missed: prop-derived const inlining inside callback-nested
|
|
7
|
-
* JSX.
|
|
8
|
-
*
|
|
9
|
-
* const cls = props.theme + '-btn'
|
|
10
|
-
* <ul>{props.items.map(i => <li class={cls}>{i}</li>)}</ul>
|
|
11
|
-
*
|
|
12
|
-
* Pre-fix: JS emitted `class={(props.theme + '-btn')}` (reactive); the native
|
|
13
|
-
* backend — PREFERRED in production — emitted `class={cls}` (the const is
|
|
14
|
-
* captured once → reactivity SILENTLY LOST in real builds for a ubiquitous
|
|
15
|
-
* pattern). Root cause: `collect_prop_derived_idents` (native/src/lib.rs)
|
|
16
|
-
* had `ArrowFunctionExpression | FunctionExpression => {}` (deliberately
|
|
17
|
-
* skipped "to avoid new scope") and NO JSX arm, so it never descended into a
|
|
18
|
-
* `.map(i => <li>{cls}</li>)` callback body. The JS pass walks the whole
|
|
19
|
-
* program AST so it substituted.
|
|
20
|
-
*
|
|
21
|
-
* Fix (native/src/lib.rs): the arrow/function arms now recurse into the body
|
|
22
|
-
* and JSX arms were added, with a `pd_filter` that removes names a scope
|
|
23
|
-
* binds (params / nested const-let / catch / loop) from the prop-derived map
|
|
24
|
-
* for that scope's subtree — byte-equivalent to the JS pass's enter/leave
|
|
25
|
-
* `shadowed` set (R2 parity), so recursing does NOT re-introduce the
|
|
26
|
-
* over-substitution clobber R2 fixed in JS. Validated against all 180
|
|
27
|
-
* native-equivalence tests (still byte-identical) + the full suite.
|
|
28
|
-
*
|
|
29
|
-
* Bisect: in native/src/lib.rs replace the new
|
|
30
|
-
* `Expression::ArrowFunctionExpression(arrow) => { … }` /
|
|
31
|
-
* `Expression::FunctionExpression(func) => { … }` arms with `=> {}` and
|
|
32
|
-
* rebuild (`bun scripts/build-native.ts`) → the cross-backend specs below
|
|
33
|
-
* fail (Rust reverts to `class={cls}`); the DIRECT spec stays green (it was
|
|
34
|
-
* never affected). Restore + rebuild → all pass.
|
|
35
|
-
*/
|
|
36
|
-
import { describe, expect, it } from 'vitest'
|
|
37
|
-
import { transformJSX, transformJSX_JS } from '../jsx'
|
|
38
|
-
|
|
39
|
-
const js = (c: string): string => transformJSX_JS(c, 'c.tsx').code ?? ''
|
|
40
|
-
const rust = (c: string): string => transformJSX(c, 'c.tsx').code ?? ''
|
|
41
|
-
|
|
42
|
-
const CALLBACK_NESTED = `function C(props){ const cls = props.theme + '-btn'; return <ul>{props.items.map(i => <li class={cls}>{i}</li>)}</ul> }`
|
|
43
|
-
const TRANSITIVE_CB = `function C(props){ const a = props.x; const b = a + 1; return <ul>{props.items.map(i => <li>{b}</li>)}</ul> }`
|
|
44
|
-
const SHADOW_PARAM = `function C(props){ const a = props.x; return <ul>{props.items.map(a => <li>{a}</li>)}</ul> }`
|
|
45
|
-
const DIRECT = `function C(props){ const a = props.x; return <div>{a}</div> }`
|
|
46
|
-
|
|
47
|
-
describe('Round 7 — prop-derived inlining inside callback-nested JSX (JS≡Rust)', () => {
|
|
48
|
-
it('JS backend inlines the prop-derived const in the callback (the contract)', () => {
|
|
49
|
-
const out = js(CALLBACK_NESTED)
|
|
50
|
-
expect(out).toContain("class={(props.theme + '-btn')}")
|
|
51
|
-
expect(out).not.toMatch(/class=\{cls\}/)
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('CONTRACT: native backend now inlines callback-nested prop-derived (R7 fixed)', () => {
|
|
55
|
-
expect(rust(CALLBACK_NESTED)).toBe(js(CALLBACK_NESTED))
|
|
56
|
-
expect(rust(CALLBACK_NESTED)).toContain("class={(props.theme + '-btn')}")
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('CONTRACT: transitive prop-derived chain also inlines in a callback, both backends', () => {
|
|
60
|
-
expect(rust(TRANSITIVE_CB)).toBe(js(TRANSITIVE_CB))
|
|
61
|
-
expect(rust(TRANSITIVE_CB)).toContain('{((props.x) + 1)}')
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('CONTRACT: a shadowing arrow param is NOT clobbered (filter prevents the R2 bug in Rust)', () => {
|
|
65
|
-
// `items.map(a => <li>{a}</li>)` with outer `const a=props.x` — `a` is the
|
|
66
|
-
// map param; recursing must NOT rewrite it to `(props.x)`.
|
|
67
|
-
expect(rust(SHADOW_PARAM)).toBe(js(SHADOW_PARAM))
|
|
68
|
-
expect(rust(SHADOW_PARAM)).not.toContain('(props.x) =>')
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('both backends agree on the DIRECT (non-callback) case (unchanged)', () => {
|
|
72
|
-
expect(js(DIRECT)).toBe(rust(DIRECT))
|
|
73
|
-
})
|
|
74
|
-
})
|