Skip to content

Commit 9712149

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(design-studio): verify scan source before publishing
1 parent 14d2bee commit 9712149

9 files changed

Lines changed: 133 additions & 62 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { execFileSync } from 'node:child_process'
2+
import { createHash } from 'node:crypto'
3+
import { readFileSync } from 'node:fs'
4+
import path from 'node:path'
5+
6+
/** Identity of HEAD, tracked edits, and nonignored new files in a local checkout. */
7+
export function workingTreeRevision(repo: string): string {
8+
const git = (args: string[], maxBuffer?: number) =>
9+
execFileSync('git', args, { cwd: repo, ...(maxBuffer ? { maxBuffer } : {}) })
10+
const head = git(['rev-parse', 'HEAD']).toString().trim()
11+
const diff = git(['diff', '--binary', 'HEAD'], 64 * 1024 * 1024)
12+
const untracked = git(['ls-files', '--others', '--exclude-standard', '-z'])
13+
.toString()
14+
.split('\0')
15+
.filter(Boolean)
16+
.sort()
17+
const digest = createHash('sha256').update(head).update(diff)
18+
for (const file of untracked) digest.update(file).update(readFileSync(path.join(repo, file)))
19+
return digest.digest('hex')
20+
}

‎scripts/design-scan/identity.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function scannerIdentity() {
2424
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
2525

2626
return {
27-
version: 'design-scan/2',
27+
version: 'design-scan/3',
2828
sourceHash: hash(canonical(files)),
2929
policy: registry.policy,
3030
contractsHash,

‎scripts/design-scan/report.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export function writeResults(
5151
path.join(temp, 'scan.json'),
5252
`${JSON.stringify(
5353
{
54-
version: 2,
54+
version: 3,
5555
identity: {
5656
...(identity as object),
5757
commit: inventory.commit,

‎scripts/design-scan/scan.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { classifyLayout, type LayoutAllowance } from '#control-analysis/layout-a
1010
import type { ReviewReport } from '#control-analysis/review'
1111
import { mergeSourceFindings } from '#control-analysis/review'
1212
import { inspectionFailure } from '#design-conformance/model'
13+
import { workingTreeRevision } from '#design-conformance/source-revision'
1314
import { GitSource } from '#design-conformance/worktree-source'
1415
import { scannerIdentity } from './identity'
1516
import { inspectInventory } from './inventory'
@@ -61,6 +62,7 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
6162
const output = validateOutput(values.output, values.repo)
6263
const identity = scannerIdentity()
6364
const source = new GitSource(values.repo, values.ref ?? 'HEAD', !!values['working-tree'])
65+
const sourceRevision = source.mode === 'working-tree' ? workingTreeRevision(source.repo) : null
6466
let controls: ControlInventory | undefined
6567
let colourAssignments: ColourAssignmentReport | undefined
6668
let layoutAllowances: LayoutAllowance[] = []
@@ -99,10 +101,12 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
99101
})
100102
if (!controls) throw new Error('Control analysis did not complete')
101103
source.assertUnchanged()
104+
if (sourceRevision && workingTreeRevision(source.repo) !== sourceRevision)
105+
throw new Error('Working-tree source changed during the scan; rerun for a consistent report')
102106
writeResults(
103107
output,
104108
inventory,
105-
identity,
109+
{ ...identity, sourceRevision },
106110
{
107111
elapsedMs: performance.now() - start,
108112
maxRss: process.resourceUsage().maxRSS,

‎scripts/design-studio/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ The published catalog groups exports and scanner signals into visual treatments.
1818

1919
Update fixture imports and switch cases when exports are renamed or removed. Product Extras require a maintained source mapping for a real component preview; automatically generated style samples are indicative. A generic sample shows one detected class or value at a time, so mutually exclusive source branches are never combined into one specimen.
2020

21+
An entry is `ready` when its preview can render. The preview provenance distinguishes live product or EMCN source from an indicative sample; `ready` does not imply a source-backed fixture.
22+
2123
## Live previews
2224

2325
Studio previews render the current checkout. Their catalog entries and fixture coverage come from the last published refresh. Source mismatches are shown beside the preview and above the catalog. Unresolved styling/control analysis and scanner limits remain separate from fixture coverage.

‎scripts/design-studio/refresh.mjs‎

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import path from 'node:path'
1717
import { fileURLToPath } from 'node:url'
1818
import { groupComponents, groupExtras } from '../../tools/design-studio/_lib/catalog.ts'
1919
import { productScope } from '../design-conformance/control-scope.ts'
20+
import { workingTreeRevision } from '../design-conformance/source-revision.ts'
2021

2122
const toolRoot = path.dirname(fileURLToPath(import.meta.url))
2223
const repo = path.resolve(process.env.SIM_STUDIO_REPO ?? path.join(toolRoot, '../..'))
@@ -495,30 +496,13 @@ function git(args) {
495496
return result.stdout.trim()
496497
}
497498

498-
function sourceRevision() {
499-
const changed = spawnSync('git', ['diff', '--binary', 'HEAD'], {
500-
cwd: repo,
501-
maxBuffer: 64 * 1024 * 1024,
502-
})
503-
if (changed.status !== 0) throw new Error(String(changed.stderr))
504-
const untracked = git(['ls-files', '--others', '--exclude-standard', '-z'])
505-
.split('\0')
506-
.filter(Boolean)
507-
.sort()
508-
const digest = createHash('sha256')
509-
.update(git(['rev-parse', 'HEAD']))
510-
.update(changed.stdout)
511-
for (const file of untracked) digest.update(file).update(readFileSync(path.join(repo, file)))
512-
return digest.digest('hex')
513-
}
514-
515499
async function main() {
516500
if (!existsSync(repo) || (!process.versions.bun && !process.env.SIM_STUDIO_SCAN_DIR))
517501
throw new Error('Run refresh with Bun from the product checkout')
518502
const relativeOutput = path.relative(repo, outputRoot)
519503
if (!relativeOutput.startsWith('..') && !path.isAbsolute(relativeOutput))
520504
throw new Error('Studio output must be outside the product checkout')
521-
const initialSourceRevision = sourceRevision()
505+
const initialSourceRevision = workingTreeRevision(repo)
522506
mkdirSync(outputRoot, { recursive: true })
523507
const runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${git(['rev-parse', '--short=10', 'HEAD'])}`
524508
const runDir = path.join(outputRoot, `run-${runId}`)
@@ -548,9 +532,15 @@ async function main() {
548532
if (scan.code > 1) throw new Error(`Scanner failed with exit ${scan.code}`)
549533
}
550534
const scanReport = JSON.parse(readFileSync(path.join(scanDir, 'scan.json'), 'utf8'))
551-
if (scanReport.version !== 2 || !scanReport.inventory?.metadata || !scanReport.controls)
535+
if (scanReport.version !== 3 || !scanReport.inventory?.metadata || !scanReport.controls)
552536
throw new Error('Invalid scanner report')
553537
const { identity, inventory, controls } = scanReport
538+
if (inventory.mode !== 'working-tree' || !identity || identity.sourceRevision === null)
539+
throw new Error('Studio requires a working-tree scan of the current checkout')
540+
if (identity.sourceRevision !== initialSourceRevision)
541+
throw new Error(
542+
'Scan source revision does not match the current checkout; rerun studio:refresh'
543+
)
554544
const components = componentInventory(inventory.metadata)
555545
attachTracedUses(components.entries, controls)
556546
const extras = extraInventory(inventory.findings)
@@ -584,7 +574,7 @@ async function main() {
584574
limitations: inventory.limitations,
585575
},
586576
}
587-
if (sourceRevision() !== manifest.sourceRevision)
577+
if (workingTreeRevision(repo) !== manifest.sourceRevision)
588578
manifest.coverageFailures.push({
589579
file: '<product-working-tree>',
590580
reason: 'Source changed during refresh; refresh again.',

‎scripts/design-studio/refresh.test.mjs‎

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import assert from 'node:assert/strict'
22
import { spawnSync } from 'node:child_process'
3-
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
3+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
44
import { tmpdir } from 'node:os'
55
import path from 'node:path'
66
import { fileURLToPath } from 'node:url'
77
import { test } from 'vitest'
8+
import { workingTreeRevision } from '../design-conformance/source-revision.ts'
89

910
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'refresh.mjs')
1011
const metadataScript = `
@@ -28,6 +29,8 @@ function updateMetadata(repo, scan) {
2829
const report = JSON.parse(readFileSync(file, 'utf8'))
2930
const { version, sourceHash, exports, diagnostics } = JSON.parse(result.stdout)
3031
report.inventory.metadata = { version, sourceHash, exports, diagnostics }
32+
report.inventory.mode = 'working-tree'
33+
report.identity.sourceRevision = workingTreeRevision(repo)
3134
writeFileSync(file, JSON.stringify(report))
3235
}
3336

@@ -111,7 +114,7 @@ function guardedRefresh() {
111114
scan,
112115
'scan.json',
113116
JSON.stringify({
114-
version: 2,
117+
version: 3,
115118
identity: { commit: 'test', treeHash: 'test', scanner: {} },
116119
inventory: {
117120
findings: [],
@@ -154,31 +157,28 @@ function guardedRefresh() {
154157
return { repo, refresh }
155158
}
156159

157-
test('Studio consumes the unmodified report written by the scanner CLI', () => {
158-
const { repo } = guardedRefresh()
159-
write(
160-
repo,
161-
'apps/sim/app/product/page.tsx',
162-
"import { Example } from '@sim/emcn'\nexport default function Page() { return <div style={{ color: '#123456' }}><Example /></div> }\n"
163-
)
164-
const root = path.dirname(repo)
165-
const scan = path.join(root, 'real-scan')
166-
const scanner = path.resolve('scripts/design-scan/scan.ts')
160+
function runScanner(repo, source = '--working-tree') {
161+
const scan = path.join(path.dirname(repo), `real-scan-${source.slice(2)}`)
167162
const scanned = spawnSync(
168163
process.env.DESIGN_TEST_BUN ?? 'bun',
169-
['--no-env-file', scanner, '--repo', repo, '--working-tree', '--output', scan],
164+
[
165+
'--no-env-file',
166+
path.resolve('scripts/design-scan/scan.ts'),
167+
'--repo',
168+
repo,
169+
source,
170+
...(source === '--ref' ? ['HEAD'] : []),
171+
'--output',
172+
scan,
173+
],
170174
{ encoding: 'utf8', timeout: 120000 }
171175
)
172176
assert.ok([0, 1].includes(scanned.status), scanned.stderr)
173-
const reportFile = path.join(scan, 'scan.json')
174-
const reportBytes = readFileSync(reportFile)
175-
const report = JSON.parse(reportBytes.toString())
176-
assert.equal(report.version, 2)
177-
assert.ok(report.inventory.metadata.exports.Example)
178-
assert.ok(report.inventory.findings.some((finding) => finding.value === '#123456'))
177+
return scan
178+
}
179179

180-
const output = path.join(root, 'real-studio')
181-
const refreshed = spawnSync('node', [script], {
180+
function publishScan(repo, scan, output) {
181+
return spawnSync('node', [script], {
182182
env: {
183183
...process.env,
184184
SIM_STUDIO_REPO: repo,
@@ -188,6 +188,26 @@ test('Studio consumes the unmodified report written by the scanner CLI', () => {
188188
encoding: 'utf8',
189189
timeout: 120000,
190190
})
191+
}
192+
193+
test('Studio consumes the unmodified report written by the scanner CLI', () => {
194+
const { repo } = guardedRefresh()
195+
write(
196+
repo,
197+
'apps/sim/app/product/page.tsx',
198+
"import { Example } from '@sim/emcn'\nexport default function Page() { return <div style={{ color: '#123456' }}><Example /></div> }\n"
199+
)
200+
const root = path.dirname(repo)
201+
const scan = runScanner(repo)
202+
const reportFile = path.join(scan, 'scan.json')
203+
const reportBytes = readFileSync(reportFile)
204+
const report = JSON.parse(reportBytes.toString())
205+
assert.equal(report.version, 3)
206+
assert.ok(report.inventory.metadata.exports.Example)
207+
assert.ok(report.inventory.findings.some((finding) => finding.value === '#123456'))
208+
209+
const output = path.join(root, 'real-studio')
210+
const refreshed = publishScan(repo, scan, output)
191211
assert.ok([0, 1].includes(refreshed.status), refreshed.stderr)
192212
assert.deepEqual(readFileSync(reportFile), reportBytes)
193213
const pointer = JSON.parse(readFileSync(path.join(output, 'latest.json'), 'utf8'))
@@ -207,6 +227,53 @@ test('Studio consumes the unmodified report written by the scanner CLI', () => {
207227
)
208228
}, 120000)
209229

230+
for (const [name, change] of [
231+
[
232+
'tracked edit',
233+
(repo) =>
234+
write(
235+
repo,
236+
'packages/emcn/src/components/example/example.tsx',
237+
"export const Example = ({size, variant}: {size?: 'sm' | 'lg'; variant?: 'plain' | 'filled'}) => <button data-changed />\n"
238+
),
239+
],
240+
[
241+
'new product file',
242+
(repo) =>
243+
write(
244+
repo,
245+
'apps/sim/app/product/new.tsx',
246+
'export const NewProductSurface = () => <button />\n'
247+
),
248+
],
249+
]) {
250+
test(`a ${name} makes an injected scan stale without replacing the latest publication`, () => {
251+
const { repo } = guardedRefresh()
252+
const scan = runScanner(repo)
253+
const output = path.join(path.dirname(repo), 'real-studio')
254+
const first = publishScan(repo, scan, output)
255+
assert.ok([0, 1].includes(first.status), first.stderr)
256+
const pointer = path.join(output, 'latest.json')
257+
const published = readFileSync(pointer)
258+
259+
change(repo)
260+
const stale = publishScan(repo, scan, output)
261+
assert.equal(stale.status, 2, stale.stderr)
262+
assert.match(stale.stderr, /scan.*source revision|source revision.*scan/i)
263+
assert.deepEqual(readFileSync(pointer), published)
264+
}, 120000)
265+
}
266+
267+
test('an immutable scan cannot publish a current Studio catalog', () => {
268+
const { repo } = guardedRefresh()
269+
const scan = runScanner(repo, '--ref')
270+
const output = path.join(path.dirname(repo), 'real-studio')
271+
const result = publishScan(repo, scan, output)
272+
assert.equal(result.status, 2, result.stderr)
273+
assert.match(result.stderr, /working.tree scan/i)
274+
assert.equal(existsSync(path.join(output, 'latest.json')), false)
275+
}, 120000)
276+
210277
test('refresh tracks source changes in imported stylesheets', () => {
211278
const { repo, refresh } = guardedRefresh()
212279
const first = refresh()
@@ -393,7 +460,7 @@ test('refresh catalogs every detected treatment and new EMCN export', () => {
393460
scan,
394461
'scan.json',
395462
JSON.stringify({
396-
version: 2,
463+
version: 3,
397464
identity: { commit: 'test-head', treeHash: 'test-tree', scanner: {} },
398465
inventory: {
399466
findings: [

‎tools/design-studio/_components/generated-gallery.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,9 @@ function TreatmentDetail({
512512
? 'Needs fixture'
513513
: failedCount
514514
? `${failedCount} fixture gaps`
515-
: 'Live fixture available'}
515+
: entry.kind === 'extra' && entry.previewKind !== 'source-component'
516+
? 'Indicative preview'
517+
: 'Live source preview'}
516518
</span>
517519
</div>
518520

‎tools/design-studio/_lib/manifest.ts‎

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import 'server-only'
22

33
import { execFileSync } from 'node:child_process'
4-
import { createHash } from 'node:crypto'
54
import { existsSync, readFileSync } from 'node:fs'
65
import { homedir } from 'node:os'
76
import path from 'node:path'
7+
import { workingTreeRevision } from '../../../scripts/design-conformance/source-revision'
88

99
export interface StudioLocation {
1010
file: string
@@ -122,21 +122,7 @@ export function getStudioPageManifest(mode: 'components' | 'extras'): {
122122
cwd: process.env.SIM_STUDIO_REPO ?? process.cwd(),
123123
encoding: 'utf8',
124124
}).trim()
125-
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim()
126-
const diff = execFileSync('git', ['diff', '--binary', 'HEAD'], {
127-
cwd: repo,
128-
maxBuffer: 64 * 1024 * 1024,
129-
})
130-
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], {
131-
cwd: repo,
132-
encoding: 'utf8',
133-
})
134-
.split('\0')
135-
.filter(Boolean)
136-
.sort()
137-
const digest = createHash('sha256').update(head).update(diff)
138-
for (const name of untracked) digest.update(name).update(readFileSync(path.join(repo, name)))
139-
stale = digest.digest('hex') !== manifest.sourceRevision
125+
stale = workingTreeRevision(repo) !== manifest.sourceRevision
140126
} catch {
141127
stale = true
142128
}

0 commit comments

Comments
 (0)