Skip to content

Commit cb7d446

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
Merge branch 'codex/design-diff-engine' into codex/design-diff-benchmark-v3
2 parents b8d1675 + b4f01a7 commit cb7d446

12 files changed

Lines changed: 519 additions & 46 deletions

File tree

scripts/design-diff/README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ Zero means no references were enumerated, not proof of no consumers. Overrides,
6262
variants and runtime conditions can prevent a referenced component from changing visually.
6363

6464
Schema 3 readers must handle either literal values or a summary object containing
65-
`$truncated`, `preview`, `sha256`, `originalBytes`, `previewBytes` and `omittedBytes`.
65+
`$truncated`, `preview`, `sha256`, `hashAlgorithm`, `originalBytes`, `previewBytes` and `omittedBytes`.
66+
`hashAlgorithm: sha256-merkle-v1` hashes the complete semantic tree, including order and types,
67+
without expanding shared symbolic subtrees; it is not the SHA-256 of flat JSON bytes.
68+
Large resolver inputs are summarized before retention, with a limitation, so comparison and
69+
report construction cannot expand shared helper evidence into gigabytes of repeated JSON.
6670
The default preview is at most 4 KiB. Full semantic evidence is compared before presentation
6771
values are shortened; large opaque helper summaries retain full-value hashes too.
6872
`truncation` records the 5 MiB serialized-report limit, total/omitted findings, and total/omitted
@@ -97,10 +101,12 @@ scripts/design-diff/
97101
ast.ts Babel parsing and syntax normalization
98102
refactors.ts Supported literal/refactor normalization
99103
mutations.ts Referenced collection/property writes
104+
finite.ts Static finite keys for computed environment reads
100105
resolve.ts Bounded expression and import resolution
101106
inputs.ts Configured file-loaded documentation inputs
102107
infrastructure.ts Rendering lockfile dependency closure
103108
report.ts Value previews and bounded JSON serialization
109+
semantic.ts Full semantic hashes without repeated tree expansion
104110
benchmark.ts Immutable-engine historical replay
105111
process.ts Native Bun process status and bounded diagnostics
106112
benchmark/comparisons.json Frozen original/holdout comparison manifest
@@ -235,7 +241,13 @@ This engine is conservative, not a runtime equivalence prover:
235241
consumer example instead of thousands of downstream records. Large changed definitions
236242
and usage inventories are sampled deterministically within the report budget.
237243
- The default limits are 2 MiB per source file, 256 MiB per source snapshot, 24 resolution
238-
levels and 5,000 evaluation steps per expression. Per-file/parser/expression limits produce
244+
levels and 5,000 evaluation steps per expression. Resolver caches are isolated per visual
245+
source file, fallback caches per expression, and parser garbage collection also runs within
246+
large import walks. JSON imports and their full semantic identities are cached per revision;
247+
malformed imported JSON retains its source blob as uncertainty evidence. Exported functions outside the affected dependency region retain their
248+
identity while changed caller arguments are still compared. Computed keys drawn from
249+
immutable Object.entries/values loops are bounded to their declared literal keys; escaping
250+
or mutated collections retain uncertainty. Literal-alias normalization caps expanded clones at 128 AST nodes. Per-file/parser/expression limits produce
239251
flags with limitations; snapshot/Git failures are operational failures, never clean results.
240252

241253
## Cloud execution and activation
@@ -332,6 +344,8 @@ source-reviewed nonvisual cases) and the next 60 entries of the original SHA-256
332344
order. Holdout source-review labels were frozen before revised engine results: 37 clear
333345
visual/content, 18 nonvisual and 5 uncertain. Labels describe source edits, not rendered pixel
334346
ground truth. Corrections must be documented separately rather than rewriting frozen labels.
347+
Holdout PRs #6986 and #6929 were inspected while debugging resolver precision and resource
348+
use after labels were frozen, so this is not a wholly untouched blind evaluation.
335349

