Skip to content

Commit 83f5cd9

Browse files
committed
fix(docs): teach the source scanner about regex literals
blankStringsAndComments was a single regex with no concept of a regex literal, so two shapes silently truncated a block's subBlock list: /don't/ the apostrophe opened a phantom string that swallowed the following entries /[}]/ the brace in the character class closed the enclosing object Both returned a short list with no warning — a confident wrong answer, which for the hidden-param filter means silently deleting a user-settable row. No block file uses a regex literal today, so this was latent. Replaces the regex with a linear scanner that distinguishes a regex literal from a division by the previous significant character, blanks regex bodies whole (their last character is arbitrary source, same reason comments are blanked whole), and tracks ${} nesting so a backtick inside a template expression cannot end the template early. The scanner now returns null when it ends inside an unterminated construct. All three call sites treat that as UNKNOWN rather than guessing, so the filter switches off instead of stripping. Generated artifacts are byte-identical and the warning count is unchanged.
1 parent a9b476e commit 83f5cd9

2 files changed

Lines changed: 190 additions & 18 deletions

File tree

scripts/generate-docs.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,3 +721,43 @@ describe('the generated catalog ordering is locale-independent', () => {
721721
expect(source).not.toMatch(/localeCompare\(\s*[A-Za-z_$][\w$.]*\s*\)/)
722722
})
723723
})
724+
725+
describe('the scanner survives regex literals in a block config', () => {
726+
/**
727+
* `blankStringsAndComments` used to be a single regex with no concept of a regex literal, so
728+
* `/don't/` opened a phantom string that swallowed the following subBlocks, and a character
729+
* class like `/[}]/` closed the enclosing object early. Both returned a short list with no
730+
* warning — a confident wrong answer, which is the one outcome the filter must never produce.
731+
*/
732+
it('does not let an apostrophe inside a regex swallow later subBlocks', () => {
733+
const ids = extractUserSettableParamIds(
734+
"subBlocks: [{ id: 'a', condition: (v) => /don't/.test(v) }, { id: 'b' }],"
735+
)
736+
737+
expect(ids).toEqual(['a', 'b'])
738+
})
739+
740+
it('does not let a brace inside a character class close the object early', () => {
741+
const ids = extractUserSettableParamIds("subBlocks: [{ id: 'a', v: /[}]/ }, { id: 'b' }],")
742+
743+
expect(ids).toEqual(['a', 'b'])
744+
})
745+
746+
it('still reads a division as arithmetic rather than a regex', () => {
747+
const ids = extractUserSettableParamIds('subBlocks: [{ id: "a", n: total / 2 }, { id: "b" }],')
748+
749+
expect(ids).toEqual(['a', 'b'])
750+
})
751+
752+
it('does not mistake a protocol slash inside a string for a comment', () => {
753+
const ids = extractUserSettableParamIds(
754+
"subBlocks: [{ id: 'a', url: 'https://example.com/x' }, { id: 'b' }],"
755+
)
756+
757+
expect(ids).toEqual(['a', 'b'])
758+
})
759+
760+
it('reports UNKNOWN rather than guessing when a literal never terminates', () => {
761+
expect(extractUserSettableParamIds("subBlocks: [{ id: 'a }],")).toBeNull()
762+
})
763+
})

scripts/generate-docs.ts

