Skip to content

Commit a44d1a4

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(design-diff): trace presentation inputs and bound schema 3 reports
1 parent ee213d9 commit a44d1a4

26 files changed

Lines changed: 9746 additions & 163 deletions

‎design-diff.config.json‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,23 @@
8282
"@/lib/cn"
8383
],
8484
"variantModules": ["class-variance-authority"],
85-
"mergeFontSizes": ["micro", "caption", "small", "md"]
85+
"mergeFontSizes": ["micro", "caption", "small", "md"],
86+
"fileInputs": [
87+
{
88+
"list": "apps/docs/lib/openapi-specs.ts",
89+
"export": "OPENAPI_SPEC_FILES",
90+
"root": "apps/docs",
91+
"renderer": "apps/docs/lib/openapi.ts"
92+
}
93+
],
94+
"environmentAdapters": [
95+
{
96+
"module": "apps/sim/lib/core/config/env-capabilities.server.ts",
97+
"export": "wireServerFallback",
98+
"environmentModule": "apps/sim/lib/core/config/env.ts",
99+
"environmentExport": "env",
100+
"implementationModule": "packages/deployment-config/src/env-capabilities.ts",
101+
"implementationExport": "wireFallback"
102+
}
103+
]
86104
}

‎scripts/design-diff/README.md‎

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ duplicating entire function bodies. Reports also include schema, engine and poli
2626
base/head/merge-base SHAs, and workflow PR/head/engine identity. No timestamp enters the
2727
deterministic payload. Finding IDs are stable for the same input pair and engine version.
2828

29-
## Binary decisions and grouped report (schema 2)
29+
## Binary decisions and grouped report (schema 3)
3030

3131
The public decisions are **`flag`** and **`exempt`**. Uncertainty produces `flag`, with
32-
the obstacle recorded in `limitations`. Schema and policy version `2.0.0`, engine version
33-
`0.2.0`, replace the earlier public `review` decision and flat consumer findings.
32+
the obstacle recorded in `limitations`. Schema and policy version `3.0.0`, engine version `0.3.0`, retain binary decisions
33+
and add bounded values and explicit report truncation metadata.
3434

3535
Each finding groups evidence by **changed source file**. Several changed definitions in
3636
one file remain individually available in `changes`. Unchanged downstream consumers do not
@@ -42,7 +42,7 @@ one finding anchored to its source, with a representative consumer and usage met
4242
| `source.before` / `source.after` | Changed source path and Git blob ID, or `null` for addition/deletion |
4343
| `decision` | `flag` if any grouped evidence requires reporting; otherwise `exempt` |
4444
| `category`, `symbol`, `reason`, `before`, `after` | Representative evidence; its locations can be in an unchanged consumer of a changed token |
45-
| `categories` | Categories of the retained direct evidence and representative consumer |
45+
| `categories` | Categories of all direct evidence and the changed consumer |
4646
| `changes` | Direct definition changes in the changed file, retaining individual decisions, values and locations |
4747
| `example` | One consumer evidence record, or `null`; `basis` distinguishes `changed-definition` from `potential-consumer` |
4848
| `impact.before` / `impact.after` | Separate counts and source locations for resolved direct references in each revision |
@@ -61,8 +61,14 @@ references do not count. Dynamic/ambiguous imports are not presented as exact us
6161
Zero means no references were enumerated, not proof of no consumers. Overrides, inactive
6262
variants and runtime conditions can prevent a referenced component from changing visually.
6363

64-
Consumers upgrading from schema 1 must read direct evidence from `changes` and the optional
65-
`example`, use `source` to identify the changed file, and stop expecting `decision: review`.
64+
Schema 3 readers must handle either literal values or a summary object containing
65+
`$truncated`, `preview`, `sha256`, `originalBytes`, `previewBytes` and `omittedBytes`.
66+
The default preview is at most 4 KiB. Full semantic evidence is compared before presentation
67+
values are shortened; large opaque helper summaries retain full-value hashes too.
68+
`truncation` records the 5 MiB serialized-report limit, total/omitted findings, and total/omitted
69+
entries for sampled detail/reference lists. Sampling retains stable prefixes. The overall
70+
`flagged` result is computed before sampling, so an omitted finding cannot make a PR clean.
71+
Read direct evidence from `changes` and optional `example`; use `source` for the changed file.
6672