336350
```sh
337351
bun --no-env-file scripts/design-diff/benchmark.ts \

scripts/design-diff/analyze.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,14 @@ export async function analyze(
131131
}
132132
}
133133
}
134-
const previousResolver = new Resolver(before)
135-
const nextResolver = new Resolver(after)
136134
const previousTailwind = new TailwindNormalizer(before)
137135
const nextTailwind = new TailwindNormalizer(after)
138136
const extract = async (
139137
tree: SourceTree,
140-
resolver: Resolver,
141138
tailwind: TailwindNormalizer,
142139
file: string
143140
): Promise<Definition[]> => {
141+
const resolver = new Resolver(tree, affected)
144142
const entry = tree.entries.get(file)
145143
if (!entry) return []
146144
if (tree.failures.has(file))
@@ -198,8 +196,8 @@ export async function analyze(
198196
if (!scoped(file, config) && !infrastructure(file, config)) continue
199197
if ([...renames.values()].includes(file) && !after.entries.has(file)) continue
200198
const oldFile = renames.get(file) ?? file
201-
const a = await extract(before, previousResolver, previousTailwind, oldFile)
202-
const b = await extract(after, nextResolver, nextTailwind, file)
199+
const a = await extract(before, previousTailwind, oldFile)
200+
const b = await extract(after, nextTailwind, file)
203201
findings.push(...compareDefinitions(a, b, affected))
204202
if (
205203
changed.has(file) &&

scripts/design-diff/finite.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import type { NodePath } from '@babel/traverse'
2+
import * as t from '@babel/types'
3+
import { propertyName } from '#design-diff/ast'
4+
import { immutableCollection } from '#design-diff/mutations'
5+
import type { Resolver } from '#design-diff/resolve'
6+
7+
interface Source {
8+
path: NodePath
9+
file: string
10+
}
11+
const child = (path: NodePath, name: string) => path.get(name) as NodePath
12+
const children = (path: NodePath, name: string) => path.get(name) as NodePath[]
13+
14+
/** Bound computed keys from immutable literals and static Object.entries/values loops. No loop or source function executes. */
15+
export function finiteKeys(path: NodePath, file: string, resolver: Resolver) {
16+
const dependencies = new Set<string>()
17+
let steps = 0
18+
let mutable = false
19+
const sources = (source: Source, active = new Set<t.Node>()): Source[] | undefined => {
20+
const { path, file } = source
21+
if (!path.node || ++steps > 512 || active.size > 24 || active.has(path.node)) return undefined
22+
const seen = new Set([...active, path.node])
23+
dependencies.add(file)
24+
const next = (path: NodePath, from = file) => sources({ path, file: from }, seen)
25+
if (path.isTSAsExpression() || path.isTSSatisfiesExpression() || path.isTSNonNullExpression())
26+
return next(child(path, 'expression'))
27+
if (path.isIdentifier()) {
28+
const binding = path.scope.getBinding(path.node.name)
29+
if (!binding) return undefined
30+
if (!immutableCollection(binding)) {
31+
mutable = true
32+
return undefined
33+
}
34+
const bound = binding.path
35+
if (bound.isVariableDeclarator()) {
36+
if (bound.node.init) return next(child(bound, 'init'))
37+
const loop = bound.parentPath.parentPath
38+
if (!loop?.isForOfStatement() || loop.node.await) return undefined
39+
let right = child(loop, 'right')
40+
while (
41+
right.isTSAsExpression() ||
42+
right.isTSSatisfiesExpression() ||
43+
right.isTSNonNullExpression()
44+
)
45+
right = child(right, 'expression')
46+
if (!right.isCallExpression()) return undefined
47+
const callee = child(right, 'callee')
48+
if (
49+
!callee.isMemberExpression() ||
50+
callee.node.computed ||
51+
!t.isIdentifier(callee.node.object, { name: 'Object' }) ||
52+
callee.scope.getBinding('Object')
53+
)
54+
return undefined
55+
const method = propertyName(callee.node.property)
56+
const args = children(right, 'arguments')
57+
if (args.length !== 1 || !['entries', 'values'].includes(method)) return undefined
58+
let index = -1
59+
if (method === 'entries' && t.isArrayPattern(bound.node.id))
60+
index = bound.node.id.elements.findIndex((node) =>
61+
t.isIdentifier(node, { name: path.node.name })
62+
)
63+
if (method === 'values' && t.isIdentifier(bound.node.id, { name: path.node.name }))
64+
index = 1
65+
if (index !== 1) return undefined
66+
const records = next(args[0])
67+
if (!records || records.length > 128) return undefined
68+
const result: Source[] = []
69+
for (const record of records) {
70+
if (!record.path.isObjectExpression()) return undefined
71+
for (const property of children(record.path, 'properties')) {
72+
if (!property.isObjectProperty() || property.node.computed) return undefined
73+
result.push({ path: child(property, 'value'), file: record.file })
74+
if (result.length > 128) return undefined
75+
}
76+
}
77+
return result
78+
}
79+
if (bound.isImportSpecifier() || bound.isImportDefaultSpecifier()) {
80+
const declaration = bound.parentPath
81+
if (!declaration.isImportDeclaration()) return undefined
82+
const target = resolver.tree.resolve(file, declaration.node.source.value)
83+
const name = bound.isImportSpecifier() ? propertyName(bound.node.imported) : 'default'
84+
const resolved = target ? resolver.tree.graph?.resolvedExport(target, name) : undefined
85+
if (!resolved?.origin) return undefined
86+
for (const route of resolved.routes) dependencies.add(route)
87+
const exported = resolver.module(resolved.origin.file).exports.get(resolved.origin.exported)
88+
if (
89+
exported?.parentPath?.isVariableDeclarator() &&
90+
t.isIdentifier(exported.parentPath.node.id)
91+
) {
92+
const binding = exported.scope.getBinding(exported.parentPath.node.id.name)
93+
if (binding && !immutableCollection(binding)) {
94+
mutable = true
95+
return undefined
96+
}
97+
}
98+
return exported ? next(exported, resolved.origin.file) : undefined
99+
}
100+
return undefined
101+
}
102+
if (
103+
path.isMemberExpression() &&
104+
(!path.node.computed || t.isStringLiteral(path.node.property))
105+
) {
106+
const key = propertyName(path.node.property)
107+
const records = next(child(path, 'object'))
108+
if (!records || records.length > 128) return undefined
109+
const result: Source[] = []
110+
for (const record of records) {
111+
if (!record.path.isObjectExpression()) return undefined
112+
const properties = children(record.path, 'properties')
113+
if (properties.some((prop) => !prop.isObjectProperty() || prop.node.computed))
114+
return undefined
115+
const property = properties
116+
.reverse()
117+
.find((prop) => prop.isObjectProperty() && propertyName(prop.node.key) === key)
118+
if (!property) return undefined
119+
const values = sources({ path: child(property, 'value'), file: record.file }, seen)
120+
if (!values) return undefined
121+
result.push(...values)
122+
}
123+
return result
124+
}
125+
return [source]
126+
}
127+
try {
128+
const values = sources({ path, file })
129+
if (!values?.length || values.length > 128)
130+
return mutable
131+
? { keys: undefined, dependencies: [...dependencies].sort(), mutable: true }
132+
: undefined
133+
const keys: (string | number)[] = []
134+
for (const value of values) {
135+
if (!value.path.isStringLiteral() && !value.path.isNumericLiteral()) return undefined
136+
keys.push(value.path.node.value)
137+
}
138+
return { keys: [...new Set(keys)], dependencies: [...dependencies].sort() }
139+
} catch {
140+
return undefined
141+
}
142+
}

scripts/design-diff/inputs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export function fileLoadedInputs(before: SourceTree, after: SourceTree, config:
6363
const a = read(before, input)
6464
const b = read(after, input)
6565
findings.push(...compareDefinitions(a, b))
66-
// An unchanged invalid configured input is a coverage failure, never a clean analysis.
66+
/** An unchanged invalid configured input is a coverage failure, never a clean analysis. */
6767
for (const definition of b.filter((definition) => definition.unresolved.length))
6868
if (!findings.some((finding) => finding.after?.location.file === definition.location.file))
6969
findings.push(...compareDefinitions([], [definition]))

scripts/design-diff/mutations.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,27 @@ export function mutations(binding: Binding, selected?: string): NodePath[] {
4848
}
4949
return [...result]
5050
}
51+
52+
/** Reject writes and escaping object references before relying on an initial literal collection. */
53+
export function immutableCollection(binding: Binding): boolean {
54+
if (!binding.constant || mutations(binding).length) return false
55+
return binding.referencePaths.every((reference) => {
56+
let value = reference
57+
while (value.parentPath?.isMemberExpression() && value.parentPath.node.object === value.node)
58+
value = value.parentPath
59+
const parent = value.parentPath
60+
if (parent?.isVariableDeclarator() || parent?.isObjectProperty()) return false
61+
if (!parent?.isCallExpression()) return true
62+
if (parent.node.callee === value.node || value !== reference) return false
63+
const callee = parent.node.callee
64+
return (
65+
t.isMemberExpression(callee) &&
66+
!callee.computed &&
67+
t.isIdentifier(callee.object, { name: 'Object' }) &&
68+
!parent.scope.getBinding('Object') &&
69+
t.isIdentifier(callee.property) &&
70+
['entries', 'keys', 'values'].includes(callee.property.name) &&
71+
parent.node.arguments.length === 1
72+
)
73+
})
74+
}

scripts/design-diff/refactors.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type traverse from '@babel/traverse'
22
import type { NodePath } from '@babel/traverse'
33
import * as t from '@babel/types'
4-
import { mutations } from '#design-diff/mutations'
4+
import { immutableCollection, mutations } from '#design-diff/mutations'
55

66
/** Normalize the equivalent Object.entries record-map idiom without executing its callback. */
77
export function normalizeRefactors(ast: t.File, visit: typeof traverse): void {
@@ -77,6 +77,24 @@ export function normalizeRefactors(ast: t.File, visit: typeof traverse): void {
7777
/** Fold immutable local literal aliases consistently before resolution budgets are applied. */
7878
export function normalizeLiteralAliases(ast: t.File, visit: typeof traverse): void {
7979
const cached = new WeakMap<t.Node, t.Expression | null>()
80+
const sizes = new WeakMap<t.Node, number>()
81+
/** Bound expanded clone size, including shared literal aliases, rather than depth alone. */
82+
const expandedSize = (node: t.Node): number => {
83+
const cached = sizes.get(node)
84+
if (cached !== undefined) return cached
85+
let size = 1
86+
for (const key of t.VISITOR_KEYS[node.type] ?? []) {
87+
const value = (node as unknown as Record<string, unknown>)[key]
88+
for (const child of Array.isArray(value) ? value : [value]) {
89+
if (child && typeof child === 'object' && 'type' in child)
90+
size += expandedSize(child as t.Node)
91+
if (size > 128) break
92+
}
93+
if (size > 128) break
94+
}
95+
sizes.set(node, size)
96+
return size
97+
}
8098
const literal = (path: NodePath, seen = new Set<t.Node>()): t.Expression | null => {
8199
if (!path.node || seen.has(path.node) || seen.size > 64) return null
82100
if ((path.node.end ?? 0) - (path.node.start ?? 0) > 4096) return null
@@ -98,8 +116,15 @@ export function normalizeLiteralAliases(ast: t.File, visit: typeof traverse): vo
98116
value = literal(path.get('expression') as NodePath, seen)
99117
else if (path.isReferencedIdentifier()) {
100118
const binding = path.scope.getBinding(path.node.name)
101-
if (binding?.constant && binding.path.isVariableDeclarator() && !mutations(binding).length)
119+
if (binding?.constant && binding.path.isVariableDeclarator() && !mutations(binding).length) {
102120
value = literal(binding.path.get('init') as NodePath, seen)
121+
if (
122+
value &&
123+
(t.isObjectExpression(value) || t.isArrayExpression(value)) &&
124+
!immutableCollection(binding)
125+
)
126+
value = null
127+
}
103128
} else if (path.isObjectExpression()) {
104129
const properties: t.ObjectProperty[] = []
105130
let valid = true
@@ -128,6 +153,7 @@ export function normalizeLiteralAliases(ast: t.File, visit: typeof traverse): vo
128153
.map((element) => literal(element as NodePath, new Set(seen)))
129154
if (elements.every((item) => item !== null)) value = t.arrayExpression(elements)
130155
}
156+
if (value && expandedSize(value) > 128) value = null
131157
cached.set(path.node, value)
132158
return value
133159
}

scripts/design-diff/report.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,33 @@
1-
import { createHash } from 'node:crypto'
1+
import { SemanticValues, valuePrefix } from '#design-diff/semantic'
22
import type { Change, Data, Report } from '#design-diff/types'
33

44
export const VALUE_PREVIEW_BYTES = 4096
55
export const REPORT_BYTES = 5 * 1024 * 1024
66

7+
const summaries = new WeakSet<object>()
8+
79
/** Hash the complete value; previews never participate in semantic comparison. */
8-
export function previewValue(value: Data, limit = VALUE_PREVIEW_BYTES): Data {
9-
const json = JSON.stringify(value)
10-
const bytes = Buffer.byteLength(json)
11-
if (bytes <= limit) return value
12-
const buffer = Buffer.from(json)
13-
let end = Math.min(limit, buffer.length)
14-
while (end && (buffer[end] & 0xc0) === 0x80) end--
15-
return {
10+
export function previewValue(
11+
value: Data,
12+
limit = VALUE_PREVIEW_BYTES,
13+
semantics = new SemanticValues()
14+
): Data {
15+
if (value !== null && typeof value === 'object' && summaries.has(value)) return value
16+
const identity = semantics.identity(value)
17+
if (identity.bytes <= limit) return value
18+
const preview = valuePrefix(value, limit)
19+
const bytes = Buffer.byteLength(preview)
20+
const summary = {
1621
$truncated: true,
17-
sha256: createHash('sha256').update(buffer).digest('hex'),
18-
originalBytes: bytes,
19-
previewBytes: end,
20-
omittedBytes: bytes - end,
21-
preview: buffer.subarray(0, end).toString('utf8'),
22+
hashAlgorithm: 'sha256-merkle-v1',
23+
sha256: identity.sha256,
24+
originalBytes: identity.bytes,
25+
previewBytes: bytes,
26+
omittedBytes: identity.bytes - bytes,
27+
preview,
2228
}
29+
summaries.add(summary)
30+
return summary
2331
}
2432

2533
/** Decisions and IDs already exist when report detail is shortened. */

0 commit comments

Comments
 (0)