diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b361e74..c02c1ca5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- **Hybrid Markdown editing** ([#460](https://github.com/raiseCatError/notMyShell/issues/460)). **F3** in a Markdown document (or `H` from the read-only views) shows it *rendered* — headings, emphasis, links, lists, tables, quotes, code — except for the block your caret (or selection) is in, which shows its exact Markdown source and is fully editable. Move on and it renders again. It is another presentation of the same buffer: every key is the editor's (letters always type; snippets, list/table helpers, undo/redo, hunk review, recovery drafts and Save As all work as in the source editor), the caret lives in source positions, and switching views never changes the text. The smallest safe block is revealed — the whole list for a nested item, the whole table, the whole fence — and a selection shows every block it touches as source. The view is anchored to a source line, so revealing a block does not move what is above it. F3 again returns to the source editor; in the split view (F2) F3 switches the text pane between source and hybrid. Limits: Markdown files only (not HTML); images appear as `[image: alt]`; Up/Down move by source line; pointer input is not handled.
+- **Problems: offline static diagnostics** ([#466](https://github.com/raiseCatError/notMyShell/issues/466)). In a Markdown, HTML or CSS document, **F1 then `e`** (the snippet palette's *Problems…* in HTML files; **F1** in a CSS file; **`P`** in the read-only views and the split's preview pane) lists what is wrong: ✖ errors, ▲ warnings, ℹ notes and `?` things that cannot be checked. Markdown: links to files that do not exist, `#fragments` with no heading or id, missing images, images without alt text, undefined `[text][ref]` references and footnotes, unused footnotes, repeated headings. HTML: missing images, stylesheets and scripts, `` without `alt`, form controls without a label, duplicate ids, unresolved `#fragments`, unclosed or unexpected tags. CSS: missing `url()` assets and unbalanced braces. Type to search, Tab filters (all, problems, errors), Enter jumps to the exact text and selects it (Back returns), Del dismisses one, Ctrl+R checks again. Checking starts when you first open Problems, follows the text (typing, undo, snippets, restored drafts, a merge) after a short pause, drops results for text that has since changed, and counts show in the editor's status row. Everything is offline: external URLs are counted, never fetched; no script, style or command is run; files are only looked at (never read except another Markdown file's headings, 256 KiB at most) and only inside the document's project (a path that leaves it, by `..` or a symlink, is reported as not checked); nothing is written. Browser-console diagnostics are a later, separate step.
- **Footnote renumbering** ([#463](https://github.com/raiseCatError/notMyShell/issues/463)). *Renumber footnotes* in the snippet palette (F1, then `/` in a Markdown file) numbers the footnotes 1, 2, 3… in the order they are first referenced and updates each definition to match, as one undo step. Named footnotes (`[^note]`), escaped references, code spans and fenced code are left alone; a definition nobody references keeps its number unless a renumbered footnote needs it.
- **Split view for Markdown and HTML documents** ([#461](https://github.com/raiseCatError/notMyShell/issues/461)). **F2** (or `s` in the read-only views) in the document workspace shows the editor beside NMSh's own preview of the *same* buffer: the text on the left, the rendered document on the right, updated as you type, without saving. The preview follows the caret to its block (`l` in the preview pane turns that off), **Ctrl+O** moves between the panes, `x` swaps the sides and `<` `>` change their widths (30–70%). Only the text pane is editable; keys typed while the preview has the focus never reach the text. The preview pane has its own find (`/`) and heading prompt (`#`). Below 100 columns only the focused pane is shown at full width, and widening the terminal brings the split back. Esc leaves for the read-only source view with your unsaved text kept; F2 again returns to editing. The browser-quality preview is not used in the split; the pane is NMSh's renderer. Mouse input in the split is not handled yet.
- **Heading navigation** ([#462](https://github.com/raiseCatError/notMyShell/issues/462), first slice). In a Markdown or HTML document, **`#`** in the viewer or workspace opens a heading prompt: type to narrow, Up/Down choose, Enter jumps (source or rendered view, any width) and **Backspace** goes back to where you were. In the editor, F1 then `h` (or *Go to heading…* in the snippet palette) lists the headings with their depth; `j` / *Back* returns to the caret position you left, even after edits. `/view README.md#install` opens the file at that heading. Anchors follow GitHub's rules (lower-case, punctuation dropped, `-1`, `-2` for repeats; raw HTML headings listed without an anchor); Jekyll/Kramdown ids are different and are not implemented (see the [architecture note](docs/architecture/document-source-map.md)). Underneath, every parsed Markdown block now knows its source lines and every rendered row its block's line, the shared map the coming split view and hybrid editing build on.
diff --git a/docs/design/document-workspace.md b/docs/design/document-workspace.md
index 93dbbecd..026bd60f 100644
--- a/docs/design/document-workspace.md
+++ b/docs/design/document-workspace.md
@@ -51,6 +51,19 @@ Limits: code fences are recognised by a line scan (indented code blocks and fenc
**Verified by** `tests/markdownEdit.test.ts` (each helper: empty and multi-line selections, nested and mixed lists, quotes, fences, Unicode, very long lines, one undo step, restored drafts) and `tests/markdownEditorKeys.test.ts` (keys, menu at 30–120 columns, non-Markdown files unchanged, CRLF saved as CRLF). Physical Ghostty/Zed: NOT RUN.
+## Problems (static diagnostics)
+
+`src/markdown/diagnostics.ts` (engine), `src/viewer/problems.ts` (runner) and the *problems* dialog of `EditorPanel.ts` ([#466](https://github.com/raiseCatError/notMyShell/issues/466)). Reuses `parseBlockLines` (code blocks are skipped through the block spans), `headingsFromLines` and `resolveFragment`; no second parser, no language server.
+
+- **Severities:** `error` (certainly broken: a file that is not there, an undefined reference/footnote/id), `warning` (likely: a missing alt text, an unclosed tag, a fragment other generators might satisfy — a document with raw `id=`/`{#…}` ids gets warnings instead of errors), `info` (a repeated heading), `unverified` (not checked, not claimed wrong: absolute and `~` paths, a path outside the project, a templated attribute, a relative file in a document with no folder yet). External URLs (`http`, `mailto`, `//`, any other scheme) are only counted. `javascript:`, `vbscript:`, `file:` and non-image `data:` links are reported as never followed.
+- **Positions:** every finding has a 0-based line and, when it names exact text (a link destination, an attribute value, an id), UTF-16 columns; Enter selects that text. Without a span the caret goes to the start of the line and nothing pretends otherwise.
+- **Filesystem:** only through `LocalFs`, created per run for the document's folder with the project root (`documentRoot`: the git project within a dozen levels, else the folder). A path is lexically resolved, then `realpath`ed, and must stay inside the real root; for a missing path the deepest existing ancestor is checked, so a symlinked directory cannot be used to probe outside. Only `lstat` and one bounded read of another Markdown file (headings only). At most 400 distinct paths per run; at most 500 findings; HTML over 1.5 MB is skipped and marked truncated; messages never name a path the document did not itself write.
+- **Currency:** checking is off until Problems is first opened. Then `ProblemsRunner` observes the buffer: each change bumps a revision and schedules a run (500 ms); a run snapshots the lines, aborts any run in flight, and its result is dropped if the revision moved. Opening Problems, Ctrl+R, a save and a Save As re-check (files the document names may have changed; no watchers). The runner is disposed with the editor, on reload and on exit, so no timer or observer outlives the document.
+- **Surface:** the dialog lists findings by severity then line with counts and the number of external links not checked; Tab cycles all / problems / errors, typing filters on the message, Del dismisses (until the next report), Esc leaves. In source and preview a jump also scrolls the read-only view to the line; hybrid and the split follow the caret.
+- **Independent review (opus subagent, security focus) and what changed:** (1–3) a dangling symlink, or a symlink to a folder this user cannot read, was told apart from other links outside the root (it leaked whether the outside target exists) and `readText` could be raced into following a link or blocking on a FIFO — the path is now walked component by component from the root, each link resolved (even dangling ones, via `readlink`) and confined before the walk continues, and the read opens with `O_NOFOLLOW|O_NONBLOCK`, `fstat`s a regular file and re-checks the real path; (4) heading checks in another file read it once per path and count against a bound; (5) quadratic regexes on hostile lines (80k characters took 17–38 s) — every quantifier is bounded, lines over 20,000 characters are scanned only that far and say so, and CSS is read by a linear scanner; (6) readers of reload, plain Save As-and-close now release the runner; (7) a failing checker no longer throws into the editor; (8–10) false positives and negatives: HTML comments and code spans across lines, TOML front matter, definitions on the next line or in quotes, escaped destinations, `url()` in CSS comments and strings, `url(#id)` checked against ids in HTML, ``, ids inside ``, the first of duplicate attributes, an undefined `[text][ref]` (CommonMark shows it as text) is only information; the outer link of a badge, nested brackets, inline `` and `@import` are now checked; (11–12) the `image-alt` span is the image's own text and a value spanning lines is not an exact span; (13) the list focus stays on a row when a new report is shorter.
+- **Not provided:** a gutter marker in the text, checks of other languages, CSS property validation, link fragments inside another HTML file, following redirects or fetching anything, quick fixes, and the browser console (a separate, gated slice).
+- **Verified by** `tests/diagnostics.test.ts` (each check, hostile paths and symlinks, the filesystem checker, no-folder documents, Unicode and stray CR, HTML and CSS, false-positive stress, bounds) and `tests/problems.test.ts` (runner staleness and disposal, the surface, filters, dismiss, refresh, widths/NO_COLOR/Safe Glyphs, and every mode: source, preview, hybrid, split, edit).
+
## Hybrid view
`DocumentMode` gains `'hybrid'` (#460), implemented in `src/viewer/HybridView.ts` over the shared source map (`docs/architecture/document-source-map.md`). It is a presentation of the one `TextBuffer`; there is no second editor and no second document.
diff --git a/docs/development/v024-ledger.md b/docs/development/v024-ledger.md
index 9a46cf2d..926f5225 100644
--- a/docs/development/v024-ledger.md
+++ b/docs/development/v024-ledger.md
@@ -44,6 +44,8 @@ The paused issues #460–#469 are the milestone (the maintainer superseded the e
8. **#460: hybrid Markdown editing** — branch `feature/v024-hybrid` (from `dev` after #489–#496 merged). Done: hybrid mode, items from the source map, active-block raw editing, anchored viewport, split text-pane toggle, tests, independent review (see its section). **#460 remaining:** images in place, visual-line movement, pointer input, tables as a grid, HTML. **#461** still lacks pointer input, preview→caret sync, vertical orientation and the Chromium preview; **#463** footnote renumbering; **#466**, **#462** (details/Mermaid/math), **#464/#465/#467** not started.
9. **#463: footnote renumbering** — branch `feature/v024-footnotes`, from `dev`. Done (tests in `footnotes.test.ts`). **#463 acceptance:** every implemented item is covered by automated tests; the only open requirement is the physical Ghostty/Zed pass (NOT RUN), so the issue stays open.
+10. **#466: offline static diagnostics** — branch `feature/v024-diagnostics`, from `dev`. Done: engine, runner, Problems surface in all modes, tests, independent review (see its section in the design doc). **#466 remaining:** the optional browser console for trusted previews (separate gated slice, nothing started), a gutter marker, further languages. **#462 next:** `` disclosure, then Mermaid and math (separate slices).
+
## Environment notes
Scratch and test runs: `t7run`; focused tests via `/Volumes/T7Tmp/ai-dev/runtests.mjs` (see the testing-gotchas memory). `npm ci` was needed in a fresh worktree (the main checkout's `node_modules` is stale).
diff --git a/docs/testing/v024-editor-physical-qa.md b/docs/testing/v024-editor-physical-qa.md
index b3e783f6..1ee44e03 100644
--- a/docs/testing/v024-editor-physical-qa.md
+++ b/docs/testing/v024-editor-physical-qa.md
@@ -21,6 +21,12 @@ Automated tests cover the logic headlessly. Everything below is **NOT RUN** unti
- [ ] Mermaid and math entries say they are source text; nothing in the preview claims to render them.
- [ ] The palette rows and footer fit 30, 50, 80 and 120 columns; NO_COLOR and Safe Glyphs stay legible.
+## Problems (#466)
+- [ ] F1 then `e` in a Markdown file with a broken link, a missing image and a bad `#fragment`: the list shows them; Enter selects the exact text; Back returns. `P` in the read-only views and in the split's preview pane.
+- [ ] An HTML file (palette → Problems) and a CSS file (F1); the status row counts; fixing a finding removes it after a moment; undo brings it back.
+- [ ] A symlink out of the project and `/etc/passwd`-style paths are reported as not checked; an external URL is never fetched (watch the network).
+- [ ] 30, 50, 80 and 120 columns; NO_COLOR; Safe Glyphs (E/W/i/?).
+
## Hybrid view (#460)
- [ ] Open a Markdown file, F3 (is F3 delivered by the terminal and by Zed?): headings, bold, links, lists, tables, quotes and fences render; the block under the caret shows its source; moving with the arrows swaps blocks without the screen jumping.
- [ ] Type in every kind of block; select across blocks with Shift+Down (both show source); undo/redo; insert a snippet and Tab through it.
diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts
index 697dfd06..206c8aae 100644
--- a/src/app/TerminalApp.ts
+++ b/src/app/TerminalApp.ts
@@ -80,6 +80,7 @@ import {createEditor, editorKey, offerDraft, renderEditor, type EditorAction, ty
import {createScratchFile, openForEdit, saveFile} from '../viewer/fileEdit.js';
import {headingsOf, resolveFragment} from '../markdown/sourceMap.js';
import {DraftKeeper, DraftStore, type DraftOffer} from '../viewer/draftRecovery.js';
+import {ProblemsRunner} from '../viewer/problems.js';
import {applyReview, beginReview, restartReview, sameDiskVersion} from '../viewer/ReviewPanel.js';
import {bufferText, createDocumentSession, renderDocumentSession, setDocumentMode, syncDocumentViewer, documentSessionKey, documentSessionLook, type DocumentSessionAction, type DocumentMode, type DocumentSession} from '../viewer/DocumentWorkspace.js';
import {formatForPath} from '../viewer/document.js';
@@ -1063,6 +1064,8 @@ export class TerminalApp {
private finish!: (exitCode: number) => void;
constructor(connection?: SessionConnection, preset?: SessionPreset) {
+ // A finished check redraws the panel that shows its counts.
+ ProblemsRunner.notify = () => { if (!this.stopped) this.render(); };
if (preset) {
if (!connection || connection.mode !== 'service' || connection.attached) throw new Error('Presets require a new live session.');
this.presetStartup = new PresetStartup(preset);
@@ -4601,6 +4604,7 @@ export class TerminalApp {
this.drafts().discard(editor);
editor.file = saved.file;
if (editor.buffer.lines.length === lines.length && editor.buffer.lines.every((line, index) => line === lines[index])) editor.buffer.markSaved();
+ editor.problems?.refresh();
editor.note = {text: `Saved as ${saved.file.title}.`, tone: 'success'};
if (session) { session.viewer.note = editor.note.text; syncDocumentViewer(session, session.mode === 'preview'); }
if (thenClose) close();
@@ -4614,6 +4618,13 @@ export class TerminalApp {
})();
}
+ /** An editor is going away (closed, reloaded, replaced): its recovery draft goes and its checking and parsed index stop. */
+ private releaseEditor(editor: EditorState): void {
+ this.drafts().discard(editor);
+ editor.problems?.dispose();
+ editor.index?.dispose();
+ }
+
private runDraftAction(editor: EditorState, action: Extract): void {
const keeper = this.drafts();
if (action.kind === 'draft') keeper.restored(editor, action.target);
@@ -4624,11 +4635,11 @@ export class TerminalApp {
/** The editor's requests that touch the disk or the clipboard; the panel shows the outcome. */
private runEditorAction(editor: EditorState, action: EditorAction): void {
if (action.kind === 'draft' || action.kind === 'draft-discard' || action.kind === 'draft-keep') { this.runDraftAction(editor, action); return; }
- if (action.kind === 'playground') { this.drafts().discard(editor); this.textEditor = undefined; void this.openPlayground(action.choice); return; }
- if (action.kind === 'save-as') { this.saveAs(editor, action.path, Boolean(action.thenClose), () => this.textEditor === editor, () => { this.textEditor = undefined; this.returnFromPanel(); }); return; }
+ if (action.kind === 'playground') { this.releaseEditor(editor); this.textEditor = undefined; void this.openPlayground(action.choice); return; }
+ if (action.kind === 'save-as') { this.saveAs(editor, action.path, Boolean(action.thenClose), () => this.textEditor === editor, () => { this.releaseEditor(editor); this.textEditor = undefined; this.returnFromPanel(); }); return; }
if (editor.file.scratch && (action.kind === 'review' || action.kind === 'review-apply' || action.kind === 'reload' || action.kind === 'save')) { editor.note = {text: 'A Playground document has no file yet: Save As creates one.', tone: 'info'}; return; }
if (action.kind === 'review' || action.kind === 'review-apply') { this.reviewDisk(editor, action.kind === 'review-apply', () => this.textEditor === editor); return; }
- if (action.kind === 'close') { this.drafts().discard(editor); this.textEditor = undefined; this.returnFromPanel(); return; }
+ if (action.kind === 'close') { this.releaseEditor(editor); this.textEditor = undefined; this.returnFromPanel(); return; }
if (action.kind === 'copy') {
void this.copyText(action.text, 'Copied').then(note => { if (note && this.textEditor === editor) { editor.note = {text: note, tone: 'success'}; this.render(); } });
return;
@@ -4639,7 +4650,7 @@ export class TerminalApp {
editor.busy = false;
if (this.textEditor !== editor) return;
if (!result.ok) { editor.note = {text: result.reason, tone: 'error'}; this.render(); return; }
- this.drafts().discard(editor);
+ this.releaseEditor(editor);
this.textEditor = {...createEditor(result.file), note: {text: 'Reloaded from disk; your changes were discarded.', tone: 'info'}};
this.render();
});
@@ -4654,8 +4665,8 @@ export class TerminalApp {
// Only what was written is clean: typing during the save stays unsaved.
if (editor.buffer.lines.length === lines.length && editor.buffer.lines.every((line, index) => line === lines[index])) editor.buffer.markSaved();
this.drafts().note(editor);
- if (action.thenClose) { this.drafts().discard(editor); this.textEditor = undefined; this.returnFromPanel(); }
- else editor.note = {text: `Saved ${editor.file.title}.`, tone: 'success'};
+ if (action.thenClose) { this.releaseEditor(editor); this.textEditor = undefined; this.returnFromPanel(); }
+ else { editor.problems?.refresh(); editor.note = {text: `Saved ${editor.file.title}.`, tone: 'success'}; }
} else if (result.conflict) {
editor.dialog = {kind: 'conflict', focus: 0, conflict: result.conflict, reason: result.reason, ...(action.thenClose ? {thenClose: true} : {})};
} else editor.note = {text: `${result.reason} Your text is still here.`, tone: 'error'};
@@ -4750,7 +4761,7 @@ export class TerminalApp {
const session = this.docSession;
if (!session) return;
this.docSession = undefined;
- this.drafts().discard(session.editor);
+ this.releaseEditor(session.editor);
session.preview?.dispose();
this.releaseViewerImages();
this.returnFromPanel();
@@ -4783,7 +4794,7 @@ export class TerminalApp {
editor.busy = false;
if (this.docSession !== session) return;
if (!result.ok) { editor.note = {text: result.reason, tone: 'error'}; session.viewer.note = result.reason; this.render(); return; }
- this.drafts().discard(editor);
+ this.releaseEditor(editor);
const fresh = createDocumentSession(result.file, session.mode === 'edit' || session.mode === 'hybrid' ? session.mode : 'source');
fresh.note = 'Reloaded from disk; your changes were discarded.';
fresh.viewer.note = fresh.note;
@@ -4802,6 +4813,7 @@ export class TerminalApp {
if (editor.buffer.lines.length === lines.length && editor.buffer.lines.every((line, index) => line === lines[index])) editor.buffer.markSaved();
if (action.thenClose) { this.closeDocumentSession(); this.render(); return; }
this.drafts().note(editor);
+ editor.problems?.refresh();
editor.note = {text: `Saved ${editor.file.title}.`, tone: 'success'};
session.viewer.note = `Saved ${editor.file.title}.`;
syncDocumentViewer(session, session.mode === 'preview');
@@ -13965,6 +13977,7 @@ export class TerminalApp {
this.docSession?.preview?.dispose();
// Unsaved editor text is written before the window ends, so closing it never costs the text.
this.draftKeeper?.dispose();
+ this.textEditor?.problems?.dispose(); this.docSession?.editor.problems?.dispose();
void this.browserHost?.shutdown();
this.releaseViewerImages();
this.graphics?.releaseAll();
diff --git a/src/markdown/diagnostics.ts b/src/markdown/diagnostics.ts
new file mode 100644
index 00000000..a096f5cc
--- /dev/null
+++ b/src/markdown/diagnostics.ts
@@ -0,0 +1,556 @@
+import {constants} from 'node:fs';
+import {lstat, open, readlink, realpath} from 'node:fs/promises';
+import {isAbsolute, join, relative, resolve, sep} from 'node:path';
+import {parseBlockLines, type Block} from './blocks.js';
+import {headingsFromLines, resolveFragment, type HeadingEntry} from './sourceMap.js';
+
+/**
+ * Offline static diagnostics for Markdown, HTML and CSS documents (#466). Everything here reads the document's own lines (the
+ * editor's buffer, saved or not) and, only through an injected `LocalFs`, checks that files the document names exist inside
+ * the document's folder or project. It never fetches a URL, runs a script or a command, follows a link out of the root,
+ * writes anything or reports a guess as a fact:
+ *
+ * error a reference that is certainly broken (the file is not there, the id/footnote/reference is not defined)
+ * warning likely a problem (a missing alt text, an unclosed tag, a fragment that Jekyll ids might satisfy)
+ * info worth knowing (a repeated heading)
+ * unverified could not be checked, and is not claimed to be wrong (an absolute path, a path outside the project, a templated
+ * attribute, a relative file of a document that has no folder yet)
+ *
+ * External URLs are only counted. Positions are source lines and UTF-16 columns of the exact text, so a jump can select it.
+ */
+
+export type Severity = 'error' | 'warning' | 'info' | 'unverified';
+
+export interface Diagnostic {
+ id: string;
+ code: string;
+ severity: Severity;
+ message: string;
+ /** 0-based source line. */
+ line: number;
+ column?: number;
+ endColumn?: number;
+ /** The span is exactly the text at fault; otherwise only the line is known. */
+ exact: boolean;
+}
+
+export interface DiagnosticReport {
+ diagnostics: Diagnostic[];
+ /** External references seen and deliberately not checked. */
+ external: number;
+ /** Filesystem checks made. */
+ checked: number;
+ /** Stopped early: too many findings or checks. */
+ truncated: boolean;
+ aborted?: boolean;
+}
+
+export type FileState = 'file' | 'directory' | 'missing' | 'unreadable' | 'outside';
+
+export interface LocalFs {
+ /** The state of a path written in the document (already percent-decoded, relative to the document's folder). */
+ stat(path: string): Promise;
+ /** Up to `maxBytes` of a regular file inside the root, for checking a heading fragment in another Markdown file. */
+ readText(path: string, maxBytes: number): Promise;
+}
+
+export const MAX_DIAGNOSTICS = 500;
+export const MAX_CHECKS = 400;
+const MAX_PATH = 1024;
+const MAX_TARGET_BYTES = 256 * 1024;
+const MAX_TARGET_READS = 50;
+/** Markdown lines longer than this are scanned only this far (the rest is reported, not guessed at). */
+const MAX_LINE = 20_000;
+
+/**
+ * A checker confined to `root`. The path is lexically resolved, then walked one component at a time from the root: a symlink
+ * (even a dangling one, even one into a folder this user cannot read) is resolved at the component where it is met and the
+ * result must stay inside the real root before the walk goes on, so nothing beyond the root is ever looked at and a link
+ * out of it can never be told apart from any other.
+ */
+export function createLocalFs(docDir: string, root: string): LocalFs {
+ let realRoot: Promise | undefined;
+ const rootReal = () => realRoot ??= realpath(root).catch(() => root);
+ const inside = (base: string, path: string) => { const rel = relative(base, path); return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel)); };
+ async function locate(path: string): Promise<{state: FileState; real?: string}> {
+ if (!path || path.length > MAX_PATH || path.includes('\0')) return {state: 'unreadable'};
+ const resolved = resolve(docDir, path);
+ if (!inside(root, resolved)) return {state: 'outside'};
+ const base = await rootReal();
+ const segments = relative(root, resolved).split(sep).filter(Boolean);
+ let current = root;
+ for (let index = 0; index < segments.length; index += 1) {
+ const next = join(current, segments[index]!);
+ let info;
+ try { info = await lstat(next); }
+ catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ return {state: code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unreadable'};
+ }
+ if (info.isSymbolicLink()) {
+ let target: string;
+ try { target = await realpath(next); }
+ catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ if (code !== 'ENOENT') return {state: 'unreadable'};
+ // Dangling: the place it points to is judged without looking at it.
+ try { return {state: inside(base, resolve(join(current), await readlink(next))) ? 'missing' : 'outside'}; } catch { return {state: 'unreadable'}; }
+ }
+ if (!inside(base, target)) return {state: 'outside'};
+ current = target;
+ continue;
+ }
+ current = next;
+ }
+ try {
+ const info = await lstat(current);
+ return {state: info.isDirectory() ? 'directory' : info.isFile() ? 'file' : 'unreadable', real: current};
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code;
+ return {state: code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unreadable'};
+ }
+ }
+ return {
+ stat: async path => (await locate(path)).state,
+ async readText(path, maxBytes) {
+ const found = await locate(path);
+ if (found.state !== 'file' || !found.real) return undefined;
+ let handle;
+ try {
+ // Never follow a link or wait on a pipe: the file is opened as the regular file it was a moment ago, or not at all.
+ handle = await open(found.real, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0) | (constants.O_NOCTTY ?? 0));
+ const info = await handle.stat();
+ const before = await lstat(found.real);
+ if (!info.isFile() || info.ino !== before.ino || info.dev !== before.dev) return undefined;
+ if (await realpath(found.real) !== found.real) return undefined;
+ const buffer = Buffer.alloc(Math.min(maxBytes, info.size));
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
+ return buffer.subarray(0, bytesRead).toString('utf8');
+ } catch { return undefined; } finally { await handle?.close().catch(() => undefined); }
+ },
+ };
+}
+
+// ---- Destinations -----------------------------------------------------------------------------------------------
+
+type Target =
+ | {kind: 'fragment'; fragment: string}
+ | {kind: 'local'; path: string; fragment?: string}
+ | {kind: 'external'}
+ | {kind: 'unsafe'; scheme: string}
+ | {kind: 'absolute'}
+ | {kind: 'ignore'};
+
+function classify(destination: string): Target {
+ const text = destination.trim();
+ if (!text) return {kind: 'ignore'};
+ if (text.startsWith('#')) return text.length > 1 ? {kind: 'fragment', fragment: text.slice(1)} : {kind: 'ignore'};
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/u.exec(text)?.[1]?.toLowerCase();
+ if (scheme) {
+ if (['javascript', 'vbscript', 'file'].includes(scheme)) return {kind: 'unsafe', scheme};
+ if (scheme === 'data') return /^data:image\//iu.test(text) ? {kind: 'ignore'} : {kind: 'unsafe', scheme};
+ if (scheme.length === 1) return {kind: 'absolute'};
+ return {kind: 'external'};
+ }
+ if (text.startsWith('//')) return {kind: 'external'};
+ if (text.startsWith('/') || text.startsWith('~') || text.startsWith('\\')) return {kind: 'absolute'};
+ const hash = text.indexOf('#');
+ const withoutFragment = hash >= 0 ? text.slice(0, hash) : text;
+ const fragment = hash >= 0 ? text.slice(hash + 1) : undefined;
+ const path = withoutFragment.replace(/\?.*$/u, '');
+ if (!path) return fragment ? {kind: 'fragment', fragment} : {kind: 'ignore'};
+ let decoded = path;
+ try { decoded = decodeURIComponent(path); } catch { /* keep it as written */ }
+ return {kind: 'local', path: decoded, ...(fragment ? {fragment} : {})};
+}
+
+const TEMPLATED = /\{\{|\{%|<%|\$\{|\{#/u;
+
+// ---- The run ----------------------------------------------------------------------------------------------------
+
+export interface DiagnoseInput {
+ lines: readonly string[];
+ format: 'markdown' | 'html' | 'css';
+ fs?: LocalFs;
+ signal?: AbortSignal;
+ maxChecks?: number;
+}
+
+class Run {
+ diagnostics: Diagnostic[] = [];
+ external = 0;
+ truncated = false;
+ private readonly states = new Map>();
+ private readonly targets = new Map>();
+ private ids = 0;
+ constructor(readonly input: DiagnoseInput) {}
+ get aborted() { return Boolean(this.input.signal?.aborted); }
+ get checked() { return this.states.size; }
+
+ add(code: string, severity: Severity, message: string, line: number, column?: number, endColumn?: number): void {
+ if (this.diagnostics.length >= MAX_DIAGNOSTICS) { this.truncated = true; return; }
+ this.diagnostics.push({id: `${code}:${line}:${column ?? ''}:${this.ids++}`, code, severity, message, line, exact: column !== undefined && endColumn !== undefined, ...(column === undefined ? {} : {column}), ...(endColumn === undefined ? {} : {endColumn})});
+ }
+
+ stat(path: string): Promise | undefined {
+ const known = this.states.get(path);
+ if (known) return known;
+ if (!this.input.fs || this.states.size >= (this.input.maxChecks ?? MAX_CHECKS)) { if (this.input.fs) this.truncated = true; return undefined; }
+ const pending = this.input.fs.stat(path);
+ this.states.set(path, pending);
+ return pending;
+ }
+
+ /** The headings of another Markdown file, read once per path and counted against the same bound as other file work. */
+ targetHeadings(path: string): Promise | undefined {
+ const known = this.targets.get(path);
+ if (known) return known;
+ if (!this.input.fs || this.targets.size >= MAX_TARGET_READS) { this.truncated = true; return undefined; }
+ const pending = this.input.fs.readText(path, MAX_TARGET_BYTES).then(text => text === undefined ? undefined : headingsFromLines(text.split('\n')));
+ this.targets.set(path, pending);
+ return pending;
+ }
+
+ /** Report a destination written in the document; `exactSpan` false when only the line is known. */
+ async reference(destination: string, line: number, column: number, headings: ReturnType | undefined, ids: ReadonlySet | undefined, softFragments: boolean): Promise {
+ const end = column + destination.length;
+ const exact = !destination.includes('\n') && !destination.includes('\r');
+ const add = (code: string, severity: Severity, message: string) => this.add(code, severity, message, line, exact ? column : undefined, exact ? end : undefined);
+ const target = classify(destination.replace(/\\([!-/:-@[-`{-~])/gu, '$1'));
+ switch (target.kind) {
+ case 'ignore': return;
+ case 'external': this.external += 1; return;
+ case 'unsafe': add('unsafe-scheme', 'warning', `A ${target.scheme}: link is never followed or checked here.`); return;
+ case 'absolute': add('absolute-path', 'unverified', 'Absolute and home-relative paths are not checked.'); return;
+ case 'fragment': {
+ if (!headings && !ids) return;
+ const found = headings ? resolveFragment(headings, target.fragment) : undefined;
+ let decoded = target.fragment;
+ try { decoded = decodeURIComponent(decoded); } catch { /* as written */ }
+ if (found || ids?.has(decoded) || ids?.has(target.fragment) || decoded.toLowerCase() === 'top') return;
+ add('broken-fragment', softFragments ? 'warning' : 'error', softFragments ? `No heading or id “${target.fragment}” here (other generators may build ids differently).` : `No heading or id “${target.fragment}” in this document.`);
+ return;
+ }
+ case 'local': {
+ const pending = this.stat(target.path);
+ if (!pending) { if (!this.input.fs) add('unverified-local', 'unverified', 'Not checked: this document has no folder yet. Save it to check local files.'); return; }
+ const state = await pending;
+ if (this.aborted) return;
+ if (state === 'outside') add('outside-root', 'unverified', 'Points outside the document\'s folder or project; not checked.');
+ else if (state === 'unreadable') add('unreadable', 'warning', 'This path cannot be read or checked.');
+ else if (state === 'missing') add('missing-file', 'error', `No such file: ${target.path.replace(/[\r\n]+/gu, ' ')}`);
+ else if (state === 'file' && target.fragment && /\.(?:md|markdown)$/iu.test(target.path) && this.input.fs) {
+ const headingsOfTarget = await this.targetHeadings(target.path);
+ if (headingsOfTarget && !resolveFragment(headingsOfTarget, target.fragment)) add('broken-fragment-in-file', 'warning', `No heading “${target.fragment}” in ${target.path}.`);
+ }
+ return;
+ }
+ }
+ }
+}
+
+function codeLines(blocks: readonly Block[], into: Set): void {
+ for (const block of blocks) {
+ if (block.kind === 'code' && block.from !== undefined && block.to !== undefined) for (let line = block.from; line < block.to; line += 1) into.add(line);
+ else if (block.kind === 'quote') codeLines(block.blocks, into);
+ else if (block.kind === 'list') for (const item of block.items) codeLines(item.blocks, into);
+ }
+}
+
+const normalize = (label: string) => label.trim().replace(/\s+/gu, ' ').toLowerCase();
+const blankOut = (text: string) => text.replace(/[^\n]/gu, ' ');
+const MAX_PARAGRAPH_FOR_SPANS = 200_000;
+
+/** The lines to scan: code, front matter, HTML comments and code spans (also across lines of a paragraph) blanked, columns kept. */
+function scannable(lines: readonly string[], skip: ReadonlySet): string[] {
+ let text = lines.map((line, at) => skip.has(at) ? '' : line).join('\n');
+ text = text.replace(//gu, blankOut);
+ const cut = text.split('\n');
+ let start = 0;
+ while (start < cut.length) {
+ if (!cut[start]!.trim()) { start += 1; continue; }
+ let end = start;
+ while (end < cut.length && cut[end]!.trim()) end += 1;
+ const paragraph = cut.slice(start, end).join('\n');
+ if (paragraph.includes('`') && paragraph.length <= MAX_PARAGRAPH_FOR_SPANS) cut.splice(start, end - start, ...paragraph.replace(/(`+)[\s\S]*?\1/gu, blankOut).split('\n'));
+ start = end;
+ }
+ return cut;
+}
+
+const LINK = /(!?)(?\n]{0,1000}>|[^\s()]{0,1000}(?:\([^\s()]{0,300}\)[^\s()]{0,300}){0,5})/gdu;
+const REFERENCE = /(?[ ]?)*[ ]{0,3})\[([^\]^][^\]]{0,999})\]:[ \t]*(<[^>\n]{0,1000}>|\S{0,1000})/du;
+const NOTE_DEFINITION = /^((?:[ ]{0,3}>[ ]?)*[ ]{0,3})\[\^([^\]\s]{1,200})\]:/u;
+const INLINE_IMG = /]{0,2000}?\bsrc\s*=\s*(?:"([^"\n]{0,1000})"|'([^'\n]{0,1000})')/gdiu;
+
+async function markdown(run: Run): Promise {
+ const lines = run.input.lines;
+ const blocks = parseBlockLines(lines, {html: true});
+ const skip = new Set();
+ codeLines(blocks, skip);
+ const fence = lines[0]?.trim();
+ if (fence === '---' || fence === '+++') for (let at = 1; at < Math.min(lines.length, 200); at += 1) if (lines[at]!.trim() === fence || (fence === '---' && lines[at]!.trim() === '...')) { for (let line = 0; line <= at; line += 1) skip.add(line); break; }
+ const headings = headingsFromLines(lines, {html: true});
+ const seen = new Map();
+ for (const heading of headings) {
+ if (heading.slug === undefined) continue;
+ const first = seen.get(heading.text.toLowerCase());
+ if (first !== undefined) run.add('duplicate-heading', 'info', `Same heading text as line ${first + 1}; its anchor is #${heading.slug}.`, heading.line);
+ else seen.set(heading.text.toLowerCase(), heading.line);
+ }
+ const soft = lines.some(line => /\bid\s*=|\{#/u.test(line));
+ const text = scannable(lines, skip);
+ const definitions = new Set(), footnoteDefs = new Map();
+ const refs: Array<{label: string; line: number; start: number; end: number}> = [], notes: Array<{label: string; line: number; start: number; end: number}> = [];
+ const destinations: Array<{destination: string; line: number; column: number; alt?: {text: string; start: number; end: number}}> = [];
+ let longLine = false;
+ text.forEach((full, line) => {
+ if (skip.has(line) || run.aborted) return;
+ if (full.length > MAX_LINE && !longLine) { longLine = true; run.add('long-line', 'info', `This line is over ${MAX_LINE.toLocaleString('en-US')} characters; only its first part is checked.`, line); }
+ const masked = full.length > MAX_LINE ? full.slice(0, MAX_LINE) : full;
+ let from = 0;
+ const note = NOTE_DEFINITION.exec(masked);
+ const definition = note ? undefined : DEFINITION.exec(masked);
+ if (note) { footnoteDefs.set(note[2]!, line); from = note[0].length; }
+ if (definition) {
+ definitions.add(normalize(definition[2]!));
+ let raw = definition[3]!, at = definition.indices![3]![0], target = line;
+ if (!raw) {
+ // The destination may start the next line.
+ const following = /^\s*(<[^>\n]*>|\S+)/u.exec(text[line + 1] ?? '');
+ if (following) { raw = following[1]!; at = following[0].length - raw.length; target = line + 1; }
+ }
+ if (raw) destinations.push({destination: raw.startsWith('<') ? raw.slice(1, -1) : raw, line: target, column: at + (raw.startsWith('<') ? 1 : 0)});
+ from = definition[0].length;
+ }
+ const rest = masked.slice(from);
+ for (const match of rest.matchAll(LINK)) {
+ const raw = match[3]!;
+ const [startIndex] = match.indices![3]!;
+ const angle = raw.startsWith('<');
+ destinations.push({destination: angle ? raw.slice(1, -1) : raw, line, column: from + startIndex + (angle ? 1 : 0),
+ ...(match[1] ? {alt: {text: match[2]!, start: from + match.index!, end: from + match.index! + 3 + match[2]!.length}} : {})});
+ }
+ for (const match of rest.matchAll(INLINE_IMG)) {
+ const group = match[1] !== undefined ? 1 : 2;
+ destinations.push({destination: match[group]!, line, column: from + match.indices![group]![0]});
+ }
+ for (const match of rest.matchAll(REFERENCE)) {
+ if (match[1]!.startsWith('^')) continue;
+ const label = match[2]! || match[1]!;
+ const [start, end] = match.indices![match[2]! ? 2 : 1]!;
+ refs.push({label, line, start: from + start, end: from + end});
+ }
+ for (const match of rest.matchAll(FOOTNOTE)) { const [start, end] = match.indices![1]!; notes.push({label: match[1]!, line, start: from + start, end: from + end}); }
+ run.external += [...rest.matchAll(/<([a-zA-Z][a-zA-Z0-9+.-]{0,30}:[^>\s]{0,1000})>/gu)].filter(autolink => classify(autolink[1]!).kind === 'external').length;
+ });
+ for (const item of destinations) {
+ if (run.aborted) return;
+ if (item.alt && !item.alt.text.trim()) run.add('image-alt', 'warning', 'This image has no alternative text.', item.line, item.alt.start, item.alt.end);
+ await run.reference(item.destination, item.line, item.column, headings, undefined, soft);
+ }
+ for (const ref of refs) if (!definitions.has(normalize(ref.label))) run.add('undefined-reference', 'info', `“${ref.label}” looks like a reference link but nothing defines it; it will show as plain text.`, ref.line, ref.start, ref.end);
+ const used = new Set();
+ for (const note of notes) { used.add(note.label); if (!footnoteDefs.has(note.label)) run.add('undefined-footnote', 'error', `No definition for footnote [^${note.label}].`, note.line, note.start, note.end); }
+ for (const [label, line] of footnoteDefs) if (!used.has(label)) run.add('unreferenced-footnote', 'warning', `Footnote [^${label}] is defined but never referenced.`, line);
+}
+
+// ---- CSS and HTML -----------------------------------------------------------------------------------------------
+
+interface CssUrl {value: string; line: number; column: number}
+
+/** One pass over CSS: its url() and @import addresses (not those in comments or strings) and its first bracket problem. */
+function scanCss(text: string, baseLine: number, baseColumn: number): {urls: CssUrl[]; issue?: {message: string; line: number; column: number}} {
+ const urls: CssUrl[] = [];
+ const stack: Array<{char: string; line: number; column: number}> = [];
+ let issue: {message: string; line: number; column: number} | undefined;
+ let line = baseLine, column = baseColumn;
+ const advance = (char: string) => { if (char === '\n') { line += 1; column = 0; } else column += 1; };
+ for (let at = 0; at < text.length;) {
+ const char = text[at]!;
+ if (char === '/' && text[at + 1] === '*') {
+ const end = text.indexOf('*/', at + 2);
+ const stop = end < 0 ? text.length : end + 2;
+ for (; at < stop; at += 1) advance(text[at]!);
+ if (end < 0 && !issue) issue = {message: 'A comment is never closed.', line, column};
+ continue;
+ }
+ if (char === '"' || char === '\'') {
+ let end = at + 1;
+ while (end < text.length && text[end] !== char) end += text[end] === '\\' ? 2 : 1;
+ const stop = Math.min(text.length, end + 1);
+ for (; at < stop; at += 1) advance(text[at]!);
+ continue;
+ }
+ if (char === '\\') { advance(char); at += 1; if (at < text.length) { advance(text[at]!); at += 1; } continue; }
+ const rest = text.slice(at, at + 7);
+ if (/^url\(/iu.test(rest) || /^@import\s/iu.test(text.slice(at, at + 8))) {
+ const isUrl = /^url\(/iu.test(rest);
+ let cursor = at + (isUrl ? 4 : 7);
+ let startLine = line, startColumn = column + (cursor - at);
+ for (let skipTo = at; skipTo < cursor; skipTo += 1) advance(text[skipTo]!);
+ while (cursor < text.length && /\s/u.test(text[cursor]!)) { advance(text[cursor]!); startLine = line; startColumn = column; cursor += 1; }
+ startLine = line; startColumn = column;
+ const quote = text[cursor] === '"' || text[cursor] === '\'' ? text[cursor]! : undefined;
+ let value = '';
+ let end = cursor;
+ if (quote) {
+ end = cursor + 1;
+ while (end < text.length && text[end] !== quote && value.length < 2000) { value += text[end] === '\\' ? text[end + 1] ?? '' : text[end]; end += text[end] === '\\' ? 2 : 1; }
+ startColumn += 1;
+ end += 1;
+ } else if (isUrl) {
+ while (end < text.length && text[end] !== ')' && !/\s/u.test(text[end]!) && value.length < 2000) { value += text[end]; end += 1; }
+ }
+ if (value && (quote || isUrl)) urls.push({value, line: startLine, column: startColumn});
+ for (; cursor < end; cursor += 1) advance(text[cursor]!);
+ // url( ... ): the closing bracket belongs to it.
+ if (isUrl) {
+ while (cursor < text.length && /\s/u.test(text[cursor]!)) { advance(text[cursor]!); cursor += 1; }
+ if (text[cursor] === ')') { advance(')'); cursor += 1; }
+ }
+ at = cursor;
+ continue;
+ }
+ if ((char === '{' || char === '(') ) stack.push({char, line, column});
+ else if (char === '}' || char === ')') {
+ const open = stack.pop();
+ if (!issue && (!open || (open.char === '{') !== (char === '}'))) issue = {message: `Unexpected “${char}”.`, line, column};
+ }
+ advance(char); at += 1;
+ }
+ const open = stack.at(-1);
+ if (!issue && open) issue = {message: `“${open.char}” is never closed.`, line: open.line, column: open.column};
+ return {urls, ...(issue ? {issue} : {})};
+}
+
+async function cssUrls(run: Run, urls: readonly CssUrl[], ids: ReadonlySet | undefined): Promise {
+ for (const url of urls) {
+ if (run.aborted) return;
+ if (TEMPLATED.test(url.value)) { run.add('unverified-dynamic', 'unverified', 'A templated address cannot be checked statically.', url.line, url.column, url.column + url.value.length); continue; }
+ await run.reference(url.value, url.line, url.column, undefined, ids, false);
+ }
+}
+
+const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
+const OPTIONAL_END = new Set(['p', 'li', 'dt', 'dd', 'tr', 'td', 'th', 'thead', 'tbody', 'tfoot', 'option', 'optgroup', 'colgroup', 'html', 'head', 'body']);
+const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title']);
+const TAG = /|<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>`=]+))?)*)\s*(\/?)>/gdu;
+const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>`=]+)))?/gdu;
+const MAX_HTML_CHARS = 1_500_000;
+
+async function html(run: Run): Promise {
+ const lines = run.input.lines;
+ const starts: number[] = [];
+ let offset = 0;
+ for (const text of lines) { starts.push(offset); offset += text.length + 1; }
+ const source = lines.join('\n');
+ if (source.length > MAX_HTML_CHARS) { run.truncated = true; return; }
+ const where = (position: number) => { let low = 0, high = starts.length - 1; while (low < high) { const mid = (low + high + 1) >> 1; if (starts[mid]! <= position) low = mid; else high = mid - 1; } return {line: low, column: position - starts[low]!}; };
+ const text = source;
+ interface Tag {name: string; attributes: Map; at: number}
+ const ids = new Map(), fragments: Array<{value: string; position: number; end: number}> = [], labelsFor = new Set(), controls: Array<{tag: Tag; labelled: boolean}> = [];
+ const stack: Array<{name: string; at: number}> = [];
+ let dynamic = 0, inLabel = 0, templateDepth = 0, baseSeen = false;
+ const jobs: Array<() => Promise> = [];
+ let last = 0;
+ for (const match of text.matchAll(TAG)) {
+ if (run.aborted) return;
+ if (match.index! < last || match[0].startsWith('', '', '', 'matrix[i][j] is prose.', '', '[r]:', ' other.md', '> [q]: other.md', '', 'See [a][r] and [b][q].', '', 'a `code', 'span [y](missing.md)` ends', '', '[esc](other\\.md) [esc2](a\\_b.md)', '', '[](other.md) [a [b] c](other.md)'];
+ const report = await run(lines, 'markdown', p.dir, p.root);
+ const bad = report.diagnostics.filter(item => item.severity === 'error');
+ assert.deepEqual(bad.map(item => `${item.code}@${item.line}: ${item.message}`), ['missing-file@18: No such file: a_b.md'], JSON.stringify(report.diagnostics));
+ const toml = await run(['+++', 'link = "[x](missing.md)"', '+++', '', '# After'], 'markdown', p.dir, p.root);
+ assert.deepEqual(toml.diagnostics, [], 'TOML front matter is skipped');
+ } finally { p.cleanup(); }
+});
+
+test('review 8-10: an undefined [text][ref] is only information (CommonMark shows it as text); the outer link of a badge and nested brackets are checked; inline and @import are checked', async () => {
+ const p = project();
+ try {
+ const md = await run(['[x][nope]', '[](gone.md)', '[a [b] c](gone2.md)', ''], 'markdown', p.dir, p.root);
+ assert.deepEqual(md.diagnostics.map(item => `${item.code}:${item.severity}@${item.line}`).sort(), ['missing-file:error@1', 'missing-file:error@2', 'missing-file:error@3', 'undefined-reference:info@0'].sort());
+ const css = await run(['@import "gone.css";', '@import url(../style.css);'], 'css', p.dir, p.root);
+ assert.deepEqual(css.diagnostics.map(item => `${item.code}@${item.line}`), ['missing-file@0']);
+ } finally { p.cleanup(); }
+});
+
+test('review 9: valid HTML and CSS that was reported before is quiet', async () => {
+ const p = project();
+ try {
+ const html = await run(['', '', '', '', ''], 'html', p.dir, p.root);
+ assert.deepEqual(html.diagnostics.map(item => `${item.code}@${item.line}`), ['broken-fragment@2']);
+ const base = await run(['', ''], 'html', p.dir, p.root);
+ assert.ok(!base.diagnostics.some(item => item.severity === 'error'), 'a makes relative addresses unverifiable');
+ const css = await run(['a { filter: url(#blur); background: url(x/*.png); }', '/* }} */', '.q { content: "a\\', '}" }'], 'css', p.dir, p.root);
+ assert.ok(!css.diagnostics.some(item => item.code === 'css-syntax' && item.line < 2), JSON.stringify(css.diagnostics));
+ } finally { p.cleanup(); }
+});
+
+test('review 11-12: the image-alt span is the image text; a value that spans lines is not an exact span and its message is one line', async () => {
+ const lines = ['x  y '];
+ const md = await run(lines, 'markdown');
+ const alt = md.diagnostics.filter(item => item.code === 'image-alt');
+ assert.equal(alt.length, 2);
+ for (const item of alt) assert.ok(lines[0]!.slice(item.column, item.endColumn).startsWith('!['), lines[0]!.slice(item.column, item.endColumn));
+ const p = project();
+ try {
+ const html = await run(['x'], 'html', p.dir, p.root);
+ const item = html.diagnostics.find(entry => entry.code === 'missing-file')!;
+ assert.equal(item.exact, false);
+ assert.ok(!item.message.includes('\n'));
+ } finally { p.cleanup(); }
+});
+
+test('review: a long Markdown source line is scanned only so far and says so; the details scan elsewhere has a budget', async () => {
+ const report = await diagnose({lines: [`${'a '.repeat(15_000)}[x](nope.md)`], format: 'markdown'});
+ assert.ok(report.diagnostics.some(item => item.code === 'long-line'));
+});
diff --git a/tests/problems.test.ts b/tests/problems.test.ts
new file mode 100644
index 00000000..906ea965
--- /dev/null
+++ b/tests/problems.test.ts
@@ -0,0 +1,255 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs';
+import {tmpdir} from 'node:os';
+import {join} from 'node:path';
+import {ProblemsRunner} from '../src/viewer/problems.js';
+import {createEditor, editorKey, renderEditor, type EditorState} from '../src/viewer/EditorPanel.js';
+import {createDocumentSession, documentSessionKey, renderDocumentSession, type DocumentSession} from '../src/viewer/DocumentWorkspace.js';
+import {createLocalFs, type LocalFs} from '../src/markdown/diagnostics.js';
+import {createScratchFile, openForEdit} from '../src/viewer/fileEdit.js';
+import {decodeKeys} from '../src/terminal/keys.js';
+import {displayWidth, stripAnsi} from '../src/util/text.js';
+
+const F1 = '\u001bOP', F3 = '\u001bOR', F2 = '\u001bOQ';
+function project() {
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-problems-')));
+ mkdirSync(join(root, '.git')); mkdirSync(join(root, 'docs'));
+ writeFileSync(join(root, 'docs', 'other.md'), '# Other\n');
+ const write = (name: string, text: string) => { const path = join(root, 'docs', name); writeFileSync(path, text); return path; };
+ return {root, write, cleanup: () => rmSync(root, {recursive: true, force: true})};
+}
+async function editorOn(path: string): Promise {
+ const opened = await openForEdit(path, '/');
+ if (!opened.ok) throw new Error(opened.reason);
+ return createEditor(opened.file);
+}
+const press = (editor: EditorState, bytes: string, columns = 100, height = 30) => { for (const key of decodeKeys(bytes)) editorKey(editor, key, columns, height); };
+const screen = (editor: EditorState, columns = 100, height = 30) => renderEditor(editor, columns, height).map(row => stripAnsi(row)).join('\n');
+const settle = async (check: () => boolean, what: string) => { const deadline = process.hrtime.bigint() + 5_000_000_000n; while (!check()) { if (process.hrtime.bigint() > deadline) throw new Error(`timed out waiting for ${what}`); await new Promise(done => setTimeout(done, 5)); } };
+const DOC = '# Title\n\n[good](other.md) [bad](nope.md) [frag](#missing)\n\n\n';
+
+test('runner: the first run reports, an edit re-runs after the debounce, undo and redo update, and nothing stale replaces newer results', async () => {
+ const p = project();
+ try {
+ const editor = await editorOn(p.write('a.md', DOC));
+ let slow = false;
+ const delayed = (inner: LocalFs): LocalFs => ({stat: async path => { if (slow) await new Promise(done => setTimeout(done, 80)); return inner.stat(path); }, readText: (path, max) => inner.readText(path, max)});
+ const runner = new ProblemsRunner(editor, {debounceMs: 10, makeFs: async () => delayed(createLocalFs(join(p.root, 'docs'), p.root))});
+ editor.problems = runner;
+ runner.enable();
+ await settle(() => Boolean(runner.report), 'the first report');
+ assert.deepEqual(runner.report!.diagnostics.map(item => item.code).sort(), ['broken-fragment', 'image-alt', 'missing-file']);
+ editor.buffer.caret = {line: 2, column: 0}; editor.buffer.insert('[x](gone.md) ');
+ await settle(() => runner.report!.diagnostics.filter(item => item.code === 'missing-file').length === 2, 'the re-run');
+ editor.buffer.undo();
+ await settle(() => runner.report!.diagnostics.filter(item => item.code === 'missing-file').length === 1, 'the undo');
+ editor.buffer.redo();
+ await settle(() => runner.report!.diagnostics.filter(item => item.code === 'missing-file').length === 2, 'the redo');
+ // A slow run for an old version must not overwrite the newer result.
+ slow = true;
+ editor.buffer.caret = {line: 0, column: 0}; editor.buffer.insert('[a](stale1.md) ');
+ await new Promise(done => setTimeout(done, 40));
+ slow = false;
+ editor.buffer.undo(); editor.buffer.undo();
+ await settle(() => !runner.busy, 'the runs to finish');
+ assert.equal(runner.report!.diagnostics.filter(item => item.code === 'missing-file').length, 1, 'the report is for the text as it is now');
+ assert.ok(!runner.report!.diagnostics.some(item => /stale1/u.test(item.message)));
+ runner.dispose();
+ } finally { p.cleanup(); }
+});
+
+test('runner: dispose stops everything (no timer, no later result); a disabled runner never runs; typing in an unopened document costs nothing', async () => {
+ const p = project();
+ try {
+ const editor = await editorOn(p.write('a.md', DOC));
+ let runs = 0;
+ const runner = new ProblemsRunner(editor, {debounceMs: 5, makeFs: async () => { runs += 1; return createLocalFs(join(p.root, 'docs'), p.root); }});
+ editor.buffer.insert('x');
+ await new Promise(done => setTimeout(done, 30));
+ assert.equal(runs, 0, 'not enabled: no work');
+ runner.enable();
+ await settle(() => Boolean(runner.report), 'a report');
+ runner.dispose();
+ const before = runs;
+ editor.buffer.insert('y');
+ await new Promise(done => setTimeout(done, 40));
+ assert.equal(runs, before);
+ assert.equal(runner.disposed, true);
+ } finally { p.cleanup(); }
+});
+
+test('a Playground document has no folder: relative references are unverified, and Save As makes them checkable after a refresh', async () => {
+ const p = project();
+ try {
+ const editor = createEditor(createScratchFile('markdown'));
+ editor.buffer.insert('[x](other.md) [y](#nope)');
+ const runner = new ProblemsRunner(editor, {debounceMs: 5});
+ editor.problems = runner; runner.enable();
+ await settle(() => Boolean(runner.report), 'report');
+ assert.deepEqual(runner.report!.diagnostics.map(item => `${item.code}:${item.severity}`).sort(), ['broken-fragment:error', 'unverified-local:unverified']);
+ runner.dispose();
+ } finally { p.cleanup(); }
+});
+
+// ---- The surface ------------------------------------------------------------------------------------------------
+
+test('F1 e opens Problems with counts and the findings; Tab filters, typing searches, Enter selects the exact text and Back returns; nothing is written or changed', async () => {
+ const p = project();
+ try {
+ const path = p.write('a.md', DOC);
+ const editor = await editorOn(path);
+ editor.problems = new ProblemsRunner(editor, {debounceMs: 5});
+ editor.buffer.caret = {line: 4, column: 3};
+ press(editor, F1); press(editor, 'e');
+ assert.equal(editor.dialog?.kind, 'problems');
+ await settle(() => Boolean(editor.problems!.report), 'report');
+ const view = screen(editor);
+ assert.match(view, /Problems/u); assert.match(view, /2 errors/u); assert.match(view, /1 warning/u);
+ assert.match(view, /No such file: nope\.md/u); assert.match(view, /No heading or id “missing”/u);
+ press(editor, 'nope');
+ assert.doesNotMatch(screen(editor), /image/iu);
+ press(editor, '\r');
+ assert.equal(editor.dialog, undefined);
+ assert.equal(editor.buffer.selectedText(), 'nope.md', 'the exact destination is selected');
+ assert.equal(editor.buffer.dirty, false);
+ assert.equal(readFileSync(path, 'utf8'), DOC);
+ press(editor, F1); press(editor, 'j');
+ assert.deepEqual(editor.buffer.caret, {line: 4, column: 3});
+ press(editor, F1); press(editor, 'e');
+ await settle(() => !editor.problems!.busy, 'refresh');
+ press(editor, '\t');
+ assert.match(screen(editor), /errors/u);
+ press(editor, '\t'); press(editor, '\t');
+ press(editor, '\u001b');
+ assert.equal(editor.dialog, undefined);
+ editor.problems!.dispose();
+ } finally { p.cleanup(); }
+});
+
+test('a dismissed finding stays hidden until the text changes; Ctrl+R checks again; a clean document says so', async () => {
+ const p = project();
+ try {
+ const editor = await editorOn(p.write('a.md', '[bad](nope.md)\n'));
+ editor.problems = new ProblemsRunner(editor, {debounceMs: 5});
+ press(editor, F1); press(editor, 'e');
+ await settle(() => Boolean(editor.problems!.report), 'report');
+ assert.match(screen(editor), /nope\.md/u);
+ press(editor, '\u001b[3~');
+ assert.doesNotMatch(screen(editor), /No such file/u);
+ press(editor, '\u0012');
+ await settle(() => !editor.problems!.busy, 'refresh');
+ assert.match(screen(editor), /No such file/u, 'a refresh shows it again');
+ editor.dialog = undefined;
+ editor.buffer.caret = {line: 0, column: 0}; editor.buffer.delete();
+ const clean = await editorOn(p.write('b.md', '# Fine\n\nNothing wrong here.\n'));
+ clean.problems = new ProblemsRunner(clean, {debounceMs: 5});
+ press(clean, F1); press(clean, 'e');
+ await settle(() => Boolean(clean.problems!.report), 'clean report');
+ assert.match(screen(clean), /No problems found/u);
+ editor.problems!.dispose(); clean.problems!.dispose();
+ } finally { p.cleanup(); }
+});
+
+test('HTML files list Problems in the palette; CSS files open it with F1; the status row shows the counts once checking is on', async () => {
+ const p = project();
+ try {
+ const html = await editorOn(p.write('p.html', '\n'));
+ html.problems = new ProblemsRunner(html, {debounceMs: 5});
+ press(html, F1); press(html, 'problems'); press(html, '\r');
+ assert.equal(html.dialog?.kind, 'problems');
+ await settle(() => Boolean(html.problems!.report), 'html report');
+ assert.match(screen(html), /gone\.png/u);
+ press(html, '\u001b');
+ assert.match(screen(html), /✖\s*1/u, 'counts in the status row');
+ const css = await editorOn(p.write('s.css', 'a { background: url(gone.png) }\n'));
+ css.problems = new ProblemsRunner(css, {debounceMs: 5});
+ press(css, F1);
+ assert.equal(css.dialog?.kind, 'problems');
+ await settle(() => Boolean(css.problems!.report), 'css report');
+ assert.match(screen(css), /gone\.png/u);
+ html.problems!.dispose(); css.problems!.dispose();
+ } finally { p.cleanup(); }
+});
+
+test('rows fit 30–120 columns with colour, NO_COLOR and Safe Glyphs; severity never depends on colour alone', async () => {
+ const p = project();
+ const saved = process.env.NO_COLOR;
+ try {
+ const editor = await editorOn(p.write('a.md', `${DOC}\n[very long](${'dir/'.repeat(30)}file.md) 日本語 😀\n`));
+ editor.problems = new ProblemsRunner(editor, {debounceMs: 5});
+ press(editor, F1); press(editor, 'e');
+ await settle(() => Boolean(editor.problems!.report), 'report');
+ for (const noColor of [false, true]) {
+ if (noColor) process.env.NO_COLOR = '1'; else delete process.env.NO_COLOR;
+ for (const columns of [30, 50, 80, 120]) for (const row of renderEditor(editor, columns, 24)) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`);
+ }
+ assert.match(screen(editor, 100), /[✖E]\s/u);
+ editor.problems!.dispose();
+ } finally { if (saved === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = saved; p.cleanup(); }
+});
+
+// ---- Every mode -------------------------------------------------------------------------------------------------
+
+async function session(path: string): Promise {
+ const opened = await openForEdit(path, '/');
+ if (!opened.ok) throw new Error(opened.reason);
+ const state = createDocumentSession(opened.file, 'edit');
+ state.editor.problems = new ProblemsRunner(state.editor, {debounceMs: 5});
+ return state;
+}
+const key = (state: DocumentSession, bytes: string, columns = 120, height = 30) => { for (const k of decodeKeys(bytes)) documentSessionKey(state, k, columns, height); };
+const LONG = ['# Top', '', ...Array.from({length: 40}, (_, index) => `filler ${index}`), '', '[bad](nope.md) here', '', '## End'].join('\n') + '\n';
+
+test('Problems works in source, preview, hybrid and split: P (or F1 e) opens it and a jump lands on the exact source and shows it', async () => {
+ const p = project();
+ try {
+ const target = LONG.split('\n').findIndex(line => line.includes('nope.md'));
+ for (const mode of ['source', 'preview', 'hybrid', 'split-preview', 'split-source', 'edit'] as const) {
+ const state = await session(p.write(`m-${mode}.md`, LONG));
+ state.editor.problems = new ProblemsRunner(state.editor, {debounceMs: 5});
+ if (mode === 'source') key(state, '\u001b');
+ else if (mode === 'preview') { key(state, '\u000f'); }
+ else if (mode === 'hybrid') key(state, F3);
+ else if (mode === 'split-preview') { key(state, F2); key(state, '\u000f'); }
+ else if (mode === 'split-source') key(state, F2);
+ if (mode === 'edit' || mode === 'hybrid' || mode === 'split-source') key(state, F1 + 'e'); else key(state, 'P');
+ assert.equal(state.editor.dialog?.kind, 'problems', mode);
+ await settle(() => Boolean(state.editor.problems!.report), `${mode} report`);
+ assert.match(renderDocumentSession(state, 120, 24).map(row => stripAnsi(row)).join('\n'), /No such file: nope\.md/u, mode);
+ key(state, '\r', 120, 24);
+ assert.equal(state.editor.dialog, undefined, mode);
+ assert.equal(state.editor.buffer.caret.line, target, `${mode}: the caret is on the finding`);
+ assert.equal(state.editor.buffer.selectedText(), 'nope.md', mode);
+ const view = renderDocumentSession(state, 120, 24).map(row => stripAnsi(row)).join('\n');
+ assert.match(view, /nope\.md|bad/u, `${mode}: the place is on screen`);
+ assert.equal(state.editor.buffer.dirty, false, mode);
+ state.editor.problems!.dispose();
+ }
+ } finally { p.cleanup(); }
+});
+
+test('review 6, 7, 13: a failing filesystem factory is not an unhandled rejection; the focus stays on a row when the list shrinks', async () => {
+ const p = project();
+ try {
+ const editor = await editorOn(p.write('a.md', '[a](nope.md) [b](nope2.md) [c](nope3.md)\n'));
+ const rejections: unknown[] = [];
+ const listener = (reason: unknown) => rejections.push(reason);
+ process.on('unhandledRejection', listener);
+ const broken = new ProblemsRunner(editor, {debounceMs: 5, makeFs: async () => { throw new Error('boom'); }});
+ broken.enable();
+ await new Promise(done => setTimeout(done, 40));
+ process.off('unhandledRejection', listener);
+ assert.deepEqual(rejections, []);
+ broken.dispose();
+ editor.problems = new ProblemsRunner(editor, {debounceMs: 5});
+ press(editor, F1); press(editor, 'e');
+ await settle(() => Boolean(editor.problems!.report), 'report');
+ press(editor, '\u001b[B'); press(editor, '\u001b[B');
+ editor.buffer.caret = {line: 0, column: 0}; editor.buffer.delete(); editor.buffer.delete();
+ for (let step = 0; step < 24; step += 1) editor.buffer.delete();
+ await settle(() => editor.problems!.visible().length < 3 && !editor.problems!.busy, 'a shorter report');
+ assert.match(screen(editor), /▸/u, 'a row is still selected');
+ editor.problems!.dispose();
+ } finally { p.cleanup(); }
+});