6773
## Architecture
6874

@@ -89,7 +95,13 @@ scripts/design-diff/
8995
source.ts Source snapshots and path/alias resolution
9096
dependencies.ts Export-aware imports and source reference counts
9197
ast.ts Babel parsing and syntax normalization
98+
refactors.ts Supported literal/refactor normalization
9299
resolve.ts Bounded expression and import resolution
100+
inputs.ts Configured file-loaded documentation inputs
101+
infrastructure.ts Rendering lockfile dependency closure
102+
report.ts Value previews and bounded JSON serialization
103+
benchmark.ts Immutable-engine historical replay
104+
benchmark/comparisons.json Frozen original/holdout comparison manifest
93105
memory.ts Bun parser-batch garbage collection
94106
tailwind.ts Pinned compiler and trusted merge convention
95107
compare.ts Stable matching and findings
@@ -147,11 +159,28 @@ through named/star re-exports and import-then-export indexes to the defining mod
147159
Button edit does not implicate a file merely because it imports an unrelated Icon from the
148160
same index. Changed top-level bindings and local dependents narrow the first propagation
149161
step and usage counts within a multi-export file. Both revisions are considered, including
150-
redirected re-exports. Module effects, side-effect imports, computed namespaces, ambiguous
151-
exports, cycles and resolution limits retain conservative dependencies. Further transitive
152-
module effects can still overestimate potential impact.
153-
154-
The resolver reuses the export index, retains at most 128 parsed modules per revision,
162+
redirected re-exports. The module graph identifies candidates only. Actual findings require
163+
changed values, guards, referenced implementations or an explicitly unresolved imported input.
164+
Unrelated imports and dead re-exports cannot flag an unchanged expression. Object properties,
165+
destructured parameter defaults, and helper return paths are traced separately. Small immutable
166+
local literals are normalized before expression budgets, preserving supported constant hoists.
167+
The exact `Object.entries(...).reduce` record-map idiom is normalized to `Object.fromEntries`
168+
only with an empty accumulator, unchanged key and no accumulator reads in the mapped value. Unknown
169+
computed namespaces, ambiguous imports, cycles and exhausted resolution remain conservative
170+
when that particular input feeds rendering. SQL tags and telemetry receivers are not standalone
171+
rendering definitions; their values can still matter if explicitly consumed by a visual input.
172+
173+
The configured `wireServerFallback` capability adapter traces its declared environment fields,
174+
provider factories and implementation. Its finite capability schema identifies relevant keys;
175+
unsupported dynamic schemas use ordinary conservative resolution. Whole-environment backend
176+
settings therefore do not contaminate the signup page solely through this known adapter.
177+
178+
The configured Fumadocs `OPENAPI_SPEC_FILES` list is parsed from Git in each revision. Listed
179+
JSON inputs are compared semantically, retaining array order and attributing changes to both
180+
the spec and configured renderer. Malformed/missing configured inputs flag with a limitation.
181+
182+
The graph reuses up to 10,000 import/export snapshots keyed by source blob, resolving their
183+
paths again for each revision. The resolver retains at most 128 parsed modules per revision,
155184
and requests Bun garbage collection between parser batches. These resource controls do
156185
not change evidence or decisions. The Node-based test runner uses its own garbage collector.
157186

@@ -170,7 +199,7 @@ application plugins or every postprocessor have been reproduced.
170199

171200
Desktop support extracts recognized `BrowserWindow` appearance options, native appearance
172201
setter calls and `nativeTheme.themeSource` assignments. Configured native
173-
menu/tray/terminal-theme modules use an uncertainty fallback, including their changed dependencies,
202+
menu/tray/terminal-theme modules use an uncertainty fallback for their own changed definitions,
174203
because embedded JXA and native operating-system rendering are not executed.
175204