Lines changed: 150 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,7 @@ export function extractUserSettableParamIds(
714714
blockName = 'block'
715715
): string[] | null {
716716
const scannable = blankStringsAndComments(blockContent)
717+
if (scannable === null) return null
717718
const keyMatch = /\bsubBlocks\s*:/.exec(scannable)
718719
if (!keyMatch) return []
719720

@@ -997,6 +998,7 @@ function collectShorthandPropertyNames(body: string, into: Set<string>): void {
997998
*/
998999
export function extractMapperWrittenParamIds(blockContent: string): string[] {
9991000
const scannable = blankStringsAndComments(blockContent)
1001+
if (scannable === null) return []
10001002
const ids = new Set<string>()
10011003

10021004
for (const [start, end] of findMapperBodyRanges(scannable)) {
@@ -1253,27 +1255,156 @@ function extractAuthType(blockContent: string): 'oauth' | 'api-key' | 'none' {
12531255
return 'none'
12541256
}
12551257

1258+
/** Characters after which a `/` begins a regex literal rather than a division. */
1259+
const REGEX_ALLOWED_AFTER = new Set([
1260+
'(',
1261+
',',
1262+
'=',
1263+
':',
1264+
'[',
1265+
'!',
1266+
'&',
1267+
'|',
1268+
'?',
1269+
'{',
1270+
'}',
1271+
';',
1272+
'+',
1273+
'-',
1274+
'*',
1275+
'%',
1276+
'~',
1277+
'^',
1278+
'<',
1279+
'>',
1280+
'\n',
1281+
])
1282+
12561283
/**
1257-
* Length-preserving copy of `content` with string-literal and comment
1258-
* interiors blanked out, so delimiter scans cannot be tripped by braces or
1259-
* quotes inside them. Indices into the result line up with indices into
1260-
* `content`.
1284+
* Blank out string literals, template literals, comments and regex literals so a structural
1285+
* scan sees only code punctuation. Length and newlines are preserved, which the `readLiteral`
1286+
* index-mapping call sites depend on.
1287+
*
1288+
* Quoted strings keep their delimiters so callers can still see where one began; comments and
1289+
* regex literals are blanked whole, because their final character is arbitrary source text —
1290+
* commented-out code ending in `[`, or a character class like `/[}]/`, otherwise leaves an
1291+
* unbalanced bracket that derails every downstream scan.
1292+
*
1293+
* Returns `null` when the scan ends inside an unterminated construct, which means the input
1294+
* was not what we assumed and no structural conclusion drawn from it can be trusted.
12611295
*/
1262-
function blankStringsAndComments(content: string): string {
1263-
return content.replace(
1264-
/(['"`])(?:\\[\s\S]|(?!\1)[^\\])*\1|\/\/[^\n]*|\/\*[\s\S]*?\*\//g,
1265-
(match: string, quote: string | undefined) => {
1266-
const blanked = match.replace(/[^\n]/g, ' ')
1267-
/**
1268-
* A comment has no delimiters worth preserving, so it is blanked whole. Keeping
1269-
* its final character would leak arbitrary source text — commented-out code ending
1270-
* in `[` or `{` leaves an unbalanced bracket that derails every scan downstream.
1271-
* A quoted string keeps its own quotes so callers can still see where it began.
1272-
*/
1273-
if (quote === undefined) return blanked
1274-
return quote + blanked.slice(1, -1) + quote
1296+
function blankStringsAndComments(content: string): string | null {
1297+
const out = content.split('')
1298+
const blank = (start: number, end: number) => {
1299+
for (let k = start; k < end && k < out.length; k++) if (out[k] !== '\n') out[k] = ' '
1300+
}
1301+
1302+
let i = 0
1303+
let prevSignificant = ''
1304+
while (i < content.length) {
1305+
const char = content[i]
1306+
1307+
if (char === '/' && content[i + 1] === '/') {
1308+
const nl = content.indexOf('\n', i)
1309+
const end = nl === -1 ? content.length : nl
1310+
blank(i, end)
1311+
i = end
1312+
continue
12751313
}
1276-
)
1314+
1315+
if (char === '/' && content[i + 1] === '*') {
1316+
const close = content.indexOf('*/', i + 2)
1317+
if (close === -1) return null
1318+
blank(i, close + 2)
1319+
i = close + 2
1320+
continue
1321+
}
1322+
1323+
if (char === '/' && (prevSignificant === '' || REGEX_ALLOWED_AFTER.has(prevSignificant))) {
1324+
let j = i + 1
1325+
let inClass = false
1326+
let closed = false
1327+
while (j < content.length) {
1328+
const c = content[j]
1329+
if (c === '\\') {
1330+
j += 2
1331+
continue
1332+
}
1333+
if (c === '\n') break
1334+
if (c === '[') inClass = true
1335+
else if (c === ']') inClass = false
1336+
else if (c === '/' && !inClass) {
1337+
closed = true
1338+
break
1339+
}
1340+
j++
1341+
}
1342+
if (!closed) return null
1343+
j++
1344+
while (j < content.length && /[a-z]/.test(content[j])) j++
1345+
blank(i, j)
1346+
prevSignificant = ')'
1347+
i = j
1348+
continue
1349+
}
1350+
1351+
if (char === "'" || char === '"') {
1352+
let j = i + 1
1353+
let closed = false
1354+
while (j < content.length) {
1355+
if (content[j] === '\\') {
1356+
j += 2
1357+
continue
1358+
}
1359+
if (content[j] === '\n') break
1360+
if (content[j] === char) {
1361+
closed = true
1362+
break
1363+
}
1364+
j++
1365+
}
1366+
if (!closed) return null
1367+
blank(i + 1, j)
1368+
prevSignificant = char
1369+
i = j + 1
1370+
continue
1371+
}
1372+
1373+
if (char === '`') {
1374+
let j = i + 1
1375+
let depth = 0
1376+
let closed = false
1377+
while (j < content.length) {
1378+
if (content[j] === '\\') {
1379+
j += 2
1380+
continue
1381+
}
1382+
if (depth === 0 && content[j] === '`') {
1383+
closed = true
1384+
break
1385+
}
1386+
if (content[j] === '$' && content[j + 1] === '{') {
1387+
depth++
1388+
j += 2
1389+
continue
1390+
}
1391+
if (depth > 0 && content[j] === '{') depth++
1392+
else if (depth > 0 && content[j] === '}') depth--
1393+
j++
1394+
}
1395+
if (!closed) return null
1396+
blank(i + 1, j)
1397+
prevSignificant = '`'
1398+
i = j + 1
1399+
continue
1400+
}
1401+
1402+
if (!/\s/.test(char)) prevSignificant = char
1403+
else if (char === '\n') prevSignificant = '\n'
1404+
i++
1405+
}
1406+
1407+
return out.join('')
12771408
}
12781409

12791410
/**
@@ -1288,6 +1419,7 @@ function extractOAuthServiceId(blockContent: string): string | undefined {
12881419
if (!typeMatch) return undefined
12891420

12901421
const scannable = blankStringsAndComments(blockContent)
1422+
if (scannable === null) return undefined
12911423
let depth = 0
12921424
let objectStart = -1
12931425
for (let i = typeMatch.index; i >= 0; i--) {

0 commit comments

Comments
 (0)