From 9be38c389c108a2ffecd0c7534086ba7078e561d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 24 Jul 2026 10:53:52 +0800 Subject: [PATCH 01/22] ci: run tests for the release-refactor integration branch --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 44a6ecf..4195f00 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,9 +4,11 @@ on: push: branches: - main + - release-refactor pull_request: branches: - main + - release-refactor types: - opened - reopened From 4af68da0171dab2b433cf124bc0ed552e8aca2aa Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 24 Jul 2026 11:02:56 +0800 Subject: [PATCH 02/22] =?UTF-8?q?refactor(ts):=20TS-1=20=E2=80=94=20move?= =?UTF-8?q?=20kernel/syntax/io/hosts=20modules=20into=20subdirectories=20(?= =?UTF-8?q?#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build(ts): make sidecar module resolution subdirectory-capable Replace the enumerated #sidecar imports maps in both package.json files with wildcard patterns (#sidecar/*.js and #sidecar/* -> ./src/*.ts). The .js entry is kept and listed first: Node's longest-suffix pattern precedence needs it so #sidecar/foo.js resolves to ./src/foo.ts. Make the test runner glob recursive ('src/**/*.test.ts', quoted so Node's test runner expands it), re-root the alias-specifiers scan at the test's own directory (import.meta.dirname) instead of a single module's resolved path, and copy sidecar sources recursively in stage-ts-assets.sh while still excluding *.test.ts. No source files move; the current flat layout keeps working. * refactor(ts): move kernel/syntax/io/hosts modules into subdirectories Mechanically relocate sidecar modules into kernel/, syntax/, io/, and hosts/ subdirectories (each with its co-located tests) and retarget every #sidecar/ specifier to #sidecar//. Delete types.ts: its Edit type moves into syntax/edits.ts and its Node re-export is dropped in favour of importing Node directly from #sidecar/syntax/node-schema. No class-shape, logic, or exported-symbol changes. --- .../ts/sidecar/src/alias-specifiers.test.ts | 8 +++---- .../ts/sidecar/src/blank-line-inserter.ts | 6 ++--- packages/ts/sidecar/src/blank-lines.ts | 4 ++-- packages/ts/sidecar/src/body-wrapper.test.ts | 2 +- packages/ts/sidecar/src/body-wrapper.ts | 11 +++++---- packages/ts/sidecar/src/class-reorder.test.ts | 2 +- packages/ts/sidecar/src/class-reorder.ts | 9 ++++---- .../sidecar/src/declaration-reorder.test.ts | 2 +- .../ts/sidecar/src/declaration-reorder.ts | 12 +++++----- packages/ts/sidecar/src/drizzle-queries.ts | 16 ++++++------- packages/ts/sidecar/src/expanded-calls.ts | 20 ++++++++-------- packages/ts/sidecar/src/fluent-chains.ts | 23 ++++++++++--------- packages/ts/sidecar/src/format-all.test.ts | 8 +++---- packages/ts/sidecar/src/format-all.ts | 14 +++++------ packages/ts/sidecar/src/format-pipeline.ts | 14 +++++------ .../src/{ => hosts}/embedded-blocks.test.ts | 2 +- .../src/{ => hosts}/embedded-blocks.ts | 4 ++-- .../src/{ => hosts}/file-targets.test.ts | 2 +- .../sidecar/src/{ => hosts}/file-targets.ts | 2 +- .../markdown-fences.property.test.ts | 2 +- .../src/{ => hosts}/markdown-fences.test.ts | 2 +- .../src/{ => hosts}/markdown-fences.ts | 0 .../{ => hosts}/vue-script.property.test.ts | 2 +- .../src/{ => hosts}/vue-script.test.ts | 2 +- .../ts/sidecar/src/{ => hosts}/vue-script.ts | 0 .../ts/sidecar/src/{ => io}/files.test.ts | 8 +++---- packages/ts/sidecar/src/{ => io}/files.ts | 0 .../src/{ => io}/process-runner.test.ts | 4 ++-- .../ts/sidecar/src/{ => io}/process-runner.ts | 6 ++--- .../sidecar/src/{ => io}/source-files.test.ts | 6 ++--- .../ts/sidecar/src/{ => io}/source-files.ts | 6 ++--- .../ts/sidecar/src/{ => kernel}/errors.ts | 0 .../ts/sidecar/src/{ => kernel}/result.ts | 0 packages/ts/sidecar/src/pass-cli-dto.ts | 2 +- packages/ts/sidecar/src/rules.ts | 4 ++-- packages/ts/sidecar/src/segment.ts | 2 +- .../ts/sidecar/src/{ => syntax}/ast.test.ts | 8 +++---- packages/ts/sidecar/src/{ => syntax}/ast.ts | 4 ++-- .../src/{ => syntax}/edits.property.test.ts | 4 ++-- .../ts/sidecar/src/{ => syntax}/edits.test.ts | 2 +- packages/ts/sidecar/src/{ => syntax}/edits.ts | 12 +++++++++- .../src/{ => syntax}/node-schema.test.ts | 8 +++---- .../sidecar/src/{ => syntax}/node-schema.ts | 0 .../src/{ => syntax}/source-text.test.ts | 2 +- .../sidecar/src/{ => syntax}/source-text.ts | 4 ++-- .../sidecar/src/{ => syntax}/sources.test.ts | 4 ++-- .../ts/sidecar/src/{ => syntax}/sources.ts | 8 +++---- .../src/{ => syntax}/template-spans.test.ts | 6 ++--- .../src/{ => syntax}/template-spans.ts | 4 ++-- packages/ts/sidecar/src/types.ts | 14 ----------- packages/ts/sidecar/src/validate-syntax.ts | 6 ++--- 51 files changed, 146 insertions(+), 147 deletions(-) rename packages/ts/sidecar/src/{ => hosts}/embedded-blocks.test.ts (98%) rename packages/ts/sidecar/src/{ => hosts}/embedded-blocks.ts (96%) rename packages/ts/sidecar/src/{ => hosts}/file-targets.test.ts (95%) rename packages/ts/sidecar/src/{ => hosts}/file-targets.ts (94%) rename packages/ts/sidecar/src/{ => hosts}/markdown-fences.property.test.ts (96%) rename packages/ts/sidecar/src/{ => hosts}/markdown-fences.test.ts (98%) rename packages/ts/sidecar/src/{ => hosts}/markdown-fences.ts (100%) rename packages/ts/sidecar/src/{ => hosts}/vue-script.property.test.ts (98%) rename packages/ts/sidecar/src/{ => hosts}/vue-script.test.ts (96%) rename packages/ts/sidecar/src/{ => hosts}/vue-script.ts (100%) rename packages/ts/sidecar/src/{ => io}/files.test.ts (94%) rename packages/ts/sidecar/src/{ => io}/files.ts (100%) rename packages/ts/sidecar/src/{ => io}/process-runner.test.ts (92%) rename packages/ts/sidecar/src/{ => io}/process-runner.ts (89%) rename packages/ts/sidecar/src/{ => io}/source-files.test.ts (91%) rename packages/ts/sidecar/src/{ => io}/source-files.ts (94%) rename packages/ts/sidecar/src/{ => kernel}/errors.ts (100%) rename packages/ts/sidecar/src/{ => kernel}/result.ts (100%) rename packages/ts/sidecar/src/{ => syntax}/ast.test.ts (92%) rename packages/ts/sidecar/src/{ => syntax}/ast.ts (97%) rename packages/ts/sidecar/src/{ => syntax}/edits.property.test.ts (95%) rename packages/ts/sidecar/src/{ => syntax}/edits.test.ts (89%) rename packages/ts/sidecar/src/{ => syntax}/edits.ts (84%) rename packages/ts/sidecar/src/{ => syntax}/node-schema.test.ts (86%) rename packages/ts/sidecar/src/{ => syntax}/node-schema.ts (100%) rename packages/ts/sidecar/src/{ => syntax}/source-text.test.ts (96%) rename packages/ts/sidecar/src/{ => syntax}/source-text.ts (97%) rename packages/ts/sidecar/src/{ => syntax}/sources.test.ts (86%) rename packages/ts/sidecar/src/{ => syntax}/sources.ts (85%) rename packages/ts/sidecar/src/{ => syntax}/template-spans.test.ts (90%) rename packages/ts/sidecar/src/{ => syntax}/template-spans.ts (94%) delete mode 100644 packages/ts/sidecar/src/types.ts diff --git a/packages/ts/sidecar/src/alias-specifiers.test.ts b/packages/ts/sidecar/src/alias-specifiers.test.ts index d60a1b7..b1461bd 100644 --- a/packages/ts/sidecar/src/alias-specifiers.test.ts +++ b/packages/ts/sidecar/src/alias-specifiers.test.ts @@ -2,10 +2,10 @@ import assert from 'node:assert/strict'; import { readdir, readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { test } from 'node:test'; -import { Ast } from '#sidecar/ast'; -import { isErr } from '#sidecar/result'; -import { Sources } from '#sidecar/sources'; -import type { Node } from '#sidecar/types'; +import { Ast } from '#sidecar/syntax/ast'; +import { isErr } from '#sidecar/kernel/result'; +import { Sources } from '#sidecar/syntax/sources'; +import type { Node } from '#sidecar/syntax/node-schema'; const sourceExtensions = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']); const exemptFiles = new Set(['sidecar.ts']); diff --git a/packages/ts/sidecar/src/blank-line-inserter.ts b/packages/ts/sidecar/src/blank-line-inserter.ts index beeea63..69aed90 100644 --- a/packages/ts/sidecar/src/blank-line-inserter.ts +++ b/packages/ts/sidecar/src/blank-line-inserter.ts @@ -1,7 +1,7 @@ -import { Ast } from '#sidecar/ast'; -import { isErr } from '#sidecar/result'; +import { Ast } from '#sidecar/syntax/ast'; +import { isErr } from '#sidecar/kernel/result'; import { Rules } from '#sidecar/rules'; -import { Sources } from '#sidecar/sources'; +import { Sources } from '#sidecar/syntax/sources'; /** Computes and applies the blank lines required by formatter rules. */ export class BlankLines { diff --git a/packages/ts/sidecar/src/blank-lines.ts b/packages/ts/sidecar/src/blank-lines.ts index 350be13..4540197 100644 --- a/packages/ts/sidecar/src/blank-lines.ts +++ b/packages/ts/sidecar/src/blank-lines.ts @@ -1,8 +1,8 @@ import { pathToFileURL } from 'node:url'; import { FormatPipeline } from '#sidecar/format-pipeline'; import { PassCliDto } from '#sidecar/pass-cli-dto'; -import { NodeProcessRunner } from '#sidecar/process-runner'; -import { NodeSourceFiles } from '#sidecar/source-files'; +import { NodeProcessRunner } from '#sidecar/io/process-runner'; +import { NodeSourceFiles } from '#sidecar/io/source-files'; async function main(): Promise { const cwd = process.cwd(); diff --git a/packages/ts/sidecar/src/body-wrapper.test.ts b/packages/ts/sidecar/src/body-wrapper.test.ts index f8ecff5..587452b 100644 --- a/packages/ts/sidecar/src/body-wrapper.test.ts +++ b/packages/ts/sidecar/src/body-wrapper.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { BodyWrapper } from '#sidecar/body-wrapper'; -import { Edits } from '#sidecar/edits'; +import { Edits } from '#sidecar/syntax/edits'; function wrapOnce(source: string): string { const edits = BodyWrapper.computeEdits(source, 'sample.ts'); diff --git a/packages/ts/sidecar/src/body-wrapper.ts b/packages/ts/sidecar/src/body-wrapper.ts index 28cbe80..7c2604f 100644 --- a/packages/ts/sidecar/src/body-wrapper.ts +++ b/packages/ts/sidecar/src/body-wrapper.ts @@ -1,8 +1,9 @@ -import { Ast } from '#sidecar/ast'; -import { isErr } from '#sidecar/result'; -import { SourceText } from '#sidecar/source-text'; -import { Sources } from '#sidecar/sources'; -import type { Edit, Node } from '#sidecar/types'; +import { Ast } from '#sidecar/syntax/ast'; +import { isErr } from '#sidecar/kernel/result'; +import { SourceText } from '#sidecar/syntax/source-text'; +import { Sources } from '#sidecar/syntax/sources'; +import type { Edit } from '#sidecar/syntax/edits'; +import type { Node } from '#sidecar/syntax/node-schema'; const STATEMENT_BODY_KEYS: Record = { DoWhileStatement: ['body'], diff --git a/packages/ts/sidecar/src/class-reorder.test.ts b/packages/ts/sidecar/src/class-reorder.test.ts index 18c7261..f7cceeb 100644 --- a/packages/ts/sidecar/src/class-reorder.test.ts +++ b/packages/ts/sidecar/src/class-reorder.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { ClassReorder } from '#sidecar/class-reorder'; -import { Edits } from '#sidecar/edits'; +import { Edits } from '#sidecar/syntax/edits'; test('class members are reordered as properties, constructors, then methods', () => { const input = ['class Example {', '\trun() {}', '\tvalue = 1;', '\tconstructor() {}', '}', ''].join('\n'); diff --git a/packages/ts/sidecar/src/class-reorder.ts b/packages/ts/sidecar/src/class-reorder.ts index 189fed8..d61483f 100644 --- a/packages/ts/sidecar/src/class-reorder.ts +++ b/packages/ts/sidecar/src/class-reorder.ts @@ -1,8 +1,9 @@ -import { Ast } from '#sidecar/ast'; -import { isErr } from '#sidecar/result'; +import { Ast } from '#sidecar/syntax/ast'; +import { isErr } from '#sidecar/kernel/result'; import { Rules } from '#sidecar/rules'; -import { Sources } from '#sidecar/sources'; -import type { Edit, Node } from '#sidecar/types'; +import { Sources } from '#sidecar/syntax/sources'; +import type { Edit } from '#sidecar/syntax/edits'; +import type { Node } from '#sidecar/syntax/node-schema'; /** Reorders class members into the formatter's stable class shape. */ export class ClassReorder { diff --git a/packages/ts/sidecar/src/declaration-reorder.test.ts b/packages/ts/sidecar/src/declaration-reorder.test.ts index bebfd24..9c95718 100644 --- a/packages/ts/sidecar/src/declaration-reorder.test.ts +++ b/packages/ts/sidecar/src/declaration-reorder.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { DeclarationReorder } from '#sidecar/declaration-reorder'; -import { Edits } from '#sidecar/edits'; +import { Edits } from '#sidecar/syntax/edits'; function reorder(source: string): string { const edits = DeclarationReorder.computeEdits(source, 'sample.ts'); diff --git a/packages/ts/sidecar/src/declaration-reorder.ts b/packages/ts/sidecar/src/declaration-reorder.ts index 765154e..9a370c4 100644 --- a/packages/ts/sidecar/src/declaration-reorder.ts +++ b/packages/ts/sidecar/src/declaration-reorder.ts @@ -1,9 +1,9 @@ -import { Ast } from '#sidecar/ast'; -import { Node } from '#sidecar/node-schema'; -import { isErr } from '#sidecar/result'; -import { SourceText } from '#sidecar/source-text'; -import { Sources } from '#sidecar/sources'; -import type { Edit } from '#sidecar/types'; +import { Ast } from '#sidecar/syntax/ast'; +import { Node } from '#sidecar/syntax/node-schema'; +import { isErr } from '#sidecar/kernel/result'; +import { SourceText } from '#sidecar/syntax/source-text'; +import { Sources } from '#sidecar/syntax/sources'; +import type { Edit } from '#sidecar/syntax/edits'; /** Reorders declarations only where the transformation is side-effect safe. */ export class DeclarationReorder { diff --git a/packages/ts/sidecar/src/drizzle-queries.ts b/packages/ts/sidecar/src/drizzle-queries.ts index 27a6ae3..81ac95a 100644 --- a/packages/ts/sidecar/src/drizzle-queries.ts +++ b/packages/ts/sidecar/src/drizzle-queries.ts @@ -1,11 +1,11 @@ -import { Ast } from '#sidecar/ast'; -import { Edits } from '#sidecar/edits'; -import { FileTargets } from '#sidecar/file-targets'; -import { Node } from '#sidecar/node-schema'; -import { isErr } from '#sidecar/result'; -import { SourceText } from '#sidecar/source-text'; -import { Sources } from '#sidecar/sources'; -import type { Edit } from '#sidecar/types'; +import { Ast } from '#sidecar/syntax/ast'; +import { Edits } from '#sidecar/syntax/edits'; +import { FileTargets } from '#sidecar/hosts/file-targets'; +import { Node } from '#sidecar/syntax/node-schema'; +import { isErr } from '#sidecar/kernel/result'; +import { SourceText } from '#sidecar/syntax/source-text'; +import { Sources } from '#sidecar/syntax/sources'; +import type { Edit } from '#sidecar/syntax/edits'; type DrizzleImports = { locals: Map; diff --git a/packages/ts/sidecar/src/expanded-calls.ts b/packages/ts/sidecar/src/expanded-calls.ts index 44f91f5..621eb8f 100644 --- a/packages/ts/sidecar/src/expanded-calls.ts +++ b/packages/ts/sidecar/src/expanded-calls.ts @@ -1,13 +1,13 @@ -import { Ast } from '#sidecar/ast'; -import { Edits } from '#sidecar/edits'; -import { FileTargets } from '#sidecar/file-targets'; -import { Node } from '#sidecar/node-schema'; -import { isErr } from '#sidecar/result'; -import { SourceText } from '#sidecar/source-text'; -import type { CallParens } from '#sidecar/source-text'; -import { Sources } from '#sidecar/sources'; -import { TemplateSpans } from '#sidecar/template-spans'; -import type { Edit } from '#sidecar/types'; +import { Ast } from '#sidecar/syntax/ast'; +import { Edits } from '#sidecar/syntax/edits'; +import { FileTargets } from '#sidecar/hosts/file-targets'; +import { Node } from '#sidecar/syntax/node-schema'; +import { isErr } from '#sidecar/kernel/result'; +import { SourceText } from '#sidecar/syntax/source-text'; +import type { CallParens } from '#sidecar/syntax/source-text'; +import { Sources } from '#sidecar/syntax/sources'; +import { TemplateSpans } from '#sidecar/syntax/template-spans'; +import type { Edit } from '#sidecar/syntax/edits'; const FUNCTION_TYPES = new Set(['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression']); diff --git a/packages/ts/sidecar/src/fluent-chains.ts b/packages/ts/sidecar/src/fluent-chains.ts index c58a3fe..03b692a 100644 --- a/packages/ts/sidecar/src/fluent-chains.ts +++ b/packages/ts/sidecar/src/fluent-chains.ts @@ -1,16 +1,17 @@ import { pathToFileURL } from 'node:url'; -import { Ast } from '#sidecar/ast'; +import { Ast } from '#sidecar/syntax/ast'; import { DrizzleQueries } from '#sidecar/drizzle-queries'; -import { Edits } from '#sidecar/edits'; -import { EmbeddedBlocks } from '#sidecar/embedded-blocks'; +import { Edits } from '#sidecar/syntax/edits'; +import { EmbeddedBlocks } from '#sidecar/hosts/embedded-blocks'; import { ExpandedCalls } from '#sidecar/expanded-calls'; import { PassCliDto } from '#sidecar/pass-cli-dto'; -import { isErr, ok } from '#sidecar/result'; -import type { Result } from '#sidecar/result'; -import type { SourceFileError, SourceFiles } from '#sidecar/source-files'; -import { SourceText } from '#sidecar/source-text'; -import { Sources } from '#sidecar/sources'; -import type { Edit, Node } from '#sidecar/types'; +import { isErr, ok } from '#sidecar/kernel/result'; +import type { Result } from '#sidecar/kernel/result'; +import type { SourceFileError, SourceFiles } from '#sidecar/io/source-files'; +import { SourceText } from '#sidecar/syntax/source-text'; +import { Sources } from '#sidecar/syntax/sources'; +import type { Edit } from '#sidecar/syntax/edits'; +import type { Node } from '#sidecar/syntax/node-schema'; const cwd = process.cwd(); @@ -226,9 +227,9 @@ export class FluentChains { const files = [...options.files]; const { mode } = options; - const { NodeProcessRunner } = await import('#sidecar/process-runner'); + const { NodeProcessRunner } = await import('#sidecar/io/process-runner'); - const { NodeSourceFiles } = await import('#sidecar/source-files'); + const { NodeSourceFiles } = await import('#sidecar/io/source-files'); const { FormatPipeline } = await import('#sidecar/format-pipeline'); diff --git a/packages/ts/sidecar/src/format-all.test.ts b/packages/ts/sidecar/src/format-all.test.ts index 7d8bef2..cd6c73e 100644 --- a/packages/ts/sidecar/src/format-all.test.ts +++ b/packages/ts/sidecar/src/format-all.test.ts @@ -5,12 +5,12 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { test } from 'node:test'; import { promisify } from 'node:util'; -import { SourceFileUnreadable } from '#sidecar/errors'; +import { SourceFileUnreadable } from '#sidecar/kernel/errors'; import { CliOptionsDto } from '#sidecar/format-all'; import { FormatPipeline } from '#sidecar/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/process-runner'; -import { err, isErr, ok } from '#sidecar/result'; -import { NodeSourceFiles } from '#sidecar/source-files'; +import { NodeProcessRunner } from '#sidecar/io/process-runner'; +import { err, isErr, ok } from '#sidecar/kernel/result'; +import { NodeSourceFiles } from '#sidecar/io/source-files'; const execFileAsync = promisify(execFile); const formatAllScript = resolve(import.meta.dirname, 'format-all.ts'); diff --git a/packages/ts/sidecar/src/format-all.ts b/packages/ts/sidecar/src/format-all.ts index d72cd04..9746600 100644 --- a/packages/ts/sidecar/src/format-all.ts +++ b/packages/ts/sidecar/src/format-all.ts @@ -1,14 +1,14 @@ import { pathToFileURL } from 'node:url'; import { z } from 'zod'; -import { UnexpectedCliArgument } from '#sidecar/errors'; -import type { OxcErrorDto } from '#sidecar/errors'; -import { FileTargets } from '#sidecar/file-targets'; +import { UnexpectedCliArgument } from '#sidecar/kernel/errors'; +import type { OxcErrorDto } from '#sidecar/kernel/errors'; +import { FileTargets } from '#sidecar/hosts/file-targets'; import { FormatPipeline } from '#sidecar/format-pipeline'; import type { FormatMode, PassOutcome, ValidationFailure } from '#sidecar/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/process-runner'; -import { err, isErr, ok } from '#sidecar/result'; -import type { Result } from '#sidecar/result'; -import { NodeSourceFiles } from '#sidecar/source-files'; +import { NodeProcessRunner } from '#sidecar/io/process-runner'; +import { err, isErr, ok } from '#sidecar/kernel/result'; +import type { Result } from '#sidecar/kernel/result'; +import { NodeSourceFiles } from '#sidecar/io/source-files'; /** Immutable command-line options for the full formatting pipeline. */ export class CliOptionsDto { diff --git a/packages/ts/sidecar/src/format-pipeline.ts b/packages/ts/sidecar/src/format-pipeline.ts index 36a4d79..eabbd57 100644 --- a/packages/ts/sidecar/src/format-pipeline.ts +++ b/packages/ts/sidecar/src/format-pipeline.ts @@ -1,13 +1,13 @@ import { availableParallelism } from 'node:os'; -import { EmbeddedBlocks } from '#sidecar/embedded-blocks'; -import type { OxfmtRunFailed, SourceFileUnreadable, SourceUnparsable } from '#sidecar/errors'; +import { EmbeddedBlocks } from '#sidecar/hosts/embedded-blocks'; +import type { OxfmtRunFailed, SourceFileUnreadable, SourceUnparsable } from '#sidecar/kernel/errors'; import { FluentChains } from '#sidecar/fluent-chains'; -import type { ProcessRunner } from '#sidecar/process-runner'; -import { isErr, ok } from '#sidecar/result'; -import type { Result } from '#sidecar/result'; +import type { ProcessRunner } from '#sidecar/io/process-runner'; +import { isErr, ok } from '#sidecar/kernel/result'; +import type { Result } from '#sidecar/kernel/result'; import { Segment } from '#sidecar/segment'; -import type { SourceFileError, SourceFiles } from '#sidecar/source-files'; -import { Sources } from '#sidecar/sources'; +import type { SourceFileError, SourceFiles } from '#sidecar/io/source-files'; +import { Sources } from '#sidecar/syntax/sources'; const OXFMT_CHUNK_SIZE = 100; diff --git a/packages/ts/sidecar/src/embedded-blocks.test.ts b/packages/ts/sidecar/src/hosts/embedded-blocks.test.ts similarity index 98% rename from packages/ts/sidecar/src/embedded-blocks.test.ts rename to packages/ts/sidecar/src/hosts/embedded-blocks.test.ts index 6814422..dc99434 100644 --- a/packages/ts/sidecar/src/embedded-blocks.test.ts +++ b/packages/ts/sidecar/src/hosts/embedded-blocks.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { EmbeddedBlocks } from '#sidecar/embedded-blocks'; +import { EmbeddedBlocks } from '#sidecar/hosts/embedded-blocks'; test('EmbeddedBlocks.isHost accepts every host extension and rejects others', () => { for (const path of ['a.vue', 'b.html', 'c.htm', 'd.md', 'e.markdown']) { diff --git a/packages/ts/sidecar/src/embedded-blocks.ts b/packages/ts/sidecar/src/hosts/embedded-blocks.ts similarity index 96% rename from packages/ts/sidecar/src/embedded-blocks.ts rename to packages/ts/sidecar/src/hosts/embedded-blocks.ts index 6137a23..0490b87 100644 --- a/packages/ts/sidecar/src/embedded-blocks.ts +++ b/packages/ts/sidecar/src/hosts/embedded-blocks.ts @@ -1,5 +1,5 @@ -import { MarkdownFences } from '#sidecar/markdown-fences'; -import { VueScript } from '#sidecar/vue-script'; +import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; +import { VueScript } from '#sidecar/hosts/vue-script'; /** A JavaScript-capable block embedded in a host document. */ export type EmbeddedBlock = { diff --git a/packages/ts/sidecar/src/file-targets.test.ts b/packages/ts/sidecar/src/hosts/file-targets.test.ts similarity index 95% rename from packages/ts/sidecar/src/file-targets.test.ts rename to packages/ts/sidecar/src/hosts/file-targets.test.ts index 2a93dcf..aad5755 100644 --- a/packages/ts/sidecar/src/file-targets.test.ts +++ b/packages/ts/sidecar/src/hosts/file-targets.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { FileTargets } from '#sidecar/file-targets'; +import { FileTargets } from '#sidecar/hosts/file-targets'; test('isTargetFile accepts ts and host documents but not declarations', () => { assert.equal(FileTargets.isTargetFile('app.ts'), true); diff --git a/packages/ts/sidecar/src/file-targets.ts b/packages/ts/sidecar/src/hosts/file-targets.ts similarity index 94% rename from packages/ts/sidecar/src/file-targets.ts rename to packages/ts/sidecar/src/hosts/file-targets.ts index 6931744..4f46ec9 100644 --- a/packages/ts/sidecar/src/file-targets.ts +++ b/packages/ts/sidecar/src/hosts/file-targets.ts @@ -1,4 +1,4 @@ -import { EmbeddedBlocks } from '#sidecar/embedded-blocks'; +import { EmbeddedBlocks } from '#sidecar/hosts/embedded-blocks'; /** Classifies paths accepted by sidecar formatting passes. */ export class FileTargets { diff --git a/packages/ts/sidecar/src/markdown-fences.property.test.ts b/packages/ts/sidecar/src/hosts/markdown-fences.property.test.ts similarity index 96% rename from packages/ts/sidecar/src/markdown-fences.property.test.ts rename to packages/ts/sidecar/src/hosts/markdown-fences.property.test.ts index 51a2546..faaaebf 100644 --- a/packages/ts/sidecar/src/markdown-fences.property.test.ts +++ b/packages/ts/sidecar/src/hosts/markdown-fences.property.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import fc from 'fast-check'; -import { MarkdownFences } from '#sidecar/markdown-fences'; +import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; type ExpectedBlock = { readonly lang: string; diff --git a/packages/ts/sidecar/src/markdown-fences.test.ts b/packages/ts/sidecar/src/hosts/markdown-fences.test.ts similarity index 98% rename from packages/ts/sidecar/src/markdown-fences.test.ts rename to packages/ts/sidecar/src/hosts/markdown-fences.test.ts index 1a14111..1651b9e 100644 --- a/packages/ts/sidecar/src/markdown-fences.test.ts +++ b/packages/ts/sidecar/src/hosts/markdown-fences.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { MarkdownFences } from '#sidecar/markdown-fences'; +import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; test('MarkdownFences.extractBlocks returns each fenced block with its offset', () => { const content = ['# Title', '', '```ts', 'const n = 1;', '```', '', 'prose', '', '~~~js', 'const m = 2;', '~~~', ''].join('\n'); diff --git a/packages/ts/sidecar/src/markdown-fences.ts b/packages/ts/sidecar/src/hosts/markdown-fences.ts similarity index 100% rename from packages/ts/sidecar/src/markdown-fences.ts rename to packages/ts/sidecar/src/hosts/markdown-fences.ts diff --git a/packages/ts/sidecar/src/vue-script.property.test.ts b/packages/ts/sidecar/src/hosts/vue-script.property.test.ts similarity index 98% rename from packages/ts/sidecar/src/vue-script.property.test.ts rename to packages/ts/sidecar/src/hosts/vue-script.property.test.ts index 68ad1dc..7fd0e7e 100644 --- a/packages/ts/sidecar/src/vue-script.property.test.ts +++ b/packages/ts/sidecar/src/hosts/vue-script.property.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import fc from 'fast-check'; -import { VueScript } from '#sidecar/vue-script'; +import { VueScript } from '#sidecar/hosts/vue-script'; type GeneratedBlock = { readonly markup: string; diff --git a/packages/ts/sidecar/src/vue-script.test.ts b/packages/ts/sidecar/src/hosts/vue-script.test.ts similarity index 96% rename from packages/ts/sidecar/src/vue-script.test.ts rename to packages/ts/sidecar/src/hosts/vue-script.test.ts index 6c87ebd..4aacd99 100644 --- a/packages/ts/sidecar/src/vue-script.test.ts +++ b/packages/ts/sidecar/src/hosts/vue-script.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { VueScript } from '#sidecar/vue-script'; +import { VueScript } from '#sidecar/hosts/vue-script'; test('VueScript.extractBlocks returns every script block with its offset', () => { const content = '\n\n'; diff --git a/packages/ts/sidecar/src/vue-script.ts b/packages/ts/sidecar/src/hosts/vue-script.ts similarity index 100% rename from packages/ts/sidecar/src/vue-script.ts rename to packages/ts/sidecar/src/hosts/vue-script.ts diff --git a/packages/ts/sidecar/src/files.test.ts b/packages/ts/sidecar/src/io/files.test.ts similarity index 94% rename from packages/ts/sidecar/src/files.test.ts rename to packages/ts/sidecar/src/io/files.test.ts index 670a473..198dc7e 100644 --- a/packages/ts/sidecar/src/files.test.ts +++ b/packages/ts/sidecar/src/io/files.test.ts @@ -3,11 +3,11 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { Files } from '#sidecar/files'; +import { Files } from '#sidecar/io/files'; import { FormatPipeline } from '#sidecar/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/process-runner'; -import { isErr } from '#sidecar/result'; -import { NodeSourceFiles } from '#sidecar/source-files'; +import { NodeProcessRunner } from '#sidecar/io/process-runner'; +import { isErr } from '#sidecar/kernel/result'; +import { NodeSourceFiles } from '#sidecar/io/source-files'; const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); diff --git a/packages/ts/sidecar/src/files.ts b/packages/ts/sidecar/src/io/files.ts similarity index 100% rename from packages/ts/sidecar/src/files.ts rename to packages/ts/sidecar/src/io/files.ts diff --git a/packages/ts/sidecar/src/process-runner.test.ts b/packages/ts/sidecar/src/io/process-runner.test.ts similarity index 92% rename from packages/ts/sidecar/src/process-runner.test.ts rename to packages/ts/sidecar/src/io/process-runner.test.ts index 76b318a..53469a9 100644 --- a/packages/ts/sidecar/src/process-runner.test.ts +++ b/packages/ts/sidecar/src/io/process-runner.test.ts @@ -3,8 +3,8 @@ import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { NodeProcessRunner } from '#sidecar/process-runner'; -import { isErr } from '#sidecar/result'; +import { NodeProcessRunner } from '#sidecar/io/process-runner'; +import { isErr } from '#sidecar/kernel/result'; test('NodeProcessRunner reports successful and failed process exits', async () => { const dir = await mkdtemp( diff --git a/packages/ts/sidecar/src/process-runner.ts b/packages/ts/sidecar/src/io/process-runner.ts similarity index 89% rename from packages/ts/sidecar/src/process-runner.ts rename to packages/ts/sidecar/src/io/process-runner.ts index 639fe76..e1bc9d9 100644 --- a/packages/ts/sidecar/src/process-runner.ts +++ b/packages/ts/sidecar/src/io/process-runner.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process'; -import { OxfmtRunFailed } from '#sidecar/errors'; -import { err, ok } from '#sidecar/result'; -import type { Result } from '#sidecar/result'; +import { OxfmtRunFailed } from '#sidecar/kernel/errors'; +import { err, ok } from '#sidecar/kernel/result'; +import type { Result } from '#sidecar/kernel/result'; /** The process operation required to invoke oxfmt. */ export type ProcessRunner = { diff --git a/packages/ts/sidecar/src/source-files.test.ts b/packages/ts/sidecar/src/io/source-files.test.ts similarity index 91% rename from packages/ts/sidecar/src/source-files.test.ts rename to packages/ts/sidecar/src/io/source-files.test.ts index 953f5df..6a64f78 100644 --- a/packages/ts/sidecar/src/source-files.test.ts +++ b/packages/ts/sidecar/src/io/source-files.test.ts @@ -3,9 +3,9 @@ import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { SourceFileUnreadable } from '#sidecar/errors'; -import { isErr } from '#sidecar/result'; -import { NodeSourceFiles } from '#sidecar/source-files'; +import { SourceFileUnreadable } from '#sidecar/kernel/errors'; +import { isErr } from '#sidecar/kernel/result'; +import { NodeSourceFiles } from '#sidecar/io/source-files'; test('SourceFileUnreadable identifies ENOENT-shaped causes only', () => { assert.equal(new SourceFileUnreadable('missing.ts', { code: 'ENOENT' }).isNotFound(), true); diff --git a/packages/ts/sidecar/src/source-files.ts b/packages/ts/sidecar/src/io/source-files.ts similarity index 94% rename from packages/ts/sidecar/src/source-files.ts rename to packages/ts/sidecar/src/io/source-files.ts index f6de35d..453f15f 100644 --- a/packages/ts/sidecar/src/source-files.ts +++ b/packages/ts/sidecar/src/io/source-files.ts @@ -1,8 +1,8 @@ import { randomBytes } from 'node:crypto'; import { readFile, rename, rm, writeFile } from 'node:fs/promises'; -import { SourceFileUnreadable, SourceFileUnwritable } from '#sidecar/errors'; -import { err, ok } from '#sidecar/result'; -import type { Result } from '#sidecar/result'; +import { SourceFileUnreadable, SourceFileUnwritable } from '#sidecar/kernel/errors'; +import { err, ok } from '#sidecar/kernel/result'; +import type { Result } from '#sidecar/kernel/result'; /** Every expected filesystem failure reported by the source-file port. */ export type SourceFileError = SourceFileUnreadable | SourceFileUnwritable; diff --git a/packages/ts/sidecar/src/errors.ts b/packages/ts/sidecar/src/kernel/errors.ts similarity index 100% rename from packages/ts/sidecar/src/errors.ts rename to packages/ts/sidecar/src/kernel/errors.ts diff --git a/packages/ts/sidecar/src/result.ts b/packages/ts/sidecar/src/kernel/result.ts similarity index 100% rename from packages/ts/sidecar/src/result.ts rename to packages/ts/sidecar/src/kernel/result.ts diff --git a/packages/ts/sidecar/src/pass-cli-dto.ts b/packages/ts/sidecar/src/pass-cli-dto.ts index 2b18c82..a42729f 100644 --- a/packages/ts/sidecar/src/pass-cli-dto.ts +++ b/packages/ts/sidecar/src/pass-cli-dto.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { FileTargets } from '#sidecar/file-targets'; +import { FileTargets } from '#sidecar/hosts/file-targets'; /** Immutable command-line options shared by standalone formatting passes. */ export class PassCliDto { diff --git a/packages/ts/sidecar/src/rules.ts b/packages/ts/sidecar/src/rules.ts index d1b4b1f..996c12a 100644 --- a/packages/ts/sidecar/src/rules.ts +++ b/packages/ts/sidecar/src/rules.ts @@ -1,5 +1,5 @@ -import { Ast } from '#sidecar/ast'; -import { Node } from '#sidecar/node-schema'; +import { Ast } from '#sidecar/syntax/ast'; +import { Node } from '#sidecar/syntax/node-schema'; const BLOCK_HAVING_STATEMENTS = new Set(['IfStatement', 'ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement', 'SwitchStatement', 'TryStatement']); const LOOP_STATEMENTS = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']); diff --git a/packages/ts/sidecar/src/segment.ts b/packages/ts/sidecar/src/segment.ts index 1a856ed..1dcc40d 100644 --- a/packages/ts/sidecar/src/segment.ts +++ b/packages/ts/sidecar/src/segment.ts @@ -2,7 +2,7 @@ import { BlankLines } from '#sidecar/blank-line-inserter'; import { BodyWrapper } from '#sidecar/body-wrapper'; import { ClassReorder } from '#sidecar/class-reorder'; import { DeclarationReorder } from '#sidecar/declaration-reorder'; -import { Edits } from '#sidecar/edits'; +import { Edits } from '#sidecar/syntax/edits'; /** Applies the sidecar's source-segment formatting passes in order. */ export class Segment { diff --git a/packages/ts/sidecar/src/ast.test.ts b/packages/ts/sidecar/src/syntax/ast.test.ts similarity index 92% rename from packages/ts/sidecar/src/ast.test.ts rename to packages/ts/sidecar/src/syntax/ast.test.ts index 39d752e..cd75d6b 100644 --- a/packages/ts/sidecar/src/ast.test.ts +++ b/packages/ts/sidecar/src/syntax/ast.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { Ast } from '#sidecar/ast'; -import { Node } from '#sidecar/node-schema'; -import { isErr } from '#sidecar/result'; -import { Sources } from '#sidecar/sources'; +import { Ast } from '#sidecar/syntax/ast'; +import { Node } from '#sidecar/syntax/node-schema'; +import { isErr } from '#sidecar/kernel/result'; +import { Sources } from '#sidecar/syntax/sources'; test('Ast traverses parsed fixtures and reads validated node fields', () => { const source = [ diff --git a/packages/ts/sidecar/src/ast.ts b/packages/ts/sidecar/src/syntax/ast.ts similarity index 97% rename from packages/ts/sidecar/src/ast.ts rename to packages/ts/sidecar/src/syntax/ast.ts index f527572..b1caa84 100644 --- a/packages/ts/sidecar/src/ast.ts +++ b/packages/ts/sidecar/src/syntax/ast.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { Node } from '#sidecar/node-schema'; -import type { AstValue } from '#sidecar/node-schema'; +import { Node } from '#sidecar/syntax/node-schema'; +import type { AstValue } from '#sidecar/syntax/node-schema'; const STATEMENT_LIST_KEYS: Record = { Program: 'body', diff --git a/packages/ts/sidecar/src/edits.property.test.ts b/packages/ts/sidecar/src/syntax/edits.property.test.ts similarity index 95% rename from packages/ts/sidecar/src/edits.property.test.ts rename to packages/ts/sidecar/src/syntax/edits.property.test.ts index a3db263..a03c46f 100644 --- a/packages/ts/sidecar/src/edits.property.test.ts +++ b/packages/ts/sidecar/src/syntax/edits.property.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import fc from 'fast-check'; -import { Edits } from '#sidecar/edits'; -import type { Edit } from '#sidecar/types'; +import { Edits } from '#sidecar/syntax/edits'; +import type { Edit } from '#sidecar/syntax/edits'; const editCaseArbitrary = fc.string({ minLength: 1, maxLength: 60 }).chain((source) => { return fc diff --git a/packages/ts/sidecar/src/edits.test.ts b/packages/ts/sidecar/src/syntax/edits.test.ts similarity index 89% rename from packages/ts/sidecar/src/edits.test.ts rename to packages/ts/sidecar/src/syntax/edits.test.ts index 5e503c1..d4dc224 100644 --- a/packages/ts/sidecar/src/edits.test.ts +++ b/packages/ts/sidecar/src/syntax/edits.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { Edits } from '#sidecar/edits'; +import { Edits } from '#sidecar/syntax/edits'; test('Edits.nonOverlapping drops overlapping edits and sorts by start', () => { const kept = Edits.nonOverlapping([ diff --git a/packages/ts/sidecar/src/edits.ts b/packages/ts/sidecar/src/syntax/edits.ts similarity index 84% rename from packages/ts/sidecar/src/edits.ts rename to packages/ts/sidecar/src/syntax/edits.ts index 683d6c1..3c50a78 100644 --- a/packages/ts/sidecar/src/edits.ts +++ b/packages/ts/sidecar/src/syntax/edits.ts @@ -1,4 +1,14 @@ -import type { Edit } from '#sidecar/types'; +/** A source replacement expressed against the original text offsets. */ +export type Edit = { + /** The inclusive replacement start. */ + start: number; + + /** The exclusive replacement end. */ + end: number; + + /** The text inserted in place of the selected range. */ + replacement: string; +}; /** Applies source edits in offset-safe order. */ export class Edits { diff --git a/packages/ts/sidecar/src/node-schema.test.ts b/packages/ts/sidecar/src/syntax/node-schema.test.ts similarity index 86% rename from packages/ts/sidecar/src/node-schema.test.ts rename to packages/ts/sidecar/src/syntax/node-schema.test.ts index acd8bd6..7ffc76f 100644 --- a/packages/ts/sidecar/src/node-schema.test.ts +++ b/packages/ts/sidecar/src/syntax/node-schema.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { SourceUnparsable } from '#sidecar/errors'; -import { Node, ParsedSourceDto } from '#sidecar/node-schema'; -import { isErr } from '#sidecar/result'; -import { Sources } from '#sidecar/sources'; +import { SourceUnparsable } from '#sidecar/kernel/errors'; +import { Node, ParsedSourceDto } from '#sidecar/syntax/node-schema'; +import { isErr } from '#sidecar/kernel/result'; +import { Sources } from '#sidecar/syntax/sources'; test('ParsedSourceDto accepts and freezes a valid parser envelope', () => { const parsed = ParsedSourceDto.from({ diff --git a/packages/ts/sidecar/src/node-schema.ts b/packages/ts/sidecar/src/syntax/node-schema.ts similarity index 100% rename from packages/ts/sidecar/src/node-schema.ts rename to packages/ts/sidecar/src/syntax/node-schema.ts diff --git a/packages/ts/sidecar/src/source-text.test.ts b/packages/ts/sidecar/src/syntax/source-text.test.ts similarity index 96% rename from packages/ts/sidecar/src/source-text.test.ts rename to packages/ts/sidecar/src/syntax/source-text.test.ts index e88ea5b..8d59796 100644 --- a/packages/ts/sidecar/src/source-text.test.ts +++ b/packages/ts/sidecar/src/syntax/source-text.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { SourceText } from '#sidecar/source-text'; +import { SourceText } from '#sidecar/syntax/source-text'; test('SourceText.lineIndent returns the leading whitespace of the position line', () => { const source = 'if (x) {\n\t\tcall();\n}\n'; diff --git a/packages/ts/sidecar/src/source-text.ts b/packages/ts/sidecar/src/syntax/source-text.ts similarity index 97% rename from packages/ts/sidecar/src/source-text.ts rename to packages/ts/sidecar/src/syntax/source-text.ts index ff24b77..be2c23b 100644 --- a/packages/ts/sidecar/src/source-text.ts +++ b/packages/ts/sidecar/src/syntax/source-text.ts @@ -1,5 +1,5 @@ -import { Ast } from '#sidecar/ast'; -import type { Node } from '#sidecar/types'; +import { Ast } from '#sidecar/syntax/ast'; +import type { Node } from '#sidecar/syntax/node-schema'; /** The opening and closing argument-parenthesis offsets of a call. */ export type CallParens = { diff --git a/packages/ts/sidecar/src/sources.test.ts b/packages/ts/sidecar/src/syntax/sources.test.ts similarity index 86% rename from packages/ts/sidecar/src/sources.test.ts rename to packages/ts/sidecar/src/syntax/sources.test.ts index daf6acc..02a1ead 100644 --- a/packages/ts/sidecar/src/sources.test.ts +++ b/packages/ts/sidecar/src/syntax/sources.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { isErr } from '#sidecar/result'; -import { Sources } from '#sidecar/sources'; +import { isErr } from '#sidecar/kernel/result'; +import { Sources } from '#sidecar/syntax/sources'; test('Sources.parse returns the program and comments for valid source', () => { const parsed = Sources.parse('sample.ts', 'const one = 1; // note\n'); diff --git a/packages/ts/sidecar/src/sources.ts b/packages/ts/sidecar/src/syntax/sources.ts similarity index 85% rename from packages/ts/sidecar/src/sources.ts rename to packages/ts/sidecar/src/syntax/sources.ts index d1eee71..8649d0a 100644 --- a/packages/ts/sidecar/src/sources.ts +++ b/packages/ts/sidecar/src/syntax/sources.ts @@ -1,8 +1,8 @@ import { parseSync } from 'oxc-parser'; -import { SourceUnparsable } from '#sidecar/errors'; -import { ParsedSourceDto } from '#sidecar/node-schema'; -import { err, ok } from '#sidecar/result'; -import type { Result } from '#sidecar/result'; +import { SourceUnparsable } from '#sidecar/kernel/errors'; +import { ParsedSourceDto } from '#sidecar/syntax/node-schema'; +import { err, ok } from '#sidecar/kernel/result'; +import type { Result } from '#sidecar/kernel/result'; /** Parses source text without exposing a broken syntax tree to formatting passes. */ export class Sources { diff --git a/packages/ts/sidecar/src/template-spans.test.ts b/packages/ts/sidecar/src/syntax/template-spans.test.ts similarity index 90% rename from packages/ts/sidecar/src/template-spans.test.ts rename to packages/ts/sidecar/src/syntax/template-spans.test.ts index 32d43e2..874da85 100644 --- a/packages/ts/sidecar/src/template-spans.test.ts +++ b/packages/ts/sidecar/src/syntax/template-spans.test.ts @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { isErr } from '#sidecar/result'; -import { Sources } from '#sidecar/sources'; -import { TemplateSpans } from '#sidecar/template-spans'; +import { isErr } from '#sidecar/kernel/result'; +import { Sources } from '#sidecar/syntax/sources'; +import { TemplateSpans } from '#sidecar/syntax/template-spans'; function spansOf(source: string): TemplateSpans | null { const parsed = Sources.parse('fixture.ts', source); diff --git a/packages/ts/sidecar/src/template-spans.ts b/packages/ts/sidecar/src/syntax/template-spans.ts similarity index 94% rename from packages/ts/sidecar/src/template-spans.ts rename to packages/ts/sidecar/src/syntax/template-spans.ts index be7ae14..2363ee3 100644 --- a/packages/ts/sidecar/src/template-spans.ts +++ b/packages/ts/sidecar/src/syntax/template-spans.ts @@ -1,5 +1,5 @@ -import { Ast } from '#sidecar/ast'; -import type { Node } from '#sidecar/types'; +import { Ast } from '#sidecar/syntax/ast'; +import type { Node } from '#sidecar/syntax/node-schema'; type Span = readonly [number, number]; diff --git a/packages/ts/sidecar/src/types.ts b/packages/ts/sidecar/src/types.ts deleted file mode 100644 index 0bb9360..0000000 --- a/packages/ts/sidecar/src/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** The AST node type admitted by the parser boundary. */ -export type { Node } from '#sidecar/node-schema'; - -/** A source replacement expressed against the original text offsets. */ -export type Edit = { - /** The inclusive replacement start. */ - start: number; - - /** The exclusive replacement end. */ - end: number; - - /** The text inserted in place of the selected range. */ - replacement: string; -}; diff --git a/packages/ts/sidecar/src/validate-syntax.ts b/packages/ts/sidecar/src/validate-syntax.ts index 2e991ef..0d84456 100644 --- a/packages/ts/sidecar/src/validate-syntax.ts +++ b/packages/ts/sidecar/src/validate-syntax.ts @@ -1,9 +1,9 @@ import { pathToFileURL } from 'node:url'; import { z } from 'zod'; -import type { OxcErrorDto } from '#sidecar/errors'; +import type { OxcErrorDto } from '#sidecar/kernel/errors'; import { FormatPipeline } from '#sidecar/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/process-runner'; -import { NodeSourceFiles } from '#sidecar/source-files'; +import { NodeProcessRunner } from '#sidecar/io/process-runner'; +import { NodeSourceFiles } from '#sidecar/io/source-files'; /** Immutable command-line options for standalone syntax validation. */ export class SyntaxCliDto { From 6e7d294fb2cc284c326f3047fa7acb7710ea3980 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 24 Jul 2026 11:12:07 +0800 Subject: [PATCH 03/22] refactor(ts): convert parser/ast/edits services to instance classes; add SourceDocument (#65) Convert the static-namespace core services in the TS sidecar into real instance classes and introduce the SourceDocument value object. - syntax/sources.ts -> syntax/source-parser.ts: Sources -> SourceParser - syntax/ast.ts -> syntax/ast-reader.ts: Ast -> AstReader, absorbing the node helpers from SourceText (sourceOf, callParens, unwrapChainExpression) - syntax/edits.ts: Edits -> EditApplier (Edit type unchanged) - syntax/source-document.ts: new frozen value object (of/withText factories) absorbing SourceText's text-coordinate queries (lineStart, lineIndent, indentUnit, slice) - node-schema.ts: hasCommentBetween becomes a ParsedSourceDto instance method - syntax/source-text.ts deleted Passes stay static this stage; each constructs the services as private static readonly fields (temporary scaffolding until DI in TS-3). Behavior is unchanged; 167 tests pass. --- .../ts/sidecar/src/alias-specifiers.test.ts | 21 +- .../ts/sidecar/src/blank-line-inserter.ts | 16 +- packages/ts/sidecar/src/body-wrapper.test.ts | 6 +- packages/ts/sidecar/src/body-wrapper.ts | 33 ++-- packages/ts/sidecar/src/class-reorder.test.ts | 4 +- packages/ts/sidecar/src/class-reorder.ts | 34 ++-- .../sidecar/src/declaration-reorder.test.ts | 6 +- .../ts/sidecar/src/declaration-reorder.ts | 87 ++++---- packages/ts/sidecar/src/drizzle-queries.ts | 187 +++++++++--------- packages/ts/sidecar/src/expanded-calls.ts | 89 +++++---- packages/ts/sidecar/src/fluent-chains.ts | 53 ++--- packages/ts/sidecar/src/format-pipeline.ts | 8 +- packages/ts/sidecar/src/rules.ts | 26 +-- packages/ts/sidecar/src/segment.ts | 10 +- .../ts/sidecar/src/syntax/ast-reader.test.ts | 128 ++++++++++++ .../src/syntax/{ast.ts => ast-reader.ts} | 117 ++++++++--- packages/ts/sidecar/src/syntax/ast.test.ts | 82 -------- .../sidecar/src/syntax/edits.property.test.ts | 16 +- packages/ts/sidecar/src/syntax/edits.test.ts | 6 +- packages/ts/sidecar/src/syntax/edits.ts | 10 +- .../ts/sidecar/src/syntax/node-schema.test.ts | 25 ++- packages/ts/sidecar/src/syntax/node-schema.ts | 28 ++- .../src/syntax/source-document.test.ts | 80 ++++++++ .../ts/sidecar/src/syntax/source-document.ts | 121 ++++++++++++ ...{sources.test.ts => source-parser.test.ts} | 10 +- .../syntax/{sources.ts => source-parser.ts} | 4 +- .../ts/sidecar/src/syntax/source-text.test.ts | 51 ----- packages/ts/sidecar/src/syntax/source-text.ts | 157 --------------- .../sidecar/src/syntax/template-spans.test.ts | 4 +- .../ts/sidecar/src/syntax/template-spans.ts | 10 +- 30 files changed, 814 insertions(+), 615 deletions(-) create mode 100644 packages/ts/sidecar/src/syntax/ast-reader.test.ts rename packages/ts/sidecar/src/syntax/{ast.ts => ast-reader.ts} (56%) delete mode 100644 packages/ts/sidecar/src/syntax/ast.test.ts create mode 100644 packages/ts/sidecar/src/syntax/source-document.test.ts create mode 100644 packages/ts/sidecar/src/syntax/source-document.ts rename packages/ts/sidecar/src/syntax/{sources.test.ts => source-parser.test.ts} (50%) rename packages/ts/sidecar/src/syntax/{sources.ts => source-parser.ts} (92%) delete mode 100644 packages/ts/sidecar/src/syntax/source-text.test.ts delete mode 100644 packages/ts/sidecar/src/syntax/source-text.ts diff --git a/packages/ts/sidecar/src/alias-specifiers.test.ts b/packages/ts/sidecar/src/alias-specifiers.test.ts index b1461bd..75b7e0e 100644 --- a/packages/ts/sidecar/src/alias-specifiers.test.ts +++ b/packages/ts/sidecar/src/alias-specifiers.test.ts @@ -2,14 +2,17 @@ import assert from 'node:assert/strict'; import { readdir, readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { test } from 'node:test'; -import { Ast } from '#sidecar/syntax/ast'; +import { AstReader } from '#sidecar/syntax/ast-reader'; import { isErr } from '#sidecar/kernel/result'; -import { Sources } from '#sidecar/syntax/sources'; +import { SourceParser } from '#sidecar/syntax/source-parser'; import type { Node } from '#sidecar/syntax/node-schema'; const sourceExtensions = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']); const exemptFiles = new Set(['sidecar.ts']); +const ast = new AstReader(); +const parser = new SourceParser(); + // Root the scan at this test's own directory so it keeps covering every source // file regardless of how the tree is nested, rather than at a single module's // resolved location, which would silently shift if that module ever moved. @@ -20,7 +23,7 @@ function isRelativeSpecifier(value: string): boolean { } function sourceValue(source: Node | undefined): string | null { - return source ? (Ast.stringValue(source) ?? null) : null; + return source ? (ast.stringValue(source) ?? null) : null; } function isSourceFile(name: string): boolean { @@ -53,17 +56,17 @@ async function listSourceFiles(dir: string): Promise { } function collectModuleSpecifiers(file: string, source: string): string[] { - const parsed = Sources.parse(file, source); + const parsed = parser.parse(file, source); const specifiers: string[] = []; if (isErr(parsed)) { return specifiers; } - Ast.visit(parsed.value.program, (node) => { + ast.visit(parsed.value.program, (node) => { if (node.type === 'ImportDeclaration' || node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') { const specifier = sourceValue( - Ast.childNode(node, 'source'), + ast.childNode(node, 'source'), ); if (specifier) { @@ -73,7 +76,7 @@ function collectModuleSpecifiers(file: string, source: string): string[] { if (node.type === 'ImportExpression') { const specifier = sourceValue( - Ast.childNode(node, 'source'), + ast.childNode(node, 'source'), ); if (specifier) { @@ -81,10 +84,10 @@ function collectModuleSpecifiers(file: string, source: string): string[] { } } - const callee = Ast.childNode(node, 'callee'); + const callee = ast.childNode(node, 'callee'); if (node.type === 'CallExpression' && callee?.type === 'Identifier' && callee.name === 'require') { - const specifier = sourceValue(Ast.childNodes(node, 'arguments')[0]); + const specifier = sourceValue(ast.childNodes(node, 'arguments')[0]); if (specifier) { specifiers.push(specifier); diff --git a/packages/ts/sidecar/src/blank-line-inserter.ts b/packages/ts/sidecar/src/blank-line-inserter.ts index 69aed90..9a97275 100644 --- a/packages/ts/sidecar/src/blank-line-inserter.ts +++ b/packages/ts/sidecar/src/blank-line-inserter.ts @@ -1,10 +1,14 @@ -import { Ast } from '#sidecar/syntax/ast'; +import { AstReader } from '#sidecar/syntax/ast-reader'; import { isErr } from '#sidecar/kernel/result'; import { Rules } from '#sidecar/rules'; -import { Sources } from '#sidecar/syntax/sources'; +import { SourceParser } from '#sidecar/syntax/source-parser'; /** Computes and applies the blank lines required by formatter rules. */ export class BlankLines { + static readonly #ast = new AstReader(); + + static readonly #parser = new SourceParser(); + static #countNewlines(source: string, from: number, to: number): number { let count = 0; @@ -25,13 +29,13 @@ export class BlankLines { * @returns Source offsets where one newline should be inserted. */ static computeInsertPositions(content: string, virtualName: string): number[] { - const parsed = Sources.parse(virtualName, content); + const parsed = BlankLines.#parser.parse(virtualName, content); if (isErr(parsed)) { return []; } - const lists = Ast.collectStatementLists(parsed.value.program); + const lists = BlankLines.#ast.collectStatementLists(parsed.value.program); const positions: number[] = []; for (const list of lists) { @@ -47,8 +51,8 @@ export class BlankLines { continue; } - const prevEnd = Ast.getEnd(prev); - const nextStart = Ast.getStart(next); + const prevEnd = BlankLines.#ast.getEnd(prev); + const nextStart = BlankLines.#ast.getStart(next); if (prevEnd < 0 || nextStart < 0 || nextStart <= prevEnd) { continue; diff --git a/packages/ts/sidecar/src/body-wrapper.test.ts b/packages/ts/sidecar/src/body-wrapper.test.ts index 587452b..b138d5e 100644 --- a/packages/ts/sidecar/src/body-wrapper.test.ts +++ b/packages/ts/sidecar/src/body-wrapper.test.ts @@ -1,12 +1,14 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { BodyWrapper } from '#sidecar/body-wrapper'; -import { Edits } from '#sidecar/syntax/edits'; +import { EditApplier } from '#sidecar/syntax/edits'; + +const editApplier = new EditApplier(); function wrapOnce(source: string): string { const edits = BodyWrapper.computeEdits(source, 'sample.ts'); - return edits.length > 0 ? Edits.apply(source, edits) : source; + return edits.length > 0 ? editApplier.apply(source, edits) : source; } function wrapFully(source: string): string { diff --git a/packages/ts/sidecar/src/body-wrapper.ts b/packages/ts/sidecar/src/body-wrapper.ts index 7c2604f..3e22050 100644 --- a/packages/ts/sidecar/src/body-wrapper.ts +++ b/packages/ts/sidecar/src/body-wrapper.ts @@ -1,7 +1,7 @@ -import { Ast } from '#sidecar/syntax/ast'; +import { AstReader } from '#sidecar/syntax/ast-reader'; import { isErr } from '#sidecar/kernel/result'; -import { SourceText } from '#sidecar/syntax/source-text'; -import { Sources } from '#sidecar/syntax/sources'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; import type { Edit } from '#sidecar/syntax/edits'; import type { Node } from '#sidecar/syntax/node-schema'; @@ -17,7 +17,11 @@ const STATEMENT_BODY_KEYS: Record = { /** Wraps unbraced statement bodies without changing unparsable source. */ export class BodyWrapper { - static #wrapStatementBody(source: string, owner: Node, body: Node, indentUnit: string): Edit | null { + static readonly #ast = new AstReader(); + + static readonly #parser = new SourceParser(); + + static #wrapStatementBody(document: SourceDocument, owner: Node, body: Node, indentUnit: string): Edit | null { if (body.type === 'BlockStatement') { return null; } @@ -26,16 +30,16 @@ export class BodyWrapper { return null; } - const start = Ast.getStart(body); - const end = Ast.getEnd(body); - const ownerStart = Ast.getStart(owner); + const start = BodyWrapper.#ast.getStart(body); + const end = BodyWrapper.#ast.getEnd(body); + const ownerStart = BodyWrapper.#ast.getStart(owner); if (start < 0 || end < 0 || ownerStart < 0) { return null; } - const indent = SourceText.lineIndent(source, ownerStart); - const bodySource = source.slice(start, end); + const indent = document.lineIndent(ownerStart); + const bodySource = document.slice(start, end); return { start, @@ -52,16 +56,17 @@ export class BodyWrapper { * @returns Non-overlapping body-wrap edits, or none for invalid source. */ static computeEdits(content: string, virtualName: string): Edit[] { - const parsed = Sources.parse(virtualName, content); + const parsed = BodyWrapper.#parser.parse(virtualName, content); if (isErr(parsed)) { return []; } + const document = SourceDocument.of(virtualName, content); const edits: Edit[] = []; - const indentUnit = SourceText.detectIndentUnit(content); + const indentUnit = document.indentUnit(); - Ast.visit(parsed.value.program, (node) => { + BodyWrapper.#ast.visit(parsed.value.program, (node) => { const bodyKeys = STATEMENT_BODY_KEYS[node.type]; if (!bodyKeys) { @@ -69,13 +74,13 @@ export class BodyWrapper { } for (const key of bodyKeys) { - const body = Ast.childNode(node, key); + const body = BodyWrapper.#ast.childNode(node, key); if (!body) { continue; } - const edit = BodyWrapper.#wrapStatementBody(content, node, body, indentUnit); + const edit = BodyWrapper.#wrapStatementBody(document, node, body, indentUnit); if (edit) { edits.push(edit); diff --git a/packages/ts/sidecar/src/class-reorder.test.ts b/packages/ts/sidecar/src/class-reorder.test.ts index f7cceeb..9fdc443 100644 --- a/packages/ts/sidecar/src/class-reorder.test.ts +++ b/packages/ts/sidecar/src/class-reorder.test.ts @@ -1,13 +1,13 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { ClassReorder } from '#sidecar/class-reorder'; -import { Edits } from '#sidecar/syntax/edits'; +import { EditApplier } from '#sidecar/syntax/edits'; test('class members are reordered as properties, constructors, then methods', () => { const input = ['class Example {', '\trun() {}', '\tvalue = 1;', '\tconstructor() {}', '}', ''].join('\n'); const edits = ClassReorder.computeEdits(input, 'fixture.ts'); - const output = Edits.apply(input, edits); + const output = new EditApplier().apply(input, edits); assert.equal(edits.length, 1); assert.equal(output, ['class Example {', '\tvalue = 1;', '\tconstructor() {}', '\trun() {}', '}', ''].join('\n')); diff --git a/packages/ts/sidecar/src/class-reorder.ts b/packages/ts/sidecar/src/class-reorder.ts index d61483f..2be68c4 100644 --- a/packages/ts/sidecar/src/class-reorder.ts +++ b/packages/ts/sidecar/src/class-reorder.ts @@ -1,12 +1,16 @@ -import { Ast } from '#sidecar/syntax/ast'; +import { AstReader } from '#sidecar/syntax/ast-reader'; import { isErr } from '#sidecar/kernel/result'; import { Rules } from '#sidecar/rules'; -import { Sources } from '#sidecar/syntax/sources'; +import { SourceParser } from '#sidecar/syntax/source-parser'; import type { Edit } from '#sidecar/syntax/edits'; import type { Node } from '#sidecar/syntax/node-schema'; /** Reorders class members into the formatter's stable class shape. */ export class ClassReorder { + static readonly #ast = new AstReader(); + + static readonly #parser = new SourceParser(); + static #containsComment(source: string): boolean { return /\/\/|\/\*/.test(source); } @@ -19,10 +23,10 @@ export class ClassReorder { return false; } - const bodyStart = Ast.getStart(body); - const bodyEnd = Ast.getEnd(body); - const firstStart = Ast.getStart(first); - const lastEnd = Ast.getEnd(last); + const bodyStart = ClassReorder.#ast.getStart(body); + const bodyEnd = ClassReorder.#ast.getEnd(body); + const firstStart = ClassReorder.#ast.getStart(first); + const lastEnd = ClassReorder.#ast.getEnd(last); if (ClassReorder.#containsComment(source.slice(bodyStart + 1, firstStart))) { return true; @@ -32,7 +36,7 @@ export class ClassReorder { const current = members[i]; const following = members[i + 1]; - if (current && following && ClassReorder.#containsComment(source.slice(Ast.getEnd(current), Ast.getStart(following)))) { + if (current && following && ClassReorder.#containsComment(source.slice(ClassReorder.#ast.getEnd(current), ClassReorder.#ast.getStart(following)))) { return true; } } @@ -41,7 +45,7 @@ export class ClassReorder { } static #computeClassReorderEdit(source: string, body: Node): Edit | null { - const members = Ast.childNodes(body, 'body'); + const members = ClassReorder.#ast.childNodes(body, 'body'); if (members.length < 2) { return null; @@ -73,8 +77,8 @@ export class ClassReorder { return null; } - const bodyStart = Ast.getStart(body); - const bodyEnd = Ast.getEnd(body); + const bodyStart = ClassReorder.#ast.getStart(body); + const bodyEnd = ClassReorder.#ast.getEnd(body); if (bodyStart < 0 || bodyEnd < 0 || ClassReorder.#hasCommentsAroundMembers(source, body, members)) { return null; @@ -87,7 +91,7 @@ export class ClassReorder { return null; } - const prefix = source.slice(bodyStart + 1, Ast.getStart(firstMember)); + const prefix = source.slice(bodyStart + 1, ClassReorder.#ast.getStart(firstMember)); const indent = prefix.match(/\n([ \t]*)$/)?.[1]; if (indent === undefined) { @@ -95,10 +99,10 @@ export class ClassReorder { } const memberSlices = desired.map((member) => { - return source.slice(Ast.getStart(member), Ast.getEnd(member)); + return source.slice(ClassReorder.#ast.getStart(member), ClassReorder.#ast.getEnd(member)); }); - const closing = source.slice(Ast.getEnd(lastOriginal), bodyEnd - 1); + const closing = source.slice(ClassReorder.#ast.getEnd(lastOriginal), bodyEnd - 1); return { start: bodyStart + 1, @@ -115,7 +119,7 @@ export class ClassReorder { * @returns Class-member ordering edits, or none for invalid source. */ static computeEdits(content: string, virtualName: string): Edit[] { - const parsed = Sources.parse(virtualName, content); + const parsed = ClassReorder.#parser.parse(virtualName, content); if (isErr(parsed)) { return []; @@ -123,7 +127,7 @@ export class ClassReorder { const edits: Edit[] = []; - for (const body of Ast.collectClassBodies(parsed.value.program)) { + for (const body of ClassReorder.#ast.collectClassBodies(parsed.value.program)) { const edit = ClassReorder.#computeClassReorderEdit(content, body); if (edit) { diff --git a/packages/ts/sidecar/src/declaration-reorder.test.ts b/packages/ts/sidecar/src/declaration-reorder.test.ts index 9c95718..1167123 100644 --- a/packages/ts/sidecar/src/declaration-reorder.test.ts +++ b/packages/ts/sidecar/src/declaration-reorder.test.ts @@ -1,12 +1,14 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { DeclarationReorder } from '#sidecar/declaration-reorder'; -import { Edits } from '#sidecar/syntax/edits'; +import { EditApplier } from '#sidecar/syntax/edits'; + +const editApplier = new EditApplier(); function reorder(source: string): string { const edits = DeclarationReorder.computeEdits(source, 'sample.ts'); - return edits.length > 0 ? Edits.apply(source, edits) : source; + return edits.length > 0 ? editApplier.apply(source, edits) : source; } test('moves single-line consts ahead of a multiline const when initializers are side-effect free', () => { diff --git a/packages/ts/sidecar/src/declaration-reorder.ts b/packages/ts/sidecar/src/declaration-reorder.ts index 9a370c4..61c3f19 100644 --- a/packages/ts/sidecar/src/declaration-reorder.ts +++ b/packages/ts/sidecar/src/declaration-reorder.ts @@ -1,24 +1,28 @@ -import { Ast } from '#sidecar/syntax/ast'; +import { AstReader } from '#sidecar/syntax/ast-reader'; import { Node } from '#sidecar/syntax/node-schema'; import { isErr } from '#sidecar/kernel/result'; -import { SourceText } from '#sidecar/syntax/source-text'; -import { Sources } from '#sidecar/syntax/sources'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; import type { Edit } from '#sidecar/syntax/edits'; /** Reorders declarations only where the transformation is side-effect safe. */ export class DeclarationReorder { - static #isMultiline(source: string, node: Node): boolean { - const start = Ast.getStart(node); - const end = Ast.getEnd(node); + static readonly #ast = new AstReader(); - return start >= 0 && end >= 0 && source.slice(start, end).includes('\n'); + static readonly #parser = new SourceParser(); + + static #isMultiline(document: SourceDocument, node: Node): boolean { + const start = DeclarationReorder.#ast.getStart(node); + const end = DeclarationReorder.#ast.getEnd(node); + + return start >= 0 && end >= 0 && document.slice(start, end).includes('\n'); } - static #nodeSource(source: string, node: Node): string { - const start = Ast.getStart(node); - const end = Ast.getEnd(node); + static #nodeSource(document: SourceDocument, node: Node): string { + const start = DeclarationReorder.#ast.getStart(node); + const end = DeclarationReorder.#ast.getEnd(node); - return `${SourceText.lineIndent(source, start)}${source.slice(start, end)}`; + return `${document.lineIndent(start)}${document.slice(start, end)}`; } static #isSideEffectSafeExpression(node: Node | undefined): boolean { @@ -62,7 +66,7 @@ export class DeclarationReorder { } if (property.type === 'SpreadElement') { - return DeclarationReorder.#isSideEffectSafeExpression(Ast.childNode(property, 'argument')); + return DeclarationReorder.#isSideEffectSafeExpression(DeclarationReorder.#ast.childNode(property, 'argument')); } if (property.type !== 'ObjectProperty' && property.type !== 'Property') { @@ -70,8 +74,8 @@ export class DeclarationReorder { } const computed = Boolean(property.computed); - const key = Ast.childNode(property, 'key'); - const value = Ast.childNode(property, 'value'); + const key = DeclarationReorder.#ast.childNode(property, 'key'); + const value = DeclarationReorder.#ast.childNode(property, 'value'); return (!computed || DeclarationReorder.#isSideEffectSafeExpression(key)) && DeclarationReorder.#isSideEffectSafeExpression(value); }) @@ -95,16 +99,16 @@ export class DeclarationReorder { } static #isSafeConstDeclaration(node: Node): boolean { - if (!Ast.isConstDeclaration(node)) { + if (!DeclarationReorder.#ast.isConstDeclaration(node)) { return false; } return ( Array.isArray(node.declarations) && - Ast.childNodes(node, 'declarations').every((declaration) => { - const id = Ast.childNode(declaration, 'id'); + DeclarationReorder.#ast.childNodes(node, 'declarations').every((declaration) => { + const id = DeclarationReorder.#ast.childNode(declaration, 'id'); - return id?.type === 'Identifier' && DeclarationReorder.#isSideEffectSafeExpression(Ast.childNode(declaration, 'init')); + return id?.type === 'Identifier' && DeclarationReorder.#isSideEffectSafeExpression(DeclarationReorder.#ast.childNode(declaration, 'init')); }) ); } @@ -113,9 +117,9 @@ export class DeclarationReorder { const names = new Set(); for (const node of nodes) { - for (const declaration of Ast.childNodes(node, 'declarations')) { - const id = Ast.childNode(declaration, 'id'); - const name = id ? Ast.nodeName(id) : undefined; + for (const declaration of DeclarationReorder.#ast.childNodes(node, 'declarations')) { + const id = DeclarationReorder.#ast.childNode(declaration, 'id'); + const name = id ? DeclarationReorder.#ast.nodeName(id) : undefined; if (id?.type === 'Identifier' && name !== undefined) { names.add(name); @@ -129,12 +133,12 @@ export class DeclarationReorder { static #usesAnyIdentifier(node: Node, names: Set): boolean { let found = false; - Ast.visit(node, (child) => { + DeclarationReorder.#ast.visit(node, (child) => { if (found || child.type !== 'Identifier') { return; } - const name = Ast.nodeName(child); + const name = DeclarationReorder.#ast.nodeName(child); if (name !== undefined && names.has(name)) { found = true; @@ -144,7 +148,7 @@ export class DeclarationReorder { return found; } - static #canReorderConstGroup(source: string, group: Node[]): boolean { + static #canReorderConstGroup(document: SourceDocument, group: Node[]): boolean { if ( !group.every((node) => { return DeclarationReorder.#isSafeConstDeclaration(node); @@ -156,7 +160,7 @@ export class DeclarationReorder { for (let i = 0; i < group.length; i++) { const node = group[i]; - if (!node || !DeclarationReorder.#isMultiline(source, node)) { + if (!node || !DeclarationReorder.#isMultiline(document, node)) { continue; } @@ -164,7 +168,7 @@ export class DeclarationReorder { if ( group.slice(i + 1).some((node) => { - return !DeclarationReorder.#isMultiline(source, node) && DeclarationReorder.#usesAnyIdentifier(node, names); + return !DeclarationReorder.#isMultiline(document, node) && DeclarationReorder.#usesAnyIdentifier(node, names); }) ) { return false; @@ -199,12 +203,12 @@ export class DeclarationReorder { return groups; } - static #groupEdit(source: string, group: Node[], canReorder: boolean): Edit | null { + static #groupEdit(document: SourceDocument, group: Node[], canReorder: boolean): Edit | null { const singleLine = group.filter((node) => { - return !DeclarationReorder.#isMultiline(source, node); + return !DeclarationReorder.#isMultiline(document, node); }); const multiline = group.filter((node) => { - return DeclarationReorder.#isMultiline(source, node); + return DeclarationReorder.#isMultiline(document, node); }); if (singleLine.length === 0 || multiline.length === 0) { @@ -216,9 +220,9 @@ export class DeclarationReorder { const replacement = desired .map((node, index) => { const previous = desired[index - 1]; - const separator = previous && (DeclarationReorder.#isMultiline(source, previous) || DeclarationReorder.#isMultiline(source, node)) ? '\n\n' : index > 0 ? '\n' : ''; + const separator = previous && (DeclarationReorder.#isMultiline(document, previous) || DeclarationReorder.#isMultiline(document, node)) ? '\n\n' : index > 0 ? '\n' : ''; - return `${separator}${DeclarationReorder.#nodeSource(source, node)}`; + return `${separator}${DeclarationReorder.#nodeSource(document, node)}`; }) .join(''); @@ -229,15 +233,15 @@ export class DeclarationReorder { return null; } - const firstStart = Ast.getStart(first); - const lastEnd = Ast.getEnd(last); + const firstStart = DeclarationReorder.#ast.getStart(first); + const lastEnd = DeclarationReorder.#ast.getEnd(last); if (firstStart < 0 || lastEnd < 0) { return null; } - const start = SourceText.lineStart(source, firstStart); - const current = source.slice(start, lastEnd); + const start = document.lineStart(firstStart); + const current = document.slice(start, lastEnd); if (current === replacement) { return null; @@ -265,13 +269,14 @@ export class DeclarationReorder { * @returns Safe declaration-ordering edits, or none for invalid source. */ static computeEdits(content: string, virtualName: string): Edit[] { - const parsed = Sources.parse(virtualName, content); + const parsed = DeclarationReorder.#parser.parse(virtualName, content); if (isErr(parsed)) { return []; } - const lists = Ast.collectStatementLists(parsed.value.program); + const document = SourceDocument.of(virtualName, content); + const lists = DeclarationReorder.#ast.collectStatementLists(parsed.value.program); const edits: Edit[] = []; for (const list of lists) { @@ -279,10 +284,12 @@ export class DeclarationReorder { return node.type === 'ImportDeclaration'; }); - const constGroups = DeclarationReorder.#splitGroups(list, Ast.isConstDeclaration); + const constGroups = DeclarationReorder.#splitGroups(list, (node) => { + return DeclarationReorder.#ast.isConstDeclaration(node); + }); for (const group of importGroups) { - const edit = DeclarationReorder.#groupEdit(content, group, true); + const edit = DeclarationReorder.#groupEdit(document, group, true); if (edit) { edits.push(edit); @@ -290,7 +297,7 @@ export class DeclarationReorder { } for (const group of constGroups) { - const edit = DeclarationReorder.#groupEdit(content, group, DeclarationReorder.#canReorderConstGroup(content, group)); + const edit = DeclarationReorder.#groupEdit(document, group, DeclarationReorder.#canReorderConstGroup(document, group)); if (edit) { edits.push(edit); diff --git a/packages/ts/sidecar/src/drizzle-queries.ts b/packages/ts/sidecar/src/drizzle-queries.ts index 81ac95a..e517bce 100644 --- a/packages/ts/sidecar/src/drizzle-queries.ts +++ b/packages/ts/sidecar/src/drizzle-queries.ts @@ -1,10 +1,11 @@ -import { Ast } from '#sidecar/syntax/ast'; -import { Edits } from '#sidecar/syntax/edits'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { EditApplier } from '#sidecar/syntax/edits'; import { FileTargets } from '#sidecar/hosts/file-targets'; import { Node } from '#sidecar/syntax/node-schema'; +import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; import { isErr } from '#sidecar/kernel/result'; -import { SourceText } from '#sidecar/syntax/source-text'; -import { Sources } from '#sidecar/syntax/sources'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; import type { Edit } from '#sidecar/syntax/edits'; type DrizzleImports = { @@ -112,8 +113,14 @@ const DRIZZLE_OBJECT_KEYS = new Set(['columns', 'extras', 'limit', 'offset', 'on /** Formats recognised Drizzle query structures without touching unrelated calls. */ export class DrizzleQueries { + static readonly #ast = new AstReader(); + + static readonly #editApplier = new EditApplier(); + + static readonly #parser = new SourceParser(); + static #localName(node: Node | undefined): string | null { - return node?.type === 'Identifier' ? (Ast.nodeName(node) ?? null) : null; + return node?.type === 'Identifier' ? (DrizzleQueries.#ast.nodeName(node) ?? null) : null; } static #literalValue(node: Node | undefined): string | null { @@ -121,7 +128,7 @@ export class DrizzleQueries { return null; } - return Ast.stringValue(node) ?? null; + return DrizzleQueries.#ast.stringValue(node) ?? null; } static #propertyName(member: Node | undefined): string | null { @@ -129,7 +136,7 @@ export class DrizzleQueries { return null; } - return DrizzleQueries.#localName(Ast.childNode(member, 'property')); + return DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(member, 'property')); } static #calleeName(callee: Node | undefined, imports: DrizzleImports): string | null { @@ -144,9 +151,9 @@ export class DrizzleQueries { } if (callee.type === 'MemberExpression' && !callee.computed) { - const object = Ast.childNode(callee, 'object'); + const object = DrizzleQueries.#ast.childNode(callee, 'object'); - const property = DrizzleQueries.#localName(Ast.childNode(callee, 'property')); + const property = DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(callee, 'property')); const objectName = DrizzleQueries.#localName(object); @@ -160,23 +167,23 @@ export class DrizzleQueries { static #collectDrizzleImports(program: Node): DrizzleImports { const imports: DrizzleImports = { locals: new Map(), namespaces: new Set() }; - const body = Ast.childNodes(program, 'body'); + const body = DrizzleQueries.#ast.childNodes(program, 'body'); for (const statement of body) { if (statement.type !== 'ImportDeclaration') { continue; } - const source = DrizzleQueries.#literalValue(Ast.childNode(statement, 'source')); + const source = DrizzleQueries.#literalValue(DrizzleQueries.#ast.childNode(statement, 'source')); if (!source?.startsWith(DRIZZLE_MODULE)) { continue; } - for (const specifier of Ast.childNodes(statement, 'specifiers')) { + for (const specifier of DrizzleQueries.#ast.childNodes(statement, 'specifiers')) { if (specifier.type === 'ImportSpecifier') { - const imported = DrizzleQueries.#localName(Ast.childNode(specifier, 'imported')); - const local = DrizzleQueries.#localName(Ast.childNode(specifier, 'local')); + const imported = DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(specifier, 'imported')); + const local = DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(specifier, 'local')); if (imported && local) { imports.locals.set(local, imported); @@ -184,7 +191,7 @@ export class DrizzleQueries { } if (specifier.type === 'ImportNamespaceSpecifier') { - const local = DrizzleQueries.#localName(Ast.childNode(specifier, 'local')); + const local = DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(specifier, 'local')); if (local) { imports.namespaces.add(local); @@ -197,7 +204,7 @@ export class DrizzleQueries { } static #chainHasQueryMember(node: Node | undefined): boolean { - const current = SourceText.unwrapChainExpression(node); + const current = DrizzleQueries.#ast.unwrapChainExpression(node); if (!current) { return false; @@ -208,18 +215,18 @@ export class DrizzleQueries { return true; } - return DrizzleQueries.#chainHasQueryMember(Ast.childNode(current, 'object')); + return DrizzleQueries.#chainHasQueryMember(DrizzleQueries.#ast.childNode(current, 'object')); } if (current.type === 'CallExpression') { - return DrizzleQueries.#chainHasQueryMember(Ast.childNode(current, 'callee')); + return DrizzleQueries.#chainHasQueryMember(DrizzleQueries.#ast.childNode(current, 'callee')); } return false; } static #isDrizzleReceiver(node: Node | undefined, imports: DrizzleImports): boolean { - const current = SourceText.unwrapChainExpression(node); + const current = DrizzleQueries.#ast.unwrapChainExpression(node); if (!current) { return false; @@ -232,7 +239,7 @@ export class DrizzleQueries { } if (current.type === 'MemberExpression') { - const object = Ast.childNode(current, 'object'); + const object = DrizzleQueries.#ast.childNode(current, 'object'); const property = DrizzleQueries.#propertyName(current); if (property === 'query') { @@ -243,7 +250,7 @@ export class DrizzleQueries { } if (current.type === 'CallExpression') { - const callee = SourceText.unwrapChainExpression(Ast.childNode(current, 'callee')); + const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(current, 'callee')); if (callee?.type === 'Identifier') { const imported = DrizzleQueries.#calleeName(callee, imports); @@ -255,10 +262,10 @@ export class DrizzleQueries { const method = DrizzleQueries.#propertyName(callee); if (method && DRIZZLE_CHAIN_METHODS.has(method)) { - return DrizzleQueries.#isDrizzleReceiver(Ast.childNode(callee, 'object'), imports); + return DrizzleQueries.#isDrizzleReceiver(DrizzleQueries.#ast.childNode(callee, 'object'), imports); } - return DrizzleQueries.#isDrizzleReceiver(Ast.childNode(callee, 'object'), imports); + return DrizzleQueries.#isDrizzleReceiver(DrizzleQueries.#ast.childNode(callee, 'object'), imports); } } @@ -266,13 +273,13 @@ export class DrizzleQueries { } static #methodName(call: Node): string | null { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); + const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); return callee?.type === 'MemberExpression' ? DrizzleQueries.#propertyName(callee) : null; } static #isDrizzleMethodCall(call: Node, imports: DrizzleImports): boolean { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); + const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); if (callee?.type !== 'MemberExpression') { return false; @@ -284,25 +291,25 @@ export class DrizzleQueries { return false; } - return DrizzleQueries.#isDrizzleReceiver(Ast.childNode(callee, 'object'), imports); + return DrizzleQueries.#isDrizzleReceiver(DrizzleQueries.#ast.childNode(callee, 'object'), imports); } static #isRelationalQueryCall(call: Node, imports: DrizzleImports): boolean { const name = DrizzleQueries.#methodName(call); - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); + const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); if ((name !== 'findMany' && name !== 'findFirst') || callee?.type !== 'MemberExpression') { return false; } - const object = Ast.childNode(callee, 'object'); + const object = DrizzleQueries.#ast.childNode(callee, 'object'); return DrizzleQueries.#chainHasQueryMember(object) && DrizzleQueries.#isDrizzleReceiver(object, imports); } static #isImportedHelperCall(call: Node, imports: DrizzleImports): boolean { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); + const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); const name = DrizzleQueries.#calleeName(callee, imports); @@ -310,7 +317,7 @@ export class DrizzleQueries { } static #isSetOperationCall(call: Node, imports: DrizzleImports): boolean { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); + const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); const name = DrizzleQueries.#calleeName(callee, imports); @@ -318,29 +325,29 @@ export class DrizzleQueries { } static #callDisplayName(source: string, call: Node, imports: DrizzleImports): string { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); + const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); if (callee?.type === 'Identifier') { - return SourceText.sourceOf(source, callee); + return DrizzleQueries.#ast.sourceOf(source, callee); } if (callee?.type === 'MemberExpression') { const name = DrizzleQueries.#calleeName(callee, imports); if (name) { - return SourceText.sourceOf(source, callee); + return DrizzleQueries.#ast.sourceOf(source, callee); } } - return callee ? SourceText.sourceOf(source, callee) : ''; + return callee ? DrizzleQueries.#ast.sourceOf(source, callee) : ''; } static #callParens(source: string, call: Node): { open: number; close: number } | null { - return SourceText.callParens(source, call, SourceText.unwrapChainExpression(Ast.childNode(call, 'callee'))); + return DrizzleQueries.#ast.callParens(source, call, DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee'))); } static #shouldFormatObjectExpression(node: Node): boolean { - const properties = Ast.childNodes(node, 'properties'); + const properties = DrizzleQueries.#ast.childNodes(node, 'properties'); if (properties.length > 1) { return true; @@ -351,9 +358,9 @@ export class DrizzleQueries { return true; } - const key = DrizzleQueries.#localName(Ast.childNode(property, 'key')); + const key = DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(property, 'key')); - const value = Ast.childNode(property, 'value'); + const value = DrizzleQueries.#ast.childNode(property, 'value'); if (!value) { return false; @@ -398,7 +405,7 @@ export class DrizzleQueries { } static #shouldFormatMethodArguments(call: Node, imports: DrizzleImports): boolean { - const args = Ast.childNodes(call, 'arguments'); + const args = DrizzleQueries.#ast.childNodes(call, 'arguments'); if (args.length === 0) { return false; @@ -435,9 +442,9 @@ export class DrizzleQueries { // Emission: render recognised structures and produce non-overlapping edits. - static #formatArrayExpression(source: string, node: Node, imports: DrizzleImports, comments: readonly Node[], indent: string, indentUnit: string): string { - if (SourceText.hasCommentBetween(comments, Ast.getStart(node), Ast.getEnd(node))) { - return SourceText.sourceOf(source, node); + static #formatArrayExpression(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(DrizzleQueries.#ast.getStart(node), DrizzleQueries.#ast.getEnd(node))) { + return DrizzleQueries.#ast.sourceOf(source, node); } const elements = Array.isArray(node.elements) ? node.elements : []; @@ -449,18 +456,18 @@ export class DrizzleQueries { const nextIndent = `${indent}${indentUnit}`; const formatted = elements.map((element) => { - return element instanceof Node ? DrizzleQueries.#formatNode(source, element, imports, comments, nextIndent, indentUnit) : ''; + return element instanceof Node ? DrizzleQueries.#formatNode(source, element, imports, parsed, nextIndent, indentUnit) : ''; }); return `[\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}]`; } - static #formatObjectExpression(source: string, node: Node, imports: DrizzleImports, comments: readonly Node[], indent: string, indentUnit: string): string { - if (SourceText.hasCommentBetween(comments, Ast.getStart(node), Ast.getEnd(node))) { - return SourceText.sourceOf(source, node); + static #formatObjectExpression(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(DrizzleQueries.#ast.getStart(node), DrizzleQueries.#ast.getEnd(node))) { + return DrizzleQueries.#ast.sourceOf(source, node); } - const properties = Ast.childNodes(node, 'properties'); + const properties = DrizzleQueries.#ast.childNodes(node, 'properties'); if (properties.length === 0) { return '{}'; @@ -470,106 +477,106 @@ export class DrizzleQueries { const formatted = properties.map((property) => { if (property.type !== 'Property') { - return SourceText.sourceOf(source, property); + return DrizzleQueries.#ast.sourceOf(source, property); } - const key = Ast.childNode(property, 'key'); - const value = Ast.childNode(property, 'value'); + const key = DrizzleQueries.#ast.childNode(property, 'key'); + const value = DrizzleQueries.#ast.childNode(property, 'value'); if (!key || !value || property.computed || property.method) { - return SourceText.sourceOf(source, property); + return DrizzleQueries.#ast.sourceOf(source, property); } if (property.shorthand) { - return SourceText.sourceOf(source, property); + return DrizzleQueries.#ast.sourceOf(source, property); } - return `${SourceText.sourceOf(source, key)}: ${DrizzleQueries.#formatNode(source, value, imports, comments, nextIndent, indentUnit)}`; + return `${DrizzleQueries.#ast.sourceOf(source, key)}: ${DrizzleQueries.#formatNode(source, value, imports, parsed, nextIndent, indentUnit)}`; }); return `{\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}}`; } - static #formatHelperCall(source: string, call: Node, imports: DrizzleImports, comments: readonly Node[], indent: string, indentUnit: string): string { - if (SourceText.hasCommentBetween(comments, Ast.getStart(call), Ast.getEnd(call))) { - return SourceText.sourceOf(source, call); + static #formatHelperCall(source: string, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(DrizzleQueries.#ast.getStart(call), DrizzleQueries.#ast.getEnd(call))) { + return DrizzleQueries.#ast.sourceOf(source, call); } - const importedName = DrizzleQueries.#calleeName(SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')), imports); + const importedName = DrizzleQueries.#calleeName(DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')), imports); - const args = Ast.childNodes(call, 'arguments'); + const args = DrizzleQueries.#ast.childNodes(call, 'arguments'); if (!importedName || !MULTILINE_HELPERS.has(importedName) || args.length === 0) { - return SourceText.sourceOf(source, call); + return DrizzleQueries.#ast.sourceOf(source, call); } const nextIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueries.#formatNode(source, arg, imports, comments, nextIndent, indentUnit)); + const formatted = args.map((arg) => DrizzleQueries.#formatNode(source, arg, imports, parsed, nextIndent, indentUnit)); return `${DrizzleQueries.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; } - static #formatSetOperationCall(source: string, call: Node, imports: DrizzleImports, comments: readonly Node[], indent: string, indentUnit: string): string { - if (SourceText.hasCommentBetween(comments, Ast.getStart(call), Ast.getEnd(call))) { - return SourceText.sourceOf(source, call); + static #formatSetOperationCall(source: string, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(DrizzleQueries.#ast.getStart(call), DrizzleQueries.#ast.getEnd(call))) { + return DrizzleQueries.#ast.sourceOf(source, call); } - const args = Ast.childNodes(call, 'arguments'); + const args = DrizzleQueries.#ast.childNodes(call, 'arguments'); if (args.length < 2) { - return SourceText.sourceOf(source, call); + return DrizzleQueries.#ast.sourceOf(source, call); } const nextIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueries.#formatNode(source, arg, imports, comments, nextIndent, indentUnit)); + const formatted = args.map((arg) => DrizzleQueries.#formatNode(source, arg, imports, parsed, nextIndent, indentUnit)); return `${DrizzleQueries.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; } - static #formatNode(source: string, node: Node, imports: DrizzleImports, comments: readonly Node[], indent: string, indentUnit: string): string { + static #formatNode(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { if (node.type === 'ObjectExpression' && DrizzleQueries.#shouldFormatObjectExpression(node)) { - return DrizzleQueries.#formatObjectExpression(source, node, imports, comments, indent, indentUnit); + return DrizzleQueries.#formatObjectExpression(source, node, imports, parsed, indent, indentUnit); } if (node.type === 'ArrayExpression' && DrizzleQueries.#shouldFormatArrayExpression(node)) { - return DrizzleQueries.#formatArrayExpression(source, node, imports, comments, indent, indentUnit); + return DrizzleQueries.#formatArrayExpression(source, node, imports, parsed, indent, indentUnit); } if (node.type === 'CallExpression') { if (DrizzleQueries.#isSetOperationCall(node, imports)) { - return DrizzleQueries.#formatSetOperationCall(source, node, imports, comments, indent, indentUnit); + return DrizzleQueries.#formatSetOperationCall(source, node, imports, parsed, indent, indentUnit); } if (DrizzleQueries.#isImportedHelperCall(node, imports)) { - return DrizzleQueries.#formatHelperCall(source, node, imports, comments, indent, indentUnit); + return DrizzleQueries.#formatHelperCall(source, node, imports, parsed, indent, indentUnit); } } - return SourceText.sourceOf(source, node); + return DrizzleQueries.#ast.sourceOf(source, node); } - static #formatCallArguments(source: string, call: Node, imports: DrizzleImports, comments: readonly Node[], indentUnit: string): Edit | null { - const parens = DrizzleQueries.#callParens(source, call); - const args = Ast.childNodes(call, 'arguments'); + static #formatCallArguments(document: SourceDocument, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indentUnit: string): Edit | null { + const parens = DrizzleQueries.#callParens(document.text, call); + const args = DrizzleQueries.#ast.childNodes(call, 'arguments'); if (!parens || args.length === 0) { return null; } - if (SourceText.hasCommentBetween(comments, parens.open, parens.close)) { + if (parsed.hasCommentBetween(parens.open, parens.close)) { return null; } - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); + const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); - const property = callee ? Ast.childNode(callee, 'property') : undefined; - const indentPos = callee?.type === 'MemberExpression' && property ? Ast.getStart(property) : Ast.getStart(call); - const indent = SourceText.lineIndent(source, indentPos); + const property = callee ? DrizzleQueries.#ast.childNode(callee, 'property') : undefined; + const indentPos = callee?.type === 'MemberExpression' && property ? DrizzleQueries.#ast.getStart(property) : DrizzleQueries.#ast.getStart(call); + const indent = document.lineIndent(indentPos); const argIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueries.#formatNode(source, arg, imports, comments, argIndent, indentUnit)); + const formatted = args.map((arg) => DrizzleQueries.#formatNode(document.text, arg, imports, parsed, argIndent, indentUnit)); const replacement = `(\n${argIndent}${formatted.join(`,\n${argIndent}`)},\n${indent})`; - if (source.slice(parens.open, parens.close + 1) === replacement) { + if (document.slice(parens.open, parens.close + 1) === replacement) { return null; } @@ -592,13 +599,13 @@ export class DrizzleQueries { return []; } - const parsed = Sources.parse(virtualName, content); + const parsed = DrizzleQueries.#parser.parse(virtualName, content); if (isErr(parsed)) { return []; } - const comments = parsed.value.comments; + const document = SourceDocument.of(virtualName, content); const imports = DrizzleQueries.#collectDrizzleImports(parsed.value.program); if (imports.locals.size === 0 && imports.namespaces.size === 0) { @@ -606,15 +613,15 @@ export class DrizzleQueries { } const edits: Edit[] = []; - const indentUnit = SourceText.detectIndentUnit(content); + const indentUnit = document.indentUnit(); - Ast.visit(parsed.value.program, (node) => { + DrizzleQueries.#ast.visit(parsed.value.program, (node) => { if (node.type !== 'CallExpression') { return; } if (DrizzleQueries.#isDrizzleMethodCall(node, imports) || DrizzleQueries.#isRelationalQueryCall(node, imports) || DrizzleQueries.#isSetOperationCall(node, imports)) { - const args = Ast.childNodes(node, 'arguments'); + const args = DrizzleQueries.#ast.childNodes(node, 'arguments'); if (DrizzleQueries.#isSetOperationCall(node, imports) && args.length > 0 && args.length < 2) { return; @@ -624,7 +631,7 @@ export class DrizzleQueries { return; } - const edit = DrizzleQueries.#formatCallArguments(content, node, imports, comments, indentUnit); + const edit = DrizzleQueries.#formatCallArguments(document, node, imports, parsed.value, indentUnit); if (edit) { edits.push(edit); @@ -632,7 +639,7 @@ export class DrizzleQueries { } }); - return Edits.nonOverlapping(edits); + return DrizzleQueries.#editApplier.nonOverlapping(edits); } /** @@ -645,6 +652,6 @@ export class DrizzleQueries { static format(content: string, virtualName: string): string { const edits = DrizzleQueries.computeEdits(content, virtualName); - return edits.length > 0 ? Edits.apply(content, edits) : content; + return edits.length > 0 ? DrizzleQueries.#editApplier.apply(content, edits) : content; } } diff --git a/packages/ts/sidecar/src/expanded-calls.ts b/packages/ts/sidecar/src/expanded-calls.ts index 621eb8f..fd421ac 100644 --- a/packages/ts/sidecar/src/expanded-calls.ts +++ b/packages/ts/sidecar/src/expanded-calls.ts @@ -1,11 +1,12 @@ -import { Ast } from '#sidecar/syntax/ast'; -import { Edits } from '#sidecar/syntax/edits'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import type { CallParens } from '#sidecar/syntax/ast-reader'; +import { EditApplier } from '#sidecar/syntax/edits'; import { FileTargets } from '#sidecar/hosts/file-targets'; import { Node } from '#sidecar/syntax/node-schema'; +import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; import { isErr } from '#sidecar/kernel/result'; -import { SourceText } from '#sidecar/syntax/source-text'; -import type { CallParens } from '#sidecar/syntax/source-text'; -import { Sources } from '#sidecar/syntax/sources'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; import { TemplateSpans } from '#sidecar/syntax/template-spans'; import type { Edit } from '#sidecar/syntax/edits'; @@ -13,6 +14,12 @@ const FUNCTION_TYPES = new Set(['ArrowFunctionExpression', 'FunctionDeclaration' /** Expands structurally complex call arguments into stable multiline layouts. */ export class ExpandedCalls { + static readonly #ast = new AstReader(); + + static readonly #editApplier = new EditApplier(); + + static readonly #parser = new SourceParser(); + static #unwrapExpression(node: Node | undefined): Node | undefined { let current = node; @@ -25,22 +32,22 @@ export class ExpandedCalls { current.type === 'TSNonNullExpression' || current.type === 'TSTypeAssertion') ) { - current = Ast.childNode(current, 'expression'); + current = ExpandedCalls.#ast.childNode(current, 'expression'); } return current; } - static #calleeParens(source: string, call: Node): CallParens | null { - return SourceText.callParens(source, call, ExpandedCalls.#unwrapExpression(Ast.childNode(call, 'callee'))); + static #calleeParens(document: SourceDocument, call: Node): CallParens | null { + return ExpandedCalls.#ast.callParens(document.text, call, ExpandedCalls.#unwrapExpression(ExpandedCalls.#ast.childNode(call, 'callee'))); } static #callArguments(call: Node): Node[] { - return Ast.childNodes(call, 'arguments'); + return ExpandedCalls.#ast.childNodes(call, 'arguments'); } static #isMethodCall(call: Node): boolean { - const callee = ExpandedCalls.#unwrapExpression(Ast.childNode(call, 'callee')); + const callee = ExpandedCalls.#unwrapExpression(ExpandedCalls.#ast.childNode(call, 'callee')); return callee?.type === 'MemberExpression'; } @@ -74,12 +81,12 @@ export class ExpandedCalls { } static #isInsideCallArgument(node: Node, call: Node): boolean { - const start = Ast.getStart(node); - const end = Ast.getEnd(node); + const start = ExpandedCalls.#ast.getStart(node); + const end = ExpandedCalls.#ast.getEnd(node); const args = ExpandedCalls.#callArguments(call); return args.some((arg) => { - return Ast.getStart(arg) <= start && end <= Ast.getEnd(arg); + return ExpandedCalls.#ast.getStart(arg) <= start && end <= ExpandedCalls.#ast.getEnd(arg); }); } @@ -143,10 +150,10 @@ export class ExpandedCalls { * sitting at its target depth is left alone. Only the first line is skipped * outright — the caller places it. */ - static #rebaseIndent(source: string, node: Node, to: string, spans: TemplateSpans): string { - const start = Ast.getStart(node); - const text = SourceText.sourceOf(source, node); - const from = SourceText.lineIndent(source, start); + static #rebaseIndent(document: SourceDocument, node: Node, to: string, spans: TemplateSpans): string { + const start = ExpandedCalls.#ast.getStart(node); + const text = ExpandedCalls.#ast.sourceOf(document.text, node); + const from = document.lineIndent(start); if (from === to || !text.includes('\n')) { return text; @@ -164,11 +171,11 @@ export class ExpandedCalls { return rebased.join('\n'); } - static #formatCallParens(source: string, call: Node, comments: readonly Node[], indent: string, indentUnit: string, spans: TemplateSpans): string | null { - const parens = ExpandedCalls.#calleeParens(source, call); + static #formatCallParens(document: SourceDocument, call: Node, parsed: ParsedSourceDto, indent: string, indentUnit: string, spans: TemplateSpans): string | null { + const parens = ExpandedCalls.#calleeParens(document, call); const args = ExpandedCalls.#callArguments(call); - if (!parens || args.length === 0 || SourceText.hasCommentBetween(comments, parens.open, parens.close)) { + if (!parens || args.length === 0 || parsed.hasCommentBetween(parens.open, parens.close)) { return null; } @@ -179,7 +186,7 @@ export class ExpandedCalls { const argIndent = `${indent}${indentUnit}`; const formattedArgs = args.map((arg) => { - return ExpandedCalls.#formatNode(source, arg, comments, argIndent, indentUnit, spans); + return ExpandedCalls.#formatNode(document, arg, parsed, argIndent, indentUnit, spans); }); const separator = `,\n${argIndent}`; @@ -188,30 +195,30 @@ export class ExpandedCalls { return `(\n${argIndent}${formattedArgs.join(separator)}${trailingComma}\n${indent})`; } - static #formatCall(source: string, call: Node, comments: readonly Node[], indent: string, indentUnit: string, spans: TemplateSpans): string { - const parens = ExpandedCalls.#calleeParens(source, call); - const formattedParens = ExpandedCalls.#formatCallParens(source, call, comments, indent, indentUnit, spans); + static #formatCall(document: SourceDocument, call: Node, parsed: ParsedSourceDto, indent: string, indentUnit: string, spans: TemplateSpans): string { + const parens = ExpandedCalls.#calleeParens(document, call); + const formattedParens = ExpandedCalls.#formatCallParens(document, call, parsed, indent, indentUnit, spans); if (!parens || formattedParens === null) { - return ExpandedCalls.#rebaseIndent(source, call, indent, spans); + return ExpandedCalls.#rebaseIndent(document, call, indent, spans); } - return `${source.slice(Ast.getStart(call), parens.open)}${formattedParens}`; + return `${document.slice(ExpandedCalls.#ast.getStart(call), parens.open)}${formattedParens}`; } // indent is where node will sit once expanded; the depth its text came from is // read back off the node's own line, because nothing has moved in the source // yet however deep the recursion goes. - static #formatNode(source: string, node: Node, comments: readonly Node[], indent: string, indentUnit: string, spans: TemplateSpans): string { + static #formatNode(document: SourceDocument, node: Node, parsed: ParsedSourceDto, indent: string, indentUnit: string, spans: TemplateSpans): string { if (node.type !== 'CallExpression') { - return ExpandedCalls.#rebaseIndent(source, node, indent, spans); + return ExpandedCalls.#rebaseIndent(document, node, indent, spans); } if (!ExpandedCalls.#shouldExpandCall(node)) { - return ExpandedCalls.#rebaseIndent(source, node, indent, spans); + return ExpandedCalls.#rebaseIndent(document, node, indent, spans); } - return ExpandedCalls.#formatCall(source, node, comments, indent, indentUnit, spans); + return ExpandedCalls.#formatCall(document, node, parsed, indent, indentUnit, spans); } /** * Compute edits for calls whose arguments require a multiline layout. @@ -225,21 +232,21 @@ export class ExpandedCalls { return []; } - const parsed = Sources.parse(virtualName, content); + const parsed = ExpandedCalls.#parser.parse(virtualName, content); if (isErr(parsed)) { return []; } - const comments = parsed.value.comments; + const document = SourceDocument.of(virtualName, content); const parents = new WeakMap(); const edits: Edit[] = []; - const indentUnit = SourceText.detectIndentUnit(content); + const indentUnit = document.indentUnit(); const spans = TemplateSpans.collect(parsed.value.program); ExpandedCalls.#collectParents(parsed.value.program, parents); - Ast.visit(parsed.value.program, (node) => { + ExpandedCalls.#ast.visit(parsed.value.program, (node) => { if (node.type !== 'CallExpression') { return; } @@ -252,16 +259,16 @@ export class ExpandedCalls { return; } - const parens = ExpandedCalls.#calleeParens(content, node); + const parens = ExpandedCalls.#calleeParens(document, node); - if (!parens || SourceText.hasCommentBetween(comments, parens.open, parens.close)) { + if (!parens || parsed.value.hasCommentBetween(parens.open, parens.close)) { return; } - const indent = SourceText.lineIndent(content, Ast.getStart(node)); + const indent = document.lineIndent(ExpandedCalls.#ast.getStart(node)); - const replacement = ExpandedCalls.#formatCallParens(content, node, comments, indent, indentUnit, spans); - const current = content.slice(parens.open, parens.close + 1); + const replacement = ExpandedCalls.#formatCallParens(document, node, parsed.value, indent, indentUnit, spans); + const current = document.slice(parens.open, parens.close + 1); if (replacement === null || replacement === current) { return; @@ -274,7 +281,7 @@ export class ExpandedCalls { }); }); - return Edits.nonOverlapping(edits); + return ExpandedCalls.#editApplier.nonOverlapping(edits); } /** @@ -287,6 +294,6 @@ export class ExpandedCalls { static format(content: string, virtualName: string): string { const edits = ExpandedCalls.computeEdits(content, virtualName); - return edits.length > 0 ? Edits.apply(content, edits) : content; + return edits.length > 0 ? ExpandedCalls.#editApplier.apply(content, edits) : content; } } diff --git a/packages/ts/sidecar/src/fluent-chains.ts b/packages/ts/sidecar/src/fluent-chains.ts index 03b692a..032ed9f 100644 --- a/packages/ts/sidecar/src/fluent-chains.ts +++ b/packages/ts/sidecar/src/fluent-chains.ts @@ -1,15 +1,16 @@ import { pathToFileURL } from 'node:url'; -import { Ast } from '#sidecar/syntax/ast'; +import { AstReader } from '#sidecar/syntax/ast-reader'; import { DrizzleQueries } from '#sidecar/drizzle-queries'; -import { Edits } from '#sidecar/syntax/edits'; +import { EditApplier } from '#sidecar/syntax/edits'; import { EmbeddedBlocks } from '#sidecar/hosts/embedded-blocks'; import { ExpandedCalls } from '#sidecar/expanded-calls'; import { PassCliDto } from '#sidecar/pass-cli-dto'; import { isErr, ok } from '#sidecar/kernel/result'; +import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; import type { Result } from '#sidecar/kernel/result'; import type { SourceFileError, SourceFiles } from '#sidecar/io/source-files'; -import { SourceText } from '#sidecar/syntax/source-text'; -import { Sources } from '#sidecar/syntax/sources'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; import type { Edit } from '#sidecar/syntax/edits'; import type { Node } from '#sidecar/syntax/node-schema'; @@ -28,29 +29,35 @@ type FluentChain = { /** Formats fluent chains and the structured calls composed with them. */ export class FluentChains { - static #memberCallLink(source: string, member: Node, object: Node, comments: readonly Node[]): ChainLink | null { + static readonly #ast = new AstReader(); + + static readonly #editApplier = new EditApplier(); + + static readonly #parser = new SourceParser(); + + static #memberCallLink(document: SourceDocument, member: Node, object: Node, parsed: ParsedSourceDto): ChainLink | null { if (member.computed) { return null; } - const property = Ast.childNode(member, 'property'); + const property = FluentChains.#ast.childNode(member, 'property'); if (!property || (property.type !== 'Identifier' && property.type !== 'PrivateIdentifier')) { return null; } - const objectEnd = Ast.getEnd(object); - const propertyStart = Ast.getStart(property); + const objectEnd = FluentChains.#ast.getEnd(object); + const propertyStart = FluentChains.#ast.getStart(property); if (objectEnd < 0 || propertyStart < 0 || propertyStart <= objectEnd) { return null; } - if (SourceText.hasCommentBetween(comments, objectEnd, propertyStart)) { + if (parsed.hasCommentBetween(objectEnd, propertyStart)) { return null; } - const separator = source.slice(objectEnd, propertyStart); + const separator = document.slice(objectEnd, propertyStart); if (separator.includes('//') || separator.includes('/*')) { return null; @@ -69,25 +76,25 @@ export class FluentChains { }; } - static #collectFluentChain(source: string, outer: Node, comments: readonly Node[]): FluentChain | null { + static #collectFluentChain(document: SourceDocument, outer: Node, parsed: ParsedSourceDto): FluentChain | null { let call: Node = outer; const links: ChainLink[] = []; while (call.type === 'CallExpression') { - const callee = SourceText.unwrapChainExpression(Ast.childNode(call, 'callee')); + const callee = FluentChains.#ast.unwrapChainExpression(FluentChains.#ast.childNode(call, 'callee')); if (callee?.type !== 'MemberExpression') { break; } - const object = SourceText.unwrapChainExpression(Ast.childNode(callee, 'object')); + const object = FluentChains.#ast.unwrapChainExpression(FluentChains.#ast.childNode(callee, 'object')); if (object?.type !== 'CallExpression') { break; } - const link = FluentChains.#memberCallLink(source, callee, object, comments); + const link = FluentChains.#memberCallLink(document, callee, object, parsed); if (!link) { return null; @@ -114,39 +121,39 @@ export class FluentChains { * @returns Fluent-chain edits, or none for invalid source. */ static computeEdits(content: string, virtualName: string): Edit[] { - const parsed = Sources.parse(virtualName, content); + const parsed = FluentChains.#parser.parse(virtualName, content); if (isErr(parsed)) { return []; } - const comments = parsed.value.comments; + const document = SourceDocument.of(virtualName, content); const edits = new Map(); - const indentStep = SourceText.detectIndentUnit(content); + const indentStep = document.indentUnit(); - Ast.visit(parsed.value.program, (node) => { + FluentChains.#ast.visit(parsed.value.program, (node) => { if (node.type !== 'CallExpression') { return; } - const chain = FluentChains.#collectFluentChain(content, node, comments); + const chain = FluentChains.#collectFluentChain(document, node, parsed.value); if (!chain) { return; } - const baseStart = Ast.getStart(chain.base); + const baseStart = FluentChains.#ast.getStart(chain.base); if (baseStart < 0) { return; } - const indent = `${SourceText.lineIndent(content, baseStart)}${indentStep}`; + const indent = `${document.lineIndent(baseStart)}${indentStep}`; for (const link of chain.links) { const replacement = `\n${indent}${link.operator}`; - if (content.slice(link.start, link.end) === replacement) { + if (document.slice(link.start, link.end) === replacement) { continue; } @@ -173,7 +180,7 @@ export class FluentChains { static format(content: string, virtualName: string): string { const edits = FluentChains.computeEdits(content, virtualName); - const fluentFormatted = edits.length > 0 ? Edits.apply(content, edits) : content; + const fluentFormatted = edits.length > 0 ? FluentChains.#editApplier.apply(content, edits) : content; const drizzleFormatted = DrizzleQueries.format(fluentFormatted, virtualName); return ExpandedCalls.format(drizzleFormatted, virtualName); diff --git a/packages/ts/sidecar/src/format-pipeline.ts b/packages/ts/sidecar/src/format-pipeline.ts index eabbd57..cbd3452 100644 --- a/packages/ts/sidecar/src/format-pipeline.ts +++ b/packages/ts/sidecar/src/format-pipeline.ts @@ -7,7 +7,7 @@ import { isErr, ok } from '#sidecar/kernel/result'; import type { Result } from '#sidecar/kernel/result'; import { Segment } from '#sidecar/segment'; import type { SourceFileError, SourceFiles } from '#sidecar/io/source-files'; -import { Sources } from '#sidecar/syntax/sources'; +import { SourceParser } from '#sidecar/syntax/source-parser'; const OXFMT_CHUNK_SIZE = 100; @@ -57,6 +57,8 @@ type ProcessOne = (file: string, mode: FormatMode) => Promise { - return Rules.#isVuePrimitiveCall(Ast.childNode(declaration, 'init')); + return Rules.#ast.childNodes(node, 'declarations').some((declaration) => { + return Rules.#isVuePrimitiveCall(Rules.#ast.childNode(declaration, 'init')); }); } @@ -105,7 +107,7 @@ export class Rules { } if (previous.type === 'ExportNamedDeclaration') { - const declarationType = Ast.childNode(previous, 'declaration')?.type; + const declarationType = Rules.#ast.childNode(previous, 'declaration')?.type; return declarationType ? TS_TYPE_DECLARATION_TYPES.has(declarationType) : false; } @@ -123,7 +125,7 @@ export class Rules { } if (previous.type === 'ExportNamedDeclaration' || previous.type === 'ExportDefaultDeclaration') { - const declarationType = Ast.childNode(previous, 'declaration')?.type; + const declarationType = Rules.#ast.childNode(previous, 'declaration')?.type; return Boolean(declarationType && STRUCTURED_PREVIOUS_STATEMENTS.has(declarationType)); } @@ -140,7 +142,7 @@ export class Rules { } static #isLetDeclaration(node: Node): boolean { - return node.type === 'VariableDeclaration' && Ast.declarationKind(node) === 'let'; + return node.type === 'VariableDeclaration' && Rules.#ast.declarationKind(node) === 'let'; } static #containsAwait(node: Node): boolean { @@ -193,7 +195,7 @@ export class Rules { return true; } - if (Ast.isConstDeclaration(previous) !== Ast.isConstDeclaration(next)) { + if (Rules.#ast.isConstDeclaration(previous) !== Rules.#ast.isConstDeclaration(next)) { return true; } @@ -219,7 +221,7 @@ export class Rules { return 'property'; } - if (node.type === 'MethodDefinition' && Ast.declarationKind(node) === 'constructor') { + if (node.type === 'MethodDefinition' && Rules.#ast.declarationKind(node) === 'constructor') { return 'constructor'; } diff --git a/packages/ts/sidecar/src/segment.ts b/packages/ts/sidecar/src/segment.ts index 1dcc40d..6047542 100644 --- a/packages/ts/sidecar/src/segment.ts +++ b/packages/ts/sidecar/src/segment.ts @@ -2,10 +2,12 @@ import { BlankLines } from '#sidecar/blank-line-inserter'; import { BodyWrapper } from '#sidecar/body-wrapper'; import { ClassReorder } from '#sidecar/class-reorder'; import { DeclarationReorder } from '#sidecar/declaration-reorder'; -import { Edits } from '#sidecar/syntax/edits'; +import { EditApplier } from '#sidecar/syntax/edits'; /** Applies the sidecar's source-segment formatting passes in order. */ export class Segment { + static readonly #editApplier = new EditApplier(); + static #applyBodyWraps(content: string, virtualName: string): string { let current = content; @@ -16,7 +18,7 @@ export class Segment { return current; } - current = Edits.apply(current, edits); + current = Segment.#editApplier.apply(current, edits); } return current; @@ -32,9 +34,9 @@ export class Segment { static process(content: string, virtualName: string): string { const bodyWrapped = Segment.#applyBodyWraps(content, virtualName); const classReorderEdits = ClassReorder.computeEdits(bodyWrapped, virtualName); - const classReordered = classReorderEdits.length > 0 ? Edits.apply(bodyWrapped, classReorderEdits) : bodyWrapped; + const classReordered = classReorderEdits.length > 0 ? Segment.#editApplier.apply(bodyWrapped, classReorderEdits) : bodyWrapped; const declarationReorderEdits = DeclarationReorder.computeEdits(classReordered, virtualName); - const reordered = declarationReorderEdits.length > 0 ? Edits.apply(classReordered, declarationReorderEdits) : classReordered; + const reordered = declarationReorderEdits.length > 0 ? Segment.#editApplier.apply(classReordered, declarationReorderEdits) : classReordered; const positions = BlankLines.computeInsertPositions(reordered, virtualName); return BlankLines.insert(reordered, positions); diff --git a/packages/ts/sidecar/src/syntax/ast-reader.test.ts b/packages/ts/sidecar/src/syntax/ast-reader.test.ts new file mode 100644 index 0000000..99ed23d --- /dev/null +++ b/packages/ts/sidecar/src/syntax/ast-reader.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { Node } from '#sidecar/syntax/node-schema'; +import { isErr } from '#sidecar/kernel/result'; +import { SourceParser } from '#sidecar/syntax/source-parser'; + +test('AstReader traverses parsed fixtures and reads validated node fields', () => { + const source = [ + "import value from 'fixture';", + 'const answer = 42;', + 'class Example {', + "\tfield = 'ready';", + '\tmethod() {', + '\t\treturn this.field;', + '\t}', + '}', + 'switch (answer) {', + '\tcase 42:', + '\t\tanswer;', + '}', + '// note', + '', + ].join('\n'); + + const ast = new AstReader(); + const parsed = new SourceParser().parse('fixture.ts', source); + + assert.equal(isErr(parsed), false); + + if (isErr(parsed)) { + return; + } + + const statements = ast.childNodes(parsed.value.program, 'body'); + const importDeclaration = statements[0]; + const variableDeclaration = statements[1]; + const classDeclaration = statements[2]; + + assert.equal(ast.childNode(parsed.value.program, 'body'), undefined); + + assert.deepEqual(ast.childNodes(parsed.value.program, 'missing'), []); + + assert.equal(importDeclaration && ast.stringValue(ast.childNode(importDeclaration, 'source') ?? importDeclaration), 'fixture'); + + assert.equal(variableDeclaration && ast.declarationKind(variableDeclaration), 'const'); + + assert.equal(variableDeclaration && ast.isConstDeclaration(variableDeclaration), true); + + assert.equal(classDeclaration && ast.nodeName(ast.childNode(classDeclaration, 'id') ?? classDeclaration), 'Example'); + + assert.equal(classDeclaration && ast.sourceOf(source, classDeclaration).startsWith('class Example'), true); + + const visited: string[] = []; + + ast.visit(parsed.value.program, (node) => { + visited.push(node.type); + }); + + assert.equal(visited[0], 'Program'); + + assert.ok(visited.includes('ReturnStatement')); + + assert.ok(ast.collectStatementLists(parsed.value.program).some((list) => list.some((node) => node.type === 'SwitchCase'))); + + assert.equal(ast.collectClassBodies(parsed.value.program).length, 1); + + assert.equal(ast.stringValue(parsed.value.comments[0] ?? parsed.value.program), ' note'); +}); + +test('AstReader position and scalar accessors preserve their fallbacks', () => { + const ast = new AstReader(); + const ranged = Node.schema.parse({ type: 'Identifier', range: [4, 9], name: 17, kind: false }); + + assert.equal(ast.getStart(ranged), 4); + + assert.equal(ast.getEnd(ranged), 9); + + assert.equal(ast.nodeName(ranged), undefined); + + assert.equal(ast.declarationKind(ranged), undefined); + + assert.equal(ast.getStart(Node.schema.parse({ type: 'Identifier' })), -1); +}); + +test('AstReader.callParens locates argument parentheses and rejects non-calls', () => { + const ast = new AstReader(); + const source = 'wrap(value);\n'; + const parsed = new SourceParser().parse('fixture.ts', source); + + assert.equal(isErr(parsed), false); + + if (isErr(parsed)) { + return; + } + + const statement = ast.childNodes(parsed.value.program, 'body')[0]; + const call = statement && ast.childNode(statement, 'expression'); + + assert.ok(call); + + const callee = ast.unwrapChainExpression(ast.childNode(call, 'callee')); + const parens = ast.callParens(source, call, callee); + + assert.deepEqual(parens, { open: source.indexOf('('), close: source.indexOf(')') }); + + assert.equal(ast.callParens(source, call, undefined), null); +}); + +test('AstReader.unwrapChainExpression returns the wrapped expression or the node itself', () => { + const ast = new AstReader(); + const parsed = new SourceParser().parse('fixture.ts', 'a?.b();\n'); + + assert.equal(isErr(parsed), false); + + if (isErr(parsed)) { + return; + } + + const statement = ast.childNodes(parsed.value.program, 'body')[0]; + const expression = statement && ast.childNode(statement, 'expression'); + + assert.ok(expression); + + assert.equal(ast.unwrapChainExpression(expression)?.type, 'CallExpression'); + + assert.equal(ast.unwrapChainExpression(undefined), undefined); +}); diff --git a/packages/ts/sidecar/src/syntax/ast.ts b/packages/ts/sidecar/src/syntax/ast-reader.ts similarity index 56% rename from packages/ts/sidecar/src/syntax/ast.ts rename to packages/ts/sidecar/src/syntax/ast-reader.ts index b1caa84..bd63568 100644 --- a/packages/ts/sidecar/src/syntax/ast.ts +++ b/packages/ts/sidecar/src/syntax/ast-reader.ts @@ -2,6 +2,15 @@ import { z } from 'zod'; import { Node } from '#sidecar/syntax/node-schema'; import type { AstValue } from '#sidecar/syntax/node-schema'; +/** The opening and closing argument-parenthesis offsets of a call. */ +export type CallParens = { + /** The opening parenthesis offset. */ + readonly open: number; + + /** The closing parenthesis offset. */ + readonly close: number; +}; + const STATEMENT_LIST_KEYS: Record = { Program: 'body', BlockStatement: 'body', @@ -10,10 +19,10 @@ const STATEMENT_LIST_KEYS: Record = { ClassBody: 'body', }; -/** Traverses boundary-admitted AST nodes and validates narrow scalar reads. */ -export class Ast { - static readonly #stringSchema = z.string(); +const stringSchema = z.string(); +/** Traverses boundary-admitted AST nodes and validates narrow scalar reads. */ +export class AstReader { /** * Read a child node off a parent property. * @@ -21,7 +30,7 @@ export class Ast { * @param key - The property to read. * @returns The child when the property holds a node, `undefined` otherwise. */ - static childNode(node: Node, key: string): Node | undefined { + childNode(node: Node, key: string): Node | undefined { const value = node[key]; return value instanceof Node ? value : undefined; @@ -34,7 +43,7 @@ export class Ast { * @param key - The property to read. * @returns The property's node entries, or an empty array when it is not an array. */ - static childNodes(node: Node, key: string): Node[] { + childNodes(node: Node, key: string): Node[] { const value = node[key]; return Array.isArray(value) @@ -50,8 +59,8 @@ export class Ast { * @param node - The node whose name to read. * @returns The validated name, or `undefined` when it is absent or invalid. */ - static nodeName(node: Node): string | undefined { - return Ast.#stringValue(node.name); + nodeName(node: Node): string | undefined { + return this.#stringValue(node.name); } /** @@ -60,8 +69,8 @@ export class Ast { * @param node - The node whose declaration kind to read. * @returns The validated kind, or `undefined` when it is absent or invalid. */ - static declarationKind(node: Node): string | undefined { - return Ast.#stringValue(node.kind); + declarationKind(node: Node): string | undefined { + return this.#stringValue(node.kind); } /** @@ -70,8 +79,8 @@ export class Ast { * @param node - The literal or comment node whose value to read. * @returns The validated value, or `undefined` when it is absent or invalid. */ - static stringValue(node: Node): string | undefined { - return Ast.#stringValue(node.value); + stringValue(node: Node): string | undefined { + return this.#stringValue(node.value); } /** @@ -80,8 +89,8 @@ export class Ast { * @param node - The node to inspect. * @returns `true` when the node declares one or more constants. */ - static isConstDeclaration(node: Node): boolean { - return node.type === 'VariableDeclaration' && Ast.declarationKind(node) === 'const'; + isConstDeclaration(node: Node): boolean { + return node.type === 'VariableDeclaration' && this.declarationKind(node) === 'const'; } /** @@ -90,7 +99,7 @@ export class Ast { * @param node - The node whose start offset to read. * @returns The source start, or `-1` when no position is available. */ - static getStart(node: Node): number { + getStart(node: Node): number { return node.start ?? node.range?.[0] ?? -1; } @@ -100,7 +109,7 @@ export class Ast { * @param node - The node whose end offset to read. * @returns The source end, or `-1` when no position is available. */ - static getEnd(node: Node): number { + getEnd(node: Node): number { return node.end ?? node.range?.[1] ?? -1; } @@ -111,18 +120,18 @@ export class Ast { * @param visitor - The operation invoked once for each node. * @returns Nothing. */ - static visit(node: Node, visitor: (node: Node) => void): void { + visit(node: Node, visitor: (node: Node) => void): void { visitor(node); for (const value of Object.values(node)) { if (Array.isArray(value)) { for (const child of value) { if (child instanceof Node) { - Ast.visit(child, visitor); + this.visit(child, visitor); } } } else if (value instanceof Node) { - Ast.visit(value, visitor); + this.visit(value, visitor); } } } @@ -133,18 +142,18 @@ export class Ast { * @param program - The program root to traverse. * @returns Statement lists in depth-first traversal order. */ - static collectStatementLists(program: Node): Node[][] { + collectStatementLists(program: Node): Node[][] { const lists: Node[][] = []; - Ast.visit(program, (node) => { + this.visit(program, (node) => { const key = STATEMENT_LIST_KEYS[node.type]; if (key && Array.isArray(node[key])) { - lists.push(Ast.childNodes(node, key)); + lists.push(this.childNodes(node, key)); } if (node.type === 'SwitchStatement' && Array.isArray(node.cases)) { - lists.push(Ast.childNodes(node, 'cases')); + lists.push(this.childNodes(node, 'cases')); } }); @@ -157,10 +166,10 @@ export class Ast { * @param program - The program root to traverse. * @returns Class bodies in depth-first traversal order. */ - static collectClassBodies(program: Node): Node[] { + collectClassBodies(program: Node): Node[] { const bodies: Node[] = []; - Ast.visit(program, (node) => { + this.visit(program, (node) => { if (node.type === 'ClassBody') { bodies.push(node); } @@ -169,8 +178,64 @@ export class Ast { return bodies; } - static #stringValue(value: AstValue): string | undefined { - const parsed = Ast.#stringSchema.safeParse(value); + /** + * Slice the source range occupied by an AST node. + * + * @param source - The complete source text. + * @param node - The node whose source to read. + * @returns The node's source text. + */ + sourceOf(source: string, node: Node): string { + return source.slice(this.getStart(node), this.getEnd(node)); + } + + /** + * Locate the argument parentheses of a call with a caller-unwrapped callee. + * + * @param source - The complete source text. + * @param call - The call expression node. + * @param callee - The callee after the caller's own unwrapping rules. + * @returns The parenthesis offsets, or `null` when they cannot be located. + */ + callParens(source: string, call: Node, callee: Node | undefined): CallParens | null { + const calleeEnd = callee ? this.getEnd(callee) : -1; + const callEnd = this.getEnd(call); + + if (calleeEnd < 0 || callEnd < 0) { + return null; + } + + const open = source.indexOf('(', calleeEnd); + + if (open < 0 || open >= callEnd) { + return null; + } + + const close = callEnd - 1; + + if (source[close] !== ')') { + return null; + } + + return { open, close }; + } + + /** + * Unwrap an ESTree chain expression when present. + * + * @param node - The possible chain expression. + * @returns Its expression child, or the original node. + */ + unwrapChainExpression(node: Node | undefined): Node | undefined { + if (node?.type === 'ChainExpression') { + return this.childNode(node, 'expression'); + } + + return node; + } + + #stringValue(value: AstValue): string | undefined { + const parsed = stringSchema.safeParse(value); return parsed.success ? parsed.data : undefined; } diff --git a/packages/ts/sidecar/src/syntax/ast.test.ts b/packages/ts/sidecar/src/syntax/ast.test.ts deleted file mode 100644 index cd75d6b..0000000 --- a/packages/ts/sidecar/src/syntax/ast.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { Ast } from '#sidecar/syntax/ast'; -import { Node } from '#sidecar/syntax/node-schema'; -import { isErr } from '#sidecar/kernel/result'; -import { Sources } from '#sidecar/syntax/sources'; - -test('Ast traverses parsed fixtures and reads validated node fields', () => { - const source = [ - "import value from 'fixture';", - 'const answer = 42;', - 'class Example {', - "\tfield = 'ready';", - '\tmethod() {', - '\t\treturn this.field;', - '\t}', - '}', - 'switch (answer) {', - '\tcase 42:', - '\t\tanswer;', - '}', - '// note', - '', - ].join('\n'); - - const parsed = Sources.parse('fixture.ts', source); - - assert.equal(isErr(parsed), false); - - if (isErr(parsed)) { - return; - } - - const statements = Ast.childNodes(parsed.value.program, 'body'); - const importDeclaration = statements[0]; - const variableDeclaration = statements[1]; - const classDeclaration = statements[2]; - - assert.equal(Ast.childNode(parsed.value.program, 'body'), undefined); - - assert.deepEqual(Ast.childNodes(parsed.value.program, 'missing'), []); - - assert.equal(importDeclaration && Ast.stringValue(Ast.childNode(importDeclaration, 'source') ?? importDeclaration), 'fixture'); - - assert.equal(variableDeclaration && Ast.declarationKind(variableDeclaration), 'const'); - - assert.equal(variableDeclaration && Ast.isConstDeclaration(variableDeclaration), true); - - assert.equal(classDeclaration && Ast.nodeName(Ast.childNode(classDeclaration, 'id') ?? classDeclaration), 'Example'); - - assert.equal(classDeclaration && source.slice(Ast.getStart(classDeclaration), Ast.getEnd(classDeclaration)).startsWith('class Example'), true); - - const visited: string[] = []; - - Ast.visit(parsed.value.program, (node) => { - visited.push(node.type); - }); - - assert.equal(visited[0], 'Program'); - - assert.ok(visited.includes('ReturnStatement')); - - assert.ok(Ast.collectStatementLists(parsed.value.program).some((list) => list.some((node) => node.type === 'SwitchCase'))); - - assert.equal(Ast.collectClassBodies(parsed.value.program).length, 1); - - assert.equal(Ast.stringValue(parsed.value.comments[0] ?? parsed.value.program), ' note'); -}); - -test('Ast position and scalar accessors preserve their fallbacks', () => { - const ranged = Node.schema.parse({ type: 'Identifier', range: [4, 9], name: 17, kind: false }); - - assert.equal(Ast.getStart(ranged), 4); - - assert.equal(Ast.getEnd(ranged), 9); - - assert.equal(Ast.nodeName(ranged), undefined); - - assert.equal(Ast.declarationKind(ranged), undefined); - - assert.equal(Ast.getStart(Node.schema.parse({ type: 'Identifier' })), -1); -}); diff --git a/packages/ts/sidecar/src/syntax/edits.property.test.ts b/packages/ts/sidecar/src/syntax/edits.property.test.ts index a03c46f..dcb0b33 100644 --- a/packages/ts/sidecar/src/syntax/edits.property.test.ts +++ b/packages/ts/sidecar/src/syntax/edits.property.test.ts @@ -1,9 +1,11 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import fc from 'fast-check'; -import { Edits } from '#sidecar/syntax/edits'; +import { EditApplier } from '#sidecar/syntax/edits'; import type { Edit } from '#sidecar/syntax/edits'; +const editApplier = new EditApplier(); + const editCaseArbitrary = fc.string({ minLength: 1, maxLength: 60 }).chain((source) => { return fc .array( @@ -27,10 +29,10 @@ const editCaseArbitrary = fc.string({ minLength: 1, maxLength: 60 }).chain((sour }); }); -test('Edits.nonOverlapping returns a sorted non-overlapping input subset', () => { +test('EditApplier.nonOverlapping returns a sorted non-overlapping input subset', () => { fc.assert( fc.property(editCaseArbitrary, ({ edits }) => { - const accepted = Edits.nonOverlapping(edits); + const accepted = editApplier.nonOverlapping(edits); for (let index = 0; index < accepted.length; index++) { const edit = accepted[index]; @@ -44,7 +46,7 @@ test('Edits.nonOverlapping returns a sorted non-overlapping input subset', () => for (let following = index + 1; following < accepted.length; following++) { const next = accepted[following]; - assert.equal(Boolean(edit && next && Edits.rangesOverlap(edit, next)), false); + assert.equal(Boolean(edit && next && editApplier.rangesOverlap(edit, next)), false); } } }), @@ -52,10 +54,10 @@ test('Edits.nonOverlapping returns a sorted non-overlapping input subset', () => ); }); -test('Edits.apply matches applying accepted edits individually right-to-left', () => { +test('EditApplier.apply matches applying accepted edits individually right-to-left', () => { fc.assert( fc.property(editCaseArbitrary, ({ source, edits }) => { - const accepted = Edits.nonOverlapping(edits); + const accepted = editApplier.nonOverlapping(edits); let individually = source; @@ -63,7 +65,7 @@ test('Edits.apply matches applying accepted edits individually right-to-left', ( individually = individually.slice(0, edit.start) + edit.replacement + individually.slice(edit.end); } - assert.equal(Edits.apply(source, accepted), individually); + assert.equal(editApplier.apply(source, accepted), individually); }), { numRuns: 100 }, ); diff --git a/packages/ts/sidecar/src/syntax/edits.test.ts b/packages/ts/sidecar/src/syntax/edits.test.ts index d4dc224..31c271a 100644 --- a/packages/ts/sidecar/src/syntax/edits.test.ts +++ b/packages/ts/sidecar/src/syntax/edits.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { Edits } from '#sidecar/syntax/edits'; +import { EditApplier } from '#sidecar/syntax/edits'; -test('Edits.nonOverlapping drops overlapping edits and sorts by start', () => { - const kept = Edits.nonOverlapping([ +test('EditApplier.nonOverlapping drops overlapping edits and sorts by start', () => { + const kept = new EditApplier().nonOverlapping([ { start: 10, end: 20, replacement: 'b' }, { start: 0, end: 5, replacement: 'a' }, { start: 15, end: 25, replacement: 'c' }, diff --git a/packages/ts/sidecar/src/syntax/edits.ts b/packages/ts/sidecar/src/syntax/edits.ts index 3c50a78..8b1a2fa 100644 --- a/packages/ts/sidecar/src/syntax/edits.ts +++ b/packages/ts/sidecar/src/syntax/edits.ts @@ -11,7 +11,7 @@ export type Edit = { }; /** Applies source edits in offset-safe order. */ -export class Edits { +export class EditApplier { /** * Report whether two edit ranges overlap. * @@ -19,7 +19,7 @@ export class Edits { * @param b - The second edit range. * @returns `true` when the edit ranges intersect. */ - static rangesOverlap(a: Edit, b: Edit): boolean { + rangesOverlap(a: Edit, b: Edit): boolean { return a.start < b.end && b.start < a.end; } @@ -29,7 +29,7 @@ export class Edits { * @param edits - Candidate edits expressed against the same source text. * @returns Accepted edits sorted from the lowest offset to the highest. */ - static nonOverlapping(edits: Edit[]): Edit[] { + nonOverlapping(edits: Edit[]): Edit[] { const accepted: Edit[] = []; const sorted = [...edits].sort((a, b) => { @@ -37,7 +37,7 @@ export class Edits { }); for (const edit of sorted) { - if (accepted.some((existing) => Edits.rangesOverlap(existing, edit))) { + if (accepted.some((existing) => this.rangesOverlap(existing, edit))) { continue; } @@ -56,7 +56,7 @@ export class Edits { * @param edits - The edits expressed against the original offsets. * @returns The edited source text. */ - static apply(source: string, edits: Edit[]): string { + apply(source: string, edits: Edit[]): string { const sorted = [...edits].sort((a, b) => { return b.start - a.start; }); diff --git a/packages/ts/sidecar/src/syntax/node-schema.test.ts b/packages/ts/sidecar/src/syntax/node-schema.test.ts index 7ffc76f..4c246a3 100644 --- a/packages/ts/sidecar/src/syntax/node-schema.test.ts +++ b/packages/ts/sidecar/src/syntax/node-schema.test.ts @@ -3,7 +3,7 @@ import { test } from 'node:test'; import { SourceUnparsable } from '#sidecar/kernel/errors'; import { Node, ParsedSourceDto } from '#sidecar/syntax/node-schema'; import { isErr } from '#sidecar/kernel/result'; -import { Sources } from '#sidecar/syntax/sources'; +import { SourceParser } from '#sidecar/syntax/source-parser'; test('ParsedSourceDto accepts and freezes a valid parser envelope', () => { const parsed = ParsedSourceDto.from({ @@ -34,7 +34,26 @@ test('ParsedSourceDto rejects malformed parser envelopes', () => { } }); -test('Sources.parse maps a rejected parser envelope to SourceUnparsable', () => { +test('ParsedSourceDto.hasCommentBetween reports comments contained by a range', () => { + const parsed = ParsedSourceDto.from({ + program: { type: 'Program', start: 0, end: 30, body: [] }, + comments: [{ type: 'Line', start: 10, end: 20, value: ' note' }], + }); + + assert.equal(parsed.success, true); + + if (!parsed.success) { + return; + } + + assert.equal(parsed.data.hasCommentBetween(5, 25), true); + + assert.equal(parsed.data.hasCommentBetween(12, 25), false); + + assert.equal(parsed.data.hasCommentBetween(5, 15), false); +}); + +test('SourceParser.parse maps a rejected parser envelope to SourceUnparsable', () => { const originalFrom = ParsedSourceDto.from; ParsedSourceDto.from = (() => { @@ -42,7 +61,7 @@ test('Sources.parse maps a rejected parser envelope to SourceUnparsable', () => }) as never; try { - const parsed = Sources.parse('fixture.ts', 'const value = 1;\n'); + const parsed = new SourceParser().parse('fixture.ts', 'const value = 1;\n'); assert.ok(isErr(parsed)); diff --git a/packages/ts/sidecar/src/syntax/node-schema.ts b/packages/ts/sidecar/src/syntax/node-schema.ts index c3d73d3..1050ed4 100644 --- a/packages/ts/sidecar/src/syntax/node-schema.ts +++ b/packages/ts/sidecar/src/syntax/node-schema.ts @@ -30,8 +30,8 @@ const NodeHeadSchema = z * positional head, while retaining other properties. Program descendants, * including their discriminators, positions, and nested structure, are trusted * Oxc output after that envelope succeeds and are recognised structurally. - * `Ast` lazily Zod-validates the descendant `name`, `kind`, and string `value` - * fields that passes consume, avoiding a recursive walk and reconstruction. + * `AstReader` lazily Zod-validates the descendant `name`, `kind`, and string + * `value` fields that passes consume, avoiding a recursive walk and reconstruction. */ export class Node { readonly [key: string]: AstValue; @@ -77,8 +77,8 @@ export class Node { * * The DTO schema eagerly validates the payload envelope, the program node * head, the comments array, and every comment node head. Program descendant - * structure and positions remain trusted Oxc data; narrow `Ast` readers lazily - * validate consumed `name`, `kind`, and string `value` fields. No pass receives + * structure and positions remain trusted Oxc data; narrow `AstReader` readers + * lazily validate consumed `name`, `kind`, and string `value` fields. No pass receives * the raw parser payload. */ export class ParsedSourceDto { @@ -110,7 +110,9 @@ export class ParsedSourceDto { const parsed = ParsedSourceDto.#schema.safeParse(value); if (!parsed.success) { - return parsed; + // The schema output type omits the DTO's own methods, so the Zod error + // is carried across on the declared parse result's failure branch. + return { success: false, error: parsed.error as unknown as z.ZodError }; } return { @@ -118,4 +120,20 @@ export class ParsedSourceDto { data: new ParsedSourceDto(parsed.data.program, parsed.data.comments), }; } + + /** + * Report whether a complete comment lies between two offsets. + * + * @param from - The inclusive lower offset. + * @param to - The inclusive upper offset. + * @returns `true` when a comment is contained by the range. + */ + hasCommentBetween(from: number, to: number): boolean { + return this.comments.some((comment) => { + const start = comment.start ?? comment.range?.[0] ?? -1; + const end = comment.end ?? comment.range?.[1] ?? -1; + + return start >= from && end <= to; + }); + } } diff --git a/packages/ts/sidecar/src/syntax/source-document.test.ts b/packages/ts/sidecar/src/syntax/source-document.test.ts new file mode 100644 index 0000000..d7ffc3f --- /dev/null +++ b/packages/ts/sidecar/src/syntax/source-document.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { SourceDocument } from '#sidecar/syntax/source-document'; + +test('SourceDocument.of carries its virtual name and text and freezes the value', () => { + const document = SourceDocument.of('sample.ts', 'const value = 1;\n'); + + assert.equal(document.virtualName, 'sample.ts'); + + assert.equal(document.text, 'const value = 1;\n'); + + assert.equal(Object.isFrozen(document), true); +}); + +test('SourceDocument.withText keeps the name and swaps the text', () => { + const document = SourceDocument.of('sample.ts', 'const value = 1;\n'); + const next = document.withText('const value = 2;\n'); + + assert.equal(next.virtualName, 'sample.ts'); + + assert.equal(next.text, 'const value = 2;\n'); + + assert.notEqual(next, document); +}); + +test('SourceDocument.lineStart returns the offset after the preceding newline', () => { + const document = SourceDocument.of('sample.ts', 'if (x) {\n\t\tcall();\n}\n'); + + assert.equal(document.lineStart(document.text.indexOf('call')), document.text.indexOf('\n') + 1); + + assert.equal(document.lineStart(0), 0); +}); + +test('SourceDocument.slice reads a range out of the source text', () => { + const document = SourceDocument.of('sample.ts', 'const value = 1;\n'); + + assert.equal(document.slice(6, 11), 'value'); +}); + +test('SourceDocument.lineIndent returns the leading whitespace of the position line', () => { + const document = SourceDocument.of('sample.ts', 'if (x) {\n\t\tcall();\n}\n'); + + assert.equal(document.lineIndent(document.text.indexOf('call')), '\t\t'); + + assert.equal(document.lineIndent(0), ''); +}); + +test('SourceDocument.indentUnit reads the unit from the first indented line', () => { + assert.equal(SourceDocument.of('a.ts', 'function run() {\n return go();\n}\n').indentUnit(), ' '); + + assert.equal(SourceDocument.of('a.ts', 'function run() {\n\treturn go();\n}\n').indentUnit(), '\t'); + + assert.equal(SourceDocument.of('a.ts', 'const value = {\n key: 1,\n};\n').indentUnit(), ' '); +}); + +test('SourceDocument.indentUnit falls back to a tab for un-indented source', () => { + assert.equal(SourceDocument.of('a.ts', 'const value = 1;\n').indentUnit(), '\t'); + + assert.equal(SourceDocument.of('a.ts', '').indentUnit(), '\t'); +}); + +test('SourceDocument.indentUnit skips block-comment continuation lines', () => { + const source = ['/**', ' * Doc comment.', ' */', 'function run() {', ' return go();', '}', ''].join('\n'); + + assert.equal(SourceDocument.of('a.ts', source).indentUnit(), ' '); +}); + +test('SourceDocument.indentUnit reads the unit relative to a baseline-indented block', () => { + const singleLine = '\t\t\tconst r = builder().withA(1).withB(2).withC(3).build();\n'; + + assert.equal(SourceDocument.of('a.ts', singleLine).indentUnit(), '\t'); + + const nested = ['\t\t\tfunction run() {', '\t\t\t\treturn go();', '\t\t\t}', ''].join('\n'); + + assert.equal(SourceDocument.of('a.ts', nested).indentUnit(), '\t'); + + const spacesBaseline = [' const value = {', ' key: 1,', ' };', ''].join('\n'); + + assert.equal(SourceDocument.of('a.ts', spacesBaseline).indentUnit(), ' '); +}); diff --git a/packages/ts/sidecar/src/syntax/source-document.ts b/packages/ts/sidecar/src/syntax/source-document.ts new file mode 100644 index 0000000..961948b --- /dev/null +++ b/packages/ts/sidecar/src/syntax/source-document.ts @@ -0,0 +1,121 @@ +/** + * An immutable source file: its virtual name paired with its text. + * + * The document answers the text-coordinate queries formatting passes need — + * line starts, line indents, the inferred indent unit, and range slices — + * without exposing a mutable buffer. It carries no caches: every query is + * computed from the frozen text on demand. + */ +export class SourceDocument { + /** The complete source text. */ + readonly text: string; + + /** The filename used to select parser syntax and label errors. */ + readonly virtualName: string; + + private constructor(virtualName: string, text: string) { + this.virtualName = virtualName; + this.text = text; + + Object.freeze(this); + } + + /** + * Build a document from a virtual name and its source text. + * + * @param virtualName - The filename used to select parser syntax and label errors. + * @param text - The complete source text. + * @returns The immutable source document. + */ + static of(virtualName: string, text: string): SourceDocument { + return new SourceDocument(virtualName, text); + } + + /** + * Derive a document that keeps this name but carries new text. + * + * @param text - The replacement source text. + * @returns A new document over the same virtual name. + */ + withText(text: string): SourceDocument { + return new SourceDocument(this.virtualName, text); + } + + /** + * Find the start offset of the line containing a position. + * + * @param position - A source offset. + * @returns The offset immediately after the preceding newline, or zero. + */ + lineStart(position: number): number { + return this.text.lastIndexOf('\n', position - 1) + 1; + } + + /** + * Read the leading whitespace of the line containing a position. + * + * @param position - A source offset. + * @returns The line's leading spaces and tabs. + */ + lineIndent(position: number): string { + const start = this.lineStart(position); + const match = this.text.slice(start, position).match(/^[ \t]*/); + + return match?.[0] ?? ''; + } + + /** + * Infer the file's per-level indentation unit from its content. + * + * The unit is read relative to the content's baseline (minimum) indentation + * so that embedded blocks whose whole body sits below column zero — an HTML + * `\n\n'; - const vueBlocks = EmbeddedBlocks.extract('component.vue', vue); + const vueBlocks = splitter.extract('component.vue', vue); assert.equal(vueBlocks.length, 1); @@ -37,7 +39,7 @@ test('EmbeddedBlocks.extract reads JS/TS script blocks from Vue and HTML', () => assert.equal(vue.slice(vueBlocks[0]?.start, (vueBlocks[0]?.start ?? 0) + (vueBlocks[0]?.content.length ?? 0)), vueBlocks[0]?.content); const html = '\n\n\n\n\n'; - const htmlBlocks = EmbeddedBlocks.extract('page.html', html); + const htmlBlocks = splitter.extract('page.html', html); assert.equal(htmlBlocks.length, 1); @@ -46,9 +48,9 @@ test('EmbeddedBlocks.extract reads JS/TS script blocks from Vue and HTML', () => assert.equal(htmlBlocks[0]?.content, '\nconst x = 1;\n'); }); -test('EmbeddedBlocks.extract reads JS/TS fences from Markdown and skips others', () => { +test('EmbeddedBlockSplitter.extract reads JS/TS fences from Markdown and skips others', () => { const markdown = ['```bash', 'echo hi', '```', '', '```tsx', 'const n = 1;', '```', ''].join('\n'); - const blocks = EmbeddedBlocks.extract('notes.md', markdown); + const blocks = splitter.extract('notes.md', markdown); assert.equal(blocks.length, 1); @@ -59,15 +61,15 @@ test('EmbeddedBlocks.extract reads JS/TS fences from Markdown and skips others', assert.equal(markdown.slice(blocks[0]?.start, (blocks[0]?.start ?? 0) + (blocks[0]?.content.length ?? 0)), blocks[0]?.content); }); -test('EmbeddedBlocks.extract returns nothing for non-host paths', () => { - assert.deepEqual(EmbeddedBlocks.extract('app.ts', 'const x = 1;\n'), []); +test('EmbeddedBlockSplitter.extract returns nothing for non-host paths', () => { + assert.deepEqual(splitter.extract('app.ts', 'const x = 1;\n'), []); }); -test('EmbeddedBlocks.rewrite applies the transform per block and preserves surrounding bytes', () => { +test('EmbeddedBlockSplitter.rewrite applies the transform per block and preserves surrounding bytes', () => { const markdown = ['# Title', '', '```ts', 'const a = 1;', '```', '', '```ts', 'const b = 2;', '```', ''].join('\n'); const seen: string[] = []; - const rewritten = EmbeddedBlocks.rewrite('notes.md', markdown, (blockContent, virtualName) => { + const rewritten = splitter.rewrite('notes.md', markdown, (blockContent, virtualName) => { seen.push(virtualName); return blockContent.toUpperCase(); @@ -84,11 +86,11 @@ test('EmbeddedBlocks.rewrite applies the transform per block and preserves surro assert.ok(rewritten.includes('```')); }); -test('EmbeddedBlocks.rewrite leaves content unchanged when the transform is identity', () => { +test('EmbeddedBlockSplitter.rewrite leaves content unchanged when the transform is identity', () => { const html = '\n'; assert.equal( - EmbeddedBlocks.rewrite('page.html', html, (blockContent) => { + splitter.rewrite('page.html', html, (blockContent) => { return blockContent; }), html, diff --git a/packages/ts/sidecar/src/hosts/embedded-blocks.ts b/packages/ts/sidecar/src/hosts/embedded-block-splitter.ts similarity index 80% rename from packages/ts/sidecar/src/hosts/embedded-blocks.ts rename to packages/ts/sidecar/src/hosts/embedded-block-splitter.ts index 0490b87..5397b61 100644 --- a/packages/ts/sidecar/src/hosts/embedded-blocks.ts +++ b/packages/ts/sidecar/src/hosts/embedded-block-splitter.ts @@ -17,29 +17,15 @@ export type EmbeddedBlock = { export type EmbeddedTransform = (blockContent: string, virtualName: string) => string; /** Extracts and rewrites embedded JavaScript blocks across every host format. */ -export class EmbeddedBlocks { - static #isMarkup(path: string): boolean { - return path.endsWith('.vue') || path.endsWith('.html') || path.endsWith('.htm'); - } - - static #isMarkdown(path: string): boolean { - return path.endsWith('.md') || path.endsWith('.markdown'); - } - - static #markupExtension(openTag: string): 'ts' | 'tsx' { - const lang = VueScript.attribute(openTag, 'lang') ?? ''; - - return lang === 'tsx' || lang === 'jsx' ? 'tsx' : 'ts'; - } - +export class EmbeddedBlockSplitter { /** * Report whether a path denotes a document that embeds JavaScript blocks. * * @param path - The source path to classify. * @returns `true` for Vue, HTML, and Markdown host documents. */ - static isHost(path: string): boolean { - return EmbeddedBlocks.#isMarkup(path) || EmbeddedBlocks.#isMarkdown(path); + isHost(path: string): boolean { + return this.#isMarkup(path) || this.#isMarkdown(path); } /** @@ -48,8 +34,8 @@ export class EmbeddedBlocks { * @param path - The source path to classify. * @returns `true` for Vue and HTML; `false` for best-effort Markdown fences. */ - static hardValidated(path: string): boolean { - return EmbeddedBlocks.#isMarkup(path); + hardValidated(path: string): boolean { + return this.#isMarkup(path); } /** @@ -59,8 +45,8 @@ export class EmbeddedBlocks { * @param content - The complete host source text. * @returns The embedded blocks in source order, with parser extensions. */ - static extract(path: string, content: string): EmbeddedBlock[] { - if (EmbeddedBlocks.#isMarkdown(path)) { + extract(path: string, content: string): EmbeddedBlock[] { + if (this.#isMarkdown(path)) { return MarkdownFences.extractBlocks(content) .filter((block) => { return MarkdownFences.isJavaScriptOrTypeScript(block.lang); @@ -70,7 +56,7 @@ export class EmbeddedBlocks { }); } - if (!EmbeddedBlocks.#isMarkup(path)) { + if (!this.#isMarkup(path)) { return []; } @@ -79,7 +65,7 @@ export class EmbeddedBlocks { return VueScript.isJavaScriptOrTypeScript(block.openTag); }) .map((block) => { - return { content: block.content, start: block.start, extension: EmbeddedBlocks.#markupExtension(block.openTag) }; + return { content: block.content, start: block.start, extension: this.#markupExtension(block.openTag) }; }); } @@ -94,10 +80,10 @@ export class EmbeddedBlocks { * @param transform - The rewrite applied to each block's content. * @returns The host source with every changed block spliced back in place. */ - static rewrite(path: string, content: string, transform: EmbeddedTransform): string { + rewrite(path: string, content: string, transform: EmbeddedTransform): string { let updated = content; - const blocks = EmbeddedBlocks.extract(path, content); + const blocks = this.extract(path, content); for (const block of [...blocks].reverse()) { const rewritten = transform(block.content, `${path}.script.${block.extension}`); @@ -111,4 +97,18 @@ export class EmbeddedBlocks { return updated; } + + #isMarkup(path: string): boolean { + return path.endsWith('.vue') || path.endsWith('.html') || path.endsWith('.htm'); + } + + #isMarkdown(path: string): boolean { + return path.endsWith('.md') || path.endsWith('.markdown'); + } + + #markupExtension(openTag: string): 'ts' | 'tsx' { + const lang = VueScript.attribute(openTag, 'lang') ?? ''; + + return lang === 'tsx' || lang === 'jsx' ? 'tsx' : 'ts'; + } } diff --git a/packages/ts/sidecar/src/hosts/file-targets.ts b/packages/ts/sidecar/src/hosts/file-targets.ts index 4f46ec9..c45c40f 100644 --- a/packages/ts/sidecar/src/hosts/file-targets.ts +++ b/packages/ts/sidecar/src/hosts/file-targets.ts @@ -1,4 +1,6 @@ -import { EmbeddedBlocks } from '#sidecar/hosts/embedded-blocks'; +import { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; + +const embeddedBlocks = new EmbeddedBlockSplitter(); /** Classifies paths accepted by sidecar formatting passes. */ export class FileTargets { @@ -19,7 +21,7 @@ export class FileTargets { * @returns `true` for host documents and non-declaration TypeScript files. */ static isTargetFile(path: string): boolean { - return (path.endsWith('.ts') && !path.endsWith('.d.ts')) || EmbeddedBlocks.isHost(path); + return (path.endsWith('.ts') && !path.endsWith('.d.ts')) || embeddedBlocks.isHost(path); } /** @@ -29,6 +31,6 @@ export class FileTargets { * @returns `true` for host documents and every TypeScript file. */ static isSyntaxTarget(path: string): boolean { - return path.endsWith('.ts') || EmbeddedBlocks.isHost(path); + return path.endsWith('.ts') || embeddedBlocks.isHost(path); } } diff --git a/packages/ts/sidecar/src/passes/blank-line-pass.test.ts b/packages/ts/sidecar/src/passes/blank-line-pass.test.ts new file mode 100644 index 0000000..77bf598 --- /dev/null +++ b/packages/ts/sidecar/src/passes/blank-line-pass.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { BlankLinePass } from '#sidecar/passes/blank-line-pass'; +import { ClassMemberPolicy } from '#sidecar/passes/policies/class-member-policy'; +import { EditApplier } from '#sidecar/syntax/edits'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; +import { StatementSpacingPolicy } from '#sidecar/passes/policies/statement-spacing-policy'; +import { VueReactivityIdioms } from '#sidecar/passes/policies/vue-reactivity-idioms'; +import type { Edit } from '#sidecar/syntax/edits'; + +const editApplier = new EditApplier(); + +function makePass(): BlankLinePass { + const ast = new AstReader(); + const members = new ClassMemberPolicy({ ast }); + const vue = new VueReactivityIdioms({ ast }); + const spacing = new StatementSpacingPolicy({ ast, members, vue }); + + return new BlankLinePass({ parser: new SourceParser(), ast, spacing }); +} + +/** + * The former BlankLines.insert, kept here as the byte-for-byte reference the + * zero-width-insert adaptation must reproduce. It dedupes positions, sorts them + * descending, and inserts one newline at each. + */ +function referenceInsert(content: string, positions: number[]): string { + const sorted = [...new Set(positions)].sort((a, b) => { + return b - a; + }); + + let out = content; + + for (const pos of sorted) { + out = out.slice(0, pos) + '\n' + out.slice(pos); + } + + return out; +} + +function zeroWidthInserts(positions: number[]): Edit[] { + return [...new Set(positions)].map((position) => { + return { start: position, end: position, replacement: '\n' }; + }); +} + +test('EditApplier byte-matches the old insert for multiple distinct positions', () => { + const source = 'alpha\nbeta\ngamma\n'; + const positions = [0, 6, 11]; + + assert.equal(editApplier.apply(source, zeroWidthInserts(positions)), referenceInsert(source, positions)); +}); + +test('EditApplier byte-matches the old insert for adjacent positions', () => { + const source = 'abcdef'; + const positions = [3, 4]; + + assert.equal(editApplier.apply(source, zeroWidthInserts(positions)), referenceInsert(source, positions)); +}); + +test('EditApplier byte-matches the old insert at offset zero and end of file', () => { + const source = 'abc'; + const positions = [0, source.length]; + + assert.equal(editApplier.apply(source, zeroWidthInserts(positions)), referenceInsert(source, positions)); +}); + +test('deduplicated zero-width inserts match the old insert for repeated positions', () => { + const source = 'let value = 1; doWork(); let next = 2;\n'; + const duplicate = [15, 15, 24]; + + // The old insert collapsed repeats via a Set; the pass dedupes positions the + // same way, so a single newline lands at the shared offset. + assert.equal(editApplier.apply(source, zeroWidthInserts(duplicate)), referenceInsert(source, duplicate)); +}); + +test('inserts a blank line between an import and a following function', () => { + const pass = makePass(); + const source = ['import { foo } from "node:foo";', 'export function bar() {', '\treturn foo();', '}', ''].join('\n'); + const document = SourceDocument.of('fixture.ts', source); + + const output = editApplier.apply(source, pass.computeEdits(document)); + + assert.equal(output, ['import { foo } from "node:foo";', '', 'export function bar() {', '\treturn foo();', '}', ''].join('\n')); +}); + +test('proposes only zero-width newline inserts', () => { + const pass = makePass(); + const source = ['import { foo } from "node:foo";', 'export function bar() {', '\treturn foo();', '}', ''].join('\n'); + + const edits = pass.computeEdits(SourceDocument.of('fixture.ts', source)); + + assert.ok(edits.length > 0); + + for (const edit of edits) { + assert.equal(edit.start, edit.end); + assert.equal(edit.replacement, '\n'); + } +}); + +test('leaves an already-spaced document unchanged', () => { + const pass = makePass(); + const source = ['import { foo } from "node:foo";', '', 'export function bar() {', '\treturn foo();', '}', ''].join('\n'); + + assert.deepEqual(pass.computeEdits(SourceDocument.of('fixture.ts', source)), []); +}); + +test('returns no edits for source with syntax errors', () => { + const pass = makePass(); + + assert.deepEqual(pass.computeEdits(SourceDocument.of('fixture.ts', 'function broken( {\n')), []); +}); diff --git a/packages/ts/sidecar/src/passes/blank-line-pass.ts b/packages/ts/sidecar/src/passes/blank-line-pass.ts new file mode 100644 index 0000000..c5a5e82 --- /dev/null +++ b/packages/ts/sidecar/src/passes/blank-line-pass.ts @@ -0,0 +1,102 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import { isErr } from '#sidecar/kernel/result'; +import type { SourceParser } from '#sidecar/syntax/source-parser'; +import type { StatementSpacingPolicy } from '#sidecar/passes/policies/statement-spacing-policy'; +import type { Edit } from '#sidecar/syntax/edits'; +import type { FormattingPass } from '#sidecar/passes/pass'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; + +/** Inserts the blank lines the formatter's statement-spacing rules require. */ +export class BlankLinePass implements FormattingPass { + /** The pass identity used for reporting. */ + readonly name = 'blank-lines'; + + readonly #parser: SourceParser; + readonly #ast: AstReader; + readonly #spacing: StatementSpacingPolicy; + + /** + * @param dependencies - The syntax services and policy consumed by the pass. + * @param dependencies.parser - Parses source into a trustworthy tree. + * @param dependencies.ast - Traverses and reads validated node fields. + * @param dependencies.spacing - Decides which statement pairs need a blank line. + */ + constructor(dependencies: { parser: SourceParser; ast: AstReader; spacing: StatementSpacingPolicy }) { + this.#parser = dependencies.parser; + this.#ast = dependencies.ast; + this.#spacing = dependencies.spacing; + } + + /** + * Compute the zero-width newline inserts required by statement spacing. + * + * Each required blank line is a zero-width insert of a single newline at the + * start of the following statement's line. Positions are deduplicated so two + * statements that share a physical line contribute one newline, matching the + * former position-set insertion exactly. + * + * @param document - The document to inspect. + * @returns Zero-width newline inserts, or none for invalid source. + */ + computeEdits(document: SourceDocument): Edit[] { + const parsed = this.#parser.parse(document.virtualName, document.text); + + if (isErr(parsed)) { + return []; + } + + const content = document.text; + const lists = this.#ast.collectStatementLists(parsed.value.program); + const positions = new Set(); + + for (const list of lists) { + for (let i = 1; i < list.length; i++) { + const prev = list[i - 1]; + const next = list[i]; + + if (!prev || !next) { + continue; + } + + if (!this.#spacing.needsBlankLine(prev, next)) { + continue; + } + + const prevEnd = this.#ast.getEnd(prev); + const nextStart = this.#ast.getStart(next); + + if (prevEnd < 0 || nextStart < 0 || nextStart <= prevEnd) { + continue; + } + + if (this.#countNewlines(content, prevEnd, nextStart) >= 2) { + continue; + } + + const lineStart = content.lastIndexOf('\n', nextStart - 1); + + if (lineStart < 0) { + continue; + } + + positions.add(lineStart + 1); + } + } + + return [...positions].map((position) => { + return { start: position, end: position, replacement: '\n' }; + }); + } + + #countNewlines(source: string, from: number, to: number): number { + let count = 0; + + for (let i = from; i < to; i++) { + if (source.charCodeAt(i) === 10) { + count++; + } + } + + return count; + } +} diff --git a/packages/ts/sidecar/src/body-wrapper.test.ts b/packages/ts/sidecar/src/passes/body-wrap-pass.test.ts similarity index 77% rename from packages/ts/sidecar/src/body-wrapper.test.ts rename to packages/ts/sidecar/src/passes/body-wrap-pass.test.ts index b138d5e..bc11a03 100644 --- a/packages/ts/sidecar/src/body-wrapper.test.ts +++ b/packages/ts/sidecar/src/passes/body-wrap-pass.test.ts @@ -1,12 +1,20 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { BodyWrapper } from '#sidecar/body-wrapper'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { BodyWrapPass } from '#sidecar/passes/body-wrap-pass'; import { EditApplier } from '#sidecar/syntax/edits'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; +const pass = new BodyWrapPass({ parser: new SourceParser(), ast: new AstReader() }); const editApplier = new EditApplier(); +function computeEdits(source: string) { + return pass.computeEdits(SourceDocument.of('sample.ts', source)); +} + function wrapOnce(source: string): string { - const edits = BodyWrapper.computeEdits(source, 'sample.ts'); + const edits = computeEdits(source); return edits.length > 0 ? editApplier.apply(source, edits) : source; } @@ -67,9 +75,9 @@ test('wraps with four spaces when the source is space-indented', () => { test('leaves already-braced bodies alone', () => { const source = 'if (ready) {\n\trun();\n}\n'; - assert.deepEqual(BodyWrapper.computeEdits(source, 'sample.ts'), []); + assert.deepEqual(computeEdits(source), []); }); test('returns no edits for source with syntax errors', () => { - assert.deepEqual(BodyWrapper.computeEdits('if (broken run();\n', 'sample.ts'), []); + assert.deepEqual(computeEdits('if (broken run();\n'), []); }); diff --git a/packages/ts/sidecar/src/body-wrapper.ts b/packages/ts/sidecar/src/passes/body-wrap-pass.ts similarity index 56% rename from packages/ts/sidecar/src/body-wrapper.ts rename to packages/ts/sidecar/src/passes/body-wrap-pass.ts index 3e22050..9aef208 100644 --- a/packages/ts/sidecar/src/body-wrapper.ts +++ b/packages/ts/sidecar/src/passes/body-wrap-pass.ts @@ -1,9 +1,10 @@ -import { AstReader } from '#sidecar/syntax/ast-reader'; +import type { AstReader } from '#sidecar/syntax/ast-reader'; import { isErr } from '#sidecar/kernel/result'; -import { SourceDocument } from '#sidecar/syntax/source-document'; -import { SourceParser } from '#sidecar/syntax/source-parser'; +import type { SourceParser } from '#sidecar/syntax/source-parser'; import type { Edit } from '#sidecar/syntax/edits'; +import type { FormattingPass } from '#sidecar/passes/pass'; import type { Node } from '#sidecar/syntax/node-schema'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; const STATEMENT_BODY_KEYS: Record = { DoWhileStatement: ['body'], @@ -16,57 +17,40 @@ const STATEMENT_BODY_KEYS: Record = { }; /** Wraps unbraced statement bodies without changing unparsable source. */ -export class BodyWrapper { - static readonly #ast = new AstReader(); +export class BodyWrapPass implements FormattingPass { + /** The pass identity used for reporting. */ + readonly name = 'body-wrap'; - static readonly #parser = new SourceParser(); + readonly #parser: SourceParser; + readonly #ast: AstReader; - static #wrapStatementBody(document: SourceDocument, owner: Node, body: Node, indentUnit: string): Edit | null { - if (body.type === 'BlockStatement') { - return null; - } - - if (body.type === 'IfStatement' && owner.type === 'IfStatement' && owner.alternate === body) { - return null; - } - - const start = BodyWrapper.#ast.getStart(body); - const end = BodyWrapper.#ast.getEnd(body); - const ownerStart = BodyWrapper.#ast.getStart(owner); - - if (start < 0 || end < 0 || ownerStart < 0) { - return null; - } - - const indent = document.lineIndent(ownerStart); - const bodySource = document.slice(start, end); - - return { - start, - end, - replacement: `{\n${indent}${indentUnit}${bodySource}\n${indent}}`, - }; + /** + * @param dependencies - The syntax services consumed by the pass. + * @param dependencies.parser - Parses source into a trustworthy tree. + * @param dependencies.ast - Traverses and reads validated node fields. + */ + constructor(dependencies: { parser: SourceParser; ast: AstReader }) { + this.#parser = dependencies.parser; + this.#ast = dependencies.ast; } /** * Compute edits that wrap unbraced statement bodies. * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. + * @param document - The document to inspect. * @returns Non-overlapping body-wrap edits, or none for invalid source. */ - static computeEdits(content: string, virtualName: string): Edit[] { - const parsed = BodyWrapper.#parser.parse(virtualName, content); + computeEdits(document: SourceDocument): Edit[] { + const parsed = this.#parser.parse(document.virtualName, document.text); if (isErr(parsed)) { return []; } - const document = SourceDocument.of(virtualName, content); const edits: Edit[] = []; const indentUnit = document.indentUnit(); - BodyWrapper.#ast.visit(parsed.value.program, (node) => { + this.#ast.visit(parsed.value.program, (node) => { const bodyKeys = STATEMENT_BODY_KEYS[node.type]; if (!bodyKeys) { @@ -74,13 +58,13 @@ export class BodyWrapper { } for (const key of bodyKeys) { - const body = BodyWrapper.#ast.childNode(node, key); + const body = this.#ast.childNode(node, key); if (!body) { continue; } - const edit = BodyWrapper.#wrapStatementBody(document, node, body, indentUnit); + const edit = this.#wrapStatementBody(document, node, body, indentUnit); if (edit) { edits.push(edit); @@ -98,4 +82,31 @@ export class BodyWrapper { }); }); } + + #wrapStatementBody(document: SourceDocument, owner: Node, body: Node, indentUnit: string): Edit | null { + if (body.type === 'BlockStatement') { + return null; + } + + if (body.type === 'IfStatement' && owner.type === 'IfStatement' && owner.alternate === body) { + return null; + } + + const start = this.#ast.getStart(body); + const end = this.#ast.getEnd(body); + const ownerStart = this.#ast.getStart(owner); + + if (start < 0 || end < 0 || ownerStart < 0) { + return null; + } + + const indent = document.lineIndent(ownerStart); + const bodySource = document.slice(start, end); + + return { + start, + end, + replacement: `{\n${indent}${indentUnit}${bodySource}\n${indent}}`, + }; + } } diff --git a/packages/ts/sidecar/src/passes/class-reorder-pass.test.ts b/packages/ts/sidecar/src/passes/class-reorder-pass.test.ts new file mode 100644 index 0000000..1470af7 --- /dev/null +++ b/packages/ts/sidecar/src/passes/class-reorder-pass.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { ClassMemberPolicy } from '#sidecar/passes/policies/class-member-policy'; +import { ClassReorderPass } from '#sidecar/passes/class-reorder-pass'; +import { EditApplier } from '#sidecar/syntax/edits'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; + +const ast = new AstReader(); +const pass = new ClassReorderPass({ parser: new SourceParser(), ast, members: new ClassMemberPolicy({ ast }) }); + +function computeEdits(source: string, virtualName: string) { + return pass.computeEdits(SourceDocument.of(virtualName, source)); +} + +test('class members are reordered as properties, constructors, then methods', () => { + const input = ['class Example {', '\trun() {}', '\tvalue = 1;', '\tconstructor() {}', '}', ''].join('\n'); + + const edits = computeEdits(input, 'fixture.ts'); + const output = new EditApplier().apply(input, edits); + + assert.equal(edits.length, 1); + assert.equal(output, ['class Example {', '\tvalue = 1;', '\tconstructor() {}', '\trun() {}', '}', ''].join('\n')); +}); + +test('class reorder skips members with comments between them', () => { + const input = ['class Example {', '\trun() {}', '\t// Preserve this member grouping.', '\tvalue = 1;', '}', ''].join('\n'); + + assert.deepEqual(computeEdits(input, 'fixture.ts'), []); +}); + +test('class reorder skips already ordered and single-member classes', () => { + assert.deepEqual(computeEdits(['class Ordered {', '\tvalue = 1;', '\tconstructor() {}', '\trun() {}', '}', ''].join('\n'), 'ordered.ts'), []); + + assert.deepEqual(computeEdits(['class Single {', '\trun() {}', '}', ''].join('\n'), 'single.ts'), []); +}); diff --git a/packages/ts/sidecar/src/passes/class-reorder-pass.ts b/packages/ts/sidecar/src/passes/class-reorder-pass.ts new file mode 100644 index 0000000..1a78866 --- /dev/null +++ b/packages/ts/sidecar/src/passes/class-reorder-pass.ts @@ -0,0 +1,157 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import type { ClassMemberPolicy } from '#sidecar/passes/policies/class-member-policy'; +import { isErr } from '#sidecar/kernel/result'; +import type { SourceParser } from '#sidecar/syntax/source-parser'; +import type { Edit } from '#sidecar/syntax/edits'; +import type { FormattingPass } from '#sidecar/passes/pass'; +import type { Node } from '#sidecar/syntax/node-schema'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; + +/** Reorders class members into the formatter's stable class shape. */ +export class ClassReorderPass implements FormattingPass { + /** The pass identity used for reporting. */ + readonly name = 'class-reorder'; + + readonly #parser: SourceParser; + readonly #ast: AstReader; + readonly #members: ClassMemberPolicy; + + /** + * @param dependencies - The syntax services and policies consumed by the pass. + * @param dependencies.parser - Parses source into a trustworthy tree. + * @param dependencies.ast - Traverses and reads validated node fields. + * @param dependencies.members - Classifies members into their ordering group. + */ + constructor(dependencies: { parser: SourceParser; ast: AstReader; members: ClassMemberPolicy }) { + this.#parser = dependencies.parser; + this.#ast = dependencies.ast; + this.#members = dependencies.members; + } + + /** + * Compute class-member ordering edits. + * + * @param document - The document to inspect. + * @returns Class-member ordering edits, or none for invalid source. + */ + computeEdits(document: SourceDocument): Edit[] { + const parsed = this.#parser.parse(document.virtualName, document.text); + + if (isErr(parsed)) { + return []; + } + + const source = document.text; + const edits: Edit[] = []; + + for (const body of this.#ast.collectClassBodies(parsed.value.program)) { + const edit = this.#computeClassReorderEdit(source, body); + + if (edit) { + edits.push(edit); + } + } + + return edits; + } + + #computeClassReorderEdit(source: string, body: Node): Edit | null { + const members = this.#ast.childNodes(body, 'body'); + + if (members.length < 2) { + return null; + } + + const properties: Node[] = []; + const constructors: Node[] = []; + const methods: Node[] = []; + + for (const member of members) { + const kind = this.#members.classify(member); + + if (kind === 'property') { + properties.push(member); + } else if (kind === 'constructor') { + constructors.push(member); + } else { + methods.push(member); + } + } + + const desired = [...properties, ...constructors, ...methods]; + + if ( + desired.every((member, index) => { + return member === members[index]; + }) + ) { + return null; + } + + const bodyStart = this.#ast.getStart(body); + const bodyEnd = this.#ast.getEnd(body); + + if (bodyStart < 0 || bodyEnd < 0 || this.#hasCommentsAroundMembers(source, body, members)) { + return null; + } + + const firstMember = members[0]; + const lastOriginal = members.at(-1); + + if (!firstMember || !lastOriginal) { + return null; + } + + const prefix = source.slice(bodyStart + 1, this.#ast.getStart(firstMember)); + const indent = prefix.match(/\n([ \t]*)$/)?.[1]; + + if (indent === undefined) { + return null; + } + + const memberSlices = desired.map((member) => { + return source.slice(this.#ast.getStart(member), this.#ast.getEnd(member)); + }); + + const closing = source.slice(this.#ast.getEnd(lastOriginal), bodyEnd - 1); + + return { + start: bodyStart + 1, + end: bodyEnd - 1, + replacement: `\n${indent}${memberSlices.join(`\n${indent}`)}${closing}`, + }; + } + + #hasCommentsAroundMembers(source: string, body: Node, members: Node[]): boolean { + const first = members[0]; + const last = members.at(-1); + + if (!first || !last) { + return false; + } + + const bodyStart = this.#ast.getStart(body); + const bodyEnd = this.#ast.getEnd(body); + const firstStart = this.#ast.getStart(first); + const lastEnd = this.#ast.getEnd(last); + + if (this.#containsComment(source.slice(bodyStart + 1, firstStart))) { + return true; + } + + for (let i = 0; i < members.length - 1; i++) { + const current = members[i]; + const following = members[i + 1]; + + if (current && following && this.#containsComment(source.slice(this.#ast.getEnd(current), this.#ast.getStart(following)))) { + return true; + } + } + + return this.#containsComment(source.slice(lastEnd, bodyEnd - 1)); + } + + #containsComment(source: string): boolean { + return /\/\/|\/\*/.test(source); + } +} diff --git a/packages/ts/sidecar/src/declaration-reorder.test.ts b/packages/ts/sidecar/src/passes/declaration-reorder-pass.test.ts similarity index 73% rename from packages/ts/sidecar/src/declaration-reorder.test.ts rename to packages/ts/sidecar/src/passes/declaration-reorder-pass.test.ts index 1167123..7dfb779 100644 --- a/packages/ts/sidecar/src/declaration-reorder.test.ts +++ b/packages/ts/sidecar/src/passes/declaration-reorder-pass.test.ts @@ -1,12 +1,20 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { DeclarationReorder } from '#sidecar/declaration-reorder'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { DeclarationReorderPass } from '#sidecar/passes/declaration-reorder-pass'; import { EditApplier } from '#sidecar/syntax/edits'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; +const pass = new DeclarationReorderPass({ parser: new SourceParser(), ast: new AstReader() }); const editApplier = new EditApplier(); +function computeEdits(source: string) { + return pass.computeEdits(SourceDocument.of('sample.ts', source)); +} + function reorder(source: string): string { - const edits = DeclarationReorder.computeEdits(source, 'sample.ts'); + const edits = computeEdits(source); return edits.length > 0 ? editApplier.apply(source, edits) : source; } @@ -46,5 +54,5 @@ test('reorders import groups so single-line imports precede multiline ones', () }); test('returns no edits for source with syntax errors', () => { - assert.deepEqual(DeclarationReorder.computeEdits('const broken = {;\nconst small = 1;\n', 'sample.ts'), []); + assert.deepEqual(computeEdits('const broken = {;\nconst small = 1;\n'), []); }); diff --git a/packages/ts/sidecar/src/passes/declaration-reorder-pass.ts b/packages/ts/sidecar/src/passes/declaration-reorder-pass.ts new file mode 100644 index 0000000..2c9e767 --- /dev/null +++ b/packages/ts/sidecar/src/passes/declaration-reorder-pass.ts @@ -0,0 +1,322 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import { isErr } from '#sidecar/kernel/result'; +import { Node } from '#sidecar/syntax/node-schema'; +import type { SourceParser } from '#sidecar/syntax/source-parser'; +import type { Edit } from '#sidecar/syntax/edits'; +import type { FormattingPass } from '#sidecar/passes/pass'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; + +/** Reorders declarations only where the transformation is side-effect safe. */ +export class DeclarationReorderPass implements FormattingPass { + /** The pass identity used for reporting. */ + readonly name = 'declaration-reorder'; + + readonly #parser: SourceParser; + readonly #ast: AstReader; + + /** + * @param dependencies - The syntax services consumed by the pass. + * @param dependencies.parser - Parses source into a trustworthy tree. + * @param dependencies.ast - Traverses and reads validated node fields. + */ + constructor(dependencies: { parser: SourceParser; ast: AstReader }) { + this.#parser = dependencies.parser; + this.#ast = dependencies.ast; + } + + /** + * Compute declaration-ordering edits. + * + * @param document - The document to inspect. + * @returns Safe declaration-ordering edits, or none for invalid source. + */ + computeEdits(document: SourceDocument): Edit[] { + const parsed = this.#parser.parse(document.virtualName, document.text); + + if (isErr(parsed)) { + return []; + } + + const lists = this.#ast.collectStatementLists(parsed.value.program); + const edits: Edit[] = []; + + for (const list of lists) { + const importGroups = this.#splitGroups(list, (node) => { + return node.type === 'ImportDeclaration'; + }); + + const constGroups = this.#splitGroups(list, (node) => { + return this.#ast.isConstDeclaration(node); + }); + + for (const group of importGroups) { + const edit = this.#groupEdit(document, group, true); + + if (edit) { + edits.push(edit); + } + } + + for (const group of constGroups) { + const edit = this.#groupEdit(document, group, this.#canReorderConstGroup(document, group)); + + if (edit) { + edits.push(edit); + } + } + } + + return edits; + } + + #isMultiline(document: SourceDocument, node: Node): boolean { + const start = this.#ast.getStart(node); + const end = this.#ast.getEnd(node); + + return start >= 0 && end >= 0 && document.slice(start, end).includes('\n'); + } + + #nodeSource(document: SourceDocument, node: Node): string { + const start = this.#ast.getStart(node); + const end = this.#ast.getEnd(node); + + return `${document.lineIndent(start)}${document.slice(start, end)}`; + } + + #isSideEffectSafeExpression(node: Node | undefined): boolean { + if (!node) { + return true; + } + + switch (node.type) { + case 'ArrowFunctionExpression': + + case 'FunctionExpression': + + case 'Identifier': + + case 'Literal': + return true; + + case 'ArrayExpression': { + const elements = node.elements; + + return ( + Array.isArray(elements) && + elements.every((element) => { + if (element === null) { + return true; + } + + return element instanceof Node && this.#isSideEffectSafeExpression(element); + }) + ); + } + + case 'ObjectExpression': { + const properties = node.properties; + + return ( + Array.isArray(properties) && + properties.every((property) => { + if (!(property instanceof Node)) { + return false; + } + + if (property.type === 'SpreadElement') { + return this.#isSideEffectSafeExpression(this.#ast.childNode(property, 'argument')); + } + + if (property.type !== 'ObjectProperty' && property.type !== 'Property') { + return false; + } + + const computed = Boolean(property.computed); + const key = this.#ast.childNode(property, 'key'); + const value = this.#ast.childNode(property, 'value'); + + return (!computed || this.#isSideEffectSafeExpression(key)) && this.#isSideEffectSafeExpression(value); + }) + ); + } + + case 'TemplateLiteral': { + const expressions = node.expressions; + + return ( + Array.isArray(expressions) && + expressions.every((expression) => { + return expression instanceof Node && this.#isSideEffectSafeExpression(expression); + }) + ); + } + + default: + return false; + } + } + + #isSafeConstDeclaration(node: Node): boolean { + if (!this.#ast.isConstDeclaration(node)) { + return false; + } + + return ( + Array.isArray(node.declarations) && + this.#ast.childNodes(node, 'declarations').every((declaration) => { + const id = this.#ast.childNode(declaration, 'id'); + + return id?.type === 'Identifier' && this.#isSideEffectSafeExpression(this.#ast.childNode(declaration, 'init')); + }) + ); + } + + #declaredNames(nodes: Node[]): Set { + const names = new Set(); + + for (const node of nodes) { + for (const declaration of this.#ast.childNodes(node, 'declarations')) { + const id = this.#ast.childNode(declaration, 'id'); + const name = id ? this.#ast.nodeName(id) : undefined; + + if (id?.type === 'Identifier' && name !== undefined) { + names.add(name); + } + } + } + + return names; + } + + #usesAnyIdentifier(node: Node, names: Set): boolean { + let found = false; + + this.#ast.visit(node, (child) => { + if (found || child.type !== 'Identifier') { + return; + } + + const name = this.#ast.nodeName(child); + + if (name !== undefined && names.has(name)) { + found = true; + } + }); + + return found; + } + + #canReorderConstGroup(document: SourceDocument, group: Node[]): boolean { + if ( + !group.every((node) => { + return this.#isSafeConstDeclaration(node); + }) + ) { + return false; + } + + for (let i = 0; i < group.length; i++) { + const node = group[i]; + + if (!node || !this.#isMultiline(document, node)) { + continue; + } + + const names = this.#declaredNames([node]); + + if ( + group.slice(i + 1).some((node) => { + return !this.#isMultiline(document, node) && this.#usesAnyIdentifier(node, names); + }) + ) { + return false; + } + } + + return true; + } + + #splitGroups(list: Node[], predicate: (node: Node) => boolean): Node[][] { + const groups: Node[][] = []; + + let current: Node[] = []; + + for (const node of list) { + if (!predicate(node)) { + if (current.length > 1) { + groups.push(current); + } + + current = []; + continue; + } + + current.push(node); + } + + if (current.length > 1) { + groups.push(current); + } + + return groups; + } + + #groupEdit(document: SourceDocument, group: Node[], canReorder: boolean): Edit | null { + const singleLine = group.filter((node) => { + return !this.#isMultiline(document, node); + }); + const multiline = group.filter((node) => { + return this.#isMultiline(document, node); + }); + + if (singleLine.length === 0 || multiline.length === 0) { + return null; + } + + const desired = canReorder ? [...singleLine, ...multiline] : group; + + const replacement = desired + .map((node, index) => { + const previous = desired[index - 1]; + const separator = previous && (this.#isMultiline(document, previous) || this.#isMultiline(document, node)) ? '\n\n' : index > 0 ? '\n' : ''; + + return `${separator}${this.#nodeSource(document, node)}`; + }) + .join(''); + + const first = group[0]; + const last = group.at(-1); + + if (!first || !last) { + return null; + } + + const firstStart = this.#ast.getStart(first); + const lastEnd = this.#ast.getEnd(last); + + if (firstStart < 0 || lastEnd < 0) { + return null; + } + + const start = document.lineStart(firstStart); + const current = document.slice(start, lastEnd); + + if (current === replacement) { + return null; + } + + const alreadyOrdered = desired.every((node, index) => { + return node === group[index]; + }); + + if (!canReorder && !alreadyOrdered) { + return null; + } + + return { + start, + end: lastEnd, + replacement, + }; + } +} diff --git a/packages/ts/sidecar/src/passes/pass.ts b/packages/ts/sidecar/src/passes/pass.ts new file mode 100644 index 0000000..60d2ff9 --- /dev/null +++ b/packages/ts/sidecar/src/passes/pass.ts @@ -0,0 +1,8 @@ +import type { Edit } from '#sidecar/syntax/edits'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; + +/** One deterministic formatting rule: reads a document, proposes edits. */ +export interface FormattingPass { + readonly name: string; + computeEdits(document: SourceDocument): Edit[]; +} diff --git a/packages/ts/sidecar/src/passes/policies/class-member-policy.ts b/packages/ts/sidecar/src/passes/policies/class-member-policy.ts new file mode 100644 index 0000000..e017b10 --- /dev/null +++ b/packages/ts/sidecar/src/passes/policies/class-member-policy.ts @@ -0,0 +1,62 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import type { Node } from '#sidecar/syntax/node-schema'; + +/** The class member group used to sort a class into its stable shape. */ +export type ClassMemberKind = 'property' | 'constructor' | 'method'; + +/** Classifies class members and the transitions that require a blank line. */ +export class ClassMemberPolicy { + readonly #ast: AstReader; + + readonly #methodTypes: ReadonlySet = new Set(['MethodDefinition', 'TSAbstractMethodDefinition']); + + readonly #propertyTypes: ReadonlySet = new Set(['PropertyDefinition', 'TSAbstractPropertyDefinition', 'AccessorProperty', 'TSIndexSignature', 'StaticBlock']); + + /** + * @param dependencies - The syntax services consumed by the policy. + * @param dependencies.ast - Reads validated scalar fields off trusted nodes. + */ + constructor(dependencies: { ast: AstReader }) { + this.#ast = dependencies.ast; + } + + /** + * Classify a class member for stable ordering. + * + * @param node - The class member to classify. + * @returns Its property, constructor, or method group. + */ + classify(node: Node): ClassMemberKind { + if (this.#propertyTypes.has(node.type)) { + return 'property'; + } + + if (node.type === 'MethodDefinition' && this.#ast.declarationKind(node) === 'constructor') { + return 'constructor'; + } + + return 'method'; + } + + /** + * Report whether two adjacent members are both class methods. + * + * @param previous - The previous member. + * @param next - The following member. + * @returns `true` when both nodes are method definitions. + */ + isMethodPair(previous: Node, next: Node): boolean { + return this.#methodTypes.has(previous.type) && this.#methodTypes.has(next.type); + } + + /** + * Report whether a member transitions from a property to a method. + * + * @param previous - The previous member. + * @param next - The following member. + * @returns `true` when a property is immediately followed by a method. + */ + isPropertyToMethodTransition(previous: Node, next: Node): boolean { + return this.#propertyTypes.has(previous.type) && this.#methodTypes.has(next.type); + } +} diff --git a/packages/ts/sidecar/src/passes/policies/statement-spacing-policy.ts b/packages/ts/sidecar/src/passes/policies/statement-spacing-policy.ts new file mode 100644 index 0000000..47a10c2 --- /dev/null +++ b/packages/ts/sidecar/src/passes/policies/statement-spacing-policy.ts @@ -0,0 +1,174 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import type { ClassMemberPolicy } from '#sidecar/passes/policies/class-member-policy'; +import { Node } from '#sidecar/syntax/node-schema'; +import type { VueReactivityIdioms } from '#sidecar/passes/policies/vue-reactivity-idioms'; + +/** Decides which adjacent statements the formatter separates with a blank line. */ +export class StatementSpacingPolicy { + readonly #ast: AstReader; + readonly #members: ClassMemberPolicy; + readonly #vue: VueReactivityIdioms; + + readonly #blockHavingStatements: ReadonlySet = new Set([ + 'IfStatement', + 'ForStatement', + 'ForInStatement', + 'ForOfStatement', + 'WhileStatement', + 'DoWhileStatement', + 'SwitchStatement', + 'TryStatement', + ]); + + readonly #loopStatements: ReadonlySet = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']); + + readonly #typeDeclarationTypes: ReadonlySet = new Set(['TSTypeAliasDeclaration', 'TSInterfaceDeclaration', 'TSEnumDeclaration', 'TSModuleDeclaration']); + + readonly #blankLineAboveTypes: ReadonlySet = new Set(['SwitchStatement', 'SwitchCase', 'FunctionDeclaration', 'ClassDeclaration', 'TSEnumDeclaration', 'TSModuleDeclaration']); + + readonly #structuredPreviousStatements: ReadonlySet = new Set([ + 'ClassDeclaration', + 'DoWhileStatement', + 'ForInStatement', + 'ForOfStatement', + 'ForStatement', + 'FunctionDeclaration', + 'IfStatement', + 'SwitchStatement', + 'TryStatement', + 'WhileStatement', + ]); + + /** + * @param dependencies - The syntax services and sibling policies consumed here. + * @param dependencies.ast - Reads validated scalar fields off trusted nodes. + * @param dependencies.members - Classifies class-member spacing transitions. + * @param dependencies.vue - Recognises Vue reactivity primitive statements. + */ + constructor(dependencies: { ast: AstReader; members: ClassMemberPolicy; vue: VueReactivityIdioms }) { + this.#ast = dependencies.ast; + this.#members = dependencies.members; + this.#vue = dependencies.vue; + } + + /** + * Decide whether two adjacent statements require a blank line. + * + * @param previous - The previous statement. + * @param next - The following statement. + * @returns `true` when the pair must be separated by a blank line. + */ + needsBlankLine(previous: Node, next: Node): boolean { + if (this.#containsAwait(previous) || this.#containsAwait(next) || this.#needsBlankLineAbove(next)) { + return true; + } + + if (this.#isLoopStatement(next)) { + return !this.#isStructuredPreviousStatement(previous); + } + + if (this.#members.isMethodPair(previous, next) || this.#members.isPropertyToMethodTransition(previous, next) || this.#isTypeDeclarationAbove(previous)) { + return true; + } + + if (previous.type === 'ImportDeclaration' && next.type !== 'ImportDeclaration') { + return true; + } + + if (this.#ast.isConstDeclaration(previous) !== this.#ast.isConstDeclaration(next)) { + return true; + } + + if (this.#isLetDeclaration(previous) !== this.#isLetDeclaration(next)) { + return true; + } + + if (previous.type === 'VariableDeclaration' && next.type !== 'VariableDeclaration') { + return true; + } + + return this.#blockHavingStatements.has(previous.type); + } + + #isExportWithDeclaration(node: Node): boolean { + if (node.type !== 'ExportNamedDeclaration' && node.type !== 'ExportDefaultDeclaration') { + return false; + } + + return Boolean(node.declaration); + } + + #isBlankLineAboveType(next: Node): boolean { + return this.#blankLineAboveTypes.has(next.type); + } + + #needsBlankLineAbove(next: Node): boolean { + if (next.type === 'ReturnStatement' || this.#vue.isVuePrimitiveStatement(next) || this.#isBlankLineAboveType(next)) { + return true; + } + + return this.#isExportWithDeclaration(next); + } + + #isTypeDeclarationAbove(previous: Node): boolean { + if (this.#typeDeclarationTypes.has(previous.type)) { + return true; + } + + if (previous.type === 'ExportNamedDeclaration') { + const declarationType = this.#ast.childNode(previous, 'declaration')?.type; + + return declarationType ? this.#typeDeclarationTypes.has(declarationType) : false; + } + + return false; + } + + #isLoopStatement(node: Node): boolean { + return this.#loopStatements.has(node.type); + } + + #isStructuredPreviousStatement(previous: Node): boolean { + if (this.#structuredPreviousStatements.has(previous.type)) { + return true; + } + + if (previous.type === 'ExportNamedDeclaration' || previous.type === 'ExportDefaultDeclaration') { + const declarationType = this.#ast.childNode(previous, 'declaration')?.type; + + return Boolean(declarationType && this.#structuredPreviousStatements.has(declarationType)); + } + + return false; + } + + #isLetDeclaration(node: Node): boolean { + return node.type === 'VariableDeclaration' && this.#ast.declarationKind(node) === 'let'; + } + + #containsAwait(node: Node): boolean { + if (node.type === 'AwaitExpression') { + return true; + } + + if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') { + return false; + } + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + if ( + value.some((child) => { + return child instanceof Node && this.#containsAwait(child); + }) + ) { + return true; + } + } else if (value instanceof Node && this.#containsAwait(value)) { + return true; + } + } + + return false; + } +} diff --git a/packages/ts/sidecar/src/passes/policies/vue-reactivity-idioms.ts b/packages/ts/sidecar/src/passes/policies/vue-reactivity-idioms.ts new file mode 100644 index 0000000..da958f6 --- /dev/null +++ b/packages/ts/sidecar/src/passes/policies/vue-reactivity-idioms.ts @@ -0,0 +1,77 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import type { Node } from '#sidecar/syntax/node-schema'; + +/** Recognises Vue reactivity primitives that earn a blank line above. */ +export class VueReactivityIdioms { + readonly #ast: AstReader; + + readonly #primitiveCalls: ReadonlySet = new Set([ + 'computed', + 'nextTick', + 'onActivated', + 'onBeforeMount', + 'onBeforeUnmount', + 'onBeforeUpdate', + 'onDeactivated', + 'onErrorCaptured', + 'onMounted', + 'onRenderTracked', + 'onRenderTriggered', + 'onServerPrefetch', + 'onUnmounted', + 'onUpdated', + 'reactive', + 'readonly', + 'ref', + 'shallowReactive', + 'shallowRef', + 'watch', + 'watchEffect', + ]); + + /** + * @param dependencies - The syntax services consumed by the policy. + * @param dependencies.ast - Reads validated scalar fields off trusted nodes. + */ + constructor(dependencies: { ast: AstReader }) { + this.#ast = dependencies.ast; + } + + /** + * Report whether a statement declares or invokes a Vue reactivity primitive. + * + * @param node - The statement to inspect. + * @returns `true` for a primitive call expression or `const` bound to one. + */ + isVuePrimitiveStatement(node: Node): boolean { + if (node.type === 'ExpressionStatement') { + return this.#isVuePrimitiveCall(this.#ast.childNode(node, 'expression')); + } + + if (node.type !== 'VariableDeclaration' || this.#ast.declarationKind(node) !== 'const') { + return false; + } + + return this.#ast.childNodes(node, 'declarations').some((declaration) => { + return this.#isVuePrimitiveCall(this.#ast.childNode(declaration, 'init')); + }); + } + + #isVuePrimitiveCall(node: Node | undefined): boolean { + if (node?.type !== 'CallExpression') { + return false; + } + + return this.#isIdentifierNamed(this.#ast.childNode(node, 'callee'), this.#primitiveCalls); + } + + #isIdentifierNamed(node: Node | undefined, names: ReadonlySet): boolean { + if (node?.type !== 'Identifier') { + return false; + } + + const name = this.#ast.nodeName(node); + + return name !== undefined && names.has(name); + } +} diff --git a/packages/ts/sidecar/src/pipeline/file-formatter.ts b/packages/ts/sidecar/src/pipeline/file-formatter.ts new file mode 100644 index 0000000..49caa0d --- /dev/null +++ b/packages/ts/sidecar/src/pipeline/file-formatter.ts @@ -0,0 +1,44 @@ +import type { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; +import type { PassPipeline } from '#sidecar/pipeline/pass-pipeline'; +import { SourceDocument } from '#sidecar/syntax/source-document'; + +/** Applies a pass pipeline to a file, rewriting embedded blocks of host documents. */ +export class FileFormatter { + /** The reporting label carried from the underlying pipeline. */ + readonly label: string; + + readonly #splitter: EmbeddedBlockSplitter; + readonly #pipeline: PassPipeline; + + /** + * @param dependencies - The host splitter and pipeline composed by the formatter. + * @param dependencies.splitter - Extracts and rewrites host embedded blocks. + * @param dependencies.pipeline - The pass pipeline applied to each source unit. + */ + constructor(dependencies: { splitter: EmbeddedBlockSplitter; pipeline: PassPipeline }) { + this.#splitter = dependencies.splitter; + this.#pipeline = dependencies.pipeline; + this.label = dependencies.pipeline.name; + } + + /** + * Format a file's text, dispatching host documents through embedded blocks. + * + * @param path - The source path, used to select host handling and syntax. + * @param content - The complete source text. + * @returns The formatted source text. + */ + format(path: string, content: string): string { + if (this.#splitter.isHost(path)) { + return this.#splitter.rewrite(path, content, (blockContent, virtualName) => { + return this.#run(virtualName, blockContent); + }); + } + + return this.#run(path, content); + } + + #run(virtualName: string, content: string): string { + return this.#pipeline.apply(SourceDocument.of(virtualName, content)).text; + } +} diff --git a/packages/ts/sidecar/src/pipeline/pass-pipeline.test.ts b/packages/ts/sidecar/src/pipeline/pass-pipeline.test.ts new file mode 100644 index 0000000..6bdb598 --- /dev/null +++ b/packages/ts/sidecar/src/pipeline/pass-pipeline.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { EditApplier } from '#sidecar/syntax/edits'; +import { IterationBudget, PassPipeline, PipelineStep } from '#sidecar/pipeline/pass-pipeline'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import type { Edit } from '#sidecar/syntax/edits'; +import type { FormattingPass } from '#sidecar/passes/pass'; + +const editApplier = new EditApplier(); + +/** A pass that appends one marker per run until a run cap is reached. */ +class AppendPass implements FormattingPass { + readonly name: string; + + readonly #marker: string; + readonly #maxRuns: number; + + #runs = 0; + + constructor(marker: string, maxRuns: number, name = 'append') { + this.name = name; + this.#marker = marker; + this.#maxRuns = maxRuns; + } + + get runs(): number { + return this.#runs; + } + + computeEdits(document: SourceDocument): Edit[] { + this.#runs++; + + if (this.#runs > this.#maxRuns) { + return []; + } + + const end = document.text.length; + + return [{ start: end, end, replacement: this.#marker }]; + } +} + +/** A pass that never proposes an edit. */ +class NoopPass implements FormattingPass { + readonly name = 'noop'; + + #runs = 0; + + get runs(): number { + return this.#runs; + } + + computeEdits(): Edit[] { + this.#runs++; + + return []; + } +} + +test('IterationBudget.once caps a step at a single application', () => { + const pass = new AppendPass('x', 10); + const step = new PipelineStep(pass, IterationBudget.once()); + + const result = step.apply(SourceDocument.of('sample.ts', ''), editApplier); + + assert.equal(result.text, 'x'); + assert.equal(pass.runs, 1); +}); + +test('IterationBudget.untilStable re-runs a pass until it returns no edits', () => { + const pass = new AppendPass('x', 3); + const step = new PipelineStep(pass, IterationBudget.untilStable(5)); + + const result = step.apply(SourceDocument.of('sample.ts', ''), editApplier); + + assert.equal(result.text, 'xxx'); + + // Three productive runs plus the stabilising empty run. + assert.equal(pass.runs, 4); +}); + +test('untilStable stops at the limit even when the pass still proposes edits', () => { + const pass = new AppendPass('x', 10); + const step = new PipelineStep(pass, IterationBudget.untilStable(5)); + + const result = step.apply(SourceDocument.of('sample.ts', ''), editApplier); + + assert.equal(result.text, 'xxxxx'); + assert.equal(pass.runs, 5); +}); + +test('a no-op pass leaves the document untouched and runs once', () => { + const pass = new NoopPass(); + const step = new PipelineStep(pass, IterationBudget.untilStable(5)); + const document = SourceDocument.of('sample.ts', 'const a = 1;\n'); + + const result = step.apply(document, editApplier); + + assert.equal(result.text, document.text); + assert.equal(pass.runs, 1); +}); + +test('an empty pipeline returns its input document unchanged', () => { + const pipeline = new PassPipeline('empty', [], editApplier); + const document = SourceDocument.of('sample.ts', 'const a = 1;\n'); + + assert.equal(pipeline.apply(document).text, document.text); +}); + +test('a pipeline folds its steps left-to-right and runs every step', () => { + const first = new AppendPass('a', 1, 'first'); + const noop = new NoopPass(); + const second = new AppendPass('b', 1, 'second'); + + const pipeline = new PassPipeline( + 'compose', + [new PipelineStep(first, IterationBudget.once()), new PipelineStep(noop, IterationBudget.once()), new PipelineStep(second, IterationBudget.once())], + editApplier, + ); + + const result = pipeline.apply(SourceDocument.of('sample.ts', '')); + + assert.equal(result.text, 'ab'); + + // The middle no-op step still executes between the two productive steps. + assert.equal(noop.runs, 1); +}); + +test('the pipeline reports its label and freezes its structure', () => { + const pipeline = new PassPipeline('blank-lines', [], editApplier); + + assert.equal(pipeline.name, 'blank-lines'); + assert.ok(Object.isFrozen(pipeline)); +}); diff --git a/packages/ts/sidecar/src/pipeline/pass-pipeline.ts b/packages/ts/sidecar/src/pipeline/pass-pipeline.ts new file mode 100644 index 0000000..1316d0b --- /dev/null +++ b/packages/ts/sidecar/src/pipeline/pass-pipeline.ts @@ -0,0 +1,115 @@ +import type { EditApplier } from '#sidecar/syntax/edits'; +import type { FormattingPass } from '#sidecar/passes/pass'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; + +/** The maximum number of times a step re-runs its pass to reach a fixed point. */ +export class IterationBudget { + /** The upper bound on pass applications within one step. */ + readonly limit: number; + + private constructor(limit: number) { + this.limit = limit; + + Object.freeze(this); + } + + /** + * Build a budget that runs a pass at most once. + * + * @returns A single-application budget. + */ + static once(): IterationBudget { + return new IterationBudget(1); + } + + /** + * Build a budget that re-runs a pass until it stabilises or the limit is hit. + * + * @param limit - The maximum number of applications. + * @returns A fixed-point budget bounded by `limit`. + */ + static untilStable(limit: number): IterationBudget { + return new IterationBudget(limit); + } +} + +/** One pipeline stage: a pass driven up to its iteration budget. */ +export class PipelineStep { + /** The formatting pass applied by this step. */ + readonly pass: FormattingPass; + + /** The iteration budget bounding the pass's re-runs. */ + readonly budget: IterationBudget; + + constructor(pass: FormattingPass, budget: IterationBudget) { + this.pass = pass; + this.budget = budget; + + Object.freeze(this); + } + + /** + * Apply the pass to a document, re-running it until it stops proposing edits. + * + * The pass runs at most `budget.limit` times. Each run computes edits against + * the current document; an empty result stops the step early and returns the + * document unchanged, otherwise the edits are applied and the loop continues. + * When the budget is exhausted with a non-empty final run, the applied result + * is returned. This reproduces the segment body-wrap fixed point exactly. + * + * @param document - The document entering the step. + * @param edits - The applier used to splice computed edits into the text. + * @returns The document after the pass reaches its stable output or budget. + */ + apply(document: SourceDocument, edits: EditApplier): SourceDocument { + let current = document; + + for (let iteration = 0; iteration < this.budget.limit; iteration++) { + const computed = this.pass.computeEdits(current); + + if (computed.length === 0) { + return current; + } + + current = current.withText(edits.apply(current.text, computed)); + } + + return current; + } +} + +/** A named, ordered sequence of pipeline steps applied left-to-right. */ +export class PassPipeline { + /** The reporting label for the pipeline as a whole. */ + readonly name: string; + + readonly #steps: readonly PipelineStep[]; + readonly #edits: EditApplier; + + constructor(name: string, steps: PipelineStep[], edits: EditApplier) { + this.name = name; + this.#steps = Object.freeze([...steps]); + this.#edits = edits; + + Object.freeze(this); + } + + /** + * Fold every step over a document in declaration order. + * + * Each step always runs, even when an earlier step proposed no edits, so a + * no-op step forwards the document unchanged to the next one. + * + * @param document - The document to format. + * @returns The document after every step reaches its stable output. + */ + apply(document: SourceDocument): SourceDocument { + let current = document; + + for (const step of this.#steps) { + current = step.apply(current, this.#edits); + } + + return current; + } +} diff --git a/packages/ts/sidecar/src/pipeline/pipeline-factory.ts b/packages/ts/sidecar/src/pipeline/pipeline-factory.ts new file mode 100644 index 0000000..e63a7e6 --- /dev/null +++ b/packages/ts/sidecar/src/pipeline/pipeline-factory.ts @@ -0,0 +1,80 @@ +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { BlankLinePass } from '#sidecar/passes/blank-line-pass'; +import { BodyWrapPass } from '#sidecar/passes/body-wrap-pass'; +import { ClassMemberPolicy } from '#sidecar/passes/policies/class-member-policy'; +import { ClassReorderPass } from '#sidecar/passes/class-reorder-pass'; +import { DeclarationReorderPass } from '#sidecar/passes/declaration-reorder-pass'; +import { EditApplier } from '#sidecar/syntax/edits'; +import { IterationBudget, PassPipeline, PipelineStep } from '#sidecar/pipeline/pass-pipeline'; +import { SourceParser } from '#sidecar/syntax/source-parser'; +import { StatementSpacingPolicy } from '#sidecar/passes/policies/statement-spacing-policy'; +import { VueReactivityIdioms } from '#sidecar/passes/policies/vue-reactivity-idioms'; + +/** The maximum body-wrap iterations before the segment step settles. */ +const BODY_WRAP_ITERATIONS = 5; + +/** Composes formatting passes into the named pipelines the formatter runs. */ +export class PipelineFactory { + readonly #edits: EditApplier; + readonly #bodyWrap: BodyWrapPass; + readonly #classReorder: ClassReorderPass; + readonly #declarationReorder: DeclarationReorderPass; + readonly #blankLine: BlankLinePass; + + /** + * @param dependencies - The services and policies composed into passes. + * @param dependencies.parser - Parses source into a trustworthy tree. + * @param dependencies.ast - Traverses and reads validated node fields. + * @param dependencies.edits - Splices computed edits into source text. + * @param dependencies.members - Classifies class members for reordering. + * @param dependencies.spacing - Decides statement blank-line obligations. + */ + constructor(dependencies: { parser: SourceParser; ast: AstReader; edits: EditApplier; members: ClassMemberPolicy; spacing: StatementSpacingPolicy }) { + this.#edits = dependencies.edits; + this.#bodyWrap = new BodyWrapPass({ parser: dependencies.parser, ast: dependencies.ast }); + this.#classReorder = new ClassReorderPass({ parser: dependencies.parser, ast: dependencies.ast, members: dependencies.members }); + this.#declarationReorder = new DeclarationReorderPass({ parser: dependencies.parser, ast: dependencies.ast }); + this.#blankLine = new BlankLinePass({ parser: dependencies.parser, ast: dependencies.ast, spacing: dependencies.spacing }); + } + + /** + * Build a factory wired with the default syntax services and policies. + * + * @returns A factory over freshly constructed, shareable service instances. + */ + static create(): PipelineFactory { + const ast = new AstReader(); + const members = new ClassMemberPolicy({ ast }); + const vue = new VueReactivityIdioms({ ast }); + + return new PipelineFactory({ + parser: new SourceParser(), + ast, + edits: new EditApplier(), + members, + spacing: new StatementSpacingPolicy({ ast, members, vue }), + }); + } + + /** + * Build the source-segment pipeline: body wrap, class and declaration + * reorder, then blank-line insertion. + * + * @returns The segment pipeline labelled `blank-lines`. + */ + segmentPipeline(): PassPipeline { + return new PassPipeline( + 'blank-lines', + [ + new PipelineStep(this.#bodyWrap, IterationBudget.untilStable(BODY_WRAP_ITERATIONS)), + new PipelineStep(this.#classReorder, IterationBudget.once()), + new PipelineStep(this.#declarationReorder, IterationBudget.once()), + new PipelineStep(this.#blankLine, IterationBudget.once()), + ], + this.#edits, + ); + } + + // TS-4 adds fluentPipeline() here, composing the fluent-chain, Drizzle-query, + // and expanded-call passes once those convert to the FormattingPass contract. +} diff --git a/packages/ts/sidecar/src/rules.test.ts b/packages/ts/sidecar/src/pipeline/segment-pipeline.test.ts similarity index 93% rename from packages/ts/sidecar/src/rules.test.ts rename to packages/ts/sidecar/src/pipeline/segment-pipeline.test.ts index a39fb1f..fa7ec96 100644 --- a/packages/ts/sidecar/src/rules.test.ts +++ b/packages/ts/sidecar/src/pipeline/segment-pipeline.test.ts @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { Segment } from '#sidecar/segment'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceDocument } from '#sidecar/syntax/source-document'; + +const pipeline = PipelineFactory.create().segmentPipeline(); + +function process(source: string, virtualName: string): string { + return pipeline.apply(SourceDocument.of(virtualName, source)).text; +} interface Case { name: string; @@ -130,7 +137,7 @@ describe('blank-line rules', () => { for (const c of cases) { it(c.name, () => { const virtualName = c.name.endsWith('.js') ? 'fixture.js' : 'fixture.ts'; - const out = Segment.process(c.input, virtualName); + const out = process(c.input, virtualName); assert.equal(out, c.expected); }); @@ -139,8 +146,8 @@ describe('blank-line rules', () => { it('is idempotent: running twice produces no further changes', () => { const input = ['import { a } from "node:a";', 'import { b } from "node:b";', 'export function run() {', '\treturn a(b());', '}', ''].join('\n'); - const once = Segment.process(input, 'fixture.ts'); - const twice = Segment.process(once, 'fixture.ts'); + const once = process(input, 'fixture.ts'); + const twice = process(once, 'fixture.ts'); assert.equal(twice, once); }); diff --git a/packages/ts/sidecar/src/segment.property.test.ts b/packages/ts/sidecar/src/pipeline/segment.property.test.ts similarity index 78% rename from packages/ts/sidecar/src/segment.property.test.ts rename to packages/ts/sidecar/src/pipeline/segment.property.test.ts index 8662007..a085134 100644 --- a/packages/ts/sidecar/src/segment.property.test.ts +++ b/packages/ts/sidecar/src/pipeline/segment.property.test.ts @@ -1,7 +1,14 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import fc from 'fast-check'; -import { Segment } from '#sidecar/segment'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceDocument } from '#sidecar/syntax/source-document'; + +const pipeline = PipelineFactory.create().segmentPipeline(); + +function process(source: string, virtualName: string): string { + return pipeline.apply(SourceDocument.of(virtualName, source)).text; +} const identifierArbitrary = fc.constantFrom('alpha', 'beta', 'gamma', 'delta', 'epsilon'); const integerArbitrary = fc.integer({ min: -10, max: 10 }); @@ -37,11 +44,11 @@ const sourceArbitrary = fc.array(statementArbitrary, { minLength: 1, maxLength: return `${statements.join('\n')}\n`; }); -test('Segment.process is idempotent for composed TypeScript sources', () => { +test('the segment pipeline is idempotent for composed TypeScript sources', () => { fc.assert( fc.property(sourceArbitrary, (source) => { - const once = Segment.process(source, 'property.ts'); - const twice = Segment.process(once, 'property.ts'); + const once = process(source, 'property.ts'); + const twice = process(once, 'property.ts'); assert.equal(twice, once); }), diff --git a/packages/ts/sidecar/src/pipeline/source-file-editor.ts b/packages/ts/sidecar/src/pipeline/source-file-editor.ts new file mode 100644 index 0000000..1511716 --- /dev/null +++ b/packages/ts/sidecar/src/pipeline/source-file-editor.ts @@ -0,0 +1,52 @@ +import { isErr, ok } from '#sidecar/kernel/result'; +import type { Result } from '#sidecar/kernel/result'; +import type { SourceFileError, SourceFiles } from '#sidecar/io/source-files'; + +/** Whether an edit checks source or writes its computed changes. */ +export type EditMode = 'check' | 'write'; + +/** Reads a file, applies a text transform, and writes only when it changed. */ +export class SourceFileEditor { + readonly #sourceFiles: SourceFiles; + + /** + * @param dependencies - The filesystem port used by the editor. + * @param dependencies.sourceFiles - Reads and atomically writes source files. + */ + constructor(dependencies: { sourceFiles: SourceFiles }) { + this.#sourceFiles = dependencies.sourceFiles; + } + + /** + * Read a file, transform its text, and write back only a genuine change. + * + * @param path - The source file to edit. + * @param mode - Whether to check or atomically write changes. + * @param transform - The pure text rewrite applied to the file contents. + * @returns Whether the file changes, or the typed filesystem failure. + */ + async apply(path: string, mode: EditMode, transform: (content: string) => string): Promise> { + const read = await this.#sourceFiles.readText(path); + + if (isErr(read)) { + return read; + } + + const original = read.value; + const updated = transform(original); + + if (updated === original) { + return ok(false); + } + + if (mode === 'write') { + const written = await this.#sourceFiles.writeTextAtomic(path, updated); + + if (isErr(written)) { + return written; + } + } + + return ok(true); + } +} diff --git a/packages/ts/sidecar/src/rules.ts b/packages/ts/sidecar/src/rules.ts deleted file mode 100644 index bd9339c..0000000 --- a/packages/ts/sidecar/src/rules.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { AstReader } from '#sidecar/syntax/ast-reader'; -import { Node } from '#sidecar/syntax/node-schema'; - -const BLOCK_HAVING_STATEMENTS = new Set(['IfStatement', 'ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement', 'SwitchStatement', 'TryStatement']); -const LOOP_STATEMENTS = new Set(['ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', 'DoWhileStatement']); -const TS_TYPE_DECLARATION_TYPES = new Set(['TSTypeAliasDeclaration', 'TSInterfaceDeclaration', 'TSEnumDeclaration', 'TSModuleDeclaration']); -const CLASS_METHOD_TYPES = new Set(['MethodDefinition', 'TSAbstractMethodDefinition']); -const CLASS_PROPERTY_TYPES = new Set(['PropertyDefinition', 'TSAbstractPropertyDefinition', 'AccessorProperty', 'TSIndexSignature', 'StaticBlock']); -const BLANK_LINE_ABOVE_TYPES = new Set(['SwitchStatement', 'SwitchCase', 'FunctionDeclaration', 'ClassDeclaration', 'TSEnumDeclaration', 'TSModuleDeclaration']); - -const STRUCTURED_PREVIOUS_STATEMENTS = new Set([ - 'ClassDeclaration', - 'DoWhileStatement', - 'ForInStatement', - 'ForOfStatement', - 'ForStatement', - 'FunctionDeclaration', - 'IfStatement', - 'SwitchStatement', - 'TryStatement', - 'WhileStatement', -]); - -const VUE_PRIMITIVE_CALLS = new Set([ - 'computed', - 'nextTick', - 'onActivated', - 'onBeforeMount', - 'onBeforeUnmount', - 'onBeforeUpdate', - 'onDeactivated', - 'onErrorCaptured', - 'onMounted', - 'onRenderTracked', - 'onRenderTriggered', - 'onServerPrefetch', - 'onUnmounted', - 'onUpdated', - 'reactive', - 'readonly', - 'ref', - 'shallowReactive', - 'shallowRef', - 'watch', - 'watchEffect', -]); - -/** Encapsulates the formatter's statement and class-member layout rules. */ -export class Rules { - static readonly #ast = new AstReader(); - - static #isExportWithDeclaration(node: Node): boolean { - if (node.type !== 'ExportNamedDeclaration' && node.type !== 'ExportDefaultDeclaration') { - return false; - } - - return Boolean(node.declaration); - } - - static #isBlankLineAboveType(next: Node): boolean { - return BLANK_LINE_ABOVE_TYPES.has(next.type); - } - - static #isIdentifierNamed(node: Node | undefined, names: Set): boolean { - if (node?.type !== 'Identifier') { - return false; - } - - const name = Rules.#ast.nodeName(node); - - return name !== undefined && names.has(name); - } - - static #isVuePrimitiveCall(node: Node | undefined): boolean { - if (node?.type !== 'CallExpression') { - return false; - } - - return Rules.#isIdentifierNamed(Rules.#ast.childNode(node, 'callee'), VUE_PRIMITIVE_CALLS); - } - - static #isVuePrimitiveStatement(node: Node): boolean { - if (node.type === 'ExpressionStatement') { - return Rules.#isVuePrimitiveCall(Rules.#ast.childNode(node, 'expression')); - } - - if (node.type !== 'VariableDeclaration' || Rules.#ast.declarationKind(node) !== 'const') { - return false; - } - - return Rules.#ast.childNodes(node, 'declarations').some((declaration) => { - return Rules.#isVuePrimitiveCall(Rules.#ast.childNode(declaration, 'init')); - }); - } - - static #needsBlankLineAbove(next: Node): boolean { - if (next.type === 'ReturnStatement' || Rules.#isVuePrimitiveStatement(next) || Rules.#isBlankLineAboveType(next)) { - return true; - } - - return Rules.#isExportWithDeclaration(next); - } - - static #isTypeDeclarationAbove(previous: Node): boolean { - if (TS_TYPE_DECLARATION_TYPES.has(previous.type)) { - return true; - } - - if (previous.type === 'ExportNamedDeclaration') { - const declarationType = Rules.#ast.childNode(previous, 'declaration')?.type; - - return declarationType ? TS_TYPE_DECLARATION_TYPES.has(declarationType) : false; - } - - return false; - } - - static #isLoopStatement(node: Node): boolean { - return LOOP_STATEMENTS.has(node.type); - } - - static #isStructuredPreviousStatement(previous: Node): boolean { - if (STRUCTURED_PREVIOUS_STATEMENTS.has(previous.type)) { - return true; - } - - if (previous.type === 'ExportNamedDeclaration' || previous.type === 'ExportDefaultDeclaration') { - const declarationType = Rules.#ast.childNode(previous, 'declaration')?.type; - - return Boolean(declarationType && STRUCTURED_PREVIOUS_STATEMENTS.has(declarationType)); - } - - return false; - } - - static #isClassMethodPair(previous: Node, next: Node): boolean { - return CLASS_METHOD_TYPES.has(previous.type) && CLASS_METHOD_TYPES.has(next.type); - } - - static #isPropertyToMethodTransition(previous: Node, next: Node): boolean { - return CLASS_PROPERTY_TYPES.has(previous.type) && CLASS_METHOD_TYPES.has(next.type); - } - - static #isLetDeclaration(node: Node): boolean { - return node.type === 'VariableDeclaration' && Rules.#ast.declarationKind(node) === 'let'; - } - - static #containsAwait(node: Node): boolean { - if (node.type === 'AwaitExpression') { - return true; - } - - if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') { - return false; - } - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - if ( - value.some((child) => { - return child instanceof Node && Rules.#containsAwait(child); - }) - ) { - return true; - } - } else if (value instanceof Node && Rules.#containsAwait(value)) { - return true; - } - } - - return false; - } - - /** - * Decide whether two adjacent statements require a blank line. - * - * @param previous - The previous statement. - * @param next - The following statement. - * @returns `true` when the pair must be separated by a blank line. - */ - static needsBlankLine(previous: Node, next: Node): boolean { - if (Rules.#containsAwait(previous) || Rules.#containsAwait(next) || Rules.#needsBlankLineAbove(next)) { - return true; - } - - if (Rules.#isLoopStatement(next)) { - return !Rules.#isStructuredPreviousStatement(previous); - } - - if (Rules.#isClassMethodPair(previous, next) || Rules.#isPropertyToMethodTransition(previous, next) || Rules.#isTypeDeclarationAbove(previous)) { - return true; - } - - if (previous.type === 'ImportDeclaration' && next.type !== 'ImportDeclaration') { - return true; - } - - if (Rules.#ast.isConstDeclaration(previous) !== Rules.#ast.isConstDeclaration(next)) { - return true; - } - - if (Rules.#isLetDeclaration(previous) !== Rules.#isLetDeclaration(next)) { - return true; - } - - if (previous.type === 'VariableDeclaration' && next.type !== 'VariableDeclaration') { - return true; - } - - return BLOCK_HAVING_STATEMENTS.has(previous.type); - } - - /** - * Classify a class member for stable ordering. - * - * @param node - The class member to classify. - * @returns Its property, constructor, or method group. - */ - static classifyMember(node: Node): 'property' | 'constructor' | 'method' { - if (CLASS_PROPERTY_TYPES.has(node.type)) { - return 'property'; - } - - if (node.type === 'MethodDefinition' && Rules.#ast.declarationKind(node) === 'constructor') { - return 'constructor'; - } - - return 'method'; - } -} diff --git a/packages/ts/sidecar/src/segment.ts b/packages/ts/sidecar/src/segment.ts deleted file mode 100644 index 6047542..0000000 --- a/packages/ts/sidecar/src/segment.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { BlankLines } from '#sidecar/blank-line-inserter'; -import { BodyWrapper } from '#sidecar/body-wrapper'; -import { ClassReorder } from '#sidecar/class-reorder'; -import { DeclarationReorder } from '#sidecar/declaration-reorder'; -import { EditApplier } from '#sidecar/syntax/edits'; - -/** Applies the sidecar's source-segment formatting passes in order. */ -export class Segment { - static readonly #editApplier = new EditApplier(); - - static #applyBodyWraps(content: string, virtualName: string): string { - let current = content; - - for (let i = 0; i < 5; i++) { - const edits = BodyWrapper.computeEdits(current, virtualName); - - if (edits.length === 0) { - return current; - } - - current = Segment.#editApplier.apply(current, edits); - } - - return current; - } - - /** - * Format one TypeScript-compatible source segment. - * - * @param content - The source text to format. - * @param virtualName - The filename used to parse the source. - * @returns The source after every segment pass reaches its stable output. - */ - static process(content: string, virtualName: string): string { - const bodyWrapped = Segment.#applyBodyWraps(content, virtualName); - const classReorderEdits = ClassReorder.computeEdits(bodyWrapped, virtualName); - const classReordered = classReorderEdits.length > 0 ? Segment.#editApplier.apply(bodyWrapped, classReorderEdits) : bodyWrapped; - const declarationReorderEdits = DeclarationReorder.computeEdits(classReordered, virtualName); - const reordered = declarationReorderEdits.length > 0 ? Segment.#editApplier.apply(classReordered, declarationReorderEdits) : classReordered; - const positions = BlankLines.computeInsertPositions(reordered, virtualName); - - return BlankLines.insert(reordered, positions); - } -} From e1653ab7ef28b9ca421c2c68fa66ff68e5882a11 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 24 Jul 2026 11:20:32 +0800 Subject: [PATCH 05/22] =?UTF-8?q?refactor(ts):=20TS-4=20=E2=80=94=20dissol?= =?UTF-8?q?ve=20the=20format-pipeline=20=E2=87=84=20fluent-chains=20cycle?= =?UTF-8?q?=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ts): split fluent/expanded/drizzle detection into DI passes Extract the chain-detection logic from FluentChains into FluentChainPass, convert ExpandedCalls to ExpandedCallPass with full { parser, ast, edits } injection, and rename DrizzleQueries to DrizzleQueryPass implementing FormattingPass. DrizzleQueryPass injects { parser, edits } at its boundary while its ~30 detection/emission helpers and module Sets stay static pending the TS-5 internal split. Add a shared mapPool concurrency helper. * refactor(ts): give FormatPipeline its final DI shape and a fluent pipeline Move format-pipeline.ts into pipeline/, extract validate() into a SyntaxValidator (deps { sourceFiles, splitter, parser }), and reduce FormatPipeline to { editor, processRunner, validator } with a runPass(FileFormatter, files, mode) generic over both pipelines. Add PipelineFactory.fluentPipeline() composing fluent-chain, Drizzle-query, and expanded-call passes in the exact order the old FluentChains.format used, plus segmentFormatter/fluentFormatter/syntaxValidator builders so the composition root vends the whole formatting graph. * refactor(ts): rewire CLIs onto the fluent pipeline and drop the import cycle Reduce fluent-chains.ts to a thin CLI shim that runs the fluent FileFormatter through the composition root, deleting the FluentChains class and the lazy 'await import(#sidecar/format-pipeline)' cycle hack. Update blank-lines, validate-syntax, and format-all to build the pipeline from { editor, processRunner, validator } and pass FileFormatters to runPass, keeping every stdout label byte-identical. Convert the fluent, Drizzle, expanded, files, and format-all tests to drive the passes and pipelines directly. --- packages/ts/sidecar/src/blank-lines.ts | 17 +- .../ts/sidecar/src/drizzle-queries.test.ts | 49 +- packages/ts/sidecar/src/drizzle-queries.ts | 657 ----------------- packages/ts/sidecar/src/expanded-calls.ts | 299 -------- packages/ts/sidecar/src/fluent-chains.test.ts | 32 +- packages/ts/sidecar/src/fluent-chains.ts | 300 ++------ packages/ts/sidecar/src/format-all.test.ts | 81 ++- packages/ts/sidecar/src/format-all.ts | 23 +- packages/ts/sidecar/src/format-pipeline.ts | 222 ------ packages/ts/sidecar/src/io/files.test.ts | 22 +- packages/ts/sidecar/src/kernel/concurrency.ts | 39 ++ .../sidecar/src/passes/drizzle-query-pass.ts | 660 ++++++++++++++++++ .../expanded-call-pass.test.ts} | 71 +- .../sidecar/src/passes/expanded-call-pass.ts | 299 ++++++++ .../sidecar/src/passes/fluent-chain-pass.ts | 172 +++++ .../sidecar/src/pipeline/format-pipeline.ts | 128 ++++ .../sidecar/src/pipeline/pipeline-factory.ts | 66 +- .../sidecar/src/pipeline/syntax-validator.ts | 80 +++ packages/ts/sidecar/src/validate-syntax.ts | 12 +- 19 files changed, 1685 insertions(+), 1544 deletions(-) delete mode 100644 packages/ts/sidecar/src/drizzle-queries.ts delete mode 100644 packages/ts/sidecar/src/expanded-calls.ts delete mode 100644 packages/ts/sidecar/src/format-pipeline.ts create mode 100644 packages/ts/sidecar/src/kernel/concurrency.ts create mode 100644 packages/ts/sidecar/src/passes/drizzle-query-pass.ts rename packages/ts/sidecar/src/{expanded-calls.test.ts => passes/expanded-call-pass.test.ts} (79%) create mode 100644 packages/ts/sidecar/src/passes/expanded-call-pass.ts create mode 100644 packages/ts/sidecar/src/passes/fluent-chain-pass.ts create mode 100644 packages/ts/sidecar/src/pipeline/format-pipeline.ts create mode 100644 packages/ts/sidecar/src/pipeline/syntax-validator.ts diff --git a/packages/ts/sidecar/src/blank-lines.ts b/packages/ts/sidecar/src/blank-lines.ts index 4540197..b7f12e2 100644 --- a/packages/ts/sidecar/src/blank-lines.ts +++ b/packages/ts/sidecar/src/blank-lines.ts @@ -1,20 +1,27 @@ import { pathToFileURL } from 'node:url'; -import { FormatPipeline } from '#sidecar/format-pipeline'; -import { PassCliDto } from '#sidecar/pass-cli-dto'; +import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; import { NodeProcessRunner } from '#sidecar/io/process-runner'; import { NodeSourceFiles } from '#sidecar/io/source-files'; +import { PassCliDto } from '#sidecar/pass-cli-dto'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; async function main(): Promise { const cwd = process.cwd(); const options = PassCliDto.parse(process.argv.slice(2)); const files = [...options.files]; const { mode } = options; - const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); + const factory = PipelineFactory.create(); + const sourceFiles = new NodeSourceFiles(); - const outcomes = await pipeline.runPass('blank-lines', files, mode, (file, passMode) => { - return pipeline.formatFile(file, passMode); + const pipeline = new FormatPipeline({ + editor: new SourceFileEditor({ sourceFiles }), + processRunner: new NodeProcessRunner(), + validator: factory.syntaxValidator(sourceFiles), }); + const outcomes = await pipeline.runPass(factory.segmentFormatter(), files, mode); + let changedCount = 0; for (const outcome of outcomes) { diff --git a/packages/ts/sidecar/src/drizzle-queries.test.ts b/packages/ts/sidecar/src/drizzle-queries.test.ts index 48740c1..b26e6f2 100644 --- a/packages/ts/sidecar/src/drizzle-queries.test.ts +++ b/packages/ts/sidecar/src/drizzle-queries.test.ts @@ -1,7 +1,28 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { DrizzleQueries } from '#sidecar/drizzle-queries'; -import { FluentChains } from '#sidecar/fluent-chains'; +import { DrizzleQueryPass } from '#sidecar/passes/drizzle-query-pass'; +import { EditApplier } from '#sidecar/syntax/edits'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; + +const fluentPipeline = PipelineFactory.create().fluentPipeline(); + +// The composed fluent → Drizzle → expanded pipeline, matching the order the old +// FluentChains.format applied. Drizzle formatting only fires once a query's +// chain has been split across lines, so these cases run the whole pipeline. +function format(input: string, virtualName: string): string { + return fluentPipeline.apply(SourceDocument.of(virtualName, input)).text; +} + +const editApplier = new EditApplier(); +const drizzlePass = new DrizzleQueryPass({ parser: new SourceParser(), edits: editApplier }); + +function drizzleFormat(input: string, virtualName: string): string { + const edits = drizzlePass.computeEdits(SourceDocument.of(virtualName, input)); + + return edits.length > 0 ? editApplier.apply(input, edits) : input; +} describe('Drizzle query formatter', () => { it('formats nested where predicates after fluent-chain splitting', () => { @@ -25,7 +46,7 @@ describe('Drizzle query formatter', () => { '', ].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('formats join predicates with Drizzle helpers', () => { @@ -49,7 +70,7 @@ describe('Drizzle query formatter', () => { '', ].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('formats mutation objects and nested conflict predicates', () => { @@ -84,7 +105,7 @@ describe('Drizzle query formatter', () => { '', ].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('formats relational query builder option objects', () => { @@ -114,7 +135,7 @@ describe('Drizzle query formatter', () => { '', ].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('formats set-operation operands', () => { @@ -122,7 +143,7 @@ describe('Drizzle query formatter', () => { const expected = ["import { union } from 'drizzle-orm';", 'const rows = await union(', '\tdb.select().from(users),', '\tdb.select().from(admins),', ').limit(10);', ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('supports aliased Drizzle imports', () => { @@ -141,7 +162,7 @@ describe('Drizzle query formatter', () => { '', ].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('leaves non-Drizzle helpers unchanged', () => { @@ -149,7 +170,7 @@ describe('Drizzle query formatter', () => { const expected = ['const rows = await db.select()', '\t.from(users)', '\t.where(and(eq(users.id, id), eq(users.active, true)));', ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('does not treat non-db select chains as Drizzle receivers', () => { @@ -157,7 +178,7 @@ describe('Drizzle query formatter', () => { const expected = ["import { and, eq } from 'drizzle-orm';", 'const rows = await builder.select()', '\t.from(users)', '\t.where(and(eq(users.id, id), eq(users.active, true)));', ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('skips commented Drizzle spans', () => { @@ -171,7 +192,7 @@ describe('Drizzle query formatter', () => { '', ].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('is idempotent for formatted Drizzle queries', () => { @@ -188,7 +209,7 @@ describe('Drizzle query formatter', () => { '', ].join('\n'); - assert.equal(FluentChains.format(FluentChains.format(input, 'fixture.ts'), 'fixture.ts'), input); + assert.equal(format(format(input, 'fixture.ts'), 'fixture.ts'), input); }); it('nests with four spaces when the source is space-indented', () => { @@ -209,7 +230,7 @@ describe('Drizzle query formatter', () => { '', ].join('\n'); - const output = DrizzleQueries.format(input, 'fixture.ts'); + const output = drizzleFormat(input, 'fixture.ts'); assert.equal(output, expected); assert.ok(!output.includes('\t'), 'space-indented Drizzle formatting must not introduce tabs'); @@ -223,6 +244,6 @@ describe('Drizzle query formatter', () => { '', ].join('\n'); - assert.equal(DrizzleQueries.format(input, 'fixture.d.ts'), input); + assert.equal(drizzleFormat(input, 'fixture.d.ts'), input); }); }); diff --git a/packages/ts/sidecar/src/drizzle-queries.ts b/packages/ts/sidecar/src/drizzle-queries.ts deleted file mode 100644 index e517bce..0000000 --- a/packages/ts/sidecar/src/drizzle-queries.ts +++ /dev/null @@ -1,657 +0,0 @@ -import { AstReader } from '#sidecar/syntax/ast-reader'; -import { EditApplier } from '#sidecar/syntax/edits'; -import { FileTargets } from '#sidecar/hosts/file-targets'; -import { Node } from '#sidecar/syntax/node-schema'; -import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; -import { isErr } from '#sidecar/kernel/result'; -import { SourceDocument } from '#sidecar/syntax/source-document'; -import { SourceParser } from '#sidecar/syntax/source-parser'; -import type { Edit } from '#sidecar/syntax/edits'; - -type DrizzleImports = { - locals: Map; - namespaces: Set; -}; - -// Detection: identify Drizzle imports, receivers, calls, and structural arguments. - -const DRIZZLE_MODULE = 'drizzle-orm'; -const DRIZZLE_RECEIVERS = new Set(['db', 'tx']); - -const DRIZZLE_CHAIN_METHODS = new Set([ - '$count', - '$dynamic', - '$with', - 'as', - 'crossJoin', - 'delete', - 'except', - 'from', - 'fullJoin', - 'groupBy', - 'having', - 'innerJoin', - 'insert', - 'intersect', - 'leftJoin', - 'limit', - 'offset', - 'onConflictDoNothing', - 'onConflictDoUpdate', - 'orderBy', - 'prepare', - 'returning', - 'rightJoin', - 'select', - 'set', - 'union', - 'unionAll', - 'update', - 'values', - 'where', - 'with', -]); - -const DRIZZLE_FORMAT_METHODS = new Set([ - '$count', - 'as', - 'crossJoin', - 'except', - 'findFirst', - 'findMany', - 'fullJoin', - 'groupBy', - 'having', - 'innerJoin', - 'intersect', - 'leftJoin', - 'onConflictDoNothing', - 'onConflictDoUpdate', - 'orderBy', - 'returning', - 'rightJoin', - 'set', - 'union', - 'unionAll', - 'values', - 'where', -]); - -const DRIZZLE_HELPERS = new Set([ - 'and', - 'arrayContained', - 'arrayContains', - 'arrayOverlaps', - 'asc', - 'between', - 'desc', - 'eq', - 'exists', - 'gt', - 'gte', - 'ilike', - 'inArray', - 'isNotNull', - 'isNull', - 'like', - 'lt', - 'lte', - 'ne', - 'not', - 'notBetween', - 'notExists', - 'notIlike', - 'notInArray', - 'notLike', - 'or', - 'sql', -]); - -const MULTILINE_HELPERS = new Set(['and', 'or', 'not', 'exists', 'notExists']); -const SET_OPERATION_HELPERS = new Set(['except', 'intersect', 'union', 'unionAll']); -const DRIZZLE_OBJECT_KEYS = new Set(['columns', 'extras', 'limit', 'offset', 'onUpdate', 'orderBy', 'set', 'target', 'targetWhere', 'where', 'with']); - -/** Formats recognised Drizzle query structures without touching unrelated calls. */ -export class DrizzleQueries { - static readonly #ast = new AstReader(); - - static readonly #editApplier = new EditApplier(); - - static readonly #parser = new SourceParser(); - - static #localName(node: Node | undefined): string | null { - return node?.type === 'Identifier' ? (DrizzleQueries.#ast.nodeName(node) ?? null) : null; - } - - static #literalValue(node: Node | undefined): string | null { - if (node?.type !== 'Literal') { - return null; - } - - return DrizzleQueries.#ast.stringValue(node) ?? null; - } - - static #propertyName(member: Node | undefined): string | null { - if (member?.type !== 'MemberExpression' || member.computed) { - return null; - } - - return DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(member, 'property')); - } - - static #calleeName(callee: Node | undefined, imports: DrizzleImports): string | null { - if (!callee) { - return null; - } - - if (callee.type === 'Identifier') { - const name = DrizzleQueries.#localName(callee); - - return name ? (imports.locals.get(name) ?? null) : null; - } - - if (callee.type === 'MemberExpression' && !callee.computed) { - const object = DrizzleQueries.#ast.childNode(callee, 'object'); - - const property = DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(callee, 'property')); - - const objectName = DrizzleQueries.#localName(object); - - if (objectName && property && imports.namespaces.has(objectName)) { - return property; - } - } - - return null; - } - - static #collectDrizzleImports(program: Node): DrizzleImports { - const imports: DrizzleImports = { locals: new Map(), namespaces: new Set() }; - const body = DrizzleQueries.#ast.childNodes(program, 'body'); - - for (const statement of body) { - if (statement.type !== 'ImportDeclaration') { - continue; - } - - const source = DrizzleQueries.#literalValue(DrizzleQueries.#ast.childNode(statement, 'source')); - - if (!source?.startsWith(DRIZZLE_MODULE)) { - continue; - } - - for (const specifier of DrizzleQueries.#ast.childNodes(statement, 'specifiers')) { - if (specifier.type === 'ImportSpecifier') { - const imported = DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(specifier, 'imported')); - const local = DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(specifier, 'local')); - - if (imported && local) { - imports.locals.set(local, imported); - } - } - - if (specifier.type === 'ImportNamespaceSpecifier') { - const local = DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(specifier, 'local')); - - if (local) { - imports.namespaces.add(local); - } - } - } - } - - return imports; - } - - static #chainHasQueryMember(node: Node | undefined): boolean { - const current = DrizzleQueries.#ast.unwrapChainExpression(node); - - if (!current) { - return false; - } - - if (current.type === 'MemberExpression') { - if (DrizzleQueries.#propertyName(current) === 'query') { - return true; - } - - return DrizzleQueries.#chainHasQueryMember(DrizzleQueries.#ast.childNode(current, 'object')); - } - - if (current.type === 'CallExpression') { - return DrizzleQueries.#chainHasQueryMember(DrizzleQueries.#ast.childNode(current, 'callee')); - } - - return false; - } - - static #isDrizzleReceiver(node: Node | undefined, imports: DrizzleImports): boolean { - const current = DrizzleQueries.#ast.unwrapChainExpression(node); - - if (!current) { - return false; - } - - if (current.type === 'Identifier') { - const name = DrizzleQueries.#localName(current); - - return Boolean(name && DRIZZLE_RECEIVERS.has(name)); - } - - if (current.type === 'MemberExpression') { - const object = DrizzleQueries.#ast.childNode(current, 'object'); - const property = DrizzleQueries.#propertyName(current); - - if (property === 'query') { - return DrizzleQueries.#isDrizzleReceiver(object, imports); - } - - return DrizzleQueries.#isDrizzleReceiver(object, imports); - } - - if (current.type === 'CallExpression') { - const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(current, 'callee')); - - if (callee?.type === 'Identifier') { - const imported = DrizzleQueries.#calleeName(callee, imports); - - return Boolean(imported && SET_OPERATION_HELPERS.has(imported)); - } - - if (callee?.type === 'MemberExpression') { - const method = DrizzleQueries.#propertyName(callee); - - if (method && DRIZZLE_CHAIN_METHODS.has(method)) { - return DrizzleQueries.#isDrizzleReceiver(DrizzleQueries.#ast.childNode(callee, 'object'), imports); - } - - return DrizzleQueries.#isDrizzleReceiver(DrizzleQueries.#ast.childNode(callee, 'object'), imports); - } - } - - return false; - } - - static #methodName(call: Node): string | null { - const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); - - return callee?.type === 'MemberExpression' ? DrizzleQueries.#propertyName(callee) : null; - } - - static #isDrizzleMethodCall(call: Node, imports: DrizzleImports): boolean { - const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); - - if (callee?.type !== 'MemberExpression') { - return false; - } - - const name = DrizzleQueries.#propertyName(callee); - - if (!name || !DRIZZLE_FORMAT_METHODS.has(name)) { - return false; - } - - return DrizzleQueries.#isDrizzleReceiver(DrizzleQueries.#ast.childNode(callee, 'object'), imports); - } - - static #isRelationalQueryCall(call: Node, imports: DrizzleImports): boolean { - const name = DrizzleQueries.#methodName(call); - - const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); - - if ((name !== 'findMany' && name !== 'findFirst') || callee?.type !== 'MemberExpression') { - return false; - } - - const object = DrizzleQueries.#ast.childNode(callee, 'object'); - - return DrizzleQueries.#chainHasQueryMember(object) && DrizzleQueries.#isDrizzleReceiver(object, imports); - } - - static #isImportedHelperCall(call: Node, imports: DrizzleImports): boolean { - const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); - - const name = DrizzleQueries.#calleeName(callee, imports); - - return Boolean(name && DRIZZLE_HELPERS.has(name)); - } - - static #isSetOperationCall(call: Node, imports: DrizzleImports): boolean { - const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); - - const name = DrizzleQueries.#calleeName(callee, imports); - - return Boolean(name && SET_OPERATION_HELPERS.has(name)); - } - - static #callDisplayName(source: string, call: Node, imports: DrizzleImports): string { - const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); - - if (callee?.type === 'Identifier') { - return DrizzleQueries.#ast.sourceOf(source, callee); - } - - if (callee?.type === 'MemberExpression') { - const name = DrizzleQueries.#calleeName(callee, imports); - - if (name) { - return DrizzleQueries.#ast.sourceOf(source, callee); - } - } - - return callee ? DrizzleQueries.#ast.sourceOf(source, callee) : ''; - } - - static #callParens(source: string, call: Node): { open: number; close: number } | null { - return DrizzleQueries.#ast.callParens(source, call, DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee'))); - } - - static #shouldFormatObjectExpression(node: Node): boolean { - const properties = DrizzleQueries.#ast.childNodes(node, 'properties'); - - if (properties.length > 1) { - return true; - } - - return properties.some((property) => { - if (property.type !== 'Property') { - return true; - } - - const key = DrizzleQueries.#localName(DrizzleQueries.#ast.childNode(property, 'key')); - - const value = DrizzleQueries.#ast.childNode(property, 'value'); - - if (!value) { - return false; - } - - if (key && DRIZZLE_OBJECT_KEYS.has(key) && (value.type === 'ObjectExpression' || value.type === 'ArrayExpression' || value.type === 'CallExpression')) { - return true; - } - - return value.type === 'ObjectExpression' || value.type === 'ArrayExpression'; - }); - } - - static #shouldFormatArrayExpression(node: Node): boolean { - const elements = Array.isArray(node.elements) ? node.elements : []; - - return elements.length > 1 || elements.some((element) => element instanceof Node && (element.type === 'ObjectExpression' || element.type === 'CallExpression')); - } - - static #isComplexArgument(node: Node, imports: DrizzleImports): boolean { - if (node.type === 'ObjectExpression') { - return DrizzleQueries.#shouldFormatObjectExpression(node); - } - - if (node.type === 'ArrayExpression') { - return DrizzleQueries.#shouldFormatArrayExpression(node); - } - - if (node.type === 'CallExpression') { - return DrizzleQueries.#isImportedHelperCall(node, imports) || DrizzleQueries.#isSetOperationCall(node, imports) || DrizzleQueries.#isDrizzleMethodCall(node, imports); - } - - return false; - } - - static #isStructuralArgument(node: Node, imports: DrizzleImports): boolean { - if (node.type === 'ObjectExpression' || node.type === 'ArrayExpression') { - return true; - } - - return node.type === 'CallExpression' && (DrizzleQueries.#isSetOperationCall(node, imports) || DrizzleQueries.#isDrizzleMethodCall(node, imports)); - } - - static #shouldFormatMethodArguments(call: Node, imports: DrizzleImports): boolean { - const args = DrizzleQueries.#ast.childNodes(call, 'arguments'); - - if (args.length === 0) { - return false; - } - - if (DrizzleQueries.#isRelationalQueryCall(call, imports)) { - return args.some((arg) => arg.type === 'ObjectExpression' && DrizzleQueries.#shouldFormatObjectExpression(arg)); - } - - const name = DrizzleQueries.#methodName(call); - - if (!name) { - return false; - } - - if (['where', 'having', '$count'].includes(name)) { - return args.some((arg) => DrizzleQueries.#isComplexArgument(arg, imports)); - } - - if (['leftJoin', 'rightJoin', 'innerJoin', 'fullJoin', 'crossJoin'].includes(name)) { - return args.length > 1 && args.some((arg, index) => index > 0 && DrizzleQueries.#isComplexArgument(arg, imports)); - } - - if (['onConflictDoNothing', 'onConflictDoUpdate', 'returning', 'set', 'values'].includes(name)) { - return args.some((arg) => DrizzleQueries.#isComplexArgument(arg, imports)); - } - - if (['as', 'except', 'groupBy', 'intersect', 'orderBy', 'union', 'unionAll'].includes(name)) { - return args.length > 1 || args.some((arg) => DrizzleQueries.#isStructuralArgument(arg, imports)); - } - - return args.length > 1 && args.some((arg) => DrizzleQueries.#isComplexArgument(arg, imports)); - } - - // Emission: render recognised structures and produce non-overlapping edits. - - static #formatArrayExpression(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { - if (parsed.hasCommentBetween(DrizzleQueries.#ast.getStart(node), DrizzleQueries.#ast.getEnd(node))) { - return DrizzleQueries.#ast.sourceOf(source, node); - } - - const elements = Array.isArray(node.elements) ? node.elements : []; - - if (elements.length === 0) { - return '[]'; - } - - const nextIndent = `${indent}${indentUnit}`; - - const formatted = elements.map((element) => { - return element instanceof Node ? DrizzleQueries.#formatNode(source, element, imports, parsed, nextIndent, indentUnit) : ''; - }); - - return `[\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}]`; - } - - static #formatObjectExpression(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { - if (parsed.hasCommentBetween(DrizzleQueries.#ast.getStart(node), DrizzleQueries.#ast.getEnd(node))) { - return DrizzleQueries.#ast.sourceOf(source, node); - } - - const properties = DrizzleQueries.#ast.childNodes(node, 'properties'); - - if (properties.length === 0) { - return '{}'; - } - - const nextIndent = `${indent}${indentUnit}`; - - const formatted = properties.map((property) => { - if (property.type !== 'Property') { - return DrizzleQueries.#ast.sourceOf(source, property); - } - - const key = DrizzleQueries.#ast.childNode(property, 'key'); - const value = DrizzleQueries.#ast.childNode(property, 'value'); - - if (!key || !value || property.computed || property.method) { - return DrizzleQueries.#ast.sourceOf(source, property); - } - - if (property.shorthand) { - return DrizzleQueries.#ast.sourceOf(source, property); - } - - return `${DrizzleQueries.#ast.sourceOf(source, key)}: ${DrizzleQueries.#formatNode(source, value, imports, parsed, nextIndent, indentUnit)}`; - }); - - return `{\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}}`; - } - - static #formatHelperCall(source: string, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { - if (parsed.hasCommentBetween(DrizzleQueries.#ast.getStart(call), DrizzleQueries.#ast.getEnd(call))) { - return DrizzleQueries.#ast.sourceOf(source, call); - } - - const importedName = DrizzleQueries.#calleeName(DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')), imports); - - const args = DrizzleQueries.#ast.childNodes(call, 'arguments'); - - if (!importedName || !MULTILINE_HELPERS.has(importedName) || args.length === 0) { - return DrizzleQueries.#ast.sourceOf(source, call); - } - - const nextIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueries.#formatNode(source, arg, imports, parsed, nextIndent, indentUnit)); - - return `${DrizzleQueries.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; - } - - static #formatSetOperationCall(source: string, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { - if (parsed.hasCommentBetween(DrizzleQueries.#ast.getStart(call), DrizzleQueries.#ast.getEnd(call))) { - return DrizzleQueries.#ast.sourceOf(source, call); - } - - const args = DrizzleQueries.#ast.childNodes(call, 'arguments'); - - if (args.length < 2) { - return DrizzleQueries.#ast.sourceOf(source, call); - } - - const nextIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueries.#formatNode(source, arg, imports, parsed, nextIndent, indentUnit)); - - return `${DrizzleQueries.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; - } - - static #formatNode(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { - if (node.type === 'ObjectExpression' && DrizzleQueries.#shouldFormatObjectExpression(node)) { - return DrizzleQueries.#formatObjectExpression(source, node, imports, parsed, indent, indentUnit); - } - - if (node.type === 'ArrayExpression' && DrizzleQueries.#shouldFormatArrayExpression(node)) { - return DrizzleQueries.#formatArrayExpression(source, node, imports, parsed, indent, indentUnit); - } - - if (node.type === 'CallExpression') { - if (DrizzleQueries.#isSetOperationCall(node, imports)) { - return DrizzleQueries.#formatSetOperationCall(source, node, imports, parsed, indent, indentUnit); - } - - if (DrizzleQueries.#isImportedHelperCall(node, imports)) { - return DrizzleQueries.#formatHelperCall(source, node, imports, parsed, indent, indentUnit); - } - } - - return DrizzleQueries.#ast.sourceOf(source, node); - } - - static #formatCallArguments(document: SourceDocument, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indentUnit: string): Edit | null { - const parens = DrizzleQueries.#callParens(document.text, call); - const args = DrizzleQueries.#ast.childNodes(call, 'arguments'); - - if (!parens || args.length === 0) { - return null; - } - - if (parsed.hasCommentBetween(parens.open, parens.close)) { - return null; - } - - const callee = DrizzleQueries.#ast.unwrapChainExpression(DrizzleQueries.#ast.childNode(call, 'callee')); - - const property = callee ? DrizzleQueries.#ast.childNode(callee, 'property') : undefined; - const indentPos = callee?.type === 'MemberExpression' && property ? DrizzleQueries.#ast.getStart(property) : DrizzleQueries.#ast.getStart(call); - const indent = document.lineIndent(indentPos); - const argIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueries.#formatNode(document.text, arg, imports, parsed, argIndent, indentUnit)); - const replacement = `(\n${argIndent}${formatted.join(`,\n${argIndent}`)},\n${indent})`; - - if (document.slice(parens.open, parens.close + 1) === replacement) { - return null; - } - - return { - start: parens.open, - end: parens.close + 1, - replacement, - }; - } - - /** - * Compute edits for recognised Drizzle query structures. - * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. - * @returns Non-overlapping query-formatting edits. - */ - static computeEdits(content: string, virtualName: string): Edit[] { - if (FileTargets.isDeclarationFile(virtualName)) { - return []; - } - - const parsed = DrizzleQueries.#parser.parse(virtualName, content); - - if (isErr(parsed)) { - return []; - } - - const document = SourceDocument.of(virtualName, content); - const imports = DrizzleQueries.#collectDrizzleImports(parsed.value.program); - - if (imports.locals.size === 0 && imports.namespaces.size === 0) { - return []; - } - - const edits: Edit[] = []; - const indentUnit = document.indentUnit(); - - DrizzleQueries.#ast.visit(parsed.value.program, (node) => { - if (node.type !== 'CallExpression') { - return; - } - - if (DrizzleQueries.#isDrizzleMethodCall(node, imports) || DrizzleQueries.#isRelationalQueryCall(node, imports) || DrizzleQueries.#isSetOperationCall(node, imports)) { - const args = DrizzleQueries.#ast.childNodes(node, 'arguments'); - - if (DrizzleQueries.#isSetOperationCall(node, imports) && args.length > 0 && args.length < 2) { - return; - } - - if (!DrizzleQueries.#isSetOperationCall(node, imports) && !DrizzleQueries.#shouldFormatMethodArguments(node, imports)) { - return; - } - - const edit = DrizzleQueries.#formatCallArguments(document, node, imports, parsed.value, indentUnit); - - if (edit) { - edits.push(edit); - } - } - }); - - return DrizzleQueries.#editApplier.nonOverlapping(edits); - } - - /** - * Format recognised Drizzle query structures. - * - * @param content - The source text to format. - * @param virtualName - The filename used to parse the source. - * @returns The formatted source, or the original source when no edits apply. - */ - static format(content: string, virtualName: string): string { - const edits = DrizzleQueries.computeEdits(content, virtualName); - - return edits.length > 0 ? DrizzleQueries.#editApplier.apply(content, edits) : content; - } -} diff --git a/packages/ts/sidecar/src/expanded-calls.ts b/packages/ts/sidecar/src/expanded-calls.ts deleted file mode 100644 index fd421ac..0000000 --- a/packages/ts/sidecar/src/expanded-calls.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { AstReader } from '#sidecar/syntax/ast-reader'; -import type { CallParens } from '#sidecar/syntax/ast-reader'; -import { EditApplier } from '#sidecar/syntax/edits'; -import { FileTargets } from '#sidecar/hosts/file-targets'; -import { Node } from '#sidecar/syntax/node-schema'; -import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; -import { isErr } from '#sidecar/kernel/result'; -import { SourceDocument } from '#sidecar/syntax/source-document'; -import { SourceParser } from '#sidecar/syntax/source-parser'; -import { TemplateSpans } from '#sidecar/syntax/template-spans'; -import type { Edit } from '#sidecar/syntax/edits'; - -const FUNCTION_TYPES = new Set(['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression']); - -/** Expands structurally complex call arguments into stable multiline layouts. */ -export class ExpandedCalls { - static readonly #ast = new AstReader(); - - static readonly #editApplier = new EditApplier(); - - static readonly #parser = new SourceParser(); - - static #unwrapExpression(node: Node | undefined): Node | undefined { - let current = node; - - while ( - current && - (current.type === 'ChainExpression' || - current.type === 'ParenthesizedExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSTypeAssertion') - ) { - current = ExpandedCalls.#ast.childNode(current, 'expression'); - } - - return current; - } - - static #calleeParens(document: SourceDocument, call: Node): CallParens | null { - return ExpandedCalls.#ast.callParens(document.text, call, ExpandedCalls.#unwrapExpression(ExpandedCalls.#ast.childNode(call, 'callee'))); - } - - static #callArguments(call: Node): Node[] { - return ExpandedCalls.#ast.childNodes(call, 'arguments'); - } - - static #isMethodCall(call: Node): boolean { - const callee = ExpandedCalls.#unwrapExpression(ExpandedCalls.#ast.childNode(call, 'callee')); - - return callee?.type === 'MemberExpression'; - } - - static #isComplexArgument(node: Node): boolean { - const current = ExpandedCalls.#unwrapExpression(node); - - return current?.type === 'CallExpression' || current?.type === 'ObjectExpression' || current?.type === 'ArrayExpression'; - } - - static #shouldExpandCall(call: Node): boolean { - const args = ExpandedCalls.#callArguments(call); - - return !ExpandedCalls.#isMethodCall(call) && args.length > 0 && args.some(ExpandedCalls.#isComplexArgument); - } - - static #collectParents(node: Node, parents: WeakMap): void { - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const child of value) { - if (child instanceof Node) { - parents.set(child, node); - ExpandedCalls.#collectParents(child, parents); - } - } - } else if (value instanceof Node) { - parents.set(value, node); - ExpandedCalls.#collectParents(value, parents); - } - } - } - - static #isInsideCallArgument(node: Node, call: Node): boolean { - const start = ExpandedCalls.#ast.getStart(node); - const end = ExpandedCalls.#ast.getEnd(node); - const args = ExpandedCalls.#callArguments(call); - - return args.some((arg) => { - return ExpandedCalls.#ast.getStart(arg) <= start && end <= ExpandedCalls.#ast.getEnd(arg); - }); - } - - static #nearestCallAncestor(node: Node, parents: WeakMap): Node | null { - let current = parents.get(node); - - while (current) { - if (FUNCTION_TYPES.has(current.type)) { - return null; - } - - if (current.type === 'CallExpression') { - return current; - } - - current = parents.get(current); - } - - return null; - } - - static #isNestedInsideUnexpandedCallArgument(node: Node, parents: WeakMap): boolean { - const ancestor = ExpandedCalls.#nearestCallAncestor(node, parents); - - if (!ancestor || !ExpandedCalls.#isInsideCallArgument(node, ancestor)) { - return false; - } - - return !ExpandedCalls.#shouldExpandCall(ancestor); - } - - static #canUseTrailingComma(arg: Node | undefined): boolean { - return arg?.type !== 'SpreadElement'; - } - - static #rebaseLine(line: string, lineStart: number, from: string, to: string, spans: TemplateSpans): string { - // A template literal's leading whitespace is string content, not - // indentation: moving it would rewrite the value, and since oxfmt hugs the - // expanded call back onto one line before the next run re-expands it, every - // run would shift the literal one level further right. - if (spans.contains(lineStart)) { - return line; - } - - if (line.trim() === '') { - return ''; - } - - return line.startsWith(from) ? `${to}${line.slice(from.length)}` : line; - } - - /** - * Re-indent lifted source so its continuation lines match where it now sits. - * - * A node's text is copied out of the call site verbatim, so its second and - * later lines are still indented relative to the line the node was written on. - * Expanding the call moves the node one or more levels deeper (`to`), and - * without rebasing those lines they keep the shallower depth and the block - * reads inside-out. Reading the origin off the node's own line, rather than off - * the call being expanded, is what makes a second run a no-op: text already - * sitting at its target depth is left alone. Only the first line is skipped - * outright — the caller places it. - */ - static #rebaseIndent(document: SourceDocument, node: Node, to: string, spans: TemplateSpans): string { - const start = ExpandedCalls.#ast.getStart(node); - const text = ExpandedCalls.#ast.sourceOf(document.text, node); - const from = document.lineIndent(start); - - if (from === to || !text.includes('\n')) { - return text; - } - - const rebased: string[] = []; - - let lineStart = start; - - for (const [index, line] of text.split('\n').entries()) { - rebased.push(index === 0 ? line : ExpandedCalls.#rebaseLine(line, lineStart, from, to, spans)); - lineStart += line.length + 1; - } - - return rebased.join('\n'); - } - - static #formatCallParens(document: SourceDocument, call: Node, parsed: ParsedSourceDto, indent: string, indentUnit: string, spans: TemplateSpans): string | null { - const parens = ExpandedCalls.#calleeParens(document, call); - const args = ExpandedCalls.#callArguments(call); - - if (!parens || args.length === 0 || parsed.hasCommentBetween(parens.open, parens.close)) { - return null; - } - - if (!ExpandedCalls.#shouldExpandCall(call)) { - return null; - } - - const argIndent = `${indent}${indentUnit}`; - - const formattedArgs = args.map((arg) => { - return ExpandedCalls.#formatNode(document, arg, parsed, argIndent, indentUnit, spans); - }); - - const separator = `,\n${argIndent}`; - const trailingComma = ExpandedCalls.#canUseTrailingComma(args.at(-1)) ? ',' : ''; - - return `(\n${argIndent}${formattedArgs.join(separator)}${trailingComma}\n${indent})`; - } - - static #formatCall(document: SourceDocument, call: Node, parsed: ParsedSourceDto, indent: string, indentUnit: string, spans: TemplateSpans): string { - const parens = ExpandedCalls.#calleeParens(document, call); - const formattedParens = ExpandedCalls.#formatCallParens(document, call, parsed, indent, indentUnit, spans); - - if (!parens || formattedParens === null) { - return ExpandedCalls.#rebaseIndent(document, call, indent, spans); - } - - return `${document.slice(ExpandedCalls.#ast.getStart(call), parens.open)}${formattedParens}`; - } - - // indent is where node will sit once expanded; the depth its text came from is - // read back off the node's own line, because nothing has moved in the source - // yet however deep the recursion goes. - static #formatNode(document: SourceDocument, node: Node, parsed: ParsedSourceDto, indent: string, indentUnit: string, spans: TemplateSpans): string { - if (node.type !== 'CallExpression') { - return ExpandedCalls.#rebaseIndent(document, node, indent, spans); - } - - if (!ExpandedCalls.#shouldExpandCall(node)) { - return ExpandedCalls.#rebaseIndent(document, node, indent, spans); - } - - return ExpandedCalls.#formatCall(document, node, parsed, indent, indentUnit, spans); - } - /** - * Compute edits for calls whose arguments require a multiline layout. - * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. - * @returns Non-overlapping expanded-call edits. - */ - static computeEdits(content: string, virtualName: string): Edit[] { - if (FileTargets.isDeclarationFile(virtualName)) { - return []; - } - - const parsed = ExpandedCalls.#parser.parse(virtualName, content); - - if (isErr(parsed)) { - return []; - } - - const document = SourceDocument.of(virtualName, content); - const parents = new WeakMap(); - const edits: Edit[] = []; - const indentUnit = document.indentUnit(); - const spans = TemplateSpans.collect(parsed.value.program); - - ExpandedCalls.#collectParents(parsed.value.program, parents); - - ExpandedCalls.#ast.visit(parsed.value.program, (node) => { - if (node.type !== 'CallExpression') { - return; - } - - if (!ExpandedCalls.#shouldExpandCall(node)) { - return; - } - - if (ExpandedCalls.#isNestedInsideUnexpandedCallArgument(node, parents)) { - return; - } - - const parens = ExpandedCalls.#calleeParens(document, node); - - if (!parens || parsed.value.hasCommentBetween(parens.open, parens.close)) { - return; - } - - const indent = document.lineIndent(ExpandedCalls.#ast.getStart(node)); - - const replacement = ExpandedCalls.#formatCallParens(document, node, parsed.value, indent, indentUnit, spans); - const current = document.slice(parens.open, parens.close + 1); - - if (replacement === null || replacement === current) { - return; - } - - edits.push({ - start: parens.open, - end: parens.close + 1, - replacement, - }); - }); - - return ExpandedCalls.#editApplier.nonOverlapping(edits); - } - - /** - * Format calls whose arguments require a multiline layout. - * - * @param content - The source text to format. - * @param virtualName - The filename used to parse the source. - * @returns The formatted source, or the original source when no edits apply. - */ - static format(content: string, virtualName: string): string { - const edits = ExpandedCalls.computeEdits(content, virtualName); - - return edits.length > 0 ? ExpandedCalls.#editApplier.apply(content, edits) : content; - } -} diff --git a/packages/ts/sidecar/src/fluent-chains.test.ts b/packages/ts/sidecar/src/fluent-chains.test.ts index eb4ebf5..8a13f54 100644 --- a/packages/ts/sidecar/src/fluent-chains.test.ts +++ b/packages/ts/sidecar/src/fluent-chains.test.ts @@ -5,7 +5,15 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; -import { FluentChains } from '#sidecar/fluent-chains'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceDocument } from '#sidecar/syntax/source-document'; + +const fluentPipeline = PipelineFactory.create().fluentPipeline(); + +// The composed fluent → Drizzle → expanded pipeline the fluent-chains CLI runs. +function format(input: string, virtualName: string): string { + return fluentPipeline.apply(SourceDocument.of(virtualName, input)).text; +} const script = fileURLToPath( import.meta.resolve('#sidecar/fluent-chains'), @@ -65,26 +73,26 @@ describe('fluent chain formatter', () => { '', ].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('is idempotent for already split chains', () => { const input = ['const routes = createRouter()', "\t.use('*', bindEnv)", "\t.get('/', getMe);", ''].join('\n'); - assert.equal(FluentChains.format(FluentChains.format(input, 'fixture.ts'), 'fixture.ts'), input); + assert.equal(format(format(input, 'fixture.ts'), 'fixture.ts'), input); }); it('uses the file indentation style for split chains', () => { const input = ['function routes() {', " return createRouter().use('*', bindEnv).get('/', getMe);", '}', ''].join('\n'); const expected = ['function routes() {', ' return createRouter()', " .use('*', bindEnv)", " .get('/', getMe);", '}', ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('splits chains with four spaces when the source is space-indented', () => { const input = ['function routes() {', " return createRouter().use('*', bindEnv).get('/', getMe);", '}', ''].join('\n'); const expected = ['function routes() {', ' return createRouter()', " .use('*', bindEnv)", " .get('/', getMe);", '}', ''].join('\n'); - const output = FluentChains.format(input, 'fixture.ts'); + const output = format(input, 'fixture.ts'); assert.equal(output, expected); assert.ok(!output.includes('\t'), 'space-indented chain splitting must not introduce tabs'); @@ -96,7 +104,7 @@ describe('fluent chain formatter', () => { // continuations land at 4 tabs, not 6. const input = ['\t\t\tconst result = builder().withA(1).withB(2).withC(3).build();', ''].join('\n'); const expected = ['\t\t\tconst result = builder()', '\t\t\t\t.withA(1)', '\t\t\t\t.withB(2)', '\t\t\t\t.withC(3)', '\t\t\t\t.build();', ''].join('\n'); - const output = FluentChains.format(input, 'fixture.ts'); + const output = format(input, 'fixture.ts'); assert.equal(output, expected); @@ -106,20 +114,20 @@ describe('fluent chain formatter', () => { it('leaves short value transform chains unchanged', () => { const input = ['const normalized = value.trim().toLowerCase();', ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), input); + assert.equal(format(input, 'fixture.ts'), input); }); it('preserves optional chain operators', () => { const input = ["const result = makeClient()?.use(auth).get('/');", ''].join('\n'); const expected = ['const result = makeClient()', '\t?.use(auth)', "\t.get('/');", ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('skips chains with comments between links', () => { const input = ['const routes = createRouter()', '\t// attach middleware first', "\t.use('*', bindEnv).get('/', getMe);", ''].join('\n'); - assert.equal(FluentChains.format(input, 'fixture.ts'), input); + assert.equal(format(input, 'fixture.ts'), input); }); it('reaches a fixed point over an expanded multiline template literal', () => { @@ -127,12 +135,12 @@ describe('fluent chain formatter', () => { // committed byte state survived another pass. const interior = ['
', ' hello', '
']; const input = ['const Harness = defineComponent({', ' template: `', ...interior, ' `,', '});', ''].join('\n'); - const once = FluentChains.format(input, 'fixture.ts'); - const twice = FluentChains.format(once, 'fixture.ts'); + const once = format(input, 'fixture.ts'); + const twice = format(once, 'fixture.ts'); assert.equal(twice, once); - assert.equal(FluentChains.format(twice, 'fixture.ts'), once); + assert.equal(format(twice, 'fixture.ts'), once); assert.ok(once.includes(interior.join('\n')), 'the literal interior must keep its original bytes'); }); diff --git a/packages/ts/sidecar/src/fluent-chains.ts b/packages/ts/sidecar/src/fluent-chains.ts index 9d9f76c..5db4576 100644 --- a/packages/ts/sidecar/src/fluent-chains.ts +++ b/packages/ts/sidecar/src/fluent-chains.ts @@ -1,276 +1,54 @@ import { pathToFileURL } from 'node:url'; -import { AstReader } from '#sidecar/syntax/ast-reader'; -import { DrizzleQueries } from '#sidecar/drizzle-queries'; -import { EditApplier } from '#sidecar/syntax/edits'; -import { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; -import { ExpandedCalls } from '#sidecar/expanded-calls'; +import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { NodeProcessRunner } from '#sidecar/io/process-runner'; +import { NodeSourceFiles } from '#sidecar/io/source-files'; import { PassCliDto } from '#sidecar/pass-cli-dto'; -import { isErr, ok } from '#sidecar/kernel/result'; -import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; -import type { Result } from '#sidecar/kernel/result'; -import type { SourceFileError, SourceFiles } from '#sidecar/io/source-files'; -import { SourceDocument } from '#sidecar/syntax/source-document'; -import { SourceParser } from '#sidecar/syntax/source-parser'; -import type { Edit } from '#sidecar/syntax/edits'; -import type { Node } from '#sidecar/syntax/node-schema'; - -const cwd = process.cwd(); - -type ChainLink = { - start: number; - end: number; - operator: '.' | '?.'; -}; - -type FluentChain = { - base: Node; - links: ChainLink[]; -}; - -/** Formats fluent chains and the structured calls composed with them. */ -export class FluentChains { - static readonly #ast = new AstReader(); - - static readonly #editApplier = new EditApplier(); - - static readonly #parser = new SourceParser(); - - static readonly #splitter = new EmbeddedBlockSplitter(); - - static #memberCallLink(document: SourceDocument, member: Node, object: Node, parsed: ParsedSourceDto): ChainLink | null { - if (member.computed) { - return null; - } - - const property = FluentChains.#ast.childNode(member, 'property'); - - if (!property || (property.type !== 'Identifier' && property.type !== 'PrivateIdentifier')) { - return null; - } - - const objectEnd = FluentChains.#ast.getEnd(object); - const propertyStart = FluentChains.#ast.getStart(property); - - if (objectEnd < 0 || propertyStart < 0 || propertyStart <= objectEnd) { - return null; - } - - if (parsed.hasCommentBetween(objectEnd, propertyStart)) { - return null; - } - - const separator = document.slice(objectEnd, propertyStart); - - if (separator.includes('//') || separator.includes('/*')) { - return null; - } - - const operator = separator.replace(/[ \t\r\n]/g, ''); - - if (operator !== '.' && operator !== '?.') { - return null; - } - - return { - start: objectEnd, - end: propertyStart, - operator, - }; - } - - static #collectFluentChain(document: SourceDocument, outer: Node, parsed: ParsedSourceDto): FluentChain | null { - let call: Node = outer; - - const links: ChainLink[] = []; - - while (call.type === 'CallExpression') { - const callee = FluentChains.#ast.unwrapChainExpression(FluentChains.#ast.childNode(call, 'callee')); - - if (callee?.type !== 'MemberExpression') { - break; - } - - const object = FluentChains.#ast.unwrapChainExpression(FluentChains.#ast.childNode(callee, 'object')); - - if (object?.type !== 'CallExpression') { - break; - } - - const link = FluentChains.#memberCallLink(document, callee, object, parsed); - - if (!link) { - return null; - } - - links.push(link); - call = object; - } - - if (links.length < 2) { - return null; - } - - return { - base: call, - links, - }; - } - /** - * Compute edits that split fluent-chain links across lines. - * - * @param content - The source text to inspect. - * @param virtualName - The filename used to parse the source. - * @returns Fluent-chain edits, or none for invalid source. - */ - static computeEdits(content: string, virtualName: string): Edit[] { - const parsed = FluentChains.#parser.parse(virtualName, content); - - if (isErr(parsed)) { - return []; - } - - const document = SourceDocument.of(virtualName, content); - const edits = new Map(); - const indentStep = document.indentUnit(); - - FluentChains.#ast.visit(parsed.value.program, (node) => { - if (node.type !== 'CallExpression') { - return; - } - - const chain = FluentChains.#collectFluentChain(document, node, parsed.value); - - if (!chain) { - return; - } - - const baseStart = FluentChains.#ast.getStart(chain.base); - - if (baseStart < 0) { - return; - } - - const indent = `${document.lineIndent(baseStart)}${indentStep}`; - - for (const link of chain.links) { - const replacement = `\n${indent}${link.operator}`; - - if (document.slice(link.start, link.end) === replacement) { - continue; - } - - edits.set(`${link.start}:${link.end}`, { - start: link.start, - end: link.end, - replacement, - }); - } - }); - - return [...edits.values()].sort((a, b) => { - return a.start - b.start; - }); - } - - /** - * Apply fluent-chain, Drizzle-query, and expanded-call formatting. - * - * @param content - The source text to format. - * @param virtualName - The filename used to parse the source. - * @returns The formatted source text. - */ - static format(content: string, virtualName: string): string { - const edits = FluentChains.computeEdits(content, virtualName); - - const fluentFormatted = edits.length > 0 ? FluentChains.#editApplier.apply(content, edits) : content; - const drizzleFormatted = DrizzleQueries.format(fluentFormatted, virtualName); - - return ExpandedCalls.format(drizzleFormatted, virtualName); - } - - /** - * Format one TypeScript or host file through an injected filesystem port. - * - * @param file - The source file to format. - * @param mode - Whether to report changes or atomically write them. - * @param sourceFiles - The filesystem port used for reads and writes. - * @returns Whether the file changes, or the typed filesystem failure. - */ - static async formatFile(file: string, mode: 'check' | 'write', sourceFiles: SourceFiles): Promise> { - const read = await sourceFiles.readText(file); - - if (isErr(read)) { - return read; - } - - const original = read.value; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; + +/** + * Run the standalone fluent-chain formatter entrypoint. + * + * @returns Nothing after reporting outcomes and setting the process status. + */ +async function main(): Promise { + const cwd = process.cwd(); + const options = PassCliDto.parse(process.argv.slice(2)); + const files = [...options.files]; + const { mode } = options; + const factory = PipelineFactory.create(); + const sourceFiles = new NodeSourceFiles(); + + const pipeline = new FormatPipeline({ + editor: new SourceFileEditor({ sourceFiles }), + processRunner: new NodeProcessRunner(), + validator: factory.syntaxValidator(sourceFiles), + }); - const updated = FluentChains.#splitter.isHost(file) - ? FluentChains.#splitter.rewrite(file, original, (blockContent, virtualName) => { - return FluentChains.format(blockContent, virtualName); - }) - : FluentChains.format(original, file); + const outcomes = await pipeline.runPass(factory.fluentFormatter(), files, mode); - if (updated === original) { - return ok(false); + const changedCount = outcomes.filter((outcome) => { + if (outcome.error?._tag === 'SourceFileUnreadable' && outcome.error.isNotFound()) { + console.warn(`[fluent-chains] path not found, skipping: ${outcome.file}`); + } else if (outcome.error) { + throw outcome.error; + } else if (outcome.changed) { + console.log(`[fluent-chains] ${mode === 'check' ? 'would change' : 'updated'} ${outcome.file}`); } - if (mode === 'write') { - const written = await sourceFiles.writeTextAtomic(file, updated); + return outcome.changed; + }).length; - if (isErr(written)) { - return written; - } - } - - return ok(true); + if (mode === 'check' && changedCount > 0) { + console.error(`[fluent-chains] ${changedCount} file(s) need fluent-chain edits. Run "pnpm format" to fix.`); + process.exit(1); } - /** - * Run the standalone fluent-chain formatter entrypoint. - * - * @returns Nothing after reporting outcomes and setting the process status. - */ - static async main(): Promise { - const options = PassCliDto.parse(process.argv.slice(2)); - const files = [...options.files]; - const { mode } = options; - - const { NodeProcessRunner } = await import('#sidecar/io/process-runner'); - - const { NodeSourceFiles } = await import('#sidecar/io/source-files'); - - const { FormatPipeline } = await import('#sidecar/format-pipeline'); - - const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); - - const outcomes = await pipeline.runPass('fluent-chains', files, mode, (file, passMode) => { - return pipeline.formatFluentFile(file, passMode); - }); - - const changedCount = outcomes.filter((outcome) => { - if (outcome.error?._tag === 'SourceFileUnreadable' && outcome.error.isNotFound()) { - console.warn(`[fluent-chains] path not found, skipping: ${outcome.file}`); - } else if (outcome.error) { - throw outcome.error; - } else if (outcome.changed) { - console.log(`[fluent-chains] ${mode === 'check' ? 'would change' : 'updated'} ${outcome.file}`); - } - - return outcome.changed; - }).length; - - if (mode === 'check' && changedCount > 0) { - console.error(`[fluent-chains] ${changedCount} file(s) need fluent-chain edits. Run "pnpm format" to fix.`); - process.exit(1); - } - - console.log(`[fluent-chains] processed ${files.length} file(s) in ${cwd}, ${changedCount} ${mode === 'check' ? 'would change' : 'changed'}`); - } + console.log(`[fluent-chains] processed ${files.length} file(s) in ${cwd}, ${changedCount} ${mode === 'check' ? 'would change' : 'changed'}`); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - FluentChains.main().catch((err: unknown) => { + main().catch((err: unknown) => { console.error(err); process.exit(1); }); diff --git a/packages/ts/sidecar/src/format-all.test.ts b/packages/ts/sidecar/src/format-all.test.ts index cd6c73e..c3d7759 100644 --- a/packages/ts/sidecar/src/format-all.test.ts +++ b/packages/ts/sidecar/src/format-all.test.ts @@ -1,20 +1,31 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { availableParallelism, tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { test } from 'node:test'; import { promisify } from 'node:util'; -import { SourceFileUnreadable } from '#sidecar/kernel/errors'; import { CliOptionsDto } from '#sidecar/format-all'; -import { FormatPipeline } from '#sidecar/format-pipeline'; +import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { mapPool } from '#sidecar/kernel/concurrency'; import { NodeProcessRunner } from '#sidecar/io/process-runner'; -import { err, isErr, ok } from '#sidecar/kernel/result'; +import { isErr } from '#sidecar/kernel/result'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; import { NodeSourceFiles } from '#sidecar/io/source-files'; const execFileAsync = promisify(execFile); const formatAllScript = resolve(import.meta.dirname, 'format-all.ts'); -const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); +const factory = PipelineFactory.create(); +const sourceFiles = new NodeSourceFiles(); + +const pipeline = new FormatPipeline({ + editor: new SourceFileEditor({ sourceFiles }), + processRunner: new NodeProcessRunner(), + validator: factory.syntaxValidator(sourceFiles), +}); + +const segmentFormatter = factory.segmentFormatter(); test('parseArgs splits flags and file sections', () => { const options = CliOptionsDto.parse(['--check', '--oxfmt-bin', '/bin/oxfmt', '--oxfmt-config', '/etc/oxfmtrc.json', '--format-files', 'a.ts', 'b.vue', '--syntax-files', 'a.ts', 'types.d.ts']); @@ -59,23 +70,25 @@ test('mapPool processes every item, preserves order, and honors the limit', asyn return index; }); - const outcomes = await pipeline.runPass('test', items.map(String), 'check', async (item) => { - active++; - peak = Math.max(peak, active); + const outcomes = await mapPool( + items, + availableParallelism(), + async (item) => { + active++; + peak = Math.max(peak, active); - await new Promise((resolvePromise) => { - return setTimeout(resolvePromise, 1); - }); + await new Promise((resolvePromise) => { + return setTimeout(resolvePromise, 1); + }); - active--; + active--; - return ok(Number(item) % 2 === 0); - }); + return item % 2 === 0; + }, + ); assert.deepEqual( - outcomes.map((outcome) => { - return outcome.changed; - }), + outcomes, items.map((item) => { return item % 2 === 0; }), @@ -85,21 +98,35 @@ test('mapPool processes every item, preserves order, and honors the limit', asyn }); test('runPass processes every file and skips missing ones', async () => { - const seen: string[] = []; + const dir = await mkdtemp( + join( + tmpdir(), + 'fmtkit-sidecar-runpass-', + ), + ); - const outcomes = await pipeline.runPass('blank-lines', ['a.ts', 'missing.ts', 'b.ts'], 'write', async (file) => { - if (file === 'missing.ts') { - return err(new SourceFileUnreadable(file, { code: 'ENOENT' })); - } + try { + const first = join(dir, 'a.ts'); + const missing = join(dir, 'missing.ts'); + const last = join(dir, 'b.ts'); - seen.push(file); + await writeFile(first, 'const value = 1;\n'); - return ok(true); - }); + await writeFile(last, 'const value = 1;\n'); - assert.deepEqual(seen.sort(), ['a.ts', 'b.ts']); + const outcomes = await pipeline.runPass(segmentFormatter, [first, missing, last], 'write'); - assert.equal(outcomes[1]?.error?._tag === 'SourceFileUnreadable' && outcomes[1].error.isNotFound(), true); + assert.equal(outcomes[0]?.error, null); + + assert.equal(outcomes[2]?.error, null); + + assert.equal(outcomes[1]?.error?._tag === 'SourceFileUnreadable' && outcomes[1].error.isNotFound(), true); + } finally { + await rm( + dir, + { recursive: true, force: true }, + ); + } }); test('runOxfmt resolves without spawning when no binary or no files are given', async () => { diff --git a/packages/ts/sidecar/src/format-all.ts b/packages/ts/sidecar/src/format-all.ts index 9746600..4581fe8 100644 --- a/packages/ts/sidecar/src/format-all.ts +++ b/packages/ts/sidecar/src/format-all.ts @@ -3,12 +3,14 @@ import { z } from 'zod'; import { UnexpectedCliArgument } from '#sidecar/kernel/errors'; import type { OxcErrorDto } from '#sidecar/kernel/errors'; import { FileTargets } from '#sidecar/hosts/file-targets'; -import { FormatPipeline } from '#sidecar/format-pipeline'; -import type { FormatMode, PassOutcome, ValidationFailure } from '#sidecar/format-pipeline'; +import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import type { FormatMode, PassOutcome, ValidationFailure } from '#sidecar/pipeline/format-pipeline'; import { NodeProcessRunner } from '#sidecar/io/process-runner'; import { err, isErr, ok } from '#sidecar/kernel/result'; import type { Result } from '#sidecar/kernel/result'; import { NodeSourceFiles } from '#sidecar/io/source-files'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; /** Immutable command-line options for the full formatting pipeline. */ export class CliOptionsDto { @@ -219,9 +221,18 @@ export async function main(): Promise { const options = parsed.value; const formatTargets = [...new Set(options.formatFiles.filter(FileTargets.isTargetFile))]; const syntaxTargets = [...new Set(options.syntaxFiles.filter(FileTargets.isSyntaxTarget))]; - const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); + const factory = PipelineFactory.create(); + const sourceFiles = new NodeSourceFiles(); - const blankLines = await pipeline.runPass('blank-lines', formatTargets, options.mode, (file, mode) => pipeline.formatFile(file, mode)); + const pipeline = new FormatPipeline({ + editor: new SourceFileEditor({ sourceFiles }), + processRunner: new NodeProcessRunner(), + validator: factory.syntaxValidator(sourceFiles), + }); + + const segmentFormatter = factory.segmentFormatter(); + + const blankLines = await pipeline.runPass(segmentFormatter, formatTargets, options.mode); if (!FormatAllReporter.reportPass('blank-lines', formatTargets, options.mode, blankLines, 'edits')) { process.exitCode = 1; @@ -238,7 +249,7 @@ export async function main(): Promise { return; } - const fluentChains = await pipeline.runPass('fluent-chains', formatTargets, options.mode, (file, mode) => pipeline.formatFluentFile(file, mode)); + const fluentChains = await pipeline.runPass(factory.fluentFormatter(), formatTargets, options.mode); if (!FormatAllReporter.reportPass('fluent-chains', formatTargets, options.mode, fluentChains, 'edits')) { process.exitCode = 1; @@ -248,7 +259,7 @@ export async function main(): Promise { // Fluent and expanded calls create blank-line obligations the first pass // cannot see, so the second pass makes one invocation reach a fixed point. - const finalBlankLines = await pipeline.runPass('blank-lines', formatTargets, options.mode, (file, mode) => pipeline.formatFile(file, mode)); + const finalBlankLines = await pipeline.runPass(segmentFormatter, formatTargets, options.mode); if (!FormatAllReporter.reportPass('blank-lines', formatTargets, options.mode, finalBlankLines, 'edits')) { process.exitCode = 1; diff --git a/packages/ts/sidecar/src/format-pipeline.ts b/packages/ts/sidecar/src/format-pipeline.ts deleted file mode 100644 index da85e03..0000000 --- a/packages/ts/sidecar/src/format-pipeline.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { availableParallelism } from 'node:os'; -import { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; -import type { OxfmtRunFailed, SourceFileUnreadable, SourceUnparsable } from '#sidecar/kernel/errors'; -import { FileFormatter } from '#sidecar/pipeline/file-formatter'; -import { FluentChains } from '#sidecar/fluent-chains'; -import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; -import type { ProcessRunner } from '#sidecar/io/process-runner'; -import { isErr, ok } from '#sidecar/kernel/result'; -import type { Result } from '#sidecar/kernel/result'; -import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; -import type { SourceFileError, SourceFiles } from '#sidecar/io/source-files'; -import { SourceParser } from '#sidecar/syntax/source-parser'; - -const OXFMT_CHUNK_SIZE = 100; - -/** Whether a pipeline pass checks source or writes its computed changes. */ -export type FormatMode = 'check' | 'write'; - -/** The result of processing one file in a formatting pass. */ -export type PassOutcome = { - /** The formatting pass that produced the outcome. */ - readonly label: string; - - /** The requested source path. */ - readonly file: string; - - /** Whether the pass would change or did change the source. */ - readonly changed: boolean; - - /** The typed filesystem failure, or `null` when processing completed. */ - readonly error: SourceFileError | null; -}; - -/** The options needed to invoke oxfmt over one pipeline stage. */ -export type OxfmtOptions = { - /** The executable to invoke, or `null` to skip the stage. */ - readonly bin: string | null; - - /** The oxfmt configuration path, or `null` to use defaults. */ - readonly config: string | null; - - /** The source paths passed to oxfmt. */ - readonly files: string[]; - - /** Whether oxfmt checks source or writes changes. */ - readonly mode: FormatMode; -}; - -/** A source file that could not be read or parsed during validation. */ -export type ValidationFailure = { - /** The original source path reported to the user. */ - readonly file: string; - - /** The carried read or parse failure. */ - readonly error: SourceFileUnreadable | SourceUnparsable; -}; - -type ProcessOne = (file: string, mode: FormatMode) => Promise>; - -/** Coordinates formatting and validation through narrow filesystem and process ports. */ -export class FormatPipeline { - readonly #sourceFiles: SourceFiles; - readonly #processRunner: ProcessRunner; - readonly #parser: SourceParser; - readonly #splitter: EmbeddedBlockSplitter; - readonly #editor: SourceFileEditor; - readonly #fileFormatter: FileFormatter; - - static async #mapPool(items: T[], limit: number, operation: (item: T) => Promise): Promise { - const results = new Array(items.length); - - let nextIndex = 0; - - const worker = async (): Promise => { - while (true) { - const index = nextIndex++; - - if (index >= items.length) { - return; - } - - const item = items[index]; - - if (item !== undefined) { - results[index] = await operation(item); - } - } - }; - - const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker); - - await Promise.all(workers); - - return results; - } - - static #scriptPrefix(content: string, scriptStart: number): string { - return content.slice(0, scriptStart).replace(/[^\r\n]/g, ' '); - } - - /** - * @param dependencies - The filesystem and process ports used by the pipeline. - * @param dependencies.sourceFiles - Reads and atomically writes source files. - * @param dependencies.processRunner - Invokes oxfmt with inherited standard streams. - */ - constructor(dependencies: { sourceFiles: SourceFiles; processRunner: ProcessRunner }) { - this.#sourceFiles = dependencies.sourceFiles; - this.#processRunner = dependencies.processRunner; - this.#parser = new SourceParser(); - this.#splitter = new EmbeddedBlockSplitter(); - this.#editor = new SourceFileEditor({ sourceFiles: dependencies.sourceFiles }); - this.#fileFormatter = new FileFormatter({ splitter: this.#splitter, pipeline: PipelineFactory.create().segmentPipeline() }); - } - - /** - * Apply the blank-line formatting pass to one TypeScript or host file. - * - * @param path - The source file to format. - * @param mode - Whether to check or atomically write changes. - * @returns Whether the file changes, or the typed filesystem failure. - */ - async formatFile(path: string, mode: FormatMode): Promise> { - return this.#editor.apply(path, mode, (content) => { - return this.#fileFormatter.format(path, content); - }); - } - - /** - * Apply fluent-chain formatting to one TypeScript or host file. - * - * @param path - The source file to format. - * @param mode - Whether to check or atomically write changes. - * @returns Whether the file changes, or the typed filesystem failure. - */ - formatFluentFile(path: string, mode: FormatMode): Promise> { - return FluentChains.formatFile(path, mode, this.#sourceFiles); - } - - /** - * Process files concurrently while preserving outcome order. - * - * @param label - The pass label associated with the outcomes. - * @param files - The source paths to process. - * @param mode - Whether the pass checks or writes changes. - * @param processOne - The operation applied to each source path. - * @returns One effect-free reporting outcome per input path. - */ - async runPass(label: string, files: string[], mode: FormatMode, processOne: ProcessOne): Promise { - return FormatPipeline.#mapPool(files, availableParallelism(), async (file): Promise => { - const outcome = await processOne(file, mode); - - if (isErr(outcome)) { - return { label, file, changed: false, error: outcome.error }; - } - - return { label, file, changed: outcome.value, error: null }; - }); - } - - /** - * Run oxfmt sequentially over bounded file chunks. - * - * @param options - The executable, configuration, files, and format mode. - * @returns Nothing, or the first typed oxfmt failure. - */ - async runOxfmt(options: OxfmtOptions): Promise> { - if (!options.bin || options.files.length === 0) { - return ok(undefined); - } - - const args = options.config ? ['--config', options.config] : []; - - args.push(options.mode === 'check' ? '--check' : '--write', '--no-error-on-unmatched-pattern'); - - for (let i = 0; i < options.files.length; i += OXFMT_CHUNK_SIZE) { - const outcome = await this.#processRunner.run(options.bin, [...args, ...options.files.slice(i, i + OXFMT_CHUNK_SIZE)]); - - if (isErr(outcome)) { - return outcome; - } - } - - return ok(undefined); - } - - /** - * Validate TypeScript files and JavaScript-compatible embedded host blocks. - * - * @param files - The source paths to validate. - * @returns Carried read and parse failures in deterministic input order. - */ - async validate(files: string[]): Promise { - const failures = await FormatPipeline.#mapPool(files, availableParallelism(), async (file): Promise => { - const read = await this.#sourceFiles.readText(file); - - if (isErr(read)) { - return [{ file, error: read.error }]; - } - - if (!this.#splitter.isHost(file)) { - const parsed = this.#parser.parse(file, read.value); - - return isErr(parsed) ? [{ file, error: parsed.error }] : []; - } - - const hostFailures: ValidationFailure[] = []; - - for (const block of this.#splitter.extract(file, read.value)) { - const virtualContent = FormatPipeline.#scriptPrefix(read.value, block.start) + block.content; - const parsed = this.#parser.parse(`${file}.script.${block.extension}`, virtualContent); - - if (isErr(parsed) && this.#splitter.hardValidated(file)) { - hostFailures.push({ file, error: parsed.error }); - } - } - - return hostFailures; - }); - - return failures.flat(); - } -} diff --git a/packages/ts/sidecar/src/io/files.test.ts b/packages/ts/sidecar/src/io/files.test.ts index 198dc7e..715a5f5 100644 --- a/packages/ts/sidecar/src/io/files.test.ts +++ b/packages/ts/sidecar/src/io/files.test.ts @@ -4,19 +4,29 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { Files } from '#sidecar/io/files'; -import { FormatPipeline } from '#sidecar/format-pipeline'; +import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; import { NodeProcessRunner } from '#sidecar/io/process-runner'; -import { isErr } from '#sidecar/kernel/result'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; import { NodeSourceFiles } from '#sidecar/io/source-files'; -const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); +const factory = PipelineFactory.create(); +const sourceFiles = new NodeSourceFiles(); + +const pipeline = new FormatPipeline({ + editor: new SourceFileEditor({ sourceFiles }), + processRunner: new NodeProcessRunner(), + validator: factory.syntaxValidator(sourceFiles), +}); + +const segmentFormatter = factory.segmentFormatter(); async function processFile(file: string, mode: 'check' | 'write'): Promise { - const outcome = await pipeline.formatFile(file, mode); + const [outcome] = await pipeline.runPass(segmentFormatter, [file], mode); - assert.equal(isErr(outcome), false); + assert.equal(outcome?.error, null); - return isErr(outcome) ? false : outcome.value; + return outcome?.changed ?? false; } async function withTempDir(fn: (dir: string) => Promise): Promise { diff --git a/packages/ts/sidecar/src/kernel/concurrency.ts b/packages/ts/sidecar/src/kernel/concurrency.ts new file mode 100644 index 0000000..a40f324 --- /dev/null +++ b/packages/ts/sidecar/src/kernel/concurrency.ts @@ -0,0 +1,39 @@ +/** + * Apply an async operation across items with a bounded worker pool. + * + * Results are written back at each item's original index, so the returned array + * preserves input order regardless of completion order. At most `limit` workers + * run concurrently, and never more than there are items. + * + * @param items - The inputs to process. + * @param limit - The maximum number of concurrent operations. + * @param operation - The async operation applied to each item. + * @returns The operation results in input order. + */ +export async function mapPool(items: T[], limit: number, operation: (item: T) => Promise): Promise { + const results = new Array(items.length); + + let nextIndex = 0; + + const worker = async (): Promise => { + while (true) { + const index = nextIndex++; + + if (index >= items.length) { + return; + } + + const item = items[index]; + + if (item !== undefined) { + results[index] = await operation(item); + } + } + }; + + const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker); + + await Promise.all(workers); + + return results; +} diff --git a/packages/ts/sidecar/src/passes/drizzle-query-pass.ts b/packages/ts/sidecar/src/passes/drizzle-query-pass.ts new file mode 100644 index 0000000..4c07d58 --- /dev/null +++ b/packages/ts/sidecar/src/passes/drizzle-query-pass.ts @@ -0,0 +1,660 @@ +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { FileTargets } from '#sidecar/hosts/file-targets'; +import { Node } from '#sidecar/syntax/node-schema'; +import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; +import { isErr } from '#sidecar/kernel/result'; +import type { Edit, EditApplier } from '#sidecar/syntax/edits'; +import type { FormattingPass } from '#sidecar/passes/pass'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; +import type { SourceParser } from '#sidecar/syntax/source-parser'; + +type DrizzleImports = { + locals: Map; + namespaces: Set; +}; + +// Detection: identify Drizzle imports, receivers, calls, and structural arguments. + +const DRIZZLE_MODULE = 'drizzle-orm'; +const DRIZZLE_RECEIVERS = new Set(['db', 'tx']); + +const DRIZZLE_CHAIN_METHODS = new Set([ + '$count', + '$dynamic', + '$with', + 'as', + 'crossJoin', + 'delete', + 'except', + 'from', + 'fullJoin', + 'groupBy', + 'having', + 'innerJoin', + 'insert', + 'intersect', + 'leftJoin', + 'limit', + 'offset', + 'onConflictDoNothing', + 'onConflictDoUpdate', + 'orderBy', + 'prepare', + 'returning', + 'rightJoin', + 'select', + 'set', + 'union', + 'unionAll', + 'update', + 'values', + 'where', + 'with', +]); + +const DRIZZLE_FORMAT_METHODS = new Set([ + '$count', + 'as', + 'crossJoin', + 'except', + 'findFirst', + 'findMany', + 'fullJoin', + 'groupBy', + 'having', + 'innerJoin', + 'intersect', + 'leftJoin', + 'onConflictDoNothing', + 'onConflictDoUpdate', + 'orderBy', + 'returning', + 'rightJoin', + 'set', + 'union', + 'unionAll', + 'values', + 'where', +]); + +const DRIZZLE_HELPERS = new Set([ + 'and', + 'arrayContained', + 'arrayContains', + 'arrayOverlaps', + 'asc', + 'between', + 'desc', + 'eq', + 'exists', + 'gt', + 'gte', + 'ilike', + 'inArray', + 'isNotNull', + 'isNull', + 'like', + 'lt', + 'lte', + 'ne', + 'not', + 'notBetween', + 'notExists', + 'notIlike', + 'notInArray', + 'notLike', + 'or', + 'sql', +]); + +const MULTILINE_HELPERS = new Set(['and', 'or', 'not', 'exists', 'notExists']); +const SET_OPERATION_HELPERS = new Set(['except', 'intersect', 'union', 'unionAll']); +const DRIZZLE_OBJECT_KEYS = new Set(['columns', 'extras', 'limit', 'offset', 'onUpdate', 'orderBy', 'set', 'target', 'targetWhere', 'where', 'with']); + +/** + * Formats recognised Drizzle query structures without touching unrelated calls. + * + * The detection and emission internals remain static pending the TS-5 split; this + * pass injects the parser and edit reducer at its boundary and delegates the rest + * to the preserved static helpers, which still share a single `AstReader`. + */ +export class DrizzleQueryPass implements FormattingPass { + /** The pass identity used for reporting. */ + readonly name = 'drizzle-queries'; + + static readonly #ast = new AstReader(); + + readonly #parser: SourceParser; + readonly #edits: EditApplier; + + /** + * @param dependencies - The syntax services consumed by the pass. + * @param dependencies.parser - Parses source into a trustworthy tree. + * @param dependencies.edits - Reduces candidate edits to a non-overlapping set. + */ + constructor(dependencies: { parser: SourceParser; edits: EditApplier }) { + this.#parser = dependencies.parser; + this.#edits = dependencies.edits; + } + + static #localName(node: Node | undefined): string | null { + return node?.type === 'Identifier' ? (DrizzleQueryPass.#ast.nodeName(node) ?? null) : null; + } + + static #literalValue(node: Node | undefined): string | null { + if (node?.type !== 'Literal') { + return null; + } + + return DrizzleQueryPass.#ast.stringValue(node) ?? null; + } + + static #propertyName(member: Node | undefined): string | null { + if (member?.type !== 'MemberExpression' || member.computed) { + return null; + } + + return DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(member, 'property')); + } + + static #calleeName(callee: Node | undefined, imports: DrizzleImports): string | null { + if (!callee) { + return null; + } + + if (callee.type === 'Identifier') { + const name = DrizzleQueryPass.#localName(callee); + + return name ? (imports.locals.get(name) ?? null) : null; + } + + if (callee.type === 'MemberExpression' && !callee.computed) { + const object = DrizzleQueryPass.#ast.childNode(callee, 'object'); + + const property = DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(callee, 'property')); + + const objectName = DrizzleQueryPass.#localName(object); + + if (objectName && property && imports.namespaces.has(objectName)) { + return property; + } + } + + return null; + } + + static #collectDrizzleImports(program: Node): DrizzleImports { + const imports: DrizzleImports = { locals: new Map(), namespaces: new Set() }; + const body = DrizzleQueryPass.#ast.childNodes(program, 'body'); + + for (const statement of body) { + if (statement.type !== 'ImportDeclaration') { + continue; + } + + const source = DrizzleQueryPass.#literalValue(DrizzleQueryPass.#ast.childNode(statement, 'source')); + + if (!source?.startsWith(DRIZZLE_MODULE)) { + continue; + } + + for (const specifier of DrizzleQueryPass.#ast.childNodes(statement, 'specifiers')) { + if (specifier.type === 'ImportSpecifier') { + const imported = DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(specifier, 'imported')); + const local = DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(specifier, 'local')); + + if (imported && local) { + imports.locals.set(local, imported); + } + } + + if (specifier.type === 'ImportNamespaceSpecifier') { + const local = DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(specifier, 'local')); + + if (local) { + imports.namespaces.add(local); + } + } + } + } + + return imports; + } + + static #chainHasQueryMember(node: Node | undefined): boolean { + const current = DrizzleQueryPass.#ast.unwrapChainExpression(node); + + if (!current) { + return false; + } + + if (current.type === 'MemberExpression') { + if (DrizzleQueryPass.#propertyName(current) === 'query') { + return true; + } + + return DrizzleQueryPass.#chainHasQueryMember(DrizzleQueryPass.#ast.childNode(current, 'object')); + } + + if (current.type === 'CallExpression') { + return DrizzleQueryPass.#chainHasQueryMember(DrizzleQueryPass.#ast.childNode(current, 'callee')); + } + + return false; + } + + static #isDrizzleReceiver(node: Node | undefined, imports: DrizzleImports): boolean { + const current = DrizzleQueryPass.#ast.unwrapChainExpression(node); + + if (!current) { + return false; + } + + if (current.type === 'Identifier') { + const name = DrizzleQueryPass.#localName(current); + + return Boolean(name && DRIZZLE_RECEIVERS.has(name)); + } + + if (current.type === 'MemberExpression') { + const object = DrizzleQueryPass.#ast.childNode(current, 'object'); + const property = DrizzleQueryPass.#propertyName(current); + + if (property === 'query') { + return DrizzleQueryPass.#isDrizzleReceiver(object, imports); + } + + return DrizzleQueryPass.#isDrizzleReceiver(object, imports); + } + + if (current.type === 'CallExpression') { + const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(current, 'callee')); + + if (callee?.type === 'Identifier') { + const imported = DrizzleQueryPass.#calleeName(callee, imports); + + return Boolean(imported && SET_OPERATION_HELPERS.has(imported)); + } + + if (callee?.type === 'MemberExpression') { + const method = DrizzleQueryPass.#propertyName(callee); + + if (method && DRIZZLE_CHAIN_METHODS.has(method)) { + return DrizzleQueryPass.#isDrizzleReceiver(DrizzleQueryPass.#ast.childNode(callee, 'object'), imports); + } + + return DrizzleQueryPass.#isDrizzleReceiver(DrizzleQueryPass.#ast.childNode(callee, 'object'), imports); + } + } + + return false; + } + + static #methodName(call: Node): string | null { + const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); + + return callee?.type === 'MemberExpression' ? DrizzleQueryPass.#propertyName(callee) : null; + } + + static #isDrizzleMethodCall(call: Node, imports: DrizzleImports): boolean { + const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); + + if (callee?.type !== 'MemberExpression') { + return false; + } + + const name = DrizzleQueryPass.#propertyName(callee); + + if (!name || !DRIZZLE_FORMAT_METHODS.has(name)) { + return false; + } + + return DrizzleQueryPass.#isDrizzleReceiver(DrizzleQueryPass.#ast.childNode(callee, 'object'), imports); + } + + static #isRelationalQueryCall(call: Node, imports: DrizzleImports): boolean { + const name = DrizzleQueryPass.#methodName(call); + + const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); + + if ((name !== 'findMany' && name !== 'findFirst') || callee?.type !== 'MemberExpression') { + return false; + } + + const object = DrizzleQueryPass.#ast.childNode(callee, 'object'); + + return DrizzleQueryPass.#chainHasQueryMember(object) && DrizzleQueryPass.#isDrizzleReceiver(object, imports); + } + + static #isImportedHelperCall(call: Node, imports: DrizzleImports): boolean { + const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); + + const name = DrizzleQueryPass.#calleeName(callee, imports); + + return Boolean(name && DRIZZLE_HELPERS.has(name)); + } + + static #isSetOperationCall(call: Node, imports: DrizzleImports): boolean { + const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); + + const name = DrizzleQueryPass.#calleeName(callee, imports); + + return Boolean(name && SET_OPERATION_HELPERS.has(name)); + } + + static #callDisplayName(source: string, call: Node, imports: DrizzleImports): string { + const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); + + if (callee?.type === 'Identifier') { + return DrizzleQueryPass.#ast.sourceOf(source, callee); + } + + if (callee?.type === 'MemberExpression') { + const name = DrizzleQueryPass.#calleeName(callee, imports); + + if (name) { + return DrizzleQueryPass.#ast.sourceOf(source, callee); + } + } + + return callee ? DrizzleQueryPass.#ast.sourceOf(source, callee) : ''; + } + + static #callParens(source: string, call: Node): { open: number; close: number } | null { + return DrizzleQueryPass.#ast.callParens(source, call, DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee'))); + } + + static #shouldFormatObjectExpression(node: Node): boolean { + const properties = DrizzleQueryPass.#ast.childNodes(node, 'properties'); + + if (properties.length > 1) { + return true; + } + + return properties.some((property) => { + if (property.type !== 'Property') { + return true; + } + + const key = DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(property, 'key')); + + const value = DrizzleQueryPass.#ast.childNode(property, 'value'); + + if (!value) { + return false; + } + + if (key && DRIZZLE_OBJECT_KEYS.has(key) && (value.type === 'ObjectExpression' || value.type === 'ArrayExpression' || value.type === 'CallExpression')) { + return true; + } + + return value.type === 'ObjectExpression' || value.type === 'ArrayExpression'; + }); + } + + static #shouldFormatArrayExpression(node: Node): boolean { + const elements = Array.isArray(node.elements) ? node.elements : []; + + return elements.length > 1 || elements.some((element) => element instanceof Node && (element.type === 'ObjectExpression' || element.type === 'CallExpression')); + } + + static #isComplexArgument(node: Node, imports: DrizzleImports): boolean { + if (node.type === 'ObjectExpression') { + return DrizzleQueryPass.#shouldFormatObjectExpression(node); + } + + if (node.type === 'ArrayExpression') { + return DrizzleQueryPass.#shouldFormatArrayExpression(node); + } + + if (node.type === 'CallExpression') { + return DrizzleQueryPass.#isImportedHelperCall(node, imports) || DrizzleQueryPass.#isSetOperationCall(node, imports) || DrizzleQueryPass.#isDrizzleMethodCall(node, imports); + } + + return false; + } + + static #isStructuralArgument(node: Node, imports: DrizzleImports): boolean { + if (node.type === 'ObjectExpression' || node.type === 'ArrayExpression') { + return true; + } + + return node.type === 'CallExpression' && (DrizzleQueryPass.#isSetOperationCall(node, imports) || DrizzleQueryPass.#isDrizzleMethodCall(node, imports)); + } + + static #shouldFormatMethodArguments(call: Node, imports: DrizzleImports): boolean { + const args = DrizzleQueryPass.#ast.childNodes(call, 'arguments'); + + if (args.length === 0) { + return false; + } + + if (DrizzleQueryPass.#isRelationalQueryCall(call, imports)) { + return args.some((arg) => arg.type === 'ObjectExpression' && DrizzleQueryPass.#shouldFormatObjectExpression(arg)); + } + + const name = DrizzleQueryPass.#methodName(call); + + if (!name) { + return false; + } + + if (['where', 'having', '$count'].includes(name)) { + return args.some((arg) => DrizzleQueryPass.#isComplexArgument(arg, imports)); + } + + if (['leftJoin', 'rightJoin', 'innerJoin', 'fullJoin', 'crossJoin'].includes(name)) { + return args.length > 1 && args.some((arg, index) => index > 0 && DrizzleQueryPass.#isComplexArgument(arg, imports)); + } + + if (['onConflictDoNothing', 'onConflictDoUpdate', 'returning', 'set', 'values'].includes(name)) { + return args.some((arg) => DrizzleQueryPass.#isComplexArgument(arg, imports)); + } + + if (['as', 'except', 'groupBy', 'intersect', 'orderBy', 'union', 'unionAll'].includes(name)) { + return args.length > 1 || args.some((arg) => DrizzleQueryPass.#isStructuralArgument(arg, imports)); + } + + return args.length > 1 && args.some((arg) => DrizzleQueryPass.#isComplexArgument(arg, imports)); + } + + // Emission: render recognised structures and produce non-overlapping edits. + + static #formatArrayExpression(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(DrizzleQueryPass.#ast.getStart(node), DrizzleQueryPass.#ast.getEnd(node))) { + return DrizzleQueryPass.#ast.sourceOf(source, node); + } + + const elements = Array.isArray(node.elements) ? node.elements : []; + + if (elements.length === 0) { + return '[]'; + } + + const nextIndent = `${indent}${indentUnit}`; + + const formatted = elements.map((element) => { + return element instanceof Node ? DrizzleQueryPass.#formatNode(source, element, imports, parsed, nextIndent, indentUnit) : ''; + }); + + return `[\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}]`; + } + + static #formatObjectExpression(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(DrizzleQueryPass.#ast.getStart(node), DrizzleQueryPass.#ast.getEnd(node))) { + return DrizzleQueryPass.#ast.sourceOf(source, node); + } + + const properties = DrizzleQueryPass.#ast.childNodes(node, 'properties'); + + if (properties.length === 0) { + return '{}'; + } + + const nextIndent = `${indent}${indentUnit}`; + + const formatted = properties.map((property) => { + if (property.type !== 'Property') { + return DrizzleQueryPass.#ast.sourceOf(source, property); + } + + const key = DrizzleQueryPass.#ast.childNode(property, 'key'); + const value = DrizzleQueryPass.#ast.childNode(property, 'value'); + + if (!key || !value || property.computed || property.method) { + return DrizzleQueryPass.#ast.sourceOf(source, property); + } + + if (property.shorthand) { + return DrizzleQueryPass.#ast.sourceOf(source, property); + } + + return `${DrizzleQueryPass.#ast.sourceOf(source, key)}: ${DrizzleQueryPass.#formatNode(source, value, imports, parsed, nextIndent, indentUnit)}`; + }); + + return `{\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}}`; + } + + static #formatHelperCall(source: string, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(DrizzleQueryPass.#ast.getStart(call), DrizzleQueryPass.#ast.getEnd(call))) { + return DrizzleQueryPass.#ast.sourceOf(source, call); + } + + const importedName = DrizzleQueryPass.#calleeName(DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')), imports); + + const args = DrizzleQueryPass.#ast.childNodes(call, 'arguments'); + + if (!importedName || !MULTILINE_HELPERS.has(importedName) || args.length === 0) { + return DrizzleQueryPass.#ast.sourceOf(source, call); + } + + const nextIndent = `${indent}${indentUnit}`; + const formatted = args.map((arg) => DrizzleQueryPass.#formatNode(source, arg, imports, parsed, nextIndent, indentUnit)); + + return `${DrizzleQueryPass.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; + } + + static #formatSetOperationCall(source: string, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(DrizzleQueryPass.#ast.getStart(call), DrizzleQueryPass.#ast.getEnd(call))) { + return DrizzleQueryPass.#ast.sourceOf(source, call); + } + + const args = DrizzleQueryPass.#ast.childNodes(call, 'arguments'); + + if (args.length < 2) { + return DrizzleQueryPass.#ast.sourceOf(source, call); + } + + const nextIndent = `${indent}${indentUnit}`; + const formatted = args.map((arg) => DrizzleQueryPass.#formatNode(source, arg, imports, parsed, nextIndent, indentUnit)); + + return `${DrizzleQueryPass.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; + } + + static #formatNode(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (node.type === 'ObjectExpression' && DrizzleQueryPass.#shouldFormatObjectExpression(node)) { + return DrizzleQueryPass.#formatObjectExpression(source, node, imports, parsed, indent, indentUnit); + } + + if (node.type === 'ArrayExpression' && DrizzleQueryPass.#shouldFormatArrayExpression(node)) { + return DrizzleQueryPass.#formatArrayExpression(source, node, imports, parsed, indent, indentUnit); + } + + if (node.type === 'CallExpression') { + if (DrizzleQueryPass.#isSetOperationCall(node, imports)) { + return DrizzleQueryPass.#formatSetOperationCall(source, node, imports, parsed, indent, indentUnit); + } + + if (DrizzleQueryPass.#isImportedHelperCall(node, imports)) { + return DrizzleQueryPass.#formatHelperCall(source, node, imports, parsed, indent, indentUnit); + } + } + + return DrizzleQueryPass.#ast.sourceOf(source, node); + } + + static #formatCallArguments(document: SourceDocument, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indentUnit: string): Edit | null { + const parens = DrizzleQueryPass.#callParens(document.text, call); + const args = DrizzleQueryPass.#ast.childNodes(call, 'arguments'); + + if (!parens || args.length === 0) { + return null; + } + + if (parsed.hasCommentBetween(parens.open, parens.close)) { + return null; + } + + const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); + + const property = callee ? DrizzleQueryPass.#ast.childNode(callee, 'property') : undefined; + const indentPos = callee?.type === 'MemberExpression' && property ? DrizzleQueryPass.#ast.getStart(property) : DrizzleQueryPass.#ast.getStart(call); + const indent = document.lineIndent(indentPos); + const argIndent = `${indent}${indentUnit}`; + const formatted = args.map((arg) => DrizzleQueryPass.#formatNode(document.text, arg, imports, parsed, argIndent, indentUnit)); + const replacement = `(\n${argIndent}${formatted.join(`,\n${argIndent}`)},\n${indent})`; + + if (document.slice(parens.open, parens.close + 1) === replacement) { + return null; + } + + return { + start: parens.open, + end: parens.close + 1, + replacement, + }; + } + + /** + * Compute edits for recognised Drizzle query structures. + * + * @param document - The document to inspect. + * @returns Non-overlapping query-formatting edits, or none for invalid source. + */ + computeEdits(document: SourceDocument): Edit[] { + if (FileTargets.isDeclarationFile(document.virtualName)) { + return []; + } + + const parsed = this.#parser.parse(document.virtualName, document.text); + + if (isErr(parsed)) { + return []; + } + + const imports = DrizzleQueryPass.#collectDrizzleImports(parsed.value.program); + + if (imports.locals.size === 0 && imports.namespaces.size === 0) { + return []; + } + + const edits: Edit[] = []; + const indentUnit = document.indentUnit(); + + DrizzleQueryPass.#ast.visit(parsed.value.program, (node) => { + if (node.type !== 'CallExpression') { + return; + } + + if (DrizzleQueryPass.#isDrizzleMethodCall(node, imports) || DrizzleQueryPass.#isRelationalQueryCall(node, imports) || DrizzleQueryPass.#isSetOperationCall(node, imports)) { + const args = DrizzleQueryPass.#ast.childNodes(node, 'arguments'); + + if (DrizzleQueryPass.#isSetOperationCall(node, imports) && args.length > 0 && args.length < 2) { + return; + } + + if (!DrizzleQueryPass.#isSetOperationCall(node, imports) && !DrizzleQueryPass.#shouldFormatMethodArguments(node, imports)) { + return; + } + + const edit = DrizzleQueryPass.#formatCallArguments(document, node, imports, parsed.value, indentUnit); + + if (edit) { + edits.push(edit); + } + } + }); + + return this.#edits.nonOverlapping(edits); + } +} diff --git a/packages/ts/sidecar/src/expanded-calls.test.ts b/packages/ts/sidecar/src/passes/expanded-call-pass.test.ts similarity index 79% rename from packages/ts/sidecar/src/expanded-calls.test.ts rename to packages/ts/sidecar/src/passes/expanded-call-pass.test.ts index 47201ee..9dd75ea 100644 --- a/packages/ts/sidecar/src/expanded-calls.test.ts +++ b/packages/ts/sidecar/src/passes/expanded-call-pass.test.ts @@ -1,13 +1,26 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { ExpandedCalls } from '#sidecar/expanded-calls'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { EditApplier } from '#sidecar/syntax/edits'; +import { ExpandedCallPass } from '#sidecar/passes/expanded-call-pass'; +import { SourceDocument } from '#sidecar/syntax/source-document'; +import { SourceParser } from '#sidecar/syntax/source-parser'; + +const editApplier = new EditApplier(); +const pass = new ExpandedCallPass({ parser: new SourceParser(), ast: new AstReader(), edits: editApplier }); + +function format(input: string, virtualName: string): string { + const edits = pass.computeEdits(SourceDocument.of(virtualName, input)); + + return edits.length > 0 ? editApplier.apply(input, edits) : input; +} describe('expanded call formatter', () => { it('expands a returned cast wrapper around a nested call argument', () => { const input = ['export function createAuth() {', '\treturn betterAuth(buildAuthConfig(env, db, mailer, app, options)) as unknown as SasuAuth;', '}', ''].join('\n'); const expected = ['export function createAuth() {', '\treturn betterAuth(', '\t\tbuildAuthConfig(env, db, mailer, app, options),', '\t) as unknown as SasuAuth;', '}', ''].join('\n'); - assert.equal(ExpandedCalls.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('expands a baseline-indented call one unit past its baseline', () => { @@ -16,7 +29,7 @@ describe('expanded call formatter', () => { // not 6 and 3. const input = ['\t\t\tconst value = resolveConfig(prefix, buildOptions(env), { strict: true });', ''].join('\n'); const expected = ['\t\t\tconst value = resolveConfig(', '\t\t\t\tprefix,', '\t\t\t\tbuildOptions(env),', '\t\t\t\t{ strict: true },', '\t\t\t);', ''].join('\n'); - const output = ExpandedCalls.format(input, 'fixture.ts'); + const output = format(input, 'fixture.ts'); assert.equal(output, expected); @@ -27,14 +40,14 @@ describe('expanded call formatter', () => { const input = ['const value = resolveConfig(prefix, buildOptions(env), { strict: true });', ''].join('\n'); const expected = ['const value = resolveConfig(', '\tprefix,', '\tbuildOptions(env),', '\t{ strict: true },', ');', ''].join('\n'); - assert.equal(ExpandedCalls.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('formats nested complex calls in one stable run', () => { const input = ['function run() {', '\treturn outer(inner(deep(a, b)), tail);', '}', ''].join('\n'); const expected = ['function run() {', '\treturn outer(', '\t\tinner(', '\t\t\tdeep(a, b),', '\t\t),', '\t\ttail,', '\t);', '}', ''].join('\n'); - const once = ExpandedCalls.format(input, 'fixture.ts'); - const twice = ExpandedCalls.format(once, 'fixture.ts'); + const once = format(input, 'fixture.ts'); + const twice = format(once, 'fixture.ts'); assert.equal(once, expected); assert.equal(twice, expected); @@ -44,53 +57,53 @@ describe('expanded call formatter', () => { const input = ['const config = createConfig({ hooks: [init(), done] }, [first(), second]);', ''].join('\n'); const expected = ['const config = createConfig(', '\t{ hooks: [init(), done] },', '\t[first(), second],', ');', ''].join('\n'); - assert.equal(ExpandedCalls.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('skips calls with comments inside the argument list', () => { const input = ['const value = createConfig(/* keep inline */ buildOptions(env));', ''].join('\n'); - assert.equal(ExpandedCalls.format(input, 'fixture.ts'), input); + assert.equal(format(input, 'fixture.ts'), input); }); it('does not add a trailing comma after a final spread argument', () => { const input = ['const result = invoke(buildOptions(env), ...args);', ''].join('\n'); const expected = ['const result = invoke(', '\tbuildOptions(env),', '\t...args', ');', ''].join('\n'); - assert.equal(ExpandedCalls.format(input, 'fixture.ts'), expected); + assert.equal(format(input, 'fixture.ts'), expected); }); it('leaves simple transform chains and computed callbacks unchanged', () => { const input = ['const normalized = value.trim().toLowerCase();', 'const item = computed(() => value);', ''].join('\n'); - assert.equal(ExpandedCalls.format(input, 'fixture.ts'), input); + assert.equal(format(input, 'fixture.ts'), input); }); it('leaves method-chain arguments for fluent formatters', () => { const input = ['const rows = builder.where(and(eq(users.id, id), eq(users.active, true)));', ''].join('\n'); - assert.equal(ExpandedCalls.format(input, 'fixture.ts'), input); + assert.equal(format(input, 'fixture.ts'), input); }); it('skips declaration files', () => { const input = ['declare const value: ReturnType;', ''].join('\n'); - assert.equal(ExpandedCalls.format(input, 'types.d.ts'), input); + assert.equal(format(input, 'types.d.ts'), input); }); it('nests with four spaces when the source is space-indented', () => { const input = ['function run() {', ' return outer(inner(deep(a, b)), tail);', '}', ''].join('\n'); const expected = ['function run() {', ' return outer(', ' inner(', ' deep(a, b),', ' ),', ' tail,', ' );', '}', ''].join('\n'); - const once = ExpandedCalls.format(input, 'fixture.ts'); + const once = format(input, 'fixture.ts'); assert.equal(once, expected); - assert.equal(ExpandedCalls.format(once, 'fixture.ts'), expected); + assert.equal(format(once, 'fixture.ts'), expected); assert.ok(!once.includes('\t'), 'expanded output must not introduce tabs into a space-indented file'); }); it('nests a space-indented top-level call one level deep with spaces', () => { const input = ['export default defineConfig({', ' cacheDir: "../../storage/.cache",', ' test: { globals: true },', '});', ''].join('\n'); - const output = ExpandedCalls.format(input, 'fixture.ts'); + const output = format(input, 'fixture.ts'); assert.ok(!output.includes('\t'), 'space-indented expansion must not introduce tabs'); assert.match(output, /defineConfig\(\n {4}\{/); @@ -102,13 +115,13 @@ describe('expanded call formatter', () => { // it — oxfmt used to hide this by collapsing the call straight back. const input = ['function send() {', '\tconst response = fetch(url, {', '\t\t...init,', '\t\theaders,', '\t});', '}', ''].join('\n'); const expected = ['function send() {', '\tconst response = fetch(', '\t\turl,', '\t\t{', '\t\t\t...init,', '\t\t\theaders,', '\t\t},', '\t);', '}', ''].join('\n'); - const once = ExpandedCalls.format(input, 'fixture.ts'); + const once = format(input, 'fixture.ts'); assert.equal(once, expected); // The rebase reads its origin off the argument's own line, so an argument // already sitting at its target depth is left where it is. - assert.equal(ExpandedCalls.format(once, 'fixture.ts'), expected); + assert.equal(format(once, 'fixture.ts'), expected); }); it('never re-indents the interior of a multiline template literal', () => { @@ -120,20 +133,20 @@ describe('expanded call formatter', () => { '\n', ); - const once = ExpandedCalls.format(input, 'fixture.ts'); + const once = format(input, 'fixture.ts'); assert.equal(once, expected); - assert.equal(ExpandedCalls.format(once, 'fixture.ts'), expected); + assert.equal(format(once, 'fixture.ts'), expected); }); it('preserves whitespace-only lines inside a template literal', () => { const input = ['const query = run(build(), {', '\tsql: `', '\t\tselect 1', ' ', '\t\tfrom t', '\t`,', '});', ''].join('\n'); - const output = ExpandedCalls.format(input, 'fixture.ts'); + const output = format(input, 'fixture.ts'); assert.ok(output.includes('\n \n'), 'a blank line carrying string content must keep its bytes'); - assert.equal(ExpandedCalls.format(output, 'fixture.ts'), output); + assert.equal(format(output, 'fixture.ts'), output); }); /** The source between the first and last backtick — a template's literal bytes. */ @@ -143,11 +156,11 @@ describe('expanded call formatter', () => { it('reaches a fixed point for a tab-indented template literal', () => { const input = ['const Harness = defineComponent({', '\ttemplate: `', '\t\t
', '\t\t\thello', '\t\t
', '\t`,', '});', ''].join('\n'); - const once = ExpandedCalls.format(input, 'fixture.ts'); + const once = format(input, 'fixture.ts'); assert.notEqual(once, input, 'the call should have expanded'); - assert.equal(ExpandedCalls.format(once, 'fixture.ts'), once); + assert.equal(format(once, 'fixture.ts'), once); assert.equal(templateBody(once), templateBody(input), 'template interior bytes must be preserved'); }); @@ -166,22 +179,22 @@ describe('expanded call formatter', () => { '', ].join('\n'); - const once = ExpandedCalls.format(input, 'fixture.ts'); + const once = format(input, 'fixture.ts'); assert.notEqual(once, input, 'the call should have expanded'); - assert.equal(ExpandedCalls.format(once, 'fixture.ts'), once); + assert.equal(format(once, 'fixture.ts'), once); assert.equal(templateBody(once), templateBody(input), 'template interior bytes must be preserved'); }); it('reaches a fixed point for a tagged template literal', () => { const input = ['const styled = create({', ' styles: css`', ' color: red;', ` margin: \${spacing}px;`, ' `,', '});', ''].join('\n'); - const once = ExpandedCalls.format(input, 'fixture.ts'); + const once = format(input, 'fixture.ts'); assert.notEqual(once, input, 'the call should have expanded'); - assert.equal(ExpandedCalls.format(once, 'fixture.ts'), once); + assert.equal(format(once, 'fixture.ts'), once); assert.equal(templateBody(once), templateBody(input), 'template interior bytes must be preserved'); }); @@ -195,7 +208,7 @@ describe('expanded call formatter', () => { let current = input; for (let i = 0; i < 5; i++) { - const next = ExpandedCalls.format(current, 'fixture.ts'); + const next = format(current, 'fixture.ts'); if (next === current) { break; @@ -207,7 +220,7 @@ describe('expanded call formatter', () => { assert.notEqual(current, input, 'both calls should have expanded'); assert.match(current, /root: defineComponent\(\n/, 'the inner call must also expand'); - assert.equal(ExpandedCalls.format(current, 'fixture.ts'), current, 'the fixed point must be stable'); + assert.equal(format(current, 'fixture.ts'), current, 'the fixed point must be stable'); assert.equal(templateBody(current), templateBody(input), 'template interior bytes must be preserved'); }); diff --git a/packages/ts/sidecar/src/passes/expanded-call-pass.ts b/packages/ts/sidecar/src/passes/expanded-call-pass.ts new file mode 100644 index 0000000..2af5679 --- /dev/null +++ b/packages/ts/sidecar/src/passes/expanded-call-pass.ts @@ -0,0 +1,299 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import type { CallParens } from '#sidecar/syntax/ast-reader'; +import type { EditApplier } from '#sidecar/syntax/edits'; +import { FileTargets } from '#sidecar/hosts/file-targets'; +import { Node } from '#sidecar/syntax/node-schema'; +import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; +import { isErr } from '#sidecar/kernel/result'; +import type { SourceParser } from '#sidecar/syntax/source-parser'; +import type { Edit } from '#sidecar/syntax/edits'; +import type { FormattingPass } from '#sidecar/passes/pass'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; +import { TemplateSpans } from '#sidecar/syntax/template-spans'; + +const FUNCTION_TYPES = new Set(['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression']); + +/** Expands structurally complex call arguments into stable multiline layouts. */ +export class ExpandedCallPass implements FormattingPass { + /** The pass identity used for reporting. */ + readonly name = 'expanded-calls'; + + readonly #parser: SourceParser; + readonly #ast: AstReader; + readonly #edits: EditApplier; + + /** + * @param dependencies - The syntax services consumed by the pass. + * @param dependencies.parser - Parses source into a trustworthy tree. + * @param dependencies.ast - Traverses and reads validated node fields. + * @param dependencies.edits - Reduces candidate edits to a non-overlapping set. + */ + constructor(dependencies: { parser: SourceParser; ast: AstReader; edits: EditApplier }) { + this.#parser = dependencies.parser; + this.#ast = dependencies.ast; + this.#edits = dependencies.edits; + } + + /** + * Compute edits for calls whose arguments require a multiline layout. + * + * @param document - The document to inspect. + * @returns Non-overlapping expanded-call edits, or none for invalid source. + */ + computeEdits(document: SourceDocument): Edit[] { + if (FileTargets.isDeclarationFile(document.virtualName)) { + return []; + } + + const parsed = this.#parser.parse(document.virtualName, document.text); + + if (isErr(parsed)) { + return []; + } + + const parents = new WeakMap(); + const edits: Edit[] = []; + const indentUnit = document.indentUnit(); + const spans = TemplateSpans.collect(parsed.value.program); + + this.#collectParents(parsed.value.program, parents); + + this.#ast.visit(parsed.value.program, (node) => { + if (node.type !== 'CallExpression') { + return; + } + + if (!this.#shouldExpandCall(node)) { + return; + } + + if (this.#isNestedInsideUnexpandedCallArgument(node, parents)) { + return; + } + + const parens = this.#calleeParens(document, node); + + if (!parens || parsed.value.hasCommentBetween(parens.open, parens.close)) { + return; + } + + const indent = document.lineIndent(this.#ast.getStart(node)); + + const replacement = this.#formatCallParens(document, node, parsed.value, indent, indentUnit, spans); + const current = document.slice(parens.open, parens.close + 1); + + if (replacement === null || replacement === current) { + return; + } + + edits.push({ + start: parens.open, + end: parens.close + 1, + replacement, + }); + }); + + return this.#edits.nonOverlapping(edits); + } + + #unwrapExpression(node: Node | undefined): Node | undefined { + let current = node; + + while ( + current && + (current.type === 'ChainExpression' || + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSNonNullExpression' || + current.type === 'TSTypeAssertion') + ) { + current = this.#ast.childNode(current, 'expression'); + } + + return current; + } + + #calleeParens(document: SourceDocument, call: Node): CallParens | null { + return this.#ast.callParens(document.text, call, this.#unwrapExpression(this.#ast.childNode(call, 'callee'))); + } + + #callArguments(call: Node): Node[] { + return this.#ast.childNodes(call, 'arguments'); + } + + #isMethodCall(call: Node): boolean { + const callee = this.#unwrapExpression(this.#ast.childNode(call, 'callee')); + + return callee?.type === 'MemberExpression'; + } + + #isComplexArgument(node: Node): boolean { + const current = this.#unwrapExpression(node); + + return current?.type === 'CallExpression' || current?.type === 'ObjectExpression' || current?.type === 'ArrayExpression'; + } + + #shouldExpandCall(call: Node): boolean { + const args = this.#callArguments(call); + + return !this.#isMethodCall(call) && args.length > 0 && args.some((argument) => this.#isComplexArgument(argument)); + } + + #collectParents(node: Node, parents: WeakMap): void { + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const child of value) { + if (child instanceof Node) { + parents.set(child, node); + this.#collectParents(child, parents); + } + } + } else if (value instanceof Node) { + parents.set(value, node); + this.#collectParents(value, parents); + } + } + } + + #isInsideCallArgument(node: Node, call: Node): boolean { + const start = this.#ast.getStart(node); + const end = this.#ast.getEnd(node); + const args = this.#callArguments(call); + + return args.some((arg) => { + return this.#ast.getStart(arg) <= start && end <= this.#ast.getEnd(arg); + }); + } + + #nearestCallAncestor(node: Node, parents: WeakMap): Node | null { + let current = parents.get(node); + + while (current) { + if (FUNCTION_TYPES.has(current.type)) { + return null; + } + + if (current.type === 'CallExpression') { + return current; + } + + current = parents.get(current); + } + + return null; + } + + #isNestedInsideUnexpandedCallArgument(node: Node, parents: WeakMap): boolean { + const ancestor = this.#nearestCallAncestor(node, parents); + + if (!ancestor || !this.#isInsideCallArgument(node, ancestor)) { + return false; + } + + return !this.#shouldExpandCall(ancestor); + } + + #canUseTrailingComma(arg: Node | undefined): boolean { + return arg?.type !== 'SpreadElement'; + } + + #rebaseLine(line: string, lineStart: number, from: string, to: string, spans: TemplateSpans): string { + // A template literal's leading whitespace is string content, not + // indentation: moving it would rewrite the value, and since oxfmt hugs the + // expanded call back onto one line before the next run re-expands it, every + // run would shift the literal one level further right. + if (spans.contains(lineStart)) { + return line; + } + + if (line.trim() === '') { + return ''; + } + + return line.startsWith(from) ? `${to}${line.slice(from.length)}` : line; + } + + /** + * Re-indent lifted source so its continuation lines match where it now sits. + * + * A node's text is copied out of the call site verbatim, so its second and + * later lines are still indented relative to the line the node was written on. + * Expanding the call moves the node one or more levels deeper (`to`), and + * without rebasing those lines they keep the shallower depth and the block + * reads inside-out. Reading the origin off the node's own line, rather than off + * the call being expanded, is what makes a second run a no-op: text already + * sitting at its target depth is left alone. Only the first line is skipped + * outright — the caller places it. + */ + #rebaseIndent(document: SourceDocument, node: Node, to: string, spans: TemplateSpans): string { + const start = this.#ast.getStart(node); + const text = this.#ast.sourceOf(document.text, node); + const from = document.lineIndent(start); + + if (from === to || !text.includes('\n')) { + return text; + } + + const rebased: string[] = []; + + let lineStart = start; + + for (const [index, line] of text.split('\n').entries()) { + rebased.push(index === 0 ? line : this.#rebaseLine(line, lineStart, from, to, spans)); + lineStart += line.length + 1; + } + + return rebased.join('\n'); + } + + #formatCallParens(document: SourceDocument, call: Node, parsed: ParsedSourceDto, indent: string, indentUnit: string, spans: TemplateSpans): string | null { + const parens = this.#calleeParens(document, call); + const args = this.#callArguments(call); + + if (!parens || args.length === 0 || parsed.hasCommentBetween(parens.open, parens.close)) { + return null; + } + + if (!this.#shouldExpandCall(call)) { + return null; + } + + const argIndent = `${indent}${indentUnit}`; + + const formattedArgs = args.map((arg) => { + return this.#formatNode(document, arg, parsed, argIndent, indentUnit, spans); + }); + + const separator = `,\n${argIndent}`; + const trailingComma = this.#canUseTrailingComma(args.at(-1)) ? ',' : ''; + + return `(\n${argIndent}${formattedArgs.join(separator)}${trailingComma}\n${indent})`; + } + + #formatCall(document: SourceDocument, call: Node, parsed: ParsedSourceDto, indent: string, indentUnit: string, spans: TemplateSpans): string { + const parens = this.#calleeParens(document, call); + const formattedParens = this.#formatCallParens(document, call, parsed, indent, indentUnit, spans); + + if (!parens || formattedParens === null) { + return this.#rebaseIndent(document, call, indent, spans); + } + + return `${document.slice(this.#ast.getStart(call), parens.open)}${formattedParens}`; + } + + // indent is where node will sit once expanded; the depth its text came from is + // read back off the node's own line, because nothing has moved in the source + // yet however deep the recursion goes. + #formatNode(document: SourceDocument, node: Node, parsed: ParsedSourceDto, indent: string, indentUnit: string, spans: TemplateSpans): string { + if (node.type !== 'CallExpression') { + return this.#rebaseIndent(document, node, indent, spans); + } + + if (!this.#shouldExpandCall(node)) { + return this.#rebaseIndent(document, node, indent, spans); + } + + return this.#formatCall(document, node, parsed, indent, indentUnit, spans); + } +} diff --git a/packages/ts/sidecar/src/passes/fluent-chain-pass.ts b/packages/ts/sidecar/src/passes/fluent-chain-pass.ts new file mode 100644 index 0000000..3dff4cf --- /dev/null +++ b/packages/ts/sidecar/src/passes/fluent-chain-pass.ts @@ -0,0 +1,172 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import { isErr } from '#sidecar/kernel/result'; +import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; +import type { SourceParser } from '#sidecar/syntax/source-parser'; +import type { Edit } from '#sidecar/syntax/edits'; +import type { FormattingPass } from '#sidecar/passes/pass'; +import type { Node } from '#sidecar/syntax/node-schema'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; + +type ChainLink = { + start: number; + end: number; + operator: '.' | '?.'; +}; + +type FluentChain = { + base: Node; + links: ChainLink[]; +}; + +/** Splits fluent-call chains so each link starts on its own line. */ +export class FluentChainPass implements FormattingPass { + /** The pass identity used for reporting. */ + readonly name = 'fluent-chains'; + + readonly #parser: SourceParser; + readonly #ast: AstReader; + + /** + * @param dependencies - The syntax services consumed by the pass. + * @param dependencies.parser - Parses source into a trustworthy tree. + * @param dependencies.ast - Traverses and reads validated node fields. + */ + constructor(dependencies: { parser: SourceParser; ast: AstReader }) { + this.#parser = dependencies.parser; + this.#ast = dependencies.ast; + } + + /** + * Compute edits that split fluent-chain links across lines. + * + * @param document - The document to inspect. + * @returns Fluent-chain edits, or none for invalid source. + */ + computeEdits(document: SourceDocument): Edit[] { + const parsed = this.#parser.parse(document.virtualName, document.text); + + if (isErr(parsed)) { + return []; + } + + const edits = new Map(); + const indentStep = document.indentUnit(); + + this.#ast.visit(parsed.value.program, (node) => { + if (node.type !== 'CallExpression') { + return; + } + + const chain = this.#collectFluentChain(document, node, parsed.value); + + if (!chain) { + return; + } + + const baseStart = this.#ast.getStart(chain.base); + + if (baseStart < 0) { + return; + } + + const indent = `${document.lineIndent(baseStart)}${indentStep}`; + + for (const link of chain.links) { + const replacement = `\n${indent}${link.operator}`; + + if (document.slice(link.start, link.end) === replacement) { + continue; + } + + edits.set(`${link.start}:${link.end}`, { + start: link.start, + end: link.end, + replacement, + }); + } + }); + + return [...edits.values()].sort((a, b) => { + return a.start - b.start; + }); + } + + #memberCallLink(document: SourceDocument, member: Node, object: Node, parsed: ParsedSourceDto): ChainLink | null { + if (member.computed) { + return null; + } + + const property = this.#ast.childNode(member, 'property'); + + if (!property || (property.type !== 'Identifier' && property.type !== 'PrivateIdentifier')) { + return null; + } + + const objectEnd = this.#ast.getEnd(object); + const propertyStart = this.#ast.getStart(property); + + if (objectEnd < 0 || propertyStart < 0 || propertyStart <= objectEnd) { + return null; + } + + if (parsed.hasCommentBetween(objectEnd, propertyStart)) { + return null; + } + + const separator = document.slice(objectEnd, propertyStart); + + if (separator.includes('//') || separator.includes('/*')) { + return null; + } + + const operator = separator.replace(/[ \t\r\n]/g, ''); + + if (operator !== '.' && operator !== '?.') { + return null; + } + + return { + start: objectEnd, + end: propertyStart, + operator, + }; + } + + #collectFluentChain(document: SourceDocument, outer: Node, parsed: ParsedSourceDto): FluentChain | null { + let call: Node = outer; + + const links: ChainLink[] = []; + + while (call.type === 'CallExpression') { + const callee = this.#ast.unwrapChainExpression(this.#ast.childNode(call, 'callee')); + + if (callee?.type !== 'MemberExpression') { + break; + } + + const object = this.#ast.unwrapChainExpression(this.#ast.childNode(callee, 'object')); + + if (object?.type !== 'CallExpression') { + break; + } + + const link = this.#memberCallLink(document, callee, object, parsed); + + if (!link) { + return null; + } + + links.push(link); + call = object; + } + + if (links.length < 2) { + return null; + } + + return { + base: call, + links, + }; + } +} diff --git a/packages/ts/sidecar/src/pipeline/format-pipeline.ts b/packages/ts/sidecar/src/pipeline/format-pipeline.ts new file mode 100644 index 0000000..1e1124b --- /dev/null +++ b/packages/ts/sidecar/src/pipeline/format-pipeline.ts @@ -0,0 +1,128 @@ +import { availableParallelism } from 'node:os'; +import type { OxfmtRunFailed } from '#sidecar/kernel/errors'; +import type { FileFormatter } from '#sidecar/pipeline/file-formatter'; +import { mapPool } from '#sidecar/kernel/concurrency'; +import type { ProcessRunner } from '#sidecar/io/process-runner'; +import { isErr, ok } from '#sidecar/kernel/result'; +import type { Result } from '#sidecar/kernel/result'; +import type { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; +import type { SourceFileError } from '#sidecar/io/source-files'; +import type { SyntaxValidator, ValidationFailure } from '#sidecar/pipeline/syntax-validator'; + +const OXFMT_CHUNK_SIZE = 100; + +/** Whether a pipeline pass checks source or writes its computed changes. */ +export type FormatMode = 'check' | 'write'; + +export type { ValidationFailure } from '#sidecar/pipeline/syntax-validator'; + +/** The result of processing one file in a formatting pass. */ +export type PassOutcome = { + /** The formatting pass that produced the outcome. */ + readonly label: string; + + /** The requested source path. */ + readonly file: string; + + /** Whether the pass would change or did change the source. */ + readonly changed: boolean; + + /** The typed filesystem failure, or `null` when processing completed. */ + readonly error: SourceFileError | null; +}; + +/** The options needed to invoke oxfmt over one pipeline stage. */ +export type OxfmtOptions = { + /** The executable to invoke, or `null` to skip the stage. */ + readonly bin: string | null; + + /** The oxfmt configuration path, or `null` to use defaults. */ + readonly config: string | null; + + /** The source paths passed to oxfmt. */ + readonly files: string[]; + + /** Whether oxfmt checks source or writes changes. */ + readonly mode: FormatMode; +}; + +/** Coordinates formatting and validation through narrow filesystem and process ports. */ +export class FormatPipeline { + readonly #editor: SourceFileEditor; + readonly #processRunner: ProcessRunner; + readonly #validator: SyntaxValidator; + + /** + * @param dependencies - The editor, process port, and validator used by the pipeline. + * @param dependencies.editor - Reads, transforms, and atomically writes single files. + * @param dependencies.processRunner - Invokes oxfmt with inherited standard streams. + * @param dependencies.validator - Validates formatted source and host blocks. + */ + constructor(dependencies: { editor: SourceFileEditor; processRunner: ProcessRunner; validator: SyntaxValidator }) { + this.#editor = dependencies.editor; + this.#processRunner = dependencies.processRunner; + this.#validator = dependencies.validator; + } + + /** + * Run a file formatter over every path concurrently, preserving outcome order. + * + * @param formatter - The file formatter whose pipeline and label drive the pass. + * @param files - The source paths to process. + * @param mode - Whether the pass checks or writes changes. + * @returns One effect-free reporting outcome per input path. + */ + async runPass(formatter: FileFormatter, files: string[], mode: FormatMode): Promise { + return mapPool( + files, + availableParallelism(), + async (file): Promise => { + const outcome = await this.#editor.apply(file, mode, (content) => { + return formatter.format(file, content); + }); + + if (isErr(outcome)) { + return { label: formatter.label, file, changed: false, error: outcome.error }; + } + + return { label: formatter.label, file, changed: outcome.value, error: null }; + }, + ); + } + + /** + * Run oxfmt sequentially over bounded file chunks. + * + * @param options - The executable, configuration, files, and format mode. + * @returns Nothing, or the first typed oxfmt failure. + */ + async runOxfmt(options: OxfmtOptions): Promise> { + if (!options.bin || options.files.length === 0) { + return ok(undefined); + } + + const args = options.config ? ['--config', options.config] : []; + + args.push(options.mode === 'check' ? '--check' : '--write', '--no-error-on-unmatched-pattern'); + + for (let i = 0; i < options.files.length; i += OXFMT_CHUNK_SIZE) { + const outcome = await this.#processRunner.run(options.bin, [...args, ...options.files.slice(i, i + OXFMT_CHUNK_SIZE)]); + + if (isErr(outcome)) { + return outcome; + } + } + + return ok(undefined); + } + + /** + * Validate TypeScript files and JavaScript-compatible embedded host blocks. + * + * @param files - The source paths to validate. + * @returns Carried read and parse failures in deterministic input order. + */ + async validate(files: string[]): Promise { + return this.#validator.validate(files); + } +} diff --git a/packages/ts/sidecar/src/pipeline/pipeline-factory.ts b/packages/ts/sidecar/src/pipeline/pipeline-factory.ts index e63a7e6..517d5ad 100644 --- a/packages/ts/sidecar/src/pipeline/pipeline-factory.ts +++ b/packages/ts/sidecar/src/pipeline/pipeline-factory.ts @@ -4,37 +4,55 @@ import { BodyWrapPass } from '#sidecar/passes/body-wrap-pass'; import { ClassMemberPolicy } from '#sidecar/passes/policies/class-member-policy'; import { ClassReorderPass } from '#sidecar/passes/class-reorder-pass'; import { DeclarationReorderPass } from '#sidecar/passes/declaration-reorder-pass'; +import { DrizzleQueryPass } from '#sidecar/passes/drizzle-query-pass'; import { EditApplier } from '#sidecar/syntax/edits'; +import { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; +import { ExpandedCallPass } from '#sidecar/passes/expanded-call-pass'; +import { FileFormatter } from '#sidecar/pipeline/file-formatter'; +import { FluentChainPass } from '#sidecar/passes/fluent-chain-pass'; import { IterationBudget, PassPipeline, PipelineStep } from '#sidecar/pipeline/pass-pipeline'; +import type { SourceFiles } from '#sidecar/io/source-files'; import { SourceParser } from '#sidecar/syntax/source-parser'; import { StatementSpacingPolicy } from '#sidecar/passes/policies/statement-spacing-policy'; +import { SyntaxValidator } from '#sidecar/pipeline/syntax-validator'; import { VueReactivityIdioms } from '#sidecar/passes/policies/vue-reactivity-idioms'; /** The maximum body-wrap iterations before the segment step settles. */ const BODY_WRAP_ITERATIONS = 5; -/** Composes formatting passes into the named pipelines the formatter runs. */ +/** Composes formatting passes into the named pipelines and formatters the formatter runs. */ export class PipelineFactory { + readonly #parser: SourceParser; + readonly #splitter: EmbeddedBlockSplitter; readonly #edits: EditApplier; readonly #bodyWrap: BodyWrapPass; readonly #classReorder: ClassReorderPass; readonly #declarationReorder: DeclarationReorderPass; readonly #blankLine: BlankLinePass; + readonly #fluentChain: FluentChainPass; + readonly #drizzleQuery: DrizzleQueryPass; + readonly #expandedCall: ExpandedCallPass; /** * @param dependencies - The services and policies composed into passes. * @param dependencies.parser - Parses source into a trustworthy tree. * @param dependencies.ast - Traverses and reads validated node fields. * @param dependencies.edits - Splices computed edits into source text. + * @param dependencies.splitter - Extracts and rewrites host embedded blocks. * @param dependencies.members - Classifies class members for reordering. * @param dependencies.spacing - Decides statement blank-line obligations. */ - constructor(dependencies: { parser: SourceParser; ast: AstReader; edits: EditApplier; members: ClassMemberPolicy; spacing: StatementSpacingPolicy }) { + constructor(dependencies: { parser: SourceParser; ast: AstReader; edits: EditApplier; splitter: EmbeddedBlockSplitter; members: ClassMemberPolicy; spacing: StatementSpacingPolicy }) { + this.#parser = dependencies.parser; + this.#splitter = dependencies.splitter; this.#edits = dependencies.edits; this.#bodyWrap = new BodyWrapPass({ parser: dependencies.parser, ast: dependencies.ast }); this.#classReorder = new ClassReorderPass({ parser: dependencies.parser, ast: dependencies.ast, members: dependencies.members }); this.#declarationReorder = new DeclarationReorderPass({ parser: dependencies.parser, ast: dependencies.ast }); this.#blankLine = new BlankLinePass({ parser: dependencies.parser, ast: dependencies.ast, spacing: dependencies.spacing }); + this.#fluentChain = new FluentChainPass({ parser: dependencies.parser, ast: dependencies.ast }); + this.#drizzleQuery = new DrizzleQueryPass({ parser: dependencies.parser, edits: dependencies.edits }); + this.#expandedCall = new ExpandedCallPass({ parser: dependencies.parser, ast: dependencies.ast, edits: dependencies.edits }); } /** @@ -51,6 +69,7 @@ export class PipelineFactory { parser: new SourceParser(), ast, edits: new EditApplier(), + splitter: new EmbeddedBlockSplitter(), members, spacing: new StatementSpacingPolicy({ ast, members, vue }), }); @@ -75,6 +94,45 @@ export class PipelineFactory { ); } - // TS-4 adds fluentPipeline() here, composing the fluent-chain, Drizzle-query, - // and expanded-call passes once those convert to the FormattingPass contract. + /** + * Build the fluent pipeline: fluent-chain splitting, then Drizzle-query and + * expanded-call formatting over the split source. + * + * @returns The fluent pipeline labelled `fluent-chains`. + */ + fluentPipeline(): PassPipeline { + return new PassPipeline( + 'fluent-chains', + [new PipelineStep(this.#fluentChain, IterationBudget.once()), new PipelineStep(this.#drizzleQuery, IterationBudget.once()), new PipelineStep(this.#expandedCall, IterationBudget.once())], + this.#edits, + ); + } + + /** + * Build a file formatter for the source-segment pipeline. + * + * @returns A formatter that applies the segment pipeline, host blocks included. + */ + segmentFormatter(): FileFormatter { + return new FileFormatter({ splitter: this.#splitter, pipeline: this.segmentPipeline() }); + } + + /** + * Build a file formatter for the fluent pipeline. + * + * @returns A formatter that applies the fluent pipeline, host blocks included. + */ + fluentFormatter(): FileFormatter { + return new FileFormatter({ splitter: this.#splitter, pipeline: this.fluentPipeline() }); + } + + /** + * Build a syntax validator over the factory's splitter and parser. + * + * @param sourceFiles - The filesystem port the validator reads through. + * @returns A validator for TypeScript files and host embedded blocks. + */ + syntaxValidator(sourceFiles: SourceFiles): SyntaxValidator { + return new SyntaxValidator({ sourceFiles, splitter: this.#splitter, parser: this.#parser }); + } } diff --git a/packages/ts/sidecar/src/pipeline/syntax-validator.ts b/packages/ts/sidecar/src/pipeline/syntax-validator.ts new file mode 100644 index 0000000..6b92fff --- /dev/null +++ b/packages/ts/sidecar/src/pipeline/syntax-validator.ts @@ -0,0 +1,80 @@ +import { availableParallelism } from 'node:os'; +import type { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; +import type { SourceFileUnreadable, SourceUnparsable } from '#sidecar/kernel/errors'; +import { isErr } from '#sidecar/kernel/result'; +import { mapPool } from '#sidecar/kernel/concurrency'; +import type { SourceFiles } from '#sidecar/io/source-files'; +import type { SourceParser } from '#sidecar/syntax/source-parser'; + +/** A source file that could not be read or parsed during validation. */ +export type ValidationFailure = { + /** The original source path reported to the user. */ + readonly file: string; + + /** The carried read or parse failure. */ + readonly error: SourceFileUnreadable | SourceUnparsable; +}; + +/** Validates TypeScript files and the JavaScript-compatible blocks of host documents. */ +export class SyntaxValidator { + readonly #sourceFiles: SourceFiles; + readonly #splitter: EmbeddedBlockSplitter; + readonly #parser: SourceParser; + + static #scriptPrefix(content: string, scriptStart: number): string { + return content.slice(0, scriptStart).replace(/[^\r\n]/g, ' '); + } + + /** + * @param dependencies - The filesystem port and syntax services used to validate. + * @param dependencies.sourceFiles - Reads source files for parsing. + * @param dependencies.splitter - Extracts host embedded blocks. + * @param dependencies.parser - Parses source and reports syntax failures. + */ + constructor(dependencies: { sourceFiles: SourceFiles; splitter: EmbeddedBlockSplitter; parser: SourceParser }) { + this.#sourceFiles = dependencies.sourceFiles; + this.#splitter = dependencies.splitter; + this.#parser = dependencies.parser; + } + + /** + * Validate TypeScript files and JavaScript-compatible embedded host blocks. + * + * @param files - The source paths to validate. + * @returns Carried read and parse failures in deterministic input order. + */ + async validate(files: string[]): Promise { + const failures = await mapPool( + files, + availableParallelism(), + async (file): Promise => { + const read = await this.#sourceFiles.readText(file); + + if (isErr(read)) { + return [{ file, error: read.error }]; + } + + if (!this.#splitter.isHost(file)) { + const parsed = this.#parser.parse(file, read.value); + + return isErr(parsed) ? [{ file, error: parsed.error }] : []; + } + + const hostFailures: ValidationFailure[] = []; + + for (const block of this.#splitter.extract(file, read.value)) { + const virtualContent = SyntaxValidator.#scriptPrefix(read.value, block.start) + block.content; + const parsed = this.#parser.parse(`${file}.script.${block.extension}`, virtualContent); + + if (isErr(parsed) && this.#splitter.hardValidated(file)) { + hostFailures.push({ file, error: parsed.error }); + } + } + + return hostFailures; + }, + ); + + return failures.flat(); + } +} diff --git a/packages/ts/sidecar/src/validate-syntax.ts b/packages/ts/sidecar/src/validate-syntax.ts index 0d84456..c79aa86 100644 --- a/packages/ts/sidecar/src/validate-syntax.ts +++ b/packages/ts/sidecar/src/validate-syntax.ts @@ -1,9 +1,11 @@ import { pathToFileURL } from 'node:url'; import { z } from 'zod'; import type { OxcErrorDto } from '#sidecar/kernel/errors'; -import { FormatPipeline } from '#sidecar/format-pipeline'; +import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; import { NodeProcessRunner } from '#sidecar/io/process-runner'; import { NodeSourceFiles } from '#sidecar/io/source-files'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; /** Immutable command-line options for standalone syntax validation. */ export class SyntaxCliDto { @@ -66,8 +68,14 @@ async function main(): Promise { const cwd = process.cwd(); const options = SyntaxCliDto.parse(process.argv.slice(2)); const files = [...options.files]; + const factory = PipelineFactory.create(); + const sourceFiles = new NodeSourceFiles(); - const pipeline = new FormatPipeline({ sourceFiles: new NodeSourceFiles(), processRunner: new NodeProcessRunner() }); + const pipeline = new FormatPipeline({ + editor: new SourceFileEditor({ sourceFiles }), + processRunner: new NodeProcessRunner(), + validator: factory.syntaxValidator(sourceFiles), + }); const failures = await pipeline.validate(files); From 4f6a1db683baf52c78765ea2ef1ea0153375b41f Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 24 Jul 2026 11:26:07 +0800 Subject: [PATCH 06/22] =?UTF-8?q?refactor(ts):=20TS-5=20=E2=80=94=20split?= =?UTF-8?q?=20the=20drizzle=20monolith=20into=20collaborators=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ts): extract Drizzle vocabulary, scanner, classifier, and writer Split the DrizzleQueryPass monolith's detection and emission internals into intent-bearing collaborators under src/passes/drizzle/: - DrizzleVocabulary owns every recognised name set behind predicates, built by the sanctioned DrizzleVocabulary.standard() factory. - DrizzleImportScanner collects Drizzle imports into a frozen DrizzleImports value object (localImport/hasNamespace/isEmpty). - DrizzleCallClassifier holds the is/should predicates over {ast, vocabulary}. - DrizzleArgumentWriter holds the format/emit helpers over {ast, vocabulary, classifier}. Logic is moved verbatim, rebinding the former static #ast reads and module-level Sets onto injected instances. Adds direct unit tests for the vocabulary and import scanner seams. * refactor(ts): reduce DrizzleQueryPass to orchestration over collaborators Move the pass to src/passes/drizzle/ and shrink it to pure orchestration: it parses, scans imports via DrizzleImportScanner, walks calls the DrizzleCallClassifier approves, and asks DrizzleArgumentWriter for each edit. The leftover static #ast field is gone; ast now flows in through the constructor. PipelineFactory builds the vocabulary/scanner/classifier/writer graph and injects it. The golden test moves alongside the pass with its assertions unchanged, its plumbing adapted to the collaborator graph. --- .../sidecar/src/passes/drizzle-query-pass.ts | 660 ------------------ .../passes/drizzle/drizzle-argument-writer.ts | 212 ++++++ .../passes/drizzle/drizzle-call-classifier.ts | 334 +++++++++ .../drizzle/drizzle-import-scanner.test.ts | 73 ++ .../passes/drizzle/drizzle-import-scanner.ts | 140 ++++ .../drizzle/drizzle-query-pass.test.ts} | 20 +- .../src/passes/drizzle/drizzle-query-pass.ts | 101 +++ .../passes/drizzle/drizzle-vocabulary.test.ts | 55 ++ .../src/passes/drizzle/drizzle-vocabulary.ts | 218 ++++++ .../sidecar/src/pipeline/pipeline-factory.ts | 20 +- 10 files changed, 1169 insertions(+), 664 deletions(-) delete mode 100644 packages/ts/sidecar/src/passes/drizzle-query-pass.ts create mode 100644 packages/ts/sidecar/src/passes/drizzle/drizzle-argument-writer.ts create mode 100644 packages/ts/sidecar/src/passes/drizzle/drizzle-call-classifier.ts create mode 100644 packages/ts/sidecar/src/passes/drizzle/drizzle-import-scanner.test.ts create mode 100644 packages/ts/sidecar/src/passes/drizzle/drizzle-import-scanner.ts rename packages/ts/sidecar/src/{drizzle-queries.test.ts => passes/drizzle/drizzle-query-pass.test.ts} (90%) create mode 100644 packages/ts/sidecar/src/passes/drizzle/drizzle-query-pass.ts create mode 100644 packages/ts/sidecar/src/passes/drizzle/drizzle-vocabulary.test.ts create mode 100644 packages/ts/sidecar/src/passes/drizzle/drizzle-vocabulary.ts diff --git a/packages/ts/sidecar/src/passes/drizzle-query-pass.ts b/packages/ts/sidecar/src/passes/drizzle-query-pass.ts deleted file mode 100644 index 4c07d58..0000000 --- a/packages/ts/sidecar/src/passes/drizzle-query-pass.ts +++ /dev/null @@ -1,660 +0,0 @@ -import { AstReader } from '#sidecar/syntax/ast-reader'; -import { FileTargets } from '#sidecar/hosts/file-targets'; -import { Node } from '#sidecar/syntax/node-schema'; -import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; -import { isErr } from '#sidecar/kernel/result'; -import type { Edit, EditApplier } from '#sidecar/syntax/edits'; -import type { FormattingPass } from '#sidecar/passes/pass'; -import type { SourceDocument } from '#sidecar/syntax/source-document'; -import type { SourceParser } from '#sidecar/syntax/source-parser'; - -type DrizzleImports = { - locals: Map; - namespaces: Set; -}; - -// Detection: identify Drizzle imports, receivers, calls, and structural arguments. - -const DRIZZLE_MODULE = 'drizzle-orm'; -const DRIZZLE_RECEIVERS = new Set(['db', 'tx']); - -const DRIZZLE_CHAIN_METHODS = new Set([ - '$count', - '$dynamic', - '$with', - 'as', - 'crossJoin', - 'delete', - 'except', - 'from', - 'fullJoin', - 'groupBy', - 'having', - 'innerJoin', - 'insert', - 'intersect', - 'leftJoin', - 'limit', - 'offset', - 'onConflictDoNothing', - 'onConflictDoUpdate', - 'orderBy', - 'prepare', - 'returning', - 'rightJoin', - 'select', - 'set', - 'union', - 'unionAll', - 'update', - 'values', - 'where', - 'with', -]); - -const DRIZZLE_FORMAT_METHODS = new Set([ - '$count', - 'as', - 'crossJoin', - 'except', - 'findFirst', - 'findMany', - 'fullJoin', - 'groupBy', - 'having', - 'innerJoin', - 'intersect', - 'leftJoin', - 'onConflictDoNothing', - 'onConflictDoUpdate', - 'orderBy', - 'returning', - 'rightJoin', - 'set', - 'union', - 'unionAll', - 'values', - 'where', -]); - -const DRIZZLE_HELPERS = new Set([ - 'and', - 'arrayContained', - 'arrayContains', - 'arrayOverlaps', - 'asc', - 'between', - 'desc', - 'eq', - 'exists', - 'gt', - 'gte', - 'ilike', - 'inArray', - 'isNotNull', - 'isNull', - 'like', - 'lt', - 'lte', - 'ne', - 'not', - 'notBetween', - 'notExists', - 'notIlike', - 'notInArray', - 'notLike', - 'or', - 'sql', -]); - -const MULTILINE_HELPERS = new Set(['and', 'or', 'not', 'exists', 'notExists']); -const SET_OPERATION_HELPERS = new Set(['except', 'intersect', 'union', 'unionAll']); -const DRIZZLE_OBJECT_KEYS = new Set(['columns', 'extras', 'limit', 'offset', 'onUpdate', 'orderBy', 'set', 'target', 'targetWhere', 'where', 'with']); - -/** - * Formats recognised Drizzle query structures without touching unrelated calls. - * - * The detection and emission internals remain static pending the TS-5 split; this - * pass injects the parser and edit reducer at its boundary and delegates the rest - * to the preserved static helpers, which still share a single `AstReader`. - */ -export class DrizzleQueryPass implements FormattingPass { - /** The pass identity used for reporting. */ - readonly name = 'drizzle-queries'; - - static readonly #ast = new AstReader(); - - readonly #parser: SourceParser; - readonly #edits: EditApplier; - - /** - * @param dependencies - The syntax services consumed by the pass. - * @param dependencies.parser - Parses source into a trustworthy tree. - * @param dependencies.edits - Reduces candidate edits to a non-overlapping set. - */ - constructor(dependencies: { parser: SourceParser; edits: EditApplier }) { - this.#parser = dependencies.parser; - this.#edits = dependencies.edits; - } - - static #localName(node: Node | undefined): string | null { - return node?.type === 'Identifier' ? (DrizzleQueryPass.#ast.nodeName(node) ?? null) : null; - } - - static #literalValue(node: Node | undefined): string | null { - if (node?.type !== 'Literal') { - return null; - } - - return DrizzleQueryPass.#ast.stringValue(node) ?? null; - } - - static #propertyName(member: Node | undefined): string | null { - if (member?.type !== 'MemberExpression' || member.computed) { - return null; - } - - return DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(member, 'property')); - } - - static #calleeName(callee: Node | undefined, imports: DrizzleImports): string | null { - if (!callee) { - return null; - } - - if (callee.type === 'Identifier') { - const name = DrizzleQueryPass.#localName(callee); - - return name ? (imports.locals.get(name) ?? null) : null; - } - - if (callee.type === 'MemberExpression' && !callee.computed) { - const object = DrizzleQueryPass.#ast.childNode(callee, 'object'); - - const property = DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(callee, 'property')); - - const objectName = DrizzleQueryPass.#localName(object); - - if (objectName && property && imports.namespaces.has(objectName)) { - return property; - } - } - - return null; - } - - static #collectDrizzleImports(program: Node): DrizzleImports { - const imports: DrizzleImports = { locals: new Map(), namespaces: new Set() }; - const body = DrizzleQueryPass.#ast.childNodes(program, 'body'); - - for (const statement of body) { - if (statement.type !== 'ImportDeclaration') { - continue; - } - - const source = DrizzleQueryPass.#literalValue(DrizzleQueryPass.#ast.childNode(statement, 'source')); - - if (!source?.startsWith(DRIZZLE_MODULE)) { - continue; - } - - for (const specifier of DrizzleQueryPass.#ast.childNodes(statement, 'specifiers')) { - if (specifier.type === 'ImportSpecifier') { - const imported = DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(specifier, 'imported')); - const local = DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(specifier, 'local')); - - if (imported && local) { - imports.locals.set(local, imported); - } - } - - if (specifier.type === 'ImportNamespaceSpecifier') { - const local = DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(specifier, 'local')); - - if (local) { - imports.namespaces.add(local); - } - } - } - } - - return imports; - } - - static #chainHasQueryMember(node: Node | undefined): boolean { - const current = DrizzleQueryPass.#ast.unwrapChainExpression(node); - - if (!current) { - return false; - } - - if (current.type === 'MemberExpression') { - if (DrizzleQueryPass.#propertyName(current) === 'query') { - return true; - } - - return DrizzleQueryPass.#chainHasQueryMember(DrizzleQueryPass.#ast.childNode(current, 'object')); - } - - if (current.type === 'CallExpression') { - return DrizzleQueryPass.#chainHasQueryMember(DrizzleQueryPass.#ast.childNode(current, 'callee')); - } - - return false; - } - - static #isDrizzleReceiver(node: Node | undefined, imports: DrizzleImports): boolean { - const current = DrizzleQueryPass.#ast.unwrapChainExpression(node); - - if (!current) { - return false; - } - - if (current.type === 'Identifier') { - const name = DrizzleQueryPass.#localName(current); - - return Boolean(name && DRIZZLE_RECEIVERS.has(name)); - } - - if (current.type === 'MemberExpression') { - const object = DrizzleQueryPass.#ast.childNode(current, 'object'); - const property = DrizzleQueryPass.#propertyName(current); - - if (property === 'query') { - return DrizzleQueryPass.#isDrizzleReceiver(object, imports); - } - - return DrizzleQueryPass.#isDrizzleReceiver(object, imports); - } - - if (current.type === 'CallExpression') { - const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(current, 'callee')); - - if (callee?.type === 'Identifier') { - const imported = DrizzleQueryPass.#calleeName(callee, imports); - - return Boolean(imported && SET_OPERATION_HELPERS.has(imported)); - } - - if (callee?.type === 'MemberExpression') { - const method = DrizzleQueryPass.#propertyName(callee); - - if (method && DRIZZLE_CHAIN_METHODS.has(method)) { - return DrizzleQueryPass.#isDrizzleReceiver(DrizzleQueryPass.#ast.childNode(callee, 'object'), imports); - } - - return DrizzleQueryPass.#isDrizzleReceiver(DrizzleQueryPass.#ast.childNode(callee, 'object'), imports); - } - } - - return false; - } - - static #methodName(call: Node): string | null { - const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); - - return callee?.type === 'MemberExpression' ? DrizzleQueryPass.#propertyName(callee) : null; - } - - static #isDrizzleMethodCall(call: Node, imports: DrizzleImports): boolean { - const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); - - if (callee?.type !== 'MemberExpression') { - return false; - } - - const name = DrizzleQueryPass.#propertyName(callee); - - if (!name || !DRIZZLE_FORMAT_METHODS.has(name)) { - return false; - } - - return DrizzleQueryPass.#isDrizzleReceiver(DrizzleQueryPass.#ast.childNode(callee, 'object'), imports); - } - - static #isRelationalQueryCall(call: Node, imports: DrizzleImports): boolean { - const name = DrizzleQueryPass.#methodName(call); - - const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); - - if ((name !== 'findMany' && name !== 'findFirst') || callee?.type !== 'MemberExpression') { - return false; - } - - const object = DrizzleQueryPass.#ast.childNode(callee, 'object'); - - return DrizzleQueryPass.#chainHasQueryMember(object) && DrizzleQueryPass.#isDrizzleReceiver(object, imports); - } - - static #isImportedHelperCall(call: Node, imports: DrizzleImports): boolean { - const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); - - const name = DrizzleQueryPass.#calleeName(callee, imports); - - return Boolean(name && DRIZZLE_HELPERS.has(name)); - } - - static #isSetOperationCall(call: Node, imports: DrizzleImports): boolean { - const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); - - const name = DrizzleQueryPass.#calleeName(callee, imports); - - return Boolean(name && SET_OPERATION_HELPERS.has(name)); - } - - static #callDisplayName(source: string, call: Node, imports: DrizzleImports): string { - const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); - - if (callee?.type === 'Identifier') { - return DrizzleQueryPass.#ast.sourceOf(source, callee); - } - - if (callee?.type === 'MemberExpression') { - const name = DrizzleQueryPass.#calleeName(callee, imports); - - if (name) { - return DrizzleQueryPass.#ast.sourceOf(source, callee); - } - } - - return callee ? DrizzleQueryPass.#ast.sourceOf(source, callee) : ''; - } - - static #callParens(source: string, call: Node): { open: number; close: number } | null { - return DrizzleQueryPass.#ast.callParens(source, call, DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee'))); - } - - static #shouldFormatObjectExpression(node: Node): boolean { - const properties = DrizzleQueryPass.#ast.childNodes(node, 'properties'); - - if (properties.length > 1) { - return true; - } - - return properties.some((property) => { - if (property.type !== 'Property') { - return true; - } - - const key = DrizzleQueryPass.#localName(DrizzleQueryPass.#ast.childNode(property, 'key')); - - const value = DrizzleQueryPass.#ast.childNode(property, 'value'); - - if (!value) { - return false; - } - - if (key && DRIZZLE_OBJECT_KEYS.has(key) && (value.type === 'ObjectExpression' || value.type === 'ArrayExpression' || value.type === 'CallExpression')) { - return true; - } - - return value.type === 'ObjectExpression' || value.type === 'ArrayExpression'; - }); - } - - static #shouldFormatArrayExpression(node: Node): boolean { - const elements = Array.isArray(node.elements) ? node.elements : []; - - return elements.length > 1 || elements.some((element) => element instanceof Node && (element.type === 'ObjectExpression' || element.type === 'CallExpression')); - } - - static #isComplexArgument(node: Node, imports: DrizzleImports): boolean { - if (node.type === 'ObjectExpression') { - return DrizzleQueryPass.#shouldFormatObjectExpression(node); - } - - if (node.type === 'ArrayExpression') { - return DrizzleQueryPass.#shouldFormatArrayExpression(node); - } - - if (node.type === 'CallExpression') { - return DrizzleQueryPass.#isImportedHelperCall(node, imports) || DrizzleQueryPass.#isSetOperationCall(node, imports) || DrizzleQueryPass.#isDrizzleMethodCall(node, imports); - } - - return false; - } - - static #isStructuralArgument(node: Node, imports: DrizzleImports): boolean { - if (node.type === 'ObjectExpression' || node.type === 'ArrayExpression') { - return true; - } - - return node.type === 'CallExpression' && (DrizzleQueryPass.#isSetOperationCall(node, imports) || DrizzleQueryPass.#isDrizzleMethodCall(node, imports)); - } - - static #shouldFormatMethodArguments(call: Node, imports: DrizzleImports): boolean { - const args = DrizzleQueryPass.#ast.childNodes(call, 'arguments'); - - if (args.length === 0) { - return false; - } - - if (DrizzleQueryPass.#isRelationalQueryCall(call, imports)) { - return args.some((arg) => arg.type === 'ObjectExpression' && DrizzleQueryPass.#shouldFormatObjectExpression(arg)); - } - - const name = DrizzleQueryPass.#methodName(call); - - if (!name) { - return false; - } - - if (['where', 'having', '$count'].includes(name)) { - return args.some((arg) => DrizzleQueryPass.#isComplexArgument(arg, imports)); - } - - if (['leftJoin', 'rightJoin', 'innerJoin', 'fullJoin', 'crossJoin'].includes(name)) { - return args.length > 1 && args.some((arg, index) => index > 0 && DrizzleQueryPass.#isComplexArgument(arg, imports)); - } - - if (['onConflictDoNothing', 'onConflictDoUpdate', 'returning', 'set', 'values'].includes(name)) { - return args.some((arg) => DrizzleQueryPass.#isComplexArgument(arg, imports)); - } - - if (['as', 'except', 'groupBy', 'intersect', 'orderBy', 'union', 'unionAll'].includes(name)) { - return args.length > 1 || args.some((arg) => DrizzleQueryPass.#isStructuralArgument(arg, imports)); - } - - return args.length > 1 && args.some((arg) => DrizzleQueryPass.#isComplexArgument(arg, imports)); - } - - // Emission: render recognised structures and produce non-overlapping edits. - - static #formatArrayExpression(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { - if (parsed.hasCommentBetween(DrizzleQueryPass.#ast.getStart(node), DrizzleQueryPass.#ast.getEnd(node))) { - return DrizzleQueryPass.#ast.sourceOf(source, node); - } - - const elements = Array.isArray(node.elements) ? node.elements : []; - - if (elements.length === 0) { - return '[]'; - } - - const nextIndent = `${indent}${indentUnit}`; - - const formatted = elements.map((element) => { - return element instanceof Node ? DrizzleQueryPass.#formatNode(source, element, imports, parsed, nextIndent, indentUnit) : ''; - }); - - return `[\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}]`; - } - - static #formatObjectExpression(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { - if (parsed.hasCommentBetween(DrizzleQueryPass.#ast.getStart(node), DrizzleQueryPass.#ast.getEnd(node))) { - return DrizzleQueryPass.#ast.sourceOf(source, node); - } - - const properties = DrizzleQueryPass.#ast.childNodes(node, 'properties'); - - if (properties.length === 0) { - return '{}'; - } - - const nextIndent = `${indent}${indentUnit}`; - - const formatted = properties.map((property) => { - if (property.type !== 'Property') { - return DrizzleQueryPass.#ast.sourceOf(source, property); - } - - const key = DrizzleQueryPass.#ast.childNode(property, 'key'); - const value = DrizzleQueryPass.#ast.childNode(property, 'value'); - - if (!key || !value || property.computed || property.method) { - return DrizzleQueryPass.#ast.sourceOf(source, property); - } - - if (property.shorthand) { - return DrizzleQueryPass.#ast.sourceOf(source, property); - } - - return `${DrizzleQueryPass.#ast.sourceOf(source, key)}: ${DrizzleQueryPass.#formatNode(source, value, imports, parsed, nextIndent, indentUnit)}`; - }); - - return `{\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}}`; - } - - static #formatHelperCall(source: string, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { - if (parsed.hasCommentBetween(DrizzleQueryPass.#ast.getStart(call), DrizzleQueryPass.#ast.getEnd(call))) { - return DrizzleQueryPass.#ast.sourceOf(source, call); - } - - const importedName = DrizzleQueryPass.#calleeName(DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')), imports); - - const args = DrizzleQueryPass.#ast.childNodes(call, 'arguments'); - - if (!importedName || !MULTILINE_HELPERS.has(importedName) || args.length === 0) { - return DrizzleQueryPass.#ast.sourceOf(source, call); - } - - const nextIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueryPass.#formatNode(source, arg, imports, parsed, nextIndent, indentUnit)); - - return `${DrizzleQueryPass.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; - } - - static #formatSetOperationCall(source: string, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { - if (parsed.hasCommentBetween(DrizzleQueryPass.#ast.getStart(call), DrizzleQueryPass.#ast.getEnd(call))) { - return DrizzleQueryPass.#ast.sourceOf(source, call); - } - - const args = DrizzleQueryPass.#ast.childNodes(call, 'arguments'); - - if (args.length < 2) { - return DrizzleQueryPass.#ast.sourceOf(source, call); - } - - const nextIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueryPass.#formatNode(source, arg, imports, parsed, nextIndent, indentUnit)); - - return `${DrizzleQueryPass.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; - } - - static #formatNode(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { - if (node.type === 'ObjectExpression' && DrizzleQueryPass.#shouldFormatObjectExpression(node)) { - return DrizzleQueryPass.#formatObjectExpression(source, node, imports, parsed, indent, indentUnit); - } - - if (node.type === 'ArrayExpression' && DrizzleQueryPass.#shouldFormatArrayExpression(node)) { - return DrizzleQueryPass.#formatArrayExpression(source, node, imports, parsed, indent, indentUnit); - } - - if (node.type === 'CallExpression') { - if (DrizzleQueryPass.#isSetOperationCall(node, imports)) { - return DrizzleQueryPass.#formatSetOperationCall(source, node, imports, parsed, indent, indentUnit); - } - - if (DrizzleQueryPass.#isImportedHelperCall(node, imports)) { - return DrizzleQueryPass.#formatHelperCall(source, node, imports, parsed, indent, indentUnit); - } - } - - return DrizzleQueryPass.#ast.sourceOf(source, node); - } - - static #formatCallArguments(document: SourceDocument, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indentUnit: string): Edit | null { - const parens = DrizzleQueryPass.#callParens(document.text, call); - const args = DrizzleQueryPass.#ast.childNodes(call, 'arguments'); - - if (!parens || args.length === 0) { - return null; - } - - if (parsed.hasCommentBetween(parens.open, parens.close)) { - return null; - } - - const callee = DrizzleQueryPass.#ast.unwrapChainExpression(DrizzleQueryPass.#ast.childNode(call, 'callee')); - - const property = callee ? DrizzleQueryPass.#ast.childNode(callee, 'property') : undefined; - const indentPos = callee?.type === 'MemberExpression' && property ? DrizzleQueryPass.#ast.getStart(property) : DrizzleQueryPass.#ast.getStart(call); - const indent = document.lineIndent(indentPos); - const argIndent = `${indent}${indentUnit}`; - const formatted = args.map((arg) => DrizzleQueryPass.#formatNode(document.text, arg, imports, parsed, argIndent, indentUnit)); - const replacement = `(\n${argIndent}${formatted.join(`,\n${argIndent}`)},\n${indent})`; - - if (document.slice(parens.open, parens.close + 1) === replacement) { - return null; - } - - return { - start: parens.open, - end: parens.close + 1, - replacement, - }; - } - - /** - * Compute edits for recognised Drizzle query structures. - * - * @param document - The document to inspect. - * @returns Non-overlapping query-formatting edits, or none for invalid source. - */ - computeEdits(document: SourceDocument): Edit[] { - if (FileTargets.isDeclarationFile(document.virtualName)) { - return []; - } - - const parsed = this.#parser.parse(document.virtualName, document.text); - - if (isErr(parsed)) { - return []; - } - - const imports = DrizzleQueryPass.#collectDrizzleImports(parsed.value.program); - - if (imports.locals.size === 0 && imports.namespaces.size === 0) { - return []; - } - - const edits: Edit[] = []; - const indentUnit = document.indentUnit(); - - DrizzleQueryPass.#ast.visit(parsed.value.program, (node) => { - if (node.type !== 'CallExpression') { - return; - } - - if (DrizzleQueryPass.#isDrizzleMethodCall(node, imports) || DrizzleQueryPass.#isRelationalQueryCall(node, imports) || DrizzleQueryPass.#isSetOperationCall(node, imports)) { - const args = DrizzleQueryPass.#ast.childNodes(node, 'arguments'); - - if (DrizzleQueryPass.#isSetOperationCall(node, imports) && args.length > 0 && args.length < 2) { - return; - } - - if (!DrizzleQueryPass.#isSetOperationCall(node, imports) && !DrizzleQueryPass.#shouldFormatMethodArguments(node, imports)) { - return; - } - - const edit = DrizzleQueryPass.#formatCallArguments(document, node, imports, parsed.value, indentUnit); - - if (edit) { - edits.push(edit); - } - } - }); - - return this.#edits.nonOverlapping(edits); - } -} diff --git a/packages/ts/sidecar/src/passes/drizzle/drizzle-argument-writer.ts b/packages/ts/sidecar/src/passes/drizzle/drizzle-argument-writer.ts new file mode 100644 index 0000000..ab9f8ca --- /dev/null +++ b/packages/ts/sidecar/src/passes/drizzle/drizzle-argument-writer.ts @@ -0,0 +1,212 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import type { DrizzleCallClassifier } from '#sidecar/passes/drizzle/drizzle-call-classifier'; +import type { DrizzleImports } from '#sidecar/passes/drizzle/drizzle-import-scanner'; +import type { DrizzleVocabulary } from '#sidecar/passes/drizzle/drizzle-vocabulary'; +import { Node } from '#sidecar/syntax/node-schema'; +import type { ParsedSourceDto } from '#sidecar/syntax/node-schema'; +import type { Edit } from '#sidecar/syntax/edits'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; + +/** + * Renders recognised Drizzle structures into stable multiline layouts. + * + * The writer owns the emission half of the pass: given a call the + * {@link DrizzleCallClassifier} has approved, it produces the single + * argument-parenthesis {@link Edit} that expands the call, recursing through + * object, array, helper, and set-operation operands. Commented spans are left + * verbatim so no edit rewrites source a reader annotated. + */ +export class DrizzleArgumentWriter { + readonly #ast: AstReader; + readonly #vocabulary: DrizzleVocabulary; + readonly #classifier: DrizzleCallClassifier; + + /** + * @param dependencies - The services consumed by the writer. + * @param dependencies.ast - Traverses and reads validated node fields. + * @param dependencies.vocabulary - The recognised Drizzle name vocabulary. + * @param dependencies.classifier - Decides which structures may be formatted. + */ + constructor(dependencies: { ast: AstReader; vocabulary: DrizzleVocabulary; classifier: DrizzleCallClassifier }) { + this.#ast = dependencies.ast; + this.#vocabulary = dependencies.vocabulary; + this.#classifier = dependencies.classifier; + } + + /** + * Produce the edit that expands a recognised call's arguments across lines. + * + * @param document - The document the call belongs to. + * @param call - The approved call expression. + * @param imports - The Drizzle imports in scope. + * @param parsed - The parsed source, consulted for comment spans. + * @param indentUnit - The document's per-level indentation unit. + * @returns The argument-parenthesis edit, or `null` when none is warranted. + */ + formatCall(document: SourceDocument, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indentUnit: string): Edit | null { + const parens = this.#callParens(document.text, call); + const args = this.#ast.childNodes(call, 'arguments'); + + if (!parens || args.length === 0) { + return null; + } + + if (parsed.hasCommentBetween(parens.open, parens.close)) { + return null; + } + + const callee = this.#ast.unwrapChainExpression(this.#ast.childNode(call, 'callee')); + + const property = callee ? this.#ast.childNode(callee, 'property') : undefined; + const indentPos = callee?.type === 'MemberExpression' && property ? this.#ast.getStart(property) : this.#ast.getStart(call); + const indent = document.lineIndent(indentPos); + const argIndent = `${indent}${indentUnit}`; + const formatted = args.map((arg) => this.#formatNode(document.text, arg, imports, parsed, argIndent, indentUnit)); + const replacement = `(\n${argIndent}${formatted.join(`,\n${argIndent}`)},\n${indent})`; + + if (document.slice(parens.open, parens.close + 1) === replacement) { + return null; + } + + return { + start: parens.open, + end: parens.close + 1, + replacement, + }; + } + + #callDisplayName(source: string, call: Node, imports: DrizzleImports): string { + const callee = this.#ast.unwrapChainExpression(this.#ast.childNode(call, 'callee')); + + if (callee?.type === 'Identifier') { + return this.#ast.sourceOf(source, callee); + } + + if (callee?.type === 'MemberExpression') { + const name = this.#classifier.calleeName(callee, imports); + + if (name) { + return this.#ast.sourceOf(source, callee); + } + } + + return callee ? this.#ast.sourceOf(source, callee) : ''; + } + + #callParens(source: string, call: Node): { open: number; close: number } | null { + return this.#ast.callParens(source, call, this.#ast.unwrapChainExpression(this.#ast.childNode(call, 'callee'))); + } + + #formatArrayExpression(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(this.#ast.getStart(node), this.#ast.getEnd(node))) { + return this.#ast.sourceOf(source, node); + } + + const elements = Array.isArray(node.elements) ? node.elements : []; + + if (elements.length === 0) { + return '[]'; + } + + const nextIndent = `${indent}${indentUnit}`; + + const formatted = elements.map((element) => { + return element instanceof Node ? this.#formatNode(source, element, imports, parsed, nextIndent, indentUnit) : ''; + }); + + return `[\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}]`; + } + + #formatObjectExpression(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(this.#ast.getStart(node), this.#ast.getEnd(node))) { + return this.#ast.sourceOf(source, node); + } + + const properties = this.#ast.childNodes(node, 'properties'); + + if (properties.length === 0) { + return '{}'; + } + + const nextIndent = `${indent}${indentUnit}`; + + const formatted = properties.map((property) => { + if (property.type !== 'Property') { + return this.#ast.sourceOf(source, property); + } + + const key = this.#ast.childNode(property, 'key'); + const value = this.#ast.childNode(property, 'value'); + + if (!key || !value || property.computed || property.method) { + return this.#ast.sourceOf(source, property); + } + + if (property.shorthand) { + return this.#ast.sourceOf(source, property); + } + + return `${this.#ast.sourceOf(source, key)}: ${this.#formatNode(source, value, imports, parsed, nextIndent, indentUnit)}`; + }); + + return `{\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent}}`; + } + + #formatHelperCall(source: string, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(this.#ast.getStart(call), this.#ast.getEnd(call))) { + return this.#ast.sourceOf(source, call); + } + + const importedName = this.#classifier.calleeName(this.#ast.unwrapChainExpression(this.#ast.childNode(call, 'callee')), imports); + + const args = this.#ast.childNodes(call, 'arguments'); + + if (!importedName || !this.#vocabulary.isMultilineHelper(importedName) || args.length === 0) { + return this.#ast.sourceOf(source, call); + } + + const nextIndent = `${indent}${indentUnit}`; + const formatted = args.map((arg) => this.#formatNode(source, arg, imports, parsed, nextIndent, indentUnit)); + + return `${this.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; + } + + #formatSetOperationCall(source: string, call: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (parsed.hasCommentBetween(this.#ast.getStart(call), this.#ast.getEnd(call))) { + return this.#ast.sourceOf(source, call); + } + + const args = this.#ast.childNodes(call, 'arguments'); + + if (args.length < 2) { + return this.#ast.sourceOf(source, call); + } + + const nextIndent = `${indent}${indentUnit}`; + const formatted = args.map((arg) => this.#formatNode(source, arg, imports, parsed, nextIndent, indentUnit)); + + return `${this.#callDisplayName(source, call, imports)}(\n${nextIndent}${formatted.join(`,\n${nextIndent}`)},\n${indent})`; + } + + #formatNode(source: string, node: Node, imports: DrizzleImports, parsed: ParsedSourceDto, indent: string, indentUnit: string): string { + if (node.type === 'ObjectExpression' && this.#classifier.shouldFormatObjectExpression(node)) { + return this.#formatObjectExpression(source, node, imports, parsed, indent, indentUnit); + } + + if (node.type === 'ArrayExpression' && this.#classifier.shouldFormatArrayExpression(node)) { + return this.#formatArrayExpression(source, node, imports, parsed, indent, indentUnit); + } + + if (node.type === 'CallExpression') { + if (this.#classifier.isSetOperationCall(node, imports)) { + return this.#formatSetOperationCall(source, node, imports, parsed, indent, indentUnit); + } + + if (this.#classifier.isImportedHelperCall(node, imports)) { + return this.#formatHelperCall(source, node, imports, parsed, indent, indentUnit); + } + } + + return this.#ast.sourceOf(source, node); + } +} diff --git a/packages/ts/sidecar/src/passes/drizzle/drizzle-call-classifier.ts b/packages/ts/sidecar/src/passes/drizzle/drizzle-call-classifier.ts new file mode 100644 index 0000000..7d61841 --- /dev/null +++ b/packages/ts/sidecar/src/passes/drizzle/drizzle-call-classifier.ts @@ -0,0 +1,334 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import type { DrizzleImports } from '#sidecar/passes/drizzle/drizzle-import-scanner'; +import type { DrizzleVocabulary } from '#sidecar/passes/drizzle/drizzle-vocabulary'; +import { Node } from '#sidecar/syntax/node-schema'; + +/** + * Decides which calls and arguments the Drizzle query formatter may touch. + * + * Every predicate reads the scan's {@link DrizzleImports} so aliased helpers and + * namespace calls resolve to their recognised names, and consults the shared + * {@link DrizzleVocabulary} for the method, helper, and key words that gate + * formatting. It proposes no edits: it only answers whether a node qualifies. + */ +export class DrizzleCallClassifier { + readonly #ast: AstReader; + readonly #vocabulary: DrizzleVocabulary; + + /** + * @param dependencies - The services consumed by the classifier. + * @param dependencies.ast - Traverses and reads validated node fields. + * @param dependencies.vocabulary - The recognised Drizzle name vocabulary. + */ + constructor(dependencies: { ast: AstReader; vocabulary: DrizzleVocabulary }) { + this.#ast = dependencies.ast; + this.#vocabulary = dependencies.vocabulary; + } + + /** + * Resolve a callee to the exported Drizzle name it invokes, if any. + * + * @param callee - The unwrapped callee node. + * @param imports - The Drizzle imports in scope. + * @returns The recognised exported name, or `null`. + */ + calleeName(callee: Node | undefined, imports: DrizzleImports): string | null { + if (!callee) { + return null; + } + + if (callee.type === 'Identifier') { + const name = this.#localName(callee); + + return name ? (imports.localImport(name) ?? null) : null; + } + + if (callee.type === 'MemberExpression' && !callee.computed) { + const object = this.#ast.childNode(callee, 'object'); + + const property = this.#localName(this.#ast.childNode(callee, 'property')); + + const objectName = this.#localName(object); + + if (objectName && property && imports.hasNamespace(objectName)) { + return property; + } + } + + return null; + } + + /** + * Report whether a call is a formattable Drizzle query-builder method call. + * + * @param call - The call expression to inspect. + * @param imports - The Drizzle imports in scope. + * @returns `true` when the call is a recognised builder method on a receiver. + */ + isDrizzleMethodCall(call: Node, imports: DrizzleImports): boolean { + const callee = this.#ast.unwrapChainExpression(this.#ast.childNode(call, 'callee')); + + if (callee?.type !== 'MemberExpression') { + return false; + } + + const name = this.#propertyName(callee); + + if (!name || !this.#vocabulary.isFormatMethod(name)) { + return false; + } + + return this.#isDrizzleReceiver(this.#ast.childNode(callee, 'object'), imports); + } + + /** + * Report whether a call is a relational query-builder `findMany`/`findFirst`. + * + * @param call - The call expression to inspect. + * @param imports - The Drizzle imports in scope. + * @returns `true` when the call reaches a `query` member on a receiver. + */ + isRelationalQueryCall(call: Node, imports: DrizzleImports): boolean { + const name = this.#methodName(call); + + const callee = this.#ast.unwrapChainExpression(this.#ast.childNode(call, 'callee')); + + if ((name !== 'findMany' && name !== 'findFirst') || callee?.type !== 'MemberExpression') { + return false; + } + + const object = this.#ast.childNode(callee, 'object'); + + return this.#chainHasQueryMember(object) && this.#isDrizzleReceiver(object, imports); + } + + /** + * Report whether a call invokes an imported Drizzle helper. + * + * @param call - The call expression to inspect. + * @param imports - The Drizzle imports in scope. + * @returns `true` when the callee resolves to a recognised helper. + */ + isImportedHelperCall(call: Node, imports: DrizzleImports): boolean { + const callee = this.#ast.unwrapChainExpression(this.#ast.childNode(call, 'callee')); + + const name = this.calleeName(callee, imports); + + return Boolean(name && this.#vocabulary.isHelper(name)); + } + + /** + * Report whether a call invokes a set-operation helper. + * + * @param call - The call expression to inspect. + * @param imports - The Drizzle imports in scope. + * @returns `true` when the callee resolves to a set-operation helper. + */ + isSetOperationCall(call: Node, imports: DrizzleImports): boolean { + const callee = this.#ast.unwrapChainExpression(this.#ast.childNode(call, 'callee')); + + const name = this.calleeName(callee, imports); + + return Boolean(name && this.#vocabulary.isSetOperation(name)); + } + + /** + * Report whether an object expression is worth expanding across lines. + * + * @param node - The object expression to inspect. + * @returns `true` when the object has multiple or structural properties. + */ + shouldFormatObjectExpression(node: Node): boolean { + const properties = this.#ast.childNodes(node, 'properties'); + + if (properties.length > 1) { + return true; + } + + return properties.some((property) => { + if (property.type !== 'Property') { + return true; + } + + const key = this.#localName(this.#ast.childNode(property, 'key')); + + const value = this.#ast.childNode(property, 'value'); + + if (!value) { + return false; + } + + if (key && this.#vocabulary.formatsObjectKey(key) && (value.type === 'ObjectExpression' || value.type === 'ArrayExpression' || value.type === 'CallExpression')) { + return true; + } + + return value.type === 'ObjectExpression' || value.type === 'ArrayExpression'; + }); + } + + /** + * Report whether an array expression is worth expanding across lines. + * + * @param node - The array expression to inspect. + * @returns `true` when the array has multiple or structural elements. + */ + shouldFormatArrayExpression(node: Node): boolean { + const elements = Array.isArray(node.elements) ? node.elements : []; + + return elements.length > 1 || elements.some((element) => element instanceof Node && (element.type === 'ObjectExpression' || element.type === 'CallExpression')); + } + + /** + * Report whether a method call's arguments should be expanded. + * + * @param call - The call expression to inspect. + * @param imports - The Drizzle imports in scope. + * @returns `true` when the method's arguments qualify for formatting. + */ + shouldFormatMethodArguments(call: Node, imports: DrizzleImports): boolean { + const args = this.#ast.childNodes(call, 'arguments'); + + if (args.length === 0) { + return false; + } + + if (this.isRelationalQueryCall(call, imports)) { + return args.some((arg) => arg.type === 'ObjectExpression' && this.shouldFormatObjectExpression(arg)); + } + + const name = this.#methodName(call); + + if (!name) { + return false; + } + + if (['where', 'having', '$count'].includes(name)) { + return args.some((arg) => this.#isComplexArgument(arg, imports)); + } + + if (['leftJoin', 'rightJoin', 'innerJoin', 'fullJoin', 'crossJoin'].includes(name)) { + return args.length > 1 && args.some((arg, index) => index > 0 && this.#isComplexArgument(arg, imports)); + } + + if (['onConflictDoNothing', 'onConflictDoUpdate', 'returning', 'set', 'values'].includes(name)) { + return args.some((arg) => this.#isComplexArgument(arg, imports)); + } + + if (['as', 'except', 'groupBy', 'intersect', 'orderBy', 'union', 'unionAll'].includes(name)) { + return args.length > 1 || args.some((arg) => this.#isStructuralArgument(arg, imports)); + } + + return args.length > 1 && args.some((arg) => this.#isComplexArgument(arg, imports)); + } + + #localName(node: Node | undefined): string | null { + return node?.type === 'Identifier' ? (this.#ast.nodeName(node) ?? null) : null; + } + + #propertyName(member: Node | undefined): string | null { + if (member?.type !== 'MemberExpression' || member.computed) { + return null; + } + + return this.#localName(this.#ast.childNode(member, 'property')); + } + + #chainHasQueryMember(node: Node | undefined): boolean { + const current = this.#ast.unwrapChainExpression(node); + + if (!current) { + return false; + } + + if (current.type === 'MemberExpression') { + if (this.#propertyName(current) === 'query') { + return true; + } + + return this.#chainHasQueryMember(this.#ast.childNode(current, 'object')); + } + + if (current.type === 'CallExpression') { + return this.#chainHasQueryMember(this.#ast.childNode(current, 'callee')); + } + + return false; + } + + #isDrizzleReceiver(node: Node | undefined, imports: DrizzleImports): boolean { + const current = this.#ast.unwrapChainExpression(node); + + if (!current) { + return false; + } + + if (current.type === 'Identifier') { + const name = this.#localName(current); + + return Boolean(name && this.#vocabulary.isConventionalReceiver(name)); + } + + if (current.type === 'MemberExpression') { + const object = this.#ast.childNode(current, 'object'); + const property = this.#propertyName(current); + + if (property === 'query') { + return this.#isDrizzleReceiver(object, imports); + } + + return this.#isDrizzleReceiver(object, imports); + } + + if (current.type === 'CallExpression') { + const callee = this.#ast.unwrapChainExpression(this.#ast.childNode(current, 'callee')); + + if (callee?.type === 'Identifier') { + const imported = this.calleeName(callee, imports); + + return Boolean(imported && this.#vocabulary.isSetOperation(imported)); + } + + if (callee?.type === 'MemberExpression') { + const method = this.#propertyName(callee); + + if (method && this.#vocabulary.isChainMethod(method)) { + return this.#isDrizzleReceiver(this.#ast.childNode(callee, 'object'), imports); + } + + return this.#isDrizzleReceiver(this.#ast.childNode(callee, 'object'), imports); + } + } + + return false; + } + + #methodName(call: Node): string | null { + const callee = this.#ast.unwrapChainExpression(this.#ast.childNode(call, 'callee')); + + return callee?.type === 'MemberExpression' ? this.#propertyName(callee) : null; + } + + #isComplexArgument(node: Node, imports: DrizzleImports): boolean { + if (node.type === 'ObjectExpression') { + return this.shouldFormatObjectExpression(node); + } + + if (node.type === 'ArrayExpression') { + return this.shouldFormatArrayExpression(node); + } + + if (node.type === 'CallExpression') { + return this.isImportedHelperCall(node, imports) || this.isSetOperationCall(node, imports) || this.isDrizzleMethodCall(node, imports); + } + + return false; + } + + #isStructuralArgument(node: Node, imports: DrizzleImports): boolean { + if (node.type === 'ObjectExpression' || node.type === 'ArrayExpression') { + return true; + } + + return node.type === 'CallExpression' && (this.isSetOperationCall(node, imports) || this.isDrizzleMethodCall(node, imports)); + } +} diff --git a/packages/ts/sidecar/src/passes/drizzle/drizzle-import-scanner.test.ts b/packages/ts/sidecar/src/passes/drizzle/drizzle-import-scanner.test.ts new file mode 100644 index 0000000..2539495 --- /dev/null +++ b/packages/ts/sidecar/src/passes/drizzle/drizzle-import-scanner.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { DrizzleImportScanner, DrizzleImports } from '#sidecar/passes/drizzle/drizzle-import-scanner'; +import { isErr } from '#sidecar/kernel/result'; +import { SourceParser } from '#sidecar/syntax/source-parser'; + +function scan(source: string): DrizzleImports { + const parsed = new SourceParser().parse('fixture.ts', source); + + assert.equal(isErr(parsed), false); + + if (isErr(parsed)) { + throw new Error('fixture failed to parse'); + } + + return new DrizzleImportScanner({ ast: new AstReader() }).scan(parsed.value.program); +} + +test('DrizzleImportScanner resolves named and aliased Drizzle imports', () => { + const imports = scan("import { and as all, eq } from 'drizzle-orm';\n"); + + assert.equal(imports.isEmpty, false); + assert.equal(imports.localImport('all'), 'and'); + assert.equal(imports.localImport('eq'), 'eq'); + assert.equal(imports.localImport('missing'), undefined); + assert.equal(imports.hasNamespace('all'), false); +}); + +test('DrizzleImportScanner records namespace imports', () => { + const imports = scan("import * as drizzle from 'drizzle-orm';\n"); + + assert.equal(imports.isEmpty, false); + assert.equal(imports.hasNamespace('drizzle'), true); + assert.equal(imports.hasNamespace('other'), false); + assert.equal(imports.localImport('drizzle'), undefined); +}); + +test('DrizzleImportScanner matches submodule sources', () => { + const imports = scan("import { sql } from 'drizzle-orm/pg-core';\n"); + + assert.equal(imports.localImport('sql'), 'sql'); +}); + +test('DrizzleImportScanner ignores non-Drizzle imports', () => { + const imports = scan("import { eq } from 'other-orm';\n"); + + assert.equal(imports.isEmpty, true); + assert.equal(imports.localImport('eq'), undefined); +}); + +test('DrizzleImports.empty carries no bindings and is frozen', () => { + const imports = DrizzleImports.empty(); + + assert.equal(imports.isEmpty, true); + assert.equal(Object.isFrozen(imports), true); + assert.equal(imports.localImport('eq'), undefined); + assert.equal(imports.hasNamespace('drizzle'), false); +}); + +test('DrizzleImports.of copies its inputs so later mutation is inert', () => { + const locals = new Map([['eq', 'eq']]); + const namespaces = new Set(['drizzle']); + const imports = DrizzleImports.of(locals, namespaces); + + locals.set('and', 'and'); + namespaces.add('other'); + + assert.equal(imports.localImport('eq'), 'eq'); + assert.equal(imports.localImport('and'), undefined); + assert.equal(imports.hasNamespace('drizzle'), true); + assert.equal(imports.hasNamespace('other'), false); +}); diff --git a/packages/ts/sidecar/src/passes/drizzle/drizzle-import-scanner.ts b/packages/ts/sidecar/src/passes/drizzle/drizzle-import-scanner.ts new file mode 100644 index 0000000..8bffe82 --- /dev/null +++ b/packages/ts/sidecar/src/passes/drizzle/drizzle-import-scanner.ts @@ -0,0 +1,140 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import type { Node } from '#sidecar/syntax/node-schema'; + +/** + * The Drizzle imports a module brings into scope. + * + * A frozen value object capturing what a scan produces: a map from each named + * import's local binding to its original exported name (so aliases resolve back + * to the recognised helper) and the set of namespace-import bindings. Consumers + * ask about a binding through {@link DrizzleImports.localImport} and + * {@link DrizzleImports.hasNamespace} rather than reaching into the collections. + */ +export class DrizzleImports { + readonly #locals: ReadonlyMap; + readonly #namespaces: ReadonlySet; + + private constructor(locals: ReadonlyMap, namespaces: ReadonlySet) { + this.#locals = locals; + this.#namespaces = namespaces; + + Object.freeze(this); + } + + /** + * Build imports from a scan's accumulated local and namespace bindings. + * + * @param locals - The local-binding to exported-name map. + * @param namespaces - The namespace-import bindings. + * @returns The frozen imports value object. + */ + static of(locals: Map, namespaces: Set): DrizzleImports { + return new DrizzleImports(new Map(locals), new Set(namespaces)); + } + + /** + * Build the empty imports carrying no Drizzle bindings. + * + * @returns The frozen empty imports. + */ + static empty(): DrizzleImports { + return new DrizzleImports(new Map(), new Set()); + } + + /** Whether the module imports nothing from Drizzle. */ + get isEmpty(): boolean { + return this.#locals.size === 0 && this.#namespaces.size === 0; + } + + /** + * Resolve a local binding to the exported Drizzle name it was imported as. + * + * @param local - The local identifier used at the call site. + * @returns The original exported name, or `undefined` when it is not imported. + */ + localImport(local: string): string | undefined { + return this.#locals.get(local); + } + + /** + * Report whether a name is bound to a Drizzle namespace import. + * + * @param name - The identifier to test. + * @returns `true` when the name is a namespace binding. + */ + hasNamespace(name: string): boolean { + return this.#namespaces.has(name); + } +} + +/** Collects the Drizzle imports a module brings into scope. */ +export class DrizzleImportScanner { + readonly #ast: AstReader; + + readonly #module = 'drizzle-orm'; + + /** + * @param dependencies - The syntax services consumed by the scanner. + * @param dependencies.ast - Traverses and reads validated node fields. + */ + constructor(dependencies: { ast: AstReader }) { + this.#ast = dependencies.ast; + } + + /** + * Collect the Drizzle imports declared at the top of a program. + * + * @param program - The parsed program root to scan. + * @returns The imports the module brings into scope. + */ + scan(program: Node): DrizzleImports { + const locals = new Map(); + const namespaces = new Set(); + const body = this.#ast.childNodes(program, 'body'); + + for (const statement of body) { + if (statement.type !== 'ImportDeclaration') { + continue; + } + + const source = this.#literalValue(this.#ast.childNode(statement, 'source')); + + if (!source?.startsWith(this.#module)) { + continue; + } + + for (const specifier of this.#ast.childNodes(statement, 'specifiers')) { + if (specifier.type === 'ImportSpecifier') { + const imported = this.#localName(this.#ast.childNode(specifier, 'imported')); + const local = this.#localName(this.#ast.childNode(specifier, 'local')); + + if (imported && local) { + locals.set(local, imported); + } + } + + if (specifier.type === 'ImportNamespaceSpecifier') { + const local = this.#localName(this.#ast.childNode(specifier, 'local')); + + if (local) { + namespaces.add(local); + } + } + } + } + + return DrizzleImports.of(locals, namespaces); + } + + #localName(node: Node | undefined): string | null { + return node?.type === 'Identifier' ? (this.#ast.nodeName(node) ?? null) : null; + } + + #literalValue(node: Node | undefined): string | null { + if (node?.type !== 'Literal') { + return null; + } + + return this.#ast.stringValue(node) ?? null; + } +} diff --git a/packages/ts/sidecar/src/drizzle-queries.test.ts b/packages/ts/sidecar/src/passes/drizzle/drizzle-query-pass.test.ts similarity index 90% rename from packages/ts/sidecar/src/drizzle-queries.test.ts rename to packages/ts/sidecar/src/passes/drizzle/drizzle-query-pass.test.ts index b26e6f2..159b27c 100644 --- a/packages/ts/sidecar/src/drizzle-queries.test.ts +++ b/packages/ts/sidecar/src/passes/drizzle/drizzle-query-pass.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { DrizzleQueryPass } from '#sidecar/passes/drizzle-query-pass'; +import { AstReader } from '#sidecar/syntax/ast-reader'; +import { DrizzleArgumentWriter } from '#sidecar/passes/drizzle/drizzle-argument-writer'; +import { DrizzleCallClassifier } from '#sidecar/passes/drizzle/drizzle-call-classifier'; +import { DrizzleImportScanner } from '#sidecar/passes/drizzle/drizzle-import-scanner'; +import { DrizzleQueryPass } from '#sidecar/passes/drizzle/drizzle-query-pass'; +import { DrizzleVocabulary } from '#sidecar/passes/drizzle/drizzle-vocabulary'; import { EditApplier } from '#sidecar/syntax/edits'; import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; import { SourceDocument } from '#sidecar/syntax/source-document'; @@ -16,7 +21,18 @@ function format(input: string, virtualName: string): string { } const editApplier = new EditApplier(); -const drizzlePass = new DrizzleQueryPass({ parser: new SourceParser(), edits: editApplier }); +const ast = new AstReader(); +const vocabulary = DrizzleVocabulary.standard(); +const classifier = new DrizzleCallClassifier({ ast, vocabulary }); + +const drizzlePass = new DrizzleQueryPass({ + parser: new SourceParser(), + ast, + edits: editApplier, + scanner: new DrizzleImportScanner({ ast }), + classifier, + writer: new DrizzleArgumentWriter({ ast, vocabulary, classifier }), +}); function drizzleFormat(input: string, virtualName: string): string { const edits = drizzlePass.computeEdits(SourceDocument.of(virtualName, input)); diff --git a/packages/ts/sidecar/src/passes/drizzle/drizzle-query-pass.ts b/packages/ts/sidecar/src/passes/drizzle/drizzle-query-pass.ts new file mode 100644 index 0000000..7fc13b2 --- /dev/null +++ b/packages/ts/sidecar/src/passes/drizzle/drizzle-query-pass.ts @@ -0,0 +1,101 @@ +import type { AstReader } from '#sidecar/syntax/ast-reader'; +import type { DrizzleArgumentWriter } from '#sidecar/passes/drizzle/drizzle-argument-writer'; +import type { DrizzleCallClassifier } from '#sidecar/passes/drizzle/drizzle-call-classifier'; +import type { DrizzleImportScanner } from '#sidecar/passes/drizzle/drizzle-import-scanner'; +import type { Edit, EditApplier } from '#sidecar/syntax/edits'; +import { FileTargets } from '#sidecar/hosts/file-targets'; +import { isErr } from '#sidecar/kernel/result'; +import type { FormattingPass } from '#sidecar/passes/pass'; +import type { SourceDocument } from '#sidecar/syntax/source-document'; +import type { SourceParser } from '#sidecar/syntax/source-parser'; + +/** + * Formats recognised Drizzle query structures without touching unrelated calls. + * + * The pass is pure orchestration: it parses the document, scans its Drizzle + * imports, then walks every call the classifier approves and asks the writer for + * the edit that expands it. Detection, vocabulary, and emission live in the + * injected collaborators — no static state and no shared reader remain here. + */ +export class DrizzleQueryPass implements FormattingPass { + /** The pass identity used for reporting. */ + readonly name = 'drizzle-queries'; + + readonly #parser: SourceParser; + readonly #ast: AstReader; + readonly #edits: EditApplier; + readonly #scanner: DrizzleImportScanner; + readonly #classifier: DrizzleCallClassifier; + readonly #writer: DrizzleArgumentWriter; + + /** + * @param dependencies - The services and collaborators consumed by the pass. + * @param dependencies.parser - Parses source into a trustworthy tree. + * @param dependencies.ast - Traverses and reads validated node fields. + * @param dependencies.edits - Reduces candidate edits to a non-overlapping set. + * @param dependencies.scanner - Collects the Drizzle imports in scope. + * @param dependencies.classifier - Decides which calls may be formatted. + * @param dependencies.writer - Emits the edit that expands an approved call. + */ + constructor(dependencies: { parser: SourceParser; ast: AstReader; edits: EditApplier; scanner: DrizzleImportScanner; classifier: DrizzleCallClassifier; writer: DrizzleArgumentWriter }) { + this.#parser = dependencies.parser; + this.#ast = dependencies.ast; + this.#edits = dependencies.edits; + this.#scanner = dependencies.scanner; + this.#classifier = dependencies.classifier; + this.#writer = dependencies.writer; + } + + /** + * Compute edits for recognised Drizzle query structures. + * + * @param document - The document to inspect. + * @returns Non-overlapping query-formatting edits, or none for invalid source. + */ + computeEdits(document: SourceDocument): Edit[] { + if (FileTargets.isDeclarationFile(document.virtualName)) { + return []; + } + + const parsed = this.#parser.parse(document.virtualName, document.text); + + if (isErr(parsed)) { + return []; + } + + const imports = this.#scanner.scan(parsed.value.program); + + if (imports.isEmpty) { + return []; + } + + const edits: Edit[] = []; + const indentUnit = document.indentUnit(); + + this.#ast.visit(parsed.value.program, (node) => { + if (node.type !== 'CallExpression') { + return; + } + + if (this.#classifier.isDrizzleMethodCall(node, imports) || this.#classifier.isRelationalQueryCall(node, imports) || this.#classifier.isSetOperationCall(node, imports)) { + const args = this.#ast.childNodes(node, 'arguments'); + + if (this.#classifier.isSetOperationCall(node, imports) && args.length > 0 && args.length < 2) { + return; + } + + if (!this.#classifier.isSetOperationCall(node, imports) && !this.#classifier.shouldFormatMethodArguments(node, imports)) { + return; + } + + const edit = this.#writer.formatCall(document, node, imports, parsed.value, indentUnit); + + if (edit) { + edits.push(edit); + } + } + }); + + return this.#edits.nonOverlapping(edits); + } +} diff --git a/packages/ts/sidecar/src/passes/drizzle/drizzle-vocabulary.test.ts b/packages/ts/sidecar/src/passes/drizzle/drizzle-vocabulary.test.ts new file mode 100644 index 0000000..38f3eae --- /dev/null +++ b/packages/ts/sidecar/src/passes/drizzle/drizzle-vocabulary.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { DrizzleVocabulary } from '#sidecar/passes/drizzle/drizzle-vocabulary'; + +test('DrizzleVocabulary.standard classifies recognised Drizzle names', () => { + const vocabulary = DrizzleVocabulary.standard(); + + assert.equal(vocabulary.isConventionalReceiver('db'), true); + assert.equal(vocabulary.isConventionalReceiver('tx'), true); + assert.equal(vocabulary.isConventionalReceiver('builder'), false); + + assert.equal(vocabulary.isChainMethod('select'), true); + assert.equal(vocabulary.isChainMethod('from'), true); + assert.equal(vocabulary.isChainMethod('findMany'), false); + + assert.equal(vocabulary.isFormatMethod('where'), true); + assert.equal(vocabulary.isFormatMethod('findMany'), true); + assert.equal(vocabulary.isFormatMethod('from'), false); + + assert.equal(vocabulary.isHelper('eq'), true); + assert.equal(vocabulary.isHelper('and'), true); + assert.equal(vocabulary.isHelper('coalesce'), false); + + assert.equal(vocabulary.isMultilineHelper('and'), true); + assert.equal(vocabulary.isMultilineHelper('exists'), true); + assert.equal(vocabulary.isMultilineHelper('eq'), false); + + assert.equal(vocabulary.isSetOperation('union'), true); + assert.equal(vocabulary.isSetOperation('unionAll'), true); + assert.equal(vocabulary.isSetOperation('where'), false); + + assert.equal(vocabulary.formatsObjectKey('with'), true); + assert.equal(vocabulary.formatsObjectKey('target'), true); + assert.equal(vocabulary.formatsObjectKey('id'), false); +}); + +test('DrizzleVocabulary is frozen and self-contained per instance', () => { + const vocabulary = DrizzleVocabulary.standard(); + + assert.equal(Object.isFrozen(vocabulary), true); + + const custom = new DrizzleVocabulary({ + receivers: ['store'], + chainMethods: ['select'], + formatMethods: ['where'], + helpers: ['eq'], + multilineHelpers: ['and'], + setOperations: ['union'], + objectKeys: ['with'], + }); + + assert.equal(custom.isConventionalReceiver('store'), true); + assert.equal(custom.isConventionalReceiver('db'), false); + assert.equal(DrizzleVocabulary.standard().isConventionalReceiver('store'), false); +}); diff --git a/packages/ts/sidecar/src/passes/drizzle/drizzle-vocabulary.ts b/packages/ts/sidecar/src/passes/drizzle/drizzle-vocabulary.ts new file mode 100644 index 0000000..2af5619 --- /dev/null +++ b/packages/ts/sidecar/src/passes/drizzle/drizzle-vocabulary.ts @@ -0,0 +1,218 @@ +/** + * The recognised Drizzle vocabulary: the method, helper, and key names that + * decide which calls and structures the query formatter is allowed to touch. + * + * The name sets live here as private readonly fields behind intent-revealing + * predicates so no other collaborator carries a bare `Set` of Drizzle words. + * {@link DrizzleVocabulary.standard} builds the canonical vocabulary the pass + * ships with. + */ +export class DrizzleVocabulary { + readonly #receivers: ReadonlySet; + readonly #chainMethods: ReadonlySet; + readonly #formatMethods: ReadonlySet; + readonly #helpers: ReadonlySet; + readonly #multilineHelpers: ReadonlySet; + readonly #setOperations: ReadonlySet; + readonly #objectKeys: ReadonlySet; + + /** + * @param vocabulary - The recognised Drizzle name sets. + * @param vocabulary.receivers - The conventional query-builder receiver names. + * @param vocabulary.chainMethods - The chainable query-builder method names. + * @param vocabulary.formatMethods - The methods whose arguments may be formatted. + * @param vocabulary.helpers - The imported condition and expression helpers. + * @param vocabulary.multilineHelpers - The helpers expanded across lines. + * @param vocabulary.setOperations - The set-operation helper names. + * @param vocabulary.objectKeys - The option-object keys whose values are formatted. + */ + constructor(vocabulary: { + receivers: Iterable; + chainMethods: Iterable; + formatMethods: Iterable; + helpers: Iterable; + multilineHelpers: Iterable; + setOperations: Iterable; + objectKeys: Iterable; + }) { + this.#receivers = new Set(vocabulary.receivers); + this.#chainMethods = new Set(vocabulary.chainMethods); + this.#formatMethods = new Set(vocabulary.formatMethods); + this.#helpers = new Set(vocabulary.helpers); + this.#multilineHelpers = new Set(vocabulary.multilineHelpers); + this.#setOperations = new Set(vocabulary.setOperations); + this.#objectKeys = new Set(vocabulary.objectKeys); + + Object.freeze(this); + } + + /** + * Build the canonical Drizzle vocabulary the query pass ships with. + * + * @returns The standard vocabulary over the recognised Drizzle names. + */ + static standard(): DrizzleVocabulary { + return new DrizzleVocabulary({ + receivers: ['db', 'tx'], + chainMethods: [ + '$count', + '$dynamic', + '$with', + 'as', + 'crossJoin', + 'delete', + 'except', + 'from', + 'fullJoin', + 'groupBy', + 'having', + 'innerJoin', + 'insert', + 'intersect', + 'leftJoin', + 'limit', + 'offset', + 'onConflictDoNothing', + 'onConflictDoUpdate', + 'orderBy', + 'prepare', + 'returning', + 'rightJoin', + 'select', + 'set', + 'union', + 'unionAll', + 'update', + 'values', + 'where', + 'with', + ], + formatMethods: [ + '$count', + 'as', + 'crossJoin', + 'except', + 'findFirst', + 'findMany', + 'fullJoin', + 'groupBy', + 'having', + 'innerJoin', + 'intersect', + 'leftJoin', + 'onConflictDoNothing', + 'onConflictDoUpdate', + 'orderBy', + 'returning', + 'rightJoin', + 'set', + 'union', + 'unionAll', + 'values', + 'where', + ], + helpers: [ + 'and', + 'arrayContained', + 'arrayContains', + 'arrayOverlaps', + 'asc', + 'between', + 'desc', + 'eq', + 'exists', + 'gt', + 'gte', + 'ilike', + 'inArray', + 'isNotNull', + 'isNull', + 'like', + 'lt', + 'lte', + 'ne', + 'not', + 'notBetween', + 'notExists', + 'notIlike', + 'notInArray', + 'notLike', + 'or', + 'sql', + ], + multilineHelpers: ['and', 'or', 'not', 'exists', 'notExists'], + setOperations: ['except', 'intersect', 'union', 'unionAll'], + objectKeys: ['columns', 'extras', 'limit', 'offset', 'onUpdate', 'orderBy', 'set', 'target', 'targetWhere', 'where', 'with'], + }); + } + + /** + * Report whether a name is a conventional Drizzle query-builder receiver. + * + * @param name - The identifier to test. + * @returns `true` when the name is a recognised receiver. + */ + isConventionalReceiver(name: string): boolean { + return this.#receivers.has(name); + } + + /** + * Report whether a method name is a chainable query-builder method. + * + * @param name - The method name to test. + * @returns `true` when the method participates in a query chain. + */ + isChainMethod(name: string): boolean { + return this.#chainMethods.has(name); + } + + /** + * Report whether a method name is one whose arguments may be formatted. + * + * @param name - The method name to test. + * @returns `true` when the method's arguments are eligible for formatting. + */ + isFormatMethod(name: string): boolean { + return this.#formatMethods.has(name); + } + + /** + * Report whether a name is an imported Drizzle condition or expression helper. + * + * @param name - The imported name to test. + * @returns `true` when the name is a recognised helper. + */ + isHelper(name: string): boolean { + return this.#helpers.has(name); + } + + /** + * Report whether a helper is one expanded across multiple lines. + * + * @param name - The helper name to test. + * @returns `true` when the helper's arguments are expanded. + */ + isMultilineHelper(name: string): boolean { + return this.#multilineHelpers.has(name); + } + + /** + * Report whether a name is a set-operation helper. + * + * @param name - The name to test. + * @returns `true` when the name is a set-operation helper. + */ + isSetOperation(name: string): boolean { + return this.#setOperations.has(name); + } + + /** + * Report whether an option-object key's value should be formatted. + * + * @param key - The object key to test. + * @returns `true` when the key introduces a structural value. + */ + formatsObjectKey(key: string): boolean { + return this.#objectKeys.has(key); + } +} diff --git a/packages/ts/sidecar/src/pipeline/pipeline-factory.ts b/packages/ts/sidecar/src/pipeline/pipeline-factory.ts index 517d5ad..f4d4b3f 100644 --- a/packages/ts/sidecar/src/pipeline/pipeline-factory.ts +++ b/packages/ts/sidecar/src/pipeline/pipeline-factory.ts @@ -4,7 +4,11 @@ import { BodyWrapPass } from '#sidecar/passes/body-wrap-pass'; import { ClassMemberPolicy } from '#sidecar/passes/policies/class-member-policy'; import { ClassReorderPass } from '#sidecar/passes/class-reorder-pass'; import { DeclarationReorderPass } from '#sidecar/passes/declaration-reorder-pass'; -import { DrizzleQueryPass } from '#sidecar/passes/drizzle-query-pass'; +import { DrizzleArgumentWriter } from '#sidecar/passes/drizzle/drizzle-argument-writer'; +import { DrizzleCallClassifier } from '#sidecar/passes/drizzle/drizzle-call-classifier'; +import { DrizzleImportScanner } from '#sidecar/passes/drizzle/drizzle-import-scanner'; +import { DrizzleQueryPass } from '#sidecar/passes/drizzle/drizzle-query-pass'; +import { DrizzleVocabulary } from '#sidecar/passes/drizzle/drizzle-vocabulary'; import { EditApplier } from '#sidecar/syntax/edits'; import { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; import { ExpandedCallPass } from '#sidecar/passes/expanded-call-pass'; @@ -51,7 +55,19 @@ export class PipelineFactory { this.#declarationReorder = new DeclarationReorderPass({ parser: dependencies.parser, ast: dependencies.ast }); this.#blankLine = new BlankLinePass({ parser: dependencies.parser, ast: dependencies.ast, spacing: dependencies.spacing }); this.#fluentChain = new FluentChainPass({ parser: dependencies.parser, ast: dependencies.ast }); - this.#drizzleQuery = new DrizzleQueryPass({ parser: dependencies.parser, edits: dependencies.edits }); + + const vocabulary = DrizzleVocabulary.standard(); + const classifier = new DrizzleCallClassifier({ ast: dependencies.ast, vocabulary }); + + this.#drizzleQuery = new DrizzleQueryPass({ + parser: dependencies.parser, + ast: dependencies.ast, + edits: dependencies.edits, + scanner: new DrizzleImportScanner({ ast: dependencies.ast }), + classifier, + writer: new DrizzleArgumentWriter({ ast: dependencies.ast, vocabulary, classifier }), + }); + this.#expandedCall = new ExpandedCallPass({ parser: dependencies.parser, ast: dependencies.ast, edits: dependencies.edits }); } From 55ed70f5d4803d60329814157420055f7ed018a3 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 24 Jul 2026 11:26:59 +0800 Subject: [PATCH 07/22] =?UTF-8?q?refactor(ts):=20TS-6=20=E2=80=94=20unifie?= =?UTF-8?q?d=20CLI=20architecture=20under=20src/cli/=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ts): drop the unused io/files inventory helper The Files directory-scan utility had no production consumers; only its own test imported it. Remove both. * refactor(ts): unify sidecar CLIs under src/cli/ with a composition root Introduce a single CLI architecture under packages/ts/sidecar/src/cli/: - CliCommand interface: run(argv) returns an exit code, never process.exit. - CompositionRoot: the single production wiring point, composing PipelineFactory's pass graph with the Node IO adapters, FormatPipeline, reporters, and command classes (formatAllCommand, segmentPassCommand, fluentPassCommand, validateSyntaxCommand). - One PassReporter and one SyntaxReporter, replacing FormatAllReporter, SyntaxErrorReporter, and the ad-hoc reporting loops in the blank-lines and fluent-chains entrypoints. Console output bytes are unchanged. - FormatAllCommand owns the segment -> oxfmt -> fluent -> segment -> validate schedule; FormatPassCommand backs both standalone format passes; ValidateSyntaxCommand backs standalone validation. - DTOs moved to cli/: PassCliDto, CliOptionsDto (cli/format-all-cli-dto), SyntaxCliDto. Flag grammar unchanged. - Entry files (blank-lines, fluent-chains, validate-syntax) hold only main() plus the run-as-main guard. Update sidecar.ts pipeline dispatch, the validate-syntax package script, and the moved CLI tests to the new paths. Naming inversion is resolved: reporters are named for what they report, not their former host module. --- packages/ts/sidecar/package.json | 2 +- packages/ts/sidecar/src/blank-lines.ts | 64 ---- .../sidecar/src/{ => cli}/blank-lines.test.ts | 2 +- packages/ts/sidecar/src/cli/blank-lines.ts | 20 ++ packages/ts/sidecar/src/cli/command.ts | 10 + .../sidecar/src/cli/composition-root.test.ts | 45 +++ .../ts/sidecar/src/cli/composition-root.ts | 104 +++++++ .../src/{ => cli}/fluent-chains.test.ts | 2 +- packages/ts/sidecar/src/cli/fluent-chains.ts | 20 ++ .../ts/sidecar/src/cli/format-all-cli-dto.ts | 93 ++++++ .../sidecar/src/{ => cli}/format-all.test.ts | 2 +- packages/ts/sidecar/src/cli/format-all.ts | 106 +++++++ .../ts/sidecar/src/cli/format-pass-command.ts | 45 +++ .../ts/sidecar/src/{ => cli}/pass-cli-dto.ts | 0 packages/ts/sidecar/src/cli/reporter.ts | 110 +++++++ packages/ts/sidecar/src/cli/syntax-cli-dto.ts | 36 +++ .../src/cli/validate-syntax-command.ts | 35 +++ .../src/{ => cli}/validate-syntax.test.ts | 2 +- .../ts/sidecar/src/cli/validate-syntax.ts | 20 ++ packages/ts/sidecar/src/fluent-chains.ts | 55 ---- packages/ts/sidecar/src/format-all.ts | 280 ------------------ packages/ts/sidecar/src/io/files.test.ts | 131 -------- packages/ts/sidecar/src/io/files.ts | 42 --- packages/ts/sidecar/src/sidecar.ts | 2 +- packages/ts/sidecar/src/validate-syntax.ts | 119 -------- 25 files changed, 650 insertions(+), 697 deletions(-) delete mode 100644 packages/ts/sidecar/src/blank-lines.ts rename packages/ts/sidecar/src/{ => cli}/blank-lines.test.ts (99%) create mode 100644 packages/ts/sidecar/src/cli/blank-lines.ts create mode 100644 packages/ts/sidecar/src/cli/command.ts create mode 100644 packages/ts/sidecar/src/cli/composition-root.test.ts create mode 100644 packages/ts/sidecar/src/cli/composition-root.ts rename packages/ts/sidecar/src/{ => cli}/fluent-chains.test.ts (99%) create mode 100644 packages/ts/sidecar/src/cli/fluent-chains.ts create mode 100644 packages/ts/sidecar/src/cli/format-all-cli-dto.ts rename packages/ts/sidecar/src/{ => cli}/format-all.test.ts (99%) create mode 100644 packages/ts/sidecar/src/cli/format-all.ts create mode 100644 packages/ts/sidecar/src/cli/format-pass-command.ts rename packages/ts/sidecar/src/{ => cli}/pass-cli-dto.ts (100%) create mode 100644 packages/ts/sidecar/src/cli/reporter.ts create mode 100644 packages/ts/sidecar/src/cli/syntax-cli-dto.ts create mode 100644 packages/ts/sidecar/src/cli/validate-syntax-command.ts rename packages/ts/sidecar/src/{ => cli}/validate-syntax.test.ts (98%) create mode 100644 packages/ts/sidecar/src/cli/validate-syntax.ts delete mode 100644 packages/ts/sidecar/src/fluent-chains.ts delete mode 100644 packages/ts/sidecar/src/format-all.ts delete mode 100644 packages/ts/sidecar/src/io/files.test.ts delete mode 100644 packages/ts/sidecar/src/io/files.ts delete mode 100644 packages/ts/sidecar/src/validate-syntax.ts diff --git a/packages/ts/sidecar/package.json b/packages/ts/sidecar/package.json index ee9d060..13ee8a4 100644 --- a/packages/ts/sidecar/package.json +++ b/packages/ts/sidecar/package.json @@ -6,7 +6,7 @@ "#sidecar/*": "./src/*.ts" }, "scripts": { - "validate-syntax": "cd ../../.. && tsx packages/ts/sidecar/src/validate-syntax.ts", + "validate-syntax": "cd ../../.. && tsx packages/ts/sidecar/src/cli/validate-syntax.ts", "lint": "cd ../../.. && git ls-files --cached --others --exclude-standard -z | xargs -0 packages/ts/sidecar/node_modules/.bin/oxlint --fix", "lint:check": "cd ../../.. && git ls-files --cached --others --exclude-standard -z | xargs -0 packages/ts/sidecar/node_modules/.bin/oxlint", "check": "pnpm lint:check", diff --git a/packages/ts/sidecar/src/blank-lines.ts b/packages/ts/sidecar/src/blank-lines.ts deleted file mode 100644 index b7f12e2..0000000 --- a/packages/ts/sidecar/src/blank-lines.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { pathToFileURL } from 'node:url'; -import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/io/process-runner'; -import { NodeSourceFiles } from '#sidecar/io/source-files'; -import { PassCliDto } from '#sidecar/pass-cli-dto'; -import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; -import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; - -async function main(): Promise { - const cwd = process.cwd(); - const options = PassCliDto.parse(process.argv.slice(2)); - const files = [...options.files]; - const { mode } = options; - const factory = PipelineFactory.create(); - const sourceFiles = new NodeSourceFiles(); - - const pipeline = new FormatPipeline({ - editor: new SourceFileEditor({ sourceFiles }), - processRunner: new NodeProcessRunner(), - validator: factory.syntaxValidator(sourceFiles), - }); - - const outcomes = await pipeline.runPass(factory.segmentFormatter(), files, mode); - - let changedCount = 0; - - for (const outcome of outcomes) { - if (outcome.error?._tag === 'SourceFileUnreadable' && outcome.error.isNotFound()) { - console.warn(`[blank-lines] path not found, skipping: ${outcome.file}`); - - continue; - } - - if (outcome.error) { - console.error(outcome.error); - process.exitCode = 1; - - return; - } - - if (!outcome.changed) { - continue; - } - - changedCount++; - console.log(`[blank-lines] ${mode === 'check' ? 'would change' : 'updated'} ${outcome.file}`); - } - - if (mode === 'check' && changedCount > 0) { - console.error(`[blank-lines] ${changedCount} file(s) need blank-line edits. Run "pnpm format" to fix.`); - process.exitCode = 1; - - return; - } - - console.log(`[blank-lines] processed ${files.length} file(s) in ${cwd}, ${changedCount} ${mode === 'check' ? 'would change' : 'changed'}`); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; - }); -} diff --git a/packages/ts/sidecar/src/blank-lines.test.ts b/packages/ts/sidecar/src/cli/blank-lines.test.ts similarity index 99% rename from packages/ts/sidecar/src/blank-lines.test.ts rename to packages/ts/sidecar/src/cli/blank-lines.test.ts index d9f40de..ad32842 100644 --- a/packages/ts/sidecar/src/blank-lines.test.ts +++ b/packages/ts/sidecar/src/cli/blank-lines.test.ts @@ -7,7 +7,7 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; const script = fileURLToPath( - import.meta.resolve('#sidecar/blank-lines'), + import.meta.resolve('#sidecar/cli/blank-lines'), ); const tsx = fileURLToPath( import.meta.resolve('tsx'), diff --git a/packages/ts/sidecar/src/cli/blank-lines.ts b/packages/ts/sidecar/src/cli/blank-lines.ts new file mode 100644 index 0000000..661671d --- /dev/null +++ b/packages/ts/sidecar/src/cli/blank-lines.ts @@ -0,0 +1,20 @@ +import { pathToFileURL } from 'node:url'; +import { CompositionRoot } from '#sidecar/cli/composition-root'; + +/** + * Run the standalone blank-lines segment formatter entrypoint. + * + * @returns Nothing after running the command and setting the process status. + */ +async function main(): Promise { + process.exitCode = await CompositionRoot.production() + .segmentPassCommand() + .run(process.argv.slice(2)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/ts/sidecar/src/cli/command.ts b/packages/ts/sidecar/src/cli/command.ts new file mode 100644 index 0000000..4a8a154 --- /dev/null +++ b/packages/ts/sidecar/src/cli/command.ts @@ -0,0 +1,10 @@ +/** A runnable sidecar CLI command that maps parsed arguments to an exit code. */ +export interface CliCommand { + /** + * Run the command over already-sliced CLI arguments. + * + * @param argv - Arguments after the executable and script path. + * @returns The process exit code; the command never calls `process.exit`. + */ + run(argv: readonly string[]): Promise; +} diff --git a/packages/ts/sidecar/src/cli/composition-root.test.ts b/packages/ts/sidecar/src/cli/composition-root.test.ts new file mode 100644 index 0000000..04c970d --- /dev/null +++ b/packages/ts/sidecar/src/cli/composition-root.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { CompositionRoot } from '#sidecar/cli/composition-root'; + +test('CompositionRoot wires formatAllCommand end-to-end through the Node adapters', async () => { + const dir = await mkdtemp( + join( + tmpdir(), + 'fmtkit-composition-root-', + ), + ); + + try { + const file = join(dir, 'app.ts'); + + await writeFile(file, 'function run() {\n\tconst x = 1;\n\tif (x) return x;\n\treturn 0;\n}\n'); + + const exitCode = await CompositionRoot.production() + .formatAllCommand() + .run(['--format-files', file, '--syntax-files', file]); + + assert.equal(exitCode, 0); + + const updated = await readFile(file, 'utf8'); + + assert.match(updated, /if \(x\) \{\n\t\treturn x;\n\t\}/); + } finally { + await rm( + dir, + { recursive: true, force: true }, + ); + } +}); + +test('CompositionRoot builds every named command', () => { + const root = CompositionRoot.production(); + + assert.equal(typeof root.formatAllCommand().run, 'function'); + assert.equal(typeof root.segmentPassCommand().run, 'function'); + assert.equal(typeof root.fluentPassCommand().run, 'function'); + assert.equal(typeof root.validateSyntaxCommand().run, 'function'); +}); diff --git a/packages/ts/sidecar/src/cli/composition-root.ts b/packages/ts/sidecar/src/cli/composition-root.ts new file mode 100644 index 0000000..21c1277 --- /dev/null +++ b/packages/ts/sidecar/src/cli/composition-root.ts @@ -0,0 +1,104 @@ +import { FormatAllCommand } from '#sidecar/cli/format-all'; +import { FormatPassCommand } from '#sidecar/cli/format-pass-command'; +import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { NodeProcessRunner } from '#sidecar/io/process-runner'; +import { NodeSourceFiles } from '#sidecar/io/source-files'; +import { PassReporter, SyntaxReporter } from '#sidecar/cli/reporter'; +import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; +import type { ProcessRunner } from '#sidecar/io/process-runner'; +import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; +import type { SourceFiles } from '#sidecar/io/source-files'; +import { ValidateSyntaxCommand } from '#sidecar/cli/validate-syntax-command'; + +/** + * The single production wiring point for the sidecar CLI. It composes the + * pass/pipeline graph from {@link PipelineFactory} with the IO adapters, the + * shared {@link FormatPipeline}, reporters, and the CLI command classes. + */ +export class CompositionRoot { + readonly #factory: PipelineFactory; + readonly #pipeline: FormatPipeline; + + /** + * @param ports - The Node adapters the pipeline reads and runs through. + * @param ports.sourceFiles - The filesystem port for reads and writes. + * @param ports.processRunner - The process port for invoking oxfmt. + */ + private constructor(ports: { sourceFiles: SourceFiles; processRunner: ProcessRunner }) { + this.#factory = PipelineFactory.create(); + this.#pipeline = new FormatPipeline({ + editor: new SourceFileEditor({ sourceFiles: ports.sourceFiles }), + processRunner: ports.processRunner, + validator: this.#factory.syntaxValidator(ports.sourceFiles), + }); + } + + /** + * Build the production composition root over the Node filesystem and process ports. + * + * @returns A composition root wired with the default Node adapters. + */ + static production(): CompositionRoot { + return new CompositionRoot({ + sourceFiles: new NodeSourceFiles(), + processRunner: new NodeProcessRunner(), + }); + } + + /** + * Build the full-pipeline command running the ordered formatting schedule. + * + * @returns The composed {@link FormatAllCommand}. + */ + formatAllCommand(): FormatAllCommand { + return new FormatAllCommand({ + pipeline: this.#pipeline, + segmentFormatter: this.#factory.segmentFormatter(), + fluentFormatter: this.#factory.fluentFormatter(), + reporter: new PassReporter(), + syntaxReporter: new SyntaxReporter(), + }); + } + + /** + * Build the standalone blank-lines segment-pass command. + * + * @returns The composed segment {@link FormatPassCommand}. + */ + segmentPassCommand(): FormatPassCommand { + return new FormatPassCommand({ + pipeline: this.#pipeline, + formatter: this.#factory.segmentFormatter(), + reporter: new PassReporter(), + label: 'blank-lines', + failureNoun: 'blank-line edits', + }); + } + + /** + * Build the standalone fluent-chains pass command. + * + * @returns The composed fluent {@link FormatPassCommand}. + */ + fluentPassCommand(): FormatPassCommand { + return new FormatPassCommand({ + pipeline: this.#pipeline, + formatter: this.#factory.fluentFormatter(), + reporter: new PassReporter(), + label: 'fluent-chains', + failureNoun: 'fluent-chain edits', + }); + } + + /** + * Build the standalone syntax-validation command. + * + * @returns The composed {@link ValidateSyntaxCommand}. + */ + validateSyntaxCommand(): ValidateSyntaxCommand { + return new ValidateSyntaxCommand({ + pipeline: this.#pipeline, + reporter: new SyntaxReporter(), + }); + } +} diff --git a/packages/ts/sidecar/src/fluent-chains.test.ts b/packages/ts/sidecar/src/cli/fluent-chains.test.ts similarity index 99% rename from packages/ts/sidecar/src/fluent-chains.test.ts rename to packages/ts/sidecar/src/cli/fluent-chains.test.ts index 8a13f54..7280721 100644 --- a/packages/ts/sidecar/src/fluent-chains.test.ts +++ b/packages/ts/sidecar/src/cli/fluent-chains.test.ts @@ -16,7 +16,7 @@ function format(input: string, virtualName: string): string { } const script = fileURLToPath( - import.meta.resolve('#sidecar/fluent-chains'), + import.meta.resolve('#sidecar/cli/fluent-chains'), ); const tsx = fileURLToPath( import.meta.resolve('tsx'), diff --git a/packages/ts/sidecar/src/cli/fluent-chains.ts b/packages/ts/sidecar/src/cli/fluent-chains.ts new file mode 100644 index 0000000..fd1bf2a --- /dev/null +++ b/packages/ts/sidecar/src/cli/fluent-chains.ts @@ -0,0 +1,20 @@ +import { pathToFileURL } from 'node:url'; +import { CompositionRoot } from '#sidecar/cli/composition-root'; + +/** + * Run the standalone fluent-chain formatter entrypoint. + * + * @returns Nothing after running the command and setting the process status. + */ +async function main(): Promise { + process.exitCode = await CompositionRoot.production() + .fluentPassCommand() + .run(process.argv.slice(2)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/ts/sidecar/src/cli/format-all-cli-dto.ts b/packages/ts/sidecar/src/cli/format-all-cli-dto.ts new file mode 100644 index 0000000..4a03139 --- /dev/null +++ b/packages/ts/sidecar/src/cli/format-all-cli-dto.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; +import { UnexpectedCliArgument } from '#sidecar/kernel/errors'; +import type { FormatMode } from '#sidecar/pipeline/format-pipeline'; +import { err, ok } from '#sidecar/kernel/result'; +import type { Result } from '#sidecar/kernel/result'; + +/** Immutable command-line options for the full formatting pipeline. */ +export class CliOptionsDto { + /** Whether the pipeline checks source or writes changes. */ + readonly mode: FormatMode; + + /** The oxfmt executable, or `null` to skip external formatting. */ + readonly oxfmtBin: string | null; + + /** The oxfmt configuration path, or `null` to use its defaults. */ + readonly oxfmtConfig: string | null; + + /** Files eligible for formatting passes. */ + readonly formatFiles: readonly string[]; + + /** Files eligible for final syntax validation. */ + readonly syntaxFiles: readonly string[]; + + static readonly #argvSchema = z.array(z.string()); + + static readonly #schema = z.object({ + mode: z.enum(['check', 'write']), + oxfmtBin: z.string().nullable(), + oxfmtConfig: z.string().nullable(), + formatFiles: z.array(z.string()), + syntaxFiles: z.array(z.string()), + }); + + private constructor(value: { mode: FormatMode; oxfmtBin: string | null; oxfmtConfig: string | null; formatFiles: string[]; syntaxFiles: string[] }) { + this.mode = value.mode; + this.oxfmtBin = value.oxfmtBin; + this.oxfmtConfig = value.oxfmtConfig; + this.formatFiles = Object.freeze(value.formatFiles); + this.syntaxFiles = Object.freeze(value.syntaxFiles); + + Object.setPrototypeOf(this, Object.prototype); + Object.freeze(this); + } + + /** + * Parse the full-pipeline command line. + * + * @param input - Arguments after the executable and script path. + * @returns Parsed options, or the unexpected argument as a typed value. + */ + static parse(input: unknown): Result { + const argv = CliOptionsDto.#argvSchema.parse(input); + + const candidate = { + mode: 'write' as FormatMode, + oxfmtBin: null as string | null, + oxfmtConfig: null as string | null, + formatFiles: [] as string[], + syntaxFiles: [] as string[], + }; + + let section: 'formatFiles' | 'syntaxFiles' | null = null; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + + if (argument === undefined) { + continue; + } + + if (argument === '--check') { + candidate.mode = 'check'; + section = null; + } else if (argument === '--oxfmt-bin') { + candidate.oxfmtBin = argv[++index] ?? null; + section = null; + } else if (argument === '--oxfmt-config') { + candidate.oxfmtConfig = argv[++index] ?? null; + section = null; + } else if (argument === '--format-files') { + section = 'formatFiles'; + } else if (argument === '--syntax-files') { + section = 'syntaxFiles'; + } else if (section) { + candidate[section].push(argument); + } else { + return err(new UnexpectedCliArgument(argument)); + } + } + + return ok(new CliOptionsDto(CliOptionsDto.#schema.parse(candidate))); + } +} diff --git a/packages/ts/sidecar/src/format-all.test.ts b/packages/ts/sidecar/src/cli/format-all.test.ts similarity index 99% rename from packages/ts/sidecar/src/format-all.test.ts rename to packages/ts/sidecar/src/cli/format-all.test.ts index c3d7759..880c4b4 100644 --- a/packages/ts/sidecar/src/format-all.test.ts +++ b/packages/ts/sidecar/src/cli/format-all.test.ts @@ -5,7 +5,7 @@ import { availableParallelism, tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { test } from 'node:test'; import { promisify } from 'node:util'; -import { CliOptionsDto } from '#sidecar/format-all'; +import { CliOptionsDto } from '#sidecar/cli/format-all-cli-dto'; import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; import { mapPool } from '#sidecar/kernel/concurrency'; import { NodeProcessRunner } from '#sidecar/io/process-runner'; diff --git a/packages/ts/sidecar/src/cli/format-all.ts b/packages/ts/sidecar/src/cli/format-all.ts new file mode 100644 index 0000000..d879dbd --- /dev/null +++ b/packages/ts/sidecar/src/cli/format-all.ts @@ -0,0 +1,106 @@ +import { pathToFileURL } from 'node:url'; +import { CliOptionsDto } from '#sidecar/cli/format-all-cli-dto'; +import type { CliCommand } from '#sidecar/cli/command'; +import { CompositionRoot } from '#sidecar/cli/composition-root'; +import { FileTargets } from '#sidecar/hosts/file-targets'; +import type { FileFormatter } from '#sidecar/pipeline/file-formatter'; +import type { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { isErr } from '#sidecar/kernel/result'; +import type { PassReporter, SyntaxReporter } from '#sidecar/cli/reporter'; + +/** Runs the full formatting schedule: segment, oxfmt, fluent, segment, validate. */ +export class FormatAllCommand implements CliCommand { + readonly #pipeline: FormatPipeline; + readonly #segmentFormatter: FileFormatter; + readonly #fluentFormatter: FileFormatter; + readonly #reporter: PassReporter; + readonly #syntaxReporter: SyntaxReporter; + + /** + * @param dependencies - The pipeline, formatters, and reporters the schedule runs on. + * @param dependencies.pipeline - Runs passes, oxfmt, and validation over files. + * @param dependencies.segmentFormatter - The blank-lines segment formatter. + * @param dependencies.fluentFormatter - The fluent-chains formatter. + * @param dependencies.reporter - Renders formatting-pass reporting lines. + * @param dependencies.syntaxReporter - Renders syntax-validation reporting lines. + */ + constructor(dependencies: { pipeline: FormatPipeline; segmentFormatter: FileFormatter; fluentFormatter: FileFormatter; reporter: PassReporter; syntaxReporter: SyntaxReporter }) { + this.#pipeline = dependencies.pipeline; + this.#segmentFormatter = dependencies.segmentFormatter; + this.#fluentFormatter = dependencies.fluentFormatter; + this.#reporter = dependencies.reporter; + this.#syntaxReporter = dependencies.syntaxReporter; + } + + /** + * Parse the full-pipeline command line and run the ordered formatting schedule. + * + * @param argv - Arguments after the executable and script path. + * @returns `0` when every stage succeeds, `1` at the first reported failure. + */ + async run(argv: readonly string[]): Promise { + const parsed = CliOptionsDto.parse(argv); + + if (isErr(parsed)) { + console.error(parsed.error); + + return 1; + } + + const options = parsed.value; + const formatTargets = [...new Set(options.formatFiles.filter(FileTargets.isTargetFile))]; + const syntaxTargets = [...new Set(options.syntaxFiles.filter(FileTargets.isSyntaxTarget))]; + + const blankLines = await this.#pipeline.runPass(this.#segmentFormatter, formatTargets, options.mode); + + if (!this.#reporter.reportPass('blank-lines', formatTargets, options.mode, blankLines, 'edits')) { + return 1; + } + + const oxfmt = await this.#pipeline.runOxfmt({ bin: options.oxfmtBin, config: options.oxfmtConfig, files: formatTargets, mode: options.mode }); + + if (isErr(oxfmt)) { + console.error(oxfmt.error); + + return 1; + } + + const fluentChains = await this.#pipeline.runPass(this.#fluentFormatter, formatTargets, options.mode); + + if (!this.#reporter.reportPass('fluent-chains', formatTargets, options.mode, fluentChains, 'edits')) { + return 1; + } + + // Fluent and expanded calls create blank-line obligations the first pass + // cannot see, so the second pass makes one invocation reach a fixed point. + const finalBlankLines = await this.#pipeline.runPass(this.#segmentFormatter, formatTargets, options.mode); + + if (!this.#reporter.reportPass('blank-lines', formatTargets, options.mode, finalBlankLines, 'edits')) { + return 1; + } + + if (!this.#syntaxReporter.report(syntaxTargets, await this.#pipeline.validate(syntaxTargets))) { + return 1; + } + + return 0; + } +} + +/** + * Run the full formatting CLI and map its exit code to the process status. + * + * @returns Nothing after running the command and setting the process status. + */ +export async function main(): Promise { + process.exitCode = await CompositionRoot.production() + .formatAllCommand() + .run(process.argv.slice(2)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/ts/sidecar/src/cli/format-pass-command.ts b/packages/ts/sidecar/src/cli/format-pass-command.ts new file mode 100644 index 0000000..685fbab --- /dev/null +++ b/packages/ts/sidecar/src/cli/format-pass-command.ts @@ -0,0 +1,45 @@ +import type { CliCommand } from '#sidecar/cli/command'; +import type { FileFormatter } from '#sidecar/pipeline/file-formatter'; +import type { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { PassCliDto } from '#sidecar/cli/pass-cli-dto'; +import type { PassReporter } from '#sidecar/cli/reporter'; + +/** Runs a single standalone formatting pass over the CLI's target files. */ +export class FormatPassCommand implements CliCommand { + readonly #pipeline: FormatPipeline; + readonly #formatter: FileFormatter; + readonly #reporter: PassReporter; + readonly #label: string; + readonly #failureNoun: string; + + /** + * @param dependencies - The pipeline, formatter, reporter, and labels for the pass. + * @param dependencies.pipeline - Applies the formatter across files concurrently. + * @param dependencies.formatter - The file formatter whose pipeline drives the pass. + * @param dependencies.reporter - Renders per-file and summary reporting lines. + * @param dependencies.label - The reporting label the pass emits. + * @param dependencies.failureNoun - The change description used in check-mode guidance. + */ + constructor(dependencies: { pipeline: FormatPipeline; formatter: FileFormatter; reporter: PassReporter; label: string; failureNoun: string }) { + this.#pipeline = dependencies.pipeline; + this.#formatter = dependencies.formatter; + this.#reporter = dependencies.reporter; + this.#label = dependencies.label; + this.#failureNoun = dependencies.failureNoun; + } + + /** + * Parse the pass command line, run the pass, and report its outcomes. + * + * @param argv - Arguments after the executable and script path. + * @returns `0` when the pass succeeds, `1` when it reports a failure. + */ + async run(argv: readonly string[]): Promise { + const options = PassCliDto.parse(argv); + const files = [...options.files]; + + const outcomes = await this.#pipeline.runPass(this.#formatter, files, options.mode); + + return this.#reporter.reportPass(this.#label, files, options.mode, outcomes, this.#failureNoun) ? 0 : 1; + } +} diff --git a/packages/ts/sidecar/src/pass-cli-dto.ts b/packages/ts/sidecar/src/cli/pass-cli-dto.ts similarity index 100% rename from packages/ts/sidecar/src/pass-cli-dto.ts rename to packages/ts/sidecar/src/cli/pass-cli-dto.ts diff --git a/packages/ts/sidecar/src/cli/reporter.ts b/packages/ts/sidecar/src/cli/reporter.ts new file mode 100644 index 0000000..4ba717a --- /dev/null +++ b/packages/ts/sidecar/src/cli/reporter.ts @@ -0,0 +1,110 @@ +import type { OxcErrorDto } from '#sidecar/kernel/errors'; +import type { FormatMode, PassOutcome, ValidationFailure } from '#sidecar/pipeline/format-pipeline'; + +/** Reports formatting-pass values to the console without coupling passes to it. */ +export class PassReporter { + /** + * Report one formatting pass and decide whether execution may continue. + * + * @param label - The formatting pass label. + * @param files - The source paths requested for the pass. + * @param mode - Whether the pass checked or wrote source. + * @param outcomes - The ordered outcomes produced by the pass. + * @param failureNoun - The change description used in check-mode guidance. + * @returns `true` when no outcome or pending change makes the pass fail. + */ + reportPass(label: string, files: readonly string[], mode: FormatMode, outcomes: PassOutcome[], failureNoun: string): boolean { + let changedCount = 0; + + for (const outcome of outcomes) { + if (outcome.error?._tag === 'SourceFileUnreadable' && outcome.error.isNotFound()) { + console.warn(`[${label}] path not found, skipping: ${outcome.file}`); + + continue; + } + + if (outcome.error) { + console.error(outcome.error); + + return false; + } + + if (outcome.changed) { + changedCount++; + console.log(`[${label}] ${mode === 'check' ? 'would change' : 'updated'} ${outcome.file}`); + } + } + + if (mode === 'check' && changedCount > 0) { + console.error(`[${label}] ${changedCount} file(s) need ${failureNoun}. Run "pnpm format" to fix.`); + + return false; + } + + console.log(`[${label}] processed ${files.length} file(s) in ${process.cwd()}, ${changedCount} ${mode === 'check' ? 'would change' : 'changed'}`); + + return true; + } +} + +/** Reports syntax-validation values to the console without coupling validation to it. */ +export class SyntaxReporter { + /** + * Format one parser diagnostic for console output. + * + * @param file - The source path associated with the diagnostic. + * @param error - The parser diagnostic to render. + * @returns A source-framed message, plain message, or stable fallback. + */ + format(file: string, error: OxcErrorDto): string { + if (error.codeframe && error.codeframe.length > 0) { + return `[validate-syntax] ${file}\n${error.codeframe.trimEnd()}`; + } + + if (error.message && error.message.length > 0) { + return `[validate-syntax] ${file}: ${error.message}`; + } + + return `[validate-syntax] ${file}: syntax validation failed`; + } + + /** + * Report syntax-validation failures and decide whether execution succeeded. + * + * @param files - The source paths requested for validation. + * @param failures - The ordered read and parse failures. + * @returns `true` when no reportable validation failure remains. + */ + report(files: readonly string[], failures: ValidationFailure[]): boolean { + const diagnostics: string[] = []; + + for (const failure of failures) { + if (failure.error._tag === 'SourceFileUnreadable') { + if (failure.error.isNotFound()) { + console.warn(`[validate-syntax] path not found, skipping: ${failure.file}`); + + continue; + } + + console.error(failure.error); + + return false; + } + + for (const error of failure.error.errors) { + diagnostics.push(this.format(failure.file, error)); + } + } + + if (diagnostics.length > 0) { + console.error(diagnostics.join('\n')); + console.error(`[validate-syntax] ${diagnostics.length} syntax error(s) found after formatting.`); + + return false; + } + + console.log(`[validate-syntax] checked ${files.length} file(s) in ${process.cwd()}`); + + return true; + } +} diff --git a/packages/ts/sidecar/src/cli/syntax-cli-dto.ts b/packages/ts/sidecar/src/cli/syntax-cli-dto.ts new file mode 100644 index 0000000..cff3b6c --- /dev/null +++ b/packages/ts/sidecar/src/cli/syntax-cli-dto.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +/** Immutable command-line options for standalone syntax validation. */ +export class SyntaxCliDto { + /** TypeScript and Vue files eligible for syntax validation. */ + readonly files: readonly string[]; + + static readonly #argvSchema = z.array(z.string()); + + static readonly #schema = z.object({ + files: z.array(z.string()), + }); + + private constructor(value: { files: string[] }) { + this.files = Object.freeze(value.files); + + Object.setPrototypeOf(this, Object.prototype); + Object.freeze(this); + } + + /** + * Parse the standalone syntax-validation command line. + * + * @param input - Arguments after the executable and script path. + * @returns Immutable syntax-validation options. + */ + static parse(input: unknown): SyntaxCliDto { + const argv = SyntaxCliDto.#argvSchema.parse(input); + + const files = argv.filter((file) => { + return file.endsWith('.ts') || file.endsWith('.vue'); + }); + + return new SyntaxCliDto(SyntaxCliDto.#schema.parse({ files })); + } +} diff --git a/packages/ts/sidecar/src/cli/validate-syntax-command.ts b/packages/ts/sidecar/src/cli/validate-syntax-command.ts new file mode 100644 index 0000000..687174b --- /dev/null +++ b/packages/ts/sidecar/src/cli/validate-syntax-command.ts @@ -0,0 +1,35 @@ +import type { CliCommand } from '#sidecar/cli/command'; +import type { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { SyntaxCliDto } from '#sidecar/cli/syntax-cli-dto'; +import type { SyntaxReporter } from '#sidecar/cli/reporter'; + +/** Runs standalone syntax validation over the CLI's target files. */ +export class ValidateSyntaxCommand implements CliCommand { + readonly #pipeline: FormatPipeline; + readonly #reporter: SyntaxReporter; + + /** + * @param dependencies - The pipeline and reporter used to validate and report. + * @param dependencies.pipeline - Validates files and host embedded blocks. + * @param dependencies.reporter - Renders parser diagnostics and summary lines. + */ + constructor(dependencies: { pipeline: FormatPipeline; reporter: SyntaxReporter }) { + this.#pipeline = dependencies.pipeline; + this.#reporter = dependencies.reporter; + } + + /** + * Parse the validation command line, validate, and report the failures. + * + * @param argv - Arguments after the executable and script path. + * @returns `0` when validation succeeds, `1` when it reports a failure. + */ + async run(argv: readonly string[]): Promise { + const options = SyntaxCliDto.parse(argv); + const files = [...options.files]; + + const failures = await this.#pipeline.validate(files); + + return this.#reporter.report(files, failures) ? 0 : 1; + } +} diff --git a/packages/ts/sidecar/src/validate-syntax.test.ts b/packages/ts/sidecar/src/cli/validate-syntax.test.ts similarity index 98% rename from packages/ts/sidecar/src/validate-syntax.test.ts rename to packages/ts/sidecar/src/cli/validate-syntax.test.ts index 3649203..af1aec1 100644 --- a/packages/ts/sidecar/src/validate-syntax.test.ts +++ b/packages/ts/sidecar/src/cli/validate-syntax.test.ts @@ -7,7 +7,7 @@ import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; const script = fileURLToPath( - import.meta.resolve('#sidecar/validate-syntax'), + import.meta.resolve('#sidecar/cli/validate-syntax'), ); const tsx = fileURLToPath( import.meta.resolve('tsx'), diff --git a/packages/ts/sidecar/src/cli/validate-syntax.ts b/packages/ts/sidecar/src/cli/validate-syntax.ts new file mode 100644 index 0000000..57efd2a --- /dev/null +++ b/packages/ts/sidecar/src/cli/validate-syntax.ts @@ -0,0 +1,20 @@ +import { pathToFileURL } from 'node:url'; +import { CompositionRoot } from '#sidecar/cli/composition-root'; + +/** + * Run the standalone syntax-validation entrypoint. + * + * @returns Nothing after running the command and setting the process status. + */ +async function main(): Promise { + process.exitCode = await CompositionRoot.production() + .validateSyntaxCommand() + .run(process.argv.slice(2)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/packages/ts/sidecar/src/fluent-chains.ts b/packages/ts/sidecar/src/fluent-chains.ts deleted file mode 100644 index 5db4576..0000000 --- a/packages/ts/sidecar/src/fluent-chains.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { pathToFileURL } from 'node:url'; -import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/io/process-runner'; -import { NodeSourceFiles } from '#sidecar/io/source-files'; -import { PassCliDto } from '#sidecar/pass-cli-dto'; -import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; -import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; - -/** - * Run the standalone fluent-chain formatter entrypoint. - * - * @returns Nothing after reporting outcomes and setting the process status. - */ -async function main(): Promise { - const cwd = process.cwd(); - const options = PassCliDto.parse(process.argv.slice(2)); - const files = [...options.files]; - const { mode } = options; - const factory = PipelineFactory.create(); - const sourceFiles = new NodeSourceFiles(); - - const pipeline = new FormatPipeline({ - editor: new SourceFileEditor({ sourceFiles }), - processRunner: new NodeProcessRunner(), - validator: factory.syntaxValidator(sourceFiles), - }); - - const outcomes = await pipeline.runPass(factory.fluentFormatter(), files, mode); - - const changedCount = outcomes.filter((outcome) => { - if (outcome.error?._tag === 'SourceFileUnreadable' && outcome.error.isNotFound()) { - console.warn(`[fluent-chains] path not found, skipping: ${outcome.file}`); - } else if (outcome.error) { - throw outcome.error; - } else if (outcome.changed) { - console.log(`[fluent-chains] ${mode === 'check' ? 'would change' : 'updated'} ${outcome.file}`); - } - - return outcome.changed; - }).length; - - if (mode === 'check' && changedCount > 0) { - console.error(`[fluent-chains] ${changedCount} file(s) need fluent-chain edits. Run "pnpm format" to fix.`); - process.exit(1); - } - - console.log(`[fluent-chains] processed ${files.length} file(s) in ${cwd}, ${changedCount} ${mode === 'check' ? 'would change' : 'changed'}`); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((err: unknown) => { - console.error(err); - process.exit(1); - }); -} diff --git a/packages/ts/sidecar/src/format-all.ts b/packages/ts/sidecar/src/format-all.ts deleted file mode 100644 index 4581fe8..0000000 --- a/packages/ts/sidecar/src/format-all.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { pathToFileURL } from 'node:url'; -import { z } from 'zod'; -import { UnexpectedCliArgument } from '#sidecar/kernel/errors'; -import type { OxcErrorDto } from '#sidecar/kernel/errors'; -import { FileTargets } from '#sidecar/hosts/file-targets'; -import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; -import type { FormatMode, PassOutcome, ValidationFailure } from '#sidecar/pipeline/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/io/process-runner'; -import { err, isErr, ok } from '#sidecar/kernel/result'; -import type { Result } from '#sidecar/kernel/result'; -import { NodeSourceFiles } from '#sidecar/io/source-files'; -import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; -import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; - -/** Immutable command-line options for the full formatting pipeline. */ -export class CliOptionsDto { - /** Whether the pipeline checks source or writes changes. */ - readonly mode: FormatMode; - - /** The oxfmt executable, or `null` to skip external formatting. */ - readonly oxfmtBin: string | null; - - /** The oxfmt configuration path, or `null` to use its defaults. */ - readonly oxfmtConfig: string | null; - - /** Files eligible for formatting passes. */ - readonly formatFiles: readonly string[]; - - /** Files eligible for final syntax validation. */ - readonly syntaxFiles: readonly string[]; - - static readonly #argvSchema = z.array(z.string()); - - static readonly #schema = z.object({ - mode: z.enum(['check', 'write']), - oxfmtBin: z.string().nullable(), - oxfmtConfig: z.string().nullable(), - formatFiles: z.array(z.string()), - syntaxFiles: z.array(z.string()), - }); - - private constructor(value: { mode: FormatMode; oxfmtBin: string | null; oxfmtConfig: string | null; formatFiles: string[]; syntaxFiles: string[] }) { - this.mode = value.mode; - this.oxfmtBin = value.oxfmtBin; - this.oxfmtConfig = value.oxfmtConfig; - this.formatFiles = Object.freeze(value.formatFiles); - this.syntaxFiles = Object.freeze(value.syntaxFiles); - - Object.setPrototypeOf(this, Object.prototype); - Object.freeze(this); - } - - /** - * Parse the full-pipeline command line. - * - * @param input - Arguments after the executable and script path. - * @returns Parsed options, or the unexpected argument as a typed value. - */ - static parse(input: unknown): Result { - const argv = CliOptionsDto.#argvSchema.parse(input); - - const candidate = { - mode: 'write' as FormatMode, - oxfmtBin: null as string | null, - oxfmtConfig: null as string | null, - formatFiles: [] as string[], - syntaxFiles: [] as string[], - }; - - let section: 'formatFiles' | 'syntaxFiles' | null = null; - - for (let index = 0; index < argv.length; index++) { - const argument = argv[index]; - - if (argument === undefined) { - continue; - } - - if (argument === '--check') { - candidate.mode = 'check'; - section = null; - } else if (argument === '--oxfmt-bin') { - candidate.oxfmtBin = argv[++index] ?? null; - section = null; - } else if (argument === '--oxfmt-config') { - candidate.oxfmtConfig = argv[++index] ?? null; - section = null; - } else if (argument === '--format-files') { - section = 'formatFiles'; - } else if (argument === '--syntax-files') { - section = 'syntaxFiles'; - } else if (section) { - candidate[section].push(argument); - } else { - return err(new UnexpectedCliArgument(argument)); - } - } - - return ok(new CliOptionsDto(CliOptionsDto.#schema.parse(candidate))); - } -} - -/** Reports pipeline values without coupling formatting passes to the console. */ -class FormatAllReporter { - /** - * Report one formatting pass and decide whether execution may continue. - * - * @param label - The formatting pass label. - * @param files - The source paths requested for the pass. - * @param mode - Whether the pass checked or wrote source. - * @param outcomes - The ordered outcomes produced by the pass. - * @param failureNoun - The change description used in check-mode guidance. - * @returns `true` when no outcome or pending change makes the pass fail. - */ - static reportPass(label: string, files: readonly string[], mode: FormatMode, outcomes: PassOutcome[], failureNoun: string): boolean { - let changedCount = 0; - - for (const outcome of outcomes) { - if (outcome.error?._tag === 'SourceFileUnreadable' && outcome.error.isNotFound()) { - console.warn(`[${label}] path not found, skipping: ${outcome.file}`); - continue; - } - - if (outcome.error) { - console.error(outcome.error); - - return false; - } - - if (outcome.changed) { - changedCount++; - console.log(`[${label}] ${mode === 'check' ? 'would change' : 'updated'} ${outcome.file}`); - } - } - - if (mode === 'check' && changedCount > 0) { - console.error(`[${label}] ${changedCount} file(s) need ${failureNoun}. Run "pnpm format" to fix.`); - - return false; - } - - console.log(`[${label}] processed ${files.length} file(s) in ${process.cwd()}, ${changedCount} ${mode === 'check' ? 'would change' : 'changed'}`); - - return true; - } - - /** - * Format one parser diagnostic for console output. - * - * @param file - The source path associated with the diagnostic. - * @param error - The parser diagnostic to render. - * @returns A source-framed message, plain message, or stable fallback. - */ - static formatError(file: string, error: OxcErrorDto): string { - if (error.codeframe && error.codeframe.length > 0) { - return `[validate-syntax] ${file}\n${error.codeframe.trimEnd()}`; - } - - if (error.message && error.message.length > 0) { - return `[validate-syntax] ${file}: ${error.message}`; - } - - return `[validate-syntax] ${file}: syntax validation failed`; - } - - /** - * Report syntax-validation failures and decide whether execution succeeded. - * - * @param files - The source paths requested for validation. - * @param failures - The ordered read and parse failures. - * @returns `true` when no reportable validation failure remains. - */ - static reportValidation(files: readonly string[], failures: ValidationFailure[]): boolean { - const diagnostics: string[] = []; - - for (const failure of failures) { - if (failure.error._tag === 'SourceFileUnreadable') { - if (failure.error.isNotFound()) { - console.warn(`[validate-syntax] path not found, skipping: ${failure.file}`); - continue; - } - - console.error(failure.error); - - return false; - } - - for (const error of failure.error.errors) { - diagnostics.push(FormatAllReporter.formatError(failure.file, error)); - } - } - - if (diagnostics.length > 0) { - console.error(diagnostics.join('\n')); - console.error(`[validate-syntax] ${diagnostics.length} syntax error(s) found after formatting.`); - - return false; - } - - console.log(`[validate-syntax] checked ${files.length} file(s) in ${process.cwd()}`); - - return true; - } -} - -/** - * Run the full formatting CLI and map outcome values to console output and status. - * - * @returns Nothing after reporting outcomes and setting the process status. - */ -export async function main(): Promise { - const parsed = CliOptionsDto.parse(process.argv.slice(2)); - - if (isErr(parsed)) { - console.error(parsed.error); - process.exitCode = 1; - - return; - } - - const options = parsed.value; - const formatTargets = [...new Set(options.formatFiles.filter(FileTargets.isTargetFile))]; - const syntaxTargets = [...new Set(options.syntaxFiles.filter(FileTargets.isSyntaxTarget))]; - const factory = PipelineFactory.create(); - const sourceFiles = new NodeSourceFiles(); - - const pipeline = new FormatPipeline({ - editor: new SourceFileEditor({ sourceFiles }), - processRunner: new NodeProcessRunner(), - validator: factory.syntaxValidator(sourceFiles), - }); - - const segmentFormatter = factory.segmentFormatter(); - - const blankLines = await pipeline.runPass(segmentFormatter, formatTargets, options.mode); - - if (!FormatAllReporter.reportPass('blank-lines', formatTargets, options.mode, blankLines, 'edits')) { - process.exitCode = 1; - - return; - } - - const oxfmt = await pipeline.runOxfmt({ bin: options.oxfmtBin, config: options.oxfmtConfig, files: formatTargets, mode: options.mode }); - - if (isErr(oxfmt)) { - console.error(oxfmt.error); - process.exitCode = 1; - - return; - } - - const fluentChains = await pipeline.runPass(factory.fluentFormatter(), formatTargets, options.mode); - - if (!FormatAllReporter.reportPass('fluent-chains', formatTargets, options.mode, fluentChains, 'edits')) { - process.exitCode = 1; - - return; - } - - // Fluent and expanded calls create blank-line obligations the first pass - // cannot see, so the second pass makes one invocation reach a fixed point. - const finalBlankLines = await pipeline.runPass(segmentFormatter, formatTargets, options.mode); - - if (!FormatAllReporter.reportPass('blank-lines', formatTargets, options.mode, finalBlankLines, 'edits')) { - process.exitCode = 1; - - return; - } - - if (!FormatAllReporter.reportValidation(syntaxTargets, await pipeline.validate(syntaxTargets))) { - process.exitCode = 1; - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; - }); -} diff --git a/packages/ts/sidecar/src/io/files.test.ts b/packages/ts/sidecar/src/io/files.test.ts deleted file mode 100644 index 715a5f5..0000000 --- a/packages/ts/sidecar/src/io/files.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { Files } from '#sidecar/io/files'; -import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/io/process-runner'; -import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; -import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; -import { NodeSourceFiles } from '#sidecar/io/source-files'; - -const factory = PipelineFactory.create(); -const sourceFiles = new NodeSourceFiles(); - -const pipeline = new FormatPipeline({ - editor: new SourceFileEditor({ sourceFiles }), - processRunner: new NodeProcessRunner(), - validator: factory.syntaxValidator(sourceFiles), -}); - -const segmentFormatter = factory.segmentFormatter(); - -async function processFile(file: string, mode: 'check' | 'write'): Promise { - const [outcome] = await pipeline.runPass(segmentFormatter, [file], mode); - - assert.equal(outcome?.error, null); - - return outcome?.changed ?? false; -} - -async function withTempDir(fn: (dir: string) => Promise): Promise { - const dir = await mkdtemp( - join( - tmpdir(), - 'fmtkit-sidecar-files-', - ), - ); - - try { - await fn(dir); - } finally { - await rm( - dir, - { recursive: true, force: true }, - ); - } -} - -test('dirExists reports existing directories and missing paths', async () => { - await withTempDir(async (dir) => { - assert.equal(await Files.dirExists(dir), true); - - assert.equal(await Files.dirExists(join(dir, 'missing')), false); - }); -}); - -test('listSourceFiles returns TypeScript and Vue files only', async () => { - await withTempDir(async (dir) => { - await writeFile( - join(dir, 'component.vue'), - '\n', - ); - - await writeFile( - join(dir, 'source.ts'), - 'const value = 1;\n', - ); - - await writeFile( - join(dir, 'notes.md'), - '# Notes\n', - ); - - const files = (await Files.listSourceFiles(dir)).map((file) => { - return file.slice(dir.length + 1); - }); - - assert.deepEqual(files.sort(), ['component.vue', 'source.ts']); - }); -}); - -test('processFile reports check changes without writing TypeScript files', async () => { - await withTempDir(async (dir) => { - const file = join(dir, 'source.ts'); - const original = ['function run() {', '\tconst value = 1;', '\tif (value) return value;', '}', ''].join('\n'); - - await writeFile(file, original); - - assert.equal(await processFile(file, 'check'), true); - - assert.equal(await readFile(file, 'utf8'), original); - }); -}); - -test('processFile leaves non-JS/TS Vue script blocks untouched', async () => { - await withTempDir(async (dir) => { - const file = join(dir, 'component.vue'); - const yamlBlock = [''].join('\n'); - const tsBlock = [''].join('\n'); - - await writeFile(file, `${yamlBlock}\n${tsBlock}\n`); - - assert.equal(await processFile(file, 'write'), true); - - const updated = await readFile(file, 'utf8'); - - assert.ok(updated.startsWith(yamlBlock), 'yaml script block must not be reformatted'); - - assert.match(updated, /if \(value\) \{\n\tconsole\.log\(value\);\n\}/); - }); -}); - -test('processFile rewrites Vue script blocks and reports unchanged files', async () => { - await withTempDir(async (dir) => { - const file = join(dir, 'component.vue'); - - await writeFile( - file, - ['', ''].join('\n'), - ); - - assert.equal(await processFile(file, 'write'), true); - - const updated = await readFile(file, 'utf8'); - - assert.match(updated, /if \(value\) \{\n\tconsole\.log\(value\);\n\}/); - - assert.equal(await processFile(file, 'check'), false); - }); -}); diff --git a/packages/ts/sidecar/src/io/files.ts b/packages/ts/sidecar/src/io/files.ts deleted file mode 100644 index c678efe..0000000 --- a/packages/ts/sidecar/src/io/files.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { readdir, stat } from 'node:fs/promises'; -import { resolve } from 'node:path'; - -/** Reads source-file inventories from the local filesystem. */ -export class Files { - /** - * Report whether a path exists as a directory. - * - * @param directory - The path to inspect. - * @returns `true` when the path identifies an existing directory. - */ - static async dirExists(directory: string): Promise { - try { - return (await stat(directory)).isDirectory(); - } catch { - return false; - } - } - - /** - * List TypeScript and Vue files below a directory recursively. - * - * @param directory - The directory tree to scan. - * @returns Absolute paths to the discovered TypeScript and Vue files. - */ - static async listSourceFiles(directory: string): Promise { - const entries = await readdir( - directory, - { recursive: true, withFileTypes: true }, - ); - - const files: string[] = []; - - for (const entry of entries) { - if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.vue'))) { - files.push(resolve(entry.parentPath, entry.name)); - } - } - - return files; - } -} diff --git a/packages/ts/sidecar/src/sidecar.ts b/packages/ts/sidecar/src/sidecar.ts index e566f54..4d26099 100644 --- a/packages/ts/sidecar/src/sidecar.ts +++ b/packages/ts/sidecar/src/sidecar.ts @@ -76,7 +76,7 @@ switch (mode) { // import; blank argv[1] so only the explicit main() call below runs. process.argv[1] = ''; - const { main } = await import('#sidecar/format-all'); + const { main } = await import('#sidecar/cli/format-all'); await main(); diff --git a/packages/ts/sidecar/src/validate-syntax.ts b/packages/ts/sidecar/src/validate-syntax.ts deleted file mode 100644 index c79aa86..0000000 --- a/packages/ts/sidecar/src/validate-syntax.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { pathToFileURL } from 'node:url'; -import { z } from 'zod'; -import type { OxcErrorDto } from '#sidecar/kernel/errors'; -import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; -import { NodeProcessRunner } from '#sidecar/io/process-runner'; -import { NodeSourceFiles } from '#sidecar/io/source-files'; -import { PipelineFactory } from '#sidecar/pipeline/pipeline-factory'; -import { SourceFileEditor } from '#sidecar/pipeline/source-file-editor'; - -/** Immutable command-line options for standalone syntax validation. */ -export class SyntaxCliDto { - /** TypeScript and Vue files eligible for syntax validation. */ - readonly files: readonly string[]; - - static readonly #argvSchema = z.array(z.string()); - - static readonly #schema = z.object({ - files: z.array(z.string()), - }); - - private constructor(value: { files: string[] }) { - this.files = Object.freeze(value.files); - - Object.setPrototypeOf(this, Object.prototype); - Object.freeze(this); - } - - /** - * Parse the standalone syntax-validation command line. - * - * @param input - Arguments after the executable and script path. - * @returns Immutable syntax-validation options. - */ - static parse(input: unknown): SyntaxCliDto { - const argv = SyntaxCliDto.#argvSchema.parse(input); - - const files = argv.filter((file) => { - return file.endsWith('.ts') || file.endsWith('.vue'); - }); - - return new SyntaxCliDto(SyntaxCliDto.#schema.parse({ files })); - } -} - -/** Formats parser diagnostics for the standalone syntax-validation command. */ -class SyntaxErrorReporter { - /** - * Format one parser diagnostic for console output. - * - * @param file - The source path associated with the diagnostic. - * @param error - The parser diagnostic to render. - * @returns A source-framed message, plain message, or stable fallback. - */ - static format(file: string, error: OxcErrorDto): string { - if (error.codeframe && error.codeframe.length > 0) { - return `[validate-syntax] ${file}\n${error.codeframe.trimEnd()}`; - } - - if (error.message && error.message.length > 0) { - return `[validate-syntax] ${file}: ${error.message}`; - } - - return `[validate-syntax] ${file}: syntax validation failed`; - } -} - -async function main(): Promise { - const cwd = process.cwd(); - const options = SyntaxCliDto.parse(process.argv.slice(2)); - const files = [...options.files]; - const factory = PipelineFactory.create(); - const sourceFiles = new NodeSourceFiles(); - - const pipeline = new FormatPipeline({ - editor: new SourceFileEditor({ sourceFiles }), - processRunner: new NodeProcessRunner(), - validator: factory.syntaxValidator(sourceFiles), - }); - - const failures = await pipeline.validate(files); - - const diagnostics: string[] = []; - - for (const failure of failures) { - if (failure.error._tag === 'SourceFileUnreadable') { - if (failure.error.isNotFound()) { - console.warn(`[validate-syntax] path not found, skipping: ${failure.file}`); - - continue; - } - - console.error(failure.error); - process.exitCode = 1; - - return; - } - - for (const error of failure.error.errors) { - diagnostics.push(SyntaxErrorReporter.format(failure.file, error)); - } - } - - if (diagnostics.length > 0) { - console.error(diagnostics.join('\n')); - console.error(`[validate-syntax] ${diagnostics.length} syntax error(s) found after formatting.`); - process.exitCode = 1; - - return; - } - - console.log(`[validate-syntax] checked ${files.length} file(s) in ${cwd}`); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; - }); -} From ca8a64310b55027801319af4857eeea61a496a14 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 24 Jul 2026 11:27:46 +0800 Subject: [PATCH 08/22] =?UTF-8?q?refactor(ts):=20TS-7=20=E2=80=94=20final?= =?UTF-8?q?=20sweep:=20cycle-free=20proof,=20static=20audit,=20cleanups=20?= =?UTF-8?q?(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ts): break the cli format-all <-> composition-root import cycle Extract FormatAllCommand into cli/format-all-command.ts (importing only its direct deps), matching format-pass-command.ts/validate-syntax-command.ts. cli/format-all.ts is now a pure entry (main + run-as-main guard importing only CompositionRoot); composition-root.ts imports the command module. sidecar.ts still imports #sidecar/cli/format-all. * refactor(ts): convert host scanners and file-target policy to injected instances Removes the last static-namespace classes in the sidecar. VueScript and MarkdownFences become instances injected into EmbeddedBlockSplitter. The static FileTargets class (and its module-private EmbeddedBlockSplitter singleton, the TS-3 compromise) becomes FileTargetPolicy, an instance holding its splitter, constructed once in PipelineFactory and injected into the declaration-aware passes (ExpandedCallPass, DrizzleQueryPass) and the CLI commands. PassCliDto.parse now takes the policy as an explicit argument. * refactor(ts): drop the ParsedSourceDto.from double-cast Widen the failure branch of ParsedSourceDto.from to carry an unparameterised z.ZodError. The schema output omits the DTO's own methods, so the previous `parsed.error as unknown as z.ZodError` bridged the phantom generic; since the only caller (SourceParser.parse) discards the typed payload and raises a fresh SourceUnparsable, the unparameterised error is exact and the double cast is gone. * refactor(ts): sweep stale class-name references and type-only imports Reword two test comments that named the pre-refactor BlankLines.insert and FluentChains.format classes to describe the current BlankLinePass reference behaviour and the fluent pipeline order. Make EmbeddedBlockSplitter's scanner imports type-only now that they are used solely as constructor parameter types. * style(ts): apply the sidecar formatter to the TS-7 changes Runs `make format-all` (the repo's own pipeline) over the stage's edits: the new FileTargetPolicy parameter pushes several constructor signatures past the width threshold, so the formatter expands their inline dependency-object types to one member per line and splits the delegated extractBlocks chains. Pure whitespace normalisation, no behaviour change. --- .../ts/sidecar/src/cli/composition-root.ts | 5 +- .../ts/sidecar/src/cli/format-all-command.ts | 96 +++++++++++++++++++ packages/ts/sidecar/src/cli/format-all.ts | 86 ----------------- .../ts/sidecar/src/cli/format-pass-command.ts | 8 +- packages/ts/sidecar/src/cli/pass-cli-dto.ts | 7 +- .../src/hosts/embedded-block-splitter.test.ts | 4 +- .../src/hosts/embedded-block-splitter.ts | 31 ++++-- .../src/hosts/file-target-policy.test.ts | 48 ++++++++++ .../sidecar/src/hosts/file-target-policy.ts | 44 +++++++++ .../ts/sidecar/src/hosts/file-targets.test.ts | 41 -------- packages/ts/sidecar/src/hosts/file-targets.ts | 36 ------- .../hosts/markdown-fences.property.test.ts | 6 +- .../sidecar/src/hosts/markdown-fences.test.ts | 52 +++++----- .../ts/sidecar/src/hosts/markdown-fences.ts | 24 ++--- .../src/hosts/vue-script.property.test.ts | 10 +- .../ts/sidecar/src/hosts/vue-script.test.ts | 28 +++--- packages/ts/sidecar/src/hosts/vue-script.ts | 10 +- .../src/passes/blank-line-pass.test.ts | 6 +- .../passes/drizzle/drizzle-query-pass.test.ts | 12 ++- .../src/passes/drizzle/drizzle-query-pass.ts | 17 +++- .../src/passes/expanded-call-pass.test.ts | 7 +- .../sidecar/src/passes/expanded-call-pass.ts | 9 +- .../sidecar/src/pipeline/pipeline-factory.ts | 32 ++++++- packages/ts/sidecar/src/syntax/node-schema.ts | 10 +- 24 files changed, 370 insertions(+), 259 deletions(-) create mode 100644 packages/ts/sidecar/src/cli/format-all-command.ts create mode 100644 packages/ts/sidecar/src/hosts/file-target-policy.test.ts create mode 100644 packages/ts/sidecar/src/hosts/file-target-policy.ts delete mode 100644 packages/ts/sidecar/src/hosts/file-targets.test.ts delete mode 100644 packages/ts/sidecar/src/hosts/file-targets.ts diff --git a/packages/ts/sidecar/src/cli/composition-root.ts b/packages/ts/sidecar/src/cli/composition-root.ts index 21c1277..af7b0d7 100644 --- a/packages/ts/sidecar/src/cli/composition-root.ts +++ b/packages/ts/sidecar/src/cli/composition-root.ts @@ -1,4 +1,4 @@ -import { FormatAllCommand } from '#sidecar/cli/format-all'; +import { FormatAllCommand } from '#sidecar/cli/format-all-command'; import { FormatPassCommand } from '#sidecar/cli/format-pass-command'; import { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; import { NodeProcessRunner } from '#sidecar/io/process-runner'; @@ -57,6 +57,7 @@ export class CompositionRoot { fluentFormatter: this.#factory.fluentFormatter(), reporter: new PassReporter(), syntaxReporter: new SyntaxReporter(), + targets: this.#factory.fileTargetPolicy(), }); } @@ -70,6 +71,7 @@ export class CompositionRoot { pipeline: this.#pipeline, formatter: this.#factory.segmentFormatter(), reporter: new PassReporter(), + targets: this.#factory.fileTargetPolicy(), label: 'blank-lines', failureNoun: 'blank-line edits', }); @@ -85,6 +87,7 @@ export class CompositionRoot { pipeline: this.#pipeline, formatter: this.#factory.fluentFormatter(), reporter: new PassReporter(), + targets: this.#factory.fileTargetPolicy(), label: 'fluent-chains', failureNoun: 'fluent-chain edits', }); diff --git a/packages/ts/sidecar/src/cli/format-all-command.ts b/packages/ts/sidecar/src/cli/format-all-command.ts new file mode 100644 index 0000000..da9b69d --- /dev/null +++ b/packages/ts/sidecar/src/cli/format-all-command.ts @@ -0,0 +1,96 @@ +import { CliOptionsDto } from '#sidecar/cli/format-all-cli-dto'; +import type { CliCommand } from '#sidecar/cli/command'; +import type { FileFormatter } from '#sidecar/pipeline/file-formatter'; +import type { FileTargetPolicy } from '#sidecar/hosts/file-target-policy'; +import type { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; +import { isErr } from '#sidecar/kernel/result'; +import type { PassReporter, SyntaxReporter } from '#sidecar/cli/reporter'; + +/** Runs the full formatting schedule: segment, oxfmt, fluent, segment, validate. */ +export class FormatAllCommand implements CliCommand { + readonly #pipeline: FormatPipeline; + readonly #segmentFormatter: FileFormatter; + readonly #fluentFormatter: FileFormatter; + readonly #reporter: PassReporter; + readonly #syntaxReporter: SyntaxReporter; + readonly #targets: FileTargetPolicy; + + /** + * @param dependencies - The pipeline, formatters, and reporters the schedule runs on. + * @param dependencies.pipeline - Runs passes, oxfmt, and validation over files. + * @param dependencies.segmentFormatter - The blank-lines segment formatter. + * @param dependencies.fluentFormatter - The fluent-chains formatter. + * @param dependencies.reporter - Renders formatting-pass reporting lines. + * @param dependencies.syntaxReporter - Renders syntax-validation reporting lines. + * @param dependencies.targets - Classifies the format and syntax target files. + */ + constructor(dependencies: { + pipeline: FormatPipeline; + segmentFormatter: FileFormatter; + fluentFormatter: FileFormatter; + reporter: PassReporter; + syntaxReporter: SyntaxReporter; + targets: FileTargetPolicy; + }) { + this.#pipeline = dependencies.pipeline; + this.#segmentFormatter = dependencies.segmentFormatter; + this.#fluentFormatter = dependencies.fluentFormatter; + this.#reporter = dependencies.reporter; + this.#syntaxReporter = dependencies.syntaxReporter; + this.#targets = dependencies.targets; + } + + /** + * Parse the full-pipeline command line and run the ordered formatting schedule. + * + * @param argv - Arguments after the executable and script path. + * @returns `0` when every stage succeeds, `1` at the first reported failure. + */ + async run(argv: readonly string[]): Promise { + const parsed = CliOptionsDto.parse(argv); + + if (isErr(parsed)) { + console.error(parsed.error); + + return 1; + } + + const options = parsed.value; + const formatTargets = [...new Set(options.formatFiles.filter((file) => this.#targets.isTargetFile(file)))]; + const syntaxTargets = [...new Set(options.syntaxFiles.filter((file) => this.#targets.isSyntaxTarget(file)))]; + + const blankLines = await this.#pipeline.runPass(this.#segmentFormatter, formatTargets, options.mode); + + if (!this.#reporter.reportPass('blank-lines', formatTargets, options.mode, blankLines, 'edits')) { + return 1; + } + + const oxfmt = await this.#pipeline.runOxfmt({ bin: options.oxfmtBin, config: options.oxfmtConfig, files: formatTargets, mode: options.mode }); + + if (isErr(oxfmt)) { + console.error(oxfmt.error); + + return 1; + } + + const fluentChains = await this.#pipeline.runPass(this.#fluentFormatter, formatTargets, options.mode); + + if (!this.#reporter.reportPass('fluent-chains', formatTargets, options.mode, fluentChains, 'edits')) { + return 1; + } + + // Fluent and expanded calls create blank-line obligations the first pass + // cannot see, so the second pass makes one invocation reach a fixed point. + const finalBlankLines = await this.#pipeline.runPass(this.#segmentFormatter, formatTargets, options.mode); + + if (!this.#reporter.reportPass('blank-lines', formatTargets, options.mode, finalBlankLines, 'edits')) { + return 1; + } + + if (!this.#syntaxReporter.report(syntaxTargets, await this.#pipeline.validate(syntaxTargets))) { + return 1; + } + + return 0; + } +} diff --git a/packages/ts/sidecar/src/cli/format-all.ts b/packages/ts/sidecar/src/cli/format-all.ts index d879dbd..1ae8296 100644 --- a/packages/ts/sidecar/src/cli/format-all.ts +++ b/packages/ts/sidecar/src/cli/format-all.ts @@ -1,91 +1,5 @@ import { pathToFileURL } from 'node:url'; -import { CliOptionsDto } from '#sidecar/cli/format-all-cli-dto'; -import type { CliCommand } from '#sidecar/cli/command'; import { CompositionRoot } from '#sidecar/cli/composition-root'; -import { FileTargets } from '#sidecar/hosts/file-targets'; -import type { FileFormatter } from '#sidecar/pipeline/file-formatter'; -import type { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; -import { isErr } from '#sidecar/kernel/result'; -import type { PassReporter, SyntaxReporter } from '#sidecar/cli/reporter'; - -/** Runs the full formatting schedule: segment, oxfmt, fluent, segment, validate. */ -export class FormatAllCommand implements CliCommand { - readonly #pipeline: FormatPipeline; - readonly #segmentFormatter: FileFormatter; - readonly #fluentFormatter: FileFormatter; - readonly #reporter: PassReporter; - readonly #syntaxReporter: SyntaxReporter; - - /** - * @param dependencies - The pipeline, formatters, and reporters the schedule runs on. - * @param dependencies.pipeline - Runs passes, oxfmt, and validation over files. - * @param dependencies.segmentFormatter - The blank-lines segment formatter. - * @param dependencies.fluentFormatter - The fluent-chains formatter. - * @param dependencies.reporter - Renders formatting-pass reporting lines. - * @param dependencies.syntaxReporter - Renders syntax-validation reporting lines. - */ - constructor(dependencies: { pipeline: FormatPipeline; segmentFormatter: FileFormatter; fluentFormatter: FileFormatter; reporter: PassReporter; syntaxReporter: SyntaxReporter }) { - this.#pipeline = dependencies.pipeline; - this.#segmentFormatter = dependencies.segmentFormatter; - this.#fluentFormatter = dependencies.fluentFormatter; - this.#reporter = dependencies.reporter; - this.#syntaxReporter = dependencies.syntaxReporter; - } - - /** - * Parse the full-pipeline command line and run the ordered formatting schedule. - * - * @param argv - Arguments after the executable and script path. - * @returns `0` when every stage succeeds, `1` at the first reported failure. - */ - async run(argv: readonly string[]): Promise { - const parsed = CliOptionsDto.parse(argv); - - if (isErr(parsed)) { - console.error(parsed.error); - - return 1; - } - - const options = parsed.value; - const formatTargets = [...new Set(options.formatFiles.filter(FileTargets.isTargetFile))]; - const syntaxTargets = [...new Set(options.syntaxFiles.filter(FileTargets.isSyntaxTarget))]; - - const blankLines = await this.#pipeline.runPass(this.#segmentFormatter, formatTargets, options.mode); - - if (!this.#reporter.reportPass('blank-lines', formatTargets, options.mode, blankLines, 'edits')) { - return 1; - } - - const oxfmt = await this.#pipeline.runOxfmt({ bin: options.oxfmtBin, config: options.oxfmtConfig, files: formatTargets, mode: options.mode }); - - if (isErr(oxfmt)) { - console.error(oxfmt.error); - - return 1; - } - - const fluentChains = await this.#pipeline.runPass(this.#fluentFormatter, formatTargets, options.mode); - - if (!this.#reporter.reportPass('fluent-chains', formatTargets, options.mode, fluentChains, 'edits')) { - return 1; - } - - // Fluent and expanded calls create blank-line obligations the first pass - // cannot see, so the second pass makes one invocation reach a fixed point. - const finalBlankLines = await this.#pipeline.runPass(this.#segmentFormatter, formatTargets, options.mode); - - if (!this.#reporter.reportPass('blank-lines', formatTargets, options.mode, finalBlankLines, 'edits')) { - return 1; - } - - if (!this.#syntaxReporter.report(syntaxTargets, await this.#pipeline.validate(syntaxTargets))) { - return 1; - } - - return 0; - } -} /** * Run the full formatting CLI and map its exit code to the process status. diff --git a/packages/ts/sidecar/src/cli/format-pass-command.ts b/packages/ts/sidecar/src/cli/format-pass-command.ts index 685fbab..02f9c6c 100644 --- a/packages/ts/sidecar/src/cli/format-pass-command.ts +++ b/packages/ts/sidecar/src/cli/format-pass-command.ts @@ -1,5 +1,6 @@ import type { CliCommand } from '#sidecar/cli/command'; import type { FileFormatter } from '#sidecar/pipeline/file-formatter'; +import type { FileTargetPolicy } from '#sidecar/hosts/file-target-policy'; import type { FormatPipeline } from '#sidecar/pipeline/format-pipeline'; import { PassCliDto } from '#sidecar/cli/pass-cli-dto'; import type { PassReporter } from '#sidecar/cli/reporter'; @@ -9,6 +10,7 @@ export class FormatPassCommand implements CliCommand { readonly #pipeline: FormatPipeline; readonly #formatter: FileFormatter; readonly #reporter: PassReporter; + readonly #targets: FileTargetPolicy; readonly #label: string; readonly #failureNoun: string; @@ -17,13 +19,15 @@ export class FormatPassCommand implements CliCommand { * @param dependencies.pipeline - Applies the formatter across files concurrently. * @param dependencies.formatter - The file formatter whose pipeline drives the pass. * @param dependencies.reporter - Renders per-file and summary reporting lines. + * @param dependencies.targets - Classifies the target files parsed from the command line. * @param dependencies.label - The reporting label the pass emits. * @param dependencies.failureNoun - The change description used in check-mode guidance. */ - constructor(dependencies: { pipeline: FormatPipeline; formatter: FileFormatter; reporter: PassReporter; label: string; failureNoun: string }) { + constructor(dependencies: { pipeline: FormatPipeline; formatter: FileFormatter; reporter: PassReporter; targets: FileTargetPolicy; label: string; failureNoun: string }) { this.#pipeline = dependencies.pipeline; this.#formatter = dependencies.formatter; this.#reporter = dependencies.reporter; + this.#targets = dependencies.targets; this.#label = dependencies.label; this.#failureNoun = dependencies.failureNoun; } @@ -35,7 +39,7 @@ export class FormatPassCommand implements CliCommand { * @returns `0` when the pass succeeds, `1` when it reports a failure. */ async run(argv: readonly string[]): Promise { - const options = PassCliDto.parse(argv); + const options = PassCliDto.parse(argv, this.#targets); const files = [...options.files]; const outcomes = await this.#pipeline.runPass(this.#formatter, files, options.mode); diff --git a/packages/ts/sidecar/src/cli/pass-cli-dto.ts b/packages/ts/sidecar/src/cli/pass-cli-dto.ts index a42729f..7544b2e 100644 --- a/packages/ts/sidecar/src/cli/pass-cli-dto.ts +++ b/packages/ts/sidecar/src/cli/pass-cli-dto.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { FileTargets } from '#sidecar/hosts/file-targets'; +import type { FileTargetPolicy } from '#sidecar/hosts/file-target-policy'; /** Immutable command-line options shared by standalone formatting passes. */ export class PassCliDto { @@ -28,15 +28,16 @@ export class PassCliDto { * Parse a standalone formatting pass command line. * * @param input - Arguments after the executable and script path. + * @param targets - The policy that classifies eligible target files. * @returns Immutable formatting pass options. */ - static parse(input: unknown): PassCliDto { + static parse(input: unknown, targets: FileTargetPolicy): PassCliDto { const argv = PassCliDto.#argvSchema.parse(input); const candidate = { mode: argv.includes('--check') ? ('check' as const) : ('write' as const), files: argv.filter((argument) => { - return argument !== '--check' && FileTargets.isTargetFile(argument); + return argument !== '--check' && targets.isTargetFile(argument); }), }; diff --git a/packages/ts/sidecar/src/hosts/embedded-block-splitter.test.ts b/packages/ts/sidecar/src/hosts/embedded-block-splitter.test.ts index 4241e3a..d3c5f81 100644 --- a/packages/ts/sidecar/src/hosts/embedded-block-splitter.test.ts +++ b/packages/ts/sidecar/src/hosts/embedded-block-splitter.test.ts @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; +import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; +import { VueScript } from '#sidecar/hosts/vue-script'; -const splitter = new EmbeddedBlockSplitter(); +const splitter = new EmbeddedBlockSplitter({ vueScript: new VueScript(), markdownFences: new MarkdownFences() }); test('EmbeddedBlockSplitter.isHost accepts every host extension and rejects others', () => { for (const path of ['a.vue', 'b.html', 'c.htm', 'd.md', 'e.markdown']) { diff --git a/packages/ts/sidecar/src/hosts/embedded-block-splitter.ts b/packages/ts/sidecar/src/hosts/embedded-block-splitter.ts index 5397b61..2988406 100644 --- a/packages/ts/sidecar/src/hosts/embedded-block-splitter.ts +++ b/packages/ts/sidecar/src/hosts/embedded-block-splitter.ts @@ -1,5 +1,5 @@ -import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; -import { VueScript } from '#sidecar/hosts/vue-script'; +import type { MarkdownFences } from '#sidecar/hosts/markdown-fences'; +import type { VueScript } from '#sidecar/hosts/vue-script'; /** A JavaScript-capable block embedded in a host document. */ export type EmbeddedBlock = { @@ -18,6 +18,19 @@ export type EmbeddedTransform = (blockContent: string, virtualName: string) => s /** Extracts and rewrites embedded JavaScript blocks across every host format. */ export class EmbeddedBlockSplitter { + readonly #vueScript: VueScript; + readonly #markdownFences: MarkdownFences; + + /** + * @param scanners - The per-format scanners the splitter delegates extraction to. + * @param scanners.vueScript - Reads script blocks from Vue and HTML host markup. + * @param scanners.markdownFences - Reads fenced code blocks from Markdown hosts. + */ + constructor(scanners: { vueScript: VueScript; markdownFences: MarkdownFences }) { + this.#vueScript = scanners.vueScript; + this.#markdownFences = scanners.markdownFences; + } + /** * Report whether a path denotes a document that embeds JavaScript blocks. * @@ -47,12 +60,13 @@ export class EmbeddedBlockSplitter { */ extract(path: string, content: string): EmbeddedBlock[] { if (this.#isMarkdown(path)) { - return MarkdownFences.extractBlocks(content) + return this.#markdownFences + .extractBlocks(content) .filter((block) => { - return MarkdownFences.isJavaScriptOrTypeScript(block.lang); + return this.#markdownFences.isJavaScriptOrTypeScript(block.lang); }) .map((block) => { - return { content: block.content, start: block.start, extension: MarkdownFences.scriptExtension(block.lang) }; + return { content: block.content, start: block.start, extension: this.#markdownFences.scriptExtension(block.lang) }; }); } @@ -60,9 +74,10 @@ export class EmbeddedBlockSplitter { return []; } - return VueScript.extractBlocks(content) + return this.#vueScript + .extractBlocks(content) .filter((block) => { - return VueScript.isJavaScriptOrTypeScript(block.openTag); + return this.#vueScript.isJavaScriptOrTypeScript(block.openTag); }) .map((block) => { return { content: block.content, start: block.start, extension: this.#markupExtension(block.openTag) }; @@ -107,7 +122,7 @@ export class EmbeddedBlockSplitter { } #markupExtension(openTag: string): 'ts' | 'tsx' { - const lang = VueScript.attribute(openTag, 'lang') ?? ''; + const lang = this.#vueScript.attribute(openTag, 'lang') ?? ''; return lang === 'tsx' || lang === 'jsx' ? 'tsx' : 'ts'; } diff --git a/packages/ts/sidecar/src/hosts/file-target-policy.test.ts b/packages/ts/sidecar/src/hosts/file-target-policy.test.ts new file mode 100644 index 0000000..a3f2f9a --- /dev/null +++ b/packages/ts/sidecar/src/hosts/file-target-policy.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; +import { FileTargetPolicy } from '#sidecar/hosts/file-target-policy'; +import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; +import { VueScript } from '#sidecar/hosts/vue-script'; + +const targets = new FileTargetPolicy({ + embeddedBlocks: new EmbeddedBlockSplitter({ vueScript: new VueScript(), markdownFences: new MarkdownFences() }), +}); + +test('isTargetFile accepts ts and host documents but not declarations', () => { + assert.equal(targets.isTargetFile('app.ts'), true); + + assert.equal(targets.isTargetFile('widget.vue'), true); + + assert.equal(targets.isTargetFile('page.html'), true); + + assert.equal(targets.isTargetFile('page.htm'), true); + + assert.equal(targets.isTargetFile('notes.md'), true); + + assert.equal(targets.isTargetFile('notes.markdown'), true); + + assert.equal(targets.isTargetFile('types.d.ts'), false); + + assert.equal(targets.isTargetFile('data.json'), false); +}); + +test('isSyntaxTarget accepts every ts file plus host documents', () => { + assert.equal(targets.isSyntaxTarget('app.ts'), true); + + assert.equal(targets.isSyntaxTarget('types.d.ts'), true); + + assert.equal(targets.isSyntaxTarget('widget.vue'), true); + + assert.equal(targets.isSyntaxTarget('page.html'), true); + + assert.equal(targets.isSyntaxTarget('notes.md'), true); + + assert.equal(targets.isSyntaxTarget('data.json'), false); +}); + +test('isDeclarationFile only matches .d.ts', () => { + assert.equal(targets.isDeclarationFile('types.d.ts'), true); + + assert.equal(targets.isDeclarationFile('app.ts'), false); +}); diff --git a/packages/ts/sidecar/src/hosts/file-target-policy.ts b/packages/ts/sidecar/src/hosts/file-target-policy.ts new file mode 100644 index 0000000..a0ac54b --- /dev/null +++ b/packages/ts/sidecar/src/hosts/file-target-policy.ts @@ -0,0 +1,44 @@ +import type { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; + +/** Classifies paths accepted by sidecar formatting passes. */ +export class FileTargetPolicy { + readonly #embeddedBlocks: EmbeddedBlockSplitter; + + /** + * @param dependencies - The collaborators the policy classifies through. + * @param dependencies.embeddedBlocks - Recognises host documents that embed JavaScript. + */ + constructor(dependencies: { embeddedBlocks: EmbeddedBlockSplitter }) { + this.#embeddedBlocks = dependencies.embeddedBlocks; + } + + /** + * Report whether a virtual filename denotes a TypeScript declaration file. + * + * @param virtualName - The filename to classify. + * @returns `true` when the filename ends in `.d.ts`. + */ + isDeclarationFile(virtualName: string): boolean { + return virtualName.endsWith('.d.ts'); + } + + /** + * Report whether a path denotes a supported non-declaration source file. + * + * @param path - The source path to classify. + * @returns `true` for host documents and non-declaration TypeScript files. + */ + isTargetFile(path: string): boolean { + return (path.endsWith('.ts') && !path.endsWith('.d.ts')) || this.#embeddedBlocks.isHost(path); + } + + /** + * Report whether a path is eligible for final syntax validation. + * + * @param path - The source path to classify. + * @returns `true` for host documents and every TypeScript file. + */ + isSyntaxTarget(path: string): boolean { + return path.endsWith('.ts') || this.#embeddedBlocks.isHost(path); + } +} diff --git a/packages/ts/sidecar/src/hosts/file-targets.test.ts b/packages/ts/sidecar/src/hosts/file-targets.test.ts deleted file mode 100644 index aad5755..0000000 --- a/packages/ts/sidecar/src/hosts/file-targets.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { FileTargets } from '#sidecar/hosts/file-targets'; - -test('isTargetFile accepts ts and host documents but not declarations', () => { - assert.equal(FileTargets.isTargetFile('app.ts'), true); - - assert.equal(FileTargets.isTargetFile('widget.vue'), true); - - assert.equal(FileTargets.isTargetFile('page.html'), true); - - assert.equal(FileTargets.isTargetFile('page.htm'), true); - - assert.equal(FileTargets.isTargetFile('notes.md'), true); - - assert.equal(FileTargets.isTargetFile('notes.markdown'), true); - - assert.equal(FileTargets.isTargetFile('types.d.ts'), false); - - assert.equal(FileTargets.isTargetFile('data.json'), false); -}); - -test('isSyntaxTarget accepts every ts file plus host documents', () => { - assert.equal(FileTargets.isSyntaxTarget('app.ts'), true); - - assert.equal(FileTargets.isSyntaxTarget('types.d.ts'), true); - - assert.equal(FileTargets.isSyntaxTarget('widget.vue'), true); - - assert.equal(FileTargets.isSyntaxTarget('page.html'), true); - - assert.equal(FileTargets.isSyntaxTarget('notes.md'), true); - - assert.equal(FileTargets.isSyntaxTarget('data.json'), false); -}); - -test('isDeclarationFile only matches .d.ts', () => { - assert.equal(FileTargets.isDeclarationFile('types.d.ts'), true); - - assert.equal(FileTargets.isDeclarationFile('app.ts'), false); -}); diff --git a/packages/ts/sidecar/src/hosts/file-targets.ts b/packages/ts/sidecar/src/hosts/file-targets.ts deleted file mode 100644 index c45c40f..0000000 --- a/packages/ts/sidecar/src/hosts/file-targets.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { EmbeddedBlockSplitter } from '#sidecar/hosts/embedded-block-splitter'; - -const embeddedBlocks = new EmbeddedBlockSplitter(); - -/** Classifies paths accepted by sidecar formatting passes. */ -export class FileTargets { - /** - * Report whether a virtual filename denotes a TypeScript declaration file. - * - * @param virtualName - The filename to classify. - * @returns `true` when the filename ends in `.d.ts`. - */ - static isDeclarationFile(virtualName: string): boolean { - return virtualName.endsWith('.d.ts'); - } - - /** - * Report whether a path denotes a supported non-declaration source file. - * - * @param path - The source path to classify. - * @returns `true` for host documents and non-declaration TypeScript files. - */ - static isTargetFile(path: string): boolean { - return (path.endsWith('.ts') && !path.endsWith('.d.ts')) || embeddedBlocks.isHost(path); - } - - /** - * Report whether a path is eligible for final syntax validation. - * - * @param path - The source path to classify. - * @returns `true` for host documents and every TypeScript file. - */ - static isSyntaxTarget(path: string): boolean { - return path.endsWith('.ts') || embeddedBlocks.isHost(path); - } -} diff --git a/packages/ts/sidecar/src/hosts/markdown-fences.property.test.ts b/packages/ts/sidecar/src/hosts/markdown-fences.property.test.ts index faaaebf..55c0268 100644 --- a/packages/ts/sidecar/src/hosts/markdown-fences.property.test.ts +++ b/packages/ts/sidecar/src/hosts/markdown-fences.property.test.ts @@ -3,6 +3,8 @@ import { test } from 'node:test'; import fc from 'fast-check'; import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; +const markdownFences = new MarkdownFences(); + type ExpectedBlock = { readonly lang: string; readonly content: string; @@ -39,10 +41,10 @@ const documentArbitrary = fc.array(fc.record({ fence: fenceArbitrary, lang: lang return { document, expected }; }); -test('MarkdownFences.extractBlocks preserves generated content offsets and language detection', () => { +test('markdownFences.extractBlocks preserves generated content offsets and language detection', () => { fc.assert( fc.property(documentArbitrary, ({ document, expected }) => { - const extracted = MarkdownFences.extractBlocks(document); + const extracted = markdownFences.extractBlocks(document); assert.equal(extracted.length, expected.length); diff --git a/packages/ts/sidecar/src/hosts/markdown-fences.test.ts b/packages/ts/sidecar/src/hosts/markdown-fences.test.ts index 1651b9e..d0bbf6f 100644 --- a/packages/ts/sidecar/src/hosts/markdown-fences.test.ts +++ b/packages/ts/sidecar/src/hosts/markdown-fences.test.ts @@ -2,9 +2,11 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { MarkdownFences } from '#sidecar/hosts/markdown-fences'; -test('MarkdownFences.extractBlocks returns each fenced block with its offset', () => { +const markdownFences = new MarkdownFences(); + +test('markdownFences.extractBlocks returns each fenced block with its offset', () => { const content = ['# Title', '', '```ts', 'const n = 1;', '```', '', 'prose', '', '~~~js', 'const m = 2;', '~~~', ''].join('\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 2); @@ -27,9 +29,9 @@ test('MarkdownFences.extractBlocks returns each fenced block with its offset', ( assert.equal(content.slice(second.start, second.start + second.content.length), second.content); }); -test('MarkdownFences.extractBlocks reads the first info-string token as the language', () => { +test('markdownFences.extractBlocks reads the first info-string token as the language', () => { const content = ['```tsx title="Example.tsx" {1,3}', 'const x = 1;', '```', ''].join('\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 1); @@ -38,9 +40,9 @@ test('MarkdownFences.extractBlocks reads the first info-string token as the lang assert.equal(blocks[0]?.content, 'const x = 1;\n'); }); -test('MarkdownFences.extractBlocks handles indented fences and preserves body bytes', () => { +test('markdownFences.extractBlocks handles indented fences and preserves body bytes', () => { const content = ['- item', '', ' ```ts', ' const x = 1;', ' ```', ''].join('\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 1); @@ -51,39 +53,39 @@ test('MarkdownFences.extractBlocks handles indented fences and preserves body by assert.equal(content.slice(start, start + (blocks[0]?.content.length ?? 0)), blocks[0]?.content); }); -test('MarkdownFences.extractBlocks requires the closing fence to be at least as long', () => { +test('markdownFences.extractBlocks requires the closing fence to be at least as long', () => { const content = ['````ts', 'const inner = "```";', '````', ''].join('\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 1); assert.equal(blocks[0]?.content, 'const inner = "```";\n'); }); -test('MarkdownFences.extractBlocks ignores an unterminated fence', () => { +test('markdownFences.extractBlocks ignores an unterminated fence', () => { const content = ['```ts', 'const x = 1;', 'const y = 2;', ''].join('\n'); - assert.deepEqual(MarkdownFences.extractBlocks(content), []); + assert.deepEqual(markdownFences.extractBlocks(content), []); }); -test('MarkdownFences.extractBlocks does not treat four-space indented code as a fence', () => { +test('markdownFences.extractBlocks does not treat four-space indented code as a fence', () => { const content = [' ```ts', ' const x = 1;', ' ```', ''].join('\n'); - assert.deepEqual(MarkdownFences.extractBlocks(content), []); + assert.deepEqual(markdownFences.extractBlocks(content), []); }); -test('MarkdownFences.extractBlocks yields an empty body for an immediately closed fence', () => { +test('markdownFences.extractBlocks yields an empty body for an immediately closed fence', () => { const content = ['```ts', '```', ''].join('\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 1); assert.equal(blocks[0]?.content, ''); }); -test('MarkdownFences.extractBlocks tolerates carriage returns', () => { +test('markdownFences.extractBlocks tolerates carriage returns', () => { const content = ['```ts', 'const x = 1;', '```', ''].join('\r\n'); - const blocks = MarkdownFences.extractBlocks(content); + const blocks = markdownFences.extractBlocks(content); assert.equal(blocks.length, 1); @@ -94,24 +96,24 @@ test('MarkdownFences.extractBlocks tolerates carriage returns', () => { assert.equal(content.slice(start, start + (blocks[0]?.content.length ?? 0)), blocks[0]?.content); }); -test('MarkdownFences.isJavaScriptOrTypeScript accepts JS/TS langs case-insensitively', () => { +test('markdownFences.isJavaScriptOrTypeScript accepts JS/TS langs case-insensitively', () => { for (const lang of ['ts', 'TS', 'tsx', 'js', 'JSX', 'typescript', 'javascript', 'mjs', 'cjs', 'mts', 'cts']) { - assert.equal(MarkdownFences.isJavaScriptOrTypeScript(lang), true, lang); + assert.equal(markdownFences.isJavaScriptOrTypeScript(lang), true, lang); } for (const lang of ['json', 'bash', 'sh', 'yaml', 'html', '']) { - assert.equal(MarkdownFences.isJavaScriptOrTypeScript(lang), false, lang); + assert.equal(markdownFences.isJavaScriptOrTypeScript(lang), false, lang); } }); -test('MarkdownFences.scriptExtension maps JSX flavours to tsx', () => { - assert.equal(MarkdownFences.scriptExtension('tsx'), 'tsx'); +test('markdownFences.scriptExtension maps JSX flavours to tsx', () => { + assert.equal(markdownFences.scriptExtension('tsx'), 'tsx'); - assert.equal(MarkdownFences.scriptExtension('JSX'), 'tsx'); + assert.equal(markdownFences.scriptExtension('JSX'), 'tsx'); - assert.equal(MarkdownFences.scriptExtension('ts'), 'ts'); + assert.equal(markdownFences.scriptExtension('ts'), 'ts'); - assert.equal(MarkdownFences.scriptExtension('js'), 'ts'); + assert.equal(markdownFences.scriptExtension('js'), 'ts'); - assert.equal(MarkdownFences.scriptExtension('typescript'), 'ts'); + assert.equal(markdownFences.scriptExtension('typescript'), 'ts'); }); diff --git a/packages/ts/sidecar/src/hosts/markdown-fences.ts b/packages/ts/sidecar/src/hosts/markdown-fences.ts index 5098672..4acd9fc 100644 --- a/packages/ts/sidecar/src/hosts/markdown-fences.ts +++ b/packages/ts/sidecar/src/hosts/markdown-fences.ts @@ -27,7 +27,7 @@ const JAVASCRIPT_LANGS = ['ts', 'tsx', 'js', 'jsx', 'typescript', 'javascript', /** Inspects fenced code blocks embedded in CommonMark documents. */ export class MarkdownFences { - static #scanLines(content: string): ScannedLine[] { + #scanLines(content: string): ScannedLine[] { const lines: ScannedLine[] = []; let position = 0; @@ -36,25 +36,25 @@ export class MarkdownFences { const newline = content.indexOf('\n', position); if (newline === -1) { - lines.push({ start: position, end: content.length, text: MarkdownFences.#stripCarriageReturn(content.slice(position)) }); + lines.push({ start: position, end: content.length, text: this.#stripCarriageReturn(content.slice(position)) }); return lines; } - lines.push({ start: position, end: newline + 1, text: MarkdownFences.#stripCarriageReturn(content.slice(position, newline)) }); + lines.push({ start: position, end: newline + 1, text: this.#stripCarriageReturn(content.slice(position, newline)) }); position = newline + 1; } } - static #stripCarriageReturn(text: string): string { + #stripCarriageReturn(text: string): string { return text.endsWith('\r') ? text.slice(0, -1) : text; } - static #infoLanguage(info: string): string { + #infoLanguage(info: string): string { return info.trim().split(/\s+/)[0] ?? ''; } - static #findClose(lines: ScannedLine[], from: number, fenceChar: string, minLength: number): number { + #findClose(lines: ScannedLine[], from: number, fenceChar: string, minLength: number): number { const pattern = new RegExp(`^ {0,3}${fenceChar}{${minLength},}[ \\t]*$`); for (let index = from; index < lines.length; index++) { @@ -79,9 +79,9 @@ export class MarkdownFences { * @param content - The complete Markdown source text. * @returns The embedded fence blocks in source order. */ - static extractBlocks(content: string): MarkdownFenceBlock[] { + extractBlocks(content: string): MarkdownFenceBlock[] { const blocks: MarkdownFenceBlock[] = []; - const lines = MarkdownFences.#scanLines(content); + const lines = this.#scanLines(content); let index = 0; @@ -105,7 +105,7 @@ export class MarkdownFences { continue; } - const closeIndex = MarkdownFences.#findClose(lines, index + 1, fenceChar, fence.length); + const closeIndex = this.#findClose(lines, index + 1, fenceChar, fence.length); if (closeIndex === -1) { break; @@ -115,7 +115,7 @@ export class MarkdownFences { const bodyEnd = lines[closeIndex]?.start ?? content.length; blocks.push({ - lang: MarkdownFences.#infoLanguage(info), + lang: this.#infoLanguage(info), content: content.slice(bodyStart, bodyEnd), start: bodyStart, }); @@ -132,7 +132,7 @@ export class MarkdownFences { * @param lang - The first token of the fence info string. * @returns `true` for JavaScript and TypeScript language identifiers. */ - static isJavaScriptOrTypeScript(lang: string): boolean { + isJavaScriptOrTypeScript(lang: string): boolean { return JAVASCRIPT_LANGS.includes(lang.toLowerCase()); } @@ -142,7 +142,7 @@ export class MarkdownFences { * @param lang - The first token of the fence info string. * @returns `tsx` for JSX-flavoured languages, otherwise `ts`. */ - static scriptExtension(lang: string): 'ts' | 'tsx' { + scriptExtension(lang: string): 'ts' | 'tsx' { const normalized = lang.toLowerCase(); return normalized === 'tsx' || normalized === 'jsx' ? 'tsx' : 'ts'; diff --git a/packages/ts/sidecar/src/hosts/vue-script.property.test.ts b/packages/ts/sidecar/src/hosts/vue-script.property.test.ts index 7fd0e7e..1360a56 100644 --- a/packages/ts/sidecar/src/hosts/vue-script.property.test.ts +++ b/packages/ts/sidecar/src/hosts/vue-script.property.test.ts @@ -3,6 +3,8 @@ import { test } from 'node:test'; import fc from 'fast-check'; import { VueScript } from '#sidecar/hosts/vue-script'; +const vueScript = new VueScript(); + type GeneratedBlock = { readonly markup: string; readonly content: string; @@ -82,10 +84,10 @@ const documentArbitrary = fc }; }); -test('VueScript.extractBlocks preserves generated content offsets and language detection', () => { +test('vueScript.extractBlocks preserves generated content offsets and language detection', () => { fc.assert( fc.property(documentArbitrary, ({ document, scripts }) => { - const extracted = VueScript.extractBlocks(document); + const extracted = vueScript.extractBlocks(document); assert.equal(extracted.length, scripts.length); @@ -99,9 +101,9 @@ test('VueScript.extractBlocks preserves generated content offsets and language d assert.equal(block?.content, generated?.content); - assert.equal(VueScript.attribute(block?.openTag ?? '', 'lang'), generated?.lang); + assert.equal(vueScript.attribute(block?.openTag ?? '', 'lang'), generated?.lang); - assert.equal(VueScript.isJavaScriptOrTypeScript(block?.openTag ?? ''), generated?.javaScriptOrTypeScript); + assert.equal(vueScript.isJavaScriptOrTypeScript(block?.openTag ?? ''), generated?.javaScriptOrTypeScript); } }), { numRuns: 100 }, diff --git a/packages/ts/sidecar/src/hosts/vue-script.test.ts b/packages/ts/sidecar/src/hosts/vue-script.test.ts index 4aacd99..dfeb12d 100644 --- a/packages/ts/sidecar/src/hosts/vue-script.test.ts +++ b/packages/ts/sidecar/src/hosts/vue-script.test.ts @@ -2,9 +2,11 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { VueScript } from '#sidecar/hosts/vue-script'; -test('VueScript.extractBlocks returns every script block with its offset', () => { +const vueScript = new VueScript(); + +test('vueScript.extractBlocks returns every script block with its offset', () => { const content = '\n\n'; - const blocks = VueScript.extractBlocks(content); + const blocks = vueScript.extractBlocks(content); assert.equal(blocks.length, 2); @@ -19,24 +21,24 @@ test('VueScript.extractBlocks returns every script block with its offset', () => assert.equal(content.slice(second.start, second.start + second.content.length), second.content); }); -test('VueScript.attribute reads quoted and bare attribute values case-insensitively', () => { - assert.equal(VueScript.attribute('\n") gitAdd(t, dir, ".") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Scopes: []string{"src"}}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll, "src") if err != nil { t.Fatalf("collect: %v", err) @@ -46,7 +75,7 @@ func TestCollectCanIncludeDeclarationFiles(t *testing.T) { writeFile(t, filepath.Join(dir, "src", "types.d.ts"), "declare const value: string;\n") gitAdd(t, dir, ".") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, IncludeDeclarations: true, Scopes: []string{"src"}}) + files, warnings, err := collectFormattable(t, dir, true, gitfiles.SelectionAll, "src") if err != nil { t.Fatalf("collect: %v", err) @@ -77,7 +106,7 @@ func TestCollectLintableExcludesNonScriptDocuments(t *testing.T) { gitAdd(t, dir, ".") // Formatting owns the HTML and Markdown documents alongside the TS/Vue files. - formatFiles, _, err := Collect(context.Background(), Options{Cwd: dir, Scopes: []string{"src"}}) + formatFiles, _, err := collectFormattable(t, dir, false, gitfiles.SelectionAll, "src") if err != nil { t.Fatalf("collect: %v", err) @@ -97,7 +126,7 @@ func TestCollectLintableExcludesNonScriptDocuments(t *testing.T) { // Linting sees only the TS/Vue files: no HTML, no Markdown, and .d.ts stays // out unless declarations are requested. - lintFiles, _, err := CollectLintable(context.Background(), Options{Cwd: dir, Scopes: []string{"src"}}) + lintFiles, _, err := collectLintable(t, dir, false, gitfiles.SelectionAll, "src") if err != nil { t.Fatalf("collect lintable: %v", err) @@ -120,7 +149,7 @@ func TestCollectLintableCanIncludeDeclarationFiles(t *testing.T) { writeFile(t, filepath.Join(dir, "src", "index.html"), "\n") gitAdd(t, dir, ".") - files, _, err := CollectLintable(context.Background(), Options{Cwd: dir, IncludeDeclarations: true, Scopes: []string{"src"}}) + files, _, err := collectLintable(t, dir, true, gitfiles.SelectionAll, "src") if err != nil { t.Fatalf("collect lintable: %v", err) @@ -145,7 +174,7 @@ func TestCollectIncludesUntrackedAndIgnoresIgnored(t *testing.T) { writeFile(t, filepath.Join(dir, "untracked.vue"), "\n") writeFile(t, filepath.Join(dir, "ignored.ts"), "const ignored = true;\n") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll) if err != nil { t.Fatalf("collect: %v", err) @@ -171,10 +200,8 @@ func TestCollectScopesAndDeduplicatesFiles(t *testing.T) { writeFile(t, filepath.Join(dir, "other", "app.ts"), "const value = 2;\n") gitAdd(t, dir, ".") - files, warnings, err := Collect(context.Background(), Options{ - Cwd: dir, - Scopes: []string{"src", filepath.Join(dir, "src", "app.ts"), "missing"}, - }) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionAll, + "src", filepath.Join(dir, "src", "app.ts"), "missing") if err != nil { t.Fatalf("collect: %v", err) @@ -191,29 +218,6 @@ func TestCollectScopesAndDeduplicatesFiles(t *testing.T) { } } -func TestChangedPathsShimDelegatesToGitfiles(t *testing.T) { - dir := initRepo(t) - writeFile(t, filepath.Join(dir, ".prettierignore"), "main.go\n") - writeFile(t, filepath.Join(dir, "main.go"), "package main\n") - gitAdd(t, dir, ".") - - files, err := ChangedPaths(context.Background(), dir, nil) - - if err != nil { - t.Fatalf("changed paths: %v", err) - } - - // The shim forwards to gitfiles, which does not consult .prettierignore. - want := []string{ - filepath.Join(dir, ".prettierignore"), - filepath.Join(dir, "main.go"), - } - - if !reflect.DeepEqual(files, want) { - t.Fatalf("files mismatch\nwant: %#v\n got: %#v", want, files) - } -} - func initRepo(t *testing.T) string { t.Helper() @@ -269,7 +273,7 @@ func TestCollectChangedCoversOnlyTheWorkingTreesChanges(t *testing.T) { writeFile(t, filepath.Join(dir, "untracked.vue"), "\n") writeFile(t, filepath.Join(dir, "ignored.ts"), "const ignored = true;\n") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) if err != nil { t.Fatalf("collect: %v", err) @@ -305,7 +309,7 @@ func TestCollectChangedIncludesStagedFiles(t *testing.T) { // A staged deletion leaves no file to format and must stay out. run(t, dir, "git", "rm", "-q", "removed.ts") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) if err != nil { t.Fatalf("collect: %v", err) @@ -327,7 +331,7 @@ func TestCollectChangedWorksBeforeTheFirstCommit(t *testing.T) { writeFile(t, filepath.Join(dir, "staged.ts"), "const staged = 1;\n") gitAdd(t, dir, "staged.ts") - files, warnings, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) + files, warnings, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) if err != nil { t.Fatalf("collect: %v", err) @@ -350,7 +354,7 @@ func TestCollectAllCoversCommittedFilesThatChangedSelectionSkips(t *testing.T) { gitAdd(t, dir, "untouched.ts") gitCommit(t, dir) - changed, _, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionChanged}) + changed, _, err := collectFormattable(t, dir, false, gitfiles.SelectionChanged) if err != nil { t.Fatalf("collect changed: %v", err) @@ -360,7 +364,7 @@ func TestCollectAllCoversCommittedFilesThatChangedSelectionSkips(t *testing.T) { t.Fatalf("a clean working tree has no changes, got: %#v", changed) } - all, _, err := Collect(context.Background(), Options{Cwd: dir, Selection: SelectionAll}) + all, _, err := collectFormattable(t, dir, false, gitfiles.SelectionAll) if err != nil { t.Fatalf("collect all: %v", err) @@ -379,7 +383,9 @@ func TestCollectDefaultsToAll(t *testing.T) { gitAdd(t, dir, "untouched.ts") gitCommit(t, dir) - files, _, err := Collect(context.Background(), Options{Cwd: dir}) + // The zero gitfiles.Selection is SelectionAll, so a Collector built with it + // must cover committed files a changed run would skip. + files, _, err := collectFormattable(t, dir, false, gitfiles.Selection(0)) if err != nil { t.Fatalf("collect: %v", err) diff --git a/packages/go/driver/internal/tsruntime/invoker.go b/packages/go/driver/internal/tsruntime/invoker.go index 958e97a..d53142a 100644 --- a/packages/go/driver/internal/tsruntime/invoker.go +++ b/packages/go/driver/internal/tsruntime/invoker.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" + "go.ollin.sh/fmtkit/driver/internal/gitfiles" "go.ollin.sh/fmtkit/driver/internal/sidecarproto" "go.ollin.sh/fmtkit/driver/internal/sourcefiles" ) @@ -18,8 +19,8 @@ type Request struct { Scopes []string // Selection is how much of the working tree to cover within Scopes. It - // defaults to sourcefiles.SelectionAll. - Selection sourcefiles.Selection + // defaults to gitfiles.SelectionAll. + Selection gitfiles.Selection // Fix, when set, lets RunLint apply oxlint's safe fixes (--fix) rather // than only reporting violations. @@ -152,22 +153,24 @@ func (i Invoker) sourcesCwd() (string, error) { return cwd, nil } -func collect(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection sourcefiles.Selection) ([]string, []string, error) { - return sourcefiles.Collect(ctx, sourcefiles.Options{ - Cwd: cwd, - IncludeDeclarations: includeDeclarations, - Scopes: scopes, - Selection: selection, - }) +func collect(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection gitfiles.Selection) ([]string, []string, error) { + collector, err := sourcefiles.New(cwd, selection, includeDeclarations) + + if err != nil { + return nil, nil, err + } + + return collector.Formattable(ctx, scopes) } -func collectLintable(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection sourcefiles.Selection) ([]string, []string, error) { - return sourcefiles.CollectLintable(ctx, sourcefiles.Options{ - Cwd: cwd, - IncludeDeclarations: includeDeclarations, - Scopes: scopes, - Selection: selection, - }) +func collectLintable(ctx context.Context, cwd string, scopes []string, includeDeclarations bool, selection gitfiles.Selection) ([]string, []string, error) { + collector, err := sourcefiles.New(cwd, selection, includeDeclarations) + + if err != nil { + return nil, nil, err + } + + return collector.Lintable(ctx, scopes) } // oxfmtConfigFor resolves the oxfmt config by precedence: the FMTKIT_OXFMTRC diff --git a/packages/go/driver/report/agent.go b/packages/go/driver/report/agent.go index fe00cc6..fc18e71 100644 --- a/packages/go/driver/report/agent.go +++ b/packages/go/driver/report/agent.go @@ -42,12 +42,12 @@ type agentViolation struct { Message string `json:"message"` } -// RenderAgent writes the agent-oriented JSON report representation. -func RenderAgent(w io.Writer, cwd string, report Combined) error { +// renderAgent writes the agent-oriented JSON report representation. +func (r Renderer) renderAgent(w io.Writer, report Combined) error { encoder := json.NewEncoder(w) encoder.SetIndent("", " ") - return encoder.Encode(toAgentReport(projectReport(cwd, report))) + return encoder.Encode(toAgentReport(projectReport(r.Root, report))) } func toAgentReport(report projectedReport) agentReport { diff --git a/packages/go/driver/report/json.go b/packages/go/driver/report/json.go index 20904f4..7a1c20a 100644 --- a/packages/go/driver/report/json.go +++ b/packages/go/driver/report/json.go @@ -37,9 +37,9 @@ type jsonViolation struct { Message string `json:"message"` } -// RenderJSON writes the JSON report representation. -func RenderJSON(w io.Writer, cwd string, report Combined) error { - return json.NewEncoder(w).Encode(toJSONReport(projectReport(cwd, report))) +// renderJSON writes the JSON report representation. +func (r Renderer) renderJSON(w io.Writer, report Combined) error { + return json.NewEncoder(w).Encode(toJSONReport(projectReport(r.Root, report))) } func toJSONReport(report projectedReport) jsonReport { diff --git a/packages/go/driver/report/projection_test.go b/packages/go/driver/report/projection_test.go index 75e1c67..e79da82 100644 --- a/packages/go/driver/report/projection_test.go +++ b/packages/go/driver/report/projection_test.go @@ -52,7 +52,7 @@ func TestProjectReportNormalizesFormatterAndVetResults(t *testing.T) { func TestRenderJSONUsesProjectedReport(t *testing.T) { var out bytes.Buffer - if err := RenderJSON(&out, "/work", sampleCombinedReport()); err != nil { + if err := (Renderer{Root: "/work"}).renderJSON(&out, sampleCombinedReport()); err != nil { t.Fatalf("render json: %v", err) } @@ -66,7 +66,7 @@ func TestRenderJSONUsesProjectedReport(t *testing.T) { func TestRenderAgentUsesProjectedReport(t *testing.T) { var out bytes.Buffer - if err := RenderAgent(&out, "/work", sampleCombinedReport()); err != nil { + if err := (Renderer{Root: "/work"}).renderAgent(&out, sampleCombinedReport()); err != nil { t.Fatalf("render agent: %v", err) } diff --git a/packages/go/driver/report/render.go b/packages/go/driver/report/render.go index 4225ccc..98d979f 100644 --- a/packages/go/driver/report/render.go +++ b/packages/go/driver/report/render.go @@ -9,26 +9,94 @@ import ( "go.ollin.sh/fmtkit/vet" ) +// Mode is whether the CLI is checking or rewriting files. It drives the verbs +// in the text render ("Checked"/"would apply" vs "Formatted"/"applied") and the +// exit-code policy (see Combined.ExitCode). +type Mode string + +// Format is the output representation the CLI renders. +type Format string + // Combined contains the formatter and vet reports rendered by the CLI. type Combined struct { Formatter formatterengine.Report `json:"formatter"` Vet vet.Report `json:"vet"` } +// Renderer writes a Combined report. Root is the base that file paths are made +// relative to; Mode selects the check/format verbs in the text render. +type Renderer struct { + Root string + Mode Mode +} + type jsonErrorMessage struct { File string `json:"file"` Message string `json:"message"` } +const ( + // ModeCheck reports what would change without touching files. + ModeCheck Mode = "check" + + // ModeFormat rewrites files in place. + ModeFormat Mode = "format" +) + +const ( + // FormatText is the human-readable, sectioned report. + FormatText Format = "text" + + // FormatJSON is the compact single-line JSON report. + FormatJSON Format = "json" + + // FormatAgent is the indented, agent-oriented JSON report. + FormatAgent Format = "agent" +) + +// ParseFormat resolves a --format flag value to a Format. Unknown values are +// rejected with the same error the CLI has always returned for them. +func ParseFormat(s string) (Format, error) { + switch Format(s) { + case FormatText, FormatJSON, FormatAgent: + return Format(s), nil + default: + return "", errors.New("unsupported output format") + } +} + +// ExitCode maps a combined report onto a process exit code for the given mode. +// Vet errors always fail. In check mode any non-pass formatter result fails; in +// format mode only formatter errors (not fixable violations) fail. +func (c Combined) ExitCode(m Mode) int { + if c.Vet.ErrorCount() > 0 { + return 1 + } + + if m == ModeCheck { + if c.Formatter.Result == formatterengine.ResultPass { + return 0 + } + + return 1 + } + + if c.Formatter.ErrorCount() > 0 { + return 1 + } + + return 0 +} + // Render writes the report in the requested output format. -func Render(w io.Writer, format, cwd, mode string, report Combined) error { +func (r Renderer) Render(w io.Writer, format Format, report Combined) error { switch format { - case "text": - return RenderText(w, cwd, mode, report) - case "json": - return RenderJSON(w, cwd, report) - case "agent": - return RenderAgent(w, cwd, report) + case FormatText: + return r.renderText(w, report) + case FormatJSON: + return r.renderJSON(w, report) + case FormatAgent: + return r.renderAgent(w, report) default: return errors.New("unsupported output format") } diff --git a/packages/go/driver/report/render_test.go b/packages/go/driver/report/render_test.go index ab509ff..a32a50d 100644 --- a/packages/go/driver/report/render_test.go +++ b/packages/go/driver/report/render_test.go @@ -15,10 +15,12 @@ func TestRenderDispatch(t *testing.T) { t.Cleanup(func() { color.NoColor = previous }) - for _, format := range []string{"text", "json", "agent"} { + renderer := Renderer{Root: "/work", Mode: ModeCheck} + + for _, format := range []Format{FormatText, FormatJSON, FormatAgent} { var out bytes.Buffer - if err := Render(&out, format, "/work", "check", sampleCombinedReport()); err != nil { + if err := renderer.Render(&out, format, sampleCombinedReport()); err != nil { t.Fatalf("render %s: %v", format, err) } @@ -29,13 +31,89 @@ func TestRenderDispatch(t *testing.T) { var out bytes.Buffer - err := Render(&out, "yaml", "/work", "check", sampleCombinedReport()) + err := renderer.Render(&out, Format("yaml"), sampleCombinedReport()) if err == nil || err.Error() != "unsupported output format" { t.Fatalf("expected unsupported format error, got %v", err) } } +func TestParseFormat(t *testing.T) { + for _, tc := range []struct { + in string + want Format + }{ + {"text", FormatText}, + {"json", FormatJSON}, + {"agent", FormatAgent}, + } { + got, err := ParseFormat(tc.in) + + if err != nil { + t.Fatalf("ParseFormat(%q): %v", tc.in, err) + } + + if got != tc.want { + t.Fatalf("ParseFormat(%q) = %q, want %q", tc.in, got, tc.want) + } + } + + if _, err := ParseFormat("yaml"); err == nil || err.Error() != "unsupported output format" { + t.Fatalf("expected unsupported format error, got %v", err) + } +} + +func TestExitCode(t *testing.T) { + cases := []struct { + name string + mode Mode + report Combined + want int + }{ + { + name: "vet errors fail either mode", + mode: ModeFormat, + report: Combined{Vet: vet.Report{Errors: []vet.ErrorResult{{Message: "boom"}}}}, + want: 1, + }, + { + name: "check passes on pass result", + mode: ModeCheck, + report: Combined{Formatter: formatterengine.Report{Result: "pass"}}, + want: 0, + }, + { + name: "check fails on non-pass result", + mode: ModeCheck, + report: Combined{Formatter: formatterengine.Report{Result: "fail"}}, + want: 1, + }, + { + name: "format fails on formatter errors", + mode: ModeFormat, + report: Combined{Formatter: formatterengine.Report{ + Result: "fail", + Errors: []formatterengine.ErrorResult{{Message: "walk failed"}}, + }}, + want: 1, + }, + { + name: "format succeeds after applying fixes", + mode: ModeFormat, + report: Combined{Formatter: formatterengine.Report{Result: "fixed"}}, + want: 0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.report.ExitCode(tc.mode); got != tc.want { + t.Fatalf("ExitCode(%s) = %d, want %d", tc.mode, got, tc.want) + } + }) + } +} + func TestCombinedResult(t *testing.T) { cases := []struct { name string diff --git a/packages/go/driver/report/text.go b/packages/go/driver/report/text.go index 5314a6b..f80ec3b 100644 --- a/packages/go/driver/report/text.go +++ b/packages/go/driver/report/text.go @@ -9,13 +9,13 @@ import ( formatterengine "go.ollin.sh/fmtkit/formatter/engine" ) -// RenderText writes the human-readable text report representation. -func RenderText(w io.Writer, cwd, mode string, report Combined) error { +// renderText writes the human-readable text report representation. +func (r Renderer) renderText(w io.Writer, report Combined) error { if _, err := color.New(color.Bold).Fprintf(w, "\nFormatter\n\n"); err != nil { return err } - if err := renderFormatterText(w, cwd, mode, report.Formatter); err != nil { + if err := renderFormatterText(w, r.Root, r.Mode, report.Formatter); err != nil { return err } @@ -23,10 +23,10 @@ func RenderText(w io.Writer, cwd, mode string, report Combined) error { return err } - return renderVetText(w, cwd, report) + return renderVetText(w, r.Root, report) } -func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.Report) error { +func renderFormatterText(w io.Writer, cwd string, mode Mode, report formatterengine.Report) error { if report.Files == 0 && len(report.Errors) == 0 { if _, err := color.New(color.FgYellow).Fprintf(w, " No Go files found.\n\n"); err != nil { return err @@ -42,7 +42,7 @@ func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.R } else { action := "Checked" - if mode == "format" { + if mode == ModeFormat { action = "Formatted" } @@ -87,7 +87,7 @@ func renderFormatterText(w io.Writer, cwd, mode string, report formatterengine.R if result.Changed { verb := "would apply" - if mode == "format" { + if mode == ModeFormat { verb = "applied" } diff --git a/packages/go/driver/report/text_test.go b/packages/go/driver/report/text_test.go index f8ca68a..84d1d6e 100644 --- a/packages/go/driver/report/text_test.go +++ b/packages/go/driver/report/text_test.go @@ -13,7 +13,7 @@ import ( // renderTextPlain renders without ANSI escapes so substring asserts are // stable. color.NoColor is global state, so these tests must not run in // parallel. -func renderTextPlain(t *testing.T, cwd, mode string, report Combined) string { +func renderTextPlain(t *testing.T, cwd string, mode Mode, report Combined) string { t.Helper() previous := color.NoColor @@ -23,7 +23,7 @@ func renderTextPlain(t *testing.T, cwd, mode string, report Combined) string { var out bytes.Buffer - if err := RenderText(&out, cwd, mode, report); err != nil { + if err := (Renderer{Root: cwd, Mode: mode}).renderText(&out, report); err != nil { t.Fatalf("render text: %v", err) } From 51d0433cab56377875123ef18a14b79742f46e7a Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 24 Jul 2026 11:48:04 +0800 Subject: [PATCH 14/22] =?UTF-8?q?refactor(go):=20G6=20=E2=80=94=20typed=20?= =?UTF-8?q?pipeline=20steps=20+=20console;=20delete=20summarize.go=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(console): extract the ANSI progress logger into a console package Move the orchestrator's inline logger (section/detail/failure/stream rendering plus FORCE_COLOR/NO_COLOR/tty detection) into a dedicated driver/internal/console package. DetectColor now resolves the color mode once and NewPrinter takes the resolved ColorMode, so the printer never reads the environment inline. The orchestrator delegates to console.Printer; rendering is byte-identical (transcript goldens unchanged). * refactor(pipeline): give the pipeline typed steps, delete stdout scraping Replace the orchestrator's Tools func-triple and RunFormat with a generic Step/Result/Detail abstraction: Pipeline runs an ordered []Step, owning only the section/tee/quiet-failure-dump mechanics. The concrete steps (TS lint, TS format, Go format) live in the app composition root, which builds them and frames the run (target header, completion footer), resolving color once via console.DetectColor. The Go step derives its summary details from the typed gotool.Outcome (new Runner.RunReport returns it) instead of scraping the rendered report text; summarize.go and its Go-report regexes are deleted. The TS steps parse their captured output through sidecarproto plus the driver's own [sources]/[lint] bookkeeping notices, as before. Transcript goldens are unchanged and still pass byte-identical, now driven by fake Steps in the orchestrator test. * refactor(pipeline): rename the orchestrator package to pipeline Pure rename now that the package is the generic step runner rather than the format-specific orchestrator: git mv the directory (carrying the transcript goldens unchanged) and rename the package identifier and its importers in app. No behavior change. * style: apply fmtkit self-formatting and fix stale rename references Running the real pipeline over the tree (make format-all) reorders the new files to fmtkit's canonical form: type declarations hoisted to the top of the file, blank lines before statements following assignments. Pure reordering, no behavior change. Also updates two sidecarproto doc comments that still named the old orchestrator package to point at the pipeline steps that now own the Go-report bookkeeping. --- packages/go/driver/internal/app/format.go | 63 ++-- packages/go/driver/internal/app/options.go | 4 +- packages/go/driver/internal/app/steps.go | 309 ++++++++++++++++++ packages/go/driver/internal/app/steps_test.go | 235 +++++++++++++ .../go/driver/internal/console/printer.go | 144 ++++++++ .../driver/internal/console/printer_test.go | 103 ++++++ packages/go/driver/internal/gotool/runner.go | 23 +- .../driver/internal/orchestrator/logging.go | 114 ------- .../driver/internal/orchestrator/pipeline.go | 166 ---------- .../internal/orchestrator/pipeline_test.go | 307 ----------------- .../driver/internal/orchestrator/summarize.go | 122 ------- .../go/driver/internal/pipeline/pipeline.go | 103 ++++++ .../driver/internal/pipeline/pipeline_test.go | 302 +++++++++++++++++ .../testdata/transcript_go_failure.txt | 0 .../testdata/transcript_go_failure_quiet.txt | 0 .../testdata/transcript_success.txt | 0 .../testdata/transcript_success_quiet.txt | 0 .../testdata/transcript_ts_failure.txt | 0 .../driver/internal/sidecarproto/summary.go | 2 +- .../internal/sidecarproto/summary_test.go | 2 +- 20 files changed, 1244 insertions(+), 755 deletions(-) create mode 100644 packages/go/driver/internal/app/steps.go create mode 100644 packages/go/driver/internal/app/steps_test.go create mode 100644 packages/go/driver/internal/console/printer.go create mode 100644 packages/go/driver/internal/console/printer_test.go delete mode 100644 packages/go/driver/internal/orchestrator/logging.go delete mode 100644 packages/go/driver/internal/orchestrator/pipeline.go delete mode 100644 packages/go/driver/internal/orchestrator/pipeline_test.go delete mode 100644 packages/go/driver/internal/orchestrator/summarize.go create mode 100644 packages/go/driver/internal/pipeline/pipeline.go create mode 100644 packages/go/driver/internal/pipeline/pipeline_test.go rename packages/go/driver/internal/{orchestrator => pipeline}/testdata/transcript_go_failure.txt (100%) rename packages/go/driver/internal/{orchestrator => pipeline}/testdata/transcript_go_failure_quiet.txt (100%) rename packages/go/driver/internal/{orchestrator => pipeline}/testdata/transcript_success.txt (100%) rename packages/go/driver/internal/{orchestrator => pipeline}/testdata/transcript_success_quiet.txt (100%) rename packages/go/driver/internal/{orchestrator => pipeline}/testdata/transcript_ts_failure.txt (100%) diff --git a/packages/go/driver/internal/app/format.go b/packages/go/driver/internal/app/format.go index 8433b11..83aaf66 100644 --- a/packages/go/driver/internal/app/format.go +++ b/packages/go/driver/internal/app/format.go @@ -3,13 +3,11 @@ package app import ( "context" "fmt" - "io" + "strings" + "go.ollin.sh/fmtkit/driver/internal/console" "go.ollin.sh/fmtkit/driver/internal/gitfiles" - "go.ollin.sh/fmtkit/driver/internal/gotool" - "go.ollin.sh/fmtkit/driver/internal/orchestrator" - "go.ollin.sh/fmtkit/driver/internal/tsruntime" - report "go.ollin.sh/fmtkit/driver/report" + "go.ollin.sh/fmtkit/driver/internal/pipeline" ) // runFormat formats what diverges from HEAD — modified files, staged or not, @@ -48,37 +46,32 @@ func (d *deps) runFormatAll(ctx context.Context, args []string) int { return d.runPipeline(ctx, []string{"."}, opts, gitfiles.SelectionAll) } +// runPipeline frames the format run (target header, completion footer) around +// the typed steps it builds for the selection, handing them to the generic +// pipeline. Color is resolved once here, at the composition root. func (d *deps) runPipeline(ctx context.Context, paths []string, opts formatOptions, selection gitfiles.Selection) int { - pipeline := orchestrator.Pipeline{ - Tools: orchestrator.Tools{ - TS: func(ctx context.Context, scopes []string, output io.Writer) error { - assets, err := tsruntime.Resolve(d.version) - - if err != nil { - return err - } - - return tsruntime.NewInvoker(assets).RunPipeline(ctx, tsruntime.Request{Scopes: scopes, Selection: selection, Stdout: output, Stderr: output}) - }, - Lint: func(ctx context.Context, scopes []string, output io.Writer) error { - assets, err := tsruntime.Resolve(d.version) - - if err != nil { - return err - } - - return tsruntime.NewInvoker(assets).RunLint(ctx, tsruntime.Request{Scopes: scopes, Selection: selection, Fix: true, Stdout: output, Stderr: output}) - }, - Go: func(ctx context.Context, args []string, output io.Writer) int { - return gotool. - Runner{Stdout: output, Stderr: output, Scope: selection}. - Run(ctx, report.ModeFormat, args[1:]) - }, - }, - Steps: opts.steps, - Quiet: opts.quiet, - Stderr: d.stderr, + if len(paths) == 0 { + paths = []string{"."} } - return pipeline.RunFormat(ctx, paths) + printer := console.NewPrinter(d.stderr, console.DetectColor(d.stderr)) + + printer.Section("Formatting target(s)") + printer.Detail("paths", strings.Join(paths, " ")) + + pipe := pipeline.Pipeline{ + Steps: d.formatSteps(paths, opts.steps, selection), + Quiet: opts.quiet, + Printer: printer, + Stderr: d.stderr, + } + + if code := pipe.Run(ctx); code != 0 { + return code + } + + printer.Section("Formatting complete") + printer.SuccessDetail("status", "done") + + return 0 } diff --git a/packages/go/driver/internal/app/options.go b/packages/go/driver/internal/app/options.go index 75d548e..3bec3f0 100644 --- a/packages/go/driver/internal/app/options.go +++ b/packages/go/driver/internal/app/options.go @@ -3,12 +3,10 @@ package app import ( "fmt" "strings" - - "go.ollin.sh/fmtkit/driver/internal/orchestrator" ) type formatOptions struct { - steps orchestrator.Steps + steps stepSelection quiet bool } diff --git a/packages/go/driver/internal/app/steps.go b/packages/go/driver/internal/app/steps.go new file mode 100644 index 0000000..e8bb8fe --- /dev/null +++ b/packages/go/driver/internal/app/steps.go @@ -0,0 +1,309 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strings" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/gotool" + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/sidecarproto" + "go.ollin.sh/fmtkit/driver/internal/tsruntime" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +// stepSelection selects which parts of the format pipeline run; the zero value +// (no --ts/--go flags) runs everything. +type stepSelection struct { + TS bool + Go bool +} + +// tsLintStep lints TS/Vue files, applying oxlint's safe fixes (--fix). +type tsLintStep struct { + version string + paths []string + selection gitfiles.Selection +} + +// tsFormatStep runs the full TS/Vue formatting pipeline (oxfmt plus the project +// passes). +type tsFormatStep struct { + version string + paths []string + selection gitfiles.Selection +} + +// goFormatStep formats Go files and runs go vet, deriving its details from the +// typed outcome rather than the rendered report text. +type goFormatStep struct { + paths []string + selection gitfiles.Selection +} + +func (s stepSelection) normalized() stepSelection { + if !s.TS && !s.Go { + return stepSelection{TS: true, Go: true} + } + + return s +} + +// formatSteps builds the ordered pipeline steps for the selection. Lint runs +// first so the formatting passes normalize whatever oxlint rewrites. +func (d *deps) formatSteps(paths []string, selected stepSelection, selection gitfiles.Selection) []pipeline.Step { + selected = selected.normalized() + + var steps []pipeline.Step + + if selected.TS { + steps = append(steps, + tsLintStep{version: d.version, paths: paths, selection: selection}, + tsFormatStep{version: d.version, paths: paths, selection: selection}, + ) + } + + if selected.Go { + steps = append(steps, goFormatStep{paths: paths, selection: selection}) + } + + return steps +} + +// Driver-owned bookkeeping lines the TS steps recognize in their captured +// output. The sidecar's own wire lines are parsed by sidecarproto; these are +// notices the Go driver prints around the sidecar, so they stay here. +const ( + sourcesMissingPrefix = "[sources] path not found, skipping:" + lintNothingToLintLine = "[lint] no TS/Vue files to lint." +) + +func (s tsLintStep) Label() string { return "Running TS/Vue lint" } + +func (s tsLintStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + var captured bytes.Buffer + + err := invokeTS(s.version, io.MultiWriter(output, &captured), func(invoker tsruntime.Invoker, w io.Writer) error { + return invoker.RunLint(ctx, tsruntime.Request{Scopes: s.paths, Selection: s.selection, Fix: true, Stdout: w, Stderr: w}) + }) + + if code := tsExitCode(err, output); code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: tsLintDetails(captured.String())} +} + +func (s tsFormatStep) Label() string { return "Running TS/Vue formatting" } + +func (s tsFormatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + var captured bytes.Buffer + + err := invokeTS(s.version, io.MultiWriter(output, &captured), func(invoker tsruntime.Invoker, w io.Writer) error { + return invoker.RunPipeline(ctx, tsruntime.Request{Scopes: s.paths, Selection: s.selection, Stdout: w, Stderr: w}) + }) + + if code := tsExitCode(err, output); code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: tsFormatDetails(captured.String())} +} + +func (s goFormatStep) Label() string { return "Running Go formatting" } + +func (s goFormatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + outcome, code := gotool. + Runner{Stdout: output, Stderr: output, Scope: s.selection}. + RunReport(ctx, report.ModeFormat, s.paths) + + if code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: goFormatDetails(outcome)} +} + +// invokeTS resolves the TS toolchain and invokes it through spawn, which +// receives the constructed Invoker and the writer to stream tool output to. +func invokeTS(version string, output io.Writer, spawn func(tsruntime.Invoker, io.Writer) error) error { + assets, err := tsruntime.Resolve(version) + + if err != nil { + return err + } + + return spawn(tsruntime.NewInvoker(assets), output) +} + +// tsExitCode maps a TS step error to its exit code. Failures that never +// produced tool output (a missing sidecar, an unreadable working tree) surface +// their message through output so they are visible both live and in the quiet +// failure dump. +func tsExitCode(err error, output io.Writer) int { + if err == nil { + return 0 + } + + var exit *exec.ExitError + + if errors.As(err, &exit) { + return exit.ExitCode() + } + + _, _ = io.WriteString(output, err.Error()+"\n") + + return 1 +} + +// tsLintDetails derives the oxlint summary line. A driver "no files" notice +// wins; otherwise oxlint's own result line; otherwise a clean fallback. +func tsLintDetails(log string) []pipeline.Detail { + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, lintNothingToLintLine) { + return []pipeline.Detail{{Label: "oxlint", Value: strings.TrimPrefix(lintNothingToLintLine, "[lint] ")}} + } + } + + if result := sidecarproto.ParseLintSummary(log).Result; result != "" { + return []pipeline.Detail{{Label: "oxlint", Value: result}} + } + + return []pipeline.Detail{{Label: "oxlint", Value: "no issues found"}} +} + +// tsFormatDetails derives the TS pipeline's detail lines from the sidecar's +// progress output plus the driver's missing-source notices. +func tsFormatDetails(log string) []pipeline.Detail { + summary := sidecarproto.ParsePipelineSummary(log) + + var details []pipeline.Detail + + if summary.BlankLines != "" { + details = append(details, pipeline.Detail{Label: "blank-lines", Value: summary.BlankLines}) + } + + missing := 0 + + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, sourcesMissingPrefix) { + missing++ + } + } + + if missing > 0 { + details = append(details, pipeline.Detail{Label: "skipped", Value: fmt.Sprintf("%d missing tracked file(s)", missing)}) + } + + if summary.Oxfmt != "" { + details = append(details, pipeline.Detail{Label: "oxfmt", Value: summary.Oxfmt}) + } + + if summary.FluentChains != "" { + details = append(details, pipeline.Detail{Label: "fluent", Value: summary.FluentChains}) + } + + if summary.ValidateSyntax != "" { + details = append(details, pipeline.Detail{Label: "validated", Value: summary.ValidateSyntax}) + } + + return details +} + +// goFormatDetails computes the Go step's detail lines from the typed outcome, +// reproducing the exact strings the text report renders (which the pipeline +// previously scraped back out of that rendered text). +func goFormatDetails(outcome gotool.Outcome) []pipeline.Detail { + fm := outcome.Combined.Formatter + vt := outcome.Combined.Vet + + var details []pipeline.Detail + + if summary := goFileSummary(fm, outcome.Mode); summary != "" { + details = append(details, pipeline.Detail{Label: "fmtkit", Value: summary}) + } + + // The formatter renders a Result line unless it found no files and hit no + // errors; the vet Result line always renders. The "result" detail is the + // first Result line (the formatter's when present, else the vet's), matching + // the text report's top-to-bottom order. + formatterResult := "" + + if fm.Files != 0 || len(fm.Errors) != 0 { + formatterResult = fmt.Sprintf("%s. %d changed, %d violation(s), %d error(s).", fm.Result, fm.Changed, fm.ViolationCount(), fm.ErrorCount()) + } + + vetResult := fmt.Sprintf("%s. %d error(s).", goVetStatus(vt), vt.ErrorCount()) + + resultLine := formatterResult + + if resultLine == "" { + resultLine = vetResult + } + + details = append(details, pipeline.Detail{Label: "result", Value: resultLine}) + + if summary := goVetSummary(vt); summary != "" { + details = append(details, pipeline.Detail{Label: "vet", Value: summary}) + } + + if vetResult != resultLine { + details = append(details, pipeline.Detail{Label: "vet result", Value: vetResult}) + } + + return details +} + +// goFileSummary is the formatter's file-count line: "No Go files found." when it +// owns none, otherwise the mode's verb and count. +func goFileSummary(fm formatterengine.Report, mode report.Mode) string { + if fm.Files == 0 { + return "No Go files found." + } + + action := "Checked" + + if mode == report.ModeFormat { + action = "Formatted" + } + + return fmt.Sprintf("%s %d file(s).", action, fm.Files) +} + +// goVetStatus classifies the vet report the same way the text report does. +func goVetStatus(vt vet.Report) string { + switch { + case vt.Skipped || vt.Root == "": + return "skipped" + case vt.ErrorCount() > 0: + return "fail" + default: + return "pass" + } +} + +// goVetSummary is the vet status line, or "" for a failure (whose per-error +// lines the text report shows instead of a one-line summary). +func goVetSummary(vt vet.Report) string { + switch goVetStatus(vt) { + case "skipped": + reason := "no Go module or workspace was detected" + + if vt.Skipped { + reason = "the Go toolchain is not available" + } + + return "Skipped automatic go vet ./... because " + reason + "." + case "pass": + return "go vet ./... passed." + default: + return "" + } +} diff --git a/packages/go/driver/internal/app/steps_test.go b/packages/go/driver/internal/app/steps_test.go new file mode 100644 index 0000000..c9d5725 --- /dev/null +++ b/packages/go/driver/internal/app/steps_test.go @@ -0,0 +1,235 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/gotool" + "go.ollin.sh/fmtkit/driver/internal/pipeline" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +func detailStrings(details []pipeline.Detail) []string { + out := make([]string, 0, len(details)) + + for _, d := range details { + out = append(out, d.Label+"|"+d.Value) + } + + return out +} + +func assertDetails(t *testing.T, got []pipeline.Detail, want ...string) { + t.Helper() + + if g := fmt.Sprint(detailStrings(got)); g != fmt.Sprint(want) { + t.Fatalf("details mismatch\n--- got ---\n%s\n--- want ---\n%s", g, fmt.Sprint(want)) + } +} + +// goOutcome builds a Go outcome for the given mode, formatter report, and vet +// report. +func goOutcome(mode report.Mode, fm formatterengine.Report, vt vet.Report) gotool.Outcome { + return gotool.Outcome{Mode: mode, Combined: report.Combined{Formatter: fm, Vet: vt}} +} + +func TestGoFormatDetailsPass(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 2 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|go vet ./... passed.", + "vet result|pass. 0 error(s).", + ) +} + +func TestGoFormatDetailsCheckModeVerb(t *testing.T) { + outcome := goOutcome( + report.ModeCheck, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 3}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Checked 3 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|go vet ./... passed.", + "vet result|pass. 0 error(s).", + ) +} + +// TestGoFormatDetailsNoFiles reproduces the scraper's quirk: with no formatter +// Result line rendered, the "result" detail borrows the vet Result line and the +// separate "vet result" line is suppressed (they are identical). +func TestGoFormatDetailsNoFiles(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 0}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|No Go files found.", + "result|pass. 0 error(s).", + "vet|go vet ./... passed.", + ) +} + +func TestGoFormatDetailsVetSkippedNoModule(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, + vet.Report{Root: ""}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 1 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|Skipped automatic go vet ./... because no Go module or workspace was detected.", + "vet result|skipped. 0 error(s).", + ) +} + +func TestGoFormatDetailsVetSkippedToolchain(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, + vet.Report{Root: "/work", Skipped: true}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 1 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|Skipped automatic go vet ./... because the Go toolchain is not available.", + "vet result|skipped. 0 error(s).", + ) +} + +// TestGoFormatDetailsVetFailure: a vet failure renders per-error lines instead +// of a status summary, so there is no "vet" detail, but the differing vet Result +// line still appears. +func TestGoFormatDetailsVetFailure(t *testing.T) { + outcome := goOutcome( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, + vet.Report{Root: "/work", Errors: []vet.ErrorResult{{File: "a.go", Message: "boom"}}}, + ) + + assertDetails(t, goFormatDetails(outcome), + "fmtkit|Formatted 2 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet result|fail. 1 error(s).", + ) +} + +func TestTSFormatDetails(t *testing.T) { + log := "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + + "Finished in 10ms on 3 files using 8 threads.\n" + + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + + "[validate-syntax] checked 3 file(s).\n" + + assertDetails(t, tsFormatDetails(log), + "blank-lines|processed 3 file(s) in /work, 0 changed", + "oxfmt|Finished in 10ms on 3 files using 8 threads.", + "fluent|processed 3 file(s) in /work, 1 changed", + "validated|checked 3 file(s).", + ) +} + +func TestTSFormatDetailsCountsMissing(t *testing.T) { + log := "[sources] path not found, skipping: /work/a\n" + + "[sources] path not found, skipping: /work/b\n" + + assertDetails(t, tsFormatDetails(log), "skipped|2 missing tracked file(s)") +} + +func TestTSLintDetailsResult(t *testing.T) { + assertDetails(t, tsLintDetails("Found 0 warnings and 0 errors.\n"), "oxlint|Found 0 warnings and 0 errors.") +} + +func TestTSLintDetailsNoFiles(t *testing.T) { + assertDetails(t, tsLintDetails("[lint] no TS/Vue files to lint.\n"), "oxlint|no TS/Vue files to lint.") +} + +func TestTSLintDetailsFallback(t *testing.T) { + assertDetails(t, tsLintDetails("nothing interesting\n"), "oxlint|no issues found") +} + +func TestTSExitCodePlainErrorWritesToOutput(t *testing.T) { + var buf bytes.Buffer + + if code := tsExitCode(errors.New("boom"), &buf); code != 1 { + t.Fatalf("tsExitCode = %d, want 1", code) + } + + if buf.String() != "boom\n" { + t.Fatalf("tsExitCode output = %q, want %q", buf.String(), "boom\n") + } +} + +func TestTSExitCodeNil(t *testing.T) { + var buf bytes.Buffer + + if code := tsExitCode(nil, &buf); code != 0 { + t.Fatalf("tsExitCode(nil) = %d, want 0", code) + } + + if buf.Len() != 0 { + t.Fatalf("tsExitCode(nil) wrote %q", buf.String()) + } +} + +func TestStepSelectionNormalized(t *testing.T) { + if got := (stepSelection{}).normalized(); !got.TS || !got.Go { + t.Fatalf("zero selection = %+v, want both set", got) + } + + if got := (stepSelection{TS: true}).normalized(); !got.TS || got.Go { + t.Fatalf("TS-only selection = %+v, want TS only", got) + } + + if got := (stepSelection{Go: true}).normalized(); got.TS || !got.Go { + t.Fatalf("Go-only selection = %+v, want Go only", got) + } +} + +func TestFormatStepsSelection(t *testing.T) { + d := &deps{version: "dev"} + + labels := func(steps []pipeline.Step) []string { + out := make([]string, 0, len(steps)) + + for _, s := range steps { + out = append(out, s.Label()) + } + + return out + } + + all := labels(d.formatSteps([]string{"."}, stepSelection{}, 0)) + + if fmt.Sprint(all) != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting", "Running Go formatting"}) { + t.Fatalf("default steps = %v", all) + } + + tsOnly := labels(d.formatSteps([]string{"."}, stepSelection{TS: true}, 0)) + + if fmt.Sprint(tsOnly) != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting"}) { + t.Fatalf("--ts steps = %v", tsOnly) + } + + goOnly := labels(d.formatSteps([]string{"."}, stepSelection{Go: true}, 0)) + + if fmt.Sprint(goOnly) != fmt.Sprint([]string{"Running Go formatting"}) { + t.Fatalf("--go steps = %v", goOnly) + } +} diff --git a/packages/go/driver/internal/console/printer.go b/packages/go/driver/internal/console/printer.go new file mode 100644 index 0000000..117da12 --- /dev/null +++ b/packages/go/driver/internal/console/printer.go @@ -0,0 +1,144 @@ +// Package console renders the pipeline's sectioned, ANSI-colored progress +// output: section headers, aligned detail lines, failure banners, and the +// indented live stream of a child tool's output. Color detection is resolved +// once by the caller (see DetectColor) and handed to NewPrinter, so the printer +// itself never reads the environment. +package console + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/mattn/go-isatty" +) + +// ColorMode is whether a Printer emits ANSI escape sequences. +type ColorMode int + +// Printer renders progress output to a writer. The palette fields are empty +// strings when color is off, so the same format strings render plain text. +type Printer struct { + w io.Writer + + bold string + dim string + cyan string + green string + red string + reset string +} + +type indentWriter struct { + printer *Printer + partial strings.Builder +} + +const ( + // ColorAuto defers the decision to DetectColor. NewPrinter treats it as + // no-color, so callers resolve it through DetectColor before constructing a + // Printer rather than passing it through. + ColorAuto ColorMode = iota + + // ColorAlways forces ANSI color on. + ColorAlways + + // ColorNever forces ANSI color off. + ColorNever +) + +// DetectColor resolves whether color should be used when writing to w. It +// honors FORCE_COLOR (always on) and NO_COLOR (always off) before falling back +// to whether w is a terminal. This is the single place the environment is read; +// callers resolve it once and pass the result to NewPrinter. +func DetectColor(w io.Writer) ColorMode { + if os.Getenv("FORCE_COLOR") != "" { + return ColorAlways + } + + if os.Getenv("NO_COLOR") != "" { + return ColorNever + } + + if file, ok := w.(*os.File); ok && isatty.IsTerminal(file.Fd()) { + return ColorAlways + } + + return ColorNever +} + +// NewPrinter builds a Printer writing to w. ANSI color is enabled only for +// ColorAlways; ColorAuto and ColorNever both render plain text, so callers pass +// the resolved result of DetectColor. +func NewPrinter(w io.Writer, mode ColorMode) *Printer { + p := &Printer{w: w} + + if mode == ColorAlways { + p.bold = "\033[1m" + p.dim = "\033[2m" + p.cyan = "\033[36m" + p.green = "\033[32m" + p.red = "\033[31m" + p.reset = "\033[0m" + } + + return p +} + +// Section prints a bold, cyan-arrowed section header preceded by a blank line. +func (p *Printer) Section(msg string) { + _, _ = fmt.Fprintf(p.w, "\n%s==>%s %s%s%s\n", p.cyan, p.reset, p.bold, msg, p.reset) +} + +// Detail prints an aligned label/value line under the current section. +func (p *Printer) Detail(label, value string) { + _, _ = fmt.Fprintf(p.w, " %s%-12s%s %s\n", p.dim, label, p.reset, value) +} + +// SuccessDetail prints an aligned label/value line in green. +func (p *Printer) SuccessDetail(label, value string) { + _, _ = fmt.Fprintf(p.w, " %s%-12s%s %s%s%s\n", p.green, label, p.reset, p.green, value, p.reset) +} + +// Failure prints a red, banged failure banner preceded by a blank line. +func (p *Printer) Failure(msg string) { + _, _ = fmt.Fprintf(p.w, "\n%s!!%s %s%s%s\n", p.red, p.reset, p.bold, msg, p.reset) +} + +// Stream returns a writer that renders a child tool's output live, dimmed and +// indented under the current section. Callers must Close it to flush a trailing +// partial line. +func (p *Printer) Stream() io.WriteCloser { + return &indentWriter{printer: p} +} + +func (w *indentWriter) Write(p []byte) (int, error) { + for _, b := range p { + if b != '\n' { + w.partial.WriteByte(b) + + continue + } + + w.flushLine() + } + + return len(p), nil +} + +func (w *indentWriter) Close() error { + if w.partial.Len() > 0 { + w.flushLine() + } + + return nil +} + +func (w *indentWriter) flushLine() { + p := w.printer + + _, _ = fmt.Fprintf(p.w, " %s%s%s\n", p.dim, w.partial.String(), p.reset) + + w.partial.Reset() +} diff --git a/packages/go/driver/internal/console/printer_test.go b/packages/go/driver/internal/console/printer_test.go new file mode 100644 index 0000000..2182c1f --- /dev/null +++ b/packages/go/driver/internal/console/printer_test.go @@ -0,0 +1,103 @@ +package console + +import ( + "strings" + "testing" +) + +func TestDetectColorHonorsForceColor(t *testing.T) { + t.Setenv("NO_COLOR", "1") + t.Setenv("FORCE_COLOR", "1") + + if got := DetectColor(&strings.Builder{}); got != ColorAlways { + t.Fatalf("DetectColor with FORCE_COLOR = %v, want ColorAlways", got) + } +} + +func TestDetectColorHonorsNoColor(t *testing.T) { + t.Setenv("FORCE_COLOR", "") + t.Setenv("NO_COLOR", "1") + + if got := DetectColor(&strings.Builder{}); got != ColorNever { + t.Fatalf("DetectColor with NO_COLOR = %v, want ColorNever", got) + } +} + +func TestDetectColorNonTerminalIsNever(t *testing.T) { + t.Setenv("FORCE_COLOR", "") + t.Setenv("NO_COLOR", "") + + // A strings.Builder is not an *os.File, so it is never a terminal. + if got := DetectColor(&strings.Builder{}); got != ColorNever { + t.Fatalf("DetectColor for non-tty = %v, want ColorNever", got) + } +} + +func TestPrinterPlainRendering(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorNever) + + p.Section("Running Go formatting") + p.Detail("fmtkit", "Formatted 2 file(s).") + p.SuccessDetail("status", "done") + p.Failure("Running Go formatting failed") + + want := "\n==> Running Go formatting\n" + + " fmtkit Formatted 2 file(s).\n" + + " status done\n" + + "\n!! Running Go formatting failed\n" + + if buf.String() != want { + t.Fatalf("plain rendering mismatch\n--- got ---\n%q\n--- want ---\n%q", buf.String(), want) + } +} + +func TestPrinterColorRendering(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorAlways) + + p.Section("Formatting complete") + + got := buf.String() + + for _, want := range []string{"\033[36m", "\033[1m", "\033[0m", "Formatting complete"} { + if !strings.Contains(got, want) { + t.Fatalf("color section missing %q:\n%q", want, got) + } + } +} + +func TestPrinterColorAutoRendersPlain(t *testing.T) { + var buf strings.Builder + + NewPrinter(&buf, ColorAuto).Detail("label", "value") + + if strings.Contains(buf.String(), "\033[") { + t.Fatalf("ColorAuto emitted ANSI escapes: %q", buf.String()) + } +} + +func TestStreamIndentsAndFlushesPartialLine(t *testing.T) { + var buf strings.Builder + + p := NewPrinter(&buf, ColorNever) + + stream := p.Stream() + + _, _ = stream.Write([]byte("first line\nsecond ")) + _, _ = stream.Write([]byte("half\ntrailing")) + + if err := stream.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + want := " first line\n" + + " second half\n" + + " trailing\n" + + if buf.String() != want { + t.Fatalf("stream mismatch\n--- got ---\n%q\n--- want ---\n%q", buf.String(), want) + } +} diff --git a/packages/go/driver/internal/gotool/runner.go b/packages/go/driver/internal/gotool/runner.go index 0b6bb2d..8895dca 100644 --- a/packages/go/driver/internal/gotool/runner.go +++ b/packages/go/driver/internal/gotool/runner.go @@ -27,10 +27,21 @@ type Runner struct { // Run parses args for mode, executes the Go formatter and vet, renders the // report, and returns the process exit code. func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { + _, code := r.RunReport(ctx, mode, args) + + return code +} + +// RunReport is Run that also returns the typed outcome so pipeline callers can +// derive their summary details from it rather than scraping the rendered text. +// On a setup failure it returns the zero Outcome and a non-zero code after +// reporting the problem to Stderr, so the outcome is only meaningful when the +// returned code is zero. +func (r Runner) RunReport(ctx context.Context, mode report.Mode, args []string) (Outcome, int) { inv, err := ParseInvocation(mode, args, r.Stderr) if err != nil { - return 1 + return Outcome{}, 1 } workRoot, err := os.Getwd() @@ -38,7 +49,7 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err != nil { r.errf("resolve cwd: %v\n", err) - return 1 + return Outcome{}, 1 } reportRoot := workRoot @@ -52,7 +63,7 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err != nil { r.errf("%v\n", err) - return 1 + return Outcome{}, 1 } outcome, err := Execute(ctx, Request{ @@ -66,7 +77,7 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err != nil { r.errf("%v\n", err) - return 1 + return Outcome{}, 1 } renderer := report.Renderer{Root: reportRoot, Mode: mode} @@ -74,10 +85,10 @@ func (r Runner) Run(ctx context.Context, mode report.Mode, args []string) int { if err := renderer.Render(r.Stdout, inv.Output, outcome.Combined); err != nil { r.errf("render report: %v\n", err) - return 1 + return Outcome{}, 1 } - return outcome.ExitCode() + return outcome, outcome.ExitCode() } func (r Runner) errf(format string, args ...any) { diff --git a/packages/go/driver/internal/orchestrator/logging.go b/packages/go/driver/internal/orchestrator/logging.go deleted file mode 100644 index c93f71c..0000000 --- a/packages/go/driver/internal/orchestrator/logging.go +++ /dev/null @@ -1,114 +0,0 @@ -// Package orchestrator drives the full fmtkit formatting pipeline (TS/Vue -// formatting, TS/Vue lint, Go formatting) with sectioned, colorized progress -// output: each step's tool output streams live, indented under its section -// header, and is followed by the condensed summary lines. -package orchestrator - -import ( - "fmt" - "io" - "os" - "strings" - - "github.com/mattn/go-isatty" -) - -type logger struct { - w io.Writer - quiet bool - - bold string - dim string - cyan string - green string - red string - reset string -} - -// stream returns a writer that renders tool output live, dimmed and indented -// under the current section. Callers must Close it to flush a trailing -// partial line. - -type indentWriter struct { - logger *logger - partial strings.Builder -} - -func newLogger(w io.Writer, quiet bool) *logger { - l := &logger{w: w, quiet: quiet} - - if colorEnabled(w) { - l.bold = "\033[1m" - l.dim = "\033[2m" - l.cyan = "\033[36m" - l.green = "\033[32m" - l.red = "\033[31m" - l.reset = "\033[0m" - } - - return l -} - -func colorEnabled(w io.Writer) bool { - if os.Getenv("FORCE_COLOR") != "" { - return true - } - - if os.Getenv("NO_COLOR") != "" { - return false - } - - file, ok := w.(*os.File) - - return ok && isatty.IsTerminal(file.Fd()) -} - -func (l *logger) section(msg string) { - _, _ = fmt.Fprintf(l.w, "\n%s==>%s %s%s%s\n", l.cyan, l.reset, l.bold, msg, l.reset) -} - -func (l *logger) detail(label, value string) { - _, _ = fmt.Fprintf(l.w, " %s%-12s%s %s\n", l.dim, label, l.reset, value) -} - -func (l *logger) successDetail(label, value string) { - _, _ = fmt.Fprintf(l.w, " %s%-12s%s %s%s%s\n", l.green, label, l.reset, l.green, value, l.reset) -} - -func (l *logger) failure(msg string) { - _, _ = fmt.Fprintf(l.w, "\n%s!!%s %s%s%s\n", l.red, l.reset, l.bold, msg, l.reset) -} - -func (l *logger) stream() io.WriteCloser { - return &indentWriter{logger: l} -} - -func (w *indentWriter) Write(p []byte) (int, error) { - for _, b := range p { - if b != '\n' { - w.partial.WriteByte(b) - - continue - } - - w.flushLine() - } - - return len(p), nil -} - -func (w *indentWriter) Close() error { - if w.partial.Len() > 0 { - w.flushLine() - } - - return nil -} - -func (w *indentWriter) flushLine() { - l := w.logger - - _, _ = fmt.Fprintf(l.w, " %s%s%s\n", l.dim, w.partial.String(), l.reset) - - w.partial.Reset() -} diff --git a/packages/go/driver/internal/orchestrator/pipeline.go b/packages/go/driver/internal/orchestrator/pipeline.go deleted file mode 100644 index 0aedfe3..0000000 --- a/packages/go/driver/internal/orchestrator/pipeline.go +++ /dev/null @@ -1,166 +0,0 @@ -package orchestrator - -import ( - "bytes" - "context" - "errors" - "io" - "os/exec" - "strings" -) - -// Tools carries the three pipeline steps. The TS steps return an error whose -// exec.ExitError code propagates; the Go step reports its exit code directly. -type Tools struct { - TS func(ctx context.Context, scopes []string, output io.Writer) error - Lint func(ctx context.Context, scopes []string, output io.Writer) error - Go func(ctx context.Context, args []string, output io.Writer) int -} - -// Steps selects which parts of the pipeline run; the zero value (no -// selection flags) runs everything. -type Steps struct { - TS bool - Go bool -} - -// Pipeline renders sectioned progress on Stderr while running the steps. -type Pipeline struct { - Tools Tools - Steps Steps - - // Quiet restores the entrypoint's summary-only output; tool logs then - // only appear when a step fails. - Quiet bool - - Stderr io.Writer -} - -func (s Steps) normalized() Steps { - if !s.TS && !s.Go { - return Steps{TS: true, Go: true} - } - - return s -} - -// RunFormat runs TS/Vue lint (applying oxlint's safe fixes), TS/Vue formatting, -// and Go formatting against the given paths. Lint runs first so the formatting -// passes normalize whatever oxlint rewrites. -func (p Pipeline) RunFormat(ctx context.Context, paths []string) int { - if len(paths) == 0 { - paths = []string{"."} - } - - log := newLogger(p.Stderr, p.Quiet) - - log.section("Formatting target(s)") - log.detail("paths", strings.Join(paths, " ")) - - selected := p.Steps.normalized() - - type step struct { - label string - summarize func(string, *logger) - run func(ctx context.Context, output io.Writer) int - } - - var steps []step - - if selected.TS { - steps = append(steps, - step{ - label: "Running TS/Vue lint", - summarize: summarizeTSLint, - run: func(ctx context.Context, output io.Writer) int { - return exitCode(p.Tools.Lint(ctx, paths, output), output) - }, - }, - step{ - label: "Running TS/Vue formatting", - summarize: summarizeTSFormat, - run: func(ctx context.Context, output io.Writer) int { - return exitCode(p.Tools.TS(ctx, paths, output), output) - }, - }, - ) - } - - if selected.Go { - steps = append(steps, step{ - label: "Running Go formatting", - summarize: summarizeGoFormat, - run: func(ctx context.Context, output io.Writer) int { - return p.Tools.Go(ctx, append([]string{"format"}, paths...), output) - }, - }) - } - - for _, step := range steps { - if code := p.runStep(ctx, log, step.label, step.summarize, step.run); code != 0 { - return code - } - } - - log.section("Formatting complete") - log.successDetail("status", "done") - - return 0 -} - -// runStep captures a step's combined output, streaming it live unless quiet, -// and prints either its summary details or (on failure) the captured log. -func (p Pipeline) runStep(ctx context.Context, log *logger, label string, summarize func(string, *logger), run func(context.Context, io.Writer) int) int { - log.section(label) - - var captured bytes.Buffer - - output := io.Writer(&captured) - - var live io.WriteCloser - - if !p.Quiet { - live = log.stream() - output = io.MultiWriter(&captured, live) - } - - code := run(ctx, output) - - if live != nil { - _ = live.Close() - } - - if code != 0 { - log.failure(label + " failed") - - if p.Quiet { - _, _ = io.Copy(p.Stderr, bytes.NewReader(captured.Bytes())) - } - - return code - } - - summarize(captured.String(), log) - - return 0 -} - -// exitCode maps a step error to its exit code. Failures that never produced -// tool output (a missing sidecar, an unreadable working tree) surface their -// message through the step's output writer so they are visible both live and -// in the failure dump. -func exitCode(err error, output io.Writer) int { - if err == nil { - return 0 - } - - var exit *exec.ExitError - - if errors.As(err, &exit) { - return exit.ExitCode() - } - - _, _ = io.WriteString(output, err.Error()+"\n") - - return 1 -} diff --git a/packages/go/driver/internal/orchestrator/pipeline_test.go b/packages/go/driver/internal/orchestrator/pipeline_test.go deleted file mode 100644 index d09f62b..0000000 --- a/packages/go/driver/internal/orchestrator/pipeline_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package orchestrator - -import ( - "bytes" - "context" - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "testing" -) - -type invocation struct { - tool string - args []string -} - -var updateGolden = flag.Bool("update", false, "rewrite pipeline transcript golden files") - -// TestMain pins a color-free environment: CI task runners export FORCE_COLOR, -// which would inject ANSI codes into the captured output these tests assert. - -// The stub outputs mirror infra/test-binary-smoke.sh so -// the Go orchestrator preserves the entrypoint's summary contract. - -func TestMain(m *testing.M) { - _ = os.Unsetenv("FORCE_COLOR") - _ = os.Setenv("NO_COLOR", "1") - - os.Exit(m.Run()) -} - -const ( - stubTSOutput = "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + - "Finished in 10ms on 3 files using 8 threads.\n" + - "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" - - stubLintOutput = "Found 0 warnings and 0 errors.\n" - - stubGoOutput = "\nFormatter\n\n" + - " Formatted 2 file(s).\n\n" + - " Result: pass. 0 changed, 0 violation(s), 0 error(s).\n\n" + - "Vet\n\n" + - " go vet ./... passed.\n\n" + - " Result: pass. 0 error(s).\n" -) - -func stubTools(log *[]invocation, tsErr, lintErr error, goCode int) Tools { - return Tools{ - TS: func(_ context.Context, scopes []string, output io.Writer) error { - *log = append(*log, invocation{"ts", scopes}) - - _, _ = io.WriteString(output, stubTSOutput) - - return tsErr - }, - Lint: func(_ context.Context, scopes []string, output io.Writer) error { - *log = append(*log, invocation{"lint", scopes}) - - _, _ = io.WriteString(output, stubLintOutput) - - return lintErr - }, - Go: func(_ context.Context, args []string, output io.Writer) int { - *log = append(*log, invocation{"go", args}) - - _, _ = io.WriteString(output, stubGoOutput) - - return goCode - }, - } -} - -// TestRunFormatTranscriptGoldens pins the complete stderr transcript the -// pipeline renders, byte for byte, across the success and failure paths in both -// streaming and quiet modes. Color is forced off by TestMain, so the golden -// files carry no ANSI escapes. These goldens characterize the current -// rendering so later refactor stages cannot silently change it; regenerate with -// `go test ./driver/internal/orchestrator -run TestRunFormatTranscriptGoldens -update`. -func TestRunFormatTranscriptGoldens(t *testing.T) { - cases := []struct { - name string - quiet bool - tsErr error - goCode int - golden string - }{ - {"success", false, nil, 0, "transcript_success.txt"}, - {"success_quiet", true, nil, 0, "transcript_success_quiet.txt"}, - {"go_failure", false, nil, 3, "transcript_go_failure.txt"}, - {"go_failure_quiet", true, nil, 3, "transcript_go_failure_quiet.txt"}, - {"ts_failure", false, errors.New("sidecar exploded"), 0, "transcript_ts_failure.txt"}, - } - - for _, tc := range cases { - tc := tc - - t.Run(tc.name, func(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, tc.tsErr, nil, tc.goCode), - Quiet: tc.quiet, - Stderr: &stderr, - } - - pipeline.RunFormat(context.Background(), []string{"."}) - - path := filepath.Join("testdata", tc.golden) - - if *updateGolden { - if err := os.WriteFile(path, stderr.Bytes(), 0o644); err != nil { - t.Fatalf("update golden: %v", err) - } - - return - } - - want, err := os.ReadFile(path) - - if err != nil { - t.Fatalf("read golden: %v", err) - } - - if stderr.String() != string(want) { - t.Fatalf("transcript mismatch for %s\n--- got ---\n%s\n--- want ---\n%s", tc.golden, stderr.String(), want) - } - }) - } -} - -func TestRunFormatRunsStepsInOrder(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), []string{"."}); code != 0 { - t.Fatalf("RunFormat = %d, want 0\n%s", code, stderr.String()) - } - - want := []invocation{ - {"lint", []string{"."}}, - {"ts", []string{"."}}, - {"go", []string{"format", "."}}, - } - - if fmt.Sprint(log) != fmt.Sprint(want) { - t.Fatalf("invocations = %v, want %v", log, want) - } - - for _, needle := range []string{ - "==> Formatting target(s)", - "paths .", - "==> Running TS/Vue lint", - "oxlint Found 0 warnings and 0 errors.", - "==> Running TS/Vue formatting", - "blank-lines processed 3 file(s) in /work, 0 changed", - "oxfmt Finished in 10ms on 3 files using 8 threads.", - "fluent processed 3 file(s) in /work, 1 changed", - "==> Running Go formatting", - "fmtkit Formatted 2 file(s).", - "result pass. 0 changed, 0 violation(s), 0 error(s).", - "vet go vet ./... passed.", - "vet result pass. 0 error(s).", - "==> Formatting complete", - "status", - "done", - } { - if !strings.Contains(stderr.String(), needle) { - t.Fatalf("stderr missing %q:\n%s", needle, stderr.String()) - } - } -} - -func TestRunFormatStreamsToolOutputLive(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 0 { - t.Fatalf("RunFormat = %d, want 0", code) - } - - // The raw tool line appears indented (live stream) in addition to the - // condensed summary line. - if !strings.Contains(stderr.String(), " [blank-lines] processed 3 file(s) in /work, 0 changed") { - t.Fatalf("stderr missing live-streamed tool output:\n%s", stderr.String()) - } -} - -func TestRunFormatQuietHidesToolOutput(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 0), - Quiet: true, - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 0 { - t.Fatalf("RunFormat = %d, want 0", code) - } - - if strings.Contains(stderr.String(), " [blank-lines]") { - t.Fatalf("quiet mode streamed tool output:\n%s", stderr.String()) - } - - if !strings.Contains(stderr.String(), "blank-lines processed 3 file(s) in /work, 0 changed") { - t.Fatalf("quiet mode lost summary:\n%s", stderr.String()) - } -} - -func TestRunFormatShortCircuitsOnTSFailure(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, errors.New("sidecar exploded"), nil, 0), - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 1 { - t.Fatalf("RunFormat = %d, want 1", code) - } - - if len(log) != 2 || log[0].tool != "lint" || log[1].tool != "ts" { - t.Fatalf("invocations = %v, want lint then ts (Go short-circuited)", log) - } - - if !strings.Contains(stderr.String(), "!! Running TS/Vue formatting failed") { - t.Fatalf("stderr missing failure banner:\n%s", stderr.String()) - } - - if !strings.Contains(stderr.String(), "sidecar exploded") { - t.Fatalf("stderr missing error message:\n%s", stderr.String()) - } -} - -func TestRunFormatQuietDumpsLogOnFailure(t *testing.T) { - var log []invocation - - var stderr bytes.Buffer - - pipeline := Pipeline{ - Tools: stubTools(&log, nil, nil, 3), - Quiet: true, - Stderr: &stderr, - } - - if code := pipeline.RunFormat(context.Background(), nil); code != 3 { - t.Fatalf("RunFormat = %d, want 3", code) - } - - if !strings.Contains(stderr.String(), "Formatted 2 file(s).") { - t.Fatalf("quiet failure did not dump captured log:\n%s", stderr.String()) - } -} - -func TestSummarizeTSLintFallbacks(t *testing.T) { - var out bytes.Buffer - - log := newLogger(&out, true) - - summarizeTSLint("[lint] no TS/Vue files to lint.\n", log) - - if !strings.Contains(out.String(), "oxlint no TS/Vue files to lint.") { - t.Fatalf("missing skip summary: %q", out.String()) - } - - out.Reset() - - summarizeTSLint("nothing interesting\n", log) - - if !strings.Contains(out.String(), "oxlint no issues found") { - t.Fatalf("missing fallback summary: %q", out.String()) - } -} - -func TestSummarizeTSFormatCountsMissing(t *testing.T) { - var out bytes.Buffer - - log := newLogger(&out, true) - - summarizeTSFormat("[sources] path not found, skipping: /work/a\n[sources] path not found, skipping: /work/b\n", log) - - if !strings.Contains(out.String(), "skipped 2 missing tracked file(s)") { - t.Fatalf("missing skipped summary: %q", out.String()) - } -} diff --git a/packages/go/driver/internal/orchestrator/summarize.go b/packages/go/driver/internal/orchestrator/summarize.go deleted file mode 100644 index 9508784..0000000 --- a/packages/go/driver/internal/orchestrator/summarize.go +++ /dev/null @@ -1,122 +0,0 @@ -package orchestrator - -import ( - "fmt" - "regexp" - "strings" - - "go.ollin.sh/fmtkit/driver/internal/sidecarproto" -) - -// The summarizers distill a step's captured output into the aligned detail -// lines shown under its section header. Lines the TS sidecar emits are parsed -// by sidecarproto; the Go-report scraping below stays here (G6 retires it). - -var ( - goFileSummaryPattern = regexp.MustCompile(`^ (Formatted|Checked) [0-9]+ file\(s\)\.$|^ No Go files found\.$`) - goVetSummaryPattern = regexp.MustCompile(`^ go vet \./\.\.\. passed\.$|^ Skipped automatic go vet `) - sourcesMissingPrefix = "[sources] path not found, skipping:" - lintNothingToLintLine = "[lint] no TS/Vue files to lint." - goResultPrefix = " Result: " -) - -func lines(log string) []string { - return strings.Split(log, "\n") -} - -func lastWithPrefix(logLines []string, prefix string) string { - var match string - - for _, line := range logLines { - if strings.HasPrefix(line, prefix) { - match = line - } - } - - return match -} - -func summarizeTSFormat(log string, l *logger) { - summary := sidecarproto.ParsePipelineSummary(log) - missing := 0 - - for _, line := range lines(log) { - if strings.HasPrefix(line, sourcesMissingPrefix) { - missing++ - } - } - - if summary.BlankLines != "" { - l.detail("blank-lines", summary.BlankLines) - } - - if missing > 0 { - l.detail("skipped", fmt.Sprintf("%d missing tracked file(s)", missing)) - } - - if summary.Oxfmt != "" { - l.detail("oxfmt", summary.Oxfmt) - } - - if summary.FluentChains != "" { - l.detail("fluent", summary.FluentChains) - } - - if summary.ValidateSyntax != "" { - l.detail("validated", summary.ValidateSyntax) - } -} - -func summarizeTSLint(log string, l *logger) { - if lastWithPrefix(lines(log), lintNothingToLintLine) != "" { - l.detail("oxlint", strings.TrimPrefix(lintNothingToLintLine, "[lint] ")) - - return - } - - if result := sidecarproto.ParseLintSummary(log).Result; result != "" { - l.detail("oxlint", result) - - return - } - - l.detail("oxlint", "no issues found") -} - -func summarizeGoFormat(log string, l *logger) { - var fileSummary, formatterResult, vetSummary, vetResult string - - for _, line := range lines(log) { - if fileSummary == "" && goFileSummaryPattern.MatchString(line) { - fileSummary = strings.TrimPrefix(line, " ") - } - - if vetSummary == "" && goVetSummaryPattern.MatchString(line) { - vetSummary = strings.TrimPrefix(line, " ") - } - - if strings.HasPrefix(line, goResultPrefix) { - if formatterResult == "" { - formatterResult = strings.TrimPrefix(line, goResultPrefix) - } - - vetResult = strings.TrimPrefix(line, goResultPrefix) - } - } - - if fileSummary != "" { - l.detail("fmtkit", fileSummary) - } - - if formatterResult != "" { - l.detail("result", formatterResult) - } - - if vetSummary != "" { - l.detail("vet", vetSummary) - } - - if vetResult != "" && vetResult != formatterResult { - l.detail("vet result", vetResult) - } -} diff --git a/packages/go/driver/internal/pipeline/pipeline.go b/packages/go/driver/internal/pipeline/pipeline.go new file mode 100644 index 0000000..cbf6b6b --- /dev/null +++ b/packages/go/driver/internal/pipeline/pipeline.go @@ -0,0 +1,103 @@ +// Package pipeline drives a sequence of typed pipeline steps, rendering +// sectioned, colorized progress: each step's tool output streams live, indented +// under its section header, followed by the condensed detail lines the step +// derives from its typed result. It owns only the section/tee/quiet-failure-dump +// mechanics; the concrete steps (and their detail computation) live with the +// composition root that builds them. +package pipeline + +import ( + "bytes" + "context" + "io" + + "go.ollin.sh/fmtkit/driver/internal/console" +) + +// Detail is one aligned label/value line shown under a step's section header. +type Detail struct { + Label string + Value string +} + +// Result is what a Step reports: the process exit code it wants (0 on success) +// and, on success, the detail lines to render under its section. +type Result struct { + ExitCode int + Details []Detail +} + +// Step is one unit of pipeline work. Label is the section header; Run writes the +// tool's live output to output (a tee of the live stream and, when a step needs +// it, its own capture) and returns the typed Result. +type Step interface { + Label() string + Run(ctx context.Context, output io.Writer) Result +} + +// Pipeline renders sectioned progress on Stderr while running the steps in +// order, short-circuiting on the first non-zero exit code. +type Pipeline struct { + Steps []Step + + // Quiet restores the summary-only output; a step's live tool log then only + // appears when it fails. + Quiet bool + + // Printer renders every section, detail, and failure banner. The caller + // constructs it once (resolving color at the boundary) and shares it. + Printer *console.Printer + + Stderr io.Writer +} + +// Run executes the steps in order, returning the first non-zero exit code or 0 +// when they all pass. +func (p Pipeline) Run(ctx context.Context) int { + for _, step := range p.Steps { + if code := p.runStep(ctx, step); code != 0 { + return code + } + } + + return 0 +} + +// runStep captures a step's combined output, streaming it live unless quiet, +// and prints either the step's detail lines or (on failure) the captured log. +func (p Pipeline) runStep(ctx context.Context, step Step) int { + p.Printer.Section(step.Label()) + + var captured bytes.Buffer + + output := io.Writer(&captured) + + var live io.WriteCloser + + if !p.Quiet { + live = p.Printer.Stream() + output = io.MultiWriter(&captured, live) + } + + result := step.Run(ctx, output) + + if live != nil { + _ = live.Close() + } + + if result.ExitCode != 0 { + p.Printer.Failure(step.Label() + " failed") + + if p.Quiet { + _, _ = io.Copy(p.Stderr, bytes.NewReader(captured.Bytes())) + } + + return result.ExitCode + } + + for _, detail := range result.Details { + p.Printer.Detail(detail.Label, detail.Value) + } + + return 0 +} diff --git a/packages/go/driver/internal/pipeline/pipeline_test.go b/packages/go/driver/internal/pipeline/pipeline_test.go new file mode 100644 index 0000000..c4d682c --- /dev/null +++ b/packages/go/driver/internal/pipeline/pipeline_test.go @@ -0,0 +1,302 @@ +package pipeline + +import ( + "bytes" + "context" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/console" +) + +// fakeStep is a scripted Step: it streams a canned tool log to output, appends +// an optional trailing message (a non-exec error the real steps surface through +// output), and returns a fixed Result. It mirrors the tool stubs the earlier +// func-triple fakes used, now expressed against the Step interface. +type fakeStep struct { + label string + output string + trailing string + details []Detail + code int + + log *[]string +} + +var updateGolden = flag.Bool("update", false, "rewrite pipeline transcript golden files") + +func (s fakeStep) Label() string { return s.label } + +func (s fakeStep) Run(_ context.Context, output io.Writer) Result { + if s.log != nil { + *s.log = append(*s.log, s.label) + } + + _, _ = io.WriteString(output, s.output) + + if s.trailing != "" { + _, _ = io.WriteString(output, s.trailing) + } + + if s.code != 0 { + return Result{ExitCode: s.code} + } + + return Result{Details: s.details} +} + +const ( + stubTSOutput = "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + + "Finished in 10ms on 3 files using 8 threads.\n" + + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + + stubLintOutput = "Found 0 warnings and 0 errors.\n" + + stubGoOutput = "\nFormatter\n\n" + + " Formatted 2 file(s).\n\n" + + " Result: pass. 0 changed, 0 violation(s), 0 error(s).\n\n" + + "Vet\n\n" + + " go vet ./... passed.\n\n" + + " Result: pass. 0 error(s).\n" +) + +var ( + lintDetails = []Detail{{"oxlint", "Found 0 warnings and 0 errors."}} + + tsDetails = []Detail{ + {"blank-lines", "processed 3 file(s) in /work, 0 changed"}, + {"oxfmt", "Finished in 10ms on 3 files using 8 threads."}, + {"fluent", "processed 3 file(s) in /work, 1 changed"}, + } + + goDetails = []Detail{ + {"fmtkit", "Formatted 2 file(s)."}, + {"result", "pass. 0 changed, 0 violation(s), 0 error(s)."}, + {"vet", "go vet ./... passed."}, + {"vet result", "pass. 0 error(s)."}, + } +) + +// runFormat frames the three scripted steps exactly as the app composition root +// does (target header, completion footer), so the transcript the goldens pin is +// reproduced end to end without importing the app package. +func runFormat(t *testing.T, stderr io.Writer, quiet bool, steps []Step) int { + t.Helper() + + printer := console.NewPrinter(stderr, console.ColorNever) + + printer.Section("Formatting target(s)") + printer.Detail("paths", ".") + + code := Pipeline{Steps: steps, Quiet: quiet, Printer: printer, Stderr: stderr}.Run(context.Background()) + + if code == 0 { + printer.Section("Formatting complete") + printer.SuccessDetail("status", "done") + } + + return code +} + +// successSteps are the three passing steps in pipeline order (lint, TS, Go). +func successSteps(log *[]string) []Step { + return []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails, log: log}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails, log: log}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, details: goDetails, log: log}, + } +} + +// TestRunFormatTranscriptGoldens pins the complete stderr transcript the +// pipeline renders, byte for byte, across the success and failure paths in both +// streaming and quiet modes. Color is forced off, so the golden files carry no +// ANSI escapes. These goldens characterize the rendering so refactors cannot +// silently change it; regenerate with +// `go test ./driver/internal/pipeline -run TestRunFormatTranscriptGoldens -update`. +func TestRunFormatTranscriptGoldens(t *testing.T) { + cases := []struct { + name string + quiet bool + steps []Step + golden string + }{ + {"success", false, successSteps(nil), "transcript_success.txt"}, + {"success_quiet", true, successSteps(nil), "transcript_success_quiet.txt"}, + { + "go_failure", false, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + }, + "transcript_go_failure.txt", + }, + { + "go_failure_quiet", true, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, details: tsDetails}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + }, + "transcript_go_failure_quiet.txt", + }, + { + "ts_failure", false, + []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, trailing: "sidecar exploded\n", code: 1}, + }, + "transcript_ts_failure.txt", + }, + } + + for _, tc := range cases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + var stderr bytes.Buffer + + runFormat(t, &stderr, tc.quiet, tc.steps) + + path := filepath.Join("testdata", tc.golden) + + if *updateGolden { + if err := os.WriteFile(path, stderr.Bytes(), 0o644); err != nil { + t.Fatalf("update golden: %v", err) + } + + return + } + + want, err := os.ReadFile(path) + + if err != nil { + t.Fatalf("read golden: %v", err) + } + + if stderr.String() != string(want) { + t.Fatalf("transcript mismatch for %s\n--- got ---\n%s\n--- want ---\n%s", tc.golden, stderr.String(), want) + } + }) + } +} + +func TestRunRunsStepsInOrder(t *testing.T) { + var log []string + + var stderr bytes.Buffer + + if code := runFormat(t, &stderr, false, successSteps(&log)); code != 0 { + t.Fatalf("Run = %d, want 0\n%s", code, stderr.String()) + } + + want := []string{"Running TS/Vue lint", "Running TS/Vue formatting", "Running Go formatting"} + + if fmt.Sprint(log) != fmt.Sprint(want) { + t.Fatalf("step order = %v, want %v", log, want) + } + + for _, needle := range []string{ + "==> Formatting target(s)", + "paths .", + "==> Running TS/Vue lint", + "oxlint Found 0 warnings and 0 errors.", + "==> Running TS/Vue formatting", + "blank-lines processed 3 file(s) in /work, 0 changed", + "oxfmt Finished in 10ms on 3 files using 8 threads.", + "fluent processed 3 file(s) in /work, 1 changed", + "==> Running Go formatting", + "fmtkit Formatted 2 file(s).", + "result pass. 0 changed, 0 violation(s), 0 error(s).", + "vet go vet ./... passed.", + "vet result pass. 0 error(s).", + "==> Formatting complete", + "status", + "done", + } { + if !strings.Contains(stderr.String(), needle) { + t.Fatalf("stderr missing %q:\n%s", needle, stderr.String()) + } + } +} + +func TestRunStreamsToolOutputLive(t *testing.T) { + var stderr bytes.Buffer + + if code := runFormat(t, &stderr, false, successSteps(nil)); code != 0 { + t.Fatalf("Run = %d, want 0", code) + } + + // The raw tool line appears indented (live stream) in addition to the + // condensed detail line. + if !strings.Contains(stderr.String(), " [blank-lines] processed 3 file(s) in /work, 0 changed") { + t.Fatalf("stderr missing live-streamed tool output:\n%s", stderr.String()) + } +} + +func TestRunQuietHidesToolOutput(t *testing.T) { + var stderr bytes.Buffer + + if code := runFormat(t, &stderr, true, successSteps(nil)); code != 0 { + t.Fatalf("Run = %d, want 0", code) + } + + if strings.Contains(stderr.String(), " [blank-lines]") { + t.Fatalf("quiet mode streamed tool output:\n%s", stderr.String()) + } + + if !strings.Contains(stderr.String(), "blank-lines processed 3 file(s) in /work, 0 changed") { + t.Fatalf("quiet mode lost detail:\n%s", stderr.String()) + } +} + +func TestRunShortCircuitsOnFailure(t *testing.T) { + var log []string + + var stderr bytes.Buffer + + steps := []Step{ + fakeStep{label: "Running TS/Vue lint", output: stubLintOutput, details: lintDetails, log: &log}, + fakeStep{label: "Running TS/Vue formatting", output: stubTSOutput, trailing: "sidecar exploded\n", code: 1, log: &log}, + fakeStep{label: "Running Go formatting", output: stubGoOutput, details: goDetails, log: &log}, + } + + if code := runFormat(t, &stderr, false, steps); code != 1 { + t.Fatalf("Run = %d, want 1", code) + } + + want := []string{"Running TS/Vue lint", "Running TS/Vue formatting"} + + if fmt.Sprint(log) != fmt.Sprint(want) { + t.Fatalf("step order = %v, want %v (Go should have been skipped)", log, want) + } + + if !strings.Contains(stderr.String(), "!! Running TS/Vue formatting failed") { + t.Fatalf("stderr missing failure banner:\n%s", stderr.String()) + } + + if !strings.Contains(stderr.String(), "sidecar exploded") { + t.Fatalf("stderr missing error message:\n%s", stderr.String()) + } +} + +func TestRunQuietDumpsLogOnFailure(t *testing.T) { + var stderr bytes.Buffer + + steps := []Step{ + fakeStep{label: "Running Go formatting", output: stubGoOutput, code: 3}, + } + + if code := runFormat(t, &stderr, true, steps); code != 3 { + t.Fatalf("Run = %d, want 3", code) + } + + if !strings.Contains(stderr.String(), "Formatted 2 file(s).") { + t.Fatalf("quiet failure did not dump captured log:\n%s", stderr.String()) + } +} diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_go_failure.txt b/packages/go/driver/internal/pipeline/testdata/transcript_go_failure.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_go_failure.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_go_failure.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_go_failure_quiet.txt b/packages/go/driver/internal/pipeline/testdata/transcript_go_failure_quiet.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_go_failure_quiet.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_go_failure_quiet.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_success.txt b/packages/go/driver/internal/pipeline/testdata/transcript_success.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_success.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_success.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_success_quiet.txt b/packages/go/driver/internal/pipeline/testdata/transcript_success_quiet.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_success_quiet.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_success_quiet.txt diff --git a/packages/go/driver/internal/orchestrator/testdata/transcript_ts_failure.txt b/packages/go/driver/internal/pipeline/testdata/transcript_ts_failure.txt similarity index 100% rename from packages/go/driver/internal/orchestrator/testdata/transcript_ts_failure.txt rename to packages/go/driver/internal/pipeline/testdata/transcript_ts_failure.txt diff --git a/packages/go/driver/internal/sidecarproto/summary.go b/packages/go/driver/internal/sidecarproto/summary.go index 19c3219..25dc007 100644 --- a/packages/go/driver/internal/sidecarproto/summary.go +++ b/packages/go/driver/internal/sidecarproto/summary.go @@ -37,7 +37,7 @@ type LintSummary struct { // These prefixes and the oxlint result pattern are the sidecar's output // contract; only the lines the TS toolchain itself emits live here. Lines the // Go driver prints about its own bookkeeping (source-collection warnings, the -// no-files notice, the Go formatter report) stay with the orchestrator. +// no-files notice, the Go formatter report) stay with the pipeline steps. const ( blankLinesMatch = "[blank-lines] processed " blankLinesTrim = "[blank-lines] " diff --git a/packages/go/driver/internal/sidecarproto/summary_test.go b/packages/go/driver/internal/sidecarproto/summary_test.go index 752a92e..7b2db58 100644 --- a/packages/go/driver/internal/sidecarproto/summary_test.go +++ b/packages/go/driver/internal/sidecarproto/summary_test.go @@ -3,7 +3,7 @@ package sidecarproto import "testing" // sampleTSOutput mirrors the sidecar's pipeline stdout, lifted from the -// orchestrator's fake-tool fixtures. +// pipeline's fake-tool fixtures. const sampleTSOutput = "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + "Finished in 10ms on 3 files using 8 threads.\n" + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + From fe2a294171e4782bb595fb9179e541260b06aaf2 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 24 Jul 2026 11:49:49 +0800 Subject: [PATCH 15/22] docs: describe the post-refactor architecture and its contracts (#82) --- docs/architecture.md | 112 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/architecture.md diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c9c1b2f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,112 @@ +# Architecture + +fmtkit is one binary with two halves: a Go driver that owns the CLI, file +discovery, reporting, and orchestration, and a bun-compiled TypeScript sidecar +that owns the TS/Vue formatting passes. This document explains how the pieces +fit, the contracts between them, and the design rules the codebase follows. + +## Design rules + +- **Behavior lives on types.** Go logic belongs to structs with methods that + share state through their owner (parse context, tree handles, printers) — + free functions are reserved for genuinely stateless leaf predicates. + TypeScript code lives behind classes with real instances and + constructor-injected dependencies. +- **Sanctioned exceptions (TS).** Only these may be static or free: + entrypoint `main()` bootstraps (a `main` plus a run-as-main guard, nothing + else), the documented `Result`/`ok`/`err` helpers in `kernel/result.ts`, + value types with factory statics (Zod DTOs' `parse`/`from`, `SourceDocument.of`, + `IterationBudget.once`), and `Error` subclasses. +- **Parse, don't validate (TS).** Untrusted data crosses a boundary once, + through a frozen Zod-backed DTO (`*CliDto`, `ParsedSourceDto`). No `typeof` + narrowing in source. The deep AST is the one documented relaxation: only + node envelopes are schema-validated; descendants are trusted Oxc output. +- **The wire is frozen.** Every value crossing the Go↔TS process boundary is + defined exactly once per side (Go: `driver/internal/sidecarproto`; TS: the + CLI DTOs) and covered by golden tests. Changing one requires changing both + sides in the same PR. +- **The repo formats itself.** `make format-all` must leave the tree + unchanged. Write class members in the formatter's order — properties, then + constructor, then methods, blank lines between members — or the self-check + will reorder them for you. + +## Go (`packages/go`, module `go.ollin.sh/fmtkit`) + +### Public library + +| Package | Role | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `formatter` | Facade: `Check`/`Format`/`CheckFiles`/`FormatFiles` | +| `formatter/engine` | `Engine` runs `Formatter` implementations concurrently, produces `Report` | +| `formatter/config` | The single source of truth for formatter configuration and defaults | +| `formatter/rules/spacing` | The spacing rule. Internally: `fileContext` (parse once) shared by `blankLineInserter`, `typeOrderRewriter`, `embedDirectiveRepairer` | +| `vet` | `go vet` wrapper with an injectable toolchain for tests | +| `driver/config` | CLI config: embeds `formatter/config.Config` (`mapstructure:",squash"`) plus the vet toggle; `config.yml` schema is a public contract | +| `driver/report` | Typed `Mode`/`Format` values, `Renderer{Root, Mode}`; the JSON/agent output shapes are a public contract | + +### Driver internals (`driver/internal/...`) + +| Package | Role | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | `Command` + `Set`: the one dispatch table. Both binaries and the umbrella `go` subcommand are `Set`s built by `app` | +| `app` | Composition root only: builds the command `Set`s, constructs the pipeline `Step`s, resolves color once | +| `gotool` | The Go check/format use case: `ParseInvocation` → `Invocation`, `Execute(Request)` → `Outcome` (typed result; owns exit policy via `Outcome.ExitCode`) | +| `pipeline` | Generic mechanism: `Step`/`Result`/`Detail` + the section/tee/quiet-failure loop. Steps compute summaries from typed results — never by scraping rendered text | +| `console` | Terminal presentation: `DetectColor` (the only NO_COLOR/FORCE_COLOR read) + `Printer` | +| `gitfiles` | `Tree`: git-based file discovery, `Selection`, `IntersectChanged` | +| `filetypes` | `Filter`: extension taxonomy (formattable/lintable) | +| `prettierignore` | `Matcher`: full `.prettierignore` gitignore semantics | +| `sourcefiles` | `Collector{Tree, Selection, Filter}`: composition of the three above | +| `sidecarproto` | The typed Go↔TS seam (see below) | +| `tsruntime` | `Assets` (extracted toolchain lifecycle), `Invoker` (spawns the sidecar via `sidecarproto`), `PrettierMigration` | +| `embedded` | `go:embed` of the staged sidecar per platform; `bin/` must stay a child of this package (staging writes there) | + +## TypeScript sidecar (`packages/ts/sidecar/src`) + +| Directory | Role | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `kernel/` | `result.ts` (sanctioned helpers), tagged errors, concurrency pool | +| `syntax/` | `SourceDocument` (frozen value object: text + coordinate queries), `SourceParser` (the Zod parse boundary), `AstReader`, `Edit` + `EditApplier`, `node-schema` | +| `hosts/` | Embedded-language handling: `EmbeddedBlockSplitter` + `VueScriptScanner`/`MarkdownFenceScanner`, `FileTargetPolicy` | +| `passes/` | One class per rule, all implementing `FormattingPass { name; computeEdits(document): Edit[] }`. Policies (`StatementSpacingPolicy`, `ClassMemberPolicy`, `VueReactivityIdioms`) hold the layout knowledge; `drizzle/` holds the vocabulary/scanner/classifier/writer collaborators | +| `pipeline/` | `PassPipeline`/`PipelineStep`/`IterationBudget` (fixed-point loops are declared here, not hidden in passes); `PipelineFactory` — **the only place pass sequences are named**; `FileFormatter` (host-aware transform), `SourceFileEditor` (read→transform→compare→atomic write), `FormatPipeline`, `SyntaxValidator` | +| `io/` | `SourceFiles`/`ProcessRunner` ports + Node adapters | +| `cli/` | `CliCommand` contract, `CompositionRoot.production()` (the DI wiring point), command classes, reporters, and the argv DTOs. Entry files are `main()` shims | +| `sidecar.ts` | The wire entry: dispatches `pipeline`/`oxfmt`/`oxlint` modes | + +Adding a pass: implement `FormattingPass`, register it in `PipelineFactory` — +nothing else changes. The file-set schedule (segment → oxfmt → fluent → +segment fixed-point → validate) lives once, in `FormatAllCommand`. + +Module resolution uses wildcard subpath imports (`#sidecar/*` → `./src/*.ts`) +declared in both `sidecar/package.json` and `sidecar/src/package.json` (the +staging script copies `src/` with the inner map). All imports must use the +alias — `alias-specifiers.test.ts` enforces it. The import graph is +cycle-free. + +## The Go↔TS seam + +The driver spawns the staged `fmtkit-ts-sidecar` executable. Everything on +the wire — the executable name, `.oxfmtrc.json`/`.oxlintrc.json`, the +`pipeline|oxfmt|oxlint` modes, the argv flags, the `FMTKIT_*`/`OX*_BIN` +override env vars, and the stdout summary prefixes the driver parses back — +is defined in `driver/internal/sidecarproto` on the Go side and consumed by +`sidecar.ts` + the `cli/` DTOs on the TS side. Golden argv tests and the +frozen-constants test are the drift tripwire. + +Assets flow: `packages/ts/infra/stage-ts-assets.sh` bun-compiles the sidecar +and stages it (plus the oxfmt/oxlint/oxc-parser napi bindings and the bundled +configs) into `driver/internal/embedded/bin/_/`, which `go:embed` +picks up only under the `fmtkit_sidecar` build tag. Dev builds carry no +assets and use `FMTKIT_SUPPORT_DIR` (see `infra/task.sh`). + +## Verification + +Every change must keep green: `go test ./...` + golangci-lint (Go), +typecheck + `test:coverage` (TS, ≥90% lines; Go gated packages ≥85%), +`infra/test-binary-smoke.sh` (the only exercise of the embed path), and +`make format-all` with a clean tree afterwards. Behavior-sensitive layers are +pinned by goldens: pipeline stderr transcripts, report text/json/agent +renders, CLI usage/exit codes for both binaries, and the spacing corpus +(`testdata/corpus`). If a golden fails, the code is wrong — goldens are never +regenerated to make a refactor pass. From b6976be5e80bf700e270e3b834958deaf28702d2 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 24 Jul 2026 12:01:54 +0800 Subject: [PATCH 16/22] docs: fold the architecture overview into the README (#83) --- README.md | 58 +++++++++++++++++----- docs/architecture.md | 112 ------------------------------------------- 2 files changed, 46 insertions(+), 124 deletions(-) delete mode 100644 docs/architecture.md diff --git a/README.md b/README.md index b5e5cde..604ac7b 100644 --- a/README.md +++ b/README.md @@ -286,17 +286,51 @@ That loop points `FMTKIT_SUPPORT_DIR` at the staged assets rather than embedding them, which keeps it fast. The embedded-asset path a release actually uses is covered by `vp run test:binary`. -Package layout: +## How the code is organized -```text -packages/go/ The Go module (go.ollin.sh/fmtkit) -packages/go/driver/ Stand-alone Go CLI, config loading, report rendering -packages/go/vet/ Vet planning and automatic go vet execution -packages/go/formatter/ Formatter planning, engine, rules, and formatters -packages/go/infra/ Go-toolchain task runner -packages/ts/sidecar/ Oxc-based formatting for supported non-Go file types -packages/ts/infra/ Staging for the bun-compiled TS toolchain assets -infra/ Repo-wide tasks, shared shell lib, release scripts -``` +fmtkit is one binary with two halves: + +- A **Go driver** (`packages/go`) that owns the CLI, finds files, formats Go, runs `go vet`, renders reports, and orchestrates the whole run. +- A **TypeScript sidecar** (`packages/ts/sidecar`), compiled with Bun and embedded in the binary, that formats TS/Vue and the embedded blocks in Markdown/HTML. + +The driver runs the sidecar as a child process. Everything that crosses that boundary — the executable name, the modes, the flags, the env vars, the summary lines the driver reads back — is defined once per side (`driver/internal/sidecarproto` in Go, the `cli/` DTOs in TS) and pinned by tests. Change one side and you change the other in the same PR. + +### Go side (`packages/go`, module `go.ollin.sh/fmtkit`) + +The importable library: + +| Package | What it does | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `formatter` | The public entry points: `Check`, `Format`, `CheckFiles`, `FormatFiles`. | +| `formatter/engine` | Runs the formatters over files concurrently and builds the `Report`. | +| `formatter/config` | The single source of truth for formatter settings and defaults. | +| `formatter/rules/spacing` | The spacing rule. Parses each file once, then three types do the work: blank-line insertion, type reordering, embed-directive repair. | +| `vet` | Wraps `go vet` behind an injectable toolchain so tests can fake it. | +| `driver/config` | CLI config. Embeds the formatter config and adds the vet toggle; the `config.yml` schema is a public contract. | +| `driver/report` | Typed output modes and the renderer; the JSON/agent shapes are a public contract. | + +The CLI internals (`driver/internal/...`), one job each: `command` holds the one dispatch table both binaries share; `app` only wires things together; `gotool` is the Go check/format use case returning a typed `Outcome`; `pipeline` runs generic steps whose summaries come from typed results (nothing scrapes rendered text); `console` owns terminal colors and printing; `gitfiles`, `filetypes`, and `prettierignore` each own one kind of file selection, composed by `sourcefiles`; `tsruntime` extracts and spawns the sidecar; `embedded` holds the `go:embed` assets (its `bin/` folder is where staging writes — do not move it). + +### TS side (`packages/ts/sidecar/src`) + +| Directory | What it does | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `kernel/` | `Result` helpers, error types, the concurrency pool. | +| `syntax/` | Parsing and editing: `SourceDocument` (an immutable file value), `SourceParser` (the Zod boundary), `AstReader`, `EditApplier`. | +| `hosts/` | Pulls TS out of `.vue`/`.md`/`.html` files and puts it back. | +| `passes/` | One class per formatting rule. Every pass implements the same small interface: `computeEdits(document)` returns edits. Policy classes hold the layout knowledge. | +| `pipeline/` | Runs passes in order. `PipelineFactory` is the only place a pass sequence is defined; loops and fixed points are declared there, not hidden inside passes. | +| `io/` | File and process access behind ports, with Node adapters. | +| `cli/` | The commands, the DTOs that parse argv, and `CompositionRoot` — the one place everything gets constructed. Entry files are just `main()` shims. | + +Adding a pass: write a class that implements `FormattingPass`, register it in `PipelineFactory`. Nothing else changes. Adding a Go rule: implement the `Rule` interface (`Name()`, `Apply()`) and register it before the engine is built. + +### Ground rules + +- **Logic lives on types.** Go logic belongs to structs with methods; free functions are for small stateless helpers only. TS code lives in classes with real instances and constructor-injected dependencies — the only exceptions are `main()` entry shims, the `Result` helpers, value types with factory statics (the Zod DTOs, `SourceDocument.of`), and error classes. +- **Parse, don't validate.** Outside data enters through a Zod-backed DTO exactly once. No `typeof` checks in TS source. +- **The wire is frozen.** The Go↔TS protocol values never change casually; golden tests on both sides fail loudly if they drift. +- **The repo formats itself.** `make format-all` must leave the tree unchanged. Write class members in the formatter's order (properties, constructor, methods) or the self-check will reorder them for you. +- **Goldens are never regenerated to make a change pass.** Pipeline transcripts, report renders, CLI usage/exit codes, and the spacing corpus are pinned byte-for-byte; if a golden fails, the code is wrong. -The pipeline runs `source → spacing rule → gofmt → goimports`, skipping any stage disabled in config. New rules can be added by implementing the `Rule` interface (`Name()`, `Apply()`) and registering them with the rule set before the engine is constructed. +The Go pipeline runs `source → spacing rule → gofmt → goimports`, skipping any stage disabled in config. diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index c9c1b2f..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,112 +0,0 @@ -# Architecture - -fmtkit is one binary with two halves: a Go driver that owns the CLI, file -discovery, reporting, and orchestration, and a bun-compiled TypeScript sidecar -that owns the TS/Vue formatting passes. This document explains how the pieces -fit, the contracts between them, and the design rules the codebase follows. - -## Design rules - -- **Behavior lives on types.** Go logic belongs to structs with methods that - share state through their owner (parse context, tree handles, printers) — - free functions are reserved for genuinely stateless leaf predicates. - TypeScript code lives behind classes with real instances and - constructor-injected dependencies. -- **Sanctioned exceptions (TS).** Only these may be static or free: - entrypoint `main()` bootstraps (a `main` plus a run-as-main guard, nothing - else), the documented `Result`/`ok`/`err` helpers in `kernel/result.ts`, - value types with factory statics (Zod DTOs' `parse`/`from`, `SourceDocument.of`, - `IterationBudget.once`), and `Error` subclasses. -- **Parse, don't validate (TS).** Untrusted data crosses a boundary once, - through a frozen Zod-backed DTO (`*CliDto`, `ParsedSourceDto`). No `typeof` - narrowing in source. The deep AST is the one documented relaxation: only - node envelopes are schema-validated; descendants are trusted Oxc output. -- **The wire is frozen.** Every value crossing the Go↔TS process boundary is - defined exactly once per side (Go: `driver/internal/sidecarproto`; TS: the - CLI DTOs) and covered by golden tests. Changing one requires changing both - sides in the same PR. -- **The repo formats itself.** `make format-all` must leave the tree - unchanged. Write class members in the formatter's order — properties, then - constructor, then methods, blank lines between members — or the self-check - will reorder them for you. - -## Go (`packages/go`, module `go.ollin.sh/fmtkit`) - -### Public library - -| Package | Role | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `formatter` | Facade: `Check`/`Format`/`CheckFiles`/`FormatFiles` | -| `formatter/engine` | `Engine` runs `Formatter` implementations concurrently, produces `Report` | -| `formatter/config` | The single source of truth for formatter configuration and defaults | -| `formatter/rules/spacing` | The spacing rule. Internally: `fileContext` (parse once) shared by `blankLineInserter`, `typeOrderRewriter`, `embedDirectiveRepairer` | -| `vet` | `go vet` wrapper with an injectable toolchain for tests | -| `driver/config` | CLI config: embeds `formatter/config.Config` (`mapstructure:",squash"`) plus the vet toggle; `config.yml` schema is a public contract | -| `driver/report` | Typed `Mode`/`Format` values, `Renderer{Root, Mode}`; the JSON/agent output shapes are a public contract | - -### Driver internals (`driver/internal/...`) - -| Package | Role | -| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `command` | `Command` + `Set`: the one dispatch table. Both binaries and the umbrella `go` subcommand are `Set`s built by `app` | -| `app` | Composition root only: builds the command `Set`s, constructs the pipeline `Step`s, resolves color once | -| `gotool` | The Go check/format use case: `ParseInvocation` → `Invocation`, `Execute(Request)` → `Outcome` (typed result; owns exit policy via `Outcome.ExitCode`) | -| `pipeline` | Generic mechanism: `Step`/`Result`/`Detail` + the section/tee/quiet-failure loop. Steps compute summaries from typed results — never by scraping rendered text | -| `console` | Terminal presentation: `DetectColor` (the only NO_COLOR/FORCE_COLOR read) + `Printer` | -| `gitfiles` | `Tree`: git-based file discovery, `Selection`, `IntersectChanged` | -| `filetypes` | `Filter`: extension taxonomy (formattable/lintable) | -| `prettierignore` | `Matcher`: full `.prettierignore` gitignore semantics | -| `sourcefiles` | `Collector{Tree, Selection, Filter}`: composition of the three above | -| `sidecarproto` | The typed Go↔TS seam (see below) | -| `tsruntime` | `Assets` (extracted toolchain lifecycle), `Invoker` (spawns the sidecar via `sidecarproto`), `PrettierMigration` | -| `embedded` | `go:embed` of the staged sidecar per platform; `bin/` must stay a child of this package (staging writes there) | - -## TypeScript sidecar (`packages/ts/sidecar/src`) - -| Directory | Role | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `kernel/` | `result.ts` (sanctioned helpers), tagged errors, concurrency pool | -| `syntax/` | `SourceDocument` (frozen value object: text + coordinate queries), `SourceParser` (the Zod parse boundary), `AstReader`, `Edit` + `EditApplier`, `node-schema` | -| `hosts/` | Embedded-language handling: `EmbeddedBlockSplitter` + `VueScriptScanner`/`MarkdownFenceScanner`, `FileTargetPolicy` | -| `passes/` | One class per rule, all implementing `FormattingPass { name; computeEdits(document): Edit[] }`. Policies (`StatementSpacingPolicy`, `ClassMemberPolicy`, `VueReactivityIdioms`) hold the layout knowledge; `drizzle/` holds the vocabulary/scanner/classifier/writer collaborators | -| `pipeline/` | `PassPipeline`/`PipelineStep`/`IterationBudget` (fixed-point loops are declared here, not hidden in passes); `PipelineFactory` — **the only place pass sequences are named**; `FileFormatter` (host-aware transform), `SourceFileEditor` (read→transform→compare→atomic write), `FormatPipeline`, `SyntaxValidator` | -| `io/` | `SourceFiles`/`ProcessRunner` ports + Node adapters | -| `cli/` | `CliCommand` contract, `CompositionRoot.production()` (the DI wiring point), command classes, reporters, and the argv DTOs. Entry files are `main()` shims | -| `sidecar.ts` | The wire entry: dispatches `pipeline`/`oxfmt`/`oxlint` modes | - -Adding a pass: implement `FormattingPass`, register it in `PipelineFactory` — -nothing else changes. The file-set schedule (segment → oxfmt → fluent → -segment fixed-point → validate) lives once, in `FormatAllCommand`. - -Module resolution uses wildcard subpath imports (`#sidecar/*` → `./src/*.ts`) -declared in both `sidecar/package.json` and `sidecar/src/package.json` (the -staging script copies `src/` with the inner map). All imports must use the -alias — `alias-specifiers.test.ts` enforces it. The import graph is -cycle-free. - -## The Go↔TS seam - -The driver spawns the staged `fmtkit-ts-sidecar` executable. Everything on -the wire — the executable name, `.oxfmtrc.json`/`.oxlintrc.json`, the -`pipeline|oxfmt|oxlint` modes, the argv flags, the `FMTKIT_*`/`OX*_BIN` -override env vars, and the stdout summary prefixes the driver parses back — -is defined in `driver/internal/sidecarproto` on the Go side and consumed by -`sidecar.ts` + the `cli/` DTOs on the TS side. Golden argv tests and the -frozen-constants test are the drift tripwire. - -Assets flow: `packages/ts/infra/stage-ts-assets.sh` bun-compiles the sidecar -and stages it (plus the oxfmt/oxlint/oxc-parser napi bindings and the bundled -configs) into `driver/internal/embedded/bin/_/`, which `go:embed` -picks up only under the `fmtkit_sidecar` build tag. Dev builds carry no -assets and use `FMTKIT_SUPPORT_DIR` (see `infra/task.sh`). - -## Verification - -Every change must keep green: `go test ./...` + golangci-lint (Go), -typecheck + `test:coverage` (TS, ≥90% lines; Go gated packages ≥85%), -`infra/test-binary-smoke.sh` (the only exercise of the embed path), and -`make format-all` with a clean tree afterwards. Behavior-sensitive layers are -pinned by goldens: pipeline stderr transcripts, report text/json/agent -renders, CLI usage/exit codes for both binaries, and the spacing corpus -(`testdata/corpus`). If a golden fails, the code is wrong — goldens are never -regenerated to make a refactor pass. From ab0cac91d4e72e95732454a108cbed65eede3cc0 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 24 Jul 2026 12:38:14 +0800 Subject: [PATCH 17/22] =?UTF-8?q?refactor(go):=20G7=20=E2=80=94=20separate?= =?UTF-8?q?=20Go=20and=20TS=20behaviour=20behind=20language=20toolchains?= =?UTF-8?q?=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(go): G7 — add the toolchain contract and registry * refactor(go): G7 — move gotool to the golang lane with its format step * refactor(go): G7 — move the TS lane under typescript/ with its steps * refactor(go): G7 — move embedded under typescript/ and retarget staging * refactor(go): G7 — rewire app to build lanes through the toolchain registry * docs: describe the language-lane driver layout (G7) --- .gitignore | 2 +- README.md | 6 +- infra/task.sh | 6 +- packages/go/driver/internal/app/app.go | 23 +- packages/go/driver/internal/app/doc.go | 2 +- packages/go/driver/internal/app/format.go | 13 +- packages/go/driver/internal/app/options.go | 12 +- packages/go/driver/internal/app/steps.go | 309 ------------------ packages/go/driver/internal/app/steps_test.go | 235 ------------- packages/go/driver/internal/app/ts.go | 10 +- .../internal/{gotool => golang}/execute.go | 2 +- .../{gotool => golang}/execute_test.go | 2 +- .../internal/{gotool => golang}/parser.go | 2 +- .../{gotool => golang}/parser_test.go | 2 +- .../internal/{gotool => golang}/runner.go | 2 +- .../{gotool => golang}/runner_test.go | 2 +- packages/go/driver/internal/golang/step.go | 145 ++++++++ .../go/driver/internal/golang/step_test.go | 146 +++++++++ .../go/driver/internal/toolchain/toolchain.go | 71 ++++ .../internal/toolchain/toolchain_test.go | 98 ++++++ .../internal/{ => typescript}/embedded/doc.go | 0 .../embedded/sidecar_darwin_amd64.go | 0 .../embedded/sidecar_darwin_arm64.go | 0 .../{ => typescript}/embedded/sidecar_dev.go | 0 .../embedded/sidecar_linux_amd64.go | 0 .../embedded/sidecar_linux_arm64.go | 0 .../{ => typescript}/filetypes/filetypes.go | 0 .../filetypes/filetypes_test.go | 0 .../prettierignore/prettierignore.go | 0 .../prettierignore/prettierignore_test.go | 0 .../proto}/command.go | 2 +- .../proto}/command_test.go | 2 +- .../proto}/sidecarproto.go | 4 +- .../proto}/sidecarproto_test.go | 2 +- .../proto}/summary.go | 2 +- .../proto}/summary_test.go | 2 +- .../runtime}/assets.go | 28 +- .../runtime}/invoker.go | 14 +- .../runtime}/prettier.go | 10 +- .../runtime}/prettier_internal_test.go | 10 +- .../runtime}/prettier_test.go | 30 +- .../runtime}/run_test.go | 26 +- .../runtime}/support_test.go | 22 +- .../{ => typescript}/sourcefiles/command.go | 0 .../sourcefiles/command_test.go | 0 .../sourcefiles/prettierignore_test.go | 0 .../sourcefiles/sourcefiles.go | 4 +- .../sourcefiles/sourcefiles_test.go | 0 .../go/driver/internal/typescript/step.go | 192 +++++++++++ .../driver/internal/typescript/step_test.go | 104 ++++++ packages/ts/infra/stage-ts-assets.sh | 6 +- 51 files changed, 893 insertions(+), 657 deletions(-) delete mode 100644 packages/go/driver/internal/app/steps.go delete mode 100644 packages/go/driver/internal/app/steps_test.go rename packages/go/driver/internal/{gotool => golang}/execute.go (99%) rename packages/go/driver/internal/{gotool => golang}/execute_test.go (99%) rename packages/go/driver/internal/{gotool => golang}/parser.go (99%) rename packages/go/driver/internal/{gotool => golang}/parser_test.go (99%) rename packages/go/driver/internal/{gotool => golang}/runner.go (99%) rename packages/go/driver/internal/{gotool => golang}/runner_test.go (99%) create mode 100644 packages/go/driver/internal/golang/step.go create mode 100644 packages/go/driver/internal/golang/step_test.go create mode 100644 packages/go/driver/internal/toolchain/toolchain.go create mode 100644 packages/go/driver/internal/toolchain/toolchain_test.go rename packages/go/driver/internal/{ => typescript}/embedded/doc.go (100%) rename packages/go/driver/internal/{ => typescript}/embedded/sidecar_darwin_amd64.go (100%) rename packages/go/driver/internal/{ => typescript}/embedded/sidecar_darwin_arm64.go (100%) rename packages/go/driver/internal/{ => typescript}/embedded/sidecar_dev.go (100%) rename packages/go/driver/internal/{ => typescript}/embedded/sidecar_linux_amd64.go (100%) rename packages/go/driver/internal/{ => typescript}/embedded/sidecar_linux_arm64.go (100%) rename packages/go/driver/internal/{ => typescript}/filetypes/filetypes.go (100%) rename packages/go/driver/internal/{ => typescript}/filetypes/filetypes_test.go (100%) rename packages/go/driver/internal/{ => typescript}/prettierignore/prettierignore.go (100%) rename packages/go/driver/internal/{ => typescript}/prettierignore/prettierignore_test.go (100%) rename packages/go/driver/internal/{sidecarproto => typescript/proto}/command.go (99%) rename packages/go/driver/internal/{sidecarproto => typescript/proto}/command_test.go (99%) rename packages/go/driver/internal/{sidecarproto => typescript/proto}/sidecarproto.go (96%) rename packages/go/driver/internal/{sidecarproto => typescript/proto}/sidecarproto_test.go (99%) rename packages/go/driver/internal/{sidecarproto => typescript/proto}/summary.go (99%) rename packages/go/driver/internal/{sidecarproto => typescript/proto}/summary_test.go (98%) rename packages/go/driver/internal/{tsruntime => typescript/runtime}/assets.go (85%) rename packages/go/driver/internal/{tsruntime => typescript/runtime}/invoker.go (95%) rename packages/go/driver/internal/{tsruntime => typescript/runtime}/prettier.go (95%) rename packages/go/driver/internal/{tsruntime => typescript/runtime}/prettier_internal_test.go (90%) rename packages/go/driver/internal/{tsruntime => typescript/runtime}/prettier_test.go (90%) rename packages/go/driver/internal/{tsruntime => typescript/runtime}/run_test.go (93%) rename packages/go/driver/internal/{tsruntime => typescript/runtime}/support_test.go (82%) rename packages/go/driver/internal/{ => typescript}/sourcefiles/command.go (100%) rename packages/go/driver/internal/{ => typescript}/sourcefiles/command_test.go (100%) rename packages/go/driver/internal/{ => typescript}/sourcefiles/prettierignore_test.go (100%) rename packages/go/driver/internal/{ => typescript}/sourcefiles/sourcefiles.go (96%) rename packages/go/driver/internal/{ => typescript}/sourcefiles/sourcefiles_test.go (100%) create mode 100644 packages/go/driver/internal/typescript/step.go create mode 100644 packages/go/driver/internal/typescript/step_test.go diff --git a/.gitignore b/.gitignore index 2df9875..c0d5ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ # TS toolchain assets staged per platform by stage-ts-assets.sh, next to the # package that embeds them. -/packages/go/driver/internal/embedded/bin/ +/packages/go/driver/internal/typescript/embedded/bin/ /storage/bin*/ /storage/dist/ /storage/dist-test/ diff --git a/README.md b/README.md index 604ac7b..19eee74 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ make check # Go formatter in check mode ``` The first run stages the host TS toolchain assets into -`packages/go/driver/internal/embedded/bin/_/` (this needs Bun and takes a +`packages/go/driver/internal/typescript/embedded/bin/_/` (this needs Bun and takes a few seconds); later runs reuse them and re-stage only when the support scripts, the tool pins, or the `.oxfmtrc.json` / `.oxlintrc.json` configs change. The inner loop is then a plain incremental `go build`. @@ -293,7 +293,7 @@ fmtkit is one binary with two halves: - A **Go driver** (`packages/go`) that owns the CLI, finds files, formats Go, runs `go vet`, renders reports, and orchestrates the whole run. - A **TypeScript sidecar** (`packages/ts/sidecar`), compiled with Bun and embedded in the binary, that formats TS/Vue and the embedded blocks in Markdown/HTML. -The driver runs the sidecar as a child process. Everything that crosses that boundary — the executable name, the modes, the flags, the env vars, the summary lines the driver reads back — is defined once per side (`driver/internal/sidecarproto` in Go, the `cli/` DTOs in TS) and pinned by tests. Change one side and you change the other in the same PR. +The driver runs the sidecar as a child process. Everything that crosses that boundary — the executable name, the modes, the flags, the env vars, the summary lines the driver reads back — is defined once per side (`driver/internal/typescript/proto` in Go, the `cli/` DTOs in TS) and pinned by tests. Change one side and you change the other in the same PR. ### Go side (`packages/go`, module `go.ollin.sh/fmtkit`) @@ -309,7 +309,7 @@ The importable library: | `driver/config` | CLI config. Embeds the formatter config and adds the vet toggle; the `config.yml` schema is a public contract. | | `driver/report` | Typed output modes and the renderer; the JSON/agent shapes are a public contract. | -The CLI internals (`driver/internal/...`), one job each: `command` holds the one dispatch table both binaries share; `app` only wires things together; `gotool` is the Go check/format use case returning a typed `Outcome`; `pipeline` runs generic steps whose summaries come from typed results (nothing scrapes rendered text); `console` owns terminal colors and printing; `gitfiles`, `filetypes`, and `prettierignore` each own one kind of file selection, composed by `sourcefiles`; `tsruntime` extracts and spawns the sidecar; `embedded` holds the `go:embed` assets (its `bin/` folder is where staging writes — do not move it). +The CLI internals (`driver/internal/...`), one job each: `command` holds the one dispatch table both binaries share; `app` only wires things together, registering the language lanes with `toolchain` — the contract and registry that turn `--ts`/`--go` into an ordered set of lanes to run (no flags means all, TS before Go); `pipeline` runs generic steps whose summaries come from typed results (nothing scrapes rendered text); `console` owns terminal colors and printing; `gitfiles` owns git-backed file selection. Each language then owns its own behaviour in its own package: `golang` is the Go check/format use case (returning a typed `Outcome`) plus its format step; `typescript` builds the TS/Vue lint and format steps and splits its machinery across subpackages — `typescript/runtime` extracts and spawns the sidecar, `typescript/proto` is the frozen wire protocol, `typescript/filetypes` and `typescript/prettierignore` each own one kind of file selection composed by `typescript/sourcefiles`, and `typescript/embedded` holds the `go:embed` assets (its `bin/` folder is where staging writes — do not move it). ### TS side (`packages/ts/sidecar/src`) diff --git a/infra/task.sh b/infra/task.sh index 140f56c..ccdcf12 100755 --- a/infra/task.sh +++ b/infra/task.sh @@ -73,7 +73,7 @@ sidecar_is_stale() { run_fmtkit() { local support_dir sidecar bin - support_dir="${REPO_ROOT}/packages/go/driver/internal/embedded/bin/$(host_target)" + support_dir="${REPO_ROOT}/packages/go/driver/internal/typescript/embedded/bin/$(host_target)" sidecar="${support_dir}/fmtkit-ts-sidecar" if sidecar_is_stale "$sidecar"; then @@ -201,8 +201,8 @@ run_coverage() { # shared test helpers are excluded — nothing else, so the number stays # honest. The threshold is a ratchet: it holds at today's coverage and goes # up as the under-tested packages (driver/config, internal/app, - # internal/sourcefiles) gain tests; it never goes down. - grep -vE '^go\.ollin\.sh/fmtkit/(driver/cmd/fmtkit/|driver/internal/embedded/|driver/testutil/)' \ + # internal/typescript/sourcefiles) gain tests; it never goes down. + grep -vE '^go\.ollin\.sh/fmtkit/(driver/cmd/fmtkit/|driver/internal/typescript/embedded/|driver/testutil/)' \ "${GO_WORKDIR}/coverage.out" > "${GO_WORKDIR}/coverage.gate.out" go_coverage="$(go -C "$GO_WORKDIR" tool cover -func=coverage.gate.out | awk '/^total:/ { gsub(/%/, "", $3); print $3 }')" diff --git a/packages/go/driver/internal/app/app.go b/packages/go/driver/internal/app/app.go index 370dd34..0d8593d 100644 --- a/packages/go/driver/internal/app/app.go +++ b/packages/go/driver/internal/app/app.go @@ -6,8 +6,10 @@ import ( "io" "go.ollin.sh/fmtkit/driver/internal/command" - "go.ollin.sh/fmtkit/driver/internal/gotool" - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" + "go.ollin.sh/fmtkit/driver/internal/golang" + "go.ollin.sh/fmtkit/driver/internal/toolchain" + "go.ollin.sh/fmtkit/driver/internal/typescript" + "go.ollin.sh/fmtkit/driver/internal/typescript/sourcefiles" report "go.ollin.sh/fmtkit/driver/report" ) @@ -19,6 +21,10 @@ type deps struct { stdout io.Writer stderr io.Writer + // toolchains are the language lanes the format pipeline runs, in execution + // order (ts before go). The --ts/--go flags select among them. + toolchains toolchain.Registry + // usage prints the enclosing Set's usage text; wired after the Set exists so // the flag-parsing handlers can reprint it on a bad argument. usage func(io.Writer) @@ -31,7 +37,14 @@ const umbrellaHeader = "usage: fmtkit 0 { - details = append(details, pipeline.Detail{Label: "skipped", Value: fmt.Sprintf("%d missing tracked file(s)", missing)}) - } - - if summary.Oxfmt != "" { - details = append(details, pipeline.Detail{Label: "oxfmt", Value: summary.Oxfmt}) - } - - if summary.FluentChains != "" { - details = append(details, pipeline.Detail{Label: "fluent", Value: summary.FluentChains}) - } - - if summary.ValidateSyntax != "" { - details = append(details, pipeline.Detail{Label: "validated", Value: summary.ValidateSyntax}) - } - - return details -} - -// goFormatDetails computes the Go step's detail lines from the typed outcome, -// reproducing the exact strings the text report renders (which the pipeline -// previously scraped back out of that rendered text). -func goFormatDetails(outcome gotool.Outcome) []pipeline.Detail { - fm := outcome.Combined.Formatter - vt := outcome.Combined.Vet - - var details []pipeline.Detail - - if summary := goFileSummary(fm, outcome.Mode); summary != "" { - details = append(details, pipeline.Detail{Label: "fmtkit", Value: summary}) - } - - // The formatter renders a Result line unless it found no files and hit no - // errors; the vet Result line always renders. The "result" detail is the - // first Result line (the formatter's when present, else the vet's), matching - // the text report's top-to-bottom order. - formatterResult := "" - - if fm.Files != 0 || len(fm.Errors) != 0 { - formatterResult = fmt.Sprintf("%s. %d changed, %d violation(s), %d error(s).", fm.Result, fm.Changed, fm.ViolationCount(), fm.ErrorCount()) - } - - vetResult := fmt.Sprintf("%s. %d error(s).", goVetStatus(vt), vt.ErrorCount()) - - resultLine := formatterResult - - if resultLine == "" { - resultLine = vetResult - } - - details = append(details, pipeline.Detail{Label: "result", Value: resultLine}) - - if summary := goVetSummary(vt); summary != "" { - details = append(details, pipeline.Detail{Label: "vet", Value: summary}) - } - - if vetResult != resultLine { - details = append(details, pipeline.Detail{Label: "vet result", Value: vetResult}) - } - - return details -} - -// goFileSummary is the formatter's file-count line: "No Go files found." when it -// owns none, otherwise the mode's verb and count. -func goFileSummary(fm formatterengine.Report, mode report.Mode) string { - if fm.Files == 0 { - return "No Go files found." - } - - action := "Checked" - - if mode == report.ModeFormat { - action = "Formatted" - } - - return fmt.Sprintf("%s %d file(s).", action, fm.Files) -} - -// goVetStatus classifies the vet report the same way the text report does. -func goVetStatus(vt vet.Report) string { - switch { - case vt.Skipped || vt.Root == "": - return "skipped" - case vt.ErrorCount() > 0: - return "fail" - default: - return "pass" - } -} - -// goVetSummary is the vet status line, or "" for a failure (whose per-error -// lines the text report shows instead of a one-line summary). -func goVetSummary(vt vet.Report) string { - switch goVetStatus(vt) { - case "skipped": - reason := "no Go module or workspace was detected" - - if vt.Skipped { - reason = "the Go toolchain is not available" - } - - return "Skipped automatic go vet ./... because " + reason + "." - case "pass": - return "go vet ./... passed." - default: - return "" - } -} diff --git a/packages/go/driver/internal/app/steps_test.go b/packages/go/driver/internal/app/steps_test.go deleted file mode 100644 index c9d5725..0000000 --- a/packages/go/driver/internal/app/steps_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package app - -import ( - "bytes" - "errors" - "fmt" - "testing" - - "go.ollin.sh/fmtkit/driver/internal/gotool" - "go.ollin.sh/fmtkit/driver/internal/pipeline" - report "go.ollin.sh/fmtkit/driver/report" - formatterengine "go.ollin.sh/fmtkit/formatter/engine" - "go.ollin.sh/fmtkit/vet" -) - -func detailStrings(details []pipeline.Detail) []string { - out := make([]string, 0, len(details)) - - for _, d := range details { - out = append(out, d.Label+"|"+d.Value) - } - - return out -} - -func assertDetails(t *testing.T, got []pipeline.Detail, want ...string) { - t.Helper() - - if g := fmt.Sprint(detailStrings(got)); g != fmt.Sprint(want) { - t.Fatalf("details mismatch\n--- got ---\n%s\n--- want ---\n%s", g, fmt.Sprint(want)) - } -} - -// goOutcome builds a Go outcome for the given mode, formatter report, and vet -// report. -func goOutcome(mode report.Mode, fm formatterengine.Report, vt vet.Report) gotool.Outcome { - return gotool.Outcome{Mode: mode, Combined: report.Combined{Formatter: fm, Vet: vt}} -} - -func TestGoFormatDetailsPass(t *testing.T) { - outcome := goOutcome( - report.ModeFormat, - formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, - vet.Report{Root: "/work"}, - ) - - assertDetails(t, goFormatDetails(outcome), - "fmtkit|Formatted 2 file(s).", - "result|pass. 0 changed, 0 violation(s), 0 error(s).", - "vet|go vet ./... passed.", - "vet result|pass. 0 error(s).", - ) -} - -func TestGoFormatDetailsCheckModeVerb(t *testing.T) { - outcome := goOutcome( - report.ModeCheck, - formatterengine.Report{Result: formatterengine.ResultPass, Files: 3}, - vet.Report{Root: "/work"}, - ) - - assertDetails(t, goFormatDetails(outcome), - "fmtkit|Checked 3 file(s).", - "result|pass. 0 changed, 0 violation(s), 0 error(s).", - "vet|go vet ./... passed.", - "vet result|pass. 0 error(s).", - ) -} - -// TestGoFormatDetailsNoFiles reproduces the scraper's quirk: with no formatter -// Result line rendered, the "result" detail borrows the vet Result line and the -// separate "vet result" line is suppressed (they are identical). -func TestGoFormatDetailsNoFiles(t *testing.T) { - outcome := goOutcome( - report.ModeFormat, - formatterengine.Report{Result: formatterengine.ResultPass, Files: 0}, - vet.Report{Root: "/work"}, - ) - - assertDetails(t, goFormatDetails(outcome), - "fmtkit|No Go files found.", - "result|pass. 0 error(s).", - "vet|go vet ./... passed.", - ) -} - -func TestGoFormatDetailsVetSkippedNoModule(t *testing.T) { - outcome := goOutcome( - report.ModeFormat, - formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, - vet.Report{Root: ""}, - ) - - assertDetails(t, goFormatDetails(outcome), - "fmtkit|Formatted 1 file(s).", - "result|pass. 0 changed, 0 violation(s), 0 error(s).", - "vet|Skipped automatic go vet ./... because no Go module or workspace was detected.", - "vet result|skipped. 0 error(s).", - ) -} - -func TestGoFormatDetailsVetSkippedToolchain(t *testing.T) { - outcome := goOutcome( - report.ModeFormat, - formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, - vet.Report{Root: "/work", Skipped: true}, - ) - - assertDetails(t, goFormatDetails(outcome), - "fmtkit|Formatted 1 file(s).", - "result|pass. 0 changed, 0 violation(s), 0 error(s).", - "vet|Skipped automatic go vet ./... because the Go toolchain is not available.", - "vet result|skipped. 0 error(s).", - ) -} - -// TestGoFormatDetailsVetFailure: a vet failure renders per-error lines instead -// of a status summary, so there is no "vet" detail, but the differing vet Result -// line still appears. -func TestGoFormatDetailsVetFailure(t *testing.T) { - outcome := goOutcome( - report.ModeFormat, - formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, - vet.Report{Root: "/work", Errors: []vet.ErrorResult{{File: "a.go", Message: "boom"}}}, - ) - - assertDetails(t, goFormatDetails(outcome), - "fmtkit|Formatted 2 file(s).", - "result|pass. 0 changed, 0 violation(s), 0 error(s).", - "vet result|fail. 1 error(s).", - ) -} - -func TestTSFormatDetails(t *testing.T) { - log := "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + - "Finished in 10ms on 3 files using 8 threads.\n" + - "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + - "[validate-syntax] checked 3 file(s).\n" - - assertDetails(t, tsFormatDetails(log), - "blank-lines|processed 3 file(s) in /work, 0 changed", - "oxfmt|Finished in 10ms on 3 files using 8 threads.", - "fluent|processed 3 file(s) in /work, 1 changed", - "validated|checked 3 file(s).", - ) -} - -func TestTSFormatDetailsCountsMissing(t *testing.T) { - log := "[sources] path not found, skipping: /work/a\n" + - "[sources] path not found, skipping: /work/b\n" - - assertDetails(t, tsFormatDetails(log), "skipped|2 missing tracked file(s)") -} - -func TestTSLintDetailsResult(t *testing.T) { - assertDetails(t, tsLintDetails("Found 0 warnings and 0 errors.\n"), "oxlint|Found 0 warnings and 0 errors.") -} - -func TestTSLintDetailsNoFiles(t *testing.T) { - assertDetails(t, tsLintDetails("[lint] no TS/Vue files to lint.\n"), "oxlint|no TS/Vue files to lint.") -} - -func TestTSLintDetailsFallback(t *testing.T) { - assertDetails(t, tsLintDetails("nothing interesting\n"), "oxlint|no issues found") -} - -func TestTSExitCodePlainErrorWritesToOutput(t *testing.T) { - var buf bytes.Buffer - - if code := tsExitCode(errors.New("boom"), &buf); code != 1 { - t.Fatalf("tsExitCode = %d, want 1", code) - } - - if buf.String() != "boom\n" { - t.Fatalf("tsExitCode output = %q, want %q", buf.String(), "boom\n") - } -} - -func TestTSExitCodeNil(t *testing.T) { - var buf bytes.Buffer - - if code := tsExitCode(nil, &buf); code != 0 { - t.Fatalf("tsExitCode(nil) = %d, want 0", code) - } - - if buf.Len() != 0 { - t.Fatalf("tsExitCode(nil) wrote %q", buf.String()) - } -} - -func TestStepSelectionNormalized(t *testing.T) { - if got := (stepSelection{}).normalized(); !got.TS || !got.Go { - t.Fatalf("zero selection = %+v, want both set", got) - } - - if got := (stepSelection{TS: true}).normalized(); !got.TS || got.Go { - t.Fatalf("TS-only selection = %+v, want TS only", got) - } - - if got := (stepSelection{Go: true}).normalized(); got.TS || !got.Go { - t.Fatalf("Go-only selection = %+v, want Go only", got) - } -} - -func TestFormatStepsSelection(t *testing.T) { - d := &deps{version: "dev"} - - labels := func(steps []pipeline.Step) []string { - out := make([]string, 0, len(steps)) - - for _, s := range steps { - out = append(out, s.Label()) - } - - return out - } - - all := labels(d.formatSteps([]string{"."}, stepSelection{}, 0)) - - if fmt.Sprint(all) != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting", "Running Go formatting"}) { - t.Fatalf("default steps = %v", all) - } - - tsOnly := labels(d.formatSteps([]string{"."}, stepSelection{TS: true}, 0)) - - if fmt.Sprint(tsOnly) != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting"}) { - t.Fatalf("--ts steps = %v", tsOnly) - } - - goOnly := labels(d.formatSteps([]string{"."}, stepSelection{Go: true}, 0)) - - if fmt.Sprint(goOnly) != fmt.Sprint([]string{"Running Go formatting"}) { - t.Fatalf("--go steps = %v", goOnly) - } -} diff --git a/packages/go/driver/internal/app/ts.go b/packages/go/driver/internal/app/ts.go index 65ab0f4..520496f 100644 --- a/packages/go/driver/internal/app/ts.go +++ b/packages/go/driver/internal/app/ts.go @@ -3,25 +3,25 @@ package app import ( "context" - "go.ollin.sh/fmtkit/driver/internal/tsruntime" + "go.ollin.sh/fmtkit/driver/internal/typescript/runtime" ) func (d *deps) runTS(ctx context.Context, paths []string) int { - assets, err := tsruntime.Resolve(d.version) + assets, err := runtime.Resolve(d.version) if err != nil { return d.reportError(err) } - return d.reportError(tsruntime.NewInvoker(assets).RunPipeline(ctx, tsruntime.Request{Scopes: paths, Stdout: d.stdout, Stderr: d.stderr})) + return d.reportError(runtime.NewInvoker(assets).RunPipeline(ctx, runtime.Request{Scopes: paths, Stdout: d.stdout, Stderr: d.stderr})) } func (d *deps) runLint(ctx context.Context, paths []string) int { - assets, err := tsruntime.Resolve(d.version) + assets, err := runtime.Resolve(d.version) if err != nil { return d.reportError(err) } - return d.reportError(tsruntime.NewInvoker(assets).RunLint(ctx, tsruntime.Request{Scopes: paths, Stdout: d.stdout, Stderr: d.stderr})) + return d.reportError(runtime.NewInvoker(assets).RunLint(ctx, runtime.Request{Scopes: paths, Stdout: d.stdout, Stderr: d.stderr})) } diff --git a/packages/go/driver/internal/gotool/execute.go b/packages/go/driver/internal/golang/execute.go similarity index 99% rename from packages/go/driver/internal/gotool/execute.go rename to packages/go/driver/internal/golang/execute.go index b89ccc6..da16ceb 100644 --- a/packages/go/driver/internal/gotool/execute.go +++ b/packages/go/driver/internal/golang/execute.go @@ -1,4 +1,4 @@ -package gotool +package golang import ( "context" diff --git a/packages/go/driver/internal/gotool/execute_test.go b/packages/go/driver/internal/golang/execute_test.go similarity index 99% rename from packages/go/driver/internal/gotool/execute_test.go rename to packages/go/driver/internal/golang/execute_test.go index cded312..8c0c9ac 100644 --- a/packages/go/driver/internal/gotool/execute_test.go +++ b/packages/go/driver/internal/golang/execute_test.go @@ -1,4 +1,4 @@ -package gotool +package golang import ( "context" diff --git a/packages/go/driver/internal/gotool/parser.go b/packages/go/driver/internal/golang/parser.go similarity index 99% rename from packages/go/driver/internal/gotool/parser.go rename to packages/go/driver/internal/golang/parser.go index 3b846e5..bd789f0 100644 --- a/packages/go/driver/internal/gotool/parser.go +++ b/packages/go/driver/internal/golang/parser.go @@ -1,4 +1,4 @@ -package gotool +package golang import ( "flag" diff --git a/packages/go/driver/internal/gotool/parser_test.go b/packages/go/driver/internal/golang/parser_test.go similarity index 99% rename from packages/go/driver/internal/gotool/parser_test.go rename to packages/go/driver/internal/golang/parser_test.go index d0405d1..3d0463e 100644 --- a/packages/go/driver/internal/gotool/parser_test.go +++ b/packages/go/driver/internal/golang/parser_test.go @@ -1,4 +1,4 @@ -package gotool +package golang import ( "io" diff --git a/packages/go/driver/internal/gotool/runner.go b/packages/go/driver/internal/golang/runner.go similarity index 99% rename from packages/go/driver/internal/gotool/runner.go rename to packages/go/driver/internal/golang/runner.go index 8895dca..3aee0c4 100644 --- a/packages/go/driver/internal/gotool/runner.go +++ b/packages/go/driver/internal/golang/runner.go @@ -1,4 +1,4 @@ -package gotool +package golang import ( "context" diff --git a/packages/go/driver/internal/gotool/runner_test.go b/packages/go/driver/internal/golang/runner_test.go similarity index 99% rename from packages/go/driver/internal/gotool/runner_test.go rename to packages/go/driver/internal/golang/runner_test.go index 942ef37..dd9f82d 100644 --- a/packages/go/driver/internal/gotool/runner_test.go +++ b/packages/go/driver/internal/golang/runner_test.go @@ -1,4 +1,4 @@ -package gotool +package golang import ( "bytes" diff --git a/packages/go/driver/internal/golang/step.go b/packages/go/driver/internal/golang/step.go new file mode 100644 index 0000000..6c504da --- /dev/null +++ b/packages/go/driver/internal/golang/step.go @@ -0,0 +1,145 @@ +package golang + +import ( + "context" + "fmt" + "io" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/toolchain" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +// Toolchain is the Go lane: it formats Go files and runs go vet, contributing a +// single format step to the pipeline. +type Toolchain struct{} + +type formatStep struct { + paths []string + selection gitfiles.Selection +} + +// New builds the Go toolchain. +func New() Toolchain { return Toolchain{} } + +// Name is the lane's selector, matching the --go flag. +func (Toolchain) Name() string { return "go" } + +// Steps returns the Go lane's ordered steps: just the format step. +func (Toolchain) Steps(req toolchain.Request) []pipeline.Step { + return []pipeline.Step{FormatStep(req.Paths, req.Selection)} +} + +// FormatStep builds the pipeline step that formats Go files and runs go vet, +// deriving its details from the typed outcome rather than the rendered report +// text. +func FormatStep(paths []string, selection gitfiles.Selection) pipeline.Step { + return formatStep{paths: paths, selection: selection} +} + +func (s formatStep) Label() string { return "Running Go formatting" } + +func (s formatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + outcome, code := Runner{Stdout: output, Stderr: output, Scope: s.selection}. + RunReport(ctx, report.ModeFormat, s.paths) + + if code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: formatDetails(outcome)} +} + +// formatDetails computes the Go step's detail lines from the typed outcome, +// reproducing the exact strings the text report renders (which the pipeline +// previously scraped back out of that rendered text). +func formatDetails(outcome Outcome) []pipeline.Detail { + fm := outcome.Combined.Formatter + vt := outcome.Combined.Vet + + var details []pipeline.Detail + + if summary := fileSummary(fm, outcome.Mode); summary != "" { + details = append(details, pipeline.Detail{Label: "fmtkit", Value: summary}) + } + + // The formatter renders a Result line unless it found no files and hit no + // errors; the vet Result line always renders. The "result" detail is the + // first Result line (the formatter's when present, else the vet's), matching + // the text report's top-to-bottom order. + formatterResult := "" + + if fm.Files != 0 || len(fm.Errors) != 0 { + formatterResult = fmt.Sprintf("%s. %d changed, %d violation(s), %d error(s).", fm.Result, fm.Changed, fm.ViolationCount(), fm.ErrorCount()) + } + + vetResult := fmt.Sprintf("%s. %d error(s).", vetStatus(vt), vt.ErrorCount()) + + resultLine := formatterResult + + if resultLine == "" { + resultLine = vetResult + } + + details = append(details, pipeline.Detail{Label: "result", Value: resultLine}) + + if summary := vetSummary(vt); summary != "" { + details = append(details, pipeline.Detail{Label: "vet", Value: summary}) + } + + if vetResult != resultLine { + details = append(details, pipeline.Detail{Label: "vet result", Value: vetResult}) + } + + return details +} + +// fileSummary is the formatter's file-count line: "No Go files found." when it +// owns none, otherwise the mode's verb and count. +func fileSummary(fm formatterengine.Report, mode report.Mode) string { + if fm.Files == 0 { + return "No Go files found." + } + + action := "Checked" + + if mode == report.ModeFormat { + action = "Formatted" + } + + return fmt.Sprintf("%s %d file(s).", action, fm.Files) +} + +// vetStatus classifies the vet report the same way the text report does. +func vetStatus(vt vet.Report) string { + switch { + case vt.Skipped || vt.Root == "": + return "skipped" + case vt.ErrorCount() > 0: + return "fail" + default: + return "pass" + } +} + +// vetSummary is the vet status line, or "" for a failure (whose per-error lines +// the text report shows instead of a one-line summary). +func vetSummary(vt vet.Report) string { + switch vetStatus(vt) { + case "skipped": + reason := "no Go module or workspace was detected" + + if vt.Skipped { + reason = "the Go toolchain is not available" + } + + return "Skipped automatic go vet ./... because " + reason + "." + case "pass": + return "go vet ./... passed." + default: + return "" + } +} diff --git a/packages/go/driver/internal/golang/step_test.go b/packages/go/driver/internal/golang/step_test.go new file mode 100644 index 0000000..b404714 --- /dev/null +++ b/packages/go/driver/internal/golang/step_test.go @@ -0,0 +1,146 @@ +package golang + +import ( + "fmt" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/toolchain" + report "go.ollin.sh/fmtkit/driver/report" + formatterengine "go.ollin.sh/fmtkit/formatter/engine" + "go.ollin.sh/fmtkit/vet" +) + +func detailStrings(details []pipeline.Detail) []string { + out := make([]string, 0, len(details)) + + for _, d := range details { + out = append(out, d.Label+"|"+d.Value) + } + + return out +} + +func assertDetails(t *testing.T, got []pipeline.Detail, want ...string) { + t.Helper() + + if g := fmt.Sprint(detailStrings(got)); g != fmt.Sprint(want) { + t.Fatalf("details mismatch\n--- got ---\n%s\n--- want ---\n%s", g, fmt.Sprint(want)) + } +} + +// outcomeFor builds a Go outcome for the given mode, formatter report, and vet +// report. +func outcomeFor(mode report.Mode, fm formatterengine.Report, vt vet.Report) Outcome { + return Outcome{Mode: mode, Combined: report.Combined{Formatter: fm, Vet: vt}} +} + +func TestFormatDetailsPass(t *testing.T) { + outcome := outcomeFor( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|Formatted 2 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|go vet ./... passed.", + "vet result|pass. 0 error(s).", + ) +} + +func TestFormatDetailsCheckModeVerb(t *testing.T) { + outcome := outcomeFor( + report.ModeCheck, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 3}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|Checked 3 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|go vet ./... passed.", + "vet result|pass. 0 error(s).", + ) +} + +// TestFormatDetailsNoFiles reproduces the scraper's quirk: with no formatter +// Result line rendered, the "result" detail borrows the vet Result line and the +// separate "vet result" line is suppressed (they are identical). +func TestFormatDetailsNoFiles(t *testing.T) { + outcome := outcomeFor( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 0}, + vet.Report{Root: "/work"}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|No Go files found.", + "result|pass. 0 error(s).", + "vet|go vet ./... passed.", + ) +} + +func TestFormatDetailsVetSkippedNoModule(t *testing.T) { + outcome := outcomeFor( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, + vet.Report{Root: ""}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|Formatted 1 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|Skipped automatic go vet ./... because no Go module or workspace was detected.", + "vet result|skipped. 0 error(s).", + ) +} + +func TestFormatDetailsVetSkippedToolchain(t *testing.T) { + outcome := outcomeFor( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 1}, + vet.Report{Root: "/work", Skipped: true}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|Formatted 1 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet|Skipped automatic go vet ./... because the Go toolchain is not available.", + "vet result|skipped. 0 error(s).", + ) +} + +// TestFormatDetailsVetFailure: a vet failure renders per-error lines instead of +// a status summary, so there is no "vet" detail, but the differing vet Result +// line still appears. +func TestFormatDetailsVetFailure(t *testing.T) { + outcome := outcomeFor( + report.ModeFormat, + formatterengine.Report{Result: formatterengine.ResultPass, Files: 2}, + vet.Report{Root: "/work", Errors: []vet.ErrorResult{{File: "a.go", Message: "boom"}}}, + ) + + assertDetails(t, formatDetails(outcome), + "fmtkit|Formatted 2 file(s).", + "result|pass. 0 changed, 0 violation(s), 0 error(s).", + "vet result|fail. 1 error(s).", + ) +} + +func TestSteps(t *testing.T) { + steps := New().Steps(toolchain.Request{Paths: []string{"."}}) + + if len(steps) != 1 { + t.Fatalf("Steps len = %d, want 1", len(steps)) + } + + if got := steps[0].Label(); got != "Running Go formatting" { + t.Fatalf("step label = %q, want %q", got, "Running Go formatting") + } + + if got := New().Name(); got != "go" { + t.Fatalf("Name = %q, want go", got) + } +} diff --git a/packages/go/driver/internal/toolchain/toolchain.go b/packages/go/driver/internal/toolchain/toolchain.go new file mode 100644 index 0000000..86e0b5b --- /dev/null +++ b/packages/go/driver/internal/toolchain/toolchain.go @@ -0,0 +1,71 @@ +// Package toolchain is the contract and registry that separate the format +// pipeline into per-language lanes. Each Toolchain contributes the ordered +// pipeline steps for one language (TS, Go); the Registry holds them in +// registration order, which is the order they run, and resolves the --ts/--go +// selection down to the lanes that should execute. +package toolchain + +import ( + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/pipeline" +) + +// Request carries what a lane needs to build its steps: the binary version +// (the TS lane extracts a per-version toolchain cache from it), the target +// paths, and how much of the working tree the run scopes to. +type Request struct { + Version string + Paths []string + Selection gitfiles.Selection +} + +// A Toolchain contributes the pipeline steps for one language lane. Name is the +// lane's selector, matching the --ts/--go flags; Steps builds the ordered steps +// for a request (TS returns [lint, format]; Go returns [format]). +type Toolchain interface { + Name() string + Steps(req Request) []pipeline.Step +} + +// Registry holds the registered lanes in registration order, which is also +// their execution order. +type Registry struct { + chains []Toolchain +} + +// NewRegistry registers the given lanes in order. The composition root +// constructs and registers them explicitly; there is no init()-based +// self-registration, so registration order is whatever the caller passes. +func NewRegistry(chains ...Toolchain) Registry { + return Registry{chains: chains} +} + +// Select resolves a set of lane names to the lanes that should run. With no +// names it returns every registered lane (the no-flag "everything" default); +// otherwise it returns the registered lanes whose Name is among names. Either +// way the result preserves registration order, and names that match no +// registered lane are ignored. +func (r Registry) Select(names ...string) []Toolchain { + if len(names) == 0 { + out := make([]Toolchain, len(r.chains)) + copy(out, r.chains) + + return out + } + + want := make(map[string]struct{}, len(names)) + + for _, name := range names { + want[name] = struct{}{} + } + + var out []Toolchain + + for _, chain := range r.chains { + if _, ok := want[chain.Name()]; ok { + out = append(out, chain) + } + } + + return out +} diff --git a/packages/go/driver/internal/toolchain/toolchain_test.go b/packages/go/driver/internal/toolchain/toolchain_test.go new file mode 100644 index 0000000..df547eb --- /dev/null +++ b/packages/go/driver/internal/toolchain/toolchain_test.go @@ -0,0 +1,98 @@ +package toolchain + +import ( + "context" + "fmt" + "io" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/pipeline" +) + +// fakeChain is a minimal Toolchain that records its name and a single labelled +// step, enough to assert the registry's selection and ordering. +type fakeChain struct { + name string +} + +// labelStep is a Step whose Label is its name, so a selection can be read back +// as a list of names. +type labelStep string + +func (c fakeChain) Name() string { return c.name } + +func (c fakeChain) Steps(Request) []pipeline.Step { + return []pipeline.Step{labelStep(c.name)} +} + +func (s labelStep) Label() string { return string(s) } + +func (s labelStep) Run(context.Context, io.Writer) pipeline.Result { return pipeline.Result{} } + +func names(chains []Toolchain) []string { + out := make([]string, 0, len(chains)) + + for _, chain := range chains { + out = append(out, chain.Name()) + } + + return out +} + +func TestSelectEmptyReturnsAllInOrder(t *testing.T) { + reg := NewRegistry(fakeChain{"ts"}, fakeChain{"go"}) + + if got := fmt.Sprint(names(reg.Select())); got != fmt.Sprint([]string{"ts", "go"}) { + t.Fatalf("Select() = %s, want [ts go]", got) + } +} + +func TestSelectByNamePreservesRegistrationOrder(t *testing.T) { + reg := NewRegistry(fakeChain{"ts"}, fakeChain{"go"}) + + // Ask in the opposite order; the registry still returns registration order. + if got := fmt.Sprint(names(reg.Select("go", "ts"))); got != fmt.Sprint([]string{"ts", "go"}) { + t.Fatalf("Select(go, ts) = %s, want [ts go]", got) + } +} + +func TestSelectSingleName(t *testing.T) { + reg := NewRegistry(fakeChain{"ts"}, fakeChain{"go"}) + + if got := fmt.Sprint(names(reg.Select("ts"))); got != fmt.Sprint([]string{"ts"}) { + t.Fatalf("Select(ts) = %s, want [ts]", got) + } + + if got := fmt.Sprint(names(reg.Select("go"))); got != fmt.Sprint([]string{"go"}) { + t.Fatalf("Select(go) = %s, want [go]", got) + } +} + +func TestSelectUnknownNamesAreIgnored(t *testing.T) { + reg := NewRegistry(fakeChain{"ts"}, fakeChain{"go"}) + + if got := names(reg.Select("rust")); len(got) != 0 { + t.Fatalf("Select(rust) = %v, want empty", got) + } + + // A known name mixed with an unknown one keeps only the known lane. + if got := fmt.Sprint(names(reg.Select("go", "rust"))); got != fmt.Sprint([]string{"go"}) { + t.Fatalf("Select(go, rust) = %s, want [go]", got) + } +} + +func TestSelectStepsComeFromChosenLanes(t *testing.T) { + reg := NewRegistry(fakeChain{"ts"}, fakeChain{"go"}) + + var labels []string + + for _, chain := range reg.Select() { + for _, step := range chain.Steps(Request{}) { + labels = append(labels, step.Label()) + } + } + + if got := fmt.Sprint(labels); got != fmt.Sprint([]string{"ts", "go"}) { + t.Fatalf("step labels = %s, want [ts go]", got) + } +} diff --git a/packages/go/driver/internal/embedded/doc.go b/packages/go/driver/internal/typescript/embedded/doc.go similarity index 100% rename from packages/go/driver/internal/embedded/doc.go rename to packages/go/driver/internal/typescript/embedded/doc.go diff --git a/packages/go/driver/internal/embedded/sidecar_darwin_amd64.go b/packages/go/driver/internal/typescript/embedded/sidecar_darwin_amd64.go similarity index 100% rename from packages/go/driver/internal/embedded/sidecar_darwin_amd64.go rename to packages/go/driver/internal/typescript/embedded/sidecar_darwin_amd64.go diff --git a/packages/go/driver/internal/embedded/sidecar_darwin_arm64.go b/packages/go/driver/internal/typescript/embedded/sidecar_darwin_arm64.go similarity index 100% rename from packages/go/driver/internal/embedded/sidecar_darwin_arm64.go rename to packages/go/driver/internal/typescript/embedded/sidecar_darwin_arm64.go diff --git a/packages/go/driver/internal/embedded/sidecar_dev.go b/packages/go/driver/internal/typescript/embedded/sidecar_dev.go similarity index 100% rename from packages/go/driver/internal/embedded/sidecar_dev.go rename to packages/go/driver/internal/typescript/embedded/sidecar_dev.go diff --git a/packages/go/driver/internal/embedded/sidecar_linux_amd64.go b/packages/go/driver/internal/typescript/embedded/sidecar_linux_amd64.go similarity index 100% rename from packages/go/driver/internal/embedded/sidecar_linux_amd64.go rename to packages/go/driver/internal/typescript/embedded/sidecar_linux_amd64.go diff --git a/packages/go/driver/internal/embedded/sidecar_linux_arm64.go b/packages/go/driver/internal/typescript/embedded/sidecar_linux_arm64.go similarity index 100% rename from packages/go/driver/internal/embedded/sidecar_linux_arm64.go rename to packages/go/driver/internal/typescript/embedded/sidecar_linux_arm64.go diff --git a/packages/go/driver/internal/filetypes/filetypes.go b/packages/go/driver/internal/typescript/filetypes/filetypes.go similarity index 100% rename from packages/go/driver/internal/filetypes/filetypes.go rename to packages/go/driver/internal/typescript/filetypes/filetypes.go diff --git a/packages/go/driver/internal/filetypes/filetypes_test.go b/packages/go/driver/internal/typescript/filetypes/filetypes_test.go similarity index 100% rename from packages/go/driver/internal/filetypes/filetypes_test.go rename to packages/go/driver/internal/typescript/filetypes/filetypes_test.go diff --git a/packages/go/driver/internal/prettierignore/prettierignore.go b/packages/go/driver/internal/typescript/prettierignore/prettierignore.go similarity index 100% rename from packages/go/driver/internal/prettierignore/prettierignore.go rename to packages/go/driver/internal/typescript/prettierignore/prettierignore.go diff --git a/packages/go/driver/internal/prettierignore/prettierignore_test.go b/packages/go/driver/internal/typescript/prettierignore/prettierignore_test.go similarity index 100% rename from packages/go/driver/internal/prettierignore/prettierignore_test.go rename to packages/go/driver/internal/typescript/prettierignore/prettierignore_test.go diff --git a/packages/go/driver/internal/sidecarproto/command.go b/packages/go/driver/internal/typescript/proto/command.go similarity index 99% rename from packages/go/driver/internal/sidecarproto/command.go rename to packages/go/driver/internal/typescript/proto/command.go index b2934b3..3a398bc 100644 --- a/packages/go/driver/internal/sidecarproto/command.go +++ b/packages/go/driver/internal/typescript/proto/command.go @@ -1,4 +1,4 @@ -package sidecarproto +package proto // The command types below build the exact argument vectors each sidecar mode // expects. The bin resolution (which executable to spawn) is the caller's diff --git a/packages/go/driver/internal/sidecarproto/command_test.go b/packages/go/driver/internal/typescript/proto/command_test.go similarity index 99% rename from packages/go/driver/internal/sidecarproto/command_test.go rename to packages/go/driver/internal/typescript/proto/command_test.go index 1d2c62a..aca383d 100644 --- a/packages/go/driver/internal/sidecarproto/command_test.go +++ b/packages/go/driver/internal/typescript/proto/command_test.go @@ -1,4 +1,4 @@ -package sidecarproto +package proto import ( "reflect" diff --git a/packages/go/driver/internal/sidecarproto/sidecarproto.go b/packages/go/driver/internal/typescript/proto/sidecarproto.go similarity index 96% rename from packages/go/driver/internal/sidecarproto/sidecarproto.go rename to packages/go/driver/internal/typescript/proto/sidecarproto.go index 4cc3679..1066382 100644 --- a/packages/go/driver/internal/sidecarproto/sidecarproto.go +++ b/packages/go/driver/internal/typescript/proto/sidecarproto.go @@ -1,4 +1,4 @@ -// Package sidecarproto is the single source of truth for the stringly-typed +// Package proto is the single source of truth for the stringly-typed // wire protocol between the Go driver and the bun-compiled TS sidecar: the // asset filenames, the sidecar's dispatch modes, the environment variables that // override toolchain resolution, the exact argument vectors each mode expects, @@ -8,7 +8,7 @@ // forms and reads these environment names, and CI's smoke test plus the Go // fake-bin tests prove both ends agree. Change a constant here only in lockstep // with packages/ts/sidecar. -package sidecarproto +package proto import "os" diff --git a/packages/go/driver/internal/sidecarproto/sidecarproto_test.go b/packages/go/driver/internal/typescript/proto/sidecarproto_test.go similarity index 99% rename from packages/go/driver/internal/sidecarproto/sidecarproto_test.go rename to packages/go/driver/internal/typescript/proto/sidecarproto_test.go index 6e5ed3b..d5f3a8a 100644 --- a/packages/go/driver/internal/sidecarproto/sidecarproto_test.go +++ b/packages/go/driver/internal/typescript/proto/sidecarproto_test.go @@ -1,4 +1,4 @@ -package sidecarproto +package proto import "testing" diff --git a/packages/go/driver/internal/sidecarproto/summary.go b/packages/go/driver/internal/typescript/proto/summary.go similarity index 99% rename from packages/go/driver/internal/sidecarproto/summary.go rename to packages/go/driver/internal/typescript/proto/summary.go index 25dc007..daaecff 100644 --- a/packages/go/driver/internal/sidecarproto/summary.go +++ b/packages/go/driver/internal/typescript/proto/summary.go @@ -1,4 +1,4 @@ -package sidecarproto +package proto import ( "regexp" diff --git a/packages/go/driver/internal/sidecarproto/summary_test.go b/packages/go/driver/internal/typescript/proto/summary_test.go similarity index 98% rename from packages/go/driver/internal/sidecarproto/summary_test.go rename to packages/go/driver/internal/typescript/proto/summary_test.go index 7b2db58..abbf1b6 100644 --- a/packages/go/driver/internal/sidecarproto/summary_test.go +++ b/packages/go/driver/internal/typescript/proto/summary_test.go @@ -1,4 +1,4 @@ -package sidecarproto +package proto import "testing" diff --git a/packages/go/driver/internal/tsruntime/assets.go b/packages/go/driver/internal/typescript/runtime/assets.go similarity index 85% rename from packages/go/driver/internal/tsruntime/assets.go rename to packages/go/driver/internal/typescript/runtime/assets.go index e737431..5dda7d8 100644 --- a/packages/go/driver/internal/tsruntime/assets.go +++ b/packages/go/driver/internal/typescript/runtime/assets.go @@ -1,4 +1,4 @@ -// Package tsruntime manages the self-contained TS toolchain shipped inside +// Package runtime manages the self-contained TS toolchain shipped inside // release binaries: a bun-compiled sidecar plus the oxc-parser, oxfmt, and // oxlint napi bindings. On first use the embedded assets are extracted to a // per-version cache directory and spawned as child processes from there. @@ -6,8 +6,8 @@ // The type split mirrors the three responsibilities: Assets owns the extracted // directory (extraction, caching, lookup); Invoker spawns the toolchain; and // PrettierMigration derives an oxfmt config from a project's Prettier setup. All -// argv and environment construction goes through the sidecarproto package. -package tsruntime +// argv and environment construction goes through the proto package. +package runtime import ( "crypto/sha256" @@ -20,8 +20,8 @@ import ( "path/filepath" "sort" - "go.ollin.sh/fmtkit/driver/internal/embedded" - "go.ollin.sh/fmtkit/driver/internal/sidecarproto" + "go.ollin.sh/fmtkit/driver/internal/typescript/embedded" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) // Assets locates the extracted TS toolchain on disk. @@ -29,25 +29,25 @@ type Assets struct { Dir string } -// sentinelName marks a completed extraction; it is tsruntime's own bookkeeping, -// not part of the sidecar wire protocol. +// sentinelName marks a completed extraction; it is the runtime's own +// bookkeeping, not part of the sidecar wire protocol. const sentinelName = ".fmtkit-complete" // Sidecar returns the path of the multiplexed toolchain executable. func (a Assets) Sidecar() string { - return filepath.Join(a.Dir, sidecarproto.SidecarName) + return filepath.Join(a.Dir, proto.SidecarName) } // OxfmtConfig returns the bundled oxfmt configuration path, or "" when the // support directory carries none. func (a Assets) OxfmtConfig() string { - return existingFile(filepath.Join(a.Dir, sidecarproto.OxfmtRCName)) + return existingFile(filepath.Join(a.Dir, proto.OxfmtRCName)) } // OxlintConfig returns the bundled oxlint configuration path, or "" when the // support directory carries none. func (a Assets) OxlintConfig() string { - return existingFile(filepath.Join(a.Dir, sidecarproto.OxlintRCName)) + return existingFile(filepath.Join(a.Dir, proto.OxlintRCName)) } func existingFile(path string) string { @@ -62,11 +62,11 @@ func existingFile(path string) string { // user cache on first use. version tells extractions of different releases // apart; dev builds derive a digest from the assets instead. func Resolve(version string) (Assets, error) { - if dir := os.Getenv(sidecarproto.SupportDirEnv); dir != "" { + if dir := os.Getenv(proto.SupportDirEnv); dir != "" { assets := Assets{Dir: dir} if existingFile(assets.Sidecar()) == "" { - return Assets{}, fmt.Errorf("%s (%s) does not contain %s", sidecarproto.SupportDirEnv, dir, sidecarproto.SidecarName) + return Assets{}, fmt.Errorf("%s (%s) does not contain %s", proto.SupportDirEnv, dir, proto.SidecarName) } return assets, nil @@ -77,7 +77,7 @@ func Resolve(version string) (Assets, error) { if !ok { return Assets{}, errors.New( "this fmtkit build carries no TS toolchain (built without the fmtkit_sidecar tag); " + - "point " + sidecarproto.SupportDirEnv + " at a staged toolchain directory " + + "point " + proto.SupportDirEnv + " at a staged toolchain directory " + "(see packages/ts/infra/stage-ts-assets.sh), or use a release binary", ) } @@ -160,7 +160,7 @@ func extract(dst string, assets fs.FS) error { mode := os.FileMode(0o644) - if entry.Name() == sidecarproto.SidecarName { + if entry.Name() == proto.SidecarName { mode = 0o755 } diff --git a/packages/go/driver/internal/tsruntime/invoker.go b/packages/go/driver/internal/typescript/runtime/invoker.go similarity index 95% rename from packages/go/driver/internal/tsruntime/invoker.go rename to packages/go/driver/internal/typescript/runtime/invoker.go index d53142a..82e19c9 100644 --- a/packages/go/driver/internal/tsruntime/invoker.go +++ b/packages/go/driver/internal/typescript/runtime/invoker.go @@ -1,4 +1,4 @@ -package tsruntime +package runtime import ( "context" @@ -9,8 +9,8 @@ import ( "path/filepath" "go.ollin.sh/fmtkit/driver/internal/gitfiles" - "go.ollin.sh/fmtkit/driver/internal/sidecarproto" - "go.ollin.sh/fmtkit/driver/internal/sourcefiles" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" + "go.ollin.sh/fmtkit/driver/internal/typescript/sourcefiles" ) // Request describes one TS toolchain invocation. @@ -35,13 +35,13 @@ type Request struct { // deep in the call paths. type Invoker struct { Assets Assets - Env sidecarproto.Overrides + Env proto.Overrides } // NewInvoker builds an Invoker for the given assets, reading the environment // overrides once. func NewInvoker(a Assets) Invoker { - return Invoker{Assets: a, Env: sidecarproto.ReadOverrides()} + return Invoker{Assets: a, Env: proto.ReadOverrides()} } // RunPipeline runs the full TS/Vue formatting pipeline (blank-lines -> oxfmt @@ -77,7 +77,7 @@ func (i Invoker) RunPipeline(ctx context.Context, req Request) error { oxfmtBin = i.Assets.Sidecar() } - command := sidecarproto.PipelineCommand{ + command := proto.PipelineCommand{ OxfmtBin: oxfmtBin, OxfmtConfig: i.oxfmtConfigFor(ctx, cwd, req.Stderr), FormatFiles: formatFiles, @@ -119,7 +119,7 @@ func (i Invoker) RunLint(ctx context.Context, req Request) error { bin = i.Assets.Sidecar() } - command := sidecarproto.OxlintCommand{ + command := proto.OxlintCommand{ ViaSidecar: viaSidecar, Fix: req.Fix, Config: i.oxlintConfigFor(cwd), diff --git a/packages/go/driver/internal/tsruntime/prettier.go b/packages/go/driver/internal/typescript/runtime/prettier.go similarity index 95% rename from packages/go/driver/internal/tsruntime/prettier.go rename to packages/go/driver/internal/typescript/runtime/prettier.go index 326604c..9901291 100644 --- a/packages/go/driver/internal/tsruntime/prettier.go +++ b/packages/go/driver/internal/typescript/runtime/prettier.go @@ -1,4 +1,4 @@ -package tsruntime +package runtime import ( "context" @@ -11,7 +11,7 @@ import ( "os/exec" "path/filepath" - "go.ollin.sh/fmtkit/driver/internal/sidecarproto" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) // PrettierMigration derives an oxfmt config from a project's Prettier setup by @@ -20,7 +20,7 @@ import ( // OXFMT_BIN override). type PrettierMigration struct { Assets Assets - Env sidecarproto.Overrides + Env proto.Overrides } // prettierConfigNames are the standalone Prettier configuration filenames, in @@ -145,7 +145,7 @@ func (m PrettierMigration) migrate(ctx context.Context, source string) ([]byte, } bin, viaSidecar := m.oxfmtExecutable() - args := sidecarproto.MigrateCommand{ViaSidecar: viaSidecar}.Argv() + args := proto.MigrateCommand{ViaSidecar: viaSidecar}.Argv() cmd := exec.CommandContext(ctx, bin, args...) cmd.Dir = dir @@ -156,7 +156,7 @@ func (m PrettierMigration) migrate(ctx context.Context, source string) ([]byte, return nil, fmt.Errorf("oxfmt --migrate=prettier: %w", err) } - derived, err := os.ReadFile(filepath.Join(dir, sidecarproto.OxfmtRCName)) + derived, err := os.ReadFile(filepath.Join(dir, proto.OxfmtRCName)) if err != nil { return nil, fmt.Errorf("read migrated config: %w", err) diff --git a/packages/go/driver/internal/tsruntime/prettier_internal_test.go b/packages/go/driver/internal/typescript/runtime/prettier_internal_test.go similarity index 90% rename from packages/go/driver/internal/tsruntime/prettier_internal_test.go rename to packages/go/driver/internal/typescript/runtime/prettier_internal_test.go index b909fe3..dd6fb8b 100644 --- a/packages/go/driver/internal/tsruntime/prettier_internal_test.go +++ b/packages/go/driver/internal/typescript/runtime/prettier_internal_test.go @@ -1,4 +1,4 @@ -package tsruntime +package runtime import ( "context" @@ -7,7 +7,7 @@ import ( "strings" "testing" - "go.ollin.sh/fmtkit/driver/internal/sidecarproto" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) func TestOxfmtExecutableDefaultsToSidecar(t *testing.T) { @@ -25,7 +25,7 @@ func TestOxfmtExecutableDefaultsToSidecar(t *testing.T) { } func TestOxfmtExecutableHonorsOxfmtBin(t *testing.T) { - migration := PrettierMigration{Env: sidecarproto.Overrides{OxfmtBin: "/usr/bin/oxfmt"}} + migration := PrettierMigration{Env: proto.Overrides{OxfmtBin: "/usr/bin/oxfmt"}} bin, viaSidecar := migration.oxfmtExecutable() @@ -82,7 +82,7 @@ func TestDerivedConfigWarnsWhenCacheWriteFails(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - migration := PrettierMigration{Assets: support, Env: sidecarproto.Overrides{OxfmtBin: oxfmt}} + migration := PrettierMigration{Assets: support, Env: proto.Overrides{OxfmtBin: oxfmt}} var stderr strings.Builder @@ -111,7 +111,7 @@ func TestDerivedConfigWarnsWhenMigrationWritesNoConfig(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - migration := PrettierMigration{Assets: support, Env: sidecarproto.Overrides{OxfmtBin: silent}} + migration := PrettierMigration{Assets: support, Env: proto.Overrides{OxfmtBin: silent}} var stderr strings.Builder diff --git a/packages/go/driver/internal/tsruntime/prettier_test.go b/packages/go/driver/internal/typescript/runtime/prettier_test.go similarity index 90% rename from packages/go/driver/internal/tsruntime/prettier_test.go rename to packages/go/driver/internal/typescript/runtime/prettier_test.go index 517465a..ec84b2a 100644 --- a/packages/go/driver/internal/tsruntime/prettier_test.go +++ b/packages/go/driver/internal/typescript/runtime/prettier_test.go @@ -1,4 +1,4 @@ -package tsruntime +package runtime import ( "bytes" @@ -8,7 +8,7 @@ import ( "strings" "testing" - "go.ollin.sh/fmtkit/driver/internal/sidecarproto" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) // writeMigrateStub creates a fake oxfmt that, on --migrate=prettier, writes an @@ -154,9 +154,9 @@ func TestOxfmtConfigForDerivesFromPrettier(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(sidecarproto.OxfmtBinEnv, oxfmt) + t.Setenv(proto.OxfmtBinEnv, oxfmt) - env := sidecarproto.ReadOverrides() + env := proto.ReadOverrides() var stderr bytes.Buffer @@ -193,9 +193,9 @@ func TestOxfmtConfigForCachesDerivedConfig(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(sidecarproto.OxfmtBinEnv, oxfmt) + t.Setenv(proto.OxfmtBinEnv, oxfmt) - env := sidecarproto.ReadOverrides() + env := proto.ReadOverrides() var stderr bytes.Buffer @@ -225,9 +225,9 @@ func TestOxfmtConfigForRemigratesWhenConfigChanges(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(sidecarproto.OxfmtBinEnv, oxfmt) + t.Setenv(proto.OxfmtBinEnv, oxfmt) - env := sidecarproto.ReadOverrides() + env := proto.ReadOverrides() var stderr bytes.Buffer @@ -254,7 +254,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { oxfmt := filepath.Join(t.TempDir(), "oxfmt") writeMigrateStub(t, oxfmt) - t.Setenv(sidecarproto.OxfmtBinEnv, oxfmt) + t.Setenv(proto.OxfmtBinEnv, oxfmt) t.Run("project .oxfmtrc beats prettier", func(t *testing.T) { cwd := t.TempDir() @@ -269,7 +269,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { var stderr bytes.Buffer - if got := (Invoker{Assets: support, Env: sidecarproto.ReadOverrides()}).oxfmtConfigFor(context.Background(), cwd, &stderr); got != "" { + if got := (Invoker{Assets: support, Env: proto.ReadOverrides()}).oxfmtConfigFor(context.Background(), cwd, &stderr); got != "" { t.Fatalf("expected auto-discovery signal for project config, got %q", got) } }) @@ -283,7 +283,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { var stderr bytes.Buffer - got := Invoker{Assets: support, Env: sidecarproto.ReadOverrides()}.oxfmtConfigFor(context.Background(), cwd, &stderr) + got := Invoker{Assets: support, Env: proto.ReadOverrides()}.oxfmtConfigFor(context.Background(), cwd, &stderr) if !strings.HasPrefix(got, filepath.Join(support.Dir, "prettier-derived")) { t.Fatalf("expected derived config, got %q", got) @@ -295,7 +295,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { var stderr bytes.Buffer - if got := (Invoker{Assets: support, Env: sidecarproto.ReadOverrides()}).oxfmtConfigFor(context.Background(), cwd, &stderr); got != support.OxfmtConfig() { + if got := (Invoker{Assets: support, Env: proto.ReadOverrides()}).oxfmtConfigFor(context.Background(), cwd, &stderr); got != support.OxfmtConfig() { t.Fatalf("expected bundled config %q, got %q", support.OxfmtConfig(), got) } }) @@ -313,7 +313,7 @@ func TestOxfmtConfigForPrecedence(t *testing.T) { t.Fatalf("write override config: %v", err) } - env := sidecarproto.ReadOverrides() + env := proto.ReadOverrides() env.OxfmtConfig = override if got := (Invoker{Assets: support, Env: env}).oxfmtConfigFor(context.Background(), cwd, &bytes.Buffer{}); got != override { @@ -342,11 +342,11 @@ func TestOxfmtConfigForFallsBackWhenMigrationFails(t *testing.T) { t.Fatalf("write prettier config: %v", err) } - t.Setenv(sidecarproto.OxfmtBinEnv, failing) + t.Setenv(proto.OxfmtBinEnv, failing) var stderr bytes.Buffer - got := Invoker{Assets: support, Env: sidecarproto.ReadOverrides()}.oxfmtConfigFor(context.Background(), cwd, &stderr) + got := Invoker{Assets: support, Env: proto.ReadOverrides()}.oxfmtConfigFor(context.Background(), cwd, &stderr) if got != support.OxfmtConfig() { t.Fatalf("expected bundled fallback %q, got %q", support.OxfmtConfig(), got) diff --git a/packages/go/driver/internal/tsruntime/run_test.go b/packages/go/driver/internal/typescript/runtime/run_test.go similarity index 93% rename from packages/go/driver/internal/tsruntime/run_test.go rename to packages/go/driver/internal/typescript/runtime/run_test.go index 0faabae..cea8122 100644 --- a/packages/go/driver/internal/tsruntime/run_test.go +++ b/packages/go/driver/internal/typescript/runtime/run_test.go @@ -1,4 +1,4 @@ -package tsruntime +package runtime import ( "bytes" @@ -10,7 +10,7 @@ import ( "strings" "testing" - "go.ollin.sh/fmtkit/driver/internal/sidecarproto" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) // writeStub creates an executable that echoes its argv, one per line, so @@ -55,7 +55,7 @@ func supportWithStub(t *testing.T) Assets { dir := t.TempDir() - writeStub(t, filepath.Join(dir, sidecarproto.SidecarName)) + writeStub(t, filepath.Join(dir, proto.SidecarName)) return Assets{Dir: dir} } @@ -74,7 +74,7 @@ func TestRunPipelineInvokesSidecar(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(sidecarproto.SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer @@ -122,7 +122,7 @@ func TestRunPipelineSkipsBundledConfigWhenProjectHasOne(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(sidecarproto.SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer @@ -140,7 +140,7 @@ func TestRunPipelineReportsMissingScopes(t *testing.T) { support := supportWithStub(t) - t.Setenv(sidecarproto.SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer @@ -170,7 +170,7 @@ func TestRunLintInvokesOxlintMode(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(sidecarproto.SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer @@ -206,7 +206,7 @@ func TestRunLintFixPassesFixFlag(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(sidecarproto.SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer @@ -246,7 +246,7 @@ func TestRunLintSkipsBundledConfigWhenProjectHasOne(t *testing.T) { t.Fatalf("write bundled config: %v", err) } - t.Setenv(sidecarproto.SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer @@ -264,7 +264,7 @@ func TestRunLintSkipsSpawnWithoutFiles(t *testing.T) { support := Assets{Dir: t.TempDir()} // no sidecar: spawning would fail - t.Setenv(sidecarproto.SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer @@ -288,7 +288,7 @@ func TestRunLintSkipsSpawnForFormatOnlyDocuments(t *testing.T) { support := Assets{Dir: t.TempDir()} // no sidecar: spawning would fail - t.Setenv(sidecarproto.SourcesCwdEnv, repo) + t.Setenv(proto.SourcesCwdEnv, repo) var stdout, stderr bytes.Buffer @@ -310,8 +310,8 @@ func TestRunLintHonorsOxlintBinOverride(t *testing.T) { writeStub(t, override) - t.Setenv(sidecarproto.SourcesCwdEnv, repo) - t.Setenv(sidecarproto.OxlintBinEnv, override) + t.Setenv(proto.SourcesCwdEnv, repo) + t.Setenv(proto.OxlintBinEnv, override) var stdout, stderr bytes.Buffer diff --git a/packages/go/driver/internal/tsruntime/support_test.go b/packages/go/driver/internal/typescript/runtime/support_test.go similarity index 82% rename from packages/go/driver/internal/tsruntime/support_test.go rename to packages/go/driver/internal/typescript/runtime/support_test.go index 89af442..f058664 100644 --- a/packages/go/driver/internal/tsruntime/support_test.go +++ b/packages/go/driver/internal/typescript/runtime/support_test.go @@ -1,4 +1,4 @@ -package tsruntime +package runtime import ( "os" @@ -6,19 +6,19 @@ import ( "testing" "testing/fstest" - "go.ollin.sh/fmtkit/driver/internal/sidecarproto" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" ) // fakeAssets mirrors a directory staged by stage-ts-assets.sh: the bindings // and the sidecar, plus the configs that ride along with them. func fakeAssets() fstest.MapFS { return fstest.MapFS{ - sidecarproto.SidecarName: &fstest.MapFile{Data: []byte("#!/bin/sh\n"), Mode: 0o755}, - "oxc-parser.node": &fstest.MapFile{Data: []byte("parser")}, - "oxfmt.node": &fstest.MapFile{Data: []byte("fmt")}, - "oxlint.node": &fstest.MapFile{Data: []byte("lint")}, - ".oxfmtrc.json": &fstest.MapFile{Data: []byte("{}")}, - ".oxlintrc.json": &fstest.MapFile{Data: []byte("{}")}, + proto.SidecarName: &fstest.MapFile{Data: []byte("#!/bin/sh\n"), Mode: 0o755}, + "oxc-parser.node": &fstest.MapFile{Data: []byte("parser")}, + "oxfmt.node": &fstest.MapFile{Data: []byte("fmt")}, + "oxlint.node": &fstest.MapFile{Data: []byte("lint")}, + ".oxfmtrc.json": &fstest.MapFile{Data: []byte("{}")}, + ".oxlintrc.json": &fstest.MapFile{Data: []byte("{}")}, } } @@ -99,11 +99,11 @@ func TestExtractOnceLosingRaceKeepsWinner(t *testing.T) { func TestResolvePrefersSupportDirEnv(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, sidecarproto.SidecarName), []byte("#!/bin/sh\n"), 0o755); err != nil { + if err := os.WriteFile(filepath.Join(dir, proto.SidecarName), []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatalf("write sidecar: %v", err) } - t.Setenv(sidecarproto.SupportDirEnv, dir) + t.Setenv(proto.SupportDirEnv, dir) support, err := Resolve("v1.0.0") @@ -117,7 +117,7 @@ func TestResolvePrefersSupportDirEnv(t *testing.T) { } func TestResolveRejectsSupportDirWithoutSidecar(t *testing.T) { - t.Setenv(sidecarproto.SupportDirEnv, t.TempDir()) + t.Setenv(proto.SupportDirEnv, t.TempDir()) if _, err := Resolve("v1.0.0"); err == nil { t.Fatal("expected error for support dir without sidecar") diff --git a/packages/go/driver/internal/sourcefiles/command.go b/packages/go/driver/internal/typescript/sourcefiles/command.go similarity index 100% rename from packages/go/driver/internal/sourcefiles/command.go rename to packages/go/driver/internal/typescript/sourcefiles/command.go diff --git a/packages/go/driver/internal/sourcefiles/command_test.go b/packages/go/driver/internal/typescript/sourcefiles/command_test.go similarity index 100% rename from packages/go/driver/internal/sourcefiles/command_test.go rename to packages/go/driver/internal/typescript/sourcefiles/command_test.go diff --git a/packages/go/driver/internal/sourcefiles/prettierignore_test.go b/packages/go/driver/internal/typescript/sourcefiles/prettierignore_test.go similarity index 100% rename from packages/go/driver/internal/sourcefiles/prettierignore_test.go rename to packages/go/driver/internal/typescript/sourcefiles/prettierignore_test.go diff --git a/packages/go/driver/internal/sourcefiles/sourcefiles.go b/packages/go/driver/internal/typescript/sourcefiles/sourcefiles.go similarity index 96% rename from packages/go/driver/internal/sourcefiles/sourcefiles.go rename to packages/go/driver/internal/typescript/sourcefiles/sourcefiles.go index 07a7367..775e16f 100644 --- a/packages/go/driver/internal/sourcefiles/sourcefiles.go +++ b/packages/go/driver/internal/typescript/sourcefiles/sourcefiles.go @@ -11,9 +11,9 @@ import ( "path/filepath" "slices" - "go.ollin.sh/fmtkit/driver/internal/filetypes" "go.ollin.sh/fmtkit/driver/internal/gitfiles" - "go.ollin.sh/fmtkit/driver/internal/prettierignore" + "go.ollin.sh/fmtkit/driver/internal/typescript/filetypes" + "go.ollin.sh/fmtkit/driver/internal/typescript/prettierignore" ) // Collector composes git discovery, the extension taxonomy, and the diff --git a/packages/go/driver/internal/sourcefiles/sourcefiles_test.go b/packages/go/driver/internal/typescript/sourcefiles/sourcefiles_test.go similarity index 100% rename from packages/go/driver/internal/sourcefiles/sourcefiles_test.go rename to packages/go/driver/internal/typescript/sourcefiles/sourcefiles_test.go diff --git a/packages/go/driver/internal/typescript/step.go b/packages/go/driver/internal/typescript/step.go new file mode 100644 index 0000000..a9f5c82 --- /dev/null +++ b/packages/go/driver/internal/typescript/step.go @@ -0,0 +1,192 @@ +// Package typescript is the TS/Vue lane: it lints (oxlint) and formats (the +// oxfmt pipeline plus the project passes) TS, Vue, HTML, and Markdown files, +// contributing the lint and format steps to the pipeline. The lane's machinery +// is split across subpackages — runtime (toolchain extraction and spawning), +// proto (the wire protocol), sourcefiles/filetypes/prettierignore (file +// discovery), and embedded (the assets baked into release binaries) — while +// this package builds the pipeline steps that drive them. +package typescript + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "strings" + + "go.ollin.sh/fmtkit/driver/internal/gitfiles" + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/toolchain" + "go.ollin.sh/fmtkit/driver/internal/typescript/proto" + "go.ollin.sh/fmtkit/driver/internal/typescript/runtime" +) + +// Toolchain is the TS/Vue lane. +type Toolchain struct{} + +type lintStep struct { + version string + paths []string + selection gitfiles.Selection +} + +type formatStep struct { + version string + paths []string + selection gitfiles.Selection +} + +// New builds the TS toolchain. +func New() Toolchain { return Toolchain{} } + +// Name is the lane's selector, matching the --ts flag. +func (Toolchain) Name() string { return "ts" } + +// Steps returns the TS lane's ordered steps. Lint runs first so the formatting +// passes normalize whatever oxlint rewrites. +func (Toolchain) Steps(req toolchain.Request) []pipeline.Step { + return []pipeline.Step{ + LintStep(req.Version, req.Paths, req.Selection), + FormatStep(req.Version, req.Paths, req.Selection), + } +} + +// LintStep builds the step that lints TS/Vue files, applying oxlint's safe +// fixes (--fix). +func LintStep(version string, paths []string, selection gitfiles.Selection) pipeline.Step { + return lintStep{version: version, paths: paths, selection: selection} +} + +// FormatStep builds the step that runs the full TS/Vue formatting pipeline +// (oxfmt plus the project passes). +func FormatStep(version string, paths []string, selection gitfiles.Selection) pipeline.Step { + return formatStep{version: version, paths: paths, selection: selection} +} + +// Driver-owned bookkeeping lines the TS steps recognize in their captured +// output. The sidecar's own wire lines are parsed by the proto package; these +// are notices the Go driver prints around the sidecar, so they stay here. +const ( + sourcesMissingPrefix = "[sources] path not found, skipping:" + lintNothingToLintLine = "[lint] no TS/Vue files to lint." +) + +func (s lintStep) Label() string { return "Running TS/Vue lint" } + +func (s lintStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + var captured bytes.Buffer + + err := invoke(s.version, io.MultiWriter(output, &captured), func(invoker runtime.Invoker, w io.Writer) error { + return invoker.RunLint(ctx, runtime.Request{Scopes: s.paths, Selection: s.selection, Fix: true, Stdout: w, Stderr: w}) + }) + + if code := exitCode(err, output); code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: lintDetails(captured.String())} +} + +func (s formatStep) Label() string { return "Running TS/Vue formatting" } + +func (s formatStep) Run(ctx context.Context, output io.Writer) pipeline.Result { + var captured bytes.Buffer + + err := invoke(s.version, io.MultiWriter(output, &captured), func(invoker runtime.Invoker, w io.Writer) error { + return invoker.RunPipeline(ctx, runtime.Request{Scopes: s.paths, Selection: s.selection, Stdout: w, Stderr: w}) + }) + + if code := exitCode(err, output); code != 0 { + return pipeline.Result{ExitCode: code} + } + + return pipeline.Result{Details: formatDetails(captured.String())} +} + +// invoke resolves the TS toolchain and invokes it through spawn, which receives +// the constructed Invoker and the writer to stream tool output to. +func invoke(version string, output io.Writer, spawn func(runtime.Invoker, io.Writer) error) error { + assets, err := runtime.Resolve(version) + + if err != nil { + return err + } + + return spawn(runtime.NewInvoker(assets), output) +} + +// exitCode maps a TS step error to its exit code. Failures that never produced +// tool output (a missing sidecar, an unreadable working tree) surface their +// message through output so they are visible both live and in the quiet failure +// dump. +func exitCode(err error, output io.Writer) int { + if err == nil { + return 0 + } + + var exit *exec.ExitError + + if errors.As(err, &exit) { + return exit.ExitCode() + } + + _, _ = io.WriteString(output, err.Error()+"\n") + + return 1 +} + +// lintDetails derives the oxlint summary line. A driver "no files" notice wins; +// otherwise oxlint's own result line; otherwise a clean fallback. +func lintDetails(log string) []pipeline.Detail { + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, lintNothingToLintLine) { + return []pipeline.Detail{{Label: "oxlint", Value: strings.TrimPrefix(lintNothingToLintLine, "[lint] ")}} + } + } + + if result := proto.ParseLintSummary(log).Result; result != "" { + return []pipeline.Detail{{Label: "oxlint", Value: result}} + } + + return []pipeline.Detail{{Label: "oxlint", Value: "no issues found"}} +} + +// formatDetails derives the TS pipeline's detail lines from the sidecar's +// progress output plus the driver's missing-source notices. +func formatDetails(log string) []pipeline.Detail { + summary := proto.ParsePipelineSummary(log) + + var details []pipeline.Detail + + if summary.BlankLines != "" { + details = append(details, pipeline.Detail{Label: "blank-lines", Value: summary.BlankLines}) + } + + missing := 0 + + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, sourcesMissingPrefix) { + missing++ + } + } + + if missing > 0 { + details = append(details, pipeline.Detail{Label: "skipped", Value: fmt.Sprintf("%d missing tracked file(s)", missing)}) + } + + if summary.Oxfmt != "" { + details = append(details, pipeline.Detail{Label: "oxfmt", Value: summary.Oxfmt}) + } + + if summary.FluentChains != "" { + details = append(details, pipeline.Detail{Label: "fluent", Value: summary.FluentChains}) + } + + if summary.ValidateSyntax != "" { + details = append(details, pipeline.Detail{Label: "validated", Value: summary.ValidateSyntax}) + } + + return details +} diff --git a/packages/go/driver/internal/typescript/step_test.go b/packages/go/driver/internal/typescript/step_test.go new file mode 100644 index 0000000..de24053 --- /dev/null +++ b/packages/go/driver/internal/typescript/step_test.go @@ -0,0 +1,104 @@ +package typescript + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "go.ollin.sh/fmtkit/driver/internal/pipeline" + "go.ollin.sh/fmtkit/driver/internal/toolchain" +) + +func detailStrings(details []pipeline.Detail) []string { + out := make([]string, 0, len(details)) + + for _, d := range details { + out = append(out, d.Label+"|"+d.Value) + } + + return out +} + +func assertDetails(t *testing.T, got []pipeline.Detail, want ...string) { + t.Helper() + + if g := fmt.Sprint(detailStrings(got)); g != fmt.Sprint(want) { + t.Fatalf("details mismatch\n--- got ---\n%s\n--- want ---\n%s", g, fmt.Sprint(want)) + } +} + +func TestFormatDetails(t *testing.T) { + log := "[blank-lines] processed 3 file(s) in /work, 0 changed\n" + + "Finished in 10ms on 3 files using 8 threads.\n" + + "[fluent-chains] processed 3 file(s) in /work, 1 changed\n" + + "[validate-syntax] checked 3 file(s).\n" + + assertDetails(t, formatDetails(log), + "blank-lines|processed 3 file(s) in /work, 0 changed", + "oxfmt|Finished in 10ms on 3 files using 8 threads.", + "fluent|processed 3 file(s) in /work, 1 changed", + "validated|checked 3 file(s).", + ) +} + +func TestFormatDetailsCountsMissing(t *testing.T) { + log := "[sources] path not found, skipping: /work/a\n" + + "[sources] path not found, skipping: /work/b\n" + + assertDetails(t, formatDetails(log), "skipped|2 missing tracked file(s)") +} + +func TestLintDetailsResult(t *testing.T) { + assertDetails(t, lintDetails("Found 0 warnings and 0 errors.\n"), "oxlint|Found 0 warnings and 0 errors.") +} + +func TestLintDetailsNoFiles(t *testing.T) { + assertDetails(t, lintDetails("[lint] no TS/Vue files to lint.\n"), "oxlint|no TS/Vue files to lint.") +} + +func TestLintDetailsFallback(t *testing.T) { + assertDetails(t, lintDetails("nothing interesting\n"), "oxlint|no issues found") +} + +func TestExitCodePlainErrorWritesToOutput(t *testing.T) { + var buf bytes.Buffer + + if code := exitCode(errors.New("boom"), &buf); code != 1 { + t.Fatalf("exitCode = %d, want 1", code) + } + + if buf.String() != "boom\n" { + t.Fatalf("exitCode output = %q, want %q", buf.String(), "boom\n") + } +} + +func TestExitCodeNil(t *testing.T) { + var buf bytes.Buffer + + if code := exitCode(nil, &buf); code != 0 { + t.Fatalf("exitCode(nil) = %d, want 0", code) + } + + if buf.Len() != 0 { + t.Fatalf("exitCode(nil) wrote %q", buf.String()) + } +} + +func TestSteps(t *testing.T) { + steps := New().Steps(toolchain.Request{Version: "dev", Paths: []string{"."}}) + + labels := make([]string, 0, len(steps)) + + for _, s := range steps { + labels = append(labels, s.Label()) + } + + if got := fmt.Sprint(labels); got != fmt.Sprint([]string{"Running TS/Vue lint", "Running TS/Vue formatting"}) { + t.Fatalf("step labels = %s, want [Running TS/Vue lint, Running TS/Vue formatting]", got) + } + + if got := New().Name(); got != "ts" { + t.Fatalf("Name = %q, want ts", got) + } +} diff --git a/packages/ts/infra/stage-ts-assets.sh b/packages/ts/infra/stage-ts-assets.sh index bfa8116..315e0d8 100755 --- a/packages/ts/infra/stage-ts-assets.sh +++ b/packages/ts/infra/stage-ts-assets.sh @@ -2,7 +2,7 @@ set -euo pipefail # Builds the self-contained TS toolchain assets embedded into the `fmtkit` -# release binary (see packages/go/driver/internal/tsruntime): +# release binary (see packages/go/driver/internal/typescript/runtime): # # - fmtkit-ts-sidecar bun-compiled bundle of packages/ts/sidecar/src/sidecar.ts # - oxc-parser.node napi binding, loaded via NAPI_RS_NATIVE_LIBRARY_PATH @@ -11,7 +11,7 @@ set -euo pipefail # - .oxfmtrc.json repo-root config, the default for projects without one # - .oxlintrc.json repo-root config, the default for projects without one # -# Output lands in packages/go/driver/internal/embedded/bin//, next to the +# Output lands in packages/go/driver/internal/typescript/embedded/bin//, next to the # package that embeds it: go:embed cannot reach outside its own directory. # # Tool versions come from packages/ts/sidecar/package.json devDependencies; @@ -31,7 +31,7 @@ if [[ $# -eq 0 ]]; then fi root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" -dist="${FMTKIT_TS_ASSET_DIR:-${root}/packages/go/driver/internal/embedded/bin}" +dist="${FMTKIT_TS_ASSET_DIR:-${root}/packages/go/driver/internal/typescript/embedded/bin}" source "${root}/infra/lib/host-target.sh" From 0eba04619471e51739f2b8370cbcb4051f71a80d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 27 Jul 2026 10:36:57 +0800 Subject: [PATCH 18/22] infra responsability --- .github/workflows/publish-release.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/tests.yml | 12 +- .goreleaser.yaml | 4 +- Makefile | 8 +- README.md | 357 ++++++++++++------ package.json | 6 +- packages/go/driver/internal/app/doc.go | 5 +- .../internal/typescript/embedded/doc.go | 2 +- .../internal/typescript/runtime/assets.go | 2 +- packages/go/driver/package.json | 10 +- packages/go/formatter/package.json | 8 +- packages/go/{infra => scripts}/task.sh | 4 +- packages/go/vet/package.json | 8 +- packages/ts/sidecar/src/sidecar.ts | 2 +- .../oxfmt-inprocess/api-bindings.test.ts | 0 .../oxfmt-inprocess/api-bindings.ts | 0 .../oxfmt-inprocess/cli-patcher.test.ts | 0 .../oxfmt-inprocess/cli-patcher.ts | 0 .../oxfmt-inprocess/errors.ts | 0 .../oxfmt-inprocess/index.ts | 0 .../oxfmt-inprocess/patch-cli-dto.ts | 0 .../oxfmt-inprocess/result.ts | 0 .../oxfmt-inprocess/shim-source.ts | 2 +- .../oxfmt-inprocess/text-files.ts | 0 packages/ts/{infra => toolchain}/package.json | 2 +- .../patch-oxfmt-inprocess.ts | 0 .../{infra => toolchain}/stage-ts-assets.sh | 20 +- .../ts/{infra => toolchain}/tsconfig.json | 0 pnpm-lock.yaml | 54 +-- pnpm-workspace.yaml | 2 +- {infra => scripts}/lib/env.sh | 0 {infra => scripts}/lib/host-target.sh | 2 +- .../release/create-release-tag.sh | 0 {infra => scripts}/release/release.sh | 0 .../release/verify-release-tag.sh | 0 {infra => scripts}/task.sh | 8 +- {infra => scripts}/test-binary-smoke.sh | 2 +- vite.config.ts | 14 +- 39 files changed, 322 insertions(+), 216 deletions(-) rename packages/go/{infra => scripts}/task.sh (85%) rename packages/ts/{infra => toolchain}/oxfmt-inprocess/api-bindings.test.ts (100%) rename packages/ts/{infra => toolchain}/oxfmt-inprocess/api-bindings.ts (100%) rename packages/ts/{infra => toolchain}/oxfmt-inprocess/cli-patcher.test.ts (100%) rename packages/ts/{infra => toolchain}/oxfmt-inprocess/cli-patcher.ts (100%) rename packages/ts/{infra => toolchain}/oxfmt-inprocess/errors.ts (100%) rename packages/ts/{infra => toolchain}/oxfmt-inprocess/index.ts (100%) rename packages/ts/{infra => toolchain}/oxfmt-inprocess/patch-cli-dto.ts (100%) rename packages/ts/{infra => toolchain}/oxfmt-inprocess/result.ts (100%) rename packages/ts/{infra => toolchain}/oxfmt-inprocess/shim-source.ts (98%) rename packages/ts/{infra => toolchain}/oxfmt-inprocess/text-files.ts (100%) rename packages/ts/{infra => toolchain}/package.json (96%) rename packages/ts/{infra => toolchain}/patch-oxfmt-inprocess.ts (100%) rename packages/ts/{infra => toolchain}/stage-ts-assets.sh (91%) rename packages/ts/{infra => toolchain}/tsconfig.json (100%) rename {infra => scripts}/lib/env.sh (100%) rename {infra => scripts}/lib/host-target.sh (91%) rename {infra => scripts}/release/create-release-tag.sh (100%) rename {infra => scripts}/release/release.sh (100%) rename {infra => scripts}/release/verify-release-tag.sh (100%) rename {infra => scripts}/task.sh (96%) rename {infra => scripts}/test-binary-smoke.sh (98%) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b747d97..654949b 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -39,7 +39,7 @@ jobs: fetch-depth: 0 - name: Verify release tag and checkout tagged commit - run: ./infra/release/verify-release-tag.sh + run: ./scripts/release/verify-release-tag.sh shell: bash - uses: actions/setup-node@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a19b97..f08491c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,7 +100,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_PREFIX: v DEFAULT_BUMP: patch - run: ./infra/release/create-release-tag.sh + run: ./scripts/release/create-release-tag.sh # Publishing is invoked directly so this workflow does not rely on tag-push # fan-out from a workflow-created tag. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4195f00..55b24b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,11 +4,9 @@ on: push: branches: - main - - release-refactor pull_request: branches: - main - - release-refactor types: - opened - reopened @@ -114,13 +112,13 @@ jobs: run: vp install --frozen-lockfile - name: Go - Run formatter tests with race detector and coverage - run: CGO_ENABLED=1 ./infra/task.sh with-env go -C packages/go/formatter test ./... -race -coverprofile=coverage.out -covermode=atomic -v + run: CGO_ENABLED=1 ./scripts/task.sh with-env go -C packages/go/formatter test ./... -race -coverprofile=coverage.out -covermode=atomic -v - name: Go - Run vet tests with race detector and coverage - run: CGO_ENABLED=1 ./infra/task.sh with-env go -C packages/go/vet test ./... -race -coverprofile=coverage.out -covermode=atomic -v + run: CGO_ENABLED=1 ./scripts/task.sh with-env go -C packages/go/vet test ./... -race -coverprofile=coverage.out -covermode=atomic -v - name: Go - Run driver tests with race detector and coverage - run: CGO_ENABLED=1 ./infra/task.sh with-env go -C packages/go/driver test ./... -race -coverprofile=coverage.out -covermode=atomic -v + run: CGO_ENABLED=1 ./scripts/task.sh with-env go -C packages/go/driver test ./... -race -coverprofile=coverage.out -covermode=atomic -v - uses: actions/upload-artifact@v4 name: Upload formatter coverage artifact @@ -178,12 +176,12 @@ jobs: args: check - name: Smoke test the self-contained binary - run: ./infra/test-binary-smoke.sh + run: ./scripts/test-binary-smoke.sh # Runs here rather than in `check`, which has no Go: the only tool # that can say whether this tree is formatted is fmtkit itself. - name: Check the repository is fmtkit-formatted - run: ./infra/task.sh self-check + run: ./scripts/task.sh self-check coverage: needs: test diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e1cd570..b606721 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,7 +3,7 @@ version: 2 project_name: fmtkit # Build output belongs under storage/ like every other artifact this repo -# produces; infra/lib/env.sh rejects a repo-root dist/. +# produces; scripts/lib/env.sh rejects a repo-root dist/. dist: storage/dist # Releases stage every platform; PR validation sets FMTKIT_STAGE_TARGETS=host @@ -13,7 +13,7 @@ env: before: hooks: - - ./packages/ts/infra/stage-ts-assets.sh {{ .Env.FMTKIT_STAGE_TARGETS }} + - ./packages/ts/toolchain/stage-ts-assets.sh {{ .Env.FMTKIT_STAGE_TARGETS }} builds: - id: fmtkit diff --git a/Makefile b/Makefile index 59e2063..537ba51 100644 --- a/Makefile +++ b/Makefile @@ -17,13 +17,13 @@ help: ## Show the available targets @printf '\nVariables: ARGS\n' format: ## Run the formatter pipeline against ARGS - @./infra/task.sh format $(ARGS) + @./scripts/task.sh format $(ARGS) format-all: ## Run the formatter pipeline against the whole repository - @./infra/task.sh fmtkit format-all + @./scripts/task.sh fmtkit format-all check: ## Run the Go formatter in check mode against ARGS - @./infra/task.sh fmtkit check $(ARGS) + @./scripts/task.sh fmtkit check $(ARGS) version: ## Print the version the working tree builds as - @./infra/task.sh fmtkit version + @./scripts/task.sh fmtkit version diff --git a/README.md b/README.md index 19eee74..88cd888 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,42 @@ [![Go 1.26.4](https://img.shields.io/badge/go-1.26.4-00ADD8?logo=go&logoColor=white)](https://go.dev/doc/go1.26) [![Tests](https://github.com/oullin/fmtkit/actions/workflows/tests.yml/badge.svg)](https://github.com/oullin/fmtkit/actions/workflows/tests.yml) [![Release](https://github.com/oullin/fmtkit/actions/workflows/release.yml/badge.svg)](https://github.com/oullin/fmtkit/actions/workflows/release.yml) -[![Codecov](https://codecov.io/gh/oullin/fmtkit/graph/badge.svg?branch=main)](https://app.codecov.io/github/oullin/fmtkit) -`fmtkit` is a rule-driven formatter for Go. It enforces layout and structure that `gofmt` leaves alone — blank lines around control flow, type hoisting, declaration grouping — then hands off to `gofmt` and `goimports` for the final pass. +One formatter for a Go + TypeScript repository. `fmtkit` enforces the layout rules `gofmt` and `oxfmt` leave alone, blank lines around control flow, declaration ordering, class member order; then hands off to the standard formatters for the final pass. -## At a glance +## What it is -- AST-based spacing rules, then `gofmt` and `goimports`, in one deterministic pipeline. -- Runs `go vet ./...` automatically when invoked inside a Go module or workspace. -- Three output modes: `text` for humans, `json` for scripts, `agent` for CI and AI tools. -- One self-contained `fmtkit` binary (Homebrew or GitHub Releases), or a Go-only CLI (`fmtkit-go`). -- Engine in [`packages/go/formatter/engine`](packages/go/formatter/engine) is importable from Go. +A single self-contained binary that formats both halves of a full-stack repo: + +- **Go** — an AST-based spacing rule, then `gofmt` and `goimports`, plus an automatic `go vet ./...`. +- **TypeScript / Vue** — `oxlint --fix`, then `oxfmt`, then structural passes for blank lines, class member order, and fluent chains. Also formats the embedded TS blocks in Markdown and HTML. + +The TS toolchain is compiled with Bun and embedded in the binary, so there is **no Node.js requirement** and nothing to `npm install`. One download, one command, both languages. + +If you only want the Go half, `fmtkit-go` is a separate `go install`-able CLI, and the engine is importable as a library. + +## Why + +`gofmt` is deliberately conservative: it normalizes indentation and alignment, but it will never tell you that a `return` should be preceded by a blank line, or that your `type` declarations belong at the top of the file. `oxfmt` is the same story on the TS side, they own whitespace within a statement, not the rhythm between statements. + +That leaves a whole category of "style" that lives in review comments and team wikis, gets applied inconsistently, and produces diff noise when someone finally cleans it up. `fmtkit` moves those rules into the formatter, where they get applied the same way every time and stop being a thing people argue about. + +It is deliberately opinionated. There is one spacing rule with one shape, and the knobs are for turning things off, not for tuning them. + +## Who it's for + +- Teams with a **Go backend and a TS/Vue frontend in one repo** who are tired of running two toolchains with two config surfaces and two CI steps. +- Anyone who wants **more structure than `gofmt` provides** and would rather not hand-maintain it. +- **CI pipelines** that want a formatting gate with no daemon, no image pull, and no Node.js on the runner. +- **AI coding agents and scripts**, via the `json` and `agent` output modes. + +It is probably _not_ for you if you want a configurable style engine — fmtkit has opinions and only a few dials. ## Install -Two ways to run it. Pick the one that fits your workflow — both produce identical output. +Both routes produce identical output. Pick whichever fits. -**With Homebrew** (recommended: one self-contained binary with the full TS/Vue + Go pipeline — no Node.js required): +### Homebrew (recommended) ```bash brew tap oullin/fmtkit @@ -28,7 +47,11 @@ brew install --cask fmtkit fmtkit format . ``` -The binary embeds the TS toolchain (oxfmt, oxlint, oxc-parser and the support scripts, compiled with Bun) and extracts it to your user cache directory on first run. Homebrew casks are macOS-only; on Linux, download the same binary from GitHub Releases instead: +The binary embeds the TS toolchain (oxfmt, oxlint, oxc-parser and the support scripts) and extracts it to your user cache directory on first run. + +### Linux / GitHub Releases + +Homebrew casks are macOS-only. On Linux, grab the same binary directly: ```bash tag=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/oullin/fmtkit/releases/latest | sed 's#.*/##') @@ -36,59 +59,150 @@ curl -fsSL "https://github.com/oullin/fmtkit/releases/download/${tag}/fmtkit_${t sudo install -m 0755 fmtkit /usr/local/bin/fmtkit ``` -Archives are published for `darwin`/`linux` × `amd64`/`arm64` with a `checksums.txt`; swap `linux_amd64` for your platform. The snippet resolves the [latest release](https://github.com/oullin/fmtkit/releases/latest) rather than naming a version, so it does not go stale. For CI, pin `tag` to a known release instead. +Archives are published for `darwin`/`linux` × `amd64`/`arm64` with a `checksums.txt`; swap `linux_amd64` for your platform. The snippet resolves the [latest release](https://github.com/oullin/fmtkit/releases/latest) rather than naming a version, so it does not go stale. But **for CI, pin `tag` to a known release** so a new upstream version can't change your build. -**With Go** (good for local hacking and contributors): +### Go install (Go-only CLI) ```bash go install go.ollin.sh/fmtkit/driver/cmd/fmtkit-go@latest fmtkit-go check . -fmtkit-go format . ``` -If `fmtkit-go` is not on your `PATH` after `go install`, add the Go bin directory: `export PATH="$(go env GOPATH)/bin:$PATH"`. - -For CI, download the release binary for the runner's platform and pin the tag — it needs no daemon, no image pull, and no Node.js. +This gives you `fmtkit-go`, the Go formatter alone; no TS/Vue support. Good for Go-only projects and for contributors. -## Usage +If it isn't on your `PATH` afterward: `export PATH="$(go env GOPATH)/bin:$PATH"`. -The distributed `fmtkit` binary runs the whole pipeline (TS/Vue lint, TS/Vue formatting, Go formatting) with `format` / `format-all`, and narrows it with step flags: +## Quickstart ```bash -fmtkit format . # every step, over the working tree's changes -fmtkit format --ts . # TS/Vue lint + formatting only -fmtkit format --go . # Go formatting only -fmtkit format-all --quiet +fmtkit format . # everything you changed, both languages +fmtkit format --go . # Go only +fmtkit format --ts . # TS/Vue only +fmtkit format-all # the entire repository +fmtkit check . # report Go violations, write nothing +fmtkit lint . # report TS/Vue lint violations, write nothing ``` -`format` applies oxlint's safe fixes (`oxlint --fix`) first, then the formatting -passes normalize whatever oxlint rewrote. Standalone `fmtkit lint` only reports -violations; it never edits your files. +In CI, use `format-all` (or `check`) — see [`format` vs `format-all`](#format-vs-format-all) for why the scope matters. + +## What it does to your code + +### Go + +Given this: + +```go +func run(items []string) error { + total := 0 + type result struct{ n int } + for _, it := range items { + total += len(it) + } + if total == 0 { + return fmt.Errorf("empty") + } + r := result{n: total} + return nil +} +``` -**`format` covers what you changed; `format-all` covers everything.** `format` -covers the files that diverge from HEAD — modified (staged or not) and -untracked — so an everyday format stays proportional to your diff rather than -the repo. -`format-all` covers every non-ignored file, and is what a CI gate wants: a -changed-file scope would pass vacuously on a fresh checkout, where nothing is -modified. Both skip anything `.gitignore`d, and both need a git working tree. +`fmtkit format` produces: -This applies to every step. The TS/Vue steps collect through git directly; the -Go formatter keeps its own walk (so `config.yml`'s `exclude` / `not_path` / -`not_name` and generated-file detection always apply) and `format` then narrows -that to what git reports as changed. `go vet` is unscoped either way — it -analyses whole packages, not files. +```go +func run(items []string) error { + total := 0 -`ts`, `lint`, `go `, `check`, `version`, and `help` are also available; `fmtkit help` lists them. + type result struct{ n int } -The `fmtkit-go` CLI (the Go-only formatter published via `go install`) accepts: + for _, it := range items { + total += len(it) + } -| Command | What it does | -| ------------------- | ----------------------------------- | -| `check [paths...]` | Reports violations without writing. | -| `format [paths...]` | Rewrites files in place. | + if total == 0 { + return fmt.Errorf("empty") + } -Both default to `.` when no paths are given. Both run `go vet ./...` automatically when the working directory is inside a Go module or workspace. + r := result{n: total} + + return nil +} +``` + +The spacing rule in summary: + +- Blank lines **before and after control flow** — `if`, `for`, `range`, `switch`, `select`, `defer`, `return`, `break`, `continue`, `goto`, `fallthrough`. +- Separates standalone `var` declarations from surrounding statements when they aren't already grouped. +- Blank lines around standalone stdlib `sort.*` / `slices.Sort*` and `rand.*` calls, and after `t.Helper()`. +- Separates `type` declarations from their neighbors, and **hoists top-level `type` definitions** to the top of the file, after imports. +- Blank line after anonymous-function assignments, and between top-level `routes.Add` / `routes.Group` calls. + +Full catalogue with before/after for every variant: [docs/spacing.md](docs/spacing.md). + +### TypeScript / Vue + +The TS lane runs `oxlint --fix` for safe lint fixes, then `oxfmt`, then these structural passes: + +| Pass | What it does | +| ------------------------ | ------------------------------------------------------------------------------ | +| `BlankLinePass` | The statement-spacing rules, mirroring the Go side. | +| `ClassReorderPass` | Reorders class members into a stable shape (properties, constructor, methods). | +| `DeclarationReorderPass` | Reorders declarations, only where provably side-effect safe. | +| `FluentChainPass` | Splits fluent call chains so each link starts on its own line. | +| `ExpandedCallPass` | Expands structurally complex call arguments into stable multiline layouts. | +| `BodyWrapPass` | Braces unbraced statement bodies. | + +### What is never touched + +When given directories, the engine walks recursively and always skips: + +| Skipped | Reason | +| ----------------------------------- | ------------------------------------ | +| Hidden directories | Convention, not source code. | +| `.git/`, `vendor/` | Repository and dependency metadata. | +| `*.gen.go` | Generated code by convention. | +| Files starting `// Code generated` | Go's standard generated-file marker. | +| `.gitignore`d paths | Not yours to format. | +| `exclude` / `not_path` / `not_name` | Your own exclusions (see below). | + +## Commands + +### `fmtkit` (the full binary) + +| Command | What it does | +| ------------------------------------------- | ---------------------------------------------------- | +| `format [--ts] [--go] [--quiet] [paths...]` | Format files changed vs `HEAD`, plus untracked ones. | +| `format-all [--ts] [--go] [--quiet]` | Format every non-ignored file in the repo. | +| `ts [paths...]` | TS/Vue formatting only. | +| `lint [paths...]` | Report TS/Vue lint violations. Never writes. | +| `check [args...]` | Run the Go formatter in check mode. | +| `go ` | The Go formatter CLI. | +| `version`, `help` | The usual. | + +No language flag means all lanes, TS before Go. + +`format` applies oxlint's safe fixes first, then the formatting passes normalize whatever oxlint rewrote. Standalone `lint` only reports; it never edits your files. + +### `format` vs `format-all` + +**`format` covers what you changed; `format-all` covers everything.** + +`format` covers files that diverge from `HEAD`, modified (staged or not) and untracked, so an everyday format stays proportional to your diff rather than your repo. + +`format-all` covers every non-ignored file, and **is what a CI gate wants**: a changed-file scope would pass vacuously on a fresh checkout, where nothing is modified. + +Both skip `.gitignore`d files, and both need a git working tree. + +This applies to every step, with two wrinkles: the Go formatter keeps its own walk (so `config.yml`'s `exclude` / `not_path` / `not_name` and generated-file detection always apply) and `format` then narrows that to what git reports as changed; and `go vet` is unscoped either way, because it analyses whole packages, not files. + +### `fmtkit-go` (the Go-only CLI) + +| Command | What it does | +| --------------------------------------------- | -------------------------------------------------------------------- | +| `check [paths...]` | Reports violations without writing. | +| `format [paths...]` | Rewrites files in place. | +| `sources [--include-declarations] [paths...]` | Prints the collected file list, NUL-separated. Plumbing for scripts. | + +Both `check` and `format` default to `.`, and both run `go vet ./...` automatically when the working directory is inside a Go module or workspace. | Flag | Default | Description | | ---------- | ------- | ------------------------------------------------------------------------ | @@ -97,19 +211,18 @@ Both default to `.` when no paths are given. Both run `go vet ./...` automatical | `--format` | `text` | Output mode: `text`, `json`, or `agent`. | | `--jobs` | `0` | Max files in parallel; `0` uses `runtime.NumCPU()`. Reads `FMTKIT_JOBS`. | -A handful of common invocations: - ```bash fmtkit-go check . fmtkit-go format ./core ./demo/api fmtkit-go check --format json . -fmtkit-go check --format agent . fmtkit-go check ./packages/go/formatter/rules/spacing/spacing.go ``` ## Configuration -`fmtkit` looks for `config.yml` in the working directory; if none is found, the defaults below apply. Point at a specific file with `--config`. +### Go (`config.yml`) + +`fmtkit` looks for `config.yml` in the working directory; without one, the defaults below apply. Point at a specific file with `--config`. ```yaml rules: @@ -148,88 +261,83 @@ concurrency: 0 | `not_name` | list | empty | Globs matched against file names. | | `concurrency` | int | `0` | Max files in parallel (`0` = `NumCPU`). | -### TS/Vue formatting (`.oxfmtrc.json`) +### TS/Vue (`.oxfmtrc.json`) -The TS/Vue layer runs [`oxfmt`](https://www.npmjs.com/package/oxfmt) over your sources, then applies project-specific syntax passes for blank lines and fluent builder chains. The binary ships a bundled `.oxfmtrc.json` (tabs, single quotes, trailing commas, 200-column width) that is applied by default, so you get the same style out of the box without any setup. +The binary ships a bundled `.oxfmtrc.json` (tabs, single quotes, trailing commas, 200-column width) applied by default, so you get a consistent style with zero setup. Resolution is by precedence, first match wins: -The config is resolved by precedence, first match wins: +1. **`FMTKIT_OXFMTRC`** — an explicit path. +2. **A project-local `.oxfmtrc.*`** (`.json`, `.jsonc`, `.ts`, `.js`, …) in the directory being formatted. The bundled default is skipped and oxfmt uses yours. +3. **Your Prettier config.** If the directory has a Prettier config (`.prettierrc*`, `prettier.config.*`, or a `"prettier"` key in `package.json`) but no oxfmt config, fmtkit translates it via `oxfmt --migrate=prettier`, so a Prettier-configured project formats consistently with no extra setup. The translation is cached by the Prettier config's content hash, so it runs once and re-runs only when that config changes. If a config can't be translated (a JS config importing project-local modules, say), fmtkit warns on stderr and falls back to the bundled default rather than failing the run. +4. **The bundled default.** -1. `FMTKIT_OXFMTRC` — an explicit path, matching the other `FMTKIT_*` knobs. -2. A project-local `.oxfmtrc.*` (`.json`, `.jsonc`, `.ts`, `.js`, …) in the directory being formatted: the bundled default is skipped and oxfmt uses yours. -3. A config derived from your Prettier setup: if the directory has a Prettier config (`.prettierrc*`, `prettier.config.*`, or a `"prettier"` key in `package.json`) but no oxfmt config, fmtkit translates it via `oxfmt --migrate=prettier` so a Prettier-configured project formats consistently with no extra setup. The translation is cached by the Prettier config's content hash, so it runs once and re-runs only when that config changes. If a config cannot be translated (a JS config importing project-local modules, say), fmtkit warns on stderr and falls back to the bundled default rather than failing the run. -4. The bundled default. - -To opt out of the Prettier-derived step, drop in your own `.oxfmtrc.*`, which takes precedence over it. +To opt out of the Prettier-derived step, drop in your own `.oxfmtrc.*` — it takes precedence. ### Ignoring files (`.prettierignore`) -`oxfmt` already honors `.prettierignore` (and `.gitignore`) in its own step. fmtkit extends that to the rest of the TS/Vue pipeline — the blank-line and fluent-chain passes and `oxlint --fix` — by filtering `.prettierignore`d paths out of the file set it collects, so an ignored file is left untouched by every lane. The matcher follows gitignore syntax (comments, negation, leading-`/` anchoring, trailing-`/` directories, and the `*`, `?`, `[…]`, and `**` wildcards). The Go formatter is unaffected: `.prettierignore` governs only the TS/Vue/HTML/Markdown lanes. - -## What it formats +`oxfmt` already honors `.prettierignore` and `.gitignore` in its own step. fmtkit extends that to the rest of the TS/Vue pipeline — the structural passes and `oxlint --fix` — by filtering ignored paths out of the file set it collects, so an ignored file is untouched by every lane. -The built-in spacing rule, in summary: - -- Inserts blank lines before and after control flow (`if`, `for`, `switch`, `select`, `defer`, `return`, `break`, `continue`, `goto`, `fallthrough`). -- Separates standalone `var` declarations from surrounding statements when they are not already grouped. -- Adds blank lines around standalone stdlib `sort.*` / `slices.Sort*` and `rand.*` calls, and after `t.Helper()`. -- Separates `type` declarations from neighbours and hoists all `type` definitions to the top of the file, after imports. -- Adds a blank line after anonymous-function assignments and between top-level `routes.Add` / `routes.Group` calls. - -Full catalogue with before/after examples: [docs/spacing.md](docs/spacing.md). - -When given directories, the engine walks recursively for `.go` files and always skips: - -| Skipped | Reason | -| ----------------------------------- | ------------------------------------ | -| Hidden directories | Convention, not source code. | -| `.git/`, `vendor/` | Repository and dependency metadata. | -| `*.gen.go` | Generated code by convention. | -| Files starting `// Code generated` | Go's standard generated-file marker. | -| `exclude` / `not_path` / `not_name` | User-defined exclusions. | +The matcher follows gitignore syntax: comments, negation, leading-`/` anchoring, trailing-`/` directories, and the `*`, `?`, `[…]`, and `**` wildcards. The Go formatter is unaffected — `.prettierignore` governs only the TS/Vue/HTML/Markdown lanes. ## Output formats -**Text** — for local runs: +**`text`** — for humans: ```text +Formatter + Checked 1 file(s). main.go - [spacing] line 5: missing blank line before if statement + [spacing] line 7: missing blank line before type definition + [spacing] line 11: missing blank line before if statement ✓ would apply spacing - Result: fail. 1 changed, 1 violation(s), 0 error(s). + Result: fail. 1 changed, 2 violation(s), 0 error(s). + +Vet + + Result: ok. 0 error(s). ``` -**JSON** — for scripts and automation: +**`json`** — for scripts. Emitted as a single line; shown here expanded: ```json { "result": "fail", - "files": 1, - "changed": 1, - "results": [ - { - "file": "main.go", - "applied": ["spacing"], - "violations": [{ "rule": "spacing", "line": 5, "message": "missing blank line before if statement" }], - "changed": true - } - ] + "formatter": { + "result": "fail", + "files": 1, + "changed": 1, + "results": [ + { + "file": "main.go", + "applied": ["spacing"], + "violations": [{ "rule": "spacing", "line": 7, "message": "missing blank line before type definition" }], + "changed": true + } + ] + }, + "vet": { "status": "skipped" } } ``` -**Agent** — compact JSON for CI and AI tools: +**`agent`** — indented JSON, grouped for CI and AI tools: ```json { "result": "fail", - "summary": { "files": 1, "changed": 1, "violations": 1 }, - "changed": [{ "file": "main.go", "steps": ["spacing"] }], - "violations": [{ "file": "main.go", "rule": "spacing", "line": 5, "message": "missing blank line before if statement" }] + "formatter": { + "result": "fail", + "summary": { "files": 1, "changed": 1, "violations": 1 }, + "changed": [{ "file": "main.go", "steps": ["spacing"] }], + "violations": [{ "file": "main.go", "rule": "spacing", "line": 7, "message": "missing blank line before type definition" }] + }, + "vet": { "status": "skipped" } } ``` +The `json` and `agent` shapes are a public contract, pinned by golden tests. + ## Exit codes | Command | Code | Meaning | @@ -239,9 +347,11 @@ When given directories, the engine walks recursively for `.go` files and always | `format` | `0` | Formatting applied successfully. | | `format` | `1` | An error occurred during formatting. | +Note that `format` exits `0` when it _fixes_ violations — it only fails on a genuine error. Use `check` for gates. + ## Development -You will need Go 1.26.4+, Vite+, and [Bun](https://bun.com) (used to compile the TS sidecar the binary embeds). Vite+ manages the project Node.js runtime and pnpm version declared by the workspace. +You'll need Go 1.26.4+, [Bun](https://bun.com) (to compile the TS sidecar), and Vite+ (which manages the Node.js runtime and pnpm version the workspace declares). ```bash curl -fsSL https://vite.plus -o install-vp.sh @@ -249,51 +359,43 @@ sh install-vp.sh vp install ``` -Use Vite+ tasks for day-to-day development: +Day-to-day tasks: ```bash vp run build # build the local fmtkit-go binary into storage/bin -vp run check # run package checks across the workspace -vp run test # run all package tests +vp run check # package checks across the workspace +vp run test # all package tests vp run test-race # tests with the race detector (forces CGO_ENABLED=1) vp run test:binary # build the self-contained binary and smoke test it -vp run vet # run go vet across the Go module packages -vp run format -- . # format this repo with fmtkit's own binary +vp run vet # go vet across the Go module packages vp run install-cli # install fmtkit-go from the local source tree -vp run release # build cross-platform binaries into storage/dist +vp run release # cross-platform binaries into storage/dist ``` -### Formatting fmtkit with fmtkit +### fmtkit formats itself -fmtkit formats itself with the binary it ships, so the development loop and the -release exercise the same Go orchestrator and the same Bun-compiled TS sidecar. -The root `Makefile` is the shortest way in: +fmtkit formats its own source with the binary it ships, so the development loop and the release exercise the same Go orchestrator and the same Bun-compiled sidecar. The `Makefile` is the shortest way in: ```bash make format # format the repo (ARGS defaults to ".") make format ARGS=--ts # only the TS/Vue half make format-all # the whole repository make check # Go formatter in check mode +make version # the version the working tree builds as ``` -The first run stages the host TS toolchain assets into -`packages/go/driver/internal/typescript/embedded/bin/_/` (this needs Bun and takes a -few seconds); later runs reuse them and re-stage only when the support scripts, -the tool pins, or the `.oxfmtrc.json` / `.oxlintrc.json` configs change. The -inner loop is then a plain incremental `go build`. +The first run stages the host TS toolchain into `packages/go/driver/internal/typescript/embedded/bin/_/` (needs Bun, takes a few seconds). Later runs reuse it and re-stage only when the support scripts, the tool pins, or the `.oxfmtrc.json` / `.oxlintrc.json` configs change. The inner loop is then a plain incremental `go build`. -That loop points `FMTKIT_SUPPORT_DIR` at the staged assets rather than embedding -them, which keeps it fast. The embedded-asset path a release actually uses is -covered by `vp run test:binary`. +That loop points `FMTKIT_SUPPORT_DIR` at the staged assets rather than embedding them, which keeps it fast. The embedded-asset path a release actually uses is covered by `vp run test:binary`. ## How the code is organized fmtkit is one binary with two halves: -- A **Go driver** (`packages/go`) that owns the CLI, finds files, formats Go, runs `go vet`, renders reports, and orchestrates the whole run. +- A **Go driver** (`packages/go`) that owns the CLI, finds files, formats Go, runs `go vet`, renders reports, and orchestrates the run. - A **TypeScript sidecar** (`packages/ts/sidecar`), compiled with Bun and embedded in the binary, that formats TS/Vue and the embedded blocks in Markdown/HTML. -The driver runs the sidecar as a child process. Everything that crosses that boundary — the executable name, the modes, the flags, the env vars, the summary lines the driver reads back — is defined once per side (`driver/internal/typescript/proto` in Go, the `cli/` DTOs in TS) and pinned by tests. Change one side and you change the other in the same PR. +The driver runs the sidecar as a child process. Everything crossing that boundary. The executable name, modes, flags, env vars, and the summary lines the driver reads back is defined once per side (`driver/internal/typescript/proto` in Go, the `cli/` DTOs in TS) and pinned by tests. **Change one side, and you change the other in the same PR.** ### Go side (`packages/go`, module `go.ollin.sh/fmtkit`) @@ -301,15 +403,17 @@ The importable library: | Package | What it does | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `formatter` | The public entry points: `Check`, `Format`, `CheckFiles`, `FormatFiles`. | +| `formatter` | Public entry points: `Check`, `Format`, `CheckFiles`, `FormatFiles`. | | `formatter/engine` | Runs the formatters over files concurrently and builds the `Report`. | -| `formatter/config` | The single source of truth for formatter settings and defaults. | +| `formatter/config` | Single source of truth for formatter settings and defaults. | | `formatter/rules/spacing` | The spacing rule. Parses each file once, then three types do the work: blank-line insertion, type reordering, embed-directive repair. | | `vet` | Wraps `go vet` behind an injectable toolchain so tests can fake it. | | `driver/config` | CLI config. Embeds the formatter config and adds the vet toggle; the `config.yml` schema is a public contract. | | `driver/report` | Typed output modes and the renderer; the JSON/agent shapes are a public contract. | -The CLI internals (`driver/internal/...`), one job each: `command` holds the one dispatch table both binaries share; `app` only wires things together, registering the language lanes with `toolchain` — the contract and registry that turn `--ts`/`--go` into an ordered set of lanes to run (no flags means all, TS before Go); `pipeline` runs generic steps whose summaries come from typed results (nothing scrapes rendered text); `console` owns terminal colors and printing; `gitfiles` owns git-backed file selection. Each language then owns its own behaviour in its own package: `golang` is the Go check/format use case (returning a typed `Outcome`) plus its format step; `typescript` builds the TS/Vue lint and format steps and splits its machinery across subpackages — `typescript/runtime` extracts and spawns the sidecar, `typescript/proto` is the frozen wire protocol, `typescript/filetypes` and `typescript/prettierignore` each own one kind of file selection composed by `typescript/sourcefiles`, and `typescript/embedded` holds the `go:embed` assets (its `bin/` folder is where staging writes — do not move it). +The CLI internals (`driver/internal/...`), one job each: `command` holds the dispatch table both binaries share; `app` wires things together, registering language lanes with `toolchain` — the registry that turns `--ts`/`--go` into an ordered set of lanes; `pipeline` runs generic steps whose summaries come from typed results (nothing scrapes rendered text); `console` owns terminal colors and printing; `gitfiles` owns git-backed file selection. + +Each language then owns its behavior in its own package. `golang` is the Go check/format use case (returning a typed `Outcome`) plus its format step. `typescript` builds the TS/Vue lint and format steps and splits its machinery across subpackages — `typescript/runtime` extracts and spawns the sidecar, `typescript/proto` is the frozen wire protocol, `typescript/filetypes` and `typescript/prettierignore` each own one kind of file selection composed by `typescript/sourcefiles`, and `typescript/embedded` holds the `go:embed` assets (its `bin/` folder is where staging writes — **do not move it**). ### TS side (`packages/ts/sidecar/src`) @@ -323,7 +427,8 @@ The CLI internals (`driver/internal/...`), one job each: `command` holds the one | `io/` | File and process access behind ports, with Node adapters. | | `cli/` | The commands, the DTOs that parse argv, and `CompositionRoot` — the one place everything gets constructed. Entry files are just `main()` shims. | -Adding a pass: write a class that implements `FormattingPass`, register it in `PipelineFactory`. Nothing else changes. Adding a Go rule: implement the `Rule` interface (`Name()`, `Apply()`) and register it before the engine is built. +**Adding a TS pass:** write a class implementing `FormattingPass`, register it in `PipelineFactory`. Nothing else changes. +**Adding a Go rule:** implement the `Rule` interface (`Name()`, `Apply()`) and register it before the engine is built. ### Ground rules @@ -331,6 +436,10 @@ Adding a pass: write a class that implements `FormattingPass`, register it in `P - **Parse, don't validate.** Outside data enters through a Zod-backed DTO exactly once. No `typeof` checks in TS source. - **The wire is frozen.** The Go↔TS protocol values never change casually; golden tests on both sides fail loudly if they drift. - **The repo formats itself.** `make format-all` must leave the tree unchanged. Write class members in the formatter's order (properties, constructor, methods) or the self-check will reorder them for you. -- **Goldens are never regenerated to make a change pass.** Pipeline transcripts, report renders, CLI usage/exit codes, and the spacing corpus are pinned byte-for-byte; if a golden fails, the code is wrong. +- **Golden are never regenerated to make a change pass.** Pipeline transcripts, report renders, CLI usage/exit codes, and the spacing corpus are pinned byte-for-byte; if a golden fails, the code is wrong. The Go pipeline runs `source → spacing rule → gofmt → goimports`, skipping any stage disabled in config. + +## License + +[MIT](LICENSE) diff --git a/package.json b/package.json index 1cea779..ef2ae45 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,10 @@ "name": "workspaces", "private": true, "scripts": { - "build": "./infra/task.sh build", + "build": "./scripts/task.sh build", "lint": "vp run --filter sidecar --fail-if-no-match lint:check", - "test": "vp run --filter formatter --filter vet --filter driver --filter sidecar --filter ts-infra --fail-if-no-match test", - "typecheck": "vp run --filter sidecar --filter ts-infra --fail-if-no-match typecheck" + "test": "vp run --filter formatter --filter vet --filter driver --filter sidecar --filter ts-toolchain --fail-if-no-match test", + "typecheck": "vp run --filter sidecar --filter ts-toolchain --fail-if-no-match typecheck" }, "devDependencies": { "vite-plus": "0.2.4" diff --git a/packages/go/driver/internal/app/doc.go b/packages/go/driver/internal/app/doc.go index 0dc9bd9..b486365 100644 --- a/packages/go/driver/internal/app/doc.go +++ b/packages/go/driver/internal/app/doc.go @@ -1,5 +1,4 @@ // Package app implements the fmtkit command surface: the pipeline -// orchestration that infra/bin/fmtkit provides in the container images, fused -// with the Go formatter CLI and the embedded TS toolchain (see -// internal/typescript). +// orchestration fused with the Go formatter CLI and the embedded TS toolchain +// (see internal/typescript). package app diff --git a/packages/go/driver/internal/typescript/embedded/doc.go b/packages/go/driver/internal/typescript/embedded/doc.go index a224cd7..0232a06 100644 --- a/packages/go/driver/internal/typescript/embedded/doc.go +++ b/packages/go/driver/internal/typescript/embedded/doc.go @@ -1,7 +1,7 @@ // Package embedded carries the TS toolchain baked into release binaries. // // The assets are staged under bin/_/ by -// packages/ts/infra/stage-ts-assets.sh and are only compiled in under the +// packages/ts/toolchain/stage-ts-assets.sh and are only compiled in under the // fmtkit_sidecar build tag (see sidecar_*.go); ordinary builds get the // sidecar_dev.go stub instead, so the staged directories need not exist. package embedded diff --git a/packages/go/driver/internal/typescript/runtime/assets.go b/packages/go/driver/internal/typescript/runtime/assets.go index 5dda7d8..6459410 100644 --- a/packages/go/driver/internal/typescript/runtime/assets.go +++ b/packages/go/driver/internal/typescript/runtime/assets.go @@ -78,7 +78,7 @@ func Resolve(version string) (Assets, error) { return Assets{}, errors.New( "this fmtkit build carries no TS toolchain (built without the fmtkit_sidecar tag); " + "point " + proto.SupportDirEnv + " at a staged toolchain directory " + - "(see packages/ts/infra/stage-ts-assets.sh), or use a release binary", + "(see packages/ts/toolchain/stage-ts-assets.sh), or use a release binary", ) } diff --git a/packages/go/driver/package.json b/packages/go/driver/package.json index 2da09a1..057dc1d 100644 --- a/packages/go/driver/package.json +++ b/packages/go/driver/package.json @@ -2,10 +2,10 @@ "name": "driver", "private": true, "scripts": { - "build": "cd ../../.. && ./infra/task.sh build", - "check": "../infra/task.sh check", - "gofmt": "../infra/task.sh gofmt", - "test": "../infra/task.sh test", - "vet": "../infra/task.sh vet" + "build": "cd ../../.. && ./scripts/task.sh build", + "check": "../scripts/task.sh check", + "gofmt": "../scripts/task.sh gofmt", + "test": "../scripts/task.sh test", + "vet": "../scripts/task.sh vet" } } diff --git a/packages/go/formatter/package.json b/packages/go/formatter/package.json index caeed5a..c76c1f0 100644 --- a/packages/go/formatter/package.json +++ b/packages/go/formatter/package.json @@ -2,9 +2,9 @@ "name": "formatter", "private": true, "scripts": { - "check": "../infra/task.sh check", - "gofmt": "../infra/task.sh gofmt", - "test": "../infra/task.sh test", - "vet": "../infra/task.sh vet" + "check": "../scripts/task.sh check", + "gofmt": "../scripts/task.sh gofmt", + "test": "../scripts/task.sh test", + "vet": "../scripts/task.sh vet" } } diff --git a/packages/go/infra/task.sh b/packages/go/scripts/task.sh similarity index 85% rename from packages/go/infra/task.sh rename to packages/go/scripts/task.sh index b0ae3bc..3bedb82 100755 --- a/packages/go/infra/task.sh +++ b/packages/go/scripts/task.sh @@ -3,13 +3,13 @@ set -euo pipefail # Go-toolchain tasks scoped to one package of the module. The package.json # shims in driver/, formatter/, and vet/ call this from their own directory, -# so ./... means that package's tree. Repo-wide tasks live in infra/task.sh. +# so ./... means that package's tree. Repo-wide tasks live in scripts/task.sh. # # usage: task.sh [args...] script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -source "${script_dir}/../../../infra/lib/env.sh" +source "${script_dir}/../../../scripts/lib/env.sh" with_env() { local status diff --git a/packages/go/vet/package.json b/packages/go/vet/package.json index 917b383..b8340f2 100644 --- a/packages/go/vet/package.json +++ b/packages/go/vet/package.json @@ -2,9 +2,9 @@ "name": "vet", "private": true, "scripts": { - "check": "../infra/task.sh check", - "gofmt": "../infra/task.sh gofmt", - "test": "../infra/task.sh test", - "vet": "../infra/task.sh vet" + "check": "../scripts/task.sh check", + "gofmt": "../scripts/task.sh gofmt", + "test": "../scripts/task.sh test", + "vet": "../scripts/task.sh vet" } } diff --git a/packages/ts/sidecar/src/sidecar.ts b/packages/ts/sidecar/src/sidecar.ts index 4d26099..ecbfa04 100644 --- a/packages/ts/sidecar/src/sidecar.ts +++ b/packages/ts/sidecar/src/sidecar.ts @@ -1,7 +1,7 @@ /** * Entry point for the self-contained TS toolchain sidecar bundled into the * `fmtkit` release binary via `bun build --compile` (see - * infra/scripts/release/stage-ts-assets.sh). + * packages/ts/toolchain/stage-ts-assets.sh). * * One executable multiplexes the three Node-based tools so the Bun runtime is * only shipped once. The napi bindings (oxc-parser, oxfmt, oxlint) are NOT diff --git a/packages/ts/infra/oxfmt-inprocess/api-bindings.test.ts b/packages/ts/toolchain/oxfmt-inprocess/api-bindings.test.ts similarity index 100% rename from packages/ts/infra/oxfmt-inprocess/api-bindings.test.ts rename to packages/ts/toolchain/oxfmt-inprocess/api-bindings.test.ts diff --git a/packages/ts/infra/oxfmt-inprocess/api-bindings.ts b/packages/ts/toolchain/oxfmt-inprocess/api-bindings.ts similarity index 100% rename from packages/ts/infra/oxfmt-inprocess/api-bindings.ts rename to packages/ts/toolchain/oxfmt-inprocess/api-bindings.ts diff --git a/packages/ts/infra/oxfmt-inprocess/cli-patcher.test.ts b/packages/ts/toolchain/oxfmt-inprocess/cli-patcher.test.ts similarity index 100% rename from packages/ts/infra/oxfmt-inprocess/cli-patcher.test.ts rename to packages/ts/toolchain/oxfmt-inprocess/cli-patcher.test.ts diff --git a/packages/ts/infra/oxfmt-inprocess/cli-patcher.ts b/packages/ts/toolchain/oxfmt-inprocess/cli-patcher.ts similarity index 100% rename from packages/ts/infra/oxfmt-inprocess/cli-patcher.ts rename to packages/ts/toolchain/oxfmt-inprocess/cli-patcher.ts diff --git a/packages/ts/infra/oxfmt-inprocess/errors.ts b/packages/ts/toolchain/oxfmt-inprocess/errors.ts similarity index 100% rename from packages/ts/infra/oxfmt-inprocess/errors.ts rename to packages/ts/toolchain/oxfmt-inprocess/errors.ts diff --git a/packages/ts/infra/oxfmt-inprocess/index.ts b/packages/ts/toolchain/oxfmt-inprocess/index.ts similarity index 100% rename from packages/ts/infra/oxfmt-inprocess/index.ts rename to packages/ts/toolchain/oxfmt-inprocess/index.ts diff --git a/packages/ts/infra/oxfmt-inprocess/patch-cli-dto.ts b/packages/ts/toolchain/oxfmt-inprocess/patch-cli-dto.ts similarity index 100% rename from packages/ts/infra/oxfmt-inprocess/patch-cli-dto.ts rename to packages/ts/toolchain/oxfmt-inprocess/patch-cli-dto.ts diff --git a/packages/ts/infra/oxfmt-inprocess/result.ts b/packages/ts/toolchain/oxfmt-inprocess/result.ts similarity index 100% rename from packages/ts/infra/oxfmt-inprocess/result.ts rename to packages/ts/toolchain/oxfmt-inprocess/result.ts diff --git a/packages/ts/infra/oxfmt-inprocess/shim-source.ts b/packages/ts/toolchain/oxfmt-inprocess/shim-source.ts similarity index 98% rename from packages/ts/infra/oxfmt-inprocess/shim-source.ts rename to packages/ts/toolchain/oxfmt-inprocess/shim-source.ts index aafdfe7..5de0169 100644 --- a/packages/ts/infra/oxfmt-inprocess/shim-source.ts +++ b/packages/ts/toolchain/oxfmt-inprocess/shim-source.ts @@ -45,7 +45,7 @@ export class ShimSource { * @returns The `worker-proxy` region, formatting embedded code in-process. */ static workerProxyRegion(): string { - return `//#region src-js/cli/worker-proxy.ts (${SHIM_MARKER} — see infra/scripts/release/oxfmt-inprocess) + return `//#region src-js/cli/worker-proxy.ts (${SHIM_MARKER} — see packages/ts/toolchain/oxfmt-inprocess) async function initExternalFormatter(numThreads) {} async function disposeExternalFormatter() {} function formatFile(options, code) { diff --git a/packages/ts/infra/oxfmt-inprocess/text-files.ts b/packages/ts/toolchain/oxfmt-inprocess/text-files.ts similarity index 100% rename from packages/ts/infra/oxfmt-inprocess/text-files.ts rename to packages/ts/toolchain/oxfmt-inprocess/text-files.ts diff --git a/packages/ts/infra/package.json b/packages/ts/toolchain/package.json similarity index 96% rename from packages/ts/infra/package.json rename to packages/ts/toolchain/package.json index ae9b299..11b2f73 100644 --- a/packages/ts/infra/package.json +++ b/packages/ts/toolchain/package.json @@ -1,5 +1,5 @@ { - "name": "ts-infra", + "name": "ts-toolchain", "private": true, "type": "module", "imports": { diff --git a/packages/ts/infra/patch-oxfmt-inprocess.ts b/packages/ts/toolchain/patch-oxfmt-inprocess.ts similarity index 100% rename from packages/ts/infra/patch-oxfmt-inprocess.ts rename to packages/ts/toolchain/patch-oxfmt-inprocess.ts diff --git a/packages/ts/infra/stage-ts-assets.sh b/packages/ts/toolchain/stage-ts-assets.sh similarity index 91% rename from packages/ts/infra/stage-ts-assets.sh rename to packages/ts/toolchain/stage-ts-assets.sh index 315e0d8..f1d1948 100755 --- a/packages/ts/infra/stage-ts-assets.sh +++ b/packages/ts/toolchain/stage-ts-assets.sh @@ -15,8 +15,8 @@ set -euo pipefail # package that embeds it: go:embed cannot reach outside its own directory. # # Tool versions come from packages/ts/sidecar/package.json devDependencies; -# patch-script versions come from packages/ts/infra/package.json. Requires bash, -# node, npm, and bun. +# patch-script versions come from packages/ts/toolchain/package.json. Requires +# bash, node, npm, and bun. # # usage: stage-ts-assets.sh @@ -33,7 +33,7 @@ fi root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" dist="${FMTKIT_TS_ASSET_DIR:-${root}/packages/go/driver/internal/typescript/embedded/bin}" -source "${root}/infra/lib/host-target.sh" +source "${root}/scripts/lib/host-target.sh" all_targets=(darwin_arm64 darwin_amd64 linux_arm64 linux_amd64) @@ -94,12 +94,12 @@ pin() { } sidecar_package_json="${root}/packages/ts/sidecar/package.json" -infra_package_json="${root}/packages/ts/infra/package.json" +toolchain_package_json="${root}/packages/ts/toolchain/package.json" oxfmt_pin="$(pin "${sidecar_package_json}" oxfmt)" oxlint_pin="$(pin "${sidecar_package_json}" oxlint)" oxc_parser_pin="$(pin "${sidecar_package_json}" oxc-parser)" -zod_pin="$(pin "${infra_package_json}" zod)" +zod_pin="$(pin "${toolchain_package_json}" zod)" workdir="$(mktemp -d)" trap 'rm -rf "${workdir}"' EXIT @@ -133,20 +133,20 @@ cp "${root}/packages/ts/sidecar/src/package.json" "${workdir}/src/package.json" # Tinypool child_process pool whose worker entry scripts do not survive # `bun build --compile` (they resolve to non-existent /$bunfs/root/ paths), which # hangs the binary on any such file. Rewrite oxfmt to do that work in-process -# before it is bundled. See packages/ts/infra/oxfmt-inprocess for the full +# before it is bundled. See packages/ts/toolchain/oxfmt-inprocess for the full # rationale. Run by node directly (type stripping) so staging needs no tsx. -# Its ESM imports resolve from the infra package, not the temporary workdir. +# Its ESM imports resolve from the toolchain package, not the temporary workdir. if ! ( - cd "${root}/packages/ts/infra" + cd "${root}/packages/ts/toolchain" node -e "import('zod')" >/dev/null 2>&1 ); then printf 'stage-ts-assets: installing zod for the oxfmt patch script\n' >&2 npm install --no-save --no-audit --no-fund \ - --prefix "${root}/packages/ts/infra" \ + --prefix "${root}/packages/ts/toolchain" \ "zod@${zod_pin}" >/dev/null fi -node "${root}/packages/ts/infra/patch-oxfmt-inprocess.ts" "${workdir}/node_modules/oxfmt/dist" +node "${root}/packages/ts/toolchain/patch-oxfmt-inprocess.ts" "${workdir}/node_modules/oxfmt/dist" # The napi bindings stay external: every target loads them from files staged # next to the sidecar through NAPI_RS_NATIVE_LIBRARY_PATH, which keeps the JS diff --git a/packages/ts/infra/tsconfig.json b/packages/ts/toolchain/tsconfig.json similarity index 100% rename from packages/ts/infra/tsconfig.json rename to packages/ts/toolchain/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a16791e..885d8ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,11 +18,23 @@ importers: packages/go/vet: {} - packages/ts/infra: + packages/ts/sidecar: devDependencies: '@types/node': specifier: 26.1.1 version: 26.1.1 + fast-check: + specifier: 4.9.0 + version: 4.9.0 + oxc-parser: + specifier: 0.140.0 + version: 0.140.0 + oxfmt: + specifier: 0.59.0 + version: 0.59.0(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) + oxlint: + specifier: 1.74.0 + version: 1.74.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) tsx: specifier: 4.23.1 version: 4.23.1 @@ -33,23 +45,11 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/ts/sidecar: + packages/ts/toolchain: devDependencies: '@types/node': specifier: 26.1.1 version: 26.1.1 - fast-check: - specifier: 4.9.0 - version: 4.9.0 - oxc-parser: - specifier: 0.140.0 - version: 0.140.0 - oxfmt: - specifier: 0.59.0 - version: 0.59.0(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) - oxlint: - specifier: 1.74.0 - version: 1.74.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) tsx: specifier: 4.23.1 version: 4.23.1 @@ -2366,19 +2366,19 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/browser-preview@4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)))': + '@vitest/browser-preview@4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) - vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) + '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10) + vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)))': + '@vitest/browser@4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) @@ -2387,7 +2387,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) + vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -2916,8 +2916,8 @@ snapshots: dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) - '@vitest/browser-preview': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) + '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/browser-preview': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10) '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) '@vitest/pretty-format': 4.1.10 @@ -2929,7 +2929,7 @@ snapshots: oxfmt: 0.57.0(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) oxlint-tsgolint: 0.24.0 - vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) + vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.4 '@voidzero-dev/vite-plus-darwin-x64': 0.2.4 @@ -2974,8 +2974,8 @@ snapshots: dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) - '@vitest/browser-preview': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) + '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10) + '@vitest/browser-preview': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10) '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) '@vitest/pretty-format': 4.1.10 @@ -2987,7 +2987,7 @@ snapshots: oxfmt: 0.57.0(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) oxlint-tsgolint: 0.24.0 - vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) + vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.4 '@voidzero-dev/vite-plus-darwin-x64': 0.2.4 @@ -3042,7 +3042,7 @@ snapshots: fsevents: 2.3.3 tsx: 4.23.1 - vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10))(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)): + vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) @@ -3066,7 +3066,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 - '@vitest/browser-preview': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) + '@vitest/browser-preview': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 880b6ba..b532348 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ packages: - packages/go/* - - packages/ts/infra + - packages/ts/toolchain - packages/ts/sidecar diff --git a/infra/lib/env.sh b/scripts/lib/env.sh similarity index 100% rename from infra/lib/env.sh rename to scripts/lib/env.sh diff --git a/infra/lib/host-target.sh b/scripts/lib/host-target.sh similarity index 91% rename from infra/lib/host-target.sh rename to scripts/lib/host-target.sh index 238fa34..7948359 100755 --- a/infra/lib/host-target.sh +++ b/scripts/lib/host-target.sh @@ -2,7 +2,7 @@ # Maps the running machine onto one of the _ names that the staged # TS toolchain assets are keyed by. Sourced by stage-ts-assets.sh to pick what to -# build, and by tasks/fmtkit.sh to find what was built. +# build, and by scripts/task.sh to find what was built. host_target() { local os arch diff --git a/infra/release/create-release-tag.sh b/scripts/release/create-release-tag.sh similarity index 100% rename from infra/release/create-release-tag.sh rename to scripts/release/create-release-tag.sh diff --git a/infra/release/release.sh b/scripts/release/release.sh similarity index 100% rename from infra/release/release.sh rename to scripts/release/release.sh diff --git a/infra/release/verify-release-tag.sh b/scripts/release/verify-release-tag.sh similarity index 100% rename from infra/release/verify-release-tag.sh rename to scripts/release/verify-release-tag.sh diff --git a/infra/task.sh b/scripts/task.sh similarity index 96% rename from infra/task.sh rename to scripts/task.sh index ccdcf12..6e9ce61 100755 --- a/infra/task.sh +++ b/scripts/task.sh @@ -3,8 +3,8 @@ set -euo pipefail # Single entrypoint for the repo-wide tasks: everything that spans both halves # of the pipeline lives here as a subcommand. Go-toolchain tasks scoped to a -# single package live in packages/go/infra/task.sh instead; the release tag -# machinery is under infra/release/. +# single package live in packages/go/scripts/task.sh instead; the release tag +# machinery is under scripts/release/. # # usage: task.sh [args...] @@ -69,7 +69,7 @@ sidecar_is_stale() { # staged on demand and reused until their sources change. The repo root is not # inside the Go module, so the inner loop is an incremental build into storage/ # rather than a `go run`; the embedded-asset path releases use is covered -# separately by infra/test-binary-smoke.sh. +# separately by scripts/test-binary-smoke.sh. run_fmtkit() { local support_dir sidecar bin @@ -77,7 +77,7 @@ run_fmtkit() { sidecar="${support_dir}/fmtkit-ts-sidecar" if sidecar_is_stale "$sidecar"; then - "${REPO_ROOT}/packages/ts/infra/stage-ts-assets.sh" host + "${REPO_ROOT}/packages/ts/toolchain/stage-ts-assets.sh" host fi ensure_storage_layout diff --git a/infra/test-binary-smoke.sh b/scripts/test-binary-smoke.sh similarity index 98% rename from infra/test-binary-smoke.sh rename to scripts/test-binary-smoke.sh index ad15bfd..a7db27d 100755 --- a/infra/test-binary-smoke.sh +++ b/scripts/test-binary-smoke.sh @@ -8,7 +8,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "$0")/.." && pwd)" -"${repo_root}/packages/ts/infra/stage-ts-assets.sh" host +"${repo_root}/packages/ts/toolchain/stage-ts-assets.sh" host tmp_root="$(mktemp -d)" diff --git a/vite.config.ts b/vite.config.ts index 67ee9e4..30ecd3d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,14 +24,14 @@ export default defineConfig({ tasks: { check: `vp run ${workspacePackages} check`, // fmtkit formats itself with the binary it ships. - format: './infra/task.sh format', - gofmt: './infra/task.sh gofmt', - 'install-cli': './infra/task.sh with-env go -C packages/go install ./driver/cmd/fmtkit-go', - release: './infra/release/release.sh', - 'test:binary': './infra/test-binary-smoke.sh', - 'test:coverage': './infra/task.sh coverage', + format: './scripts/task.sh format', + gofmt: './scripts/task.sh gofmt', + 'install-cli': './scripts/task.sh with-env go -C packages/go install ./driver/cmd/fmtkit-go', + release: './scripts/release/release.sh', + 'test:binary': './scripts/test-binary-smoke.sh', + 'test:coverage': './scripts/task.sh coverage', 'test-race': - 'CGO_ENABLED=1 ./infra/task.sh with-env go -C packages/go/formatter test ./... -race -v && CGO_ENABLED=1 ./infra/task.sh with-env go -C packages/go/vet test ./... -race -v && CGO_ENABLED=1 ./infra/task.sh with-env go -C packages/go/driver test ./... -race -v', + 'CGO_ENABLED=1 ./scripts/task.sh with-env go -C packages/go/formatter test ./... -race -v && CGO_ENABLED=1 ./scripts/task.sh with-env go -C packages/go/vet test ./... -race -v && CGO_ENABLED=1 ./scripts/task.sh with-env go -C packages/go/driver test ./... -race -v', vet: `vp run ${goPackages} vet`, }, }, From 7da5c9f56271304f259d98ad69b1f9f52cfee6ee Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 27 Jul 2026 10:46:03 +0800 Subject: [PATCH 19/22] imports --- .oxlintrc.json | 2 +- packages/ts/sidecar/package.json | 4 ++-- .../ts/sidecar/src/alias-specifiers.test.ts | 7 +------ packages/ts/sidecar/src/sidecar.ts | 16 +++++++++++++--- packages/ts/sidecar/src/vendor-cli.d.ts | 19 +++++++++++++++++++ packages/ts/sidecar/tsconfig.json | 6 +++++- packages/ts/toolchain/stage-ts-assets.sh | 12 ++++++++++-- scripts/task.sh | 1 + 8 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 packages/ts/sidecar/src/vendor-cli.d.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 2a0fe58..c4be189 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,5 +1,5 @@ { - "$schema": "./node_modules/oxlint/configuration_schema.json", + "$schema": "https://cdn.jsdelivr.net/npm/oxlint@1.74.0/configuration_schema.json", "plugins": ["typescript", "oxc"], "categories": { "correctness": "error" diff --git a/packages/ts/sidecar/package.json b/packages/ts/sidecar/package.json index 13ee8a4..6757743 100644 --- a/packages/ts/sidecar/package.json +++ b/packages/ts/sidecar/package.json @@ -7,8 +7,8 @@ }, "scripts": { "validate-syntax": "cd ../../.. && tsx packages/ts/sidecar/src/cli/validate-syntax.ts", - "lint": "cd ../../.. && git ls-files --cached --others --exclude-standard -z | xargs -0 packages/ts/sidecar/node_modules/.bin/oxlint --fix", - "lint:check": "cd ../../.. && git ls-files --cached --others --exclude-standard -z | xargs -0 packages/ts/sidecar/node_modules/.bin/oxlint", + "lint": "cd ../../.. && git ls-files --cached --others --exclude-standard -z | xargs -0 oxlint --fix", + "lint:check": "cd ../../.. && git ls-files --cached --others --exclude-standard -z | xargs -0 oxlint", "check": "pnpm lint:check", "test": "node --import tsx --test 'src/**/*.test.ts'", "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-lines=90 'src/**/*.test.ts'", diff --git a/packages/ts/sidecar/src/alias-specifiers.test.ts b/packages/ts/sidecar/src/alias-specifiers.test.ts index 75b7e0e..9564479 100644 --- a/packages/ts/sidecar/src/alias-specifiers.test.ts +++ b/packages/ts/sidecar/src/alias-specifiers.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { readdir, readFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; +import { join } from 'node:path'; import { test } from 'node:test'; import { AstReader } from '#sidecar/syntax/ast-reader'; import { isErr } from '#sidecar/kernel/result'; @@ -8,7 +8,6 @@ import { SourceParser } from '#sidecar/syntax/source-parser'; import type { Node } from '#sidecar/syntax/node-schema'; const sourceExtensions = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']); -const exemptFiles = new Set(['sidecar.ts']); const ast = new AstReader(); const parser = new SourceParser(); @@ -104,10 +103,6 @@ test('script module specifiers use aliases instead of relative paths', async () const violations: string[] = []; for (const file of files) { - if (exemptFiles.has(basename(file))) { - continue; - } - const source = await readFile(file, 'utf8'); const specifiers = collectModuleSpecifiers(file, source); diff --git a/packages/ts/sidecar/src/sidecar.ts b/packages/ts/sidecar/src/sidecar.ts index ecbfa04..162fda6 100644 --- a/packages/ts/sidecar/src/sidecar.ts +++ b/packages/ts/sidecar/src/sidecar.ts @@ -7,6 +7,17 @@ * only shipped once. The napi bindings (oxc-parser, oxfmt, oxlint) are NOT * bundled; they are extracted next to this executable and loaded through the * napi-rs NAPI_RS_NATIVE_LIBRARY_PATH override. + * + * The `@vendor/*` specifiers below are `compilerOptions.paths` entries in + * packages/ts/sidecar/tsconfig.json, not npm packages. oxfmt and oxlint keep + * their CLI entry out of their `exports` map, so `oxfmt/dist/cli.js` does not + * resolve, and a `#`-prefixed alias cannot reach it either: Node rejects an + * `imports` target that escapes the package or names a node_modules segment. + * `paths` is the one mechanism both `tsc` and `bun build` honour — which means + * these two imports do NOT resolve under plain node or tsx. Targets resolve + * relative to the tsconfig's own directory, so node_modules must sit beside the + * tsconfig; that is why stage-ts-assets.sh copies it into the build workdir. + * Both entries are side-effect-only and typed in vendor-cli.d.ts. */ import { dirname, join } from 'node:path'; import { z } from 'zod'; @@ -86,15 +97,14 @@ switch (mode) { case 'oxfmt': process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bindings.oxfmt; - await import('../node_modules/oxfmt/dist/cli.js'); + await import('@vendor/oxfmt-cli'); break; case 'oxlint': process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bindings.oxlint; - // @ts-expect-error -- the oxlint CLI entry ships without declarations. - await import('../node_modules/oxlint/dist/cli.js'); + await import('@vendor/oxlint-cli'); break; diff --git a/packages/ts/sidecar/src/vendor-cli.d.ts b/packages/ts/sidecar/src/vendor-cli.d.ts new file mode 100644 index 0000000..5141a08 --- /dev/null +++ b/packages/ts/sidecar/src/vendor-cli.d.ts @@ -0,0 +1,19 @@ +/** + * Types for the vendored oxfmt and oxlint CLI entries that sidecar.ts imports + * through the `@vendor/*` aliases. + * + * Both are side-effect-only modules: importing one runs the CLI against + * `process.argv` and exports nothing. oxfmt ships a `dist/cli.d.ts` that says + * exactly that (`export {}`) and oxlint ships none at all, but neither is + * reachable by a `paths` substitution — TypeScript resolves the substituted + * `.js` file literally rather than looking for declarations beside it — so + * declaring them here is what keeps `noImplicitAny` satisfied without a + * suppression at the call site. + * + * These declarations do NOT make the `compilerOptions.paths` entries redundant: + * `bun build` needs them to find the real files at bundle time. See the header + * comment in sidecar.ts for the whole arrangement. + */ +declare module '@vendor/oxfmt-cli'; + +declare module '@vendor/oxlint-cli'; diff --git a/packages/ts/sidecar/tsconfig.json b/packages/ts/sidecar/tsconfig.json index 1306637..130864d 100644 --- a/packages/ts/sidecar/tsconfig.json +++ b/packages/ts/sidecar/tsconfig.json @@ -11,7 +11,11 @@ "esModuleInterop": true, "skipLibCheck": true, "types": ["node"], - "noEmit": true + "noEmit": true, + "paths": { + "@vendor/oxfmt-cli": ["./node_modules/oxfmt/dist/cli.js"], + "@vendor/oxlint-cli": ["./node_modules/oxlint/dist/cli.js"] + } }, "include": ["src/**/*.ts"] } diff --git a/packages/ts/toolchain/stage-ts-assets.sh b/packages/ts/toolchain/stage-ts-assets.sh index f1d1948..8620309 100755 --- a/packages/ts/toolchain/stage-ts-assets.sh +++ b/packages/ts/toolchain/stage-ts-assets.sh @@ -105,7 +105,11 @@ workdir="$(mktemp -d)" trap 'rm -rf "${workdir}"' EXIT # Mirror the layout sidecar.ts expects in the repo: the sources next to their -# package.json (for the #sidecar imports map) with node_modules one level up. +# package.json (for the #sidecar imports map) with node_modules one level up, +# and the tsconfig beside that node_modules. sidecar.ts reaches the vendored +# oxfmt and oxlint CLI entries through its @vendor/* compilerOptions.paths +# aliases, whose targets resolve relative to the tsconfig's own directory — the +# sidecar package root in the repo, "${workdir}" here. mkdir -p "${workdir}/src" # Copy the sources recursively, preserving the directory structure, so nested @@ -118,6 +122,7 @@ while IFS= read -r script; do done < <(cd "${src}" && find . -name '*.ts' ! -name '*.test.ts') cp "${root}/packages/ts/sidecar/src/package.json" "${workdir}/src/package.json" +cp "${root}/packages/ts/sidecar/tsconfig.json" "${workdir}/tsconfig.json" ( cd "${workdir}" @@ -199,7 +204,10 @@ for target in "${targets[@]}"; do # Run bun from the throwaway workdir: bun drops *.bun-build scratch files # into the invoking directory, and a dirty repo would fail the release's - # git state check. + # git state check. The cwd is also how bun picks up the tsconfig copied + # above, which carries the @vendor/* aliases sidecar.ts imports; passing + # --tsconfig-override instead builds fine but makes bun 1.3.14 report a + # spurious "directory mismatch" internal error on every target. ( cd "${workdir}" diff --git a/scripts/task.sh b/scripts/task.sh index 6e9ce61..73968ae 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -57,6 +57,7 @@ sidecar_is_stale() { newer="$(find \ "${REPO_ROOT}/packages/ts/sidecar/src" \ "${REPO_ROOT}/packages/ts/sidecar/package.json" \ + "${REPO_ROOT}/packages/ts/sidecar/tsconfig.json" \ "${REPO_ROOT}/.oxfmtrc.json" \ "${REPO_ROOT}/.oxlintrc.json" \ -newer "$sidecar" -print -quit 2>/dev/null)" From ed717f6251ad408591cc0ba23e6eed303aa4e61d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Mon, 27 Jul 2026 11:05:16 +0800 Subject: [PATCH 20/22] chore(deps): refresh Go and TS toolchains Go 1.26.4 -> 1.26.5, plus the seven places the version is echoed by hand (README badge and prose, the smoke-test and testutil go.mod fixtures, the vet go.work fixtures, and the Dockerfile fixture string). CI reads the version from go.mod, so those echoes drift silently otherwise. Go modules: go-isatty 0.0.22 -> 0.0.24, go.yaml.in/yaml/v3 3.0.4 -> 3.0.5. Every other module was already current. go mod tidy also drops a stale gopkg.in/check.v1 indirect. TypeScript: oxfmt 0.59.0 -> 0.60.0, oxlint 1.74.0 -> 1.75.0, oxc-parser 0.140.0 -> 0.141.0, vite-plus 0.2.4 -> 0.2.6. The vite-plus bump is coupled to oxc-parser: 0.2.6 pins @oxc-project/types to =0.141.0, so moving one without the other splits the resolution again. pnpm 10.33.0 -> 11.17.0 and Node 25.8.2 -> 25.9.0. Regenerating the lockfile under pnpm 11 also clears two pre-existing drifts: stale @oxfmt/binding-* 0.57.0 entries, and root vite-plus resolving typescript 6.0.3 while both TS packages resolved 7.0.2. pnpm 11 gates dependency build scripts, so pnpm-workspace.yaml now records esbuild explicitly. It has never run in this workspace -- Vite+ resolves the native binary through the @esbuild/ optional dependency -- so it stays off, which preserves current behaviour rather than changing it. oxfmt 0.60.0 was the risk here: the in-process CLI patch anchors on strings in oxfmt's bundle, and a mismatch shows up as a hang rather than a failure. The binary smoke test passes, and the patcher resolved the new content-hashed API module, so the anchors and the shim still hold. --- .github/workflows/release.yml | 4 +- .github/workflows/tests.yml | 4 +- .nvmrc | 2 +- README.md | 4 +- package.json | 4 +- packages/go/driver/testutil/files.go | 2 +- packages/go/formatter/engine/engine_test.go | 2 +- packages/go/go.mod | 7 +- packages/go/go.sum | 11 +- packages/go/vet/vet_test.go | 4 +- packages/ts/sidecar/package.json | 6 +- pnpm-lock.yaml | 1606 ++++++++----------- pnpm-workspace.yaml | 8 + scripts/test-binary-smoke.sh | 2 +- 14 files changed, 728 insertions(+), 938 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f08491c..e9025af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: - uses: voidzero-dev/setup-vp@v1 with: - node-version: 25.8.2 + node-version: 25.9.0 cache: true - uses: actions/setup-go@v6 @@ -58,7 +58,7 @@ jobs: - uses: voidzero-dev/setup-vp@v1 with: - node-version: 25.8.2 + node-version: 25.9.0 cache: true - uses: actions/setup-go@v6 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 55b24b1..d73f1e7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -64,7 +64,7 @@ jobs: - uses: voidzero-dev/setup-vp@v1 name: Install Vite+ with: - node-version: 25.8.2 + node-version: 25.9.0 cache: true - uses: actions/setup-go@v6 @@ -97,7 +97,7 @@ jobs: - uses: voidzero-dev/setup-vp@v1 name: Install Vite+ with: - node-version: 25.8.2 + node-version: 25.9.0 cache: true - uses: actions/setup-go@v6 diff --git a/.nvmrc b/.nvmrc index 9af7122..e362e4c 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -25.8.2 +25.9.0 diff --git a/README.md b/README.md index 88cd888..5a0f9f4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # fmtkit [![Go Reference](https://pkg.go.dev/badge/go.ollin.sh/fmtkit/driver.svg)](https://pkg.go.dev/go.ollin.sh/fmtkit/driver) -[![Go 1.26.4](https://img.shields.io/badge/go-1.26.4-00ADD8?logo=go&logoColor=white)](https://go.dev/doc/go1.26) +[![Go 1.26.5](https://img.shields.io/badge/go-1.26.5-00ADD8?logo=go&logoColor=white)](https://go.dev/doc/go1.26) [![Tests](https://github.com/oullin/fmtkit/actions/workflows/tests.yml/badge.svg)](https://github.com/oullin/fmtkit/actions/workflows/tests.yml) [![Release](https://github.com/oullin/fmtkit/actions/workflows/release.yml/badge.svg)](https://github.com/oullin/fmtkit/actions/workflows/release.yml) @@ -351,7 +351,7 @@ Note that `format` exits `0` when it _fixes_ violations — it only fails on a g ## Development -You'll need Go 1.26.4+, [Bun](https://bun.com) (to compile the TS sidecar), and Vite+ (which manages the Node.js runtime and pnpm version the workspace declares). +You'll need Go 1.26.5+, [Bun](https://bun.com) (to compile the TS sidecar), and Vite+ (which manages the Node.js runtime and pnpm version the workspace declares). ```bash curl -fsSL https://vite.plus -o install-vp.sh diff --git a/package.json b/package.json index ef2ae45..879c97c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "typecheck": "vp run --filter sidecar --filter ts-toolchain --fail-if-no-match typecheck" }, "devDependencies": { - "vite-plus": "0.2.4" + "vite-plus": "0.2.6" }, - "packageManager": "pnpm@10.33.0" + "packageManager": "pnpm@11.17.0" } diff --git a/packages/go/driver/testutil/files.go b/packages/go/driver/testutil/files.go index c99e0e8..11b0081 100644 --- a/packages/go/driver/testutil/files.go +++ b/packages/go/driver/testutil/files.go @@ -27,7 +27,7 @@ func WriteGoFile(t *testing.T, path string, content string) { func WriteGoMod(t *testing.T, dir string, modulePath string) { t.Helper() - WriteFile(t, filepath.Join(dir, "go.mod"), "module "+modulePath+"\n\ngo 1.26.4\n") + WriteFile(t, filepath.Join(dir, "go.mod"), "module "+modulePath+"\n\ngo 1.26.5\n") } func WriteGoWork(t *testing.T, dir string, content string) { diff --git a/packages/go/formatter/engine/engine_test.go b/packages/go/formatter/engine/engine_test.go index 437dfb2..2a34bbf 100644 --- a/packages/go/formatter/engine/engine_test.go +++ b/packages/go/formatter/engine/engine_test.go @@ -153,7 +153,7 @@ func TestCollectGoFilesSkipsHiddenVendorAndGenerated(t *testing.T) { testutil.WriteGoFile(t, filepath.Join(root, "vendor", "skip.go"), "package sample\n") testutil.WriteGoFile(t, filepath.Join(root, ".hidden", "skip.go"), "package sample\n") testutil.WriteGoFile(t, filepath.Join(root, "generated.gen.go"), "package sample\n") - testutil.WriteFile(t, filepath.Join(root, "docker", "Dockerfile.golang"), "FROM golang:1.26.4-bookworm\n") + testutil.WriteFile(t, filepath.Join(root, "docker", "Dockerfile.golang"), "FROM golang:1.26.5-bookworm\n") files, err := engine.CollectGoFiles([]string{root}, config.Default()) diff --git a/packages/go/go.mod b/packages/go/go.mod index 2222d2c..c978ee6 100644 --- a/packages/go/go.mod +++ b/packages/go/go.mod @@ -1,10 +1,10 @@ module go.ollin.sh/fmtkit -go 1.26.4 +go 1.26.5 require ( github.com/fatih/color v1.19.0 - github.com/mattn/go-isatty v0.0.22 + github.com/mattn/go-isatty v0.0.24 github.com/spf13/viper v1.21.0 golang.org/x/tools v0.48.0 ) @@ -19,10 +19,9 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/mod v0.38.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/packages/go/go.sum b/packages/go/go.sum index 1a2054c..2b76d7a 100644 --- a/packages/go/go.sum +++ b/packages/go/go.sum @@ -16,8 +16,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -38,8 +38,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -50,8 +50,5 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/packages/go/vet/vet_test.go b/packages/go/vet/vet_test.go index 6b5d3f3..34cff89 100644 --- a/packages/go/vet/vet_test.go +++ b/packages/go/vet/vet_test.go @@ -112,7 +112,7 @@ func TestRunPrefersWorkspace(t *testing.T) { workspaceFile := filepath.Join(workspaceRoot, "go.work") moduleFile := filepath.Join(moduleRoot, "go.mod") - testutil.WriteFile(t, workspaceFile, "go 1.26.4\n") + testutil.WriteFile(t, workspaceFile, "go 1.26.5\n") testutil.WriteFile(t, moduleFile, "module example.com/test\n") tc := fakeToolchain{ @@ -169,7 +169,7 @@ func run() { println("ok") } `) - testutil.WriteGoWork(t, workspaceRoot, `go 1.26.4 + testutil.WriteGoWork(t, workspaceRoot, `go 1.26.5 use ( ./module-a diff --git a/packages/ts/sidecar/package.json b/packages/ts/sidecar/package.json index 6757743..3e9d2bb 100644 --- a/packages/ts/sidecar/package.json +++ b/packages/ts/sidecar/package.json @@ -17,9 +17,9 @@ "devDependencies": { "@types/node": "26.1.1", "fast-check": "4.9.0", - "oxc-parser": "0.140.0", - "oxfmt": "0.59.0", - "oxlint": "1.74.0", + "oxc-parser": "0.141.0", + "oxfmt": "0.60.0", + "oxlint": "1.75.0", "tsx": "4.23.1", "typescript": "7.0.2", "zod": "4.4.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 885d8ef..7cd5db7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: vite-plus: - specifier: 0.2.4 - version: 0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) + specifier: 0.2.6 + version: 0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) packages/go/driver: {} @@ -27,14 +27,14 @@ importers: specifier: 4.9.0 version: 4.9.0 oxc-parser: - specifier: 0.140.0 - version: 0.140.0 + specifier: 0.141.0 + version: 0.141.0 oxfmt: - specifier: 0.59.0 - version: 0.59.0(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) + specifier: 0.60.0 + version: 0.60.0(vite-plus@0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) oxlint: - specifier: 1.74.0 - version: 1.74.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) + specifier: 1.75.0 + version: 1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) tsx: specifier: 4.23.1 version: 4.23.1 @@ -260,666 +260,419 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@oxc-parser/binding-android-arm-eabi@0.140.0': - resolution: {integrity: sha512-ZfjDZ422mo7eo3b3VltqNsV9kmv1qt/sPEAMSl64iOSwhVfd0eIZ9LB79Mbs1xYXJnk7WSROwzBCKDIiVxPTvQ==} + '@oxc-parser/binding-android-arm-eabi@0.141.0': + resolution: {integrity: sha512-jk7086MFvR/T4DG9IY7MKBVt1PMxvSZoz/TvnifodvS0pjghVwJHRttnAExhlwdMOgHv1TmLdENnbNpYk2zjvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.140.0': - resolution: {integrity: sha512-Ia8jSvikUX6Sf+Ht+KOCUF/k1HpR0VlmqIYymubmWDebOEGtsyliHDR6JxsZ4IX3/c/GbrB1uh09aVGQv/LQmQ==} + '@oxc-parser/binding-android-arm64@0.141.0': + resolution: {integrity: sha512-a4XDQ27ZT7e7zwAlxJDTiCA7IBGWDuy2+MhFq85Of7XlBSmpkfcBFml11q0Zx6f7RMuI0B4xCtt2ytBS4yOptg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.140.0': - resolution: {integrity: sha512-G6VK0nK61pH0d0mBjUqSZbVxGqqO5uzeginLDQj+gOO6ObfJjXRwgkD/ol0w1INcnFeAb6YGGO7qc3ueGHaycQ==} + '@oxc-parser/binding-darwin-arm64@0.141.0': + resolution: {integrity: sha512-m/kVk6rzYmBeHYnz+1Y5fod00AVTTxMbC71azFfm/zjx1j9XxwKtA0+VfkKuVMC8rbghb9TtfevnuWZa9OuPEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.140.0': - resolution: {integrity: sha512-HazBOuZzd2pO1C2uMmp8Gv7mhzMHqKSKDS1OZfcLEvpIcgA+48J92HEtNanVHDIzRD9PRPCV6aS6fkZIWOVl8Q==} + '@oxc-parser/binding-darwin-x64@0.141.0': + resolution: {integrity: sha512-o0X+6KZlfucWU/v5oKRQPwdFXsXAjW8jmpo/Gpw/qyKsbKtlfkHoeH9Bjp/m13TwjewvJnCkwF0DWzgpC4HjTQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.140.0': - resolution: {integrity: sha512-9hSUU+HmTUyOe4JzMHxNGgLWNY7rrO+6ShicZwImNJacEAACDMIkuEQQkvXSL+WJN50jaNtLYJv8s4OcBdpyUQ==} + '@oxc-parser/binding-freebsd-x64@0.141.0': + resolution: {integrity: sha512-W5KbTnNkTMMMylqj6dYqnsXvkmESVPodPKYLJ5zdzIPdl9fUJtolkpUeSzYEbGGYB4a4A4avl3EePnZ/wLIdJg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.140.0': - resolution: {integrity: sha512-RAEuQsYtS0KcDFqN0ABTjyyNlokS91JeuDuoW9tEbG0JTbRNXnpQUdbYc/16JoA6Z/2ALbNrE3KmxtqDiuIjCQ==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.141.0': + resolution: {integrity: sha512-g3dtbJa8zeOGK36Sr9cQavsdi5H/ie2hVjrSjIxsNAR1qZA40ZYVXnfdfoMAlq8CmB9qFL1yhsSCUHeNmdmt8w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.140.0': - resolution: {integrity: sha512-c4CkHvPvqfojouredJ0w3e6+jiBq0SbFyhH61kr/zPb/7XsaYTNKQ54vmlSsopfdQbNDX40ZeK9Abs2Qet6wcw==} + '@oxc-parser/binding-linux-arm-musleabihf@0.141.0': + resolution: {integrity: sha512-e6hwQqd+3lvP13G2jxvFpoA7dzHcFLN+Mq47JCVMtdNHbbyBRo756JCtbbJH6ca8inTfyqZoqBmS3vhQlzAK2w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.140.0': - resolution: {integrity: sha512-yrjmLj8ixPB25yqvPGr28meGjb+keed7m1GqqY/0uqkhZIoT4t9zmfwUgFEtC33C7dtE+UQ7TU0IaVxf97SWJg==} + '@oxc-parser/binding-linux-arm64-gnu@0.141.0': + resolution: {integrity: sha512-vXz2BLAuypA+4MLyBg94pzEo6THVnzYnCtAjXoihIIQo0t2pnp/AmW+SH1EI+4VbuJnC//KplIJ5yyaCGua4jA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.140.0': - resolution: {integrity: sha512-ggGMQTN8Agwxp2WiLMpdY671dt0qTDJWiWlJeig3HnUwTnerRl0J2JdGVghWBeDcss2D9S2V2Js6dZHEiVabVA==} + '@oxc-parser/binding-linux-arm64-musl@0.141.0': + resolution: {integrity: sha512-jMkS/EztNW34HKsXIaT/SoHcmtocq/vWhwFOVduF9kduuuRIVwfwQ6uxzIO+qPKSXdd2TXt54of0BJ2zFMXnmw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.140.0': - resolution: {integrity: sha512-IgTs8xYAFgAUGNmR65tIqjlJ8vKgrfXzC515e9goSdfMyKQV4aJpd2pUUudU4u51G64H0/DSEJEXKOraxm9ZCA==} + '@oxc-parser/binding-linux-ppc64-gnu@0.141.0': + resolution: {integrity: sha512-vo+MR+n3zQJ6Mq92hiP084NZcgDv5iJlVR02gMf28neMvVT1tKVm7VeiW/DxhdqOi3QLeaXIk9cUcLL1qrkngw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.140.0': - resolution: {integrity: sha512-A1x+PMWZmSGaFVOx2YeNTFau8uD+QO14/vLP4GrcuvUPs3+nBkUOjy9Lus86ftHsDojjYMbvBelmKc3F7Rv08g==} + '@oxc-parser/binding-linux-riscv64-gnu@0.141.0': + resolution: {integrity: sha512-oh80w+7RuiO5gBp9Jnoa/H8Qlt3JsHL2MkW+0dwEdlDMdslVZX/YsekSK6EeyEenY66/mhCfypsNATQ7Ph3qlQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.140.0': - resolution: {integrity: sha512-zBqpfRo2myWPrPo5xUjeZqlnPXPXsX8BcWtWff66/eGRQdbPjhzPgXa/F+AtxT2afUViPxbuDlwscMKzQ5tg+g==} + '@oxc-parser/binding-linux-riscv64-musl@0.141.0': + resolution: {integrity: sha512-LOyEmFA8sCnYbEXP1+iQvCC/P1YXHMA/t6x1Ksp0Y9VwhLFsiBJFzV1zIxrOIE2LKaGGhDjQ29xq9cbq6omDXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.140.0': - resolution: {integrity: sha512-2M1DPm/8w9I//YzFlFC9qXw+r2tJFh5CYwRlYTq2vUJQS7qoQftEDeCZ8EnN7KHtvSiXvYj8mZI5pR7DpXmcEw==} + '@oxc-parser/binding-linux-s390x-gnu@0.141.0': + resolution: {integrity: sha512-3wnwk/l1CvszVE5TJR1wSl/zSEfydRqrNhn6s7Vr9IzSJpUQIroqVsIoPARHRFA+FQwkxAFDAHDAasa7v8OobQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.140.0': - resolution: {integrity: sha512-8aRDbZ/U/jO8N7go1MO72jtbpb4uswV8d7vOkMvt/BPgZiyEYvl1VIWK4ESxZZhnJ4tqwVldgX7dNiP/eB1Jdg==} + '@oxc-parser/binding-linux-x64-gnu@0.141.0': + resolution: {integrity: sha512-qtyQVAAebFq57B2tifTlel3TgGqUtsYNI/e+p6aya9rN9lOZVTDvr215fGYSA9XWooxzMxDiVxkBLk2jQHbsOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.140.0': - resolution: {integrity: sha512-xRqpeI8U2sQQS1W5BMWRyMTxtagkuLG2dEWruet5lFsWHTvBth11/TpSaJatHdqVVwHN0q3uuoS9zRsGinq8hg==} + '@oxc-parser/binding-linux-x64-musl@0.141.0': + resolution: {integrity: sha512-SkGV1nKw40roEc94pv5EaaeH2ay14G6+roe8Q0wIUC1LcEKxzKW921h7+ZuZX0D3q2Mb/7aSFmxEVqnko3lPRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.140.0': - resolution: {integrity: sha512-GbGRe26MqAKciFRvXeHNQJ6VAHYs9R4miP89sEAncysM3n+f4lnyLWgsa9kklJNpfnxdq2yRoNYHFqwBckVimw==} + '@oxc-parser/binding-openharmony-arm64@0.141.0': + resolution: {integrity: sha512-cVgDM7n8QziQqOaP5hNgUYfMG7S/ZeuPxFWXnnHRv7rh025COk0rfQ6eEdKG3j/GaUuyvNZN4ifF1J8KmuXLLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.140.0': - resolution: {integrity: sha512-vFiC1hqys+hkX1GnQkIoiTQJNiUm43Z0lO35ETKXTw0YtpW7+cN58YRRXFAQQ+TgpkIi3lrhcxdlnqz+Oi3ptQ==} + '@oxc-parser/binding-wasm32-wasi@0.141.0': + resolution: {integrity: sha512-HggH++Fkn3OilBn+bs3jpgIFQa34oMAyUUHy0vpGum+gt1Eb5nyLc8dNU/RAPSw6lsLrx7ncKtHSZE+3Sp0l2g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@oxc-parser/binding-win32-arm64-msvc@0.140.0': - resolution: {integrity: sha512-fGSQldwEYKhM+H8uLt76Op8hh5+FYaR6lvvQ1Txw3Mhn86DyQXLcI0fi1EkFlTK7F+46OCk/j0AJMzZQm6g5Xg==} + '@oxc-parser/binding-win32-arm64-msvc@0.141.0': + resolution: {integrity: sha512-KLSEH9GwgbrqbJOjtGHt9STw96s+78yDzp7IDN8Lno+7Ut9sNBfZ4jYZIz4mD50qmWUjoOI7i9I6UENbhNbMZQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.140.0': - resolution: {integrity: sha512-sDS2Bai+g3ZWYwfZqmosiSuFDBcVnZ3Ta6pszzsiJoLMqsJEWKcxXXbGa7b7yXr++W2lQNPb3ZRJ8czseqL7RA==} + '@oxc-parser/binding-win32-ia32-msvc@0.141.0': + resolution: {integrity: sha512-9UVWUOOCI/1YkiSSNjg2zyBJYM9E/t1A/8GNobd48JDn/fQ6mzxcVO3H08jb3rAaW/B1VBf8eCORTvSsO9T08g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.140.0': - resolution: {integrity: sha512-kHbE1zWyb5OQgJA6/5P4WjiuB01sYdQwtZnSSyE58FQEXDAMnyeeq4vj7KgN75i5SlBzOs8A5MrtlD3gOlDKqQ==} + '@oxc-parser/binding-win32-x64-msvc@0.141.0': + resolution: {integrity: sha512-HI/wsvbWT5RHHw5c37D0fEgeTd8/1Q4OJs5jUmEBc17VZFG6SsCIe4barq7NsAPPks/JW+3ayi3Rp+PQI5h4Kg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxc-project/runtime@0.138.0': - resolution: {integrity: sha512-yHhoXsN8tYxgdJCdD91PbySNjEEaBX/tH2OQRDXJpsQv5b184oC4/qVbU7qlblvfil/JP15Lh2HW7+HN5DS90Q==} + '@oxc-project/runtime@0.141.0': + resolution: {integrity: sha512-Z/6jQ0dyvE2fVjMUO7GoD4h+3q0G4lI4BWQNjaPhC4RPxaS4hF1b7/QYKB/Tl+KAQwqqKgMF54ukR/VvV5FILg==} engines: {node: ^20.19.0 || >=22.12.0} '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - '@oxc-project/types@0.138.0': - resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + '@oxc-project/types@0.141.0': + resolution: {integrity: sha512-S4as7z0j0xQkXcJlyY5ehntwK8/wRkQb9Cyqw+J/N2rkWGQGK0SxD6X6DhQTc7qsxVTBxXbxZtBJh3mr3PtIzQ==} - '@oxc-project/types@0.140.0': - resolution: {integrity: sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==} - - '@oxfmt/binding-android-arm-eabi@0.57.0': - resolution: {integrity: sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxfmt/binding-android-arm-eabi@0.59.0': - resolution: {integrity: sha512-bNTnfbuG7sAwb2PakMNaDukx5kXeW9duXOBeWtTOiLz3fXz3q2DlWguufPZ+c2IHEVrRXHD+M4aUgEWm841LDA==} + '@oxfmt/binding-android-arm-eabi@0.60.0': + resolution: {integrity: sha512-1q4q4Jc8FlOMVojEisyFAVyl8h1yawNv6phjgmhGVEDeyeOdsSnSr9x0+D4mOnEKvpO5L4mxKZ/DP9X6U3A/Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.57.0': - resolution: {integrity: sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxfmt/binding-android-arm64@0.59.0': - resolution: {integrity: sha512-R/Sn7z52QtdAKNqQLLY0EK7hVMjXiz3XUlvoCFCm/60jgIzAnQtiqLKBCFaBkimCQL5rs2ezPMcicpjCsrl54Q==} + '@oxfmt/binding-android-arm64@0.60.0': + resolution: {integrity: sha512-tD41I6nCt9k8SQXft0CSjjU9jg6SwG7uMu7PxodSEHXl+GDW0868oy6tTtoJkyUze8YKFgTpz/k5LuPUnFiGLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.57.0': - resolution: {integrity: sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg==} + '@oxfmt/binding-darwin-arm64@0.60.0': + resolution: {integrity: sha512-TTpzPug96Zxdyb46KvTyIUQDdsqbumXh2TKG9C23PCT0kF7JkW56Z/quPuG9rqOFKQIi1gpRNZ7DX18LwxXPnw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-arm64@0.59.0': - resolution: {integrity: sha512-vm/ynUqE4HjC0ZIEjmXv1UJu1/GngccQ+T+TJudTMxUxm6r+GQTg1TO3E5jJfI71pBaXxSzs1+vWHIwuilGHhw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxfmt/binding-darwin-x64@0.57.0': - resolution: {integrity: sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A==} + '@oxfmt/binding-darwin-x64@0.60.0': + resolution: {integrity: sha512-CnOoWgQ7L+JL/YQaRJ+NyATciSfcftncm7y3kqyte1cGtFEGnStaCd1TAyrinkfQ7nRBfHrTs1/vTwUJr3WF2Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.59.0': - resolution: {integrity: sha512-uTtYDpLN/obfKVWGpgEc8BqYlLZBQTPz2uYEvLRy3HPZxjZ34wiFzukUBU2bf64JuCYZI//GTV1EOMmWlPjf/w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxfmt/binding-freebsd-x64@0.57.0': - resolution: {integrity: sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxfmt/binding-freebsd-x64@0.59.0': - resolution: {integrity: sha512-e2UnxL/ifStSPy8ffBCDbdy595SYsGy+U1pur4G65TuMmWxAMBzYGG7atZo/3mp515p8rZdsflxVD/E1FAdPLQ==} + '@oxfmt/binding-freebsd-x64@0.60.0': + resolution: {integrity: sha512-ychJo7S3hZxdO6eDZ9zM6F2lM9fpJS3EKS5CAUSWyprdLYxTu4gbaUKV/VBPTcMJwQa2Bpo+643y3OJ537pihA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': - resolution: {integrity: sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm-gnueabihf@0.59.0': - resolution: {integrity: sha512-LtdeZ1l0urxte3VNi3g8cocZwv1xGM1NKHSgF/fJEEVhyQmlgGh7WFWKFd/pNuO7djfvPNtNO1+MS+FEWkgVSA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxfmt/binding-linux-arm-musleabihf@0.57.0': - resolution: {integrity: sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.60.0': + resolution: {integrity: sha512-36IH5o55T2Fx7E0feDttt+mifxN6yk9pWv4KfhAIsP0dFnUq27331OwbpOsZdoXF9soOLWm7mQUz5+UUmyec4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.59.0': - resolution: {integrity: sha512-dBTciSsj9GTMl7p+h2gMSI0hoPn2ijfc/dUsbnWsP0RbwgPl2r0C/5zkMb3Pb+gGj17LH7f1o4qLo9aes/pAvA==} + '@oxfmt/binding-linux-arm-musleabihf@0.60.0': + resolution: {integrity: sha512-G1Ve7lAa6sFBolVI2LWHfEAqy0YKh4vnioH8uYO9kAEdgM7mR40IksIx9/Zk4+vbYew/sGa4J9Q4tZ3n9gXDHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.57.0': - resolution: {integrity: sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg==} + '@oxfmt/binding-linux-arm64-gnu@0.60.0': + resolution: {integrity: sha512-LTQdRBf6uzj/h7Xk6lKzbGD2hrF/fK4YI9LIN1c0509tPUn8wRa3mCmrFQpEWJPLYGFrLFFMTYW1Ljj6VqW2Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-gnu@0.59.0': - resolution: {integrity: sha512-tXVdJ/JINsNWdponPHN0OuKHtC+HdpyoS9sd6IDPNiiEYsRki8b7tefRZ1iMnRkdbyT4SEbguWsr6o+5awvbPQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-arm64-musl@0.57.0': - resolution: {integrity: sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA==} + '@oxfmt/binding-linux-arm64-musl@0.60.0': + resolution: {integrity: sha512-2JMo3XPxMPx3hiqddSZYyaH+fKJm6cz0u8n1naYjP/CdOQOZW34i8lKBUfmbWiuFvd6KoYXLmhAyBuvojsYS7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-arm64-musl@0.59.0': - resolution: {integrity: sha512-RRTq38i2zT5fnw6XGHjvT6w2mh6x/G3m6AZcAZ56OTDTT/lsOeYnG3SVjwmH40z5kPqF+lf+o35e6m6PpKy9Dw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-ppc64-gnu@0.57.0': - resolution: {integrity: sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ==} + '@oxfmt/binding-linux-ppc64-gnu@0.60.0': + resolution: {integrity: sha512-L3C+nBD13lr306tr/PjM3RMll+BVqgFrIgUyoeHuai5oueJrRLgO3j+GO5/Cbhtkf5PSlHYTI1JY7iqBd1qa6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-ppc64-gnu@0.59.0': - resolution: {integrity: sha512-lD3k7glAJSaXW0D6xzu8VOZbYbosvy+0ktOVkfLEoQF5HJlMSxTQ2KNW0JO+08ccP/1ElOKktVEMI0fqRbVB4w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-gnu@0.57.0': - resolution: {integrity: sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-riscv64-gnu@0.59.0': - resolution: {integrity: sha512-WH5ZP1RbuHKBO/yfPRQKpNO/ijHcEDNbnmC4VPf/Bcd3+mbMAZpRiJWRa1PL5bREdIZZHo343mk3sqlc9x7Usw==} + '@oxfmt/binding-linux-riscv64-gnu@0.60.0': + resolution: {integrity: sha512-M4MsmvqlxFiPtSRGyBYQSZxchEf463AOyd+Dh4/9xDpjWBsRtDUTDMFN5EdHinjVK1/eDJQ8MLpcYjpYayaCnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.57.0': - resolution: {integrity: sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-riscv64-musl@0.59.0': - resolution: {integrity: sha512-743wOiaI9RZY4QVGkWkfGRavD5ZJUJ6gscFjVrVu1dP8AZh9jM+a6v3NhlR+OIzHdS6DhLM96w+gcVskskz7rw==} + '@oxfmt/binding-linux-riscv64-musl@0.60.0': + resolution: {integrity: sha512-OH+9UskYuxRB+GxqdGkVN8f5UpwhqG8YscNo1wl8+KJ62cd7wZdGga6iGLJIf8kibF1WBwvlfDUx3cez/VXwFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.57.0': - resolution: {integrity: sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA==} + '@oxfmt/binding-linux-s390x-gnu@0.60.0': + resolution: {integrity: sha512-y7AAFutt9wFWBFOAn6+BHaV39usZmcr3YYH2385f+NHgPNpIF9HpqKp0jgUxPaUOCyG3oaX5VhJduL1Nw164rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-s390x-gnu@0.59.0': - resolution: {integrity: sha512-xjRXQsRnrRZCcCkIEnbd2lmsQNobtwwkJxdy2bWXhZ1lIN0ouZwsBXRsoovW3yATuziAYwr9HMiQuR/Cc75NIw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-gnu@0.57.0': - resolution: {integrity: sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg==} + '@oxfmt/binding-linux-x64-gnu@0.60.0': + resolution: {integrity: sha512-yKZ9+CXAI+1RO5nH/4Z/9M6DAsfOzd5bw/gtWk81KB4mpalMaRRSXfouc5/tHxazDmBek55HNPepNYBgaCew0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.59.0': - resolution: {integrity: sha512-4hNjqq/Rbr9B+StY9zMMAfm72+mtM4v80xYL5Qkb59Qd72g2vJMI0iFlPj3kf6miMsie/yJ7rt4urJT292HBgA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxfmt/binding-linux-x64-musl@0.57.0': - resolution: {integrity: sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxfmt/binding-linux-x64-musl@0.59.0': - resolution: {integrity: sha512-NH579iN8EVQYsWowUB8B5vFchcylJtwPVJ7NmUAqEQHNLfhPbDT3K56KrECNAkUN4QpF4qiMgN2vsfZwVvjm7g==} + '@oxfmt/binding-linux-x64-musl@0.60.0': + resolution: {integrity: sha512-bCUGaF6hJOYnQzLJdHLZbvGsOd5oSvGAyJhPAKum2uyLYUuXmP8vqg690DWi2hqcnIoYpqSqCrjzE5aiUAgwQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.57.0': - resolution: {integrity: sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxfmt/binding-openharmony-arm64@0.59.0': - resolution: {integrity: sha512-mzZy3Z5Aj1D75Aq9FVlmoRQH5ei8Ga4o/NZmlXkKyeZ5EmPrUXRR7c6BMBteV1ZuZ/356UYDuLRLjAMxTDTiBA==} + '@oxfmt/binding-openharmony-arm64@0.60.0': + resolution: {integrity: sha512-GrUeZOvzP30ExxfCuQiyofuUGI+OmvAgFwOO5w5p9mGPlxcyuqI+6Sy9fAKFFfLQrqKYWFgc5sYA2Unj/29nPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.57.0': - resolution: {integrity: sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g==} + '@oxfmt/binding-win32-arm64-msvc@0.60.0': + resolution: {integrity: sha512-WD4Q954kUl2TDJV/6q7UnE2rlKk047kXLJsr4bJ2mXRaAqNXcmV3nwKUsGCc3mz/jYDBnXtJEaBErJEybK8iQQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-arm64-msvc@0.59.0': - resolution: {integrity: sha512-0CpDJ1gE3jN1Gk6xms1Ie6LPfPcOtY4FAtoOmVLHQoAf8DvO2wd0DW2dIX2f7YTp5dxrr0ND8JeUEjm3DP3k5g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxfmt/binding-win32-ia32-msvc@0.57.0': - resolution: {integrity: sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxfmt/binding-win32-ia32-msvc@0.59.0': - resolution: {integrity: sha512-zwdKBu3pt87uW0bRcywZb0oGMS7C6n87qogwRYFUgmk44T90ZzYlPjtlFYXs/DnBFrgNCvlHwCuWKfVWLeE7kw==} + '@oxfmt/binding-win32-ia32-msvc@0.60.0': + resolution: {integrity: sha512-HqDekjr8JXzVDUP1YthDZ1Y3CBEcuZT4WX3B+1kaxj8CvZA8Y2YhcEsXqoSop3tVsgjACxjnFQFDkBo0r/jq1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.57.0': - resolution: {integrity: sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxfmt/binding-win32-x64-msvc@0.59.0': - resolution: {integrity: sha512-dUUbZkKgWrmAeI/puzv4bxN8lzcYaFnQVwFTFtwO2Gp8M7lZGSE2qJjC58g518+1bltJ8mizjYwD0BGHym0l/w==} + '@oxfmt/binding-win32-x64-msvc@0.60.0': + resolution: {integrity: sha512-tz78yhmGPKboTMHCHSaUqXK8JrmoSejgDcWeqAtg2s07ZGKQ3rH5Jn8NuXPGNG33CDbY2e9NoQWXIVEmKO21Rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint-tsgolint/darwin-arm64@0.24.0': - resolution: {integrity: sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ==} + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.24.0': - resolution: {integrity: sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ==} + '@oxlint-tsgolint/darwin-x64@7.0.2001': + resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.24.0': - resolution: {integrity: sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ==} + '@oxlint-tsgolint/linux-arm64@7.0.2001': + resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.24.0': - resolution: {integrity: sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw==} + '@oxlint-tsgolint/linux-x64@7.0.2001': + resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.24.0': - resolution: {integrity: sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw==} + '@oxlint-tsgolint/win32-arm64@7.0.2001': + resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.24.0': - resolution: {integrity: sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew==} + '@oxlint-tsgolint/win32-x64@7.0.2001': + resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==} cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.72.0': - resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxlint/binding-android-arm-eabi@1.74.0': - resolution: {integrity: sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw==} + '@oxlint/binding-android-arm-eabi@1.75.0': + resolution: {integrity: sha512-lutovtFzJqlRaqpZrCqSSGaHZzl9nIxxpjLzhSRLunN6dCLylj0uzlCyQGaQDIys7rrv8kVXiFO+R4Zpn0bX7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.72.0': - resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + '@oxlint/binding-android-arm64@1.75.0': + resolution: {integrity: sha512-hXI0hDgHkw4w5nfru72aG7y+2iQJmC4waH/KV6H/hbgA6yAP5jYNx0P9yug15Hs0tWl/+mda3Jjn/2gmDT48tw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-android-arm64@1.74.0': - resolution: {integrity: sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw==} + '@oxlint/binding-darwin-arm64@1.75.0': + resolution: {integrity: sha512-D91BWbK/dMYfCcrghspPIuKs2D9LF4Z/OabVSQjw1AO6PWxArD7teDA48bm0ySFqWDaPVqmQRl5GMWNglTXyrQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] - os: [android] - - '@oxlint/binding-darwin-arm64@1.72.0': - resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxlint/binding-darwin-arm64@1.74.0': - resolution: {integrity: sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxlint/binding-darwin-x64@1.72.0': - resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] os: [darwin] - '@oxlint/binding-darwin-x64@1.74.0': - resolution: {integrity: sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q==} + '@oxlint/binding-darwin-x64@1.75.0': + resolution: {integrity: sha512-02mpwzf12BonZ6PT0TuQoomvEh2kVl2WGBIKWezCyToIS+rYkQZ6GXnARBAl9A4Ovm2V+Xe7M4KretyqmmcnJQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.72.0': - resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + '@oxlint/binding-freebsd-x64@1.75.0': + resolution: {integrity: sha512-qZJgLnDaBsiL5YESx2t/TZ8eXkL9fEkKoXEdzegROhlz9A0lgyGnZ0dAzJrh7LJAHQl2K9RdRueN2s/9N7+odg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-freebsd-x64@1.74.0': - resolution: {integrity: sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxlint/binding-linux-arm-gnueabihf@1.72.0': - resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + '@oxlint/binding-linux-arm-gnueabihf@1.75.0': + resolution: {integrity: sha512-7XlaWA5BJD3XpCfrEqjEe6Zseeb14S7QGa304XfwKignRaKQ+eIj775BQ7nIslggWickl4IsPUFqJ+/gAyNHVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-gnueabihf@1.74.0': - resolution: {integrity: sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g==} + '@oxlint/binding-linux-arm-musleabihf@1.75.0': + resolution: {integrity: sha512-av6Tpv8yrcMMMOadOqENBhlsLRcGFXXwoQ0hzHhsmS9FJ4Wioy8we427GbcMe2XTxmL2e60T67H1Dyr3up+tAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.72.0': - resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm-musleabihf@1.74.0': - resolution: {integrity: sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxlint/binding-linux-arm64-gnu@1.72.0': - resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-arm64-gnu@1.74.0': - resolution: {integrity: sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w==} + '@oxlint/binding-linux-arm64-gnu@1.75.0': + resolution: {integrity: sha512-WcUhd8fHT5plrA14lANevl+hOl815mVI5t2hU21oFWrZKFXIVV/Sr4rWQV0NzSvzBupbMLNc5ErEA6Ehxh5jMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.72.0': - resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + '@oxlint/binding-linux-arm64-musl@1.75.0': + resolution: {integrity: sha512-UWzp5wRHFe/ESO3+eEaxXsTkYTGLYjnTsi/I5neEacXSItQ6WNleapfOAeA4x2b8nyhJ4uQxqvtv9pHv8kWJtQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-arm64-musl@1.74.0': - resolution: {integrity: sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-ppc64-gnu@1.72.0': - resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + '@oxlint/binding-linux-ppc64-gnu@1.75.0': + resolution: {integrity: sha512-XEVRwGMLKCUKrvhLAz4F6AIh8MJrQVdSZtAmPpRZt9tGPsUnamPOcl3dS/ZQzJnar/Ymgc//+xho0L60Emzuxg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-ppc64-gnu@1.74.0': - resolution: {integrity: sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-gnu@1.72.0': - resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + '@oxlint/binding-linux-riscv64-gnu@1.75.0': + resolution: {integrity: sha512-mAG4DUXqfLC8cTjMD2kt3jDmVzFREYtDyeLNdLdsCcBc4Zbl2EMuiFektGBilQwkNjYnMvCqJs55U+Hyb+b+jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.74.0': - resolution: {integrity: sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-riscv64-musl@1.72.0': - resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + '@oxlint/binding-linux-riscv64-musl@1.75.0': + resolution: {integrity: sha512-95hrAvriAlI+pekSomTFIn0+bawMDlDwTNVmdjsFusTHyL2JWh7TWvRNG/Lkim72uN8OiCcO9wcaC6omLP5E3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-riscv64-musl@1.74.0': - resolution: {integrity: sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-s390x-gnu@1.72.0': - resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + '@oxlint/binding-linux-s390x-gnu@1.75.0': + resolution: {integrity: sha512-4b6f2+FrtruAESrCqIKcrarzfrSx+wk2QNcp+RT91/Prc+pMQMAfyZ1rG1c3tFQNl8Bc616tx40uNXyxNBRPbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-s390x-gnu@1.74.0': - resolution: {integrity: sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-gnu@1.72.0': - resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + '@oxlint/binding-linux-x64-gnu@1.75.0': + resolution: {integrity: sha512-nshAhrUvXFUWOvqQ2soIw7HFNWvpvEV4o0cYSqPtzLiPF5gKyYTDOOTJ6Rn8g8K/iGvPIrbDA4v8+5MvnjJrrg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.74.0': - resolution: {integrity: sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@oxlint/binding-linux-x64-musl@1.72.0': - resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@oxlint/binding-linux-x64-musl@1.74.0': - resolution: {integrity: sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg==} + '@oxlint/binding-linux-x64-musl@1.75.0': + resolution: {integrity: sha512-e4jNxLKnxLC6sYBQRxrI2pgIIxnmMtF8U/VwNYcjTT/CLS+spH624cYVnj07bTKwaEWT37/e025isOs6j/0xqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.72.0': - resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + '@oxlint/binding-openharmony-arm64@1.75.0': + resolution: {integrity: sha512-hZ2lH+1qLf/DiEP9UWuQTK2JWj/BgvMB4jhIV4SmNU1wfEiYYX4TynQyAZXx0j9X4qRYizAL042SKaV+8ynh4w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-openharmony-arm64@1.74.0': - resolution: {integrity: sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxlint/binding-win32-arm64-msvc@1.72.0': - resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxlint/binding-win32-arm64-msvc@1.74.0': - resolution: {integrity: sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA==} + '@oxlint/binding-win32-arm64-msvc@1.75.0': + resolution: {integrity: sha512-Ilj6PNzGDS3bCU0MSJH7Msh0NhH+T/mRp2shwg+q+GHeVlPwP5LEboW96aW+3kVKFk6zYZy1Xi5pZkqZh6X8KQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.72.0': - resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxlint/binding-win32-ia32-msvc@1.74.0': - resolution: {integrity: sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ==} + '@oxlint/binding-win32-ia32-msvc@1.75.0': + resolution: {integrity: sha512-QVit2nOEOiPhkmsrksPSkoGCdnZRNkspt8fwoYyP09te1VEbnSj4LAxua4rc8FKTmWkySVe05j8iz9GXYfF1AQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.72.0': - resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@oxlint/binding-win32-x64-msvc@1.74.0': - resolution: {integrity: sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA==} + '@oxlint/binding-win32-x64-msvc@1.75.0': + resolution: {integrity: sha512-DSxnNkBUAYARPwJtR12Ig3deWr8w0H997xP6jy33i+e0SyYJw8FKuz4+cZtpmPEhQmvlPJE3X/2vNxDmLkd/rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxlint/plugins@1.68.0': - resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} + '@oxlint/plugins@1.73.0': + resolution: {integrity: sha512-OhgMQeMmZA0dcFcX4/priaJZWdFECxiClgq6mRX6aatZEcV9PbKC3P3/v8U1hVjviT1i5U+vR8lAtBV6m4FXAA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} '@polka/url@1.0.0-next.29': @@ -1213,8 +966,8 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - '@voidzero-dev/vite-plus-core@0.2.4': - resolution: {integrity: sha512-AoAYGPwNO56o9TuCR+KaQGA5XpSnTpn2QYHK0DQ0f8j3wvaMmToeWOJF6STo+XMntMDfiaB7sOsSFNE+hPYyjg==} + '@voidzero-dev/vite-plus-core@0.2.6': + resolution: {integrity: sha512-vqDuuLJWS2JHWkFO7r8Z6AehtKnurpnYtw2XEQtjIfwE2GgSAO3zJdPIeaEZiovS6CqfQa+iOMn3kzVzeTSpTw==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} peerDependencies: '@arethetypeswrong/core': ^0.18.1 @@ -1230,7 +983,7 @@ packages: sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 - typescript: ^5.0.0 || ^6.0.0 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 unplugin-unused: ^0.5.0 unrun: '*' yaml: ^2.4.2 @@ -1270,58 +1023,183 @@ packages: yaml: optional: true - '@voidzero-dev/vite-plus-darwin-arm64@0.2.4': - resolution: {integrity: sha512-UQwoLpnBW3qLqj5H3airPQeMCX3kfffVDM2EYGe889Fn5dOS9VhikuWloJlcjwtKwqLbFqkrsGjfb6XRvuLozg==} + '@voidzero-dev/vite-plus-darwin-arm64@0.2.6': + resolution: {integrity: sha512-QSZWgfqPx5lrIKZ0vwgVRujRqo7gORcc9Oq4fI1UDjKZFgYz9gXVMibSjEqYU3R1webN6FKTOCnzFmrfXchWYw==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [darwin] - '@voidzero-dev/vite-plus-darwin-x64@0.2.4': - resolution: {integrity: sha512-f4XtiA4cBc/Z260QvvXIlBqTb/dZMAioohQDoR5jLG9o9vIOaWE2Ujm/zcWDyNpyZ88RLRrcO8ZwiMrEsdzbJw==} + '@voidzero-dev/vite-plus-darwin-x64@0.2.6': + resolution: {integrity: sha512-pMQZGCQO0s+akuxbjgh31hOUgrdDndCmfbP7BciJuts436qrBb8KaVzBhNjJQtxD6H8yY21CIOjD8nlrp4eDIw==} engines: {node: '>=20.0.0'} cpu: [x64] os: [darwin] - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.4': - resolution: {integrity: sha512-TUamG9wEehZI4SFG7M6nUKb6z/7x3nNOBbnPdWIpv4C8uWKHwNtsnaaVLeygHIUIJEhbByR3oEFDylYLLr4KRw==} + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.6': + resolution: {integrity: sha512-0e6nrJgUDN5WwoDRjyh7KV/jbL7WDUhTEWslI64KGpuvD7M6OoPBXYo8Et176O2ENwf52CKVDjXwuvMMNIAzAA==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.4': - resolution: {integrity: sha512-CS9uQ22QFr+lZZp95Huccg3cip3QYVU9GWrhvATqMBXWXb8IRHOulzuXrFxzvhMBITyPaRS9m/eIHmnM4L4h4Q==} + '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.6': + resolution: {integrity: sha512-j2yvyv3r2ZEuJG73rWlXFrYrxA7u5+bFSlWTqhsfHZhdb1FKMkklfYRuLtBBdBRLXJ+YMNt9GZT6VTVPJDZnnQ==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.4': - resolution: {integrity: sha512-X5XfvftFERjer78SG+QKaJCa9LgIKygx4w13sD1/RS/kYOtaf1+yifrJpOFel+t9TRe/YqGTZa5C8uFrDz3OHw==} + '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.6': + resolution: {integrity: sha512-5vqldBBnRjIAUmQ88oKli+wPd5vSQaA2AZ2xs+omRlZfeJhC8chlud6DyJV6fVZCC9GI4otK2zSGpfQZx8Kc3A==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-x64-musl@0.2.4': - resolution: {integrity: sha512-2iIWW6JR0UOK4QIFKgUQHjaF/7hZxELePny8R1OIn2jzShRfQhS1cmxksSg8ce/5nhYrfQ9dkg5ZmdLbxhpS/A==} + '@voidzero-dev/vite-plus-linux-x64-musl@0.2.6': + resolution: {integrity: sha512-29ned+euDKAl+BmCigzGrPykQ/kxTjtsPGV6G3hkImwakHNUnmLRayqmsWKHSwXmBk5xo5m+wqjMbo3G0npJQg==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] libc: [musl] - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.4': - resolution: {integrity: sha512-3yY4FnpvKBxjmHtEcao6Wcs/VBstzC0GhXAPWetbiFEl/sgL66V4Uhws02esfOSWw8abqLrXyPE9vK+newtsuw==} + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.6': + resolution: {integrity: sha512-4b5ASpQlkxSXDYzuM6MYsOc1GnYkqG9CbSA1QzobXm87V/Q+MGPn57eILyacgfbmNNLrO2HQ5hasoLvd2BXaSg==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [win32] - '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.4': - resolution: {integrity: sha512-ERnFrh1O0XZhmGSqND04jtHM7tyKaGAOeT1w/HpVFmL/cQK2vuLl2GKjEwOqkBLd2csNGCcnk3e7C2qDmrNR/Q==} + '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.6': + resolution: {integrity: sha512-rvGkm4WmVkCKPhvUbLkRtlVhi7EYb8GU8KaqIGJ0dVfNQHDEqnFboaiUHuPN1npFNwB+80Ulzz9AkQ/Q8MswQA==} engines: {node: '>=20.0.0'} cpu: [x64] os: [win32] + '@yuku-codegen/binding-darwin-arm64@0.5.48': + resolution: {integrity: sha512-yo96Oef12WzqnphInfz/eexVse3+kWgfGS5g2S3rFS3dcGn1ENW9xLFDZUP9rh+yP76DOq38wBoFi1+I9+6qBg==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.5.48': + resolution: {integrity: sha512-aRCTw0EZC4bVosmw//0OMYP5tGWFE0Cu5yUBFkUbhXx/iBzvORcJ2xPNlOp/vtCCo9Ys4vp8b0DigJV6uOVb2g==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.5.48': + resolution: {integrity: sha512-CA0AQAEApDkbw51PdLWMtKPJ41/7rvXsS3SJs+phG7fHJI+MuFzWuLbkucZfZoEOiDscmcsfYIdgL8BsfuyKKQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.5.48': + resolution: {integrity: sha512-DuSQlk8bH4gpmW3/00P0NLagAcMv8jOxjT40cQmxKRkktr+SUOALCfkT89tdDq3qtY95NR2GXOZ7AjNh7KKqCw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm-musl@0.5.48': + resolution: {integrity: sha512-bxj4Ee+wlaJcWJwft2ReJXWw5sfl1qavDz6+dlRdU1xfTEtjPSNiAWhiCHnJR0R4Ygd57DnzSQmAVGvFv6RcGw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.48': + resolution: {integrity: sha512-mk5JVWh+0JOe5ue8k17kbYX8uGBoKt3ZqoCyxNh4nYAAcX7+X1tFUiU7jbjctu4vHeejCBFSTdQ021+V31cUCQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm64-musl@0.5.48': + resolution: {integrity: sha512-4q3vkrNghbllyxOm2KesFLxCPKHF7r3JyQ7BWZccY1j2Y05yKoIFhoWCqIuQ2W/dpte9RI0+OVfwyxnrKg6fkA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-x64-gnu@0.5.48': + resolution: {integrity: sha512-csd4M1EVrGaohM8acM6gq1zpUA/Rwe2ulUMBKUcwQXm/k6n7cq1A++qdew78SOVb4do3JH1WE+WFwoGQAcWc1w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-x64-musl@0.5.48': + resolution: {integrity: sha512-KcDuEOT+GFoVKdvAWOv1v9iYjwnmvMZlO+j1Rw+5PYdeFLGWGzv/DD11y4SAAdwXIFcil4T0hibeIaF82WStMg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-win32-arm64@0.5.48': + resolution: {integrity: sha512-HI8qNrI8dWM5BuqIMKsqornRvTNFrE6sm5zToIJ9YIa9zt5+29P7fJ7Nr39EVf6dAWSb6q7JSpScJnRsQ+FgZA==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.5.48': + resolution: {integrity: sha512-X5YWJLO6EfBZpeBqO0AYESnUizbpFDWArcvVD61w0PEWQ3CaFRLnbQXs+kpM4ZZfGMfIE22zfA08QSY67q7TNQ==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-darwin-arm64@0.5.48': + resolution: {integrity: sha512-If8mb7HH3vqghJ2NNZ8SuHfhsnjVzOxJpB8xcNOXS5WjYrs2mUhHIh5KOIvK13hDOzh0htGeGK3A6MsiEqE7HQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.5.48': + resolution: {integrity: sha512-EimvPXfspzxf1K11eB6tCW5oiQEXB8g84T2wP1TwzQagdDKo33bkmmVF0B32vTIpXnk/Ifu5IB61izZ1MylljA==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.5.48': + resolution: {integrity: sha512-0GcUMrumLHheThY9r5Tp46gaZYzn0irWPS1Zba6WY+vVQfhUtzGiWgXxI6tuXX0N32kEaaEVRpkKctvo6Kx3aQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.5.48': + resolution: {integrity: sha512-8S5T5wjCC73dmmpQeZ49aYsSunIUM3D4Fc6rdK96c+Ayg/p3FmeSPF3xuLZHejcTmqJIIvnbfPlUF+rB6DITjQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.5.48': + resolution: {integrity: sha512-tTmbxvnUHcK2/crS9547vk2SMmsajH1yqJ8ltXhIuHJgqR1v+d9n9KT+kSayo/5CS76LegeYxhMFjEivBH2hFA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.5.48': + resolution: {integrity: sha512-KGYCBMqI2zfwyhgq5tpPVNe7jpUeYTBm8DhjdS+zqWNumde/PEC170QE5RHxcOAlsirIDeIUk0jqx+r/axoFSw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.5.48': + resolution: {integrity: sha512-2wTSMsCSXLTc2lZUjMAuU5X4cje55u205WJqfV5NWNF6j9pW/tXyxr15dJeekj8ziLqBXzIsj4DbRh4sY/WcjA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.5.48': + resolution: {integrity: sha512-d/6v9UnGglVu1WC2JQyv/5aWSi5fXZeGSlidCfmHp4+N65N1GDKUnFtys5MK5eAPeAjTgSHGGtOc/yCcKTlv3A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.5.48': + resolution: {integrity: sha512-gX19gw6u4ApPy7SYMPKfFlEkrtj6WlORvrTKK3sBQqjyV+8+mUAkQgxXNjHw4RnOiAmVYg7TOlZcg8d+Qqod9A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.5.48': + resolution: {integrity: sha512-w6cQQLbqj3Jcom5Q7ifm103NUOQ9d+Cb4VU5lkrZDjMnwVJ9Hzzg1vCQR7miJuF44vhCXldbme5UryE3giEKlA==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.5.48': + resolution: {integrity: sha512-4gO0HmG7fzFxrw1rs0dUdnnaY9YgennjETqDWrTSp7x9fmTUOAoN4VsMfP7YyliQeG1WJJHc55O+rOhmsLppow==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.5.43': + resolution: {integrity: sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1397,30 +1275,60 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} @@ -1428,6 +1336,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} @@ -1435,6 +1350,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -1442,6 +1364,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} @@ -1449,22 +1378,45 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1485,25 +1437,12 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} - oxc-parser@0.140.0: - resolution: {integrity: sha512-h6QFWd6lBMfjESqgQ27GjzrSDb0qbznp7VDQqp2zvgsrWut4vcchyMIzOVXvGQ2GMZgKw9RWrFNWv9WqGL0p7Q==} - engines: {node: ^20.19.0 || >=22.12.0} - - oxfmt@0.57.0: - resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==} + oxc-parser@0.141.0: + resolution: {integrity: sha512-uFkGGr1KMWd6aWv9UAqooYrN78trw8MWWmoPvgWokfBEUq1+eiIQ+qfj3wokhy0fxtZWZk+0dHoS7/yRTJtd6w==} engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - svelte: ^5.0.0 - vite-plus: '*' - peerDependenciesMeta: - svelte: - optional: true - vite-plus: - optional: true - oxfmt@0.59.0: - resolution: {integrity: sha512-Xqk6cPZS1yMvVa7OAuenaDZUsgMDutvvbZ9/L5gSvAfW64+WN4HVhgipLj5rVERbYQt8fLs9TopyZ1rU1XEG/w==} + oxfmt@0.60.0: + resolution: {integrity: sha512-fViX6i+gJuZWY+jI/fnR6WRbRj70GZ9RlCd30MygJrHTUNc4DxvKHWw8vBjMjffv3PgU5qWDR0AzmojQByqaZA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1515,29 +1454,16 @@ packages: vite-plus: optional: true - oxlint-tsgolint@0.24.0: - resolution: {integrity: sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw==} - hasBin: true - - oxlint@1.72.0: - resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} - engines: {node: ^20.19.0 || >=22.12.0} + oxlint-tsgolint@7.0.2001: + resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - peerDependencies: - oxlint-tsgolint: '>=0.22.1' - vite-plus: '*' - peerDependenciesMeta: - oxlint-tsgolint: - optional: true - vite-plus: - optional: true - oxlint@1.74.0: - resolution: {integrity: sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA==} + oxlint@1.75.0: + resolution: {integrity: sha512-m9WzjRcRYA/uqIZDa9tclrieoPJ/ln1QYTKdFx6NUOs8uY5DiHlIwRQoCrHT6OM6O3ww3l2skY5gO7G7ZphE7g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.24.0' + oxlint-tsgolint: '>=7.0.2001' vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: @@ -1626,11 +1552,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typescript@6.0.3: - resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} - engines: {node: '>=14.17'} - hasBin: true - typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -1639,8 +1560,8 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - vite-plus@0.2.4: - resolution: {integrity: sha512-gaBBjOXIq9lLRU44oAYdIr99p+JBLX1kxs+l/6LqGgSXwcVKAdDa1boSrOTELqYCkQQ0fpppXUGWi9o6JDT5zw==} + vite-plus@0.2.6: + resolution: {integrity: sha512-fFX8GLENhtzvnE4NmTPC8INRxjD2kZKcqUW0p7jOdawmHTNZqM5FiieyWOG6WH1a/axu9FCsbUNb918grfzDTw==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} hasBin: true peerDependencies: @@ -1753,6 +1674,12 @@ packages: utf-8-validate: optional: true + yuku-codegen@0.5.48: + resolution: {integrity: sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw==} + + yuku-parser@0.5.48: + resolution: {integrity: sha512-OWBfhrpgK9+/4+IXG9oT8Bao4AhViQA7vdyNNH7EUg8dQYgwa70XtIBWTpCEme1P1ECyoDNYkn0wT63f8XRcVA==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -1896,325 +1823,209 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@oxc-parser/binding-android-arm-eabi@0.140.0': + '@oxc-parser/binding-android-arm-eabi@0.141.0': optional: true - '@oxc-parser/binding-android-arm64@0.140.0': + '@oxc-parser/binding-android-arm64@0.141.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.140.0': + '@oxc-parser/binding-darwin-arm64@0.141.0': optional: true - '@oxc-parser/binding-darwin-x64@0.140.0': + '@oxc-parser/binding-darwin-x64@0.141.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.140.0': + '@oxc-parser/binding-freebsd-x64@0.141.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.140.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.141.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.140.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.141.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.140.0': + '@oxc-parser/binding-linux-arm64-gnu@0.141.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.140.0': + '@oxc-parser/binding-linux-arm64-musl@0.141.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.140.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.141.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.140.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.141.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.140.0': + '@oxc-parser/binding-linux-riscv64-musl@0.141.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.140.0': + '@oxc-parser/binding-linux-s390x-gnu@0.141.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.140.0': + '@oxc-parser/binding-linux-x64-gnu@0.141.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.140.0': + '@oxc-parser/binding-linux-x64-musl@0.141.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.140.0': + '@oxc-parser/binding-openharmony-arm64@0.141.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.140.0': + '@oxc-parser/binding-wasm32-wasi@0.141.0': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.140.0': + '@oxc-parser/binding-win32-arm64-msvc@0.141.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.140.0': + '@oxc-parser/binding-win32-ia32-msvc@0.141.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.140.0': + '@oxc-parser/binding-win32-x64-msvc@0.141.0': optional: true - '@oxc-project/runtime@0.138.0': {} + '@oxc-project/runtime@0.141.0': {} '@oxc-project/types@0.133.0': {} - '@oxc-project/types@0.138.0': {} - - '@oxc-project/types@0.140.0': {} - - '@oxfmt/binding-android-arm-eabi@0.57.0': - optional: true - - '@oxfmt/binding-android-arm-eabi@0.59.0': - optional: true - - '@oxfmt/binding-android-arm64@0.57.0': - optional: true - - '@oxfmt/binding-android-arm64@0.59.0': - optional: true - - '@oxfmt/binding-darwin-arm64@0.57.0': - optional: true - - '@oxfmt/binding-darwin-arm64@0.59.0': - optional: true - - '@oxfmt/binding-darwin-x64@0.57.0': - optional: true - - '@oxfmt/binding-darwin-x64@0.59.0': - optional: true - - '@oxfmt/binding-freebsd-x64@0.57.0': - optional: true - - '@oxfmt/binding-freebsd-x64@0.59.0': - optional: true - - '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': - optional: true - - '@oxfmt/binding-linux-arm-gnueabihf@0.59.0': - optional: true - - '@oxfmt/binding-linux-arm-musleabihf@0.57.0': - optional: true - - '@oxfmt/binding-linux-arm-musleabihf@0.59.0': - optional: true - - '@oxfmt/binding-linux-arm64-gnu@0.57.0': - optional: true - - '@oxfmt/binding-linux-arm64-gnu@0.59.0': - optional: true - - '@oxfmt/binding-linux-arm64-musl@0.57.0': - optional: true - - '@oxfmt/binding-linux-arm64-musl@0.59.0': - optional: true - - '@oxfmt/binding-linux-ppc64-gnu@0.57.0': - optional: true - - '@oxfmt/binding-linux-ppc64-gnu@0.59.0': - optional: true - - '@oxfmt/binding-linux-riscv64-gnu@0.57.0': - optional: true - - '@oxfmt/binding-linux-riscv64-gnu@0.59.0': - optional: true - - '@oxfmt/binding-linux-riscv64-musl@0.57.0': - optional: true - - '@oxfmt/binding-linux-riscv64-musl@0.59.0': - optional: true - - '@oxfmt/binding-linux-s390x-gnu@0.57.0': - optional: true - - '@oxfmt/binding-linux-s390x-gnu@0.59.0': - optional: true - - '@oxfmt/binding-linux-x64-gnu@0.57.0': - optional: true - - '@oxfmt/binding-linux-x64-gnu@0.59.0': - optional: true - - '@oxfmt/binding-linux-x64-musl@0.57.0': - optional: true - - '@oxfmt/binding-linux-x64-musl@0.59.0': - optional: true - - '@oxfmt/binding-openharmony-arm64@0.57.0': - optional: true - - '@oxfmt/binding-openharmony-arm64@0.59.0': - optional: true - - '@oxfmt/binding-win32-arm64-msvc@0.57.0': - optional: true - - '@oxfmt/binding-win32-arm64-msvc@0.59.0': - optional: true - - '@oxfmt/binding-win32-ia32-msvc@0.57.0': - optional: true - - '@oxfmt/binding-win32-ia32-msvc@0.59.0': - optional: true - - '@oxfmt/binding-win32-x64-msvc@0.57.0': - optional: true - - '@oxfmt/binding-win32-x64-msvc@0.59.0': - optional: true + '@oxc-project/types@0.141.0': {} - '@oxlint-tsgolint/darwin-arm64@0.24.0': + '@oxfmt/binding-android-arm-eabi@0.60.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.24.0': + '@oxfmt/binding-android-arm64@0.60.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.24.0': + '@oxfmt/binding-darwin-arm64@0.60.0': optional: true - '@oxlint-tsgolint/linux-x64@0.24.0': + '@oxfmt/binding-darwin-x64@0.60.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.24.0': + '@oxfmt/binding-freebsd-x64@0.60.0': optional: true - '@oxlint-tsgolint/win32-x64@0.24.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.60.0': optional: true - '@oxlint/binding-android-arm-eabi@1.72.0': + '@oxfmt/binding-linux-arm-musleabihf@0.60.0': optional: true - '@oxlint/binding-android-arm-eabi@1.74.0': + '@oxfmt/binding-linux-arm64-gnu@0.60.0': optional: true - '@oxlint/binding-android-arm64@1.72.0': + '@oxfmt/binding-linux-arm64-musl@0.60.0': optional: true - '@oxlint/binding-android-arm64@1.74.0': + '@oxfmt/binding-linux-ppc64-gnu@0.60.0': optional: true - '@oxlint/binding-darwin-arm64@1.72.0': + '@oxfmt/binding-linux-riscv64-gnu@0.60.0': optional: true - '@oxlint/binding-darwin-arm64@1.74.0': + '@oxfmt/binding-linux-riscv64-musl@0.60.0': optional: true - '@oxlint/binding-darwin-x64@1.72.0': + '@oxfmt/binding-linux-s390x-gnu@0.60.0': optional: true - '@oxlint/binding-darwin-x64@1.74.0': + '@oxfmt/binding-linux-x64-gnu@0.60.0': optional: true - '@oxlint/binding-freebsd-x64@1.72.0': + '@oxfmt/binding-linux-x64-musl@0.60.0': optional: true - '@oxlint/binding-freebsd-x64@1.74.0': + '@oxfmt/binding-openharmony-arm64@0.60.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + '@oxfmt/binding-win32-arm64-msvc@0.60.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.74.0': + '@oxfmt/binding-win32-ia32-msvc@0.60.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.72.0': + '@oxfmt/binding-win32-x64-msvc@0.60.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.74.0': + '@oxlint-tsgolint/darwin-arm64@7.0.2001': optional: true - '@oxlint/binding-linux-arm64-gnu@1.72.0': + '@oxlint-tsgolint/darwin-x64@7.0.2001': optional: true - '@oxlint/binding-linux-arm64-gnu@1.74.0': + '@oxlint-tsgolint/linux-arm64@7.0.2001': optional: true - '@oxlint/binding-linux-arm64-musl@1.72.0': + '@oxlint-tsgolint/linux-x64@7.0.2001': optional: true - '@oxlint/binding-linux-arm64-musl@1.74.0': + '@oxlint-tsgolint/win32-arm64@7.0.2001': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.72.0': + '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.74.0': + '@oxlint/binding-android-arm-eabi@1.75.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.72.0': + '@oxlint/binding-android-arm64@1.75.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.74.0': + '@oxlint/binding-darwin-arm64@1.75.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.72.0': + '@oxlint/binding-darwin-x64@1.75.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.74.0': + '@oxlint/binding-freebsd-x64@1.75.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.72.0': + '@oxlint/binding-linux-arm-gnueabihf@1.75.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.74.0': + '@oxlint/binding-linux-arm-musleabihf@1.75.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.72.0': + '@oxlint/binding-linux-arm64-gnu@1.75.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.74.0': + '@oxlint/binding-linux-arm64-musl@1.75.0': optional: true - '@oxlint/binding-linux-x64-musl@1.72.0': + '@oxlint/binding-linux-ppc64-gnu@1.75.0': optional: true - '@oxlint/binding-linux-x64-musl@1.74.0': + '@oxlint/binding-linux-riscv64-gnu@1.75.0': optional: true - '@oxlint/binding-openharmony-arm64@1.72.0': + '@oxlint/binding-linux-riscv64-musl@1.75.0': optional: true - '@oxlint/binding-openharmony-arm64@1.74.0': + '@oxlint/binding-linux-s390x-gnu@1.75.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.72.0': + '@oxlint/binding-linux-x64-gnu@1.75.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.74.0': + '@oxlint/binding-linux-x64-musl@1.75.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.72.0': + '@oxlint/binding-openharmony-arm64@1.75.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.74.0': + '@oxlint/binding-win32-arm64-msvc@1.75.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.72.0': + '@oxlint/binding-win32-ia32-msvc@1.75.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.74.0': + '@oxlint/binding-win32-x64-msvc@1.75.0': optional: true - '@oxlint/plugins@1.68.0': {} + '@oxlint/plugins@1.73.0': {} '@polka/url@1.0.0-next.29': {} @@ -2436,57 +2247,113 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@voidzero-dev/vite-plus-core@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)': - dependencies: - '@oxc-project/runtime': 0.138.0 - '@oxc-project/types': 0.138.0 - lightningcss: 1.32.0 - postcss: 8.5.15 - optionalDependencies: - '@types/node': 26.1.1 - esbuild: 0.28.0 - fsevents: 2.3.3 - tsx: 4.23.1 - typescript: 6.0.3 - - '@voidzero-dev/vite-plus-core@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)': + '@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)': dependencies: - '@oxc-project/runtime': 0.138.0 - '@oxc-project/types': 0.138.0 - lightningcss: 1.32.0 + '@oxc-project/runtime': 0.141.0 + '@oxc-project/types': 0.141.0 + lightningcss: 1.33.0 postcss: 8.5.15 + yuku-codegen: 0.5.48 + yuku-parser: 0.5.48 optionalDependencies: '@types/node': 26.1.1 esbuild: 0.28.0 fsevents: 2.3.3 tsx: 4.23.1 typescript: 7.0.2 + + '@voidzero-dev/vite-plus-darwin-arm64@0.2.6': optional: true - '@voidzero-dev/vite-plus-darwin-arm64@0.2.4': + '@voidzero-dev/vite-plus-darwin-x64@0.2.6': optional: true - '@voidzero-dev/vite-plus-darwin-x64@0.2.4': + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.6': optional: true - '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.4': + '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.6': optional: true - '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.4': + '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.6': optional: true - '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.4': + '@voidzero-dev/vite-plus-linux-x64-musl@0.2.6': optional: true - '@voidzero-dev/vite-plus-linux-x64-musl@0.2.4': + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.6': optional: true - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.4': + '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.6': optional: true - '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.4': + '@yuku-codegen/binding-darwin-arm64@0.5.48': optional: true + '@yuku-codegen/binding-darwin-x64@0.5.48': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.5.48': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.5.48': + optional: true + + '@yuku-codegen/binding-win32-x64@0.5.48': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.5.48': + optional: true + + '@yuku-parser/binding-darwin-x64@0.5.48': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.5.48': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.5.48': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.5.48': + optional: true + + '@yuku-parser/binding-win32-arm64@0.5.48': + optional: true + + '@yuku-parser/binding-win32-x64@0.5.48': + optional: true + + '@yuku-toolchain/types@0.5.43': {} + ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} @@ -2560,36 +2427,69 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -2606,6 +2506,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -2618,188 +2534,88 @@ snapshots: obug@2.1.3: {} - oxc-parser@0.140.0: - dependencies: - '@oxc-project/types': 0.140.0 - optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.140.0 - '@oxc-parser/binding-android-arm64': 0.140.0 - '@oxc-parser/binding-darwin-arm64': 0.140.0 - '@oxc-parser/binding-darwin-x64': 0.140.0 - '@oxc-parser/binding-freebsd-x64': 0.140.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.140.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.140.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.140.0 - '@oxc-parser/binding-linux-arm64-musl': 0.140.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.140.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.140.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.140.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.140.0 - '@oxc-parser/binding-linux-x64-gnu': 0.140.0 - '@oxc-parser/binding-linux-x64-musl': 0.140.0 - '@oxc-parser/binding-openharmony-arm64': 0.140.0 - '@oxc-parser/binding-wasm32-wasi': 0.140.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.140.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.140.0 - '@oxc-parser/binding-win32-x64-msvc': 0.140.0 - - oxfmt@0.57.0(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))): - dependencies: - tinypool: 2.1.0 - optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.57.0 - '@oxfmt/binding-android-arm64': 0.57.0 - '@oxfmt/binding-darwin-arm64': 0.57.0 - '@oxfmt/binding-darwin-x64': 0.57.0 - '@oxfmt/binding-freebsd-x64': 0.57.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.57.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.57.0 - '@oxfmt/binding-linux-arm64-gnu': 0.57.0 - '@oxfmt/binding-linux-arm64-musl': 0.57.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.57.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.57.0 - '@oxfmt/binding-linux-riscv64-musl': 0.57.0 - '@oxfmt/binding-linux-s390x-gnu': 0.57.0 - '@oxfmt/binding-linux-x64-gnu': 0.57.0 - '@oxfmt/binding-linux-x64-musl': 0.57.0 - '@oxfmt/binding-openharmony-arm64': 0.57.0 - '@oxfmt/binding-win32-arm64-msvc': 0.57.0 - '@oxfmt/binding-win32-ia32-msvc': 0.57.0 - '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) - - oxfmt@0.57.0(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))): + oxc-parser@0.141.0: dependencies: - tinypool: 2.1.0 + '@oxc-project/types': 0.141.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.57.0 - '@oxfmt/binding-android-arm64': 0.57.0 - '@oxfmt/binding-darwin-arm64': 0.57.0 - '@oxfmt/binding-darwin-x64': 0.57.0 - '@oxfmt/binding-freebsd-x64': 0.57.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.57.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.57.0 - '@oxfmt/binding-linux-arm64-gnu': 0.57.0 - '@oxfmt/binding-linux-arm64-musl': 0.57.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.57.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.57.0 - '@oxfmt/binding-linux-riscv64-musl': 0.57.0 - '@oxfmt/binding-linux-s390x-gnu': 0.57.0 - '@oxfmt/binding-linux-x64-gnu': 0.57.0 - '@oxfmt/binding-linux-x64-musl': 0.57.0 - '@oxfmt/binding-openharmony-arm64': 0.57.0 - '@oxfmt/binding-win32-arm64-msvc': 0.57.0 - '@oxfmt/binding-win32-ia32-msvc': 0.57.0 - '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) - optional: true - - oxfmt@0.59.0(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))): + '@oxc-parser/binding-android-arm-eabi': 0.141.0 + '@oxc-parser/binding-android-arm64': 0.141.0 + '@oxc-parser/binding-darwin-arm64': 0.141.0 + '@oxc-parser/binding-darwin-x64': 0.141.0 + '@oxc-parser/binding-freebsd-x64': 0.141.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.141.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.141.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.141.0 + '@oxc-parser/binding-linux-arm64-musl': 0.141.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.141.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.141.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.141.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.141.0 + '@oxc-parser/binding-linux-x64-gnu': 0.141.0 + '@oxc-parser/binding-linux-x64-musl': 0.141.0 + '@oxc-parser/binding-openharmony-arm64': 0.141.0 + '@oxc-parser/binding-wasm32-wasi': 0.141.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.141.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.141.0 + '@oxc-parser/binding-win32-x64-msvc': 0.141.0 + + oxfmt@0.60.0(vite-plus@0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))): dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.59.0 - '@oxfmt/binding-android-arm64': 0.59.0 - '@oxfmt/binding-darwin-arm64': 0.59.0 - '@oxfmt/binding-darwin-x64': 0.59.0 - '@oxfmt/binding-freebsd-x64': 0.59.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.59.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.59.0 - '@oxfmt/binding-linux-arm64-gnu': 0.59.0 - '@oxfmt/binding-linux-arm64-musl': 0.59.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.59.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.59.0 - '@oxfmt/binding-linux-riscv64-musl': 0.59.0 - '@oxfmt/binding-linux-s390x-gnu': 0.59.0 - '@oxfmt/binding-linux-x64-gnu': 0.59.0 - '@oxfmt/binding-linux-x64-musl': 0.59.0 - '@oxfmt/binding-openharmony-arm64': 0.59.0 - '@oxfmt/binding-win32-arm64-msvc': 0.59.0 - '@oxfmt/binding-win32-ia32-msvc': 0.59.0 - '@oxfmt/binding-win32-x64-msvc': 0.59.0 - vite-plus: 0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) - - oxlint-tsgolint@0.24.0: - optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.24.0 - '@oxlint-tsgolint/darwin-x64': 0.24.0 - '@oxlint-tsgolint/linux-arm64': 0.24.0 - '@oxlint-tsgolint/linux-x64': 0.24.0 - '@oxlint-tsgolint/win32-arm64': 0.24.0 - '@oxlint-tsgolint/win32-x64': 0.24.0 - - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))): + '@oxfmt/binding-android-arm-eabi': 0.60.0 + '@oxfmt/binding-android-arm64': 0.60.0 + '@oxfmt/binding-darwin-arm64': 0.60.0 + '@oxfmt/binding-darwin-x64': 0.60.0 + '@oxfmt/binding-freebsd-x64': 0.60.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.60.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.60.0 + '@oxfmt/binding-linux-arm64-gnu': 0.60.0 + '@oxfmt/binding-linux-arm64-musl': 0.60.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.60.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.60.0 + '@oxfmt/binding-linux-riscv64-musl': 0.60.0 + '@oxfmt/binding-linux-s390x-gnu': 0.60.0 + '@oxfmt/binding-linux-x64-gnu': 0.60.0 + '@oxfmt/binding-linux-x64-musl': 0.60.0 + '@oxfmt/binding-openharmony-arm64': 0.60.0 + '@oxfmt/binding-win32-arm64-msvc': 0.60.0 + '@oxfmt/binding-win32-ia32-msvc': 0.60.0 + '@oxfmt/binding-win32-x64-msvc': 0.60.0 + vite-plus: 0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) + + oxlint-tsgolint@7.0.2001: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.72.0 - '@oxlint/binding-android-arm64': 1.72.0 - '@oxlint/binding-darwin-arm64': 1.72.0 - '@oxlint/binding-darwin-x64': 1.72.0 - '@oxlint/binding-freebsd-x64': 1.72.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 - '@oxlint/binding-linux-arm-musleabihf': 1.72.0 - '@oxlint/binding-linux-arm64-gnu': 1.72.0 - '@oxlint/binding-linux-arm64-musl': 1.72.0 - '@oxlint/binding-linux-ppc64-gnu': 1.72.0 - '@oxlint/binding-linux-riscv64-gnu': 1.72.0 - '@oxlint/binding-linux-riscv64-musl': 1.72.0 - '@oxlint/binding-linux-s390x-gnu': 1.72.0 - '@oxlint/binding-linux-x64-gnu': 1.72.0 - '@oxlint/binding-linux-x64-musl': 1.72.0 - '@oxlint/binding-openharmony-arm64': 1.72.0 - '@oxlint/binding-win32-arm64-msvc': 1.72.0 - '@oxlint/binding-win32-ia32-msvc': 1.72.0 - '@oxlint/binding-win32-x64-msvc': 1.72.0 - oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) - - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))): + '@oxlint-tsgolint/darwin-arm64': 7.0.2001 + '@oxlint-tsgolint/darwin-x64': 7.0.2001 + '@oxlint-tsgolint/linux-arm64': 7.0.2001 + '@oxlint-tsgolint/linux-x64': 7.0.2001 + '@oxlint-tsgolint/win32-arm64': 7.0.2001 + '@oxlint-tsgolint/win32-x64': 7.0.2001 + + oxlint@1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.72.0 - '@oxlint/binding-android-arm64': 1.72.0 - '@oxlint/binding-darwin-arm64': 1.72.0 - '@oxlint/binding-darwin-x64': 1.72.0 - '@oxlint/binding-freebsd-x64': 1.72.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 - '@oxlint/binding-linux-arm-musleabihf': 1.72.0 - '@oxlint/binding-linux-arm64-gnu': 1.72.0 - '@oxlint/binding-linux-arm64-musl': 1.72.0 - '@oxlint/binding-linux-ppc64-gnu': 1.72.0 - '@oxlint/binding-linux-riscv64-gnu': 1.72.0 - '@oxlint/binding-linux-riscv64-musl': 1.72.0 - '@oxlint/binding-linux-s390x-gnu': 1.72.0 - '@oxlint/binding-linux-x64-gnu': 1.72.0 - '@oxlint/binding-linux-x64-musl': 1.72.0 - '@oxlint/binding-openharmony-arm64': 1.72.0 - '@oxlint/binding-win32-arm64-msvc': 1.72.0 - '@oxlint/binding-win32-ia32-msvc': 1.72.0 - '@oxlint/binding-win32-x64-msvc': 1.72.0 - oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) - optional: true - - oxlint@1.74.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))): - optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.74.0 - '@oxlint/binding-android-arm64': 1.74.0 - '@oxlint/binding-darwin-arm64': 1.74.0 - '@oxlint/binding-darwin-x64': 1.74.0 - '@oxlint/binding-freebsd-x64': 1.74.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.74.0 - '@oxlint/binding-linux-arm-musleabihf': 1.74.0 - '@oxlint/binding-linux-arm64-gnu': 1.74.0 - '@oxlint/binding-linux-arm64-musl': 1.74.0 - '@oxlint/binding-linux-ppc64-gnu': 1.74.0 - '@oxlint/binding-linux-riscv64-gnu': 1.74.0 - '@oxlint/binding-linux-riscv64-musl': 1.74.0 - '@oxlint/binding-linux-s390x-gnu': 1.74.0 - '@oxlint/binding-linux-x64-gnu': 1.74.0 - '@oxlint/binding-linux-x64-musl': 1.74.0 - '@oxlint/binding-openharmony-arm64': 1.74.0 - '@oxlint/binding-win32-arm64-msvc': 1.74.0 - '@oxlint/binding-win32-ia32-msvc': 1.74.0 - '@oxlint/binding-win32-x64-msvc': 1.74.0 - oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) + '@oxlint/binding-android-arm-eabi': 1.75.0 + '@oxlint/binding-android-arm64': 1.75.0 + '@oxlint/binding-darwin-arm64': 1.75.0 + '@oxlint/binding-darwin-x64': 1.75.0 + '@oxlint/binding-freebsd-x64': 1.75.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.75.0 + '@oxlint/binding-linux-arm-musleabihf': 1.75.0 + '@oxlint/binding-linux-arm64-gnu': 1.75.0 + '@oxlint/binding-linux-arm64-musl': 1.75.0 + '@oxlint/binding-linux-ppc64-gnu': 1.75.0 + '@oxlint/binding-linux-riscv64-gnu': 1.75.0 + '@oxlint/binding-linux-riscv64-musl': 1.75.0 + '@oxlint/binding-linux-s390x-gnu': 1.75.0 + '@oxlint/binding-linux-x64-gnu': 1.75.0 + '@oxlint/binding-linux-x64-musl': 1.75.0 + '@oxlint/binding-openharmony-arm64': 1.75.0 + '@oxlint/binding-win32-arm64-msvc': 1.75.0 + '@oxlint/binding-win32-ia32-msvc': 1.75.0 + '@oxlint/binding-win32-x64-msvc': 1.75.0 + oxlint-tsgolint: 7.0.2001 + vite-plus: 0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) pathe@2.0.3: {} @@ -2884,9 +2700,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - typescript@6.0.3: - optional: true - typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -2912,68 +2725,10 @@ snapshots: undici-types@8.3.0: {} - vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)): - dependencies: - '@oxc-project/types': 0.138.0 - '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10) - '@vitest/browser-preview': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10) - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - '@voidzero-dev/vite-plus-core': 0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3) - oxfmt: 0.57.0(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@6.0.3)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) - oxlint-tsgolint: 0.24.0 - vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) - optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.2.4 - '@voidzero-dev/vite-plus-darwin-x64': 0.2.4 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.2.4 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.2.4 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.2.4 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.2.4 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.2.4 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.2.4 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@types/node' - - '@vitejs/devtools' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - msw - - publint - - sass - - sass-embedded - - stylus - - sugarss - - svelte - - terser - - tsx - - typescript - - unplugin-unused - - unrun - - utf-8-validate - - vite - - yaml - - vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)): + vite-plus@0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)): dependencies: - '@oxc-project/types': 0.138.0 - '@oxlint/plugins': 1.68.0 + '@oxc-project/types': 0.141.0 + '@oxlint/plugins': 1.73.0 '@vitest/browser': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10) '@vitest/browser-preview': 4.1.10(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))(vitest@4.1.10) '@vitest/expect': 4.1.10 @@ -2983,20 +2738,20 @@ snapshots: '@vitest/snapshot': 4.1.10 '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 - '@voidzero-dev/vite-plus-core': 0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2) - oxfmt: 0.57.0(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.4(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) - oxlint-tsgolint: 0.24.0 + '@voidzero-dev/vite-plus-core': 0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2) + oxfmt: 0.60.0(vite-plus@0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) + oxlint: 1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.6(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)(typescript@7.0.2)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1))) + oxlint-tsgolint: 7.0.2001 vitest: 4.1.10(@types/node@26.1.1)(@vitest/browser-preview@4.1.10)(vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1)) optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.2.4 - '@voidzero-dev/vite-plus-darwin-x64': 0.2.4 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.2.4 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.2.4 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.2.4 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.2.4 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.2.4 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.2.4 + '@voidzero-dev/vite-plus-darwin-arm64': 0.2.6 + '@voidzero-dev/vite-plus-darwin-x64': 0.2.6 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.2.6 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.2.6 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.2.6 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.2.6 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.2.6 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.2.6 transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -3027,7 +2782,6 @@ snapshots: - utf-8-validate - vite - yaml - optional: true vite@8.0.16(@types/node@26.1.1)(esbuild@0.28.0)(tsx@4.23.1): dependencies: @@ -3077,4 +2831,36 @@ snapshots: ws@8.21.0: {} + yuku-codegen@0.5.48: + dependencies: + '@yuku-toolchain/types': 0.5.43 + optionalDependencies: + '@yuku-codegen/binding-darwin-arm64': 0.5.48 + '@yuku-codegen/binding-darwin-x64': 0.5.48 + '@yuku-codegen/binding-freebsd-x64': 0.5.48 + '@yuku-codegen/binding-linux-arm-gnu': 0.5.48 + '@yuku-codegen/binding-linux-arm-musl': 0.5.48 + '@yuku-codegen/binding-linux-arm64-gnu': 0.5.48 + '@yuku-codegen/binding-linux-arm64-musl': 0.5.48 + '@yuku-codegen/binding-linux-x64-gnu': 0.5.48 + '@yuku-codegen/binding-linux-x64-musl': 0.5.48 + '@yuku-codegen/binding-win32-arm64': 0.5.48 + '@yuku-codegen/binding-win32-x64': 0.5.48 + + yuku-parser@0.5.48: + dependencies: + '@yuku-toolchain/types': 0.5.43 + optionalDependencies: + '@yuku-parser/binding-darwin-arm64': 0.5.48 + '@yuku-parser/binding-darwin-x64': 0.5.48 + '@yuku-parser/binding-freebsd-x64': 0.5.48 + '@yuku-parser/binding-linux-arm-gnu': 0.5.48 + '@yuku-parser/binding-linux-arm-musl': 0.5.48 + '@yuku-parser/binding-linux-arm64-gnu': 0.5.48 + '@yuku-parser/binding-linux-arm64-musl': 0.5.48 + '@yuku-parser/binding-linux-x64-gnu': 0.5.48 + '@yuku-parser/binding-linux-x64-musl': 0.5.48 + '@yuku-parser/binding-win32-arm64': 0.5.48 + '@yuku-parser/binding-win32-x64': 0.5.48 + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b532348..0510f27 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,11 @@ packages: - packages/go/* - packages/ts/toolchain - packages/ts/sidecar + +# pnpm 11 blocks dependency build scripts unless they are listed here. esbuild +# arrives as a transitive dependency of Vite+, which resolves its native binary +# through the @esbuild/ optional dependency, so esbuild's own install +# script has nothing left to do. It has never run in this workspace; keeping it +# off preserves that and silences the prompt on every install. +allowBuilds: + esbuild: false diff --git a/scripts/test-binary-smoke.sh b/scripts/test-binary-smoke.sh index a7db27d..759032e 100755 --- a/scripts/test-binary-smoke.sh +++ b/scripts/test-binary-smoke.sh @@ -38,7 +38,7 @@ git init --quiet . # rewrites it, while a dropped config leaves oxfmt on its double-quote default. printf 'const a = { x:1, s:"hi" }\nexport default a\n' > app.ts printf 'package p\n\nfunc f() {\n\tdefer println("d")\n\treturn\n}\n' > app.go -printf 'module fixture\n\ngo 1.26.4\n' > go.mod +printf 'module fixture\n\ngo 1.26.5\n' > go.mod # The Vue SFC is the embedded-formatter probe: its