176205
## Movement proof and remaining limits
@@ -186,20 +215,21 @@ change the result. There is no blanket exemption for translation, margins, gaps
186215
This engine is conservative, not a runtime equivalence prover:
187216

188217
- Runtime data, arbitrary functions, mutable bindings, dependency cycles, parser failures,
189-
unknown props and unsupported rendering syntax produce flags with limitations when affected.
218+
unknown props and unsupported rendering syntax produce flags when changed presentation evidence
219+
or a traceable unresolved rendering input changes. They are not blanket module-level triggers.
190220
- Source-order matching after substantial markup edits can pair different elements. Such
191221
changes remain flagged; findings are evidence for review, not an exact DOM correspondence.
192222
- Dynamic module/asset paths, inherited/conditional export maps outside the supported forms,
193223
generated source and arbitrary imperative renderers cannot be fully followed. Directly
194224
detected DOM/canvas operations and configured native rendering use uncertainty fallbacks.
195225
- MDX expressions and embedded HTML scripts are flagged, without running MDX components or
196226
scripts. Plain HTML whitespace is preserved because CSS can make it meaningful.
197-
- Broad dependency updates and shared runtime expressions can create false positives. All
198-
lockfile changes are flagged, including changes to tooling-only dependencies. Inactive
199-
variants, unused assets and an apparently inert removed class can also be flagged.
227+
- Lockfiles compare recognized rendering dependencies and their resolved transitive closure.
228+
Unrelated tooling/backend updates are clean. Unknown rendering configs/plugins still flag.
229+
Inactive variants, unused assets and an apparently inert removed class can also be flagged.
200230
- Impact is a conservative approximation. Findings retain direct source changes and one
201231
consumer example instead of thousands of downstream records. Large changed definitions
202-
and usage inventories can still produce substantial JSON reports.
232+
and usage inventories are sampled deterministically within the report budget.
203233
- The default limits are 2 MiB per source file, 256 MiB per source snapshot, 24 resolution
204234
levels and 5,000 evaluation steps per expression. Per-file/parser/expression limits produce
205235
flags with limitations; snapshot/Git failures are operational failures, never clean results.
@@ -259,7 +289,7 @@ are whole commits, including ancillary changes, rather than only their headline
259289
These comparisons validate source-policy behavior, not rendered pixels or recall over all
260290
historical PRs. Screenshot capture, AI interpretation and Slack delivery are separate stages.
261291

262-
### Incremental smoke test on PR #7742
292+
### Earlier incremental smoke test on PR #7742 (schema 2)
263293

264294
Five temporary commits were created directly on `458a515cbed7fbcf127ce72348ff755c5308ce13`,
265295
each changing one existing source file. Comparing that commit with each temporary head
@@ -288,5 +318,26 @@ These are partial source-reference counts, not claims that every reference chang
288318
Independent changed source files remain separate findings, and all direct definition changes
289319
within each changed file are retained.
290320

291-
These checks validate local engine behavior, not cloud workflow activation. They are examples,
321+
These earlier checks do not validate the revised schema 3 engine or cloud workflow activation. They are examples,
292322
not a measured detection rate across all possible UI changes.
323+
324+
## Frozen historical benchmark
325+
326+
The manifest preserves the original 120 comparisons (baseline: 108 flagged, including 39/49
327+
source-reviewed nonvisual cases) and the next 60 entries of the original SHA-256 sampling
328+
order. Holdout source-review labels were frozen before revised engine results: 37 clear
329+
visual/content, 18 nonvisual and 5 uncertain. Labels describe source edits, not rendered pixel
330+
ground truth. Corrections must be documented separately rather than rewriting frozen labels.
331+
332+
```sh
333+
bun --no-env-file scripts/design-diff/benchmark.ts \
334+
--engine /path/to/clean/engine-checkout --sha <immutable-engine-commit> \
335+
--manifest scripts/design-diff/benchmark/comparisons.json --output /tmp/design-benchmark
336+
```
337+
338+
Fetch manifest commit objects beforehand; missing history fails explicitly. The runner verifies
339+
the frozen comparison commits and GitHub file sets. Cache identity includes engine SHA, trusted
340+
config, lockfile, runtime and comparison commits, with report-content verification before reuse.
341+
It records per-comparison elapsed time and peak RSS separately from deterministic reports.
342+
`/usr/bin/time` is required (macOS or Linux); source findings are not printed. Review original
343+
and holdout rates separately, and inspect every disagreement against the source label.

