@lariocpt/flawless-react 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +608 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,784 @@
1
+ import ts from "typescript";
2
+ import { mockValue } from "../scaffold-tests/mockValues.ts";
3
+ import { withRouteParams } from "../scaffold-tests/registry.ts";
4
+ import type {
5
+ DataDepInfo,
6
+ GuardInfo,
7
+ ModuleImportInfo,
8
+ PredicateInversionInfo,
9
+ PredicateRefusal,
10
+ PredicateSiteInfo,
11
+ PropsShape,
12
+ PropValue,
13
+ } from "../types.ts";
14
+ import { posOf } from "./pos.ts";
15
+ import { bodyStatements } from "./refs.ts";
16
+ import {
17
+ evaluate,
18
+ initialRenderExpression,
19
+ makeEnv,
20
+ type RenderEnv,
21
+ reachability,
22
+ truthy,
23
+ unwrap,
24
+ } from "./renderPath.ts";
25
+
26
+ /**
27
+ * B-19: which predicates can a spec INVERT, and what provably changes when it
28
+ * does. A generated page spec's fixtures make every predicate true, so
29
+ * `userIsTrustee ? A : B` only ever renders A — and no fixture fix can reach
30
+ * B, because reaching it means a second mount with the predicate flipped.
31
+ *
32
+ * Everything here is decided by EVALUATING conditions against the same values
33
+ * the spec will actually mock with (mockValue is mockLiteral's value mirror),
34
+ * once with the defaults and once with a single flip applied. A site counts
35
+ * only when both evaluations settle AND disagree — the certification that
36
+ * makes the second mount's assertions claims about the component rather than
37
+ * hopes. Four flip kinds, each backed by evidence the analyser already holds:
38
+ *
39
+ * dep-bool the dep's own `: boolean` KEYWORD annotation admits false
40
+ * dep-null a nullable-annotated dep behind the page's only truthy guard
41
+ * dep-empty a usage-proven array dep flipped to []
42
+ * prop a prop (or destructured awaited-prop field) the DEFAULTS decide
43
+ *
44
+ * Refusals are named, never silent (the renderUndecidable rule).
45
+ */
46
+
47
+ const MAX_CONTROLS = 3;
48
+
49
+ interface ExtractArgs {
50
+ bodyNode: ts.Node;
51
+ sourceFile: ts.SourceFile;
52
+ dataDeps: DataDepInfo[];
53
+ guards: GuardInfo[];
54
+ moduleImports: ModuleImportInfo[];
55
+ propsShape: PropsShape;
56
+ unresolvedProps: string[];
57
+ staticText: string[];
58
+ }
59
+
60
+ export function extractPredicates(args: ExtractArgs): {
61
+ inversions: PredicateInversionInfo[];
62
+ refusals: PredicateRefusal[];
63
+ } {
64
+ const { bodyNode, sourceFile, dataDeps, guards, staticText } = args;
65
+ const none = { inversions: [], refusals: [] };
66
+
67
+ const props = withRouteParams(args.propsShape);
68
+ const env = makeEnv(props, args.unresolvedProps, []);
69
+ const aliasPaths = foldAwaitedPropAliases(bodyNode, props, env);
70
+ foldDepValues(args, props, env);
71
+
72
+ const render = initialRenderExpression(bodyNode, env);
73
+ if (!render) return none;
74
+
75
+ const jsxSites = collectJsxSites(render.expr);
76
+ const bodySites = collectBodySites(bodyNode);
77
+ const guardVars = guardTaintedVars(bodyNode, guards, sourceFile);
78
+ const callCounts = countCallSites(bodyNode, sourceFile);
79
+
80
+ const inversions: PredicateInversionInfo[] = [];
81
+ const refusals: PredicateRefusal[] = [];
82
+
83
+ const certify = (control: PredicateInversionInfo, flipVar: string): void => {
84
+ const flipped = flipEnv(env, flipVar, control.flip);
85
+ const sites: PredicateSiteInfo[] = [];
86
+ for (const site of jsxSites) {
87
+ const s = certifyJsxSite(site, env, flipped, render.expr, sourceFile);
88
+ if (s) sites.push(s);
89
+ }
90
+ for (const site of bodySites) {
91
+ const s = certifyBodySite(site, env, flipped, args.moduleImports, sourceFile);
92
+ if (s) sites.push(s);
93
+ }
94
+ if (sites.length === 0) {
95
+ refusals.push({ varName: control.varName, reason: "invisible", pos: control.pos });
96
+ return;
97
+ }
98
+ if (!assertable(sites)) {
99
+ refusals.push({ varName: control.varName, reason: "unwitnessed", pos: control.pos });
100
+ return;
101
+ }
102
+ inversions.push({ ...control, sites });
103
+ };
104
+
105
+ // --- dep-backed controls --------------------------------------------------
106
+ for (const dep of dataDeps) {
107
+ if (dep.varName === undefined || dep.specifier === undefined) continue;
108
+ if (dep.callee !== dep.calleeRoot) continue;
109
+ const kind = depControlKind(dep, args.moduleImports);
110
+ if (!kind) continue;
111
+ const pos = dep.pos;
112
+ const varName = dep.varName;
113
+ if ((callCounts.get(dep.calleeRoot) ?? 0) !== 1) {
114
+ refusals.push({ varName, reason: "multi-call", pos });
115
+ continue;
116
+ }
117
+ if (kind === "dep-null") {
118
+ // The one flip that must UN-fire a guard: only certifiable when that
119
+ // guard is the page's only one (another guard could still exit the
120
+ // flipped mount) and the page carries unconditional text to witness.
121
+ const g = guards[0];
122
+ if (
123
+ guards.length !== 1 ||
124
+ !g ||
125
+ g.conditionKind !== "truthy" ||
126
+ g.depVars.length !== 1 ||
127
+ g.depVars[0] !== varName ||
128
+ staticText.length === 0
129
+ ) {
130
+ continue;
131
+ }
132
+ inversions.push({
133
+ kind,
134
+ varName,
135
+ calleeRoot: dep.calleeRoot,
136
+ specifier: dep.specifier,
137
+ awaited: dep.awaited,
138
+ flip: null,
139
+ sites: [],
140
+ pos,
141
+ });
142
+ continue;
143
+ }
144
+ // A control whose var also decides an exit guard is emitGuardTests'
145
+ // territory: that false side EXITS, and inverting it here would assert
146
+ // absences over a mount that rendered nothing.
147
+ if (guardVars.has(varName)) {
148
+ refusals.push({ varName, reason: "guard-entangled", pos });
149
+ continue;
150
+ }
151
+ const baseline = env.state.get(varName);
152
+ if (kind === "dep-bool") {
153
+ if (baseline === undefined || !truthy(baseline)) continue;
154
+ certify(
155
+ {
156
+ kind,
157
+ varName,
158
+ calleeRoot: dep.calleeRoot,
159
+ specifier: dep.specifier,
160
+ awaited: dep.awaited,
161
+ flip: false,
162
+ sites: [],
163
+ pos,
164
+ },
165
+ varName,
166
+ );
167
+ continue;
168
+ }
169
+ // dep-empty: only a fixture that IS non-empty has an empty side to show.
170
+ if (!Array.isArray(baseline) || baseline.length === 0) continue;
171
+ certify(
172
+ {
173
+ kind,
174
+ varName,
175
+ calleeRoot: dep.calleeRoot,
176
+ specifier: dep.specifier,
177
+ awaited: dep.awaited,
178
+ flip: [],
179
+ sites: [],
180
+ pos,
181
+ },
182
+ varName,
183
+ );
184
+ }
185
+
186
+ // --- prop controls --------------------------------------------------------
187
+ for (const { control, local } of propControls(jsxSites, env, aliasPaths, sourceFile)) {
188
+ certify(control, local);
189
+ }
190
+
191
+ // Rank by how much each flip moves, keep the cap honest: what falls off is
192
+ // a named refusal, not a silent drop.
193
+ inversions.sort((a, b) => b.sites.length - a.sites.length || a.pos.line - b.pos.line);
194
+ for (const dropped of inversions.splice(MAX_CONTROLS)) {
195
+ refusals.push({ varName: dropped.varName, reason: "capped", pos: dropped.pos });
196
+ }
197
+ return { inversions, refusals };
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Environment: the values the spec will actually mock with
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * `const { error } = await searchParams` — fold each destructured field of an
206
+ * awaited PROP into the env with the value DEFAULTS carries there, and record
207
+ * the path so a prop flip knows what to override in buildProps.
208
+ */
209
+ function foldAwaitedPropAliases(
210
+ bodyNode: ts.Node,
211
+ props: PropsShape,
212
+ env: RenderEnv,
213
+ ): Map<string, string[]> {
214
+ const paths = new Map<string, string[]>();
215
+ for (const stmt of bodyStatements(bodyNode)) {
216
+ if (!ts.isVariableStatement(stmt)) continue;
217
+ for (const decl of stmt.declarationList.declarations) {
218
+ if (!decl.initializer || !ts.isObjectBindingPattern(decl.name)) continue;
219
+ const init = ts.isAwaitExpression(decl.initializer)
220
+ ? decl.initializer.expression
221
+ : decl.initializer;
222
+ if (!ts.isIdentifier(init)) continue;
223
+ const root = init.text;
224
+ const rootValue = (props as Record<string, PropValue>)[root];
225
+ if (rootValue === undefined) continue;
226
+ for (const el of decl.name.elements) {
227
+ if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
228
+ const key =
229
+ el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
230
+ const local = el.name.text;
231
+ paths.set(local, [root, key]);
232
+ const v =
233
+ rootValue !== null && typeof rootValue === "object" && !Array.isArray(rootValue)
234
+ ? (rootValue as Record<string, PropValue>)[key]
235
+ : undefined;
236
+ if (v !== undefined) env.state.set(local, v);
237
+ else env.opaque.add(local);
238
+ }
239
+ }
240
+ }
241
+ return paths;
242
+ }
243
+
244
+ /** Dep var → the PropValue its emitted mock resolves to, folded into env. */
245
+ function foldDepValues(args: ExtractArgs, props: PropsShape, env: RenderEnv): void {
246
+ for (const dep of args.dataDeps) {
247
+ if (dep.varName === undefined) continue;
248
+ if (dep.uncertain || dep.shape.kind === "unknown") {
249
+ // The emitted mock is the annotation value, or {} — an OpenAPI join is
250
+ // emit-only knowledge, and {} keeps the same truthiness while making
251
+ // its field reads undecidable, which safely skips those sites.
252
+ env.state.set(dep.varName, dep.inferredReturn?.value ?? {});
253
+ continue;
254
+ }
255
+ let v = mockValue(dep.shape, dep.calleeRoot, props);
256
+ const fill = importedReturnFor(args.moduleImports, dep)?.value;
257
+ v = overlayFill(v, fill);
258
+ env.state.set(dep.varName, v);
259
+ }
260
+ }
261
+
262
+ /** The importedReturns entry for this dep's callee, resolved by module-side name. */
263
+ function importedReturnFor(
264
+ moduleImports: ModuleImportInfo[],
265
+ dep: DataDepInfo,
266
+ ):
267
+ | { value: PropValue; sync: boolean; booleanAnnotated?: true; nullableAnnotated?: true }
268
+ | undefined {
269
+ for (const mi of moduleImports) {
270
+ if (dep.specifier !== undefined && mi.specifier !== dep.specifier) continue;
271
+ const i = mi.names.indexOf(dep.calleeRoot);
272
+ if (i < 0) continue;
273
+ const moduleName = mi.importedNames?.[i] ?? dep.calleeRoot;
274
+ const entry = mi.importedReturns?.[moduleName];
275
+ if (entry) return entry;
276
+ }
277
+ return undefined;
278
+ }
279
+
280
+ /**
281
+ * The same placeholder-holes-only overlay the emitter applies (mockValues'
282
+ * fieldLiteral fill rule) — the env must hold the values the mount will see.
283
+ */
284
+ function overlayFill(v: PropValue, fill: PropValue | undefined): PropValue {
285
+ if (fill === undefined || fill === null) return v;
286
+ if (Array.isArray(v)) {
287
+ const el = Array.isArray(fill) ? fill[0] : undefined;
288
+ return el === undefined ? v : v.map((x) => overlayFill(x, el));
289
+ }
290
+ if (v !== null && typeof v === "object") {
291
+ if (fill === null || typeof fill !== "object" || Array.isArray(fill)) return v;
292
+ const out: Record<string, PropValue> = { ...(v as Record<string, PropValue>) };
293
+ for (const [k, cur] of Object.entries(out)) {
294
+ const f = (fill as Record<string, PropValue>)[k];
295
+ if (f === undefined) continue;
296
+ if (typeof cur === "string" && cur.startsWith("flawless-")) {
297
+ if (typeof f === "string" && !f.startsWith("flawless-")) out[k] = f;
298
+ continue;
299
+ }
300
+ out[k] = overlayFill(cur, f);
301
+ }
302
+ return out;
303
+ }
304
+ return v;
305
+ }
306
+
307
+ function flipEnv(env: RenderEnv, varName: string, flip: PropValue | undefined): RenderEnv {
308
+ const out: RenderEnv = {
309
+ props: new Map(env.props),
310
+ state: new Map(env.state),
311
+ opaque: new Set(env.opaque),
312
+ };
313
+ out.opaque.delete(varName);
314
+ // A flip to `undefined` (an omitted prop) is modelled as null: evaluate has
315
+ // no way to distinguish a stored undefined from "unknown", and null carries
316
+ // the same falsiness through every condition shape this certifies.
317
+ out.state.set(varName, flip === undefined ? null : flip);
318
+ return out;
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Controls
323
+ // ---------------------------------------------------------------------------
324
+
325
+ function depControlKind(
326
+ dep: DataDepInfo,
327
+ moduleImports: ModuleImportInfo[],
328
+ ): "dep-bool" | "dep-null" | "dep-empty" | undefined {
329
+ const entry = importedReturnFor(moduleImports, dep);
330
+ const booleanAnnotated = dep.inferredReturn?.booleanAnnotated ?? entry?.booleanAnnotated;
331
+ if (booleanAnnotated) return "dep-bool";
332
+ const nullableAnnotated = dep.inferredReturn?.nullableAnnotated ?? entry?.nullableAnnotated;
333
+ if (nullableAnnotated) return "dep-null";
334
+ if (dep.awaited && dep.shape.kind === "array" && dep.shape.element) return "dep-empty";
335
+ return undefined;
336
+ }
337
+
338
+ /**
339
+ * Sites whose condition the DEFAULTS alone decide, flipped by overriding one
340
+ * prop (or destructured awaited-prop field). Candidates per condition: every
341
+ * literal it strictly compares the name against, plus absence.
342
+ */
343
+ function propControls(
344
+ sites: JsxSite[],
345
+ env: RenderEnv,
346
+ aliasPaths: Map<string, string[]>,
347
+ sourceFile: ts.SourceFile,
348
+ ): { control: PredicateInversionInfo; local: string }[] {
349
+ const out = new Map<string, { control: PredicateInversionInfo; local: string }>();
350
+ for (const site of sites) {
351
+ const names = identifierLeaves(site.condition);
352
+ if (names.length !== 1) continue;
353
+ const name = names[0] as string;
354
+ const isProp = env.props.has(name);
355
+ const isAlias = aliasPaths.has(name);
356
+ if (!isProp && !isAlias) continue;
357
+ if (evaluate(site.condition, env) === undefined) continue;
358
+ const path = isAlias ? (aliasPaths.get(name) as string[]) : [name];
359
+ // Every literal the condition compares the name against, absence, and a
360
+ // truthy string — the last is what flips a bare `token ?` whose default
361
+ // is empty.
362
+ const candidates: (PropValue | undefined)[] = [
363
+ ...comparedLiterals(site.condition, name),
364
+ undefined,
365
+ `flawless-${path[path.length - 1]}`,
366
+ ];
367
+ for (const flip of candidates) {
368
+ const key = `${path.join(".")}=${JSON.stringify(flip ?? null)}`;
369
+ if (out.has(key)) break;
370
+ const flipped = flipEnv(env, name, flip);
371
+ const before = evaluate(site.condition, env);
372
+ const after = evaluate(site.condition, flipped);
373
+ if (before === undefined || after === undefined) continue;
374
+ if (truthy(before) === truthy(after)) continue;
375
+ out.set(key, {
376
+ control: {
377
+ kind: "prop",
378
+ varName: path.join("."),
379
+ propPath: path,
380
+ ...(flip !== undefined ? { flip } : {}),
381
+ sites: [],
382
+ pos: posOf(site.pos, sourceFile),
383
+ },
384
+ local: name,
385
+ });
386
+ break;
387
+ }
388
+ }
389
+ return [...out.values()];
390
+ }
391
+
392
+ function identifierLeaves(expr: ts.Expression): string[] {
393
+ const out = new Set<string>();
394
+ const visit = (n: ts.Node): void => {
395
+ if (ts.isIdentifier(n) && n.text !== "undefined") {
396
+ const parent = n.parent;
397
+ // A property NAME is not a reference; the access root is.
398
+ if (parent && ts.isPropertyAccessExpression(parent) && parent.name === n) return;
399
+ out.add(n.text);
400
+ return;
401
+ }
402
+ ts.forEachChild(n, visit);
403
+ };
404
+ visit(expr);
405
+ return [...out];
406
+ }
407
+
408
+ function comparedLiterals(expr: ts.Expression, name: string): PropValue[] {
409
+ const out: PropValue[] = [];
410
+ const visit = (n: ts.Node): void => {
411
+ if (
412
+ ts.isBinaryExpression(n) &&
413
+ (n.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
414
+ n.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken)
415
+ ) {
416
+ for (const [side, other] of [
417
+ [n.left, n.right],
418
+ [n.right, n.left],
419
+ ] as const) {
420
+ const s = unwrap(side);
421
+ const referencesName =
422
+ (ts.isIdentifier(s) && s.text === name) ||
423
+ (ts.isPropertyAccessExpression(s) &&
424
+ ts.isIdentifier(s.expression) &&
425
+ s.expression.text === name);
426
+ if (!referencesName) continue;
427
+ const o = unwrap(other);
428
+ if (ts.isStringLiteral(o)) out.push(o.text);
429
+ else if (ts.isNumericLiteral(o)) out.push(Number(o.text));
430
+ }
431
+ }
432
+ ts.forEachChild(n, visit);
433
+ };
434
+ visit(expr);
435
+ return out;
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Sites
440
+ // ---------------------------------------------------------------------------
441
+
442
+ interface JsxSite {
443
+ kind: "ternary" | "and";
444
+ condition: ts.Expression;
445
+ whenTrue?: ts.Expression;
446
+ whenFalse?: ts.Expression;
447
+ node: ts.Node;
448
+ pos: ts.Node;
449
+ }
450
+
451
+ /**
452
+ * Render-position conditionals: ternaries and `&&` in JSX-child position.
453
+ * Attribute values are out (a claim about what a CHILD renders needs evidence
454
+ * this analyser does not hold), and so is anything inside a callback — a row
455
+ * env is not the mount env.
456
+ */
457
+ function collectJsxSites(root: ts.Expression): JsxSite[] {
458
+ const out: JsxSite[] = [];
459
+ const visit = (n: ts.Node): void => {
460
+ if (ts.isFunctionLike(n)) return;
461
+ if (ts.isJsxAttribute(n)) return;
462
+ if (ts.isConditionalExpression(n) && inJsxChildPosition(n)) {
463
+ out.push({
464
+ kind: "ternary",
465
+ condition: n.condition,
466
+ whenTrue: n.whenTrue,
467
+ whenFalse: n.whenFalse,
468
+ node: n,
469
+ pos: n,
470
+ });
471
+ } else if (
472
+ ts.isBinaryExpression(n) &&
473
+ n.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
474
+ inJsxChildPosition(n)
475
+ ) {
476
+ out.push({ kind: "and", condition: n.left, whenTrue: n.right, node: n, pos: n });
477
+ // The right side may nest further sites; the condition side may not.
478
+ visit(n.right);
479
+ return;
480
+ }
481
+ ts.forEachChild(n, visit);
482
+ };
483
+ visit(root);
484
+ return out;
485
+ }
486
+
487
+ /** `{ … }` directly under a JsxElement/Fragment — text position, not an attr. */
488
+ function inJsxChildPosition(n: ts.Node): boolean {
489
+ let cur: ts.Node | undefined = n.parent;
490
+ while (cur) {
491
+ if (ts.isParenthesizedExpression(cur) || ts.isConditionalExpression(cur)) {
492
+ cur = cur.parent;
493
+ continue;
494
+ }
495
+ if (
496
+ ts.isBinaryExpression(cur) &&
497
+ (cur.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
498
+ cur.operatorToken.kind === ts.SyntaxKind.BarBarToken)
499
+ ) {
500
+ cur = cur.parent;
501
+ continue;
502
+ }
503
+ if (ts.isJsxExpression(cur)) {
504
+ const holder = cur.parent;
505
+ return holder !== undefined && (ts.isJsxElement(holder) || ts.isJsxFragment(holder));
506
+ }
507
+ // The whole returned expression is a legal site holder too.
508
+ return ts.isReturnStatement(cur) || ts.isArrowFunction(cur);
509
+ }
510
+ return false;
511
+ }
512
+
513
+ interface BodySite {
514
+ condition: ts.Expression;
515
+ whenTrue: ts.Expression;
516
+ whenFalse: ts.Expression;
517
+ pos: ts.Node;
518
+ }
519
+
520
+ /** Data-selection ternaries in top-level initializers: `c ? f(…) : g(…)`. */
521
+ function collectBodySites(bodyNode: ts.Node): BodySite[] {
522
+ const out: BodySite[] = [];
523
+ for (const stmt of bodyStatements(bodyNode)) {
524
+ if (!ts.isVariableStatement(stmt)) continue;
525
+ for (const decl of stmt.declarationList.declarations) {
526
+ if (!decl.initializer) continue;
527
+ const visit = (n: ts.Node): void => {
528
+ if (ts.isFunctionLike(n)) return;
529
+ if (ts.isConditionalExpression(n)) {
530
+ out.push({
531
+ condition: n.condition,
532
+ whenTrue: n.whenTrue,
533
+ whenFalse: n.whenFalse,
534
+ pos: n,
535
+ });
536
+ }
537
+ ts.forEachChild(n, visit);
538
+ };
539
+ visit(decl.initializer);
540
+ }
541
+ }
542
+ return out;
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Certification: does THIS flip move THIS site, and what witnesses it
547
+ // ---------------------------------------------------------------------------
548
+
549
+ function certifyJsxSite(
550
+ site: JsxSite,
551
+ env: RenderEnv,
552
+ flipped: RenderEnv,
553
+ root: ts.Expression,
554
+ sourceFile: ts.SourceFile,
555
+ ): PredicateSiteInfo | undefined {
556
+ const before = evaluate(site.condition, env);
557
+ const after = evaluate(site.condition, flipped);
558
+ if (before === undefined || after === undefined) return undefined;
559
+ if (truthy(before) === truthy(after)) return undefined;
560
+
561
+ const trueWitness = site.whenTrue ? harvestWitness(site.whenTrue, sourceFile) : undefined;
562
+ const falseWitness = site.whenFalse ? harvestWitness(site.whenFalse, sourceFile) : undefined;
563
+ // A pair where one witness contains the other cannot support absence
564
+ // assertions — asserting "All invoices" absent while "My invoices" renders
565
+ // is fine, but "invoices" vs "My invoices" is not.
566
+ const pair =
567
+ trueWitness !== undefined &&
568
+ falseWitness !== undefined &&
569
+ (trueWitness.includes(falseWitness) || falseWitness.includes(trueWitness))
570
+ ? {}
571
+ : { trueWitness, falseWitness };
572
+
573
+ const reachableAtDefaults = reachability(site.node, root, env) === "yes";
574
+ const reachableWhenFlipped = reachability(site.node, root, flipped) === "yes";
575
+ if (!reachableAtDefaults && !reachableWhenFlipped) return undefined;
576
+ return {
577
+ baselineSide: truthy(before),
578
+ ...(pair.trueWitness !== undefined ? { trueWitness: pair.trueWitness } : {}),
579
+ ...(pair.falseWitness !== undefined ? { falseWitness: pair.falseWitness } : {}),
580
+ reachableAtDefaults,
581
+ reachableWhenFlipped,
582
+ pos: posOf(site.pos, sourceFile),
583
+ };
584
+ }
585
+
586
+ function certifyBodySite(
587
+ site: BodySite,
588
+ env: RenderEnv,
589
+ flipped: RenderEnv,
590
+ moduleImports: ModuleImportInfo[],
591
+ sourceFile: ts.SourceFile,
592
+ ): PredicateSiteInfo | undefined {
593
+ const before = evaluate(site.condition, env);
594
+ const after = evaluate(site.condition, flipped);
595
+ if (before === undefined || after === undefined) return undefined;
596
+ if (truthy(before) === truthy(after)) return undefined;
597
+
598
+ const trueCallee = mockedCallee(site.whenTrue, moduleImports);
599
+ const falseCallee = mockedCallee(site.whenFalse, moduleImports);
600
+ if (trueCallee === undefined && falseCallee === undefined) return undefined;
601
+ return {
602
+ baselineSide: truthy(before),
603
+ ...(trueCallee !== undefined ? { trueCallee } : {}),
604
+ ...(falseCallee !== undefined ? { falseCallee } : {}),
605
+ reachableAtDefaults: true,
606
+ reachableWhenFlipped: true,
607
+ pos: posOf(site.pos, sourceFile),
608
+ };
609
+ }
610
+
611
+ /** The arm is a single call of a mocked import — a countable witness. */
612
+ function mockedCallee(arm: ts.Expression, moduleImports: ModuleImportInfo[]): string | undefined {
613
+ const node = unwrap(arm);
614
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) return undefined;
615
+ const name = node.expression.text;
616
+ const imported = moduleImports.some(
617
+ (mi) => !mi.isTypeOnly && mi.kind === "value" && mi.names.includes(name),
618
+ );
619
+ return imported ? name : undefined;
620
+ }
621
+
622
+ /**
623
+ * The longest text-position literal unconditional WITHIN the arm: JsxText, a
624
+ * string in JSX-child expression position, or the arm itself being a bare
625
+ * string. Nested conditionals and callbacks are someone else's site.
626
+ */
627
+ function harvestWitness(arm: ts.Expression, sourceFile: ts.SourceFile): string | undefined {
628
+ const node = unwrap(arm);
629
+ const found: string[] = [];
630
+ const note = (raw: string): void => {
631
+ // JsxText arrives source-raw, but the DOM the assertion reads is DECODED:
632
+ // `/invite/&lt;token&gt;` renders as `/invite/<token>`. Decode what we
633
+ // know; an entity we don't is a witness we refuse rather than misquote.
634
+ const decoded = decodeJsxEntities(raw);
635
+ if (decoded === undefined) return;
636
+ const text = decoded.replace(/\s+/g, " ").trim();
637
+ if (text.length >= 3 && /\w/.test(text)) found.push(text);
638
+ };
639
+ if (ts.isStringLiteral(node)) note(node.text);
640
+ const visit = (n: ts.Node): void => {
641
+ if (ts.isFunctionLike(n)) return;
642
+ if (ts.isConditionalExpression(n)) return;
643
+ if (
644
+ ts.isBinaryExpression(n) &&
645
+ n.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
646
+ ) {
647
+ return;
648
+ }
649
+ if (ts.isJsxAttribute(n)) return;
650
+ if (ts.isJsxText(n)) {
651
+ note(n.text);
652
+ return;
653
+ }
654
+ if (
655
+ ts.isStringLiteral(n) &&
656
+ n.parent &&
657
+ ts.isJsxExpression(n.parent) &&
658
+ n.parent.parent &&
659
+ (ts.isJsxElement(n.parent.parent) || ts.isJsxFragment(n.parent.parent))
660
+ ) {
661
+ note(n.text);
662
+ return;
663
+ }
664
+ ts.forEachChild(n, visit);
665
+ };
666
+ visit(node);
667
+ void sourceFile;
668
+ if (found.length === 0) return undefined;
669
+ return found.reduce((a, b) => (b.length > a.length ? b : a));
670
+ }
671
+
672
+ const NAMED_ENTITIES: Record<string, string> = {
673
+ amp: "&",
674
+ lt: "<",
675
+ gt: ">",
676
+ quot: '"',
677
+ apos: "'",
678
+ nbsp: " ",
679
+ mdash: "—",
680
+ ndash: "–",
681
+ hellip: "…",
682
+ lsquo: "‘",
683
+ rsquo: "’",
684
+ ldquo: "“",
685
+ rdquo: "”",
686
+ rarr: "→",
687
+ larr: "←",
688
+ times: "×",
689
+ middot: "·",
690
+ bull: "•",
691
+ copy: "©",
692
+ };
693
+
694
+ /** Decoded text, or undefined when an entity we cannot decode remains. */
695
+ function decodeJsxEntities(raw: string): string | undefined {
696
+ let bad = false;
697
+ const out = raw.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (whole, body: string) => {
698
+ if (body.startsWith("#x") || body.startsWith("#X")) {
699
+ return String.fromCodePoint(Number.parseInt(body.slice(2), 16));
700
+ }
701
+ if (body.startsWith("#")) return String.fromCodePoint(Number.parseInt(body.slice(1), 10));
702
+ const named = NAMED_ENTITIES[body];
703
+ if (named === undefined) {
704
+ bad = true;
705
+ return whole;
706
+ }
707
+ return named;
708
+ });
709
+ return bad ? undefined : out;
710
+ }
711
+
712
+ /** At least one leg can make a POSITIVE claim — pure absence is B-26's form. */
713
+ function assertable(sites: PredicateSiteInfo[]): boolean {
714
+ return sites.some((s) => {
715
+ if (s.trueCallee !== undefined || s.falseCallee !== undefined) return true;
716
+ const flippedWitness = s.baselineSide ? s.falseWitness : s.trueWitness;
717
+ const baselineWitness = s.baselineSide ? s.trueWitness : s.falseWitness;
718
+ if (flippedWitness !== undefined && s.reachableWhenFlipped) return true;
719
+ // Absence-only is allowed when the SAME witness is proven present in leg
720
+ // one — the pair structure keeps it falsifiable.
721
+ return baselineWitness !== undefined && s.reachableAtDefaults && s.reachableWhenFlipped;
722
+ });
723
+ }
724
+
725
+ /**
726
+ * Every variable whose VALUE can decide an exit guard — the direct condition
727
+ * identifiers, closed backward through top-level initializers: with
728
+ * `const mine = members.find(…)` and a guard on `mine`, flipping `members`
729
+ * changes the guard's outcome just as surely, and an inverted mount that
730
+ * guard-exits asserts absences over nothing. Guard if-statements are matched
731
+ * by the position GuardInfo recorded.
732
+ */
733
+ function guardTaintedVars(
734
+ bodyNode: ts.Node,
735
+ guards: GuardInfo[],
736
+ sourceFile: ts.SourceFile,
737
+ ): Set<string> {
738
+ const guardPositions = new Set(guards.map((g) => `${g.pos.line}:${g.pos.column}`));
739
+ const tainted = new Set<string>();
740
+ const initializers = new Map<string, ts.Expression>();
741
+ for (const stmt of bodyStatements(bodyNode)) {
742
+ if (ts.isIfStatement(stmt)) {
743
+ const p = posOf(stmt, sourceFile);
744
+ if (guardPositions.has(`${p.line}:${p.column}`)) {
745
+ for (const id of identifierLeaves(stmt.expression)) tainted.add(id);
746
+ }
747
+ continue;
748
+ }
749
+ if (!ts.isVariableStatement(stmt)) continue;
750
+ for (const decl of stmt.declarationList.declarations) {
751
+ if (decl.initializer && ts.isIdentifier(decl.name)) {
752
+ initializers.set(decl.name.text, decl.initializer);
753
+ }
754
+ }
755
+ }
756
+ let grew = true;
757
+ while (grew) {
758
+ grew = false;
759
+ for (const [name, init] of initializers) {
760
+ if (!tainted.has(name)) continue;
761
+ for (const id of identifierLeaves(init)) {
762
+ if (!tainted.has(id)) {
763
+ tainted.add(id);
764
+ grew = true;
765
+ }
766
+ }
767
+ }
768
+ }
769
+ return tainted;
770
+ }
771
+
772
+ /** Call sites per bare-identifier callee, across the whole body. */
773
+ function countCallSites(bodyNode: ts.Node, sourceFile: ts.SourceFile): Map<string, number> {
774
+ void sourceFile;
775
+ const out = new Map<string, number>();
776
+ const visit = (n: ts.Node): void => {
777
+ if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
778
+ out.set(n.expression.text, (out.get(n.expression.text) ?? 0) + 1);
779
+ }
780
+ ts.forEachChild(n, visit);
781
+ };
782
+ visit(bodyNode);
783
+ return out;
784
+ }