From 577b5d3cc73b1acb135af0d5b8f69f203503c45f Mon Sep 17 00:00:00 2001 From: Vijay Janapa Reddi Date: Sat, 14 Feb 2026 16:18:16 -0500 Subject: [PATCH] feat(vscode-ext): add health status bar and validation checks Add a persistent health indicator to the extension: a status bar item that shows pass/warn/error at a glance, plus a health summary node at the top of the Pre-commit tree view. Fast in-process TypeScript checks run on file save, editor switch, and startup (<100ms per file). Checks: duplicate labels, unclosed div fences, missing figure alt-text, and unresolved in-file cross-references. - Add src/validation/qmdChecks.ts with four pure check functions - Add src/validation/healthManager.ts with central status tracker - Wire HealthManager into extension.ts with status bar and event hooks - Add expandable health summary node to PrecommitTreeProvider - Register showHealthDetails command in package.json --- book/vscode-ext/package.json | 3 +- book/vscode-ext/src/extension.ts | 78 ++++- .../src/providers/precommitTreeProvider.ts | 132 +++++++- .../src/validation/healthManager.ts | 179 +++++++++++ book/vscode-ext/src/validation/qmdChecks.ts | 281 ++++++++++++++++++ 5 files changed, 666 insertions(+), 7 deletions(-) create mode 100644 book/vscode-ext/src/validation/healthManager.ts create mode 100644 book/vscode-ext/src/validation/qmdChecks.ts diff --git a/book/vscode-ext/package.json b/book/vscode-ext/package.json index d3b93809c..32a65de76 100644 --- a/book/vscode-ext/package.json +++ b/book/vscode-ext/package.json @@ -116,7 +116,8 @@ { "command": "mlsysbook.openLastFailureDetails", "title": "MLSysBook: Open Last Failure Details" }, { "command": "mlsysbook.setChapterOrderSource", "title": "MLSysBook: Set Chapter Order Source" }, { "command": "mlsysbook.setQmdVisualPreset", "title": "MLSysBook: Set QMD Visual Preset" }, - { "command": "mlsysbook.openSettings", "title": "MLSysBook: Open Extension Settings" } + { "command": "mlsysbook.openSettings", "title": "MLSysBook: Open Extension Settings" }, + { "command": "mlsysbook.showHealthDetails", "title": "MLSysBook: Show Health Details" } ], "configuration": { "title": "MLSysBook Workbench", diff --git a/book/vscode-ext/src/extension.ts b/book/vscode-ext/src/extension.ts index 051a14a85..8ce963950 100644 --- a/book/vscode-ext/src/extension.ts +++ b/book/vscode-ext/src/extension.ts @@ -29,6 +29,7 @@ import { runInVisibleTerminal, } from './utils/terminal'; import { ChapterOrderSource } from './types'; +import { HealthManager, HealthStatus } from './validation/healthManager'; export function activate(context: vscode.ExtensionContext): void { const root = getRepoRoot(); @@ -52,6 +53,22 @@ export function activate(context: vscode.ExtensionContext): void { const qmdAutoFoldManager = new QmdAutoFoldManager(); const chunkHighlighter = new QmdChunkHighlighter(); + // Health manager + status bar + const healthManager = new HealthManager(); + precommitProvider.setHealthManager(healthManager); + + const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); + statusBarItem.command = 'mlsysbook.showHealthDetails'; + updateStatusBar(statusBarItem, healthManager); + statusBarItem.show(); + + healthManager.onDidUpdateHealth(() => { + updateStatusBar(statusBarItem, healthManager); + precommitProvider.refresh(); + }); + + context.subscriptions.push(healthManager, statusBarItem); + // Inline Python value resolution (hover, ghost text, CodeLens) let pythonResolver: QmdPythonValueResolver | undefined; let pythonHoverProvider: QmdPythonHoverProvider | undefined; @@ -194,6 +211,9 @@ export function activate(context: vscode.ExtensionContext): void { await vscode.commands.executeCommand('workbench.action.openSettings', '@ext:mlsysbook.mlsysbook-workbench'); }), vscode.commands.registerCommand('mlsysbook.renameLabelReferences', () => void renameLabelAcrossWorkspace()), + vscode.commands.registerCommand('mlsysbook.showHealthDetails', () => { + void vscode.commands.executeCommand('mlsysbook.precommit.focus'); + }), // Section ID management (uses binder CLI) vscode.commands.registerCommand('mlsysbook.addSectionIds', () => { @@ -234,8 +254,20 @@ export function activate(context: vscode.ExtensionContext): void { registerContextMenuCommands(context); context.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor(editor => navigatorProvider.refreshFromEditor(editor)), - vscode.workspace.onDidSaveTextDocument(document => navigatorProvider.refreshFromDocument(document)), + vscode.window.onDidChangeActiveTextEditor(editor => { + navigatorProvider.refreshFromEditor(editor); + // Run health checks when switching to a QMD file + if (editor?.document.uri.fsPath.endsWith('.qmd')) { + healthManager.runFastChecks(editor.document); + } + }), + vscode.workspace.onDidSaveTextDocument(document => { + navigatorProvider.refreshFromDocument(document); + // Run health checks on save for QMD files + if (document.uri.fsPath.endsWith('.qmd')) { + healthManager.runFastChecks(document); + } + }), vscode.workspace.onDidChangeConfiguration(event => { if (event.affectsConfiguration('mlsysbook.chapterOrderSource')) { buildProvider.refresh(); @@ -255,6 +287,48 @@ export function activate(context: vscode.ExtensionContext): void { void navigatorTreeView.reveal(sectionItem, { focus: false, select: false, expand: true }); }), ); + + // Run health checks on the currently active file at startup + if (vscode.window.activeTextEditor?.document.uri.fsPath.endsWith('.qmd')) { + healthManager.runFastChecks(vscode.window.activeTextEditor.document); + } +} + +// --------------------------------------------------------------------------- +// Status bar helper +// --------------------------------------------------------------------------- + +function updateStatusBar(item: vscode.StatusBarItem, manager: HealthManager): void { + item.text = formatStatusBarText(manager.status, manager.getSummaryText()); + item.tooltip = manager.getTooltip(); + + switch (manager.status) { + case 'ok': + item.backgroundColor = undefined; + item.color = undefined; + break; + case 'warn': + item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + item.color = undefined; + break; + case 'error': + item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); + item.color = undefined; + break; + default: + item.backgroundColor = undefined; + item.color = undefined; + break; + } +} + +function formatStatusBarText(status: HealthStatus, summary: string): string { + switch (status) { + case 'ok': return `$(pass) ${summary}`; + case 'warn': return `$(warning) ${summary}`; + case 'error': return `$(error) ${summary}`; + default: return `$(circle-outline) ${summary}`; + } } export function deactivate(): void { diff --git a/book/vscode-ext/src/providers/precommitTreeProvider.ts b/book/vscode-ext/src/providers/precommitTreeProvider.ts index aadf7b645..a670f7894 100644 --- a/book/vscode-ext/src/providers/precommitTreeProvider.ts +++ b/book/vscode-ext/src/providers/precommitTreeProvider.ts @@ -1,8 +1,13 @@ import * as vscode from 'vscode'; import { PRECOMMIT_CHECK_HOOKS, PRECOMMIT_FIXER_HOOKS, CHECK_ACTIONS } from '../constants'; import { ActionTreeItem } from '../models/treeItems'; +import { HealthManager } from '../validation/healthManager'; -type TreeNode = ActionTreeItem | SeparatorItem; +type TreeNode = ActionTreeItem | SeparatorItem | HealthSummaryItem | HealthIssueItem; + +// --------------------------------------------------------------------------- +// Tree item helpers +// --------------------------------------------------------------------------- class SeparatorItem extends vscode.TreeItem { constructor(label: string) { @@ -12,15 +17,132 @@ class SeparatorItem extends vscode.TreeItem { } } +class HealthSummaryItem extends vscode.TreeItem { + constructor(private readonly healthManager: HealthManager) { + const status = healthManager.status; + const hasIssues = status === 'error' || status === 'warn'; + super( + healthManager.getSummaryText().replace('MLSysBook: ', ''), + hasIssues + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.None, + ); + + this.contextValue = 'health-summary'; + this.tooltip = healthManager.getTooltip(); + + switch (status) { + case 'ok': + this.iconPath = new vscode.ThemeIcon('pass', new vscode.ThemeColor('testing.iconPassed')); + this.description = ''; + break; + case 'warn': + this.iconPath = new vscode.ThemeIcon('warning', new vscode.ThemeColor('list.warningForeground')); + this.description = ''; + break; + case 'error': + this.iconPath = new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground')); + this.description = ''; + break; + case 'pending': + default: + this.iconPath = new vscode.ThemeIcon('circle-outline'); + this.description = 'no files checked'; + break; + } + } + + getChildren(): HealthIssueItem[] { + const allResults = this.healthManager.getAllResults(); + const items: HealthIssueItem[] = []; + + for (const fh of allResults) { + const shortPath = fh.uri.replace(/^file:\/\//, '').split('/').slice(-2).join('/'); + for (const r of fh.results) { + items.push(new HealthIssueItem(r.message, r.severity, shortPath, r.line, fh.uri)); + } + } + + return items; + } +} + +class HealthIssueItem extends vscode.TreeItem { + constructor( + message: string, + severity: 'error' | 'warning' | 'info', + filePath: string, + line: number, + fileUri: string, + ) { + super(message, vscode.TreeItemCollapsibleState.None); + this.contextValue = 'health-issue'; + this.description = `${filePath}:${line + 1}`; + this.tooltip = `${message}\n${filePath} line ${line + 1}`; + + // Click to open file at the issue line + const uri = vscode.Uri.parse(fileUri); + this.command = { + command: 'mlsysbook.openNavigatorLocation', + title: 'Go to Issue', + arguments: [uri, line], + }; + + switch (severity) { + case 'error': + this.iconPath = new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('list.errorForeground')); + break; + case 'warning': + this.iconPath = new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('list.warningForeground')); + break; + case 'info': + this.iconPath = new vscode.ThemeIcon('info', new vscode.ThemeColor('list.deemphasizedForeground')); + break; + } + } +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + export class PrecommitTreeProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private _healthManager: HealthManager | undefined; + private _healthSummary: HealthSummaryItem | undefined; + + /** Wire up the health manager (called from extension.ts). */ + setHealthManager(manager: HealthManager): void { + this._healthManager = manager; + } + + refresh(): void { + this._healthSummary = undefined; // force rebuild + this._onDidChangeTreeData.fire(undefined); + } + getTreeItem(element: TreeNode): vscode.TreeItem { return element; } - getChildren(): TreeNode[] { + getChildren(element?: TreeNode): TreeNode[] { + // If expanding the health summary node, return its children + if (element instanceof HealthSummaryItem) { + return element.getChildren(); + } + + // Top-level children + const items: TreeNode[] = []; + + // Health summary at the very top + if (this._healthManager) { + this._healthSummary = new HealthSummaryItem(this._healthManager); + items.push(this._healthSummary); + items.push(new SeparatorItem('')); + } + const runAll = new ActionTreeItem( 'Run ALL Hooks', 'mlsysbook.precommitRunAll', @@ -53,7 +175,7 @@ export class PrecommitTreeProvider implements vscode.TreeDataProvider new ActionTreeItem(action.label, 'mlsysbook.validateRunAction', [action.command], action.icon) ); - return [ + items.push( runAll, new SeparatorItem('--- Pre-commit Checks ---'), ...checkItems, @@ -63,6 +185,8 @@ export class PrecommitTreeProvider implements vscode.TreeDataProvider ...fixerItems, new SeparatorItem('--- Binder Check (Fast, Focused) ---'), ...binderCheckItems, - ]; + ); + + return items; } } diff --git a/book/vscode-ext/src/validation/healthManager.ts b/book/vscode-ext/src/validation/healthManager.ts new file mode 100644 index 000000000..efa13aae5 --- /dev/null +++ b/book/vscode-ext/src/validation/healthManager.ts @@ -0,0 +1,179 @@ +/** + * Central health-status tracker for the MLSysBook extension. + * + * Maintains per-file check results, aggregates an overall status, + * and emits events so the status bar and tree view stay in sync. + */ + +import * as vscode from 'vscode'; +import { CheckResult, runAllChecks } from './qmdChecks'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type HealthStatus = 'ok' | 'warn' | 'error' | 'pending'; + +export interface FileHealth { + uri: string; + results: CheckResult[]; + checkedAt: number; // Date.now() +} + +// --------------------------------------------------------------------------- +// HealthManager +// --------------------------------------------------------------------------- + +export class HealthManager implements vscode.Disposable { + private readonly _results = new Map(); + + private readonly _onDidUpdateHealth = new vscode.EventEmitter(); + /** Fires whenever the aggregated health status changes. */ + readonly onDidUpdateHealth = this._onDidUpdateHealth.event; + + private _status: HealthStatus = 'pending'; + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + /** Run all fast in-process checks on a single document. */ + runFastChecks(document: vscode.TextDocument): CheckResult[] { + const text = document.getText(); + const results = runAllChecks(text); + + this._results.set(document.uri.toString(), { + uri: document.uri.toString(), + results, + checkedAt: Date.now(), + }); + + this._recomputeStatus(); + this._onDidUpdateHealth.fire(); + return results; + } + + /** Remove results for a file (e.g. when it's closed). */ + clearFile(uri: vscode.Uri): void { + if (this._results.delete(uri.toString())) { + this._recomputeStatus(); + this._onDidUpdateHealth.fire(); + } + } + + /** Current aggregated status across all tracked files. */ + get status(): HealthStatus { + return this._status; + } + + /** Total number of issues across all tracked files. */ + get totalIssues(): number { + let count = 0; + for (const fh of this._results.values()) { + count += fh.results.length; + } + return count; + } + + /** Number of errors (not warnings/info). */ + get errorCount(): number { + let count = 0; + for (const fh of this._results.values()) { + for (const r of fh.results) { + if (r.severity === 'error') { count++; } + } + } + return count; + } + + /** Number of warnings. */ + get warningCount(): number { + let count = 0; + for (const fh of this._results.values()) { + for (const r of fh.results) { + if (r.severity === 'warning') { count++; } + } + } + return count; + } + + /** Number of info-level issues. */ + get infoCount(): number { + let count = 0; + for (const fh of this._results.values()) { + for (const r of fh.results) { + if (r.severity === 'info') { count++; } + } + } + return count; + } + + /** Human-readable one-line summary for the status bar. */ + getSummaryText(): string { + if (this._status === 'pending') { return 'MLSysBook'; } + if (this._status === 'ok') { return 'MLSysBook: All Clear'; } + + const parts: string[] = []; + const errors = this.errorCount; + const warnings = this.warningCount; + if (errors > 0) { parts.push(`${errors} error${errors !== 1 ? 's' : ''}`); } + if (warnings > 0) { parts.push(`${warnings} warning${warnings !== 1 ? 's' : ''}`); } + return `MLSysBook: ${parts.join(', ')}`; + } + + /** Tooltip with more detail for the status bar hover. */ + getTooltip(): string { + if (this._status === 'pending') { return 'MLSysBook — no files checked yet'; } + if (this._status === 'ok') { + const fileCount = this._results.size; + return `MLSysBook — ${fileCount} file${fileCount !== 1 ? 's' : ''} checked, no issues`; + } + + const lines: string[] = [`MLSysBook Health: ${this.totalIssues} issue(s)`]; + for (const fh of this._results.values()) { + if (fh.results.length === 0) { continue; } + const shortPath = fh.uri.replace(/^file:\/\//, '').split('/').slice(-2).join('/'); + lines.push(` ${shortPath}: ${fh.results.length} issue(s)`); + } + return lines.join('\n'); + } + + /** All results grouped by file, for the tree view. */ + getAllResults(): FileHealth[] { + return Array.from(this._results.values()).filter(fh => fh.results.length > 0); + } + + /** Results for a specific file. */ + getFileResults(uri: string): CheckResult[] { + return this._results.get(uri)?.results ?? []; + } + + // ----------------------------------------------------------------------- + // Internals + // ----------------------------------------------------------------------- + + private _recomputeStatus(): void { + if (this._results.size === 0) { + this._status = 'pending'; + return; + } + + let hasError = false; + let hasWarning = false; + + for (const fh of this._results.values()) { + for (const r of fh.results) { + if (r.severity === 'error') { hasError = true; } + if (r.severity === 'warning') { hasWarning = true; } + } + } + + if (hasError) { this._status = 'error'; } + else if (hasWarning) { this._status = 'warn'; } + else { this._status = 'ok'; } + } + + dispose(): void { + this._onDidUpdateHealth.dispose(); + } +} diff --git a/book/vscode-ext/src/validation/qmdChecks.ts b/book/vscode-ext/src/validation/qmdChecks.ts new file mode 100644 index 000000000..289564d7e --- /dev/null +++ b/book/vscode-ext/src/validation/qmdChecks.ts @@ -0,0 +1,281 @@ +/** + * Pure QMD validation functions — no VS Code API dependency. + * + * Each function accepts the full text of a `.qmd` file and returns + * an array of {@link CheckResult} objects describing any issues found. + * All checks are designed to run in <100 ms per file. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CheckResult { + /** 0-based line number where the issue was found. */ + line: number; + /** Human-readable description of the issue. */ + message: string; + /** Severity level for UI display. */ + severity: 'error' | 'warning' | 'info'; + /** Machine-readable check identifier (e.g. 'duplicate-label'). */ + checkId: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** All Quarto label prefixes we care about. */ +const LABEL_PREFIXES = ['fig', 'tbl', 'lst', 'sec', 'eq', 'thm', 'lem', 'cor', 'def', 'exm', 'exr']; + +/** Matches label definitions: {#fig-xxx}, {#tbl-xxx}, {#sec-xxx}, etc. */ +const LABEL_DEF_RE = new RegExp( + `\\{#((?:${LABEL_PREFIXES.join('|')})-[\\w-]+)\\}`, + 'g', +); + +/** Matches #| label: fig-xxx in Python code blocks. */ +const CODE_LABEL_RE = /^#\|\s*label:\s*((?:fig|tbl|lst)-[\w-]+)/; + +/** Matches @ref cross-references in prose. */ +const REF_RE = new RegExp( + `@((?:${LABEL_PREFIXES.join('|')})-[\\w-]+)`, + 'g', +); + +/** Matches div fence openers: ::: or :::: (with optional class). */ +const DIV_OPEN_RE = /^(:{3,})\s*\{/; + +/** Matches div fence closers: ::: or :::: on a line by themselves. */ +const DIV_CLOSE_RE = /^(:{3,})\s*$/; + +// --------------------------------------------------------------------------- +// Checks +// --------------------------------------------------------------------------- + +/** + * Detect duplicate label definitions within a single file. + * + * Scans for both attribute-style labels (`{#fig-xxx}`) and + * code-block labels (`#| label: fig-xxx`). + */ +export function checkDuplicateLabels(text: string): CheckResult[] { + const results: CheckResult[] = []; + const seen = new Map(); // label → first line (0-based) + const lines = text.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Attribute-style labels + LABEL_DEF_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = LABEL_DEF_RE.exec(line)) !== null) { + const label = m[1]; + if (seen.has(label)) { + results.push({ + line: i, + message: `Duplicate label '#${label}' (first defined on line ${seen.get(label)! + 1})`, + severity: 'error', + checkId: 'duplicate-label', + }); + } else { + seen.set(label, i); + } + } + + // Code-block labels + const cm = CODE_LABEL_RE.exec(line); + if (cm) { + const label = cm[1]; + if (seen.has(label)) { + results.push({ + line: i, + message: `Duplicate label '${label}' (first defined on line ${seen.get(label)! + 1})`, + severity: 'error', + checkId: 'duplicate-label', + }); + } else { + seen.set(label, i); + } + } + } + + return results; +} + +/** + * Detect unclosed or mismatched div fences (`::: { }` / `::::`). + * + * Tracks a stack of openers and reports unmatched fences. + */ +export function checkUnclosedDivs(text: string): CheckResult[] { + const results: CheckResult[] = []; + const lines = text.split('\n'); + + // Stack entries: [colon-count, line-number] + const stack: Array<[number, number]> = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + const openMatch = DIV_OPEN_RE.exec(line); + if (openMatch) { + stack.push([openMatch[1].length, i]); + continue; + } + + const closeMatch = DIV_CLOSE_RE.exec(line); + if (closeMatch) { + const closeLen = closeMatch[1].length; + if (stack.length === 0) { + results.push({ + line: i, + message: `Closing div fence '${''.padStart(closeLen, ':')}' has no matching opener`, + severity: 'warning', + checkId: 'unclosed-div', + }); + } else { + // Pop the most recent opener — Quarto allows ::: to close :::: + stack.pop(); + } + } + } + + // Any remaining openers are unclosed + for (const [colons, lineNum] of stack) { + results.push({ + line: lineNum, + message: `Div fence '${''.padStart(colons, ':')}' opened here is never closed`, + severity: 'warning', + checkId: 'unclosed-div', + }); + } + + return results; +} + +/** + * Detect figure code blocks missing `fig-alt` metadata. + * + * Scans `{python}` blocks that have a `#| label: fig-*` directive + * and warns if no `#| fig-alt:` directive is present. + */ +export function checkMissingAltText(text: string): CheckResult[] { + const results: CheckResult[] = []; + const lines = text.split('\n'); + + let inPythonBlock = false; + let blockStartLine = -1; + let hasFigLabel = false; + let hasAltText = false; + let figLabel = ''; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (/^```\{python\}/.test(line)) { + inPythonBlock = true; + blockStartLine = i; + hasFigLabel = false; + hasAltText = false; + figLabel = ''; + continue; + } + + if (inPythonBlock && /^```\s*$/.test(line)) { + // End of block — check + if (hasFigLabel && !hasAltText) { + results.push({ + line: blockStartLine, + message: `Figure '${figLabel}' is missing '#| fig-alt:' accessibility text`, + severity: 'warning', + checkId: 'missing-alt-text', + }); + } + inPythonBlock = false; + continue; + } + + if (inPythonBlock) { + const labelMatch = /^#\|\s*label:\s*(fig-[\w-]+)/.exec(line); + if (labelMatch) { + hasFigLabel = true; + figLabel = labelMatch[1]; + } + if (/^#\|\s*fig-alt:/.test(line)) { + hasAltText = true; + } + } + } + + return results; +} + +/** + * Detect cross-references (`@fig-xxx`, `@tbl-xxx`, etc.) that point to + * labels not defined anywhere in the same file. + * + * Note: cross-chapter references are valid in Quarto and will produce + * false positives here, so these are reported as `info` severity. + */ +export function checkBrokenInFileRefs(text: string): CheckResult[] { + const results: CheckResult[] = []; + const lines = text.split('\n'); + + // First pass: collect all defined labels + const definedLabels = new Set(); + + for (const line of lines) { + LABEL_DEF_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = LABEL_DEF_RE.exec(line)) !== null) { + definedLabels.add(m[1]); + } + + const cm = CODE_LABEL_RE.exec(line); + if (cm) { + definedLabels.add(cm[1]); + } + } + + // Second pass: find references not in the defined set + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Skip lines inside code blocks (rough heuristic: starts with #|) + if (/^\s*#\|/.test(line)) { continue; } + // Skip YAML frontmatter-like lines + if (/^\s*-\s/.test(line) && i < 20) { continue; } + + REF_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = REF_RE.exec(line)) !== null) { + const ref = m[1]; + if (!definedLabels.has(ref)) { + // Only flag sec- refs as info (almost always cross-chapter) + const severity = ref.startsWith('sec-') ? 'info' as const : 'info' as const; + results.push({ + line: i, + message: `Reference '@${ref}' has no matching label in this file (may be cross-chapter)`, + severity, + checkId: 'unresolved-ref', + }); + } + } + } + + return results; +} + +/** + * Run all fast checks on a QMD file's text. + */ +export function runAllChecks(text: string): CheckResult[] { + return [ + ...checkDuplicateLabels(text), + ...checkUnclosedDivs(text), + ...checkMissingAltText(text), + ...checkBrokenInFileRefs(text), + ]; +}