diff --git a/biome.jsonc b/biome.jsonc index d00f79f8..ef1b2bb0 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -44,9 +44,13 @@ "useHookAtTopLevel": "error" }, "nursery": { - "noFloatingPromises": "error" + "noFloatingPromises": "error", + "noMisusedPromises": "error", + "noUnsafePlusOperands": "error", + "useExhaustiveSwitchCases": "error" }, "suspicious": { + "noArrayIndexKey": "error", "noImportCycles": "error" } } diff --git a/docs/development.md b/docs/development.md index d360ef52..779ab49c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -10,9 +10,11 @@ npm run format npm run check ``` -Run `npm run format` after edits and before `npm run check` so Biome formatting and import organization issues are fixed upfront. `npm run check` runs TypeScript type checking, unit tests, lint checks, Biome format and assist checks, CSS verification, and the production esbuild bundle. `npm run check:ci` uses the same checks with CI cache settings for TypeScript, tests, and ESLint. Use `npm run build` when you need to refresh Obsidian-loaded assets without running the full check. +Use this as the normal edit loop. `npm run format` applies Biome formatting and import organization; `npm run check` is the local preflight for type checking, tests, lint, format/assist checks, CSS checks, and the production bundle. -Formatting, import organization, general JavaScript/TypeScript/JSON linting, and JavaScript/TypeScript/CSS/JSON formatting are handled by Biome. The Biome lint configuration explicitly uses the recommended preset, with accessibility diagnostics downgraded to info while the Obsidian-specific UI semantics are reviewed rule by rule. Biome also owns floating Promise, import cycle, and Preact hook dependency/order checks. CSS is formatted by Biome but excluded from Biome linting so CSS lint policy stays centralized in Stylelint and the project CSS usage script. ESLint remains for unsafe-any TypeScript parser-service checks, Obsidian plugin API/text policy checks, and Codex Panel responsibility-boundary checks. +Use focused scripts for tight loops: `npm run typecheck`, `npm run test`, `npm run lint`, or `npm run build`. The `*:ci` variants match CI cache behavior. + +Biome owns formatting, import organization, and general JavaScript/TypeScript/JSON linting. Biome warnings fail `npm run lint` and `npm run check`; info diagnostics stay advisory, including accessibility while Obsidian-specific UI semantics are reviewed rule by rule. CSS lint policy stays in Stylelint and the CSS usage script. ESLint remains for typed unsafe-any checks, Obsidian plugin policy, and Codex Panel responsibility-boundary rules. ## Generated and Loaded Files diff --git a/scripts/api-baseline.mjs b/scripts/api-baseline.mjs index 2b1b3016..520cf5f9 100644 --- a/scripts/api-baseline.mjs +++ b/scripts/api-baseline.mjs @@ -67,7 +67,7 @@ function readMarkdownTableValues(markdown) { const header = splitMarkdownTableRow(lines[index]); if (!header) continue; const separator = splitMarkdownTableRow(lines[index + 1]); - if (!separator || !separator.every((cell) => /^:?-{3,}:?$/.test(cell))) continue; + if (!separator?.every((cell) => /^:?-{3,}:?$/.test(cell))) continue; const keyColumn = header.findIndex((cell) => normalizeTableHeader(cell) === "key"); const versionColumn = header.findIndex((cell) => normalizeTableHeader(cell) === "version"); diff --git a/scripts/check.mjs b/scripts/check.mjs index def2528d..08d56d9c 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -47,7 +47,7 @@ function lintCommands({ ciMode, namePrefix = "" }) { const eslintCacheArgs = ciMode ? "" : " --cache --cache-strategy content --cache-location node_modules/.cache/eslint/.eslintcache"; return [ - { name: `${namePrefix}biome`, command: "biome lint --no-errors-on-unmatched --diagnostic-level=error" }, + { name: `${namePrefix}biome`, command: "biome lint --no-errors-on-unmatched --diagnostic-level=warn --error-on-warnings" }, { name: `${namePrefix}eslint`, command: `eslint src tests scripts "*.config.ts" "*.config.mjs" --max-warnings=0${eslintCacheArgs}`, diff --git a/scripts/lint/check-css-usage.mjs b/scripts/lint/check-css-usage.mjs index dbfd711e..a453a895 100644 --- a/scripts/lint/check-css-usage.mjs +++ b/scripts/lint/check-css-usage.mjs @@ -90,7 +90,7 @@ async function readTexts(files) { function locationsInTexts(texts, needle) { const locations = []; for (const { file, text } of texts) { - let offset = text.indexOf(needle); + const offset = text.indexOf(needle); if (offset === -1) continue; const line = text.slice(0, offset).split("\n").length; locations.push(`${relative(file)}:${line}`); diff --git a/src/app-server/protocol/thread.ts b/src/app-server/protocol/thread.ts index aaee877b..7f350f64 100644 --- a/src/app-server/protocol/thread.ts +++ b/src/app-server/protocol/thread.ts @@ -39,7 +39,7 @@ function finiteTimestamp(value: number): number { } function recencyAtPatch(thread: ThreadRecord): { recencyAt: number | null } | Record { - if (!Object.prototype.hasOwnProperty.call(thread, "recencyAt")) return {}; + if (!Object.hasOwn(thread, "recencyAt")) return {}; const value = thread.recencyAt; return { recencyAt: typeof value === "number" && Number.isFinite(value) ? value : null }; } diff --git a/src/features/chat/app-server/inbound/notification-plan.ts b/src/features/chat/app-server/inbound/notification-plan.ts index adad3c6c..3feecc66 100644 --- a/src/features/chat/app-server/inbound/notification-plan.ts +++ b/src/features/chat/app-server/inbound/notification-plan.ts @@ -155,7 +155,7 @@ const STREAM_UPDATE_PLANNERS = { "item/reasoning/summaryPartAdded": (state, notification) => appendToolTextPlan(state, notification.params.itemId, notification.params.turnId, "reasoning", "", "reasoning"), "item/started": (_state, notification) => startedItemPlan(notification.params.item, notification.params.turnId), - "item/completed": (state, notification) => completedItemPlan(state, notification.params.item, notification.params.turnId), + "item/completed": (_state, notification) => completedItemPlan(notification.params.item, notification.params.turnId), "item/commandExecution/outputDelta": (_state, notification) => actionPlan({ type: "message-stream/item-output-appended", @@ -433,7 +433,7 @@ function startedItemPlan(item: TurnItem, turnId: string): ChatNotificationPlan { return streamItem ? actionPlan({ type: "message-stream/item-upserted", item: streamItem }) : EMPTY_PLAN; } -function completedItemPlan(state: ChatState, item: TurnItem, turnId: string): ChatNotificationPlan { +function completedItemPlan(item: TurnItem, turnId: string): ChatNotificationPlan { if (item.type === "userMessage") return EMPTY_PLAN; const streamItem = messageStreamItemFromTurnItem(item, turnId); if (!streamItem) return EMPTY_PLAN; diff --git a/src/features/chat/app-server/inbound/routing.ts b/src/features/chat/app-server/inbound/routing.ts index b7951e04..53268151 100644 --- a/src/features/chat/app-server/inbound/routing.ts +++ b/src/features/chat/app-server/inbound/routing.ts @@ -312,11 +312,11 @@ function serverRequestScope(request: ServerRequest): MessageScope { } function isServerRequest(message: ServerNotification | ServerRequest): message is ServerRequest { - return Object.prototype.hasOwnProperty.call(SERVER_REQUEST_SCOPE_EXTRACTORS, message.method); + return Object.hasOwn(SERVER_REQUEST_SCOPE_EXTRACTORS, message.method); } function isServerNotification(message: ServerNotification | ServerRequest): message is ServerNotification { - return Object.prototype.hasOwnProperty.call(SERVER_NOTIFICATION_SCOPE_EXTRACTORS, message.method); + return Object.hasOwn(SERVER_NOTIFICATION_SCOPE_EXTRACTORS, message.method); } function threadTurnRequestScope(request: { params: { threadId: string; turnId: string | null } }): MessageScope { diff --git a/src/features/chat/application/runtime/settings-actions.ts b/src/features/chat/application/runtime/settings-actions.ts index 95e09b3b..8443eed1 100644 --- a/src/features/chat/application/runtime/settings-actions.ts +++ b/src/features/chat/application/runtime/settings-actions.ts @@ -263,7 +263,7 @@ function threadSettingsValueEqual(left: unknown, right: unknown): boolean { const rightKeys = Object.keys(right); return ( leftKeys.length === rightKeys.length && - leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && threadSettingsValueEqual(left[key], right[key])) + leftKeys.every((key) => Object.hasOwn(right, key) && threadSettingsValueEqual(left[key], right[key])) ); } diff --git a/src/features/chat/ui/composer.tsx b/src/features/chat/ui/composer.tsx index 44bf049e..d84adca8 100644 --- a/src/features/chat/ui/composer.tsx +++ b/src/features/chat/ui/composer.tsx @@ -47,6 +47,8 @@ interface ComposerContextMeterViewModel { percent: string; } +const COMPOSER_CONTEXT_METER_CELL_IDS = ["context-0", "context-1", "context-2", "context-3"] as const; + export interface ComposerCallbacks { onInput: (value: string) => void; onUpdateSuggestions: () => void; @@ -341,14 +343,18 @@ function ComposerContextMeter({ context }: { context: ComposerMetaViewModel["con return ( - {context.cells.map((cell, index) => ( - - {cell.text} - - ))} + {COMPOSER_CONTEXT_METER_CELL_IDS.map((id, index) => { + const cell = context.cells[index]; + if (!cell) return null; + return ( + + {cell.text} + + ); + })} {context.percent} diff --git a/src/features/chat/ui/message-stream/detail.tsx b/src/features/chat/ui/message-stream/detail.tsx index 33cfc097..38cdc1d5 100644 --- a/src/features/chat/ui/message-stream/detail.tsx +++ b/src/features/chat/ui/message-stream/detail.tsx @@ -2,7 +2,7 @@ import type { ComponentChild as UiNode } from "preact"; import { useLayoutEffect, useRef } from "preact/hooks"; import { renderRawDiffLines } from "../../../../shared/diff/render"; -import { type DetailSection, type DetailView } from "../../presentation/message-stream/detail-view"; +import type { DetailSection, DetailView } from "../../presentation/message-stream/detail-view"; import type { MessageStreamDisclosureState } from "./context"; export interface DetailRenderContext { diff --git a/src/features/chat/ui/message-stream/pending-request-block.tsx b/src/features/chat/ui/message-stream/pending-request-block.tsx index 813c2bad..f4e6b409 100644 --- a/src/features/chat/ui/message-stream/pending-request-block.tsx +++ b/src/features/chat/ui/message-stream/pending-request-block.tsx @@ -3,12 +3,12 @@ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import { hasPendingRequests, pendingRequestCountsFromQueues } from "../../../../domain/pending-requests/aggregate"; import { approvalDetailsDisclosureId } from "../../domain/pending-requests/disclosure-ids"; -import { - type PendingApprovalViewModel, - type PendingMcpElicitationFieldViewModel, - type PendingMcpElicitationViewModel, - type PendingUserInputQuestionViewModel, - type PendingUserInputViewModel, +import type { + PendingApprovalViewModel, + PendingMcpElicitationFieldViewModel, + PendingMcpElicitationViewModel, + PendingUserInputQuestionViewModel, + PendingUserInputViewModel, } from "../../presentation/pending-requests/view-model"; import type { PendingRequestBlockActions } from "./context"; import { createStatusMessageClassName } from "./status"; diff --git a/src/features/chat/ui/message-stream/stream-blocks.tsx b/src/features/chat/ui/message-stream/stream-blocks.tsx index 5ea9417c..f6c2b149 100644 --- a/src/features/chat/ui/message-stream/stream-blocks.tsx +++ b/src/features/chat/ui/message-stream/stream-blocks.tsx @@ -1,9 +1,9 @@ import { Fragment, type ComponentChild as UiNode } from "preact"; -import { - type MessageStreamActivityItemView, - type MessageStreamRenderedItemView, - type MessageStreamViewBlock, +import type { + MessageStreamActivityItemView, + MessageStreamRenderedItemView, + MessageStreamViewBlock, } from "../../presentation/message-stream/view-model"; import type { MessageStreamContext, PendingRequestBlockContext } from "./context"; import { detailNode } from "./detail"; diff --git a/src/features/chat/ui/message-stream/text-content.tsx b/src/features/chat/ui/message-stream/text-content.tsx index 711d87b7..a1dc24d2 100644 --- a/src/features/chat/ui/message-stream/text-content.tsx +++ b/src/features/chat/ui/message-stream/text-content.tsx @@ -1,4 +1,4 @@ -import { type Ref, type ComponentChild as UiNode } from "preact"; +import type { Ref, ComponentChild as UiNode } from "preact"; import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; import type { MessageStreamTextView } from "../../presentation/message-stream/text-view"; diff --git a/tests/scripts/development-scripts.test.ts b/tests/scripts/development-scripts.test.ts index 4bce2944..6153b71f 100644 --- a/tests/scripts/development-scripts.test.ts +++ b/tests/scripts/development-scripts.test.ts @@ -27,7 +27,7 @@ describe("development scripts", () => { [ "#!/usr/bin/env node", "import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';", - "appendFileSync('codex-args.txt', `${process.argv.slice(2).join('\\n')}\\n`);", + "appendFileSync('codex-args.txt', process.argv.slice(2).join('\\n') + '\\n');", "mkdirSync('src/generated/app-server/v2', { recursive: true });", "writeFileSync('src/generated/app-server/v2/Example.ts', '// GENERATED CODE! DO NOT MODIFY BY HAND!\\nexport type Example = string | null | null;\\n');", ].join("\n"), @@ -116,6 +116,28 @@ describe("development scripts", () => { expect(report.failures).toEqual([]); }); + it("runs Biome lint with warning-level diagnostics", async () => { + const cwd = await tempWorkspace(); + const binDir = path.join(cwd, "bin"); + await mkdir(binDir, { recursive: true }); + await mkdir(path.join(cwd, "scripts", "lint"), { recursive: true }); + await writeFile(path.join(cwd, "scripts", "lint", "check-css-usage.mjs"), ""); + + await writeCaptureBin(path.join(binDir, "biome"), "biome-args.txt"); + await writeExitZeroBin(path.join(binDir, "eslint")); + await writeExitZeroBin(path.join(binDir, "stylelint")); + await writeExitZeroBin(path.join(binDir, "knip")); + + const result = runNodeScript("scripts/check.mjs", ["--lint"], cwd, { + PATH: `${binDir}${path.delimiter}${process.env["PATH"] ?? ""}`, + }); + + expect(result.status).toBe(0); + await expect(readFile(path.join(cwd, "biome-args.txt"), "utf8")).resolves.toBe( + "lint\n--no-errors-on-unmatched\n--diagnostic-level=warn\n--error-on-warnings\n", + ); + }); + it("keeps CSS usage checks quiet when every class is used", async () => { const cwd = await cssUsageFixture({ "src/styles/10-component.css": ".codex-panel__used { display: block; }\n", @@ -154,7 +176,7 @@ describe("development scripts", () => { "src/styles/10-component.css": ".codex-panel__used { display: block; }\n", "src/component.ts": [ 'export const used = "codex-panel__used";', - "export const dynamicClassName = `codex-panel__task-step--${status}`;", + "export const dynamicClassName = `codex-panel__task-step--$" + "{status}`;", ].join("\n"), }); @@ -170,7 +192,7 @@ describe("development scripts", () => { it("does not let dynamic CSS prefixes hide unconfigured class candidates", async () => { const cwd = await cssUsageFixture({ "src/styles/10-component.css": ".codex-panel__task-step--stale { display: block; }\n", - "src/component.ts": "export const className = `codex-panel__task-step--${status}`;\n", + "src/component.ts": "export const className = `codex-panel__task-step--$" + "{status}`;\n", }); const result = runNodeScript("scripts/lint/check-css-usage.mjs", [], cwd); @@ -250,3 +272,20 @@ async function writeJson(file: string, value: unknown): Promise { async function readJson(file: string): Promise { return JSON.parse(await readFile(file, "utf8")); } + +async function writeCaptureBin(file: string, outputFile: string): Promise { + await writeFile( + file, + [ + "#!/usr/bin/env node", + "const { appendFileSync } = require('node:fs');", + `appendFileSync(${JSON.stringify(outputFile)}, \`\${process.argv.slice(2).join("\\n")}\\n\`);`, + ].join("\n"), + ); + await chmod(file, 0o755); +} + +async function writeExitZeroBin(file: string): Promise { + await writeFile(file, "#!/usr/bin/env node\n"); + await chmod(file, 0o755); +}