+ ) : null}
+
+ );
+ })}
+
+ >
)}
) : (
@@ -216,6 +370,9 @@ export function ProblemsPanel({
audit={audit}
onRefresh={() => void loadAudit()}
onNavigate={handleProjectNav}
+ fixableCount={projectFixableFiles.reduce((n, f) => n + countFixable(f.diagnostics), 0)}
+ fixing={projectFixing}
+ onFixAll={() => void fixAllProjectFiles()}
/>
)}
@@ -226,10 +383,19 @@ function ProjectAuditBody({
audit,
onRefresh,
onNavigate,
+ fixableCount,
+ fixing,
+ onFixAll,
}: {
audit: ProjectAuditState;
onRefresh: () => void;
onNavigate: (filePath: string, diagnostic: DiagnosticLike) => void;
+ /** Auto-fixable diagnostics across the loaded audit (same unit the doc
+ * scope's Fix all counts — problems, not files). */
+ fixableCount: number;
+ /** Sweep progress while a project Fix all is running, else null. */
+ fixing: { done: number; total: number } | null;
+ onFixAll: () => void;
}) {
const { t } = useLingui();
const loading = audit.status === 'loading' || audit.status === 'idle';
@@ -245,17 +411,39 @@ function ProjectAuditBody({
>
)}
-
-
-
+
+ {/* The Fix all button is disabled during a sweep, so AT can't focus it
+ to hear the "Fixing N/M" progress — announce it from a live region
+ instead. Rendered only while sweeping so it never coexists with the
+ loading skeleton's own role="status". */}
+ {fixing !== null ? (
+
+ {t`Fixing ${fixing.done} of ${fixing.total} files`}
+
+ ) : null}
+
+ {fixing !== null ? (
+
+ Fixing {fixing.done}/{fixing.total}
+
+ ) : undefined}
+
+
+
+
+
{loading && (
diff --git a/packages/app/src/components/handoff/compose-lint-fix-prompt.test.ts b/packages/app/src/components/handoff/compose-lint-fix-prompt.test.ts
new file mode 100644
index 00000000..8b4440eb
--- /dev/null
+++ b/packages/app/src/components/handoff/compose-lint-fix-prompt.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from 'bun:test';
+import type { LintDiagnostic } from '@inkeep/open-knowledge-core';
+import { composeLintFixTerminalPaste } from './compose-lint-fix-prompt.ts';
+
+function diag(over: Partial = {}): LintDiagnostic {
+ return {
+ range: { start: { line: 11, character: 2 }, end: { line: 11, character: 3 } },
+ severity: 'warning',
+ source: 'markdownlint',
+ code: 'MD010',
+ message: 'Hard tabs',
+ ...over,
+ };
+}
+
+describe('composeLintFixTerminalPaste', () => {
+ test('grounds the paste with the .md-suffixed doc path and 1-based position', () => {
+ const paste = composeLintFixTerminalPaste('guides/setup', diag(), '\tindented');
+ expect(paste).toContain('@guides/setup.md');
+ // 0-based LSP range start (11,2) renders as line 12, column 3.
+ expect(paste).toContain('at line 12, column 3');
+ expect(paste).toContain('\tindented');
+ });
+
+ test('resolves the primary markdownlint alias from the generated catalog', () => {
+ const paste = composeLintFixTerminalPaste('notes', diag(), undefined);
+ expect(paste).toContain('markdownlint/MD010 (no-hard-tabs)');
+ });
+
+ test('a non-markdownlint source carries no alias', () => {
+ const paste = composeLintFixTerminalPaste(
+ 'notes',
+ diag({ source: 'frontmatter', code: 'FM001' }),
+ undefined,
+ );
+ expect(paste).toContain('frontmatter/FM001 at');
+ expect(paste).not.toContain('FM001 (');
+ });
+});
diff --git a/packages/app/src/components/handoff/compose-lint-fix-prompt.ts b/packages/app/src/components/handoff/compose-lint-fix-prompt.ts
new file mode 100644
index 00000000..19d32d9d
--- /dev/null
+++ b/packages/app/src/components/handoff/compose-lint-fix-prompt.ts
@@ -0,0 +1,36 @@
+import {
+ composeLintFixPrompt,
+ type LintDiagnostic,
+ MARKDOWNLINT_RULE_CATALOG,
+} from '@inkeep/open-knowledge-core';
+import { docNameToRelativePath } from '@/lib/workspace-paths';
+
+const ALIAS_BY_CODE = new Map(MARKDOWNLINT_RULE_CATALOG.map((rule) => [rule.id, rule.alias]));
+
+/**
+ * Grounded lint-fix paste for a terminal CLI: the doc named as an `@`-mention,
+ * one diagnostic located precisely (rule, line, column, message, offending
+ * line), and a fix-via-OK-MCP directive. The Problems panel "Ask AI" button
+ * fires this through `requestActiveTerminalInput` — same transport as the
+ * selection paste, so a live agent TUI receives it for review-before-send.
+ *
+ * `lineText` is the offending source line read from `Y.Text('source')` at
+ * click time; the composer omits its block when unavailable.
+ */
+export function composeLintFixTerminalPaste(
+ docName: string,
+ diagnostic: LintDiagnostic,
+ lineText: string | undefined,
+): string {
+ return composeLintFixPrompt({
+ relativePath: docNameToRelativePath(docName),
+ source: diagnostic.source,
+ code: diagnostic.code,
+ ruleAlias:
+ diagnostic.source === 'markdownlint' ? ALIAS_BY_CODE.get(diagnostic.code) : undefined,
+ message: diagnostic.message,
+ line: diagnostic.range.start.line + 1,
+ column: diagnostic.range.start.character + 1,
+ lineText,
+ });
+}
diff --git a/packages/app/src/editor/apply-lint-fix.test.ts b/packages/app/src/editor/apply-lint-fix.test.ts
index 32498f81..602020d8 100644
--- a/packages/app/src/editor/apply-lint-fix.test.ts
+++ b/packages/app/src/editor/apply-lint-fix.test.ts
@@ -89,6 +89,45 @@ describe('applyLintFixes', () => {
expect(doc.getText('source').toString()).toBe('# Title\n\npara\n\nextra blank\n');
});
+ test('applies an exact duplicate edit only once', () => {
+ // Two diagnostics (e.g. two rules flagging the same run) can carry
+ // byte-identical fixes; compounding them would delete twice.
+ const doc = docWith('a\tb\n');
+ applyLintFixes({ document: doc }, [edit(0, 1, 0, 2, ' '), edit(0, 1, 0, 2, ' ')]);
+ expect(doc.getText('source').toString()).toBe('a b\n');
+ });
+
+ test('skips an edit swallowed by an already-applied whole-line delete', () => {
+ // A whole-line delete (MD012 shape) containing another diagnostic's
+ // same-line replace: applying both would delete bytes twice. Upstream
+ // applyFixes skips the overlapped fix; the skipped issue re-surfaces on
+ // the post-fix re-lint.
+ const doc = docWith('keep\nx\ty\nkeep\n');
+ applyLintFixes({ document: doc }, [
+ edit(1, 0, 2, 0, ''), // delete line 1 entirely
+ edit(1, 1, 1, 2, ' '), // replace the tab inside line 1
+ ]);
+ expect(doc.getText('source').toString()).toBe('keep\nkeep\n');
+ });
+
+ test('applies touching (end-exclusive adjacent) edits from different diagnostics', () => {
+ // [2,3) and [1,2) touch at offset 2 but do not overlap — both must land.
+ const doc = docWith('a\t\tb\n');
+ applyLintFixes({ document: doc }, [edit(0, 1, 0, 2, ' '), edit(0, 2, 0, 3, ' ')]);
+ expect(doc.getText('source').toString()).toBe('a b\n');
+ });
+
+ test('multi-diagnostic combination applies all non-conflicting fixes', () => {
+ const doc = docWith('a\tb \n\n\npara');
+ applyLintFixes({ document: doc }, [
+ edit(0, 1, 0, 2, ' '), // MD010 hard tab
+ edit(0, 3, 0, 6, ''), // MD009 trailing spaces
+ edit(2, 0, 3, 0, ''), // MD012 extra blank line
+ edit(3, 4, 3, 4, '\n'), // MD047 trailing newline
+ ]);
+ expect(doc.getText('source').toString()).toBe('a b\n\npara\n');
+ });
+
test('fires LINT_SOURCE_FIXED_EVENT once after a non-empty fix', () => {
let fired = 0;
withWindowStub((win) => {
diff --git a/packages/app/src/editor/apply-lint-fix.ts b/packages/app/src/editor/apply-lint-fix.ts
index 095343b0..5baa3cea 100644
--- a/packages/app/src/editor/apply-lint-fix.ts
+++ b/packages/app/src/editor/apply-lint-fix.ts
@@ -55,6 +55,12 @@ export function collectFixes(diagnostics: readonly LintDiagnostic[]): LintTextEd
* Offsets are resolved against the pre-fix source, then applied high→low so an
* earlier edit never shifts a later one's coordinates (matches how CodeMirror
* composes a lint fix's change set). Returns false when there is nothing to do.
+ *
+ * Edits from DIFFERENT diagnostics may duplicate or overlap (e.g. a whole-line
+ * delete swallowing another rule's same-line replace). Upstream markdownlint's
+ * `applyFixes` skips such fixes rather than compounding them; mirror that:
+ * exact duplicates apply once, and an edit overlapping an already-applied one
+ * is dropped — it resurfaces as a live diagnostic on the post-fix re-lint.
*/
export function applyLintFixes(
provider: SourceWriteProvider,
@@ -69,11 +75,24 @@ export function applyLintFixes(
to: offsetOf(source, fix.range.end),
insert: fix.newText,
}))
- .sort((a, b) => b.from - a.from);
+ // End-desc so a containing edit (whole-line delete) orders before edits
+ // inside it — the container applies, the swallowed edit skips. For
+ // non-overlapping edits this is the same high→low order as from-desc.
+ .sort((a, b) => b.to - a.to || b.from - a.from || a.insert.localeCompare(b.insert));
provider.document.transact(() => {
+ let lowestAppliedFrom = Number.POSITIVE_INFINITY;
+ let previous: (typeof edits)[number] | undefined;
for (const edit of edits) {
+ const isDuplicate =
+ previous !== undefined &&
+ edit.from === previous.from &&
+ edit.to === previous.to &&
+ edit.insert === previous.insert;
+ previous = edit;
+ if (isDuplicate || edit.to > lowestAppliedFrom) continue;
if (edit.to > edit.from) ytext.delete(edit.from, edit.to - edit.from);
if (edit.insert.length > 0) ytext.insert(edit.from, edit.insert);
+ lowestAppliedFrom = edit.from;
}
}, LINT_FIX_ORIGIN);
// Nudge any mounted WYSIWYG lint decoration to re-lint — a source-only fix
diff --git a/packages/app/src/editor/lint-config-client.ts b/packages/app/src/editor/lint-config-client.ts
index 8c4a20a3..164ba010 100644
--- a/packages/app/src/editor/lint-config-client.ts
+++ b/packages/app/src/editor/lint-config-client.ts
@@ -11,6 +11,8 @@ import {
type LintConfigResponse,
LintConfigResponseSchema,
type LinterConfig,
+ type LintFixResult,
+ LintFixResultSchema,
type MarkdownlintRuleWriteValue,
} from '@inkeep/open-knowledge-core';
import { useEffect, useState } from 'react';
@@ -81,6 +83,43 @@ export async function runLintAudit(targetPath?: string): Promise {
+ try {
+ const res = await fetch('/api/lint/fix', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ docName }),
+ });
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { title?: unknown } | null;
+ return {
+ ok: false,
+ errorDetail: typeof errBody?.title === 'string' ? errBody.title : null,
+ };
+ }
+ const body = await res.json().catch(() => null);
+ const parsed = LintFixResultSchema.safeParse(body);
+ if (!parsed.success) {
+ // Mirror the sibling fetchLintConfig/runLintAudit logging so a
+ // client/server schema drift leaves a diagnostic trail instead of a
+ // silent failure.
+ console.warn('[lint] fix response failed schema validation', parsed.error.issues);
+ return { ok: false, errorDetail: null };
+ }
+ return { ok: true, result: parsed.data };
+ } catch {
+ return { ok: false, errorDetail: null };
+ }
+}
+
/**
* POST one rule change to the project's native `.markdownlint.*` file (the
* source of truth). `value: null` removes the rule (reverts to OK's default).
diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json
index 1cf5511e..88aa8265 100644
--- a/packages/app/src/locales/en/messages.json
+++ b/packages/app/src/locales/en/messages.json
@@ -286,6 +286,7 @@
"5Umg9i": ["Disable external link previews"],
"5WDhAk": ["Link to a page or external URL."],
"5ZB7XC": ["File drop zone"],
+ "5_8TCr": ["Fix all (", ["count"], ")"],
"5_j-DA": ["Kill Terminal"],
"5e1Dri": ["The menu label agents pick this template by (required)."],
"5gr_ev": [
@@ -878,6 +879,7 @@
"It9ARY": ["Toggle the right document panel."],
"Ix0K8o": ["Hide external URL nodes"],
"IxyZXD": ["Screenshot"],
+ "Izb10l": ["Fix all ", ["count"], " fixable problems"],
"J2MgzQ": ["Undo all edits on ", ["docName"]],
"J2Mv98": ["Name (e.g. ", ["login"], ")"],
"J2eKUI": ["File"],
@@ -1284,6 +1286,7 @@
"S34Vju": ["Have something else in mind?"],
"S6l5dv": ["Could not start creating a new item"],
"S9Vnum": ["Remove ", ["patternText"]],
+ "S9t1PI": ["Fixing ", ["0"], "/", ["1"]],
"SCoBBH": ["Tag suggestions"],
"SCzAbA": [["phase"], " — ", ["pct"], "%"],
"SEYWYq": ["Undo all edits on this file?"],
@@ -1879,6 +1882,7 @@
"fnlBN4": ["Auto-approve settings not yet loaded — try again in a moment"],
"fo0VXg": ["Uninstall"],
"fqSfXY": ["Replace"],
+ "fru3T7": ["Could not fix ", ["0"], " of ", ["1"], " files."],
"fuJZKk": ["Search branches"],
"fwhX-N": ["Indent or outdent source"],
"fxJk_6": ["<0>Have more to add?0> Write to <1/> and mention your reference."],
@@ -1909,6 +1913,7 @@
"Pick a starter pack to scaffold your project with ready-made folders and templates."
],
"gYz2ep": ["Expires in ", ["timeLabel"]],
+ "gZElfU": ["Fixing ", ["0"], " of ", ["1"], " files"],
"gbHcbI": ["Failed to delete property"],
"gfdqcY": ["Couldn't apply the change: ", ["0"]],
"gfohmY": ["Connect tools"],
@@ -1974,6 +1979,7 @@
"haHgQj": ["Copied relative path"],
"habOU_": ["Failed to update the auto-approve setting — ", ["detail"]],
"he3ygx": ["Copy"],
+ "hh4mVC": ["Ask AI to fix ", ["flatId"]],
"hjO0Mq": ["Add tab"],
"hmYd9H": [
"Keep track of the people and companies you work with and the meetings you have with them. Good for remembering who someone is, how you know them, and what you last talked about."
diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po
index 0a52377f..5ec78ff2 100644
--- a/packages/app/src/locales/en/messages.po
+++ b/packages/app/src/locales/en/messages.po
@@ -1052,6 +1052,7 @@ msgstr "Ask {0} CLI"
#: src/components/BottomComposer.tsx
#: src/components/EditorFooter.tsx
#: src/components/OnboardingCard.tsx
+#: src/components/ProblemsPanel.tsx
#: src/editor/bubble-menu/EditWithAiBubbleButton.tsx
#: src/lib/keyboard-shortcuts.ts
msgid "Ask AI"
@@ -1065,6 +1066,10 @@ msgstr "Ask AI (from selection)"
msgid "Ask AI about this code block"
msgstr "Ask AI about this code block"
+#: src/components/ProblemsPanel.tsx
+msgid "Ask AI to fix {flatId}"
+msgstr "Ask AI to fix {flatId}"
+
#: src/editor/extensions/WikiLinkPropPanel.tsx
#: src/editor/link-path-suggestions.tsx
msgid "Asset"
@@ -2051,6 +2056,12 @@ msgstr "Could not fetch branch. Check your connection."
msgid "Could not finalize project setup. Try again."
msgstr "Could not finalize project setup. Try again."
+#. placeholder {0}: failures.length
+#. placeholder {1}: projectFixableFiles.length
+#: src/components/ProblemsPanel.tsx
+msgid "Could not fix {0} of {1} files."
+msgstr "Could not fix {0} of {1} files."
+
#. placeholder {0}: seed.candidatePath
#: src/components/ShareReceiveDialog.tsx
msgid "Could not initialize {0}."
@@ -3632,10 +3643,30 @@ msgstr "Fix"
msgid "Fix {flatId}"
msgstr "Fix {flatId}"
+#: src/components/ProblemsPanel.tsx
+msgid "Fix all ({count})"
+msgstr "Fix all ({count})"
+
+#: src/components/ProblemsPanel.tsx
+msgid "Fix all {count} fixable problems"
+msgstr "Fix all {count} fixable problems"
+
#: src/components/settings/SettingsDialogBody.tsx
msgid "Fixed folder in content root"
msgstr "Fixed folder in content root"
+#. placeholder {0}: fixing.done
+#. placeholder {1}: fixing.total
+#: src/components/ProblemsPanel.tsx
+msgid "Fixing {0} of {1} files"
+msgstr "Fixing {0} of {1} files"
+
+#. placeholder {0}: fixing.done
+#. placeholder {1}: fixing.total
+#: src/components/ProblemsPanel.tsx
+msgid "Fixing {0}/{1}"
+msgstr "Fixing {0}/{1}"
+
#: src/components/settings/LintingSection.tsx
msgid "Flag common markdown issues in the editor. Powered by <0>markdownlint<1/>0>."
msgstr "Flag common markdown issues in the editor. Powered by <0>markdownlint<1/>0>."
diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json
index 6bf38f40..095caf64 100644
--- a/packages/app/src/locales/pseudo/messages.json
+++ b/packages/app/src/locales/pseudo/messages.json
@@ -286,6 +286,7 @@
"5Umg9i": ["Ďĩśàƀĺē ēxţēŕńàĺ ĺĩńķ ƥŕēvĩēŵś"],
"5WDhAk": ["Ĺĩńķ ţō à ƥàĝē ōŕ ēxţēŕńàĺ ŨŔĹ."],
"5ZB7XC": ["Ƒĩĺē ďŕōƥ źōńē"],
+ "5_8TCr": ["Ƒĩx àĺĺ (", ["count"], ")"],
"5_j-DA": ["Ķĩĺĺ Ţēŕḿĩńàĺ"],
"5e1Dri": ["Ţĥē ḿēńũ ĺàƀēĺ àĝēńţś ƥĩćķ ţĥĩś ţēḿƥĺàţē ƀŷ (ŕēǫũĩŕēď)."],
"5gr_ev": [
@@ -878,6 +879,7 @@
"It9ARY": ["Ţōĝĝĺē ţĥē ŕĩĝĥţ ďōćũḿēńţ ƥàńēĺ."],
"Ix0K8o": ["Ĥĩďē ēxţēŕńàĺ ŨŔĹ ńōďēś"],
"IxyZXD": ["Śćŕēēńśĥōţ"],
+ "Izb10l": ["Ƒĩx àĺĺ ", ["count"], " ƒĩxàƀĺē ƥŕōƀĺēḿś"],
"J2MgzQ": ["Ũńďō àĺĺ ēďĩţś ōń ", ["docName"]],
"J2Mv98": ["Ńàḿē (ē.ĝ. ", ["login"], ")"],
"J2eKUI": ["Ƒĩĺē"],
@@ -1284,6 +1286,7 @@
"S34Vju": ["Ĥàvē śōḿēţĥĩńĝ ēĺśē ĩń ḿĩńď?"],
"S6l5dv": ["Ćōũĺď ńōţ śţàŕţ ćŕēàţĩńĝ à ńēŵ ĩţēḿ"],
"S9Vnum": ["Ŕēḿōvē ", ["patternText"]],
+ "S9t1PI": ["Ƒĩxĩńĝ ", ["0"], "/", ["1"]],
"SCoBBH": ["Ţàĝ śũĝĝēśţĩōńś"],
"SCzAbA": [["phase"], " — ", ["pct"], "%"],
"SEYWYq": ["Ũńďō àĺĺ ēďĩţś ōń ţĥĩś ƒĩĺē?"],
@@ -1879,6 +1882,7 @@
"fnlBN4": ["Àũţō-àƥƥŕōvē śēţţĩńĝś ńōţ ŷēţ ĺōàďēď — ţŕŷ àĝàĩń ĩń à ḿōḿēńţ"],
"fo0VXg": ["Ũńĩńśţàĺĺ"],
"fqSfXY": ["Ŕēƥĺàćē"],
+ "fru3T7": ["Ćōũĺď ńōţ ƒĩx ", ["0"], " ōƒ ", ["1"], " ƒĩĺēś."],
"fuJZKk": ["Śēàŕćĥ ƀŕàńćĥēś"],
"fwhX-N": ["Ĩńďēńţ ōŕ ōũţďēńţ śōũŕćē"],
"fxJk_6": ["<0>Ĥàvē ḿōŕē ţō àďď?0> Ŵŕĩţē ţō <1/> àńď ḿēńţĩōń ŷōũŕ ŕēƒēŕēńćē."],
@@ -1909,6 +1913,7 @@
"Ƥĩćķ à śţàŕţēŕ ƥàćķ ţō śćàƒƒōĺď ŷōũŕ ƥŕōĴēćţ ŵĩţĥ ŕēàďŷ-ḿàďē ƒōĺďēŕś àńď ţēḿƥĺàţēś."
],
"gYz2ep": ["Ēxƥĩŕēś ĩń ", ["timeLabel"]],
+ "gZElfU": ["Ƒĩxĩńĝ ", ["0"], " ōƒ ", ["1"], " ƒĩĺēś"],
"gbHcbI": ["Ƒàĩĺēď ţō ďēĺēţē ƥŕōƥēŕţŷ"],
"gfdqcY": ["Ćōũĺďń'ţ àƥƥĺŷ ţĥē ćĥàńĝē: ", ["0"]],
"gfohmY": ["Ćōńńēćţ ţōōĺś"],
@@ -1974,6 +1979,7 @@
"haHgQj": ["Ćōƥĩēď ŕēĺàţĩvē ƥàţĥ"],
"habOU_": ["Ƒàĩĺēď ţō ũƥďàţē ţĥē àũţō-àƥƥŕōvē śēţţĩńĝ — ", ["detail"]],
"he3ygx": ["Ćōƥŷ"],
+ "hh4mVC": ["Àśķ ÀĨ ţō ƒĩx ", ["flatId"]],
"hjO0Mq": ["Àďď ţàƀ"],
"hmYd9H": [
"Ķēēƥ ţŕàćķ ōƒ ţĥē ƥēōƥĺē àńď ćōḿƥàńĩēś ŷōũ ŵōŕķ ŵĩţĥ àńď ţĥē ḿēēţĩńĝś ŷōũ ĥàvē ŵĩţĥ ţĥēḿ. Ĝōōď ƒōŕ ŕēḿēḿƀēŕĩńĝ ŵĥō śōḿēōńē ĩś, ĥōŵ ŷōũ ķńōŵ ţĥēḿ, àńď ŵĥàţ ŷōũ ĺàśţ ţàĺķēď àƀōũţ."
diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po
index aa7c0957..6a644c38 100644
--- a/packages/app/src/locales/pseudo/messages.po
+++ b/packages/app/src/locales/pseudo/messages.po
@@ -1048,6 +1048,7 @@ msgstr ""
#: src/components/BottomComposer.tsx
#: src/components/EditorFooter.tsx
#: src/components/OnboardingCard.tsx
+#: src/components/ProblemsPanel.tsx
#: src/editor/bubble-menu/EditWithAiBubbleButton.tsx
#: src/lib/keyboard-shortcuts.ts
msgid "Ask AI"
@@ -1061,6 +1062,10 @@ msgstr ""
msgid "Ask AI about this code block"
msgstr ""
+#: src/components/ProblemsPanel.tsx
+msgid "Ask AI to fix {flatId}"
+msgstr ""
+
#: src/editor/extensions/WikiLinkPropPanel.tsx
#: src/editor/link-path-suggestions.tsx
msgid "Asset"
@@ -2047,6 +2052,12 @@ msgstr ""
msgid "Could not finalize project setup. Try again."
msgstr ""
+#. placeholder {0}: failures.length
+#. placeholder {1}: projectFixableFiles.length
+#: src/components/ProblemsPanel.tsx
+msgid "Could not fix {0} of {1} files."
+msgstr ""
+
#. placeholder {0}: seed.candidatePath
#: src/components/ShareReceiveDialog.tsx
msgid "Could not initialize {0}."
@@ -3628,10 +3639,30 @@ msgstr ""
msgid "Fix {flatId}"
msgstr ""
+#: src/components/ProblemsPanel.tsx
+msgid "Fix all ({count})"
+msgstr ""
+
+#: src/components/ProblemsPanel.tsx
+msgid "Fix all {count} fixable problems"
+msgstr ""
+
#: src/components/settings/SettingsDialogBody.tsx
msgid "Fixed folder in content root"
msgstr ""
+#. placeholder {0}: fixing.done
+#. placeholder {1}: fixing.total
+#: src/components/ProblemsPanel.tsx
+msgid "Fixing {0} of {1} files"
+msgstr ""
+
+#. placeholder {0}: fixing.done
+#. placeholder {1}: fixing.total
+#: src/components/ProblemsPanel.tsx
+msgid "Fixing {0}/{1}"
+msgstr ""
+
#: src/components/settings/LintingSection.tsx
msgid "Flag common markdown issues in the editor. Powered by <0>markdownlint<1/>0>."
msgstr ""
diff --git a/packages/app/tests/integration/lint-http.test.ts b/packages/app/tests/integration/lint-http.test.ts
index 8d61f09d..469fd83e 100644
--- a/packages/app/tests/integration/lint-http.test.ts
+++ b/packages/app/tests/integration/lint-http.test.ts
@@ -161,6 +161,42 @@ describe('POST /api/lint/fix', () => {
const res = await postFix(`no-such-doc-${crypto.randomUUID().slice(0, 8)}`);
expect(res.status).toBe(404);
});
+
+ test('a bare body (no agentId) fixes as the principal and surfaces no agent presence', async () => {
+ const folder = join(server.contentDir, 'lint-fix-principal');
+ mkdirSync(folder, { recursive: true });
+ const file = join(folder, 'tabbed.md');
+ writeFileSync(file, '# Doc\n\n\tindented with a hard tab\n', 'utf-8');
+ try {
+ const res = await fetch(api('/api/lint/fix'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ docName: 'lint-fix-principal/tabbed' }),
+ });
+ expect(res.status).toBe(200);
+ const body = LintFixResultSchema.parse(await res.json());
+ expect(body.fixedCount).toBeGreaterThanOrEqual(1);
+ expect(readFileSync(file, 'utf-8')).not.toContain('\t');
+ // The write is the principal's (or the neutral anonymous writer's) —
+ // either way a `principal-*` id, filtered at the presence-broadcaster
+ // boundary. No phantom agent badge may appear for a UI-initiated fix.
+ const presence = await fetch(api('/api/metrics/agent-presence'));
+ const map = (await presence.json()) as { agents?: Record };
+ const ids = Object.keys(map.agents ?? map);
+ expect(ids.some((id) => id.startsWith('principal-'))).toBe(false);
+ } finally {
+ rmSync(folder, { recursive: true, force: true });
+ }
+ });
+
+ test('a non-string summary is a 400', async () => {
+ const res = await fetch(api('/api/lint/fix'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ docName: 'anything', summary: 123 }),
+ });
+ expect(res.status).toBe(400);
+ });
});
describe('POST /api/lint/markdownlint-config', () => {
diff --git a/packages/cli/src/content/lint-runner.test.ts b/packages/cli/src/content/lint-runner.test.ts
index f0875fe4..4c38b7ae 100644
--- a/packages/cli/src/content/lint-runner.test.ts
+++ b/packages/cli/src/content/lint-runner.test.ts
@@ -45,6 +45,16 @@ function run(opts: Partial[0]> = {}) {
}
describe('runLint — walk + lint', () => {
+ test('the walk skips hidden segments; an explicit hidden file target still lints', async () => {
+ write('visible.md', '# A\n\ntext with a\ttab\n');
+ write('.ok/skills/pack/SKILL.md', '# S\n\ntext with a\ttab\n');
+ const swept = await run();
+ expect(swept.files.map((f) => f.file)).toEqual(['visible.md']);
+ // Naming the hidden file bypasses the walk — linter-CLI convention.
+ const explicit = await run({ targetPath: join(root, '.ok/skills/pack/SKILL.md') });
+ expect(explicit.files.map((f) => f.file)).toEqual(['.ok/skills/pack/SKILL.md']);
+ });
+
test('lints every in-scope doc and counts problems', async () => {
write('a.md', '# A\n\ntext with a\ttab\n');
write('b.md', '# B\n\n#bad heading\n');
diff --git a/packages/cli/src/content/lint-runner.ts b/packages/cli/src/content/lint-runner.ts
index 6266170f..db6f3475 100644
--- a/packages/cli/src/content/lint-runner.ts
+++ b/packages/cli/src/content/lint-runner.ts
@@ -103,6 +103,11 @@ export async function runLint(opts: RunLintOptions): Promise {
return;
}
for (const entry of entries) {
+ // Hidden segments (.ok/, .git/, .obsidian/, dotfiles) are OK state, not
+ // authored content — same skip the server audit applies. An explicitly
+ // named hidden file (`ok lint .ok/foo.md`) still lints: the file-scope
+ // branch above bypasses the walk, matching linter-CLI convention.
+ if (entry.name.startsWith('.')) continue;
const full = join(absDir, entry.name);
const rel = relative(contentDir, full);
if (entry.isDirectory()) {
diff --git a/packages/core/src/handoff/index.ts b/packages/core/src/handoff/index.ts
index a585bbcb..5c65c0ab 100644
--- a/packages/core/src/handoff/index.ts
+++ b/packages/core/src/handoff/index.ts
@@ -20,9 +20,11 @@ export {
composeEmptySpacePrompt,
composeFilePrompt,
composeFolderPrompt,
+ composeLintFixPrompt,
composeSelectionPrompt,
composeSkillPrompt,
composeTerminalBareLaunchPrompt,
+ type LintFixPromptInput,
OK_PROJECT_SKILL_POINTER,
OK_TERMINAL_SURFACE_PREAMBLE,
type PromptTransport,
diff --git a/packages/core/src/handoff/prompt-composer.test.ts b/packages/core/src/handoff/prompt-composer.test.ts
index 5e1ad52b..59d3794a 100644
--- a/packages/core/src/handoff/prompt-composer.test.ts
+++ b/packages/core/src/handoff/prompt-composer.test.ts
@@ -10,6 +10,7 @@ import {
composeEmptySpacePrompt,
composeFilePrompt,
composeFolderPrompt,
+ composeLintFixPrompt,
composeSelectionPrompt,
composeSkillPrompt,
composeTerminalBareLaunchPrompt,
@@ -1590,3 +1591,79 @@ test('an anchor-kind selection stays locus on BOTH transports — the passage is
expect(out).not.toContain(passage);
}
});
+
+// --- composeLintFixPrompt ----------------------------------------------------
+
+test('composeLintFixPrompt names the doc, locates the rule, and quotes the material', () => {
+ const prompt = composeLintFixPrompt({
+ relativePath: 'guides/setup.md',
+ source: 'markdownlint',
+ code: 'MD010',
+ ruleAlias: 'no-hard-tabs',
+ message: 'Hard tabs',
+ line: 12,
+ column: 3,
+ lineText: '\tindented with a tab',
+ });
+ expect(prompt).toContain('Fix this lint problem in @guides/setup.md using OpenKnowledge.');
+ expect(prompt).toContain('Problem: markdownlint/MD010 (no-hard-tabs) at line 12, column 3:');
+ expect(prompt).toContain('> Hard tabs');
+ expect(prompt).toContain('Line 12 reads:');
+ expect(prompt).toContain('\tindented with a tab');
+ expect(prompt).toContain(
+ 'Edit @guides/setup.md via the OpenKnowledge MCP server, then re-lint it to confirm the problem is resolved.',
+ );
+});
+
+test('composeLintFixPrompt omits the alias parens and line block when absent', () => {
+ const prompt = composeLintFixPrompt({
+ relativePath: 'notes.md',
+ source: 'markdownlint',
+ code: 'MD025',
+ message: 'Multiple top-level headings',
+ line: 5,
+ column: 1,
+ });
+ expect(prompt).toContain('Problem: markdownlint/MD025 at line 5, column 1:');
+ expect(prompt).not.toContain('(');
+ expect(prompt).not.toContain('reads:');
+});
+
+test('composeLintFixPrompt blockquotes every line of a multi-line message', () => {
+ const prompt = composeLintFixPrompt({
+ relativePath: 'notes.md',
+ source: 'markdownlint',
+ code: 'MD013',
+ message: 'Line too long\nExpected: 80; Actual: 120',
+ line: 2,
+ column: 81,
+ });
+ expect(prompt).toContain('> Line too long\n> Expected: 80; Actual: 120');
+});
+
+test('composeLintFixPrompt fences the offending line past its own backtick runs', () => {
+ const prompt = composeLintFixPrompt({
+ relativePath: 'notes.md',
+ source: 'markdownlint',
+ code: 'MD038',
+ message: 'Spaces inside code span',
+ line: 3,
+ column: 1,
+ lineText: 'a ``` b `code` c',
+ });
+ // The wrapping fence must outlast the 3-backtick run inside the line.
+ expect(prompt).toContain('````\na ``` b `code` c\n````');
+});
+
+test('composeLintFixPrompt sanitizes the @-mention path like the selection composer', () => {
+ const prompt = composeLintFixPrompt({
+ relativePath: 'my docs/note.md',
+ source: 'markdownlint',
+ code: 'MD010',
+ message: 'Hard tabs',
+ line: 1,
+ column: 1,
+ });
+ // Whitespace + zero-width bytes collapse so the mention stays one token.
+ expect(prompt).toContain('@my_docs/note_.md');
+});
diff --git a/packages/core/src/handoff/prompt-composer.ts b/packages/core/src/handoff/prompt-composer.ts
index a54a7ccd..df59965d 100644
--- a/packages/core/src/handoff/prompt-composer.ts
+++ b/packages/core/src/handoff/prompt-composer.ts
@@ -1197,3 +1197,59 @@ export function composeAskProjectPrompt(
transport,
});
}
+
+/** Inputs to `composeLintFixPrompt` — one lint diagnostic, fully located. */
+export interface LintFixPromptInput {
+ /** Doc's path relative to the OK content dir, forward-slash normalized with
+ * the `.md` suffix. Sanitized before interpolation as an `@`-mention. */
+ readonly relativePath: string;
+ /** Diagnostic origin plugin id, e.g. `markdownlint`. */
+ readonly source: string;
+ /** Rule code, e.g. `MD010`. */
+ readonly code: string;
+ /** Primary rule alias, e.g. `no-hard-tabs`; omitted when unknown. */
+ readonly ruleAlias?: string;
+ /** The diagnostic message. May quote doc content — blockquoted so the agent
+ * reads it as material, not instructions. */
+ readonly message: string;
+ /** 1-based line of the problem in the doc source (frontmatter included). */
+ readonly line: number;
+ /** 1-based column. */
+ readonly column: number;
+ /** The offending source line, verbatim; omitted when unavailable. Fenced
+ * like a selection passage (fence outlasts any backtick run inside). */
+ readonly lineText?: string;
+}
+
+/**
+ * Lint-fix composer for the Problems panel's "Ask AI" affordance. Produces a
+ * terminal paste (no URL budget, no autoOpen trailer — the user is already
+ * looking at the doc) that names the doc as an `@`-mention, locates one
+ * diagnostic precisely, and directs the agent to fix it via OK MCP and
+ * re-lint. Quoting posture matches `composeSelectionPrompt`: doc-derived text
+ * (message, offending line) travels blockquoted / fenced as material.
+ */
+export function composeLintFixPrompt(input: LintFixPromptInput): string {
+ const safePath = sanitizePathForAtMention(input.relativePath);
+ const rule =
+ input.ruleAlias === undefined
+ ? `${input.source}/${input.code}`
+ : `${input.source}/${input.code} (${input.ruleAlias})`;
+ const lines: string[] = [
+ `Fix this lint problem in @${safePath} using OpenKnowledge.`,
+ '',
+ `Problem: ${rule} at line ${input.line}, column ${input.column}:`,
+ '',
+ blockquote(input.message.trim()),
+ ];
+ const lineText = input.lineText ?? '';
+ if (lineText.trim() !== '') {
+ const fence = fenceFor(lineText);
+ lines.push('', `Line ${input.line} reads:`, '', fence, lineText, fence);
+ }
+ lines.push(
+ '',
+ `Edit @${safePath} via the OpenKnowledge MCP server, then re-lint it to confirm the problem is resolved.`,
+ );
+ return lines.join('\n');
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 27316654..a40ca50b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -379,6 +379,7 @@ export {
composeEmptySpacePrompt,
composeFilePrompt,
composeFolderPrompt,
+ composeLintFixPrompt,
composeSelectionPrompt,
composeSkillPrompt,
composeTerminalBareLaunchPrompt,
@@ -391,6 +392,7 @@ export {
type InstallState,
type IpcChannelReason,
type IpcChannelWithUrn,
+ type LintFixPromptInput,
lookupUrnInRegistry,
OK_GATED_TOOL_NAMES,
OK_PROJECT_SKILL_POINTER,
diff --git a/packages/server/src/api-extension.ts b/packages/server/src/api-extension.ts
index 14eb61c3..59058c72 100644
--- a/packages/server/src/api-extension.ts
+++ b/packages/server/src/api-extension.ts
@@ -17845,6 +17845,20 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
{ handler: 'markdownlint-config', method: 'POST' },
);
+ /**
+ * Live CRDT source for a currently-loaded doc, else null. Lint reads must
+ * see the same bytes the editor and `/api/lint/fix` operate on: the disk
+ * file lags the CRDT behind the persistence debounce, and if a flush is
+ * ever lost the two diverge durably — a disk-only audit then reports
+ * problems the live doc no longer has and the Fix all sweep no-ops forever.
+ * Unloaded docs have no live copy; disk is authoritative for them.
+ */
+ const liveLintSourceFor = (docRelPath: string): string | null => {
+ const docName = docRelPath.replace(/\.(md|mdx)$/i, '');
+ const doc = hocuspocus.documents.get(docName);
+ return doc === undefined ? null : doc.getText('source').toString();
+ };
+
const handleLintDoc = withValidation(
EmptyRequestSchema,
async (req, res) => {
@@ -17870,6 +17884,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
contentDir,
baseConfig,
docRelPath,
+ liveSourceFor: liveLintSourceFor,
});
successResponse(res, 200, LintDocResultSchema, result, { handler: 'lint' });
} catch (e) {
@@ -17910,6 +17925,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
contentDir,
baseConfig,
targetPath: target,
+ liveSourceFor: liveLintSourceFor,
});
successResponse(res, 200, LintAuditResponseSchema, result, { handler: 'lint-audit' });
} catch (e) {
@@ -17930,8 +17946,28 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
if (effectiveDocName === null) return;
const resolvedDocName = resolveAlias(effectiveDocName);
- const { agentId, agentName, colorSeed, clientName, clientVersion, label } =
- extractAgentIdentity(body);
+ // A deterministic auto-fix is attributed to whoever asked for it: an
+ // agent caller (MCP `lint fix:true` always sends agentId) keeps agent
+ // attribution; a bare UI body lands as the principal — the human
+ // clicked the button, no agent was involved. `principal-*` ids are
+ // filtered at the presence-broadcaster boundary, so the structurally
+ // enforced setPresence/touchMode shape below stays valid without
+ // badging the user as their own agent. No principal loaded → neutral
+ // anonymous writer with no contributor record (mirrors rename).
+ const actor = extractActorIdentity(
+ body as unknown as Record,
+ getPrincipal,
+ );
+ if (actor.kind === 'invalid-summary') {
+ errorResponse(res, 400, 'urn:ok:error:invalid-request', 'Summary must be a string.', {
+ handler: 'lint-fix',
+ });
+ return;
+ }
+ const agentId = actor.kind === 'anonymous' ? 'principal-anonymous' : actor.writerId;
+ const agentName = actor.kind === 'anonymous' ? 'Anonymous' : actor.displayName;
+ const colorSeed = actor.kind === 'anonymous' ? agentId : actor.colorSeed;
+ const clientName = actor.kind === 'agent' ? actor.clientName : undefined;
if (isSystemDoc(resolvedDocName) || isConfigDoc(resolvedDocName)) {
errorResponse(
@@ -17959,7 +17995,7 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
assertNoSymlinkEscape(resolve(contentDir, docRelPath), contentDir);
const baseConfig = getLinterBaseConfig?.() ?? DEFAULT_LINTER_CONFIG;
- const { stored: storedSummary } = summaryResponseFields(normalizeSummary(body.summary));
+ const { stored: storedSummary } = summaryResponseFields(actor.summary);
const session = await sessionManager.getSession(resolvedDocName, agentId, {
displayName: agentName,
colorSeed,
@@ -18010,15 +18046,23 @@ export function createApiExtension(options: ApiExtensionOptions): Extension {
);
}, session.origin);
- recordContributor(
- resolvedDocName,
- agentId,
- agentName,
- colorSeed,
- undefined,
- buildAgentActor({ clientName, clientVersion, label }),
- storedSummary,
- );
+ if (actor.kind !== 'anonymous') {
+ recordContributor(
+ resolvedDocName,
+ agentId,
+ agentName,
+ colorSeed,
+ undefined,
+ actor.kind === 'agent'
+ ? buildAgentActor({
+ clientName: actor.clientName,
+ clientVersion: actor.clientVersion,
+ label: actor.label,
+ })
+ : actor.actor,
+ storedSummary,
+ );
+ }
} finally {
agentPresenceBroadcaster?.touchMode(agentId, 'idle');
}
diff --git a/packages/server/src/lint/audit.test.ts b/packages/server/src/lint/audit.test.ts
index cf923fa5..7b35eb5e 100644
--- a/packages/server/src/lint/audit.test.ts
+++ b/packages/server/src/lint/audit.test.ts
@@ -135,6 +135,56 @@ describe('auditProject', () => {
]);
});
+ test('skips hidden path segments — docs there are not addressable to fix or navigate', async () => {
+ // A dirty SKILL.md under .ok/ used to surface in the audit and then fail
+ // the project Fix all sweep: the fix endpoint refuses docNames with
+ // hidden segments (validateDocName), so the audit must not admit them.
+ write('.ok/skills/pack/SKILL.md', DOC_WITH_TAB);
+ write('.hidden-notes.md', DOC_WITH_TAB);
+ write('visible.md', DOC_WITH_TAB);
+ const audit = await auditProject({
+ projectDir: root,
+ contentDir: root,
+ baseConfig: base,
+ });
+ expect(audit.files.map((f) => f.file)).toEqual(['visible.md']);
+ });
+
+ test('refuses a targetPath under a hidden segment', async () => {
+ write('.ok/skills/pack/SKILL.md', DOC_WITH_TAB);
+ const audit = await auditProject({
+ projectDir: root,
+ contentDir: root,
+ baseConfig: base,
+ targetPath: '.ok/skills',
+ });
+ expect(audit.files).toEqual([]);
+ expect(audit.warnings).toEqual([
+ expect.stringContaining('refusing audit scope under a hidden path segment'),
+ ]);
+ });
+
+ test('liveSourceFor overrides disk for loaded docs; null falls back to disk', async () => {
+ // The disk/CRDT divergence wedge: disk still carries the violation while
+ // the live doc is already clean. The audit must lint what the editor and
+ // the fix endpoint see, or a Fix all sweep no-ops forever against
+ // problems only the stale disk copy has.
+ write('loaded-clean.md', DOC_WITH_TAB);
+ write('loaded-dirty.md', CLEAN_DOC);
+ write('unloaded.md', DOC_WITH_TAB);
+ const audit = await auditProject({
+ projectDir: root,
+ contentDir: root,
+ baseConfig: base,
+ liveSourceFor: (rel) => {
+ if (rel === 'loaded-clean.md') return CLEAN_DOC;
+ if (rel === 'loaded-dirty.md') return DOC_WITH_TAB;
+ return null;
+ },
+ });
+ expect(audit.files.map((f) => f.file).sort()).toEqual(['loaded-dirty.md', 'unloaded.md']);
+ });
+
test('returns nothing when linting is disabled', async () => {
write('dirty.md', DOC_WITH_TAB);
const audit = await auditProject({
diff --git a/packages/server/src/lint/audit.ts b/packages/server/src/lint/audit.ts
index c29e3938..bd1e5c25 100644
--- a/packages/server/src/lint/audit.ts
+++ b/packages/server/src/lint/audit.ts
@@ -38,18 +38,34 @@ export interface AuditOptions {
projectDir: string;
contentDir: string;
baseConfig: LinterConfig;
+ /**
+ * Live CRDT source for a currently-loaded doc, else null. When provided,
+ * loaded docs lint against the bytes the editor and the fix endpoint see —
+ * not the disk file, which lags the CRDT behind the persistence debounce
+ * (and lies outright if a flush was lost). Without this overlay a
+ * disk/CRDT divergence wedges the Fix all sweep: the audit keeps reporting
+ * problems the live doc no longer has, and every "fix" is a clean no-op.
+ */
+ liveSourceFor?: (docRelPath: string) => string | null;
}
-/** Lint a single document (read from disk) with its per-doc effective config. */
+/** Lint a single document (live CRDT source when loaded, else disk) with its
+ * per-doc effective config. */
export async function lintDoc(
opts: AuditOptions & { docRelPath: string; onConfigProblem?: (problem: string) => void },
): Promise {
- const { contentDir, baseConfig, docRelPath, onConfigProblem } = opts;
- // Symlinks inside the content dir are supported (realpath-based identity),
- // but an escape must be refused before the read: lint diagnostics echo
- // source text, so linting an escaped symlink is an arbitrary-file read.
- const canonical = resolveCanonicalDocPath(join(contentDir, docRelPath), contentDir);
- const text = readFileSync(canonical, 'utf-8');
+ const { contentDir, baseConfig, docRelPath, onConfigProblem, liveSourceFor } = opts;
+ const live = liveSourceFor?.(docRelPath) ?? null;
+ let text: string;
+ if (live !== null) {
+ text = live;
+ } else {
+ // Symlinks inside the content dir are supported (realpath-based identity),
+ // but an escape must be refused before the read: lint diagnostics echo
+ // source text, so linting an escaped symlink is an arbitrary-file read.
+ const canonical = resolveCanonicalDocPath(join(contentDir, docRelPath), contentDir);
+ text = readFileSync(canonical, 'utf-8');
+ }
const cfg = resolveEffectiveLinterConfig(contentDir, baseConfig, {
docName: docRelPath,
onProblem: onConfigProblem,
@@ -105,6 +121,13 @@ export async function auditProject(
warnings.push(`refusing audit scope outside the content directory: ${targetPath ?? ''}`);
return { files: [], fileCount: 0, errorCount: 0, warningCount: 0, warnings };
}
+ // Same hidden-segment rule the walk applies per entry: a scope like
+ // `.ok/skills` names docs the write path cannot address, so auditing it
+ // would produce unfixable, unnavigable rows.
+ if (scopeRel.split('/').some((segment) => segment.startsWith('.'))) {
+ warnings.push(`refusing audit scope under a hidden path segment: ${targetPath ?? ''}`);
+ return { files: [], fileCount: 0, errorCount: 0, warningCount: 0, warnings };
+ }
try {
if (!isWithinContentDir(realpathSync(scope.path), realpathSync(contentDir))) {
warnings.push(`symlink-escape: audit scope resolves outside the content directory`);
@@ -128,6 +151,12 @@ export async function auditProject(
return;
}
for (const entry of entries) {
+ // Hidden segments (.ok/, .git/, .obsidian/, dotfiles) are not
+ // addressable as docNames (see `validateDocName`), so a diagnostic here
+ // could be neither navigated to nor auto-fixed — the fix endpoint
+ // rejects the docName outright. Skip them so the audit's scope stays
+ // symmetric with the write path's addressability (precedent #55).
+ if (entry.name.startsWith('.')) continue;
const full = join(absDir, entry.name);
const rel = relative(contentDir, full);
if (entry.isDirectory()) {
@@ -162,6 +191,7 @@ export async function auditProject(
baseConfig,
docRelPath: rel,
onConfigProblem,
+ liveSourceFor: opts.liveSourceFor,
});
} catch (e) {
warnings.push(`could not lint ${rel}: ${errMsg(e)}`);