Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,73 @@ downstream source-map integration together.

The workflow uses a read-only token and disables persisted checkout
credentials because it executes downstream repository code.

## Source-map heap profiling

The `profile:source-map` script captures V8 heap snapshots at specific
construction phases to verify that lazy indexes (`lineStarts`,
`sourceGapPrefix`) do not exist in retained heap until first use.

### Usage

```bash
# Capture a snapshot
pnpm run profile:source-map -- segments build
pnpm run profile:source-map -- segments raw
pnpm run profile:source-map -- segments range

# Open in Chrome DevTools → Memory → Load .heapsnapshot
```

**Fixtures:**

| Fixture | Shape | Purpose |
|---|---|---|
| `many-nodes` | 10k short paragraphs | high node count, low segment density |
| `segments` | 256 KiB `&\(` repeats | maximum segment count per node |
| `fenced-code` | 1 MiB code block | single large code node with source-map segments |
| `urls` | 1000 link definitions | URL segment construction |

**Phases:**

| Phase | What runs | What should NOT exist in heap |
|---|---|---|
| `build` | `parseMdWithSourceMap()` only | `lineStarts`, `sourceGapPrefix` |
| `raw` | + `getRaw()` on every mapped node | `lineStarts`, `sourceGapPrefix` |
| `range` | + `getSourceRange()` on every mapped value node | (indexes now created on demand) |

### What to look for in DevTools

**Retained size dominators** — in the Dominators view, check:

- AST nodes (`type`, `position`, `children`, `value`)
- Source strings (the original Markdown input)
- Segment objects (`valueStart`, `valueEnd`, `sourceStart`, `sourceEnd`, `kind`)
- Segment arrays (one per mapped node)
- WeakMap state (sourceMap closure → WeakMap → values)
- Lazy indexes (`lineStarts` array, `sourceGapPrefix` arrays)

The build → raw → range progression should show `lineStarts` and
`sourceGapPrefix` (for multi-segment ranges) appearing only in the
`range` phase. If they appear
in `build` or `raw`, the lazy design is broken.

### Interpretation

The goal is a retained-heap breakdown:

```
retained heap
├── AST ??%
├── source strings ??%
├── segment objects ??%
├── segment arrays ??%
├── WeakMap state ??%
└── lazy indexes ??%
```

If AST + source strings + parser strings dominate, source-map memory
optimization is not worthwhile. If segment objects/arrays are large,
consider a more compact segment representation.

**Rule: no heap dominator / retained-size evidence → no memory optimization PRs.**
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
"test:package": "publint && node scripts/test-package.mjs",
"prebench:source-map": "pnpm run build",
"bench:source-map": "node scripts/bench-source-map.mjs",
"preprofile:source-map": "pnpm run build",
"profile:source-map": "node --expose-gc scripts/profile-source-map-heap.mjs",
"test:update": "jest --updateSnapshot",
"prepublishOnly": "pnpm run lint && pnpm run build && pnpm run test"
},
Expand Down
144 changes: 144 additions & 0 deletions scripts/profile-source-map-heap.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Heap-profile source-map retained structures.
//
// Run: pnpm run profile:source-map -- <fixture> <phase>
// e.g. pnpm run profile:source-map -- segments build
//
// Fixtures:
// many-nodes – 10k small text nodes (flat list)
// segments – 256 KiB high segment-density (&amp;\( repeats)
// fenced-code – 1 MiB fenced code block (single code node with segments)
// urls – 1000 link definitions
//
// Phases:
// build – parse only (no lazy indexes should exist)
// raw – parse + getRaw on every mapped node
// range – parse + getSourceRange on every mapped node
//
// Outputs a .heapsnapshot to temp/heap-profile/.
// Requires Node >= 19 with --expose-gc.
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import v8 from 'node:v8';

const require = createRequire(import.meta.url);
const { parseMdWithSourceMap } = require('../dist/lint-md-parser.cjs');

