Skip to content

Commit 83dafd1

Browse files
Tools: HF-24 close 7 review findings from A+C parallel review
Addresses the must-fix + should-fix findings from the parallel code-review-quality (A) and sherlock-review (C) passes on PR #1665. **P0 — `tryAccountingFormat` sign-loss bug** (A's only must-fix bug): docs adapter `customStringifyCurrency` in `currency-handling.md` mis-rendered negative values when the negative section was plain `$#,##0.00` (no parens). `Math.abs(value)` was formatted without ever adding the `-` prefix, so `value = -1234.5` against format `"$#,##0.00;$#,##0.00"` returned `$1,234.50` (positive-looking) instead of `-$1,234.50`. New branch: `parenMatch ? "(" + formatted + ")" : "-" + formatted`. Regression test for this path landed in hyperformula-tests `85979a0`. **`extract-doc-snippets.js` hardening** (C's main concerns): - Detect malformed `<!--snippet:NAME-->` markers (no spaces around the body) and fail loudly instead of silently skipping. Pre-fix, a typo in a docs author's marker meant the snippet was silently dropped from `test-utils/snippets/` while `snippets:check` still passed — drift undetected. - Skip symbolic links during `docs/` recursion (loop / sandbox-escape guard). - Cap recursion at `MAX_DEPTH = 8`. - Sort `readdirSync` output for deterministic generated content across platforms (CI-determinism, no more "works on my Mac" drift). - Prune orphan `*.generated.ts` files when a snippet is renamed or removed (otherwise rename leaves a tracked stale file behind, which `snippets:check` still treats as drift-free because it just looks for new diffs). - Render generated file with a `// @ts-nocheck` pragma so the body (verbatim untyped JS from the docs snippet) doesn't block the TypeScript compile when consumed. - Extra blank line between banner and content for grep-friendliness. **CI ordering** (A's should-fix): `npm run snippets:check` now runs AFTER `npm run lint` in `.github/workflows/lint.yml` — an extractor crash on a future malformed marker no longer masks a real lint failure as a docs-tooling failure. **tsconfig.test.json**: `test-utils` added to `include` so the generated snippet IS in the test build graph (combined with `@ts-nocheck`, the file is reachable for `import` without breaking compile). Together these close findings B5 (duplicate-reply pattern is documented in memory as part of the same review), plus all the should-fix items A and C surfaced on the codegen MVP. The follow-up A path (expand docs adapter so the test inline copy can be replaced by an `import` from `test-utils/snippets/currency-adapter.generated`) remains a product decision for Sequba and is tracked in [[project_hf_24_followups]].
1 parent de4d6fe commit 83dafd1

5 files changed

Lines changed: 100 additions & 21 deletions

File tree

.github/workflows/lint.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ jobs:
4444
- name: Install dependencies
4545
run: npm ci
4646

47-
- name: Check docs snippets are in sync with extracted source-of-truth
48-
run: npm run snippets:check
49-
5047
- name: Run linter
5148
run: npm run lint
49+
50+
- name: Check docs snippets are in sync with extracted source-of-truth
51+
# After lint so an extractor crash doesn't mask lint failures
52+
# downstream consumers care about.
53+
run: npm run snippets:check

docs/guide/currency-handling.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ const CURRENCY_RULES = [
150150
]
151151

152152
// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses
153+
// (or $#,##0.00;$#,##0.00 — both sections plain, sign rendered explicitly)
153154
function tryAccountingFormat(value, format) {
154155
const sections = format.split(';')
155156
if (sections.length !== 2) return undefined
@@ -166,7 +167,8 @@ function tryAccountingFormat(value, format) {
166167
maximumFractionDigits: fractionDigits,
167168
})
168169
const formatted = nf.format(Math.abs(value))
169-
return isNegative && parenMatch ? `(${formatted})` : formatted
170+
if (!isNegative) return formatted
171+
return parenMatch ? `(${formatted})` : `-${formatted}`
170172
}
171173

