@lariocpt/flawless-react 0.0.0-stage → 0.3.1
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/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- 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/<token>` 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
|
+
}
|