// ── Fixtures ────────────────────────────────────────────────────────────

function fixtureManyNodes() {
// 10k short paragraphs, each becomes a text node.
// Blank lines required — consecutive lines merge into one paragraph.
const lines = [];
for (let i = 0; i < 10_000; i++) lines.push(`node ${i}`, '');
return lines.join('\n');
}

function fixtureSegments() {
// 256 KiB of alternating &amp; \( — maximizes segment count per node.
const unit = '&amp;\\(';
const count = Math.round((256 * 1024) / unit.length);
return unit.repeat(count);
}

function fixtureFencedCode() {
// 1 MiB code inside a fenced block — one large code node with
// code-value source mapping.
const line = 'x'.repeat(80);
const target = 1024 * 1024;
const count = Math.ceil(target / (line.length + 1));
return '```\n' + `${line}\n`.repeat(count) + '```';
}

function fixtureUrls() {
// 1000 link definitions with short URLs.
const defs = [];
for (let i = 0; i < 1000; i++) {
defs.push(`[text${i}]: /path/to/resource-${i}?q=${i} "title ${i}"`);
}
return defs.join('\n');
}

const FIXTURES = {
'many-nodes': fixtureManyNodes,
segments: fixtureSegments,
'fenced-code': fixtureFencedCode,
urls: fixtureUrls,
};

// ── Helpers ─────────────────────────────────────────────────────────────

/** Collect all mapped text/inlineCode/code/link/definition nodes. */
function collectMappedNodes(root) {
const out = [];
(function walk(n) {
if (
n.type === 'text'
|| n.type === 'inlineCode'
|| n.type === 'code'
|| n.type === 'link'
|| n.type === 'definition'
) {
out.push(n);
}
for (const c of n.children || []) walk(c);
})(root);
return out;
}

// ── Main ────────────────────────────────────────────────────────────────

const [, , fixtureName, phase] = process.argv;

if (!fixtureName || !phase || !FIXTURES[fixtureName] || !['build', 'raw', 'range'].includes(phase)) {
console.error(
'Usage: node --expose-gc scripts/profile-source-map-heap.mjs'
+ ` <fixture> <phase>\n`
+ ` fixtures: ${Object.keys(FIXTURES).join(', ')}\n`
+ ` phases: build, raw, range`,
);
process.exit(1);
}

const md = FIXTURES[fixtureName]();

// Phase 1: build (parse only)
const { ast, sourceMap } = parseMdWithSourceMap(md);

// Phase 2 or 3: exercise the source map
if (phase === 'raw') {
const nodes = collectMappedNodes(ast);
for (const n of nodes) {
sourceMap.getRaw(n);
}
} else if (phase === 'range') {
const nodes = collectMappedNodes(ast).filter(
n => n.type === 'text' || n.type === 'inlineCode' || n.type === 'code',
);
for (const n of nodes) {
if (!n.value) continue;
try {
sourceMap.getSourceRange(n, 0, n.value.length);
} catch (error) {
// Only non-contiguous ranges (e.g. across blockquote boundaries)
// are expected to fail. Anything else is a real bug.
if (!(error instanceof RangeError)) throw error;
}
}
}

// Force GC before snapshot.
if (global.gc) global.gc();

// Write snapshot.
const outDir = join(process.cwd(), 'temp', 'heap-profile');
mkdirSync(outDir, { recursive: true });
const filename = `${fixtureName}-${phase}-${Date.now()}.heapsnapshot`;
const snapshotPath = join(outDir, filename);
v8.writeHeapSnapshot(snapshotPath);

console.log(`Snapshot written to ${snapshotPath}`);
console.log(` fixture: ${fixtureName}`);
console.log(` phase: ${phase}`);
console.log(` md size: ${(md.length / 1024).toFixed(1)} KiB (${md.length} chars)`);
console.log(` nodes: ${collectMappedNodes(ast).length} mapped`);