From 442818027af47fc7e3bc00cbf39f4b9819f4d092 Mon Sep 17 00:00:00 2001 From: serafin-garcia Date: Fri, 17 Jul 2026 14:48:48 -0700 Subject: [PATCH] Lint Fix all + Ask AI actions in the Problems panel (#2710) * feat(open-knowledge): lint Fix all and Ask AI actions in Problems panel Problems panel gains a Fix all button in both tabs: doc scope applies every fixable diagnostic client-side in one undoable transaction (applyLintFixes is now overlap-safe, mirroring upstream applyFixes skip semantics); project scope sweeps fixable files sequentially through POST /api/lint/fix with progress, partial-failure toasts, and an audit refresh. handleLintFix now resolves identity via extractActorIdentity: MCP agent callers keep agent attribution, bare UI bodies land as the principal (no phantom presence badge), and the anonymous edge proceeds without a contributor record. Desktop rows also get Ask AI: composeLintFixPrompt grounds one diagnostic (doc mention, rule, line, column, message, offending line) and types it into the live agent terminal via requestActiveTerminalInput, or launches Claude when none is open. Web hosts hide the button. * feat(open-knowledge): show fixable count on the Fix all button Both Problems scopes label the button with how many problems carry a deterministic auto-fix, e.g. Fix all (3), so the click's effect is sized before it happens. Counts problems, not files, in both tabs. * fix(open-knowledge): audit skips hidden paths; fix-all toast names the failure The project lint audit walked into hidden directories, so a dirty .ok/skills/*/SKILL.md surfaced as an unfixable, unnavigable row and made the Fix all sweep report a failure: the fix endpoint refuses docNames with hidden segments. The walk and the scoped targetPath now apply the same hidden-segment rule as validateDocName, keeping audit scope symmetric with write addressability (precedent #55). The partial-failure toast also names the first failing file and the server reason instead of only a count. * fix(open-knowledge): ok lint walk skips hidden paths like the audit Same .ok exclusion the project audit gained: the CLI sweep no longer walks into hidden directories. An explicitly named hidden file still lints. * fix(open-knowledge): lint audit reads live CRDT source for loaded docs The audit linted disk files while /api/lint/fix lints the live CRDT. When the two diverge (persistence debounce, or a historically lost flush), the project Fix all sweep wedges: the audit keeps reporting problems the live doc no longer has, every fix is a clean no-op, and the count never moves. lintDoc and auditProject now accept a liveSourceFor overlay; the lint and audit handlers feed it from the loaded-documents map, so lint reads agree with the editor and the fix endpoint by construction. Disk remains authoritative for unloaded docs. * fix(open-knowledge): doc-scope Problems row uses full-width message, actions bottom-right The Fix / Ask AI buttons sat in the row's horizontal flex, so their (invisible-until-hover) width squeezed the diagnostic message even at rest. Pull them out of flow into an absolute bottom-right overlay revealed on hover/focus; the message now spans the full row and wraps like the project scope. A bg-muted matching the row hover cleanly occludes the subline. * fix(open-knowledge): guard project sweep against unmount; a11y polish Review follow-ups on the Problems panel: - The project Fix all sweep is fire-and-forget; guard it with a mounted ref so it stops posting fixes (and skips setState) if the panel unmounts mid-sweep, and wrap the trailing re-audit in try/catch so a failure surfaces the Try-again state instead of an unhandled rejection. - Drop the static Fix all aria-label during a sweep so the visible Fixing N/M progress is the accessible name instead of a frozen count. - Add aria-hidden to the RefreshCw icon (matches every other icon in the file). - Test the null-errorDetail toast path (filename only, no dash suffix). * fix(open-knowledge): second lint-review round + rebase test resolution Re-review polish on the Problems panel: - fixLintDoc logs a schema-drift console.warn on parse failure, matching the sibling fetchLintConfig/runLintAudit diagnostics. - loadAudit guards its post-await setAudit with the mounted ref, so the success path matches the sweep's already-guarded catch. - The action overlay's transition-opacity gets motion-reduce:transition-none. - A visually-hidden role=status live region announces sweep progress to screen readers (the disabled button can't be focused), rendered only while sweeping so it never collides with the loading skeleton's status role. Also reconciles prompt-composer.test.ts after rebasing onto main's terminal transport work: both the new terminal-transport tests and the composeLintFixPrompt tests are retained. GitOrigin-RevId: 79cd346ff4158b6c9284b68ca094403d7fdaa399 --- .changeset/lint-fix-all-and-ask-ai.md | 5 + .../app/src/components/DocPanel.dom.test.tsx | 47 ++- packages/app/src/components/DocPanel.tsx | 23 +- .../src/components/ProblemsPanel.dom.test.tsx | 177 +++++++++++ packages/app/src/components/ProblemsPanel.tsx | 282 +++++++++++++++--- .../handoff/compose-lint-fix-prompt.test.ts | 39 +++ .../handoff/compose-lint-fix-prompt.ts | 36 +++ .../app/src/editor/apply-lint-fix.test.ts | 39 +++ packages/app/src/editor/apply-lint-fix.ts | 21 +- packages/app/src/editor/lint-config-client.ts | 39 +++ packages/app/src/locales/en/messages.json | 6 + packages/app/src/locales/en/messages.po | 31 ++ packages/app/src/locales/pseudo/messages.json | 6 + packages/app/src/locales/pseudo/messages.po | 31 ++ .../app/tests/integration/lint-http.test.ts | 36 +++ packages/cli/src/content/lint-runner.test.ts | 10 + packages/cli/src/content/lint-runner.ts | 5 + packages/core/src/handoff/index.ts | 2 + .../core/src/handoff/prompt-composer.test.ts | 77 +++++ packages/core/src/handoff/prompt-composer.ts | 56 ++++ packages/core/src/index.ts | 2 + packages/server/src/api-extension.ts | 68 ++++- packages/server/src/lint/audit.test.ts | 50 ++++ packages/server/src/lint/audit.ts | 44 ++- 24 files changed, 1062 insertions(+), 70 deletions(-) create mode 100644 .changeset/lint-fix-all-and-ask-ai.md create mode 100644 packages/app/src/components/handoff/compose-lint-fix-prompt.test.ts create mode 100644 packages/app/src/components/handoff/compose-lint-fix-prompt.ts diff --git a/.changeset/lint-fix-all-and-ask-ai.md b/.changeset/lint-fix-all-and-ask-ai.md new file mode 100644 index 00000000..98bd3e2c --- /dev/null +++ b/.changeset/lint-fix-all-and-ask-ai.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +The Problems panel gains two new lint actions. A "Fix all" button applies every auto-fixable problem at once — in the "This doc" tab it fixes the open document instantly (undoable, attributed to you), and in the "Project" tab it sweeps every fixable file and refreshes the audit, reporting any files it could not fix. On desktop, each problem row also offers "Ask AI": it composes a grounded fix prompt (document, rule, line, message, and the offending text) and types it into your running agent terminal for review — or launches Claude with it if no terminal is open. Deterministic fixes triggered from the UI are now attributed to you (the principal) rather than to an agent; fixes requested by agents over MCP keep agent attribution. diff --git a/packages/app/src/components/DocPanel.dom.test.tsx b/packages/app/src/components/DocPanel.dom.test.tsx index 0c7833a0..46c54595 100644 --- a/packages/app/src/components/DocPanel.dom.test.tsx +++ b/packages/app/src/components/DocPanel.dom.test.tsx @@ -50,8 +50,9 @@ mock.module('@/lib/single-file-mode', () => ({ useSingleFileMode: () => singleFi // Lint plumbing — DocPanel computes live diagnostics to drive the Problems // badge. Stub the source so the test controls the count without a provider/fetch. let diagnosticsValue: Array<{ severity: string }> = []; +let activeProviderValue: unknown = null; mock.module('@/editor/DocumentContext', () => ({ - useDocumentContext: () => ({ activeProvider: null, activeDocName: 'notes' }), + useDocumentContext: () => ({ activeProvider: activeProviderValue, activeDocName: 'notes' }), })); mock.module('@/editor/lint-config-client', () => ({ useDocLintConfig: () => ({ data: null }), @@ -59,6 +60,11 @@ mock.module('@/editor/lint-config-client', () => ({ mock.module('@/editor/useDocDiagnostics', () => ({ useDocDiagnostics: () => diagnosticsValue, })); +// Terminal availability drives the Ask-AI gate — null mirrors the web host. +let terminalLaunchValue: unknown = null; +mock.module('@/components/handoff/TerminalLaunchContext', () => ({ + useTerminalLaunch: () => terminalLaunchValue, +})); // Stub the heavy panel children so the test stays focused on tab visibility. mock.module('@/components/OutlinePanel', () => ({ @@ -70,8 +76,12 @@ mock.module('@/components/LinksPanel', () => ({ mock.module('@/components/TimelinePanel', () => ({ TimelineContent: () =>
, })); +let lastProblemsProps: Record | null = null; mock.module('@/components/ProblemsPanel', () => ({ - ProblemsPanel: () =>
, + ProblemsPanel: (props: Record) => { + lastProblemsProps = props; + return
; + }, })); const { DocPanel } = await import('./DocPanel'); @@ -96,6 +106,9 @@ afterEach(() => { cleanup(); singleFileValue = false; diagnosticsValue = []; + activeProviderValue = null; + terminalLaunchValue = null; + lastProblemsProps = null; }); describe('DocPanel — tab gating', () => { @@ -121,6 +134,36 @@ describe('DocPanel — tab gating', () => { }); }); +describe('DocPanel — Problems fix/ask-ai wiring', () => { + const fakeProvider = { document: { getText: () => ({ toString: () => '' }) } }; + + test('web host (no terminal launch context) withholds onAskAi but keeps the fix handlers', () => { + activeProviderValue = fakeProvider; + terminalLaunchValue = null; + renderPanel('problems'); + expect(lastProblemsProps?.onAskAi).toBeUndefined(); + expect(typeof lastProblemsProps?.onFix).toBe('function'); + expect(typeof lastProblemsProps?.onFixAll).toBe('function'); + }); + + test('desktop host (terminal launch available) passes onAskAi alongside the fix handlers', () => { + activeProviderValue = fakeProvider; + terminalLaunchValue = { launchInTerminal: () => {}, installedClis: {} }; + renderPanel('problems'); + expect(typeof lastProblemsProps?.onAskAi).toBe('function'); + expect(typeof lastProblemsProps?.onFixAll).toBe('function'); + }); + + test('without a matching provider every fix/ask handler is withheld', () => { + activeProviderValue = null; + terminalLaunchValue = { launchInTerminal: () => {}, installedClis: {} }; + renderPanel('problems'); + expect(lastProblemsProps?.onFix).toBeUndefined(); + expect(lastProblemsProps?.onFixAll).toBeUndefined(); + expect(lastProblemsProps?.onAskAi).toBeUndefined(); + }); +}); + describe('DocPanel — Problems badge', () => { test('no badge when there are no diagnostics', () => { diagnosticsValue = []; diff --git a/packages/app/src/components/DocPanel.tsx b/packages/app/src/components/DocPanel.tsx index 80e73deb..5e2e2bde 100644 --- a/packages/app/src/components/DocPanel.tsx +++ b/packages/app/src/components/DocPanel.tsx @@ -3,6 +3,9 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { AlertTriangle, Clock, Link2, ListTree, Network } from 'lucide-react'; import { lazy, Suspense, useState } from 'react'; import type { DiffLayout } from '@/components/DiffView'; +import { composeLintFixTerminalPaste } from '@/components/handoff/compose-lint-fix-prompt'; +import { useTerminalLaunch } from '@/components/handoff/TerminalLaunchContext'; +import { requestActiveTerminalInput } from '@/components/handoff/terminal-input-events'; import { LinksPanel } from '@/components/LinksPanel'; import { OutlinePanel } from '@/components/OutlinePanel'; import { ProblemsPanel } from '@/components/ProblemsPanel'; @@ -10,7 +13,7 @@ import { TimelineContent } from '@/components/TimelinePanel'; import { Badge } from '@/components/ui/badge'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { applyLintFixes } from '@/editor/apply-lint-fix'; +import { applyLintFixes, collectFixes } from '@/editor/apply-lint-fix'; import { useDocumentContext } from '@/editor/DocumentContext'; import { useDocLintConfig } from '@/editor/lint-config-client'; import { useDocDiagnostics } from '@/editor/useDocDiagnostics'; @@ -95,6 +98,22 @@ export function DocPanel({ applyLintFixes(lintProvider, diagnostic.fixes); } }; + const handleFixAll = () => { + if (lintProvider !== null) { + applyLintFixes(lintProvider, collectFixes(diagnostics)); + } + }; + // Hand one diagnostic to the docked terminal's agent as a grounded prompt + // (live TUI reuse or a fresh Claude launch — the host decides). Desktop-only: + // on web nothing subscribes to the terminal-input event, so the button is + // withheld entirely by not passing `onAskAi`. + const terminalLaunch = useTerminalLaunch(); + const handleAskAi = (diagnostic: (typeof diagnostics)[number]) => { + if (lintProvider === null) return; + const source = lintProvider.document.getText('source').toString(); + const lineText = source.split('\n')[diagnostic.range.start.line]; + requestActiveTerminalInput(composeLintFixTerminalPaste(docName, diagnostic, lineText)); + }; // Single-file `ok ` keeps only the Outline + Problems tabs. Links/Graph // need a multi-doc knowledge base, and Timeline is git history — all empty or // inert for a lone git-off file; linting applies to any single file. Coerce a @@ -198,6 +217,8 @@ export function DocPanel({ docName={docName} diagnostics={diagnostics} onFix={lintProvider !== null ? handleFix : undefined} + onFixAll={lintProvider !== null ? handleFixAll : undefined} + onAskAi={lintProvider !== null && terminalLaunch !== null ? handleAskAi : undefined} /> )}
diff --git a/packages/app/src/components/ProblemsPanel.dom.test.tsx b/packages/app/src/components/ProblemsPanel.dom.test.tsx index 7a735484..c4e81c8d 100644 --- a/packages/app/src/components/ProblemsPanel.dom.test.tsx +++ b/packages/app/src/components/ProblemsPanel.dom.test.tsx @@ -32,7 +32,12 @@ mock.module('@lingui/react/macro', () => linguiMacroMock); let auditCalls = 0; let runLintAuditImpl: () => Promise = async () => null; +let fixLintDocCalls: string[] = []; +let fixLintDocImpl: (docName: string) => Promise<{ ok: boolean; errorDetail?: string | null }> = + async () => ({ ok: true }); +const toastError = mock((_message: string) => {}); +mock.module('sonner', () => ({ toast: { error: toastError } })); mock.module('@/editor/lint-config-client', () => ({ emitLintConfigChanged: () => {}, subscribeToLintConfigChanged: () => () => {}, @@ -40,6 +45,10 @@ mock.module('@/editor/lint-config-client', () => ({ auditCalls += 1; return runLintAuditImpl(); }, + fixLintDoc: (docName: string) => { + fixLintDocCalls.push(docName); + return fixLintDocImpl(docName); + }, useDocLintConfig: () => ({ data: null }), useProjectLintConfig: () => ({ data: null }), fetchEffectiveLintConfig: async () => null, @@ -76,6 +85,9 @@ function auditResult(over: Partial = {}): LintAuditResponse { beforeEach(() => { auditCalls = 0; runLintAuditImpl = async () => null; + fixLintDocCalls = []; + fixLintDocImpl = async () => ({ ok: true }); + toastError.mockClear(); }); afterEach(() => { @@ -126,6 +138,82 @@ describe('ProblemsPanel', () => { expect(screen.queryByRole('button', { name: /Fix markdownlint/ })).toBeNull(); }); + test('doc-scope Fix all renders when onFixAll is provided and calls it on click', () => { + const fixable = diag({ + code: 'MD010', + fixes: [ + { + range: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } }, + newText: ' ', + }, + ], + }); + const unfixable = diag({ line: 5, code: 'MD025', message: 'Multiple H1' }); + const onFixAll = mock(() => {}); + render( + , + ); + const button = screen.getByTestId('problems-fix-all') as HTMLButtonElement; + expect(button.disabled).toBe(false); + // The label sizes the click before it happens: only the fixable diagnostic + // counts, not the total problem count. + expect(button.textContent).toContain('(1)'); + button.click(); + expect(onFixAll).toHaveBeenCalledTimes(1); + }); + + test('Ask AI renders on fixable AND unfixable rows only when onAskAi is provided', () => { + const fixable = diag({ + line: 3, + code: 'MD010', + fixes: [ + { + range: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } }, + newText: ' ', + }, + ], + }); + const unfixable = diag({ line: 5, code: 'MD025', message: 'Multiple H1' }); + const onAskAi = mock(() => {}); + const { unmount } = render( + , + ); + const askButtons = screen.getAllByTestId('problems-ask-ai'); + // One per row — the unfixable row gets it too (AI matters most where no + // deterministic fix exists). + expect(askButtons).toHaveLength(2); + askButtons[1]?.click(); + expect(onAskAi).toHaveBeenCalledTimes(1); + expect((onAskAi.mock.calls[0] as unknown[])[0]).toMatchObject({ code: 'MD025' }); + unmount(); + + render(); + expect(screen.queryByTestId('problems-ask-ai')).toBeNull(); + }); + + test('doc-scope Fix all is disabled when no diagnostic is fixable', () => { + const onFixAll = mock(() => {}); + render( + , + ); + const disabledButton = screen.getByTestId('problems-fix-all') as HTMLButtonElement; + expect(disabledButton.disabled).toBe(true); + expect(disabledButton.textContent).toContain('(0)'); + }); + + test('doc-scope Fix all is absent without onFixAll or without diagnostics', () => { + const { unmount } = render(); + expect(screen.queryByTestId('problems-fix-all')).toBeNull(); + unmount(); + // Empty state renders no action row at all. + render( {})} />); + expect(screen.queryByTestId('problems-fix-all')).toBeNull(); + }); + test('renders a row per diagnostic, sorted by line', () => { const diagnostics = [ diag({ code: 'MD012', message: 'Multiple blanks', line: 9 }), @@ -332,6 +420,95 @@ describe('ProblemsPanel — project scope', () => { } }); + test('project Fix all sweeps only fixable files, re-audits, and stays quiet on success', async () => { + const fixableEdit = { + range: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } }, + newText: ' ', + }; + runLintAuditImpl = async () => + auditResult({ + files: [ + { file: 'a.md', diagnostics: [diag({ fixes: [fixableEdit] })] }, + { file: 'b.md', diagnostics: [diag({ code: 'MD025', message: 'Multiple H1' })] }, + { file: 'nested/c.md', diagnostics: [diag({ fixes: [fixableEdit] })] }, + ], + }); + render(); + fireEvent.click(screen.getByTestId('panel-scope-project')); + await waitFor(() => expect(screen.getByText('a.md')).toBeTruthy()); + + // Counts fixable problems across the audit (a.md + nested/c.md), not files + // and not the unfixable b.md diagnostic. + expect(screen.getByTestId('problems-fix-all').textContent).toContain('(2)'); + fireEvent.click(screen.getByTestId('problems-fix-all')); + // Only the two files carrying fixable diagnostics are swept, extension-less. + await waitFor(() => expect(fixLintDocCalls).toEqual(['a', 'nested/c'])); + // The sweep ends in a fresh audit fetch (initial activation + re-audit). + await waitFor(() => expect(auditCalls).toBe(2)); + expect(toastError).not.toHaveBeenCalled(); + }); + + test('project Fix all continues past per-file failures and surfaces one error toast', async () => { + const fixableEdit = { + range: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } }, + newText: ' ', + }; + runLintAuditImpl = async () => + auditResult({ + files: [ + { file: 'bad.md', diagnostics: [diag({ fixes: [fixableEdit] })] }, + { file: 'good.md', diagnostics: [diag({ fixes: [fixableEdit] })] }, + ], + }); + fixLintDocImpl = async (docName) => + docName === 'bad' ? { ok: false, errorDetail: 'conflict' } : { ok: true }; + render(); + fireEvent.click(screen.getByTestId('panel-scope-project')); + await waitFor(() => expect(screen.getByText('bad.md')).toBeTruthy()); + + fireEvent.click(screen.getByTestId('problems-fix-all')); + // The failed file does not stop the sweep. + await waitFor(() => expect(fixLintDocCalls).toEqual(['bad', 'good'])); + await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1)); + expect(String(toastError.mock.calls[0]?.[0])).toContain('1 of 2'); + // The toast names the first casualty and the server's reason. + const options = toastError.mock.calls[0]?.[1] as { description?: string } | undefined; + expect(options?.description).toBe('bad.md — conflict'); + }); + + test('failure toast omits the dash-suffix when the server gives no error detail', async () => { + const fixableEdit = { + range: { start: { line: 2, character: 0 }, end: { line: 2, character: 1 } }, + newText: ' ', + }; + runLintAuditImpl = async () => + auditResult({ files: [{ file: 'bad.md', diagnostics: [diag({ fixes: [fixableEdit] })] }] }); + fixLintDocImpl = async () => ({ ok: false, errorDetail: null }); + render(); + fireEvent.click(screen.getByTestId('panel-scope-project')); + await waitFor(() => expect(screen.getByText('bad.md')).toBeTruthy()); + + fireEvent.click(screen.getByTestId('problems-fix-all')); + await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1)); + // Null detail → just the filename, no ` — ` suffix. + const options = toastError.mock.calls[0]?.[1] as { description?: string } | undefined; + expect(options?.description).toBe('bad.md'); + }); + + test('project Fix all is disabled while loading and when nothing is fixable', async () => { + runLintAuditImpl = async () => + auditResult({ + files: [{ file: 'plain.md', diagnostics: [diag({ code: 'MD025' })] }], + }); + render(); + fireEvent.click(screen.getByTestId('panel-scope-project')); + await waitFor(() => expect(screen.getByText('plain.md')).toBeTruthy()); + // Loaded audit with zero fixable files keeps the button disabled. + expect((screen.getByTestId('problems-fix-all') as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(screen.getByTestId('problems-fix-all')); + expect(fixLintDocCalls).toEqual([]); + }); + test('doc-scope content and count stay doc-scoped while project scope is active', async () => { runLintAuditImpl = async () => auditResult({ files: [{ file: 'other.md', diagnostics: [diag({}), diag({ line: 9 })] }] }); diff --git a/packages/app/src/components/ProblemsPanel.tsx b/packages/app/src/components/ProblemsPanel.tsx index a17c9689..1ccc7647 100644 --- a/packages/app/src/components/ProblemsPanel.tsx +++ b/packages/app/src/components/ProblemsPanel.tsx @@ -1,8 +1,16 @@ // biome-ignore-all lint/plugin/no-raw-html-interactive-element: matches sibling OutlinePanel — positional list of + ); +} + /** * Lint diagnostics panel in the right-hand doc rail, scoped per-doc or * project-wide. Doc scope is live and mode-agnostic: `useDocDiagnostics` @@ -105,25 +153,95 @@ export function ProblemsPanel({ docName, diagnostics, onFix, + onFixAll, + onAskAi, }: { docName: string; diagnostics: LintDiagnostic[]; /** Apply a fixable diagnostic's auto-fix (this-doc scope only). When absent * (e.g. unit harness), fixable rows render no Fix button. */ onFix?: (diagnostic: LintDiagnostic) => void; + /** Apply every fixable diagnostic's auto-fix in this doc. When absent, the + * doc-scope Fix all button is not rendered. */ + onFixAll?: () => void; + /** Hand one diagnostic to the docked terminal's agent as a grounded fix + * prompt. Desktop-only — absent on web, where rows render no Ask AI button. + * Offered on every row, fixable or not: AI is most useful exactly where no + * deterministic fix exists. */ + onAskAi?: (diagnostic: LintDiagnostic) => void; }) { const { t } = useLingui(); const [scope, setScope] = useState('doc'); const [audit, setAudit] = useState({ status: 'idle' }); + const [projectFixing, setProjectFixing] = useState<{ done: number; total: number } | null>(null); + // Tracks whether the panel is still mounted so the async project sweep can + // stop early instead of posting fixes and setState-ing into an unmounted tree. + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); const sorted = [...diagnostics].sort(compareDiagnostics); async function loadAudit() { setAudit({ status: 'loading' }); const result = await runLintAudit(); + // Match the sweep's mounted guard: don't setState into an unmounted tree + // (loadAudit is awaited from the sweep, the refresh button, and scope + // activation). + if (!mountedRef.current) return; setAudit(result === null ? { status: 'failed' } : { status: 'loaded', result }); } + const projectFixableFiles = + audit.status === 'loaded' + ? audit.result.files.filter((file) => + file.diagnostics.some((d) => (d.fixes?.length ?? 0) > 0), + ) + : []; + + async function fixAllProjectFiles() { + if (projectFixing !== null || projectFixableFiles.length === 0) return; + setProjectFixing({ done: 0, total: projectFixableFiles.length }); + const failures: { file: string; detail: string | null }[] = []; + // Sequential on purpose: each fix lands through the agent-write spine and + // flushes disk + git — parallel posts contend on the git flush and multiply + // CRDT sessions. Failures (conflict, symlink refusal, capacity) don't stop + // the sweep; the re-audit below shows what remains. + for (const file of projectFixableFiles) { + const outcome = await fixLintDoc(filePathToDocName(file.file)); + // Bail if the panel unmounted mid-sweep (tab switch, agent-mode flip): the + // user walked away, so stop posting fixes and skip the state updates React + // would no-op anyway (mirrors the `cancelled` guard in useDocLintConfig). + if (!mountedRef.current) return; + if (!outcome.ok) failures.push({ file: file.file, detail: outcome.errorDetail }); + setProjectFixing((prev) => (prev === null ? prev : { ...prev, done: prev.done + 1 })); + } + setProjectFixing(null); + if (failures.length > 0) { + // Name the first casualty so the toast is actionable — "1 of 10 failed" + // alone gives the user nothing to act on. The detail is the server's + // problem+json title (untranslated, like the rule-write error toasts). + const first = failures[0]; + toast.error(t`Could not fix ${failures.length} of ${projectFixableFiles.length} files.`, { + description: + first === undefined + ? undefined + : `${first.file}${first.detail === null ? '' : ` — ${first.detail}`}`, + }); + } + // Guard the re-audit so a failure surfaces the "Try again" state instead of + // an unhandled rejection off the fire-and-forget `void fixAllProjectFiles()`. + try { + await loadAudit(); + } catch { + if (mountedRef.current) setAudit({ status: 'failed' }); + } + } + function handleScopeChange(next: PanelScope) { setScope(next); // Only the first activation fetches; afterwards the snapshot is served @@ -174,41 +292,77 @@ export function ProblemsPanel({ No problems found. ) : ( -
    - {sorted.map((diagnostic) => { - const displayLine = diagnostic.range.start.line + 1; - const fixable = onFix !== undefined && (diagnostic.fixes?.length ?? 0) > 0; - const flatId = `${diagnostic.source}/${diagnostic.code}`; - return ( -
  • - - {fixable ? ( - - ) : null} -
  • - ); - })} -
+ + + {fixable || onAskAi !== undefined ? ( + // Bottom-right, revealed on hover/focus. `bg-muted` + // matches the row's own hover background so it cleanly + // occludes the `source/code · line` subline underneath + // if a long id would otherwise run beneath it. +
+ {fixable ? ( + + ) : null} + {onAskAi !== undefined ? ( + + ) : null} +
+ ) : 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? 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/>." msgstr "Flag common markdown issues in the editor. Powered by <0>markdownlint<1/>." 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ē ḿōŕē ţō àďď? Ŵŕĩţē ţō <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/>." 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)}`);