@tscircuit/schematic-trace-solver 0.0.77 → 0.0.78
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/.github/ISSUE_TEMPLATE/json-bug-report.yml +24 -0
- package/.github/scripts/import-json-bug-report.ts +483 -0
- package/.github/workflows/json-bug-report.yml +154 -0
- package/dist/index.d.ts +32 -10
- package/dist/index.js +216 -74
- package/lib/solvers/AvailableNetOrientationSolver/AvailableNetOrientationSolver.ts +4 -0
- package/lib/solvers/AvailableNetOrientationSolver/types.ts +1 -0
- package/lib/solvers/NetLabelNetLabelCollisionSolver/NetLabelNetLabelCollisionSolver.ts +6 -0
- package/lib/solvers/NetLabelPlacementSolver/SingleNetLabelPlacementSolver/SingleNetLabelPlacementSolver.ts +21 -1
- package/lib/solvers/NetLabelPlacementSolver/SingleNetLabelPlacementSolver/SingleNetLabelPlacementSolver_visualize.ts +9 -3
- package/lib/solvers/NetLabelPlacementSolver/SingleNetLabelPlacementSolver/solvePortOnlyPin.ts +27 -2
- package/lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver.ts +1 -0
- package/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2.ts +121 -30
- package/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions.ts +10 -10
- package/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/mid.ts +5 -8
- package/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/rect.ts +31 -6
- package/lib/solvers/TraceAnchoredNetLabelOverlapSolver/TraceAnchoredNetLabelOverlapSolver.ts +3 -0
- package/lib/solvers/TraceAnchoredNetLabelOverlapSolver/types.ts +1 -0
- package/lib/solvers/VccNetLabelCornerPlacementSolver/VccNetLabelCornerPlacementSolver.ts +3 -0
- package/lib/solvers/VccNetLabelCornerPlacementSolver/types.ts +1 -0
- package/lib/utils/textBoxBounds.ts +40 -0
- package/package.json +1 -1
- package/tests/repros/__snapshots__/manufacturePartNumber-text-box.snap.svg +12 -12
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: JSON Bug Report
|
|
2
|
+
description: Attach a schematic trace solver input JSON file for a generated snapshot regression test.
|
|
3
|
+
title: "JSON Bug Report: "
|
|
4
|
+
labels: ["json-bug-report"]
|
|
5
|
+
body:
|
|
6
|
+
- type: markdown
|
|
7
|
+
attributes:
|
|
8
|
+
value: |
|
|
9
|
+
Attach a `.json` file containing a `SchematicTracePipelineSolver` input problem. The automation will validate the file, generate a snapshot-only regression test, and open a pull request.
|
|
10
|
+
- type: textarea
|
|
11
|
+
id: json-file
|
|
12
|
+
attributes:
|
|
13
|
+
label: Solver input JSON attachment
|
|
14
|
+
description: Drag and drop exactly one `.json` file into this field.
|
|
15
|
+
placeholder: Attach the JSON file here.
|
|
16
|
+
validations:
|
|
17
|
+
required: true
|
|
18
|
+
- type: textarea
|
|
19
|
+
id: notes
|
|
20
|
+
attributes:
|
|
21
|
+
label: Notes
|
|
22
|
+
description: Add any context that helps explain the solver behavior.
|
|
23
|
+
validations:
|
|
24
|
+
required: false
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
type IssueEvent = {
|
|
5
|
+
issue: {
|
|
6
|
+
number: number
|
|
7
|
+
created_at: string
|
|
8
|
+
body?: string | null
|
|
9
|
+
labels?: Array<string | { name?: string }>
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type JsonLinkCandidate = {
|
|
14
|
+
label?: string
|
|
15
|
+
url: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const validDirections = new Set(["x+", "x-", "y+", "y-"])
|
|
19
|
+
|
|
20
|
+
const repoRoot = process.cwd()
|
|
21
|
+
const eventPath = process.env["GITHUB_EVENT_PATH"]
|
|
22
|
+
const githubOutputPath = process.env["GITHUB_OUTPUT"]
|
|
23
|
+
const githubToken =
|
|
24
|
+
process.env["TSCIRCUIT_BOT_GITHUB_TOKEN"] || process.env["GITHUB_TOKEN"]
|
|
25
|
+
const maxAttachmentBytes = 10 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
if (!eventPath) {
|
|
29
|
+
throw new Error("GITHUB_EVENT_PATH is not set")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const event = JSON.parse(fs.readFileSync(eventPath, "utf-8")) as IssueEvent
|
|
33
|
+
const issue = event.issue
|
|
34
|
+
|
|
35
|
+
if (!issue) {
|
|
36
|
+
throw new Error("This workflow must be run from an issue event")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const body = issue.body ?? ""
|
|
40
|
+
if (!isJsonBugReportIssue(issue, body)) {
|
|
41
|
+
writeOutput("skipped", "true")
|
|
42
|
+
writeOutput("skip_reason", "Issue is not from the JSON Bug Report form")
|
|
43
|
+
console.log("Skipping issue because it is not a JSON Bug Report")
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const timestamp = getTimestampFromCreatedAt(issue.created_at)
|
|
48
|
+
const reportId = `bug-report-${timestamp}`
|
|
49
|
+
const branchName = `json-bug-report/${reportId}`
|
|
50
|
+
const reportDir = path.join("tests", "bug-reports", reportId)
|
|
51
|
+
const reportDirAbs = path.join(repoRoot, reportDir)
|
|
52
|
+
const jsonPath = path.join(reportDir, `${reportId}.json`)
|
|
53
|
+
const testPath = path.join(reportDir, `${reportId}.test.ts`)
|
|
54
|
+
|
|
55
|
+
writeOutput("report_id", reportId)
|
|
56
|
+
writeOutput("branch_name", branchName)
|
|
57
|
+
writeOutput("report_dir", reportDir)
|
|
58
|
+
writeOutput("json_path", jsonPath)
|
|
59
|
+
writeOutput("test_path", testPath)
|
|
60
|
+
|
|
61
|
+
if (fs.existsSync(reportDirAbs)) {
|
|
62
|
+
writeOutput("created", "false")
|
|
63
|
+
writeOutput("skipped", "true")
|
|
64
|
+
writeOutput("skip_reason", `${reportDir} already exists`)
|
|
65
|
+
console.log(`${reportDir} already exists; no files generated`)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const jsonAttachment = await getJsonAttachment(body)
|
|
70
|
+
assertInputProblem(jsonAttachment.value)
|
|
71
|
+
|
|
72
|
+
fs.mkdirSync(reportDirAbs, { recursive: true })
|
|
73
|
+
fs.writeFileSync(
|
|
74
|
+
path.join(repoRoot, jsonPath),
|
|
75
|
+
`${JSON.stringify(jsonAttachment.value, null, 2)}\n`,
|
|
76
|
+
)
|
|
77
|
+
fs.writeFileSync(path.join(repoRoot, testPath), getTestFile(reportId))
|
|
78
|
+
|
|
79
|
+
writeOutput("created", "true")
|
|
80
|
+
writeOutput("attachment_url", jsonAttachment.url)
|
|
81
|
+
console.log(`Generated ${jsonPath} and ${testPath}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isJsonBugReportIssue(issue: IssueEvent["issue"], body: string) {
|
|
85
|
+
const labelNames = issue.labels?.map((label) => {
|
|
86
|
+
if (typeof label === "string") return label
|
|
87
|
+
return label.name ?? ""
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
labelNames?.includes("json-bug-report") ||
|
|
92
|
+
body.includes("### Solver input JSON attachment")
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getTimestampFromCreatedAt(createdAt: string) {
|
|
97
|
+
const date = new Date(createdAt)
|
|
98
|
+
|
|
99
|
+
if (Number.isNaN(date.getTime())) {
|
|
100
|
+
throw new Error(`Could not parse issue created_at timestamp: ${createdAt}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return date
|
|
104
|
+
.toISOString()
|
|
105
|
+
.replace(/\.\d{3}Z$/, "Z")
|
|
106
|
+
.replaceAll("-", "")
|
|
107
|
+
.replaceAll(":", "")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function getJsonAttachment(body: string) {
|
|
111
|
+
const candidates = getJsonLinkCandidates(body)
|
|
112
|
+
|
|
113
|
+
if (candidates.length === 0) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
"No .json GitHub user attachment was found in the issue body. Attach a JSON file to the 'Solver input JSON attachment' field and edit the issue to retry.",
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const errors: string[] = []
|
|
120
|
+
|
|
121
|
+
for (const candidate of candidates) {
|
|
122
|
+
try {
|
|
123
|
+
const content = await downloadGitHubUserAttachment(candidate.url)
|
|
124
|
+
return {
|
|
125
|
+
url: candidate.url,
|
|
126
|
+
value: JSON.parse(content),
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
errors.push(
|
|
130
|
+
`${candidate.label ?? candidate.url}: ${getErrorMessage(error)}`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Could not download and parse a JSON attachment.\n\n${errors.join("\n")}`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getJsonLinkCandidates(body: string): JsonLinkCandidate[] {
|
|
141
|
+
const candidates: JsonLinkCandidate[] = []
|
|
142
|
+
const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g
|
|
143
|
+
let markdownMatch = markdownLinkRegex.exec(body)
|
|
144
|
+
|
|
145
|
+
while (markdownMatch) {
|
|
146
|
+
candidates.push({
|
|
147
|
+
label: markdownMatch[1],
|
|
148
|
+
url: cleanupUrl(markdownMatch[2]!),
|
|
149
|
+
})
|
|
150
|
+
markdownMatch = markdownLinkRegex.exec(body)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const bareUrlRegex = /https?:\/\/[^\s<>)"']+/g
|
|
154
|
+
let bareUrlMatch = bareUrlRegex.exec(body)
|
|
155
|
+
|
|
156
|
+
while (bareUrlMatch) {
|
|
157
|
+
candidates.push({ url: cleanupUrl(bareUrlMatch[0]) })
|
|
158
|
+
bareUrlMatch = bareUrlRegex.exec(body)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const seen = new Set<string>()
|
|
162
|
+
const filtered: JsonLinkCandidate[] = []
|
|
163
|
+
|
|
164
|
+
for (const candidate of candidates) {
|
|
165
|
+
if (!isAllowedGitHubUserAttachment(candidate.url)) continue
|
|
166
|
+
if (!isJsonAttachmentCandidate(candidate)) continue
|
|
167
|
+
if (seen.has(candidate.url)) continue
|
|
168
|
+
seen.add(candidate.url)
|
|
169
|
+
filtered.push(candidate)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return filtered
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function cleanupUrl(url: string) {
|
|
176
|
+
return url.replace(/[.,;]+$/, "")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isAllowedGitHubUserAttachment(url: string) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = new URL(url)
|
|
182
|
+
return (
|
|
183
|
+
parsed.protocol === "https:" &&
|
|
184
|
+
parsed.hostname === "github.com" &&
|
|
185
|
+
parsed.pathname.startsWith("/user-attachments/")
|
|
186
|
+
)
|
|
187
|
+
} catch {
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isJsonAttachmentCandidate(candidate: JsonLinkCandidate) {
|
|
193
|
+
return [candidate.label, candidate.url].some((value) =>
|
|
194
|
+
/\.json(?:[?#].*)?$/i.test(value ?? ""),
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function downloadGitHubUserAttachment(url: string, redirectCount = 0) {
|
|
199
|
+
if (redirectCount > 5) {
|
|
200
|
+
throw new Error("Too many redirects while downloading attachment")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const parsed = new URL(url)
|
|
204
|
+
const headers: Record<string, string> = {
|
|
205
|
+
Accept: "application/octet-stream",
|
|
206
|
+
"User-Agent": "schematic-trace-solver-json-bug-report-importer",
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (githubToken && parsed.hostname === "github.com") {
|
|
210
|
+
headers["Authorization"] = `Bearer ${githubToken}`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const response = await fetch(url, {
|
|
214
|
+
headers,
|
|
215
|
+
redirect: "manual",
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
if (response.status >= 300 && response.status < 400) {
|
|
219
|
+
const location = response.headers.get("location")
|
|
220
|
+
|
|
221
|
+
if (!location) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Attachment download redirected without a Location header`,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return downloadGitHubUserAttachment(
|
|
228
|
+
new URL(location, url).toString(),
|
|
229
|
+
redirectCount + 1,
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Attachment download failed with HTTP ${response.status} ${response.statusText}`,
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const contentLength = Number(response.headers.get("content-length"))
|
|
240
|
+
|
|
241
|
+
if (Number.isFinite(contentLength) && contentLength > maxAttachmentBytes) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Attachment is larger than the ${maxAttachmentBytes} byte limit`,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const content = await response.text()
|
|
248
|
+
|
|
249
|
+
if (content.length > maxAttachmentBytes) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Attachment is larger than the ${maxAttachmentBytes} byte limit`,
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return content
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function assertInputProblem(value: unknown) {
|
|
259
|
+
assertRecord(value, "input problem")
|
|
260
|
+
assertArray(value["chips"], "chips")
|
|
261
|
+
assertArray(value["directConnections"], "directConnections")
|
|
262
|
+
assertArray(value["netConnections"], "netConnections")
|
|
263
|
+
assertRecord(
|
|
264
|
+
value["availableNetLabelOrientations"],
|
|
265
|
+
"availableNetLabelOrientations",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
for (let index = 0; index < value["chips"].length; index++) {
|
|
269
|
+
assertChip(value["chips"][index], `chips[${index}]`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (let index = 0; index < value["directConnections"].length; index++) {
|
|
273
|
+
assertDirectConnection(
|
|
274
|
+
value["directConnections"][index],
|
|
275
|
+
`directConnections[${index}]`,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (let index = 0; index < value["netConnections"].length; index++) {
|
|
280
|
+
assertNetConnection(
|
|
281
|
+
value["netConnections"][index],
|
|
282
|
+
`netConnections[${index}]`,
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const [netId, orientations] of Object.entries(
|
|
287
|
+
value["availableNetLabelOrientations"],
|
|
288
|
+
)) {
|
|
289
|
+
const pathName = `availableNetLabelOrientations.${netId}`
|
|
290
|
+
assertArray(orientations, pathName)
|
|
291
|
+
|
|
292
|
+
for (let index = 0; index < orientations.length; index++) {
|
|
293
|
+
const orientation = orientations[index]
|
|
294
|
+
|
|
295
|
+
if (
|
|
296
|
+
typeof orientation !== "string" ||
|
|
297
|
+
!validDirections.has(orientation)
|
|
298
|
+
) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`${pathName}[${index}] must be one of x+, x-, y+, or y-`,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if ("textBoxes" in value && value["textBoxes"] !== undefined) {
|
|
307
|
+
assertArray(value["textBoxes"], "textBoxes")
|
|
308
|
+
|
|
309
|
+
for (let index = 0; index < value["textBoxes"].length; index++) {
|
|
310
|
+
assertTextBox(value["textBoxes"][index], `textBoxes[${index}]`)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (
|
|
315
|
+
"maxMspPairDistance" in value &&
|
|
316
|
+
value["maxMspPairDistance"] !== undefined
|
|
317
|
+
) {
|
|
318
|
+
assertFiniteNumber(value["maxMspPairDistance"], "maxMspPairDistance")
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function assertChip(value: unknown, pathName: string) {
|
|
323
|
+
assertRecord(value, pathName)
|
|
324
|
+
assertString(value["chipId"], `${pathName}.chipId`)
|
|
325
|
+
assertPoint(value["center"], `${pathName}.center`)
|
|
326
|
+
assertFiniteNumber(value["width"], `${pathName}.width`)
|
|
327
|
+
assertFiniteNumber(value["height"], `${pathName}.height`)
|
|
328
|
+
assertArray(value["pins"], `${pathName}.pins`)
|
|
329
|
+
|
|
330
|
+
for (let index = 0; index < value["pins"].length; index++) {
|
|
331
|
+
assertPin(value["pins"][index], `${pathName}.pins[${index}]`)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if ("sectionId" in value && value["sectionId"] !== undefined) {
|
|
335
|
+
assertString(value["sectionId"], `${pathName}.sectionId`)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function assertPin(value: unknown, pathName: string) {
|
|
340
|
+
assertRecord(value, pathName)
|
|
341
|
+
assertString(value["pinId"], `${pathName}.pinId`)
|
|
342
|
+
assertFiniteNumber(value["x"], `${pathName}.x`)
|
|
343
|
+
assertFiniteNumber(value["y"], `${pathName}.y`)
|
|
344
|
+
|
|
345
|
+
if ("_facingDirection" in value && value["_facingDirection"] !== undefined) {
|
|
346
|
+
const direction = value["_facingDirection"]
|
|
347
|
+
|
|
348
|
+
if (typeof direction !== "string" || !validDirections.has(direction)) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`${pathName}._facingDirection must be one of x+, x-, y+, or y-`,
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function assertDirectConnection(value: unknown, pathName: string) {
|
|
357
|
+
assertRecord(value, pathName)
|
|
358
|
+
assertArray(value["pinIds"], `${pathName}.pinIds`)
|
|
359
|
+
|
|
360
|
+
if (value["pinIds"].length !== 2) {
|
|
361
|
+
throw new Error(`${pathName}.pinIds must contain exactly 2 pin ids`)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
assertString(value["pinIds"][0], `${pathName}.pinIds[0]`)
|
|
365
|
+
assertString(value["pinIds"][1], `${pathName}.pinIds[1]`)
|
|
366
|
+
|
|
367
|
+
if ("netId" in value && value["netId"] !== undefined) {
|
|
368
|
+
assertString(value["netId"], `${pathName}.netId`)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if ("netLabelWidth" in value && value["netLabelWidth"] !== undefined) {
|
|
372
|
+
assertFiniteNumber(value["netLabelWidth"], `${pathName}.netLabelWidth`)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function assertNetConnection(value: unknown, pathName: string) {
|
|
377
|
+
assertRecord(value, pathName)
|
|
378
|
+
assertString(value["netId"], `${pathName}.netId`)
|
|
379
|
+
assertArray(value["pinIds"], `${pathName}.pinIds`)
|
|
380
|
+
|
|
381
|
+
for (let index = 0; index < value["pinIds"].length; index++) {
|
|
382
|
+
assertString(value["pinIds"][index], `${pathName}.pinIds[${index}]`)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if ("netLabelWidth" in value && value["netLabelWidth"] !== undefined) {
|
|
386
|
+
assertFiniteNumber(value["netLabelWidth"], `${pathName}.netLabelWidth`)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if ("netLabelHeight" in value && value["netLabelHeight"] !== undefined) {
|
|
390
|
+
assertFiniteNumber(value["netLabelHeight"], `${pathName}.netLabelHeight`)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function assertTextBox(value: unknown, pathName: string) {
|
|
395
|
+
assertRecord(value, pathName)
|
|
396
|
+
assertPoint(value["center"], `${pathName}.center`)
|
|
397
|
+
assertFiniteNumber(value["width"], `${pathName}.width`)
|
|
398
|
+
assertFiniteNumber(value["height"], `${pathName}.height`)
|
|
399
|
+
|
|
400
|
+
if ("chipId" in value && value["chipId"] !== undefined) {
|
|
401
|
+
assertString(value["chipId"], `${pathName}.chipId`)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if ("text" in value && value["text"] !== undefined) {
|
|
405
|
+
assertString(value["text"], `${pathName}.text`)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function assertPoint(value: unknown, pathName: string) {
|
|
410
|
+
assertRecord(value, pathName)
|
|
411
|
+
assertFiniteNumber(value["x"], `${pathName}.x`)
|
|
412
|
+
assertFiniteNumber(value["y"], `${pathName}.y`)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function assertRecord(
|
|
416
|
+
value: unknown,
|
|
417
|
+
pathName: string,
|
|
418
|
+
): asserts value is Record<string, unknown> {
|
|
419
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
420
|
+
throw new Error(`${pathName} must be an object`)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function assertArray(
|
|
425
|
+
value: unknown,
|
|
426
|
+
pathName: string,
|
|
427
|
+
): asserts value is unknown[] {
|
|
428
|
+
if (!Array.isArray(value)) {
|
|
429
|
+
throw new Error(`${pathName} must be an array`)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function assertString(value: unknown, pathName: string) {
|
|
434
|
+
if (typeof value !== "string") {
|
|
435
|
+
throw new Error(`${pathName} must be a string`)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function assertFiniteNumber(value: unknown, pathName: string) {
|
|
440
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
441
|
+
throw new Error(`${pathName} must be a finite number`)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function getTestFile(reportId: string) {
|
|
446
|
+
return `import { expect, test } from "bun:test"
|
|
447
|
+
import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver"
|
|
448
|
+
import inputProblem from "./${reportId}.json"
|
|
449
|
+
import "tests/fixtures/matcher"
|
|
450
|
+
|
|
451
|
+
test("${reportId}", () => {
|
|
452
|
+
const solver = new SchematicTracePipelineSolver(inputProblem as any)
|
|
453
|
+
|
|
454
|
+
solver.solve()
|
|
455
|
+
|
|
456
|
+
expect(solver).toMatchSolverSnapshot(import.meta.path)
|
|
457
|
+
})
|
|
458
|
+
`
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function writeOutput(key: string, value: string) {
|
|
462
|
+
if (!githubOutputPath) return
|
|
463
|
+
fs.appendFileSync(githubOutputPath, `${key}=${value}\n`)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function writeFailureSummary(error: unknown) {
|
|
467
|
+
const message = getErrorMessage(error)
|
|
468
|
+
fs.writeFileSync(
|
|
469
|
+
path.join(repoRoot, "bug-report-error.md"),
|
|
470
|
+
`The JSON bug report import failed before a pull request could be opened.\n\n${message}\n`,
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function getErrorMessage(error: unknown) {
|
|
475
|
+
if (error instanceof Error) return error.message
|
|
476
|
+
return String(error)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
main().catch((error) => {
|
|
480
|
+
writeFailureSummary(error)
|
|
481
|
+
console.error(error)
|
|
482
|
+
process.exit(1)
|
|
483
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
name: JSON Bug Report
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
issues:
|
|
5
|
+
types: [opened, edited]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: write
|
|
9
|
+
issues: write
|
|
10
|
+
pull-requests: write
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: json-bug-report-${{ github.event.issue.number }}
|
|
14
|
+
cancel-in-progress: false
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
import-json-bug-report:
|
|
18
|
+
if: >-
|
|
19
|
+
${{
|
|
20
|
+
contains(join(github.event.issue.labels.*.name, ','), 'json-bug-report') ||
|
|
21
|
+
contains(github.event.issue.body || '', '### Solver input JSON attachment')
|
|
22
|
+
}}
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
timeout-minutes: 10
|
|
25
|
+
|
|
26
|
+
steps:
|
|
27
|
+
- name: Checkout code
|
|
28
|
+
uses: actions/checkout@v4
|
|
29
|
+
with:
|
|
30
|
+
token: ${{ secrets.TSCIRCUIT_BOT_GITHUB_TOKEN || github.token }}
|
|
31
|
+
persist-credentials: false
|
|
32
|
+
|
|
33
|
+
- name: Setup bun
|
|
34
|
+
uses: oven-sh/setup-bun@v2
|
|
35
|
+
with:
|
|
36
|
+
bun-version: 1.3.1
|
|
37
|
+
|
|
38
|
+
- name: Install dependencies
|
|
39
|
+
run: bun install
|
|
40
|
+
|
|
41
|
+
- name: Import JSON bug report
|
|
42
|
+
id: import
|
|
43
|
+
env:
|
|
44
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
45
|
+
TSCIRCUIT_BOT_GITHUB_TOKEN: ${{ secrets.TSCIRCUIT_BOT_GITHUB_TOKEN }}
|
|
46
|
+
run: bun .github/scripts/import-json-bug-report.ts
|
|
47
|
+
|
|
48
|
+
- name: Run generated snapshot test
|
|
49
|
+
if: steps.import.outputs.created == 'true'
|
|
50
|
+
run: |
|
|
51
|
+
set +e
|
|
52
|
+
timeout 7m bun test "${{ steps.import.outputs.test_path }}" -u 2>&1 | tee bug-report-test.log
|
|
53
|
+
status=${PIPESTATUS[0]}
|
|
54
|
+
if [ "$status" -ne 0 ]; then
|
|
55
|
+
{
|
|
56
|
+
echo "The attached JSON was downloaded and validated, but the generated solver snapshot test failed."
|
|
57
|
+
echo
|
|
58
|
+
echo '```'
|
|
59
|
+
tail -n 80 bug-report-test.log
|
|
60
|
+
echo '```'
|
|
61
|
+
} > bug-report-error.md
|
|
62
|
+
exit "$status"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
- name: Format generated test
|
|
66
|
+
if: steps.import.outputs.created == 'true'
|
|
67
|
+
run: ./node_modules/.bin/biome format --write "${{ steps.import.outputs.test_path }}"
|
|
68
|
+
|
|
69
|
+
- name: Open pull request
|
|
70
|
+
id: pr
|
|
71
|
+
if: steps.import.outputs.created == 'true'
|
|
72
|
+
env:
|
|
73
|
+
GH_TOKEN: ${{ secrets.TSCIRCUIT_BOT_GITHUB_TOKEN || github.token }}
|
|
74
|
+
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
75
|
+
REPORT_ID: ${{ steps.import.outputs.report_id }}
|
|
76
|
+
BRANCH_NAME: ${{ steps.import.outputs.branch_name }}
|
|
77
|
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
78
|
+
run: |
|
|
79
|
+
set -euo pipefail
|
|
80
|
+
|
|
81
|
+
git config user.name "tscircuit-bot"
|
|
82
|
+
git config user.email "tscircuit-bot@users.noreply.github.com"
|
|
83
|
+
git checkout -B "$BRANCH_NAME"
|
|
84
|
+
git add "${{ steps.import.outputs.report_dir }}"
|
|
85
|
+
|
|
86
|
+
if git diff --cached --quiet; then
|
|
87
|
+
echo "No changes to commit."
|
|
88
|
+
exit 0
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
git commit -m "Add JSON bug report $REPORT_ID"
|
|
92
|
+
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
|
93
|
+
git push --force-with-lease origin "HEAD:$BRANCH_NAME"
|
|
94
|
+
|
|
95
|
+
pr_body_file="$(mktemp)"
|
|
96
|
+
{
|
|
97
|
+
echo "Generated from #${ISSUE_NUMBER}."
|
|
98
|
+
echo
|
|
99
|
+
echo "Adds a snapshot-only regression test for the attached JSON solver input."
|
|
100
|
+
echo
|
|
101
|
+
echo "Workflow run: ${RUN_URL}"
|
|
102
|
+
} > "$pr_body_file"
|
|
103
|
+
|
|
104
|
+
pr_url="$(gh pr view "$BRANCH_NAME" --json url --jq .url 2>/dev/null || true)"
|
|
105
|
+
if [ -z "$pr_url" ]; then
|
|
106
|
+
pr_url="$(gh pr create \
|
|
107
|
+
--base main \
|
|
108
|
+
--head "$BRANCH_NAME" \
|
|
109
|
+
--title "Add JSON bug report $REPORT_ID" \
|
|
110
|
+
--body-file "$pr_body_file")"
|
|
111
|
+
else
|
|
112
|
+
gh pr edit "$BRANCH_NAME" \
|
|
113
|
+
--title "Add JSON bug report $REPORT_ID" \
|
|
114
|
+
--body-file "$pr_body_file"
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT"
|
|
118
|
+
|
|
119
|
+
- name: Tag original issue
|
|
120
|
+
if: steps.import.outputs.created == 'true' && steps.pr.outputs.pr_url != ''
|
|
121
|
+
env:
|
|
122
|
+
GH_TOKEN: ${{ secrets.TSCIRCUIT_BOT_GITHUB_TOKEN || github.token }}
|
|
123
|
+
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
124
|
+
REPORT_ID: ${{ steps.import.outputs.report_id }}
|
|
125
|
+
PR_URL: ${{ steps.pr.outputs.pr_url }}
|
|
126
|
+
run: |
|
|
127
|
+
message_file="$(mktemp)"
|
|
128
|
+
{
|
|
129
|
+
echo "Created or updated ${PR_URL} for this JSON bug report."
|
|
130
|
+
echo
|
|
131
|
+
echo "Report id: \`${REPORT_ID}\`"
|
|
132
|
+
} > "$message_file"
|
|
133
|
+
|
|
134
|
+
gh issue comment "$ISSUE_NUMBER" --body-file "$message_file"
|
|
135
|
+
|
|
136
|
+
- name: Comment on issue when import fails
|
|
137
|
+
if: failure()
|
|
138
|
+
env:
|
|
139
|
+
GH_TOKEN: ${{ secrets.TSCIRCUIT_BOT_GITHUB_TOKEN || github.token }}
|
|
140
|
+
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
141
|
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
142
|
+
run: |
|
|
143
|
+
message_file="$(mktemp)"
|
|
144
|
+
{
|
|
145
|
+
echo "I couldn't import this JSON bug report automatically."
|
|
146
|
+
echo
|
|
147
|
+
if [ -f bug-report-error.md ]; then
|
|
148
|
+
cat bug-report-error.md
|
|
149
|
+
echo
|
|
150
|
+
fi
|
|
151
|
+
echo "Workflow run: ${RUN_URL}"
|
|
152
|
+
} > "$message_file"
|
|
153
|
+
|
|
154
|
+
gh issue comment "$ISSUE_NUMBER" --body-file "$message_file"
|