mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(zustand): v5 selector stability issues (#4539)
* fix(zustand): v5 selector stability issues * address comments
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
#!/usr/bin/env bun
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..')
|
||||
const APP_DIR = path.join(ROOT, 'apps/sim')
|
||||
|
||||
const SKIP_DIRS = new Set(['node_modules', '.next', '.turbo', 'coverage', 'dist', 'build'])
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])
|
||||
const STORE_HOOK_CALL_PATTERN = /\buse[A-Z][A-Za-z0-9_]*Store\s*\(/g
|
||||
const SAFE_ANNOTATION = 'zustand-v5-safe:'
|
||||
const UNSAFE_SELECTOR_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
|
||||
{
|
||||
pattern: /=>\s*\(\s*\{/,
|
||||
reason: 'selector returns a fresh object literal; wrap it in useShallow',
|
||||
},
|
||||
{
|
||||
pattern: /\breturn\s+\{/,
|
||||
reason: 'selector returns a fresh object literal; wrap it in useShallow',
|
||||
},
|
||||
{
|
||||
pattern: /=>\s*\[/,
|
||||
reason: 'selector returns a fresh array literal; wrap it in useShallow',
|
||||
},
|
||||
{
|
||||
pattern: /\breturn\s+\[/,
|
||||
reason: 'selector returns a fresh array literal; wrap it in useShallow',
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/(?:=>|return)\s+Object\.(?:values|entries)\s*\([^)]*\)(?!\s*\.\s*(?:length|some|every)\b)/,
|
||||
reason:
|
||||
'selector allocates a derived collection; use useStoreWithEqualityFn or memoize outside',
|
||||
},
|
||||
{
|
||||
pattern: /\bObject\.fromEntries\s*\(/,
|
||||
reason: 'selector allocates a derived object; use useStoreWithEqualityFn or memoize outside',
|
||||
},
|
||||
{
|
||||
pattern: /\bObject\.keys\s*\([^)]*\)(?!\s*\.length\b)/,
|
||||
reason: 'selector allocates Object.keys; return a primitive or use useShallow',
|
||||
},
|
||||
{
|
||||
pattern: /(?:=>|return)\s+[^;{}]*\.(?:map|filter|reduce)\s*\(/,
|
||||
reason: 'selector allocates a derived value; use useStoreWithEqualityFn or memoize outside',
|
||||
},
|
||||
{
|
||||
pattern: /\bnew\s+(?:Set|Map)\s*\(/,
|
||||
reason:
|
||||
'selector returns a fresh collection; use useStoreWithEqualityFn or a stable store reference',
|
||||
},
|
||||
{
|
||||
pattern: /\?\?\s*(?:\(\s*\)\s*=>|\{\s*\}|\[\s*\])/,
|
||||
reason: 'selector uses an unstable fallback reference; move the fallback to module scope',
|
||||
},
|
||||
{
|
||||
pattern: /\|\|\s*(?:\(\s*\)\s*=>|\{\s*\}|\[\s*\])/,
|
||||
reason: 'selector uses an unstable fallback reference; move the fallback to module scope',
|
||||
},
|
||||
]
|
||||
|
||||
interface Violation {
|
||||
file: string
|
||||
line: number
|
||||
description: string
|
||||
snippet: string
|
||||
}
|
||||
|
||||
async function walk(dir: string, results: string[] = []): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue
|
||||
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full, results)
|
||||
continue
|
||||
}
|
||||
|
||||
if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
|
||||
results.push(full)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function findMatchingParen(source: string, openIndex: number): number {
|
||||
let depth = 0
|
||||
let quote: '"' | "'" | '`' | null = null
|
||||
let escaped = false
|
||||
let lineComment = false
|
||||
let blockComment = false
|
||||
|
||||
for (let index = openIndex; index < source.length; index++) {
|
||||
const char = source[index]
|
||||
const next = source[index + 1]
|
||||
|
||||
if (lineComment) {
|
||||
if (char === '\n') lineComment = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (blockComment) {
|
||||
if (char === '*' && next === '/') {
|
||||
blockComment = false
|
||||
index++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (char === quote) {
|
||||
quote = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '/' && next === '/') {
|
||||
lineComment = true
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '/' && next === '*') {
|
||||
blockComment = true
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'" || char === '`') {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '(') depth++
|
||||
if (char === ')') {
|
||||
depth--
|
||||
if (depth === 0) return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function splitTopLevelArguments(args: string): string[] {
|
||||
const result: string[] = []
|
||||
let start = 0
|
||||
let depth = 0
|
||||
let quote: '"' | "'" | '`' | null = null
|
||||
let escaped = false
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const char = args[index]
|
||||
|
||||
if (quote) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (char === quote) quote = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'" || char === '`') {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '(' || char === '[' || char === '{') depth++
|
||||
if (char === ')' || char === ']' || char === '}') depth--
|
||||
|
||||
if (char === ',' && depth === 0) {
|
||||
result.push(args.slice(start, index).trim())
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
|
||||
const finalArg = args.slice(start).trim()
|
||||
if (finalArg) result.push(finalArg)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function lineNumberAt(source: string, index: number): number {
|
||||
let line = 1
|
||||
for (let i = 0; i < index; i++) {
|
||||
if (source[i] === '\n') line++
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
function hasSafeAnnotation(source: string, callStart: number): boolean {
|
||||
const before = source.slice(0, callStart)
|
||||
const lines = before.split('\n')
|
||||
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 4); i--) {
|
||||
const trimmed = lines[i]?.trim()
|
||||
if (!trimmed) continue
|
||||
if (trimmed.includes(SAFE_ANNOTATION) && trimmed.split(SAFE_ANNOTATION)[1]?.trim()) {
|
||||
return true
|
||||
}
|
||||
if (!trimmed.startsWith('//') && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function oneLineSnippet(source: string, start: number, end: number): string {
|
||||
return source.slice(start, end).replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
}
|
||||
|
||||
function auditFile(file: string, source: string): Violation[] {
|
||||
const violations: Violation[] = []
|
||||
STORE_HOOK_CALL_PATTERN.lastIndex = 0
|
||||
|
||||
for (
|
||||
let match = STORE_HOOK_CALL_PATTERN.exec(source);
|
||||
match;
|
||||
match = STORE_HOOK_CALL_PATTERN.exec(source)
|
||||
) {
|
||||
const callStart = match.index
|
||||
const callee = match[0].replace(/\s*\($/, '')
|
||||
if (callee === 'useSyncExternalStore') continue
|
||||
|
||||
if (hasSafeAnnotation(source, callStart)) continue
|
||||
|
||||
const openParenIndex = source.indexOf('(', callStart)
|
||||
const closeParenIndex = findMatchingParen(source, openParenIndex)
|
||||
if (closeParenIndex === -1) continue
|
||||
|
||||
const args = splitTopLevelArguments(source.slice(openParenIndex + 1, closeParenIndex))
|
||||
const line = lineNumberAt(source, callStart)
|
||||
const snippet = oneLineSnippet(source, callStart, closeParenIndex + 1)
|
||||
|
||||
if (args.length === 0) {
|
||||
violations.push({
|
||||
file,
|
||||
line,
|
||||
description: `${callee} subscribes to the entire store; select only the fields needed`,
|
||||
snippet,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (args.length > 1) {
|
||||
violations.push({
|
||||
file,
|
||||
line,
|
||||
description: `${callee} passes a second equality argument; Zustand v5 create() hooks ignore the v4 pattern. Use useShallow or useStoreWithEqualityFn.`,
|
||||
snippet,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const selector = args[0]
|
||||
if (!selector || selector.startsWith('useShallow(')) continue
|
||||
|
||||
for (const { pattern, reason } of UNSAFE_SELECTOR_PATTERNS) {
|
||||
pattern.lastIndex = 0
|
||||
if (pattern.test(selector)) {
|
||||
if (returnsPrimitiveDerivedValue(selector)) continue
|
||||
if (usesReferenceFallbackOnlyInsideBlockBody(selector)) continue
|
||||
violations.push({
|
||||
file,
|
||||
line,
|
||||
description: `${callee} ${reason}`,
|
||||
snippet,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function returnsPrimitiveDerivedValue(selector: string): boolean {
|
||||
return (
|
||||
/\bObject\.(?:keys|values|entries)\s*\([^)]*\)\s*\.\s*(?:length|some|every)\b/.test(selector) ||
|
||||
/\bObject\.keys\s*\([^)]*\)\.length\b/.test(selector) ||
|
||||
/\.(?:map|filter)\s*\([^)]*\)\s*\.\s*(?:length|some|every|join)\b/.test(selector)
|
||||
)
|
||||
}
|
||||
|
||||
function usesReferenceFallbackOnlyInsideBlockBody(selector: string): boolean {
|
||||
if (!/\)\s*=>\s*\{/.test(selector)) return false
|
||||
|
||||
const returnExpressions = [...selector.matchAll(/\breturn\s+([^;\n}]+)/g)].map((match) =>
|
||||
match[1].trim()
|
||||
)
|
||||
|
||||
return (
|
||||
returnExpressions.length > 0 &&
|
||||
returnExpressions.every((expression) => isPrimitiveReturnExpression(expression, selector))
|
||||
)
|
||||
}
|
||||
|
||||
function isPrimitiveReturnExpression(expression: string, selector: string): boolean {
|
||||
const normalized = expression
|
||||
.trim()
|
||||
.replace(/^\((.*)\)$/, '$1')
|
||||
.trim()
|
||||
|
||||
if (/^(?:true|false|null|undefined)\b/.test(normalized)) return true
|
||||
if (/^(?:['"`]|\d)/.test(normalized)) return true
|
||||
if (/^(?:!|typeof\b)/.test(normalized)) return true
|
||||
if (/^(?:Boolean|Number|String)\s*\(/.test(normalized)) return true
|
||||
if (/(?:===|!==|==|!=|>=|<=|>|<)/.test(normalized)) return true
|
||||
if (/\.(?:length|some|every|includes|has)\s*(?:\(|$)/.test(normalized)) return true
|
||||
|
||||
if (/^[A-Za-z_$][\w$]*$/.test(normalized)) {
|
||||
return isIdentifierAssignedPrimitive(normalized, selector)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isIdentifierAssignedPrimitive(identifier: string, selector: string): boolean {
|
||||
const declarationPattern = new RegExp(`\\b(?:const|let)\\s+${identifier}\\s*=\\s*([^;\\n]+)`)
|
||||
const declaration = selector.match(declarationPattern)
|
||||
if (!declaration) return false
|
||||
|
||||
return isPrimitiveReturnExpression(declaration[1], selector)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = await walk(APP_DIR)
|
||||
const violations: Violation[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, 'utf8')
|
||||
const relativeFile = path.relative(ROOT, file)
|
||||
violations.push(...auditFile(relativeFile, source))
|
||||
}
|
||||
|
||||
if (violations.length === 0) {
|
||||
console.log('✅ Zustand v5 selector audit OK')
|
||||
return
|
||||
}
|
||||
|
||||
console.error('❌ Zustand v5 selector hazards found:')
|
||||
console.error(
|
||||
`Add useShallow/useStoreWithEqualityFn, split into primitive selectors, or document intentional exceptions with // ${SAFE_ANNOTATION} <reason>.`
|
||||
)
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
` ${violation.file}:${violation.line} — ${violation.description}\n ${violation.snippet}`
|
||||
)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error('Zustand v5 selector audit failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
export function formatGeneratedSource(source: string, stdinFilePath: string, cwd: string): string {
|
||||
const result = spawnSync('bunx', ['biome', 'format', '--stdin-file-path', stdinFilePath], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
input: source,
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to format generated source for ${stdinFilePath}:\n${
|
||||
result.stderr || result.stdout || 'unknown error'
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { compile } from 'json-schema-to-typescript'
|
||||
import { formatGeneratedSource } from './format-generated-source'
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
@@ -70,8 +71,16 @@ async function main() {
|
||||
})
|
||||
|
||||
const constants = generateRuntimeConstants(schema, types)
|
||||
const rendered = constants ? `${types}\n${constants}\n` : types
|
||||
const renderedSchemaModule = renderRuntimeSchemaModule(schema)
|
||||
const rendered = formatGeneratedSource(
|
||||
constants ? `${types}\n${constants}\n` : types,
|
||||
OUTPUT_PATH,
|
||||
ROOT
|
||||
)
|
||||
const renderedSchemaModule = formatGeneratedSource(
|
||||
renderRuntimeSchemaModule(schema),
|
||||
RUNTIME_SCHEMA_OUTPUT_PATH,
|
||||
ROOT
|
||||
)
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { compile } from 'json-schema-to-typescript'
|
||||
import { formatGeneratedSource } from './format-generated-source'
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
@@ -26,9 +27,7 @@ function generateRuntimeConstants(schema: Record<string, unknown>): string {
|
||||
.map((v) => ` ${JSON.stringify(v)}: ${JSON.stringify(v)}`)
|
||||
.join(',\n')
|
||||
|
||||
lines.push(
|
||||
`export const ${name} = {\n${entries},\n} as const;\n`
|
||||
)
|
||||
lines.push(`export const ${name} = {\n${entries},\n} as const;\n`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
@@ -37,19 +36,24 @@ function generateRuntimeConstants(schema: Record<string, unknown>): string {
|
||||
async function main() {
|
||||
const checkOnly = process.argv.includes('--check')
|
||||
const inputPathArg = process.argv.find((arg) => arg.startsWith('--input='))
|
||||
const inputPath = inputPathArg ? resolve(ROOT, inputPathArg.slice('--input='.length)) : DEFAULT_CONTRACT_PATH
|
||||
const inputPath = inputPathArg
|
||||
? resolve(ROOT, inputPathArg.slice('--input='.length))
|
||||
: DEFAULT_CONTRACT_PATH
|
||||
|
||||
const raw = await readFile(inputPath, 'utf8')
|
||||
const schema = JSON.parse(raw)
|
||||
const types = await compile(schema, 'RequestTraceV1SimReport', {
|
||||
bannerComment:
|
||||
'// AUTO-GENERATED FILE. DO NOT EDIT.\n//',
|
||||
bannerComment: '// AUTO-GENERATED FILE. DO NOT EDIT.\n//',
|
||||
unreachableDefinitions: true,
|
||||
additionalProperties: false
|
||||
additionalProperties: false,
|
||||
})
|
||||
|
||||
const constants = generateRuntimeConstants(schema)
|
||||
const rendered = constants ? `${types}\n${constants}\n` : types
|
||||
const rendered = formatGeneratedSource(
|
||||
constants ? `${types}\n${constants}\n` : types,
|
||||
OUTPUT_PATH,
|
||||
ROOT
|
||||
)
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { formatGeneratedSource } from './format-generated-source'
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
@@ -208,7 +209,7 @@ async function main() {
|
||||
lines.push('};')
|
||||
lines.push('')
|
||||
|
||||
const rendered = lines.join('\n')
|
||||
const rendered = formatGeneratedSource(lines.join('\n'), OUTPUT_PATH, ROOT)
|
||||
const runtimeSchemaRendered = renderRuntimeSchemaModule(catalog)
|
||||
|
||||
if (checkOnly) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { formatGeneratedSource } from './format-generated-source'
|
||||
|
||||
/**
|
||||
* Generate `apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts`
|
||||
@@ -27,12 +28,9 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
const DEFAULT_CONTRACT_PATH = resolve(
|
||||
ROOT,
|
||||
'../copilot/copilot/contracts/trace-attribute-values-v1.schema.json',
|
||||
)
|
||||
const OUTPUT_PATH = resolve(
|
||||
ROOT,
|
||||
'apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts',
|
||||
'../copilot/copilot/contracts/trace-attribute-values-v1.schema.json'
|
||||
)
|
||||
const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts')
|
||||
|
||||
interface ExtractedEnum {
|
||||
/** The Go type name — becomes the TS const + type name. */
|
||||
@@ -66,13 +64,9 @@ function toValueIdent(value: string): string {
|
||||
if (parts.length === 0) {
|
||||
throw new Error(`Cannot derive identifier for enum value: ${value}`)
|
||||
}
|
||||
const ident = parts
|
||||
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
||||
.join('')
|
||||
const ident = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join('')
|
||||
if (/^[0-9]/.test(ident)) {
|
||||
throw new Error(
|
||||
`Derived identifier "${ident}" for value "${value}" starts with a digit`,
|
||||
)
|
||||
throw new Error(`Derived identifier "${ident}" for value "${value}" starts with a digit`)
|
||||
}
|
||||
return ident
|
||||
}
|
||||
@@ -84,7 +78,7 @@ function renderEnum(e: ExtractedEnum): string {
|
||||
const prev = seen.get(ident)
|
||||
if (prev && prev !== v) {
|
||||
throw new Error(
|
||||
`Enum ${e.name}: identifier collision — "${prev}" and "${v}" both map to "${ident}"`,
|
||||
`Enum ${e.name}: identifier collision — "${prev}" and "${v}" both map to "${ident}"`
|
||||
)
|
||||
}
|
||||
seen.set(ident, v)
|
||||
@@ -128,16 +122,16 @@ async function main() {
|
||||
const enums = extractEnums(schema)
|
||||
if (enums.length === 0) {
|
||||
throw new Error(
|
||||
'No enum $defs found in trace-attribute-values-v1.schema.json — did you add the Go type to TraceAttributeValuesV1AllDefs?',
|
||||
'No enum $defs found in trace-attribute-values-v1.schema.json — did you add the Go type to TraceAttributeValuesV1AllDefs?'
|
||||
)
|
||||
}
|
||||
const rendered = render(enums)
|
||||
const rendered = formatGeneratedSource(render(enums), OUTPUT_PATH, ROOT)
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
|
||||
if (existing !== rendered) {
|
||||
throw new Error(
|
||||
'Generated trace attribute values contract is stale. Run: bun run trace-attribute-values-contract:generate',
|
||||
'Generated trace attribute values contract is stale. Run: bun run trace-attribute-values-contract:generate'
|
||||
)
|
||||
}
|
||||
console.log('Trace attribute values contract is up to date.')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { formatGeneratedSource } from './format-generated-source'
|
||||
|
||||
/**
|
||||
* Generate `apps/sim/lib/copilot/generated/trace-attributes-v1.ts`
|
||||
@@ -32,12 +33,9 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
const DEFAULT_CONTRACT_PATH = resolve(
|
||||
ROOT,
|
||||
'../copilot/copilot/contracts/trace-attributes-v1.schema.json',
|
||||
)
|
||||
const OUTPUT_PATH = resolve(
|
||||
ROOT,
|
||||
'apps/sim/lib/copilot/generated/trace-attributes-v1.ts',
|
||||
'../copilot/copilot/contracts/trace-attributes-v1.schema.json'
|
||||
)
|
||||
const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-attributes-v1.ts')
|
||||
|
||||
function extractAttrKeys(schema: Record<string, unknown>): string[] {
|
||||
const defs = (schema.$defs ?? {}) as Record<string, unknown>
|
||||
@@ -47,9 +45,7 @@ function extractAttrKeys(schema: Record<string, unknown>): string[] {
|
||||
typeof nameDef !== 'object' ||
|
||||
!Array.isArray((nameDef as Record<string, unknown>).enum)
|
||||
) {
|
||||
throw new Error(
|
||||
'trace-attributes-v1.schema.json is missing $defs.TraceAttributesV1Name.enum',
|
||||
)
|
||||
throw new Error('trace-attributes-v1.schema.json is missing $defs.TraceAttributesV1Name.enum')
|
||||
}
|
||||
const enumValues = (nameDef as Record<string, unknown>).enum as unknown[]
|
||||
if (!enumValues.every((v) => typeof v === 'string')) {
|
||||
@@ -71,13 +67,9 @@ function toIdentifier(name: string): string {
|
||||
if (parts.length === 0) {
|
||||
throw new Error(`Cannot derive identifier for attribute key: ${name}`)
|
||||
}
|
||||
const ident = parts
|
||||
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
||||
.join('')
|
||||
const ident = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join('')
|
||||
if (/^[0-9]/.test(ident)) {
|
||||
throw new Error(
|
||||
`Derived identifier "${ident}" for attribute "${name}" starts with a digit`,
|
||||
)
|
||||
throw new Error(`Derived identifier "${ident}" for attribute "${name}" starts with a digit`)
|
||||
}
|
||||
return ident
|
||||
}
|
||||
@@ -91,16 +83,12 @@ function render(attrKeys: string[]): string {
|
||||
for (const p of pairs) {
|
||||
const prev = seen.get(p.ident)
|
||||
if (prev && prev !== p.name) {
|
||||
throw new Error(
|
||||
`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`,
|
||||
)
|
||||
throw new Error(`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`)
|
||||
}
|
||||
seen.set(p.ident, p.name)
|
||||
}
|
||||
|
||||
const constLines = pairs
|
||||
.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`)
|
||||
.join('\n')
|
||||
const constLines = pairs.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`).join('\n')
|
||||
const arrayEntries = attrKeys.map((n) => ` ${JSON.stringify(n)},`).join('\n')
|
||||
|
||||
return `// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
@@ -144,13 +132,13 @@ async function main() {
|
||||
const raw = await readFile(inputPath, 'utf8')
|
||||
const schema = JSON.parse(raw)
|
||||
const attrKeys = extractAttrKeys(schema)
|
||||
const rendered = render(attrKeys)
|
||||
const rendered = formatGeneratedSource(render(attrKeys), OUTPUT_PATH, ROOT)
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
|
||||
if (existing !== rendered) {
|
||||
throw new Error(
|
||||
'Generated trace attributes contract is stale. Run: bun run trace-attributes-contract:generate',
|
||||
'Generated trace attributes contract is stale. Run: bun run trace-attributes-contract:generate'
|
||||
)
|
||||
}
|
||||
console.log('Trace attributes contract is up to date.')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { formatGeneratedSource } from './format-generated-source'
|
||||
|
||||
/**
|
||||
* Generate `apps/sim/lib/copilot/generated/trace-events-v1.ts` from
|
||||
@@ -17,12 +18,9 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
const DEFAULT_CONTRACT_PATH = resolve(
|
||||
ROOT,
|
||||
'../copilot/copilot/contracts/trace-events-v1.schema.json',
|
||||
)
|
||||
const OUTPUT_PATH = resolve(
|
||||
ROOT,
|
||||
'apps/sim/lib/copilot/generated/trace-events-v1.ts',
|
||||
'../copilot/copilot/contracts/trace-events-v1.schema.json'
|
||||
)
|
||||
const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-events-v1.ts')
|
||||
|
||||
function extractEventNames(schema: Record<string, unknown>): string[] {
|
||||
const defs = (schema.$defs ?? {}) as Record<string, unknown>
|
||||
@@ -32,9 +30,7 @@ function extractEventNames(schema: Record<string, unknown>): string[] {
|
||||
typeof nameDef !== 'object' ||
|
||||
!Array.isArray((nameDef as Record<string, unknown>).enum)
|
||||
) {
|
||||
throw new Error(
|
||||
'trace-events-v1.schema.json is missing $defs.TraceEventsV1Name.enum',
|
||||
)
|
||||
throw new Error('trace-events-v1.schema.json is missing $defs.TraceEventsV1Name.enum')
|
||||
}
|
||||
const enumValues = (nameDef as Record<string, unknown>).enum as unknown[]
|
||||
if (!enumValues.every((v) => typeof v === 'string')) {
|
||||
@@ -48,13 +44,9 @@ function toIdentifier(name: string): string {
|
||||
if (parts.length === 0) {
|
||||
throw new Error(`Cannot derive identifier for event name: ${name}`)
|
||||
}
|
||||
const ident = parts
|
||||
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
||||
.join('')
|
||||
const ident = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join('')
|
||||
if (/^[0-9]/.test(ident)) {
|
||||
throw new Error(
|
||||
`Derived identifier "${ident}" for event "${name}" starts with a digit`,
|
||||
)
|
||||
throw new Error(`Derived identifier "${ident}" for event "${name}" starts with a digit`)
|
||||
}
|
||||
return ident
|
||||
}
|
||||
@@ -66,16 +58,12 @@ function render(eventNames: string[]): string {
|
||||
for (const p of pairs) {
|
||||
const prev = seen.get(p.ident)
|
||||
if (prev && prev !== p.name) {
|
||||
throw new Error(
|
||||
`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`,
|
||||
)
|
||||
throw new Error(`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`)
|
||||
}
|
||||
seen.set(p.ident, p.name)
|
||||
}
|
||||
|
||||
const constLines = pairs
|
||||
.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`)
|
||||
.join('\n')
|
||||
const constLines = pairs.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`).join('\n')
|
||||
const arrayEntries = eventNames.map((n) => ` ${JSON.stringify(n)},`).join('\n')
|
||||
|
||||
return `// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
@@ -113,13 +101,13 @@ async function main() {
|
||||
const raw = await readFile(inputPath, 'utf8')
|
||||
const schema = JSON.parse(raw)
|
||||
const eventNames = extractEventNames(schema)
|
||||
const rendered = render(eventNames)
|
||||
const rendered = formatGeneratedSource(render(eventNames), OUTPUT_PATH, ROOT)
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
|
||||
if (existing !== rendered) {
|
||||
throw new Error(
|
||||
'Generated trace events contract is stale. Run: bun run trace-events-contract:generate',
|
||||
'Generated trace events contract is stale. Run: bun run trace-events-contract:generate'
|
||||
)
|
||||
}
|
||||
console.log('Trace events contract is up to date.')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { formatGeneratedSource } from './format-generated-source'
|
||||
|
||||
/**
|
||||
* Generate `apps/sim/lib/copilot/generated/trace-spans-v1.ts` from the
|
||||
@@ -22,12 +23,9 @@ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SCRIPT_DIR, '..')
|
||||
const DEFAULT_CONTRACT_PATH = resolve(
|
||||
ROOT,
|
||||
'../copilot/copilot/contracts/trace-spans-v1.schema.json',
|
||||
)
|
||||
const OUTPUT_PATH = resolve(
|
||||
ROOT,
|
||||
'apps/sim/lib/copilot/generated/trace-spans-v1.ts',
|
||||
'../copilot/copilot/contracts/trace-spans-v1.schema.json'
|
||||
)
|
||||
const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-spans-v1.ts')
|
||||
|
||||
function extractSpanNames(schema: Record<string, unknown>): string[] {
|
||||
const defs = (schema.$defs ?? {}) as Record<string, unknown>
|
||||
@@ -37,9 +35,7 @@ function extractSpanNames(schema: Record<string, unknown>): string[] {
|
||||
typeof nameDef !== 'object' ||
|
||||
!Array.isArray((nameDef as Record<string, unknown>).enum)
|
||||
) {
|
||||
throw new Error(
|
||||
'trace-spans-v1.schema.json is missing $defs.TraceSpansV1Name.enum',
|
||||
)
|
||||
throw new Error('trace-spans-v1.schema.json is missing $defs.TraceSpansV1Name.enum')
|
||||
}
|
||||
const enumValues = (nameDef as Record<string, unknown>).enum as unknown[]
|
||||
if (!enumValues.every((v) => typeof v === 'string')) {
|
||||
@@ -63,14 +59,10 @@ function toIdentifier(name: string): string {
|
||||
if (parts.length === 0) {
|
||||
throw new Error(`Cannot derive identifier for span name: ${name}`)
|
||||
}
|
||||
const ident = parts
|
||||
.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
||||
.join('')
|
||||
const ident = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join('')
|
||||
// Safety: identifiers may not start with a digit.
|
||||
if (/^[0-9]/.test(ident)) {
|
||||
throw new Error(
|
||||
`Derived identifier "${ident}" for span "${name}" starts with a digit`,
|
||||
)
|
||||
throw new Error(`Derived identifier "${ident}" for span "${name}" starts with a digit`)
|
||||
}
|
||||
return ident
|
||||
}
|
||||
@@ -85,16 +77,12 @@ function render(spanNames: string[]): string {
|
||||
for (const p of pairs) {
|
||||
const prev = seen.get(p.ident)
|
||||
if (prev && prev !== p.name) {
|
||||
throw new Error(
|
||||
`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`,
|
||||
)
|
||||
throw new Error(`Identifier collision: "${prev}" and "${p.name}" both map to "${p.ident}"`)
|
||||
}
|
||||
seen.set(p.ident, p.name)
|
||||
}
|
||||
|
||||
const constLines = pairs
|
||||
.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`)
|
||||
.join('\n')
|
||||
const constLines = pairs.map((p) => ` ${p.ident}: ${JSON.stringify(p.name)},`).join('\n')
|
||||
const arrayEntries = spanNames.map((n) => ` ${JSON.stringify(n)},`).join('\n')
|
||||
|
||||
return `// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
@@ -131,13 +119,13 @@ async function main() {
|
||||
const raw = await readFile(inputPath, 'utf8')
|
||||
const schema = JSON.parse(raw)
|
||||
const spanNames = extractSpanNames(schema)
|
||||
const rendered = render(spanNames)
|
||||
const rendered = formatGeneratedSource(render(spanNames), OUTPUT_PATH, ROOT)
|
||||
|
||||
if (checkOnly) {
|
||||
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
|
||||
if (existing !== rendered) {
|
||||
throw new Error(
|
||||
'Generated trace spans contract is stale. Run: bun run trace-spans-contract:generate',
|
||||
'Generated trace spans contract is stale. Run: bun run trace-spans-contract:generate'
|
||||
)
|
||||
}
|
||||
console.log('Trace spans contract is up to date.')
|
||||
|
||||
Reference in New Issue
Block a user