‎scripts/design-diff/analyze.ts‎

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { extractDocument } from '#design-diff/extract/documents'
66
import { extractTsx } from '#design-diff/extract/tsx'
77
import { GitReader } from '#design-diff/git'
88
import { groupFindings } from '#design-diff/group'
9+
import { renderingLock } from '#design-diff/infrastructure'
10+
import { fileLoadedInputs } from '#design-diff/inputs'
911
import { reclaimMemory } from '#design-diff/memory'
1012
import { limitations } from '#design-diff/policy'
1113
import { Resolver } from '#design-diff/resolve'
@@ -21,9 +23,9 @@ import type { Change, Config, Definition, Report } from '#design-diff/types'
2123

2224
export function emptyReport(): Report {
2325
return {
24-
schemaVersion: '2.0.0',
25-
engineVersion: '0.2.0',
26-
policyVersion: '2.0.0',
26+
schemaVersion: '3.0.0',
27+
engineVersion: '0.3.0',
28+
policyVersion: '3.0.0',
2729
commits: null,
2830
status: 'failed',
2931
flagged: null,
@@ -151,11 +153,6 @@ export async function analyze(
151153
const source = tree.texts.get(file)
152154
if (source === undefined) return []
153155
const normalizeAll = async (definitions: Definition[]) => {
154-
for (const definition of definitions)
155-
if (definition.unresolved.length || definition.kind === 'review')
156-
definition.dependencies = [
157-
...new Set([...definition.dependencies, ...(tree.dependencies.get(file) ?? [])]),
158-
].sort()
159156
return Promise.all(definitions.map((definition) => tailwind.normalize(definition)))
160157
}
161158
try {
@@ -177,10 +174,6 @@ export async function analyze(
177174
)
178175
)
179176
definition.movement = undefined
180-
if (definition.unresolved.length || definition.kind === 'review')
181-
definition.dependencies = [
182-
...new Set([...definition.dependencies, ...(tree.dependencies.get(file) ?? [])]),
183-
].sort()
184177
}
185178
const normalized: Definition[] = []
186179
for (const definition of defs) normalized.push(await tailwind.normalize(definition))
@@ -191,7 +184,7 @@ export async function analyze(
191184
/\.html?$/.test(file) ||
192185
(/\.mdx?$/.test(file) && config.renderedMarkdown.some((root) => file.startsWith(root)))
193186
)
194-
return normalizeAll(extractDocument(source, file))
187+
return normalizeAll(extractDocument(source, file, resolver))
195188
if (/\.(?:scss|sass|less|vue|svelte)$/.test(file))
196189
return [review(file, entry.oid, 'Unsupported rendering syntax')]
197190
} catch {
@@ -249,16 +242,15 @@ export async function analyze(
249242
if (file !== 'bun.lock' && !file.endsWith('/package.json') && file !== 'package.json')
250243
continue
251244
if (file === 'bun.lock') {
252-
findings.push(
253-
finding(
254-
undefined,
255-
review(
256-
file,
257-
after.entries.get(file)?.oid ?? '',
258-
'Resolved dependency changes can affect rendering'
245+
const a = renderingLock(before.texts.get(file), config.renderingDependencies)
246+
const b = renderingLock(after.texts.get(file), config.renderingDependencies)
247+
if (JSON.stringify(a) !== JSON.stringify(b))
248+
findings.push(
249+
finding(
250+
{ ...review(file, '', 'Resolved dependency changes can affect rendering'), value: a },
251+
{ ...review(file, '', 'Resolved dependency changes can affect rendering'), value: b }
259252
)
260253
)
261-
)
262254
continue
263255
}
264256
const project = (source?: string) => {
@@ -298,6 +290,11 @@ export async function analyze(
298290
)
299291
}
300292
}
293+
findings.push(...fileLoadedInputs(before, after, config))
294+
if (findings.length && !before.graph) {
295+
before.buildGraph()
296+
after.buildGraph()
297+
}
301298
report.status = 'completed'
302299
report.findings = groupFindings(findings, causes, before, after, renames)
303300
report.flagged = report.findings.some((item) => item.decision === 'flag')

‎scripts/design-diff/ast.ts‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
22
import { parse } from '@babel/parser'
33
import traverseModule, { type NodePath } from '@babel/traverse'
44
import * as t from '@babel/types'
5+
import { normalizeLiteralAliases, normalizeRefactors } from '#design-diff/refactors'
56
import type { Data, Location } from '#design-diff/types'
67

78
/** Handles Babel's CommonJS interop consistently in Bun and Vitest. */
@@ -11,13 +12,16 @@ export const traverse: typeof traverseModule =
1112
: (traverseModule as unknown as { default: typeof traverseModule }).default
1213

1314
export function parseSource(source: string, file: string) {
14-
return parse(source, {
15+
const ast = parse(source, {
1516
sourceType: 'unambiguous',
1617
sourceFilename: file,
1718
plugins: ['jsx', 'typescript', 'decorators-legacy'],
1819
errorRecovery: false,
1920
attachComment: false,
2021
})
22+
normalizeRefactors(ast, traverse)
23+
normalizeLiteralAliases(ast, traverse)
24+
return ast
2125
}
2226

2327
/** Removes syntax trivia and erased types, retaining runtime ordering and literal whitespace. */
@@ -67,6 +71,34 @@ export function canonical(value: unknown): Data {
6771
return result
6872
}
6973

74+
/** JSON data has no erased AST fields: retain every key and meaningful array order. */
75+
export function canonicalJson(value: Data): Data {
76+
if (Array.isArray(value)) return value.map(canonicalJson)
77+
if (!value || typeof value !== 'object') return value
78+
return Object.fromEntries(
79+
Object.keys(value)
80+
.sort()
81+
.map((key) => [key, canonicalJson(value[key])])
82+
)
83+
}
84+
85+
/** React's line-wise JSX text whitespace semantics, including explicit single-line spaces. */
86+
export function jsxText(text: string): string {
87+
const lines = text.replace(/\r\n?/g, '\n').split('\n')
88+
let last = 0
89+
lines.forEach((line, i) => {
90+
if (/[^ \t]/.test(line)) last = i
91+
})
92+
return lines
93+
.map((line, i) => {
94+
let value = line.replace(/\t/g, ' ')
95+
if (i !== 0) value = value.replace(/^ +/, '')
96+
if (i !== lines.length - 1) value = value.replace(/ +$/, '')
97+
return value ? value + (i !== last ? ' ' : '') : ''
98+
})
99+
.join('')
100+
}
101+
70102
export function location(file: string, node?: t.Node | null): Location {
71103
return { file, line: node?.loc?.start.line ?? 1, column: (node?.loc?.start.column ?? 0) + 1 }
72104
}
@@ -107,9 +139,15 @@ export function semanticSource(source: string, file: string): string {
107139
return JSON.stringify(canonical(ast.program))
108140
}
109141

110-
/** Compact evidence for unsupported syntax without duplicating entire function bodies. */
142+
const fingerprints = new WeakMap<object, string>()
143+
144+
/** Source ASTs are immutable; repeated references share their complete semantic hash. */
111145
export function fingerprint(value: unknown): string {
112-
return createHash('sha256')
146+
const cached = value && typeof value === 'object' ? fingerprints.get(value) : undefined
147+
if (cached) return cached
148+
const hash = createHash('sha256')
113149
.update(JSON.stringify(canonical(value)))
114150
.digest('hex')
151+
if (value && typeof value === 'object') fingerprints.set(value, hash)
152+
return hash
115153
}

0 commit comments

Comments
 (0)