172174
export const customStringifyCurrency = (value, currencyFormat) => {

script/extract-doc-snippets.js

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,20 @@
1919
* The script intentionally has zero npm deps so it runs in the same Node we
2020
* use for `compile` without adding to package.json.
2121
*
22+
* Constraints:
23+
* - Snippet bodies must NOT contain nested triple-backtick fences. The
24+
* closing fence matcher accepts any `^```\s*$` line, so a nested fenced
25+
* block inside a snippet would terminate the outer match early.
26+
* - Symlinks under `docs/` are not followed (loop / sandbox-escape guard).
27+
* - Recursion depth is capped at MAX_DEPTH to prevent runaway walks.
28+
* - File enumeration order is stabilised via sort() so generated content
29+
* is byte-identical across platforms (CI-determinism).
30+
*
2231
* Exit codes:
23-
* 0 — snippets extracted successfully
24-
* 1 — duplicate snippet name across files, mismatched markers, or other
25-
* structural error
32+
* 0 — snippets extracted successfully (or no markers present)
33+
* 1 — any structural error: malformed marker, mismatched markers,
34+
* duplicate name, fence opened-but-not-closed, marker mismatch,
35+
* or missing docs dir
2636
*/
2737
'use strict'
2838

@@ -32,30 +42,60 @@ const path = require('path')
3242
const REPO_ROOT = path.resolve(__dirname, '..')
3343
const DOCS_DIR = path.join(REPO_ROOT, 'docs')
3444
const OUT_DIR = path.join(REPO_ROOT, 'test-utils', 'snippets')
45+
const MAX_DEPTH = 8
3546

36-
/** Recursively list every `.md` file under `dir`. */
37-
function listMarkdown(dir) {
47+
/**
48+
* Recursively list every `.md` file under `dir`. Skips symlinks and bails
49+
* past MAX_DEPTH so a stray loop under `docs/` can't hang the build.
50+
* Entries are sorted at every level for deterministic ordering across
51+
* platforms / filesystems.
52+
*/
53+
function listMarkdown(dir, depth = 0) {
54+
if (depth > MAX_DEPTH) {
55+
console.error(`extract-doc-snippets: depth limit (${MAX_DEPTH}) exceeded under ${dir} — refusing to recurse further`)
56+
return []
57+
}
58+
const entries = fs.readdirSync(dir, { withFileTypes: true })
59+
.filter((e) => !e.isSymbolicLink()) // never follow symlinks
60+
.sort((a, b) => a.name.localeCompare(b.name))
3861
const out = []
39-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
62+
for (const entry of entries) {
4063
const p = path.join(dir, entry.name)
41-
if (entry.isDirectory()) out.push(...listMarkdown(p))
64+
if (entry.isDirectory()) out.push(...listMarkdown(p, depth + 1))
4265
else if (entry.isFile() && entry.name.endsWith('.md')) out.push(p)
4366
}
4467
return out
4568
}
4669

70+
/** Throw with a precise diagnostic when a line looks like a snippet marker
71+
* but doesn't match the strict grammar (open or close). Catches typos such
72+
* as `<!--snippet:foo-->` (no spaces) which the strict regex skips silently. */
73+
const malformedSniffRe = /<!--\s*\/?\s*snippet\b/i
74+
4775
/** Parse a markdown file and yield { name, lang, code, sourceFile, line }. */
4876
function * extractSnippets(filePath) {
4977
const text = fs.readFileSync(filePath, 'utf8')
5078
const lines = text.split('\n')
51-
const openRe = /^<!--\s*snippet:([a-zA-Z][a-zA-Z0-9_-]*)\s*-->\s*$/
52-
const closeRe = /^<!--\s*\/snippet:([a-zA-Z][a-zA-Z0-9_-]*)\s*-->\s*$/
79+
const openRe = /^<!--\s+snippet:([a-zA-Z][a-zA-Z0-9_-]*)\s+-->\s*$/
80+
const closeRe = /^<!--\s+\/snippet:([a-zA-Z][a-zA-Z0-9_-]*)\s+-->\s*$/
5381
const fenceRe = /^```([a-zA-Z0-9]*)\s*$/
5482

5583
let i = 0
5684
while (i < lines.length) {
57-
const openMatch = openRe.exec(lines[i])
58-
if (!openMatch) { i++; continue }
85+
const line = lines[i]
86+
const openMatch = openRe.exec(line)
87+
if (!openMatch) {
88+
// Distinguish "not a marker at all" from "looks like a malformed marker".
89+
if (malformedSniffRe.test(line) && !closeRe.exec(line)) {
90+
throw new Error(
91+
`${filePath}:${i + 1} — line looks like a snippet marker but doesn't match the grammar. ` +
92+
`Required form: \`<!-- snippet:NAME -->\` (note the spaces around the marker body). ` +
93+
`Got: \`${line.trim()}\``,
94+
)
95+
}
96+
i++
97+
continue
98+
}
5999

60100
const name = openMatch[1]
61101
const startLine = i + 1
@@ -103,12 +143,32 @@ function render({ name, lang, code, sourceFile, line }) {
103143
'// Edit the source markdown then run `npm run snippets:extract`.',
104144
'// CI fails if this file drifts from the source.',
105145
'',
146+
// Docs snippets are written in JS without TypeScript annotations (they
147+
// need to copy-paste runnable for the reader). The .ts extension lets
148+
// consumers `import { … }` without a tsconfig.allowJs change, but the
149+
// body is intentionally untyped — `@ts-nocheck` keeps tsc quiet without
150+
// forcing every snippet author to learn TypeScript.
151+
'// @ts-nocheck',
152+
'',
106153
].join('\n')
107154
// Snippets in docs are JS for readability; the generated `.ts` file consumes
108155
// them as TypeScript. Preserve the original content byte-for-byte.
109156
return banner + code + (code.endsWith('\n') ? '' : '\n')
110157
}
111158

159+
/** Remove `*.generated.ts` files in OUT_DIR that aren't in `keep`. */
160+
function pruneOrphans(keep) {
161+
if (!fs.existsSync(OUT_DIR)) return []
162+
const removed = []
163+
for (const name of fs.readdirSync(OUT_DIR).sort()) {
164+
if (!name.endsWith('.generated.ts')) continue
165+
if (keep.has(name)) continue
166+
fs.unlinkSync(path.join(OUT_DIR, name))
167+
removed.push(path.relative(REPO_ROOT, path.join(OUT_DIR, name)))
168+
}
169+
return removed
170+
}
171+
112172
function main() {
113173
if (!fs.existsSync(DOCS_DIR)) {
114174
console.error(`extract-doc-snippets: docs dir not found at ${DOCS_DIR}`)
@@ -118,6 +178,7 @@ function main() {
118178

119179
const seen = new Map()
120180
const written = []
181+
const keepBasenames = new Set()
121182
for (const file of listMarkdown(DOCS_DIR)) {
122183
for (const snippet of extractSnippets(file)) {
123184
if (seen.has(snippet.name)) {
@@ -128,18 +189,28 @@ function main() {
128189
process.exit(1)
129190
}
130191
seen.set(snippet.name, snippet)
131-
const outPath = path.join(OUT_DIR, `${snippet.name}.generated.ts`)
192+
const basename = `${snippet.name}.generated.ts`
193+
const outPath = path.join(OUT_DIR, basename)
132194
fs.writeFileSync(outPath, render(snippet))
133195
written.push(path.relative(REPO_ROOT, outPath))
196+
keepBasenames.add(basename)
134197
}
135198
}
136199

137-
if (written.length === 0) {
200+
const removed = pruneOrphans(keepBasenames)
201+
202+
if (written.length === 0 && removed.length === 0) {
138203
console.log('extract-doc-snippets: no <!-- snippet:NAME --> blocks found')
139204
return
140205
}
141-
console.log(`extract-doc-snippets: wrote ${written.length} file(s)`)
142-
for (const w of written) console.log(` ${w}`)
206+
if (written.length > 0) {
207+
console.log(`extract-doc-snippets: wrote ${written.length} file(s)`)
208+
for (const w of written) console.log(` ${w}`)
209+
}
210+
if (removed.length > 0) {
211+
console.log(`extract-doc-snippets: pruned ${removed.length} orphan(s)`)
212+
for (const r of removed) console.log(` - ${r}`)
213+
}
143214
}
144215

145216
main()

test-utils/snippets/currency-adapter.generated.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Source: docs/guide/currency-handling.md:111 (snippet:currency-adapter)
33
// Edit the source markdown then run `npm run snippets:extract`.
44
// CI fails if this file drifts from the source.
5+
6+
// @ts-nocheck
57
// Minimal Excel-format-string → Intl.NumberFormat adapter.
68
// Extend the LCID_TO_LOCALE map and CURRENCY_RULES list to cover more formats.
79

@@ -42,6 +44,7 @@ const CURRENCY_RULES = [
4244
]
4345

4446
// Accounting: $#,##0.00;($#,##0.00) — positive;negative with parentheses
47+
// (or $#,##0.00;$#,##0.00 — both sections plain, sign rendered explicitly)
4548
function tryAccountingFormat(value, format) {
4649
const sections = format.split(';')
4750
if (sections.length !== 2) return undefined
@@ -58,7 +61,8 @@ function tryAccountingFormat(value, format) {
5861
maximumFractionDigits: fractionDigits,
5962
})
6063
const formatted = nf.format(Math.abs(value))
61-
return isNegative && parenMatch ? `(${formatted})` : formatted
64+
if (!isNegative) return formatted
65+
return parenMatch ? `(${formatted})` : `-${formatted}`
6266
}
6367

6468
export const customStringifyCurrency = (value, currencyFormat) => {

tsconfig.test.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"sourceMap": true
88
},
99
"extends": "./tsconfig",
10-
"include": ["src", "test"],
10+
"include": ["src", "test", "test-utils"],
1111
/* Exclude files that are specific for jest setup */
1212
"exclude": ["test/_setupFiles/jest"]
1313
}

0 commit comments

Comments
 (0)