From 78173a56b8eaeb50d9eaac81c5e35b7f59101740 Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:15:07 -0400 Subject: [PATCH 01/11] feat: add defragmentation guardrail rules --- configs/core.jsonc | 2 + configs/full.jsonc | 2 + rules/no-effect-step-const-staging.grit | 18 +++++ rules/no-fragmented-const-assembly.grit | 27 +++++++ .../defragmentation/invalid-assembly-chain.ts | 28 +++++++ .../invalid-direct-const-fragment.ts | 22 ++++++ .../invalid-effect-step-const-staging.ts | 24 ++++++ .../invalid-pipe-effect-step-const-staging.ts | 23 ++++++ .../defragmentation/valid-const-assembly.ts | 40 ++++++++++ .../valid-effect-step-continuous-flow.ts | 24 ++++++ .../no-effect-step-const-staging.test.ts | 79 +++++++++++++++++++ .../no-fragmented-const-assembly.test.ts | 75 ++++++++++++++++++ 12 files changed, 364 insertions(+) create mode 100644 rules/no-effect-step-const-staging.grit create mode 100644 rules/no-fragmented-const-assembly.grit create mode 100644 tests/fixtures/defragmentation/invalid-assembly-chain.ts create mode 100644 tests/fixtures/defragmentation/invalid-direct-const-fragment.ts create mode 100644 tests/fixtures/defragmentation/invalid-effect-step-const-staging.ts create mode 100644 tests/fixtures/defragmentation/invalid-pipe-effect-step-const-staging.ts create mode 100644 tests/fixtures/defragmentation/valid-const-assembly.ts create mode 100644 tests/fixtures/defragmentation/valid-effect-step-continuous-flow.ts create mode 100644 tests/rules/no-effect-step-const-staging.test.ts create mode 100644 tests/rules/no-fragmented-const-assembly.test.ts diff --git a/configs/core.jsonc b/configs/core.jsonc index 64c9fe1..9444365 100644 --- a/configs/core.jsonc +++ b/configs/core.jsonc @@ -31,6 +31,8 @@ "./node_modules/@catenarycloud/linteffect/rules/warn-effect-sync-wrapper.grit", "./node_modules/@catenarycloud/linteffect/rules/no-effect-side-effect-wrapper.grit", "./node_modules/@catenarycloud/linteffect/rules/no-effect-all-step-sequencing.grit", + "./node_modules/@catenarycloud/linteffect/rules/no-effect-step-const-staging.grit", + "./node_modules/@catenarycloud/linteffect/rules/no-fragmented-const-assembly.grit", "./node_modules/@catenarycloud/linteffect/rules/no-effect-orElse-ladder.grit", "./node_modules/@catenarycloud/linteffect/rules/no-return-in-callback.grit", "./node_modules/@catenarycloud/linteffect/rules/no-try-catch.grit", diff --git a/configs/full.jsonc b/configs/full.jsonc index a89e01e..e8dfe54 100644 --- a/configs/full.jsonc +++ b/configs/full.jsonc @@ -35,6 +35,8 @@ "./node_modules/@catenarycloud/linteffect/rules/warn-effect-sync-wrapper.grit", "./node_modules/@catenarycloud/linteffect/rules/no-effect-side-effect-wrapper.grit", "./node_modules/@catenarycloud/linteffect/rules/no-effect-all-step-sequencing.grit", + "./node_modules/@catenarycloud/linteffect/rules/no-effect-step-const-staging.grit", + "./node_modules/@catenarycloud/linteffect/rules/no-fragmented-const-assembly.grit", "./node_modules/@catenarycloud/linteffect/rules/no-effect-orElse-ladder.grit", "./node_modules/@catenarycloud/linteffect/rules/no-render-side-effects.grit", "./node_modules/@catenarycloud/linteffect/rules/no-naked-object-state-update.grit", diff --git a/rules/no-effect-step-const-staging.grit b/rules/no-effect-step-const-staging.grit new file mode 100644 index 0000000..198c7b5 --- /dev/null +++ b/rules/no-effect-step-const-staging.grit @@ -0,0 +1,18 @@ +language js(typescript, jsx) + +JsVariableStatement() as $statement where { + or { + $statement <: contains `const $name = Effect.$method($args);`, + $statement <: contains `const $name = pipe($args);` where { + $statement <: contains `Effect.$method($effectArgs)` + }, + $statement <: contains `const $name = $source.pipe($steps);` where { + $statement <: contains `Effect.$method($effectArgs)` + } + }, + register_diagnostic( + span = $statement, + message = "Rule: avoid Effect-step const staging. Why this is wrong: this const stores an intermediate Effect step, then later code has to chase names to reconstruct validation, decision, control flow, and the final Effect. Safe remediation: do not move the step into another helper or wrapper. Inline the step into one visible Effect pipeline at the operation that performs the work; use pipe/flatMap/andThen/tap to keep sequencing continuous. Bind a local only for a final domain-shaped value with an explicit domain type, not for staged Effect inputs.", + severity = "error" + ) +} diff --git a/rules/no-fragmented-const-assembly.grit b/rules/no-fragmented-const-assembly.grit new file mode 100644 index 0000000..0dbc87a --- /dev/null +++ b/rules/no-fragmented-const-assembly.grit @@ -0,0 +1,27 @@ +language js(typescript, jsx) + +JsVariableStatement() as $statement where { + $statement <: contains `($params) => $expr`, + not $statement <: contains `S.Struct($schemaArgs)`, + not $statement <: contains `S.Literal($schemaArgs)`, + not $statement <: contains `S.Union($schemaArgs)`, + not $statement <: contains `S.Number.pipe($schemaArgs)`, + not $statement <: contains `Schema.Struct($schemaArgs)`, + not $statement <: contains `Schema.Literal($schemaArgs)`, + not $statement <: contains `Schema.Union($schemaArgs)`, + not $statement <: contains `Schema.Number.pipe($schemaArgs)`, + not $statement <: contains `Data.taggedEnum($args)`, + not $statement <: contains `Data.taggedEnum<$type>()`, + or { + $statement <: contains `({ $props })`, + $statement <: contains `$receiver.pipe($steps)`, + $statement <: contains `$constructor({ $props })`, + $statement <: contains `$namespace.$method($args)`, + $statement <: r"Match[.]value|Option[.]match|Either[.]match|[$]match|[.]pipe[(]" + }, + register_diagnostic( + span = $statement, + message = "Rule: avoid fragmented const assembly chains. Why this is wrong: this const function pre-assembles an object/branch/pipeline fragment outside the operation that uses it, so the reader must chase staged pieces to understand the contract. Safe remediation: do not create another wrapper. Move this assembly back into the service/API operation that emits it, keep validation -> decision -> object/Effect construction in one visible block, bind only the final domain-shaped value with an explicit return/domain type, then return/use that value. Only keep a helper if it is a stable domain constructor used by multiple operations and its signature names the domain contract.", + severity = "error" + ) +} diff --git a/tests/fixtures/defragmentation/invalid-assembly-chain.ts b/tests/fixtures/defragmentation/invalid-assembly-chain.ts new file mode 100644 index 0000000..53bd867 --- /dev/null +++ b/tests/fixtures/defragmentation/invalid-assembly-chain.ts @@ -0,0 +1,28 @@ +import { Data, Option } from "effect"; + +type FailureKind = Data.TaggedEnum<{ + Transport: { readonly boundary: string }; +}>; + +// Proper usage: ADT constructor declarations are domain declarations, not +// assembly fragments, so this const is intentionally allowed. +const FailureKind = Data.taggedEnum(); + +// Derailment level 1: a loose named fragment is extracted before there is a +// meaningful domain value. +const errorFieldName = "errorMessage"; + +// Derailment level 2: this helper const assembles a partial object from the +// loose fragment above. It is not a standalone domain concept; it is a staged +// piece of the final API error shape. +const apiErrorFields = (message: string) => ({ + [errorFieldName]: message, +}); + +// Derailment: the exported object builder now assembles the final contract from +// another assembly const and an inline transform. The reader has to chase the +// chain instead of seeing the API error contract in one operation-local block. +export const apiErrorObject = (error: { readonly message: string }) => ({ + ...apiErrorFields(error.message), + stackTrace: Option.fromNullable(error.message).pipe(Option.map((value) => [value])), +}); diff --git a/tests/fixtures/defragmentation/invalid-direct-const-fragment.ts b/tests/fixtures/defragmentation/invalid-direct-const-fragment.ts new file mode 100644 index 0000000..15733f7 --- /dev/null +++ b/tests/fixtures/defragmentation/invalid-direct-const-fragment.ts @@ -0,0 +1,22 @@ +import { Data } from "effect"; + +type FailureKind = Data.TaggedEnum<{ + Transport: { readonly boundary: string }; +}>; + +// Proper usage: ADT constructor declarations are domain declarations, not +// assembly fragments, so this const is intentionally allowed. +const FailureKind = Data.taggedEnum(); + +// Derailment: a standalone field fragment is later pulled into an object +// builder const. The contract shape is no longer local to the operation that +// emits it. +const errorFieldName = "errorMessage"; + +// Derailment: this module-level const function assembles its return object from +// the named fragment above. Keep the field choice in the same local operation +// block, or promote a real typed domain constructor. +const apiErrorObject = (error: { readonly message: string }) => ({ + field: errorFieldName, + message: error.message, +}); diff --git a/tests/fixtures/defragmentation/invalid-effect-step-const-staging.ts b/tests/fixtures/defragmentation/invalid-effect-step-const-staging.ts new file mode 100644 index 0000000..9c22837 --- /dev/null +++ b/tests/fixtures/defragmentation/invalid-effect-step-const-staging.ts @@ -0,0 +1,24 @@ +import { Effect } from "effect"; + +type PolicyResult = { + readonly decision: "ALLOW" | "DENY"; +}; + +export const evaluatePolicy = ( + policyText: string, +): Effect.Effect => { + // Derailment: this const stores an intermediate Effect step. The next const + // depends on it, so the operation flow is split into named staging pieces. + const parsedPolicy = Effect.try({ + try: () => JSON.parse(policyText) as { readonly allow?: boolean }, + catch: (cause) => new Error(`Policy parse failed: ${cause}`), + }); + + // Derailment: this second Effect const continues the staged chain instead of + // keeping parse -> validate -> result construction in one visible pipeline. + const evaluatedPolicy = Effect.map(parsedPolicy, (policy): PolicyResult => ({ + decision: policy.allow === true ? "ALLOW" : "DENY", + })); + + return evaluatedPolicy; +}; diff --git a/tests/fixtures/defragmentation/invalid-pipe-effect-step-const-staging.ts b/tests/fixtures/defragmentation/invalid-pipe-effect-step-const-staging.ts new file mode 100644 index 0000000..36d8555 --- /dev/null +++ b/tests/fixtures/defragmentation/invalid-pipe-effect-step-const-staging.ts @@ -0,0 +1,23 @@ +import { Effect, pipe } from "effect"; + +type PolicyResult = { + readonly decision: "ALLOW" | "DENY"; +}; + +export const evaluatePolicy = ( + policyText: string, +): Effect.Effect => { + // Derailment: the pipeline itself is pre-staged as a const. The operation now + // has a named Effect fragment instead of one continuous visible flow. + const evaluationPipeline = pipe( + Effect.try({ + try: () => JSON.parse(policyText) as { readonly allow?: boolean }, + catch: (cause) => new Error(`Policy parse failed: ${cause}`), + }), + Effect.map((policy): PolicyResult => ({ + decision: policy.allow === true ? "ALLOW" : "DENY", + })), + ); + + return evaluationPipeline; +}; diff --git a/tests/fixtures/defragmentation/valid-const-assembly.ts b/tests/fixtures/defragmentation/valid-const-assembly.ts new file mode 100644 index 0000000..b047ef1 --- /dev/null +++ b/tests/fixtures/defragmentation/valid-const-assembly.ts @@ -0,0 +1,40 @@ +import { Data, Schema as S } from "effect"; + +type BoundaryFailureCause = Data.TaggedEnum<{ + Transport: { readonly cause: unknown }; +}>; + +// Proper usage: ADT constructor declarations are domain declarations, not +// operation assembly fragments. +const BoundaryFailureCause = Data.taggedEnum(); + +// Proper usage: schema declarations define a reusable boundary contract. They +// are not staged helper pieces for one object assembly. +const BoundaryInputSchema = S.Struct({ + id: S.String, +}); + +type ApiErrorShape = { + readonly errorData: string | undefined; + readonly errorMessage: string; + readonly errorType: string; + readonly stackTrace: ReadonlyArray | undefined; +}; + +// Proper usage: the helper has an explicit domain return type and binds the +// final contract-shaped value locally before returning it. It does not assemble +// the object from unrelated module-level fragments. +const apiErrorObject = (error: ApiErrorShape): ApiErrorShape => { + const apiError: ApiErrorShape = { + errorData: error.errorData, + errorMessage: error.errorMessage, + errorType: error.errorType, + stackTrace: error.stackTrace, + }; + + return apiError; +}; + +export const decodeBoundaryInput = S.decodeUnknown(BoundaryInputSchema); +export const makeTransportFailure = BoundaryFailureCause.Transport; +export const mapApiError = apiErrorObject; diff --git a/tests/fixtures/defragmentation/valid-effect-step-continuous-flow.ts b/tests/fixtures/defragmentation/valid-effect-step-continuous-flow.ts new file mode 100644 index 0000000..400e31d --- /dev/null +++ b/tests/fixtures/defragmentation/valid-effect-step-continuous-flow.ts @@ -0,0 +1,24 @@ +import { Effect, pipe } from "effect"; + +type PolicyResult = { + readonly decision: "ALLOW" | "DENY"; +}; + +export const evaluatePolicy = ( + policyText: string, +): Effect.Effect => + // Proper usage for this rule: the Effect steps stay in one visible operation + // pipeline. There is no intermediate const that stores a partial Effect step. + pipe( + Effect.try({ + try: () => JSON.parse(policyText) as { readonly allow?: boolean }, + catch: (cause) => new Error(`Policy parse failed: ${cause}`), + }), + Effect.map((policy): PolicyResult => { + const result: PolicyResult = { + decision: policy.allow === true ? "ALLOW" : "DENY", + }; + + return result; + }), + ); diff --git a/tests/rules/no-effect-step-const-staging.test.ts b/tests/rules/no-effect-step-const-staging.test.ts new file mode 100644 index 0000000..e974dfa --- /dev/null +++ b/tests/rules/no-effect-step-const-staging.test.ts @@ -0,0 +1,79 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const fixtureRoot = path.join(repoRoot, "tests", "fixtures", "defragmentation"); + +function resolveBiomeBin() { + const biomePackagePath = require.resolve("@biomejs/biome/package.json"); + const biomePackage = require(biomePackagePath); + const biomeBinRelative = + typeof biomePackage.bin === "string" ? biomePackage.bin : biomePackage.bin.biome; + return path.resolve(path.dirname(biomePackagePath), biomeBinRelative); +} + +function lintWithRule(fixtureFile: string) { + const tempDir = mkdtempSync(path.join(tmpdir(), "linteffect-rule-test-")); + const configPath = path.join(tempDir, "biome.json"); + const rulePath = path.join(repoRoot, "rules", "no-effect-step-const-staging.grit"); + + writeFileSync(configPath, `${JSON.stringify({ plugins: [rulePath] }, null, 2)}\n`, "utf8"); + + try { + const result = spawnSync( + process.execPath, + [ + resolveBiomeBin(), + "lint", + "--reporter=json", + "--max-diagnostics=none", + `--config-path=${configPath}`, + fixtureFile, + ], + { cwd: repoRoot, encoding: "utf8" }, + ); + + return { + status: result.status ?? 1, + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + }; + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } +} + +const diagnosticMessage = "Rule: avoid Effect-step const staging."; + +describe("no-effect-step-const-staging", () => { + it("It catches Effect steps staged in local consts", () => { + const result = lintWithRule( + path.join(fixtureRoot, "invalid-effect-step-const-staging.ts"), + ); + + expect(result.status).toBe(1); + expect(result.output).toContain(diagnosticMessage); + }); + + it("It catches Effect pipelines staged in local consts", () => { + const result = lintWithRule( + path.join(fixtureRoot, "invalid-pipe-effect-step-const-staging.ts"), + ); + + expect(result.status).toBe(1); + expect(result.output).toContain(diagnosticMessage); + }); + + it("It allows continuous Effect pipelines without intermediate step consts", () => { + const result = lintWithRule( + path.join(fixtureRoot, "valid-effect-step-continuous-flow.ts"), + ); + + expect(result.status).toBe(0); + }); +}); diff --git a/tests/rules/no-fragmented-const-assembly.test.ts b/tests/rules/no-fragmented-const-assembly.test.ts new file mode 100644 index 0000000..2b19be8 --- /dev/null +++ b/tests/rules/no-fragmented-const-assembly.test.ts @@ -0,0 +1,75 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const fixtureRoot = path.join(repoRoot, "tests", "fixtures", "defragmentation"); + +function resolveBiomeBin() { + const biomePackagePath = require.resolve("@biomejs/biome/package.json"); + const biomePackage = require(biomePackagePath); + const biomeBinRelative = + typeof biomePackage.bin === "string" ? biomePackage.bin : biomePackage.bin.biome; + return path.resolve(path.dirname(biomePackagePath), biomeBinRelative); +} + +function lintWithRule(fixtureFile: string) { + const tempDir = mkdtempSync(path.join(tmpdir(), "linteffect-rule-test-")); + const configPath = path.join(tempDir, "biome.json"); + const rulePath = path.join(repoRoot, "rules", "no-fragmented-const-assembly.grit"); + + writeFileSync(configPath, `${JSON.stringify({ plugins: [rulePath] }, null, 2)}\n`, "utf8"); + + try { + const result = spawnSync( + process.execPath, + [ + resolveBiomeBin(), + "lint", + "--reporter=json", + "--max-diagnostics=none", + `--config-path=${configPath}`, + fixtureFile, + ], + { cwd: repoRoot, encoding: "utf8" }, + ); + + return { + status: result.status ?? 1, + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + }; + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } +} + +const diagnosticMessage = "Rule: avoid fragmented const assembly chains."; + +describe("no-fragmented-const-assembly", () => { + it("It catches direct const fragments used inside const function object assembly", () => { + const result = lintWithRule( + path.join(fixtureRoot, "invalid-direct-const-fragment.ts"), + ); + + expect(result.status).toBe(1); + expect(result.output).toContain(diagnosticMessage); + }); + + it("It catches second-level const assembly chains", () => { + const result = lintWithRule(path.join(fixtureRoot, "invalid-assembly-chain.ts")); + + expect(result.status).toBe(1); + expect(result.output).toContain(diagnosticMessage); + }); + + it("It allows Data tagged enum declarations and explicit final object contracts", () => { + const result = lintWithRule(path.join(fixtureRoot, "valid-const-assembly.ts")); + + expect(result.status).toBe(0); + }); +}); From 5a758dabb5d46b924d414e6a79f670d315df84b7 Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:24:59 -0400 Subject: [PATCH 02/11] ci: restore manual dev npm publish --- .github/workflows/release.yml | 68 ++++++++++++++++++++++++++++++++++- .projenrc.ts | 68 ++++++++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdf76df..47378ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,79 @@ on: push: branches: - master - workflow_dispatch: {} + workflow_dispatch: + inputs: + prerelease: + description: prerelease identifier; use dev to publish @dev + required: false + default: "" + type: string jobs: + publish_dev: + name: Publish npm dev build + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + CI: "true" + if: github.event_name == 'workflow_dispatch' && inputs.prerelease == 'dev' + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Install Specific Yarn Version + run: corepack enable && corepack prepare yarn@4.6.0 --activate + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 24.11.1 + package-manager-cache: false + - name: Install dependencies + run: yarn install --immutable + - name: Set next dev version + run: |- + node <<'NODE' + const { execFileSync } = require("node:child_process"); + const fs = require("node:fs"); + + const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); + const distTags = JSON.parse( + execFileSync("npm", ["view", packageJson.name, "dist-tags", "--json"], { + encoding: "utf8", + }), + ); + const latest = distTags.latest ?? "0.0.0"; + const stableBase = latest.split("-")[0]; + const [major, minor, patch] = stableBase.split(".").map(Number); + + if (![major, minor, patch].every(Number.isInteger)) { + throw new Error("Cannot derive next dev version from npm latest dist-tag: " + latest); + } + + const runNumber = process.env.GITHUB_RUN_NUMBER; + const runAttempt = process.env.GITHUB_RUN_ATTEMPT ?? "1"; + const devVersion = [major, minor, patch + 1].join(".") + "-dev." + runNumber + "." + runAttempt; + + packageJson.version = devVersion; + fs.writeFileSync("package.json", JSON.stringify(packageJson, null, 2) + "\n"); + fs.appendFileSync(process.env.GITHUB_ENV, "DEV_VERSION=" + devVersion + "\n"); + NODE + - name: Test + run: yarn test + - name: Prepare package output + run: mkdir -p dist + - name: Pack + run: npm pack --pack-destination dist + - name: Publish + env: + NPM_CONFIG_PROVENANCE: "true" + run: npm publish dist/*.tgz --tag dev --access public release_please: runs-on: ubuntu-latest permissions: contents: write pull-requests: write + if: github.event_name != 'workflow_dispatch' || inputs.prerelease != 'dev' outputs: release_created: ${{ steps.release.outputs.release_created }} steps: diff --git a/.projenrc.ts b/.projenrc.ts index 86eff9b..b963c1f 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -229,13 +229,79 @@ on: push: branches: - master - workflow_dispatch: {} + workflow_dispatch: + inputs: + prerelease: + description: prerelease identifier; use dev to publish @dev + required: false + default: "" + type: string jobs: + publish_dev: + name: Publish npm dev build + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + CI: "true" + if: github.event_name == 'workflow_dispatch' && inputs.prerelease == 'dev' + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Install Specific Yarn Version + run: corepack enable && corepack prepare yarn@${yarnVersion} --activate + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 24.11.1 + package-manager-cache: false + - name: Install dependencies + run: yarn install --immutable + - name: Set next dev version + run: |- + node <<'NODE' + const { execFileSync } = require("node:child_process"); + const fs = require("node:fs"); + + const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); + const distTags = JSON.parse( + execFileSync("npm", ["view", packageJson.name, "dist-tags", "--json"], { + encoding: "utf8", + }), + ); + const latest = distTags.latest ?? "0.0.0"; + const stableBase = latest.split("-")[0]; + const [major, minor, patch] = stableBase.split(".").map(Number); + + if (![major, minor, patch].every(Number.isInteger)) { + throw new Error("Cannot derive next dev version from npm latest dist-tag: " + latest); + } + + const runNumber = process.env.GITHUB_RUN_NUMBER; + const runAttempt = process.env.GITHUB_RUN_ATTEMPT ?? "1"; + const devVersion = [major, minor, patch + 1].join(".") + "-dev." + runNumber + "." + runAttempt; + + packageJson.version = devVersion; + fs.writeFileSync("package.json", JSON.stringify(packageJson, null, 2) + "\\n"); + fs.appendFileSync(process.env.GITHUB_ENV, "DEV_VERSION=" + devVersion + "\\n"); + NODE + - name: Test + run: yarn test + - name: Prepare package output + run: mkdir -p dist + - name: Pack + run: npm pack --pack-destination dist + - name: Publish + env: + NPM_CONFIG_PROVENANCE: "true" + run: npm publish dist/*.tgz --tag dev --access public release_please: runs-on: ubuntu-latest permissions: contents: write pull-requests: write + if: github.event_name != 'workflow_dispatch' || inputs.prerelease != 'dev' outputs: release_created: \${{ steps.release.outputs.release_created }} steps: From 5a87e8004bb7dc66bfea9387f29d295a63e3a871 Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:26:57 -0400 Subject: [PATCH 03/11] Revert "ci: restore manual dev npm publish" This reverts commit 5a758dabb5d46b924d414e6a79f670d315df84b7. --- .github/workflows/release.yml | 68 +---------------------------------- .projenrc.ts | 68 +---------------------------------- 2 files changed, 2 insertions(+), 134 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47378ad..cdf76df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,79 +5,13 @@ on: push: branches: - master - workflow_dispatch: - inputs: - prerelease: - description: prerelease identifier; use dev to publish @dev - required: false - default: "" - type: string + workflow_dispatch: {} jobs: - publish_dev: - name: Publish npm dev build - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - env: - CI: "true" - if: github.event_name == 'workflow_dispatch' && inputs.prerelease == 'dev' - steps: - - name: Checkout - uses: actions/checkout@v5 - - name: Install Specific Yarn Version - run: corepack enable && corepack prepare yarn@4.6.0 --activate - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version: 24.11.1 - package-manager-cache: false - - name: Install dependencies - run: yarn install --immutable - - name: Set next dev version - run: |- - node <<'NODE' - const { execFileSync } = require("node:child_process"); - const fs = require("node:fs"); - - const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); - const distTags = JSON.parse( - execFileSync("npm", ["view", packageJson.name, "dist-tags", "--json"], { - encoding: "utf8", - }), - ); - const latest = distTags.latest ?? "0.0.0"; - const stableBase = latest.split("-")[0]; - const [major, minor, patch] = stableBase.split(".").map(Number); - - if (![major, minor, patch].every(Number.isInteger)) { - throw new Error("Cannot derive next dev version from npm latest dist-tag: " + latest); - } - - const runNumber = process.env.GITHUB_RUN_NUMBER; - const runAttempt = process.env.GITHUB_RUN_ATTEMPT ?? "1"; - const devVersion = [major, minor, patch + 1].join(".") + "-dev." + runNumber + "." + runAttempt; - - packageJson.version = devVersion; - fs.writeFileSync("package.json", JSON.stringify(packageJson, null, 2) + "\n"); - fs.appendFileSync(process.env.GITHUB_ENV, "DEV_VERSION=" + devVersion + "\n"); - NODE - - name: Test - run: yarn test - - name: Prepare package output - run: mkdir -p dist - - name: Pack - run: npm pack --pack-destination dist - - name: Publish - env: - NPM_CONFIG_PROVENANCE: "true" - run: npm publish dist/*.tgz --tag dev --access public release_please: runs-on: ubuntu-latest permissions: contents: write pull-requests: write - if: github.event_name != 'workflow_dispatch' || inputs.prerelease != 'dev' outputs: release_created: ${{ steps.release.outputs.release_created }} steps: diff --git a/.projenrc.ts b/.projenrc.ts index b963c1f..86eff9b 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -229,79 +229,13 @@ on: push: branches: - master - workflow_dispatch: - inputs: - prerelease: - description: prerelease identifier; use dev to publish @dev - required: false - default: "" - type: string + workflow_dispatch: {} jobs: - publish_dev: - name: Publish npm dev build - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - env: - CI: "true" - if: github.event_name == 'workflow_dispatch' && inputs.prerelease == 'dev' - steps: - - name: Checkout - uses: actions/checkout@v5 - - name: Install Specific Yarn Version - run: corepack enable && corepack prepare yarn@${yarnVersion} --activate - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version: 24.11.1 - package-manager-cache: false - - name: Install dependencies - run: yarn install --immutable - - name: Set next dev version - run: |- - node <<'NODE' - const { execFileSync } = require("node:child_process"); - const fs = require("node:fs"); - - const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); - const distTags = JSON.parse( - execFileSync("npm", ["view", packageJson.name, "dist-tags", "--json"], { - encoding: "utf8", - }), - ); - const latest = distTags.latest ?? "0.0.0"; - const stableBase = latest.split("-")[0]; - const [major, minor, patch] = stableBase.split(".").map(Number); - - if (![major, minor, patch].every(Number.isInteger)) { - throw new Error("Cannot derive next dev version from npm latest dist-tag: " + latest); - } - - const runNumber = process.env.GITHUB_RUN_NUMBER; - const runAttempt = process.env.GITHUB_RUN_ATTEMPT ?? "1"; - const devVersion = [major, minor, patch + 1].join(".") + "-dev." + runNumber + "." + runAttempt; - - packageJson.version = devVersion; - fs.writeFileSync("package.json", JSON.stringify(packageJson, null, 2) + "\\n"); - fs.appendFileSync(process.env.GITHUB_ENV, "DEV_VERSION=" + devVersion + "\\n"); - NODE - - name: Test - run: yarn test - - name: Prepare package output - run: mkdir -p dist - - name: Pack - run: npm pack --pack-destination dist - - name: Publish - env: - NPM_CONFIG_PROVENANCE: "true" - run: npm publish dist/*.tgz --tag dev --access public release_please: runs-on: ubuntu-latest permissions: contents: write pull-requests: write - if: github.event_name != 'workflow_dispatch' || inputs.prerelease != 'dev' outputs: release_created: \${{ steps.release.outputs.release_created }} steps: From da0dd9bffa1bb0a5a4f2b65ad2a664c7b6ed5184 Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:39:17 -0400 Subject: [PATCH 04/11] ci: add release-please dev publishing line --- .github/workflows/dev-release.yml | 50 ++++++++++++++++++++ .projenrc.ts | 76 +++++++++++++++++++++++++++++++ release-please-config.dev.json | 14 ++++++ 3 files changed, 140 insertions(+) create mode 100644 .github/workflows/dev-release.yml create mode 100644 release-please-config.dev.json diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 0000000..5ccec96 --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,50 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". + +name: dev-release +on: + push: + branches: + - dev + workflow_dispatch: {} +jobs: + release_please_dev: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + steps: + - id: release + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.dev.json + manifest-file: .release-please-manifest.json + target-branch: dev + publish_npm_dev: + needs: release_please_dev + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + CI: "true" + if: ${{ needs.release_please_dev.outputs.release_created == 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Install Specific Yarn Version + run: corepack enable && corepack prepare yarn@4.6.0 --activate + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 24.11.1 + package-manager-cache: false + - name: Install dependencies + run: yarn install --immutable + - name: Test + run: yarn test + - name: Publish dev + env: + NPM_CONFIG_PROVENANCE: "true" + run: npm publish --tag dev --access public diff --git a/.projenrc.ts b/.projenrc.ts index 86eff9b..7fba977 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -276,3 +276,79 @@ jobs: fs.chmodSync(releaseWorkflowPath, 0o644); fs.writeFileSync(releaseWorkflowPath, releasePleaseWorkflow); fs.chmodSync(releaseWorkflowPath, 0o444); + +const devReleasePleaseConfigPath = "release-please-config.dev.json"; +const devReleasePleaseConfig = { + $schema: "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + packages: { + ".": { + "bump-patch-for-minor-pre-major": true, + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false, + "package-name": "@catenarycloud/linteffect", + prerelease: true, + "prerelease-type": "dev", + "release-type": "node", + }, + }, +}; + +fs.writeFileSync( + devReleasePleaseConfigPath, + `${JSON.stringify(devReleasePleaseConfig, null, 2)}\n`, +); + +const devReleaseWorkflowPath = ".github/workflows/dev-release.yml"; +const devReleaseWorkflow = `# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". + +name: dev-release +on: + push: + branches: + - dev + workflow_dispatch: {} +jobs: + release_please_dev: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: \${{ steps.release.outputs.release_created }} + steps: + - id: release + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.dev.json + manifest-file: .release-please-manifest.json + target-branch: dev + publish_npm_dev: + needs: release_please_dev + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + CI: "true" + if: \${{ needs.release_please_dev.outputs.release_created == 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Install Specific Yarn Version + run: corepack enable && corepack prepare yarn@${yarnVersion} --activate + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 24.11.1 + package-manager-cache: false + - name: Install dependencies + run: yarn install --immutable + - name: Test + run: yarn test + - name: Publish dev + env: + NPM_CONFIG_PROVENANCE: "true" + run: npm publish --tag dev --access public +`; + +fs.writeFileSync(devReleaseWorkflowPath, devReleaseWorkflow); diff --git a/release-please-config.dev.json b/release-please-config.dev.json new file mode 100644 index 0000000..b128817 --- /dev/null +++ b/release-please-config.dev.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "bump-patch-for-minor-pre-major": true, + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false, + "package-name": "@catenarycloud/linteffect", + "prerelease": true, + "prerelease-type": "dev", + "release-type": "node" + } + } +} From 956a92a7b12d0f79c099aa3b63dd19ad28650cb1 Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:42:45 -0400 Subject: [PATCH 05/11] ci: use release-please prerelease versioning for dev --- .projenrc.ts | 1 + release-please-config.dev.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.projenrc.ts b/.projenrc.ts index 7fba977..514d7e8 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -289,6 +289,7 @@ const devReleasePleaseConfig = { prerelease: true, "prerelease-type": "dev", "release-type": "node", + versioning: "prerelease", }, }, }; diff --git a/release-please-config.dev.json b/release-please-config.dev.json index b128817..3a0d5d2 100644 --- a/release-please-config.dev.json +++ b/release-please-config.dev.json @@ -8,7 +8,8 @@ "package-name": "@catenarycloud/linteffect", "prerelease": true, "prerelease-type": "dev", - "release-type": "node" + "release-type": "node", + "versioning": "prerelease" } } } From 371563055d7adfbcc5f8aa34fd0ccde80abaa29c Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:44:01 -0400 Subject: [PATCH 06/11] ci: start dev prereleases at dev.0 --- .projenrc.ts | 2 +- release-please-config.dev.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.projenrc.ts b/.projenrc.ts index 514d7e8..0e57198 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -287,7 +287,7 @@ const devReleasePleaseConfig = { "include-component-in-tag": false, "package-name": "@catenarycloud/linteffect", prerelease: true, - "prerelease-type": "dev", + "prerelease-type": "dev.0", "release-type": "node", versioning: "prerelease", }, diff --git a/release-please-config.dev.json b/release-please-config.dev.json index 3a0d5d2..8234ea5 100644 --- a/release-please-config.dev.json +++ b/release-please-config.dev.json @@ -7,7 +7,7 @@ "include-component-in-tag": false, "package-name": "@catenarycloud/linteffect", "prerelease": true, - "prerelease-type": "dev", + "prerelease-type": "dev.0", "release-type": "node", "versioning": "prerelease" } From 78f616b957508416cd9c79185553ba963b335cca Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:58:42 -0400 Subject: [PATCH 07/11] ci: run dev prerelease publishing from release workflow --- .github/workflows/dev-release.yml | 50 ---------------- .github/workflows/release.yml | 46 +++++++++++++++ .projenrc.ts | 97 ++++++++++++++----------------- 3 files changed, 90 insertions(+), 103 deletions(-) delete mode 100644 .github/workflows/dev-release.yml diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml deleted file mode 100644 index 5ccec96..0000000 --- a/.github/workflows/dev-release.yml +++ /dev/null @@ -1,50 +0,0 @@ -# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". - -name: dev-release -on: - push: - branches: - - dev - workflow_dispatch: {} -jobs: - release_please_dev: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - outputs: - release_created: ${{ steps.release.outputs.release_created }} - steps: - - id: release - uses: googleapis/release-please-action@v4 - with: - config-file: release-please-config.dev.json - manifest-file: .release-please-manifest.json - target-branch: dev - publish_npm_dev: - needs: release_please_dev - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - env: - CI: "true" - if: ${{ needs.release_please_dev.outputs.release_created == 'true' }} - steps: - - name: Checkout - uses: actions/checkout@v5 - - name: Install Specific Yarn Version - run: corepack enable && corepack prepare yarn@4.6.0 --activate - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version: 24.11.1 - package-manager-cache: false - - name: Install dependencies - run: yarn install --immutable - - name: Test - run: yarn test - - name: Publish dev - env: - NPM_CONFIG_PROVENANCE: "true" - run: npm publish --tag dev --access public diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdf76df..821c3c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,9 +5,13 @@ on: push: branches: - master + - dev workflow_dispatch: {} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: release_please: + if: github.ref_name == 'master' runs-on: ubuntu-latest permissions: contents: write @@ -20,6 +24,21 @@ jobs: with: config-file: release-please-config.json manifest-file: .release-please-manifest.json + release_please_dev: + if: github.ref_name == 'dev' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + steps: + - id: release + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.dev.json + manifest-file: .release-please-manifest.json + target-branch: dev publish_npm: needs: release_please runs-on: ubuntu-latest @@ -47,3 +66,30 @@ jobs: env: NPM_CONFIG_PROVENANCE: "true" run: npm publish --access public + publish_npm_dev: + needs: release_please_dev + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + env: + CI: "true" + if: ${{ needs.release_please_dev.outputs.release_created == 'true' }} + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Install Specific Yarn Version + run: corepack enable && corepack prepare yarn@4.6.0 --activate + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 24.11.1 + package-manager-cache: false + - name: Install dependencies + run: yarn install --immutable + - name: Test + run: yarn test + - name: Publish dev + env: + NPM_CONFIG_PROVENANCE: "true" + run: npm publish --tag dev --access public diff --git a/.projenrc.ts b/.projenrc.ts index 0e57198..6186f2b 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -229,9 +229,13 @@ on: push: branches: - master + - dev workflow_dispatch: {} +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: release_please: + if: github.ref_name == 'master' runs-on: ubuntu-latest permissions: contents: write @@ -244,6 +248,21 @@ jobs: with: config-file: release-please-config.json manifest-file: .release-please-manifest.json + release_please_dev: + if: github.ref_name == 'dev' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: \${{ steps.release.outputs.release_created }} + steps: + - id: release + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.dev.json + manifest-file: .release-please-manifest.json + target-branch: dev publish_npm: needs: release_please runs-on: ubuntu-latest @@ -271,58 +290,6 @@ jobs: env: NPM_CONFIG_PROVENANCE: "true" run: npm publish --access public -`; - -fs.chmodSync(releaseWorkflowPath, 0o644); -fs.writeFileSync(releaseWorkflowPath, releasePleaseWorkflow); -fs.chmodSync(releaseWorkflowPath, 0o444); - -const devReleasePleaseConfigPath = "release-please-config.dev.json"; -const devReleasePleaseConfig = { - $schema: "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - packages: { - ".": { - "bump-patch-for-minor-pre-major": true, - "changelog-path": "CHANGELOG.md", - "include-component-in-tag": false, - "package-name": "@catenarycloud/linteffect", - prerelease: true, - "prerelease-type": "dev.0", - "release-type": "node", - versioning: "prerelease", - }, - }, -}; - -fs.writeFileSync( - devReleasePleaseConfigPath, - `${JSON.stringify(devReleasePleaseConfig, null, 2)}\n`, -); - -const devReleaseWorkflowPath = ".github/workflows/dev-release.yml"; -const devReleaseWorkflow = `# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". - -name: dev-release -on: - push: - branches: - - dev - workflow_dispatch: {} -jobs: - release_please_dev: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - outputs: - release_created: \${{ steps.release.outputs.release_created }} - steps: - - id: release - uses: googleapis/release-please-action@v4 - with: - config-file: release-please-config.dev.json - manifest-file: .release-please-manifest.json - target-branch: dev publish_npm_dev: needs: release_please_dev runs-on: ubuntu-latest @@ -352,4 +319,28 @@ jobs: run: npm publish --tag dev --access public `; -fs.writeFileSync(devReleaseWorkflowPath, devReleaseWorkflow); +fs.chmodSync(releaseWorkflowPath, 0o644); +fs.writeFileSync(releaseWorkflowPath, releasePleaseWorkflow); +fs.chmodSync(releaseWorkflowPath, 0o444); + +const devReleasePleaseConfigPath = "release-please-config.dev.json"; +const devReleasePleaseConfig = { + $schema: "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + packages: { + ".": { + "bump-patch-for-minor-pre-major": true, + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false, + "package-name": "@catenarycloud/linteffect", + prerelease: true, + "prerelease-type": "dev.0", + "release-type": "node", + versioning: "prerelease", + }, + }, +}; + +fs.writeFileSync( + devReleasePleaseConfigPath, + `${JSON.stringify(devReleasePleaseConfig, null, 2)}\n`, +); From cde2cb9360fd21dbe6c726f92a6340ba285fa438 Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:00:17 -0400 Subject: [PATCH 08/11] ci: publish missing dev prereleases from release workflow --- .github/workflows/release.yml | 17 ++++++++++++++--- .projenrc.ts | 17 ++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 821c3c8..6896681 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: id-token: write env: CI: "true" - if: ${{ needs.release_please_dev.outputs.release_created == 'true' }} + if: github.ref_name == 'dev' steps: - name: Checkout uses: actions/checkout@v5 @@ -89,7 +89,18 @@ jobs: run: yarn install --immutable - name: Test run: yarn test - - name: Publish dev + - name: Publish dev if missing env: NPM_CONFIG_PROVENANCE: "true" - run: npm publish --tag dev --access public + run: |- + PACKAGE_NAME=$(node -p "require('./package.json').name") + PACKAGE_VERSION=$(node -p "require('./package.json').version") + case "$PACKAGE_VERSION" in + *-dev*) ;; + *) echo "Skipping non-dev version $PACKAGE_VERSION"; exit 0 ;; + esac + if npm view "$PACKAGE_NAME@$PACKAGE_VERSION" version >/dev/null 2>&1; then + echo "$PACKAGE_NAME@$PACKAGE_VERSION is already published" + exit 0 + fi + npm publish --tag dev --access public diff --git a/.projenrc.ts b/.projenrc.ts index 6186f2b..9013bd0 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -298,7 +298,7 @@ jobs: id-token: write env: CI: "true" - if: \${{ needs.release_please_dev.outputs.release_created == 'true' }} + if: github.ref_name == 'dev' steps: - name: Checkout uses: actions/checkout@v5 @@ -313,10 +313,21 @@ jobs: run: yarn install --immutable - name: Test run: yarn test - - name: Publish dev + - name: Publish dev if missing env: NPM_CONFIG_PROVENANCE: "true" - run: npm publish --tag dev --access public + run: |- + PACKAGE_NAME=$(node -p "require('./package.json').name") + PACKAGE_VERSION=$(node -p "require('./package.json').version") + case "$PACKAGE_VERSION" in + *-dev*) ;; + *) echo "Skipping non-dev version $PACKAGE_VERSION"; exit 0 ;; + esac + if npm view "$PACKAGE_NAME@$PACKAGE_VERSION" version >/dev/null 2>&1; then + echo "$PACKAGE_NAME@$PACKAGE_VERSION is already published" + exit 0 + fi + npm publish --tag dev --access public `; fs.chmodSync(releaseWorkflowPath, 0o644); From c0ed256cb1c101d5cbae16d0084ef1c5397d8b73 Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:01:43 -0400 Subject: [PATCH 09/11] ci: use node24 release-please action --- .github/workflows/release.yml | 4 ++-- .projenrc.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6896681..2805133 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: release_created: ${{ steps.release.outputs.release_created }} steps: - id: release - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@v5 with: config-file: release-please-config.json manifest-file: .release-please-manifest.json @@ -34,7 +34,7 @@ jobs: release_created: ${{ steps.release.outputs.release_created }} steps: - id: release - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@v5 with: config-file: release-please-config.dev.json manifest-file: .release-please-manifest.json diff --git a/.projenrc.ts b/.projenrc.ts index 9013bd0..1d21252 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -244,7 +244,7 @@ jobs: release_created: \${{ steps.release.outputs.release_created }} steps: - id: release - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@v5 with: config-file: release-please-config.json manifest-file: .release-please-manifest.json @@ -258,7 +258,7 @@ jobs: release_created: \${{ steps.release.outputs.release_created }} steps: - id: release - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@v5 with: config-file: release-please-config.dev.json manifest-file: .release-please-manifest.json From 7a2d048d043d209d8a31f90f2dcf38e9b12eaf24 Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:02:52 -0400 Subject: [PATCH 10/11] fix: increment dev prerelease suffixes --- .projenrc.ts | 2 +- release-please-config.dev.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.projenrc.ts b/.projenrc.ts index 1d21252..3e65877 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -344,7 +344,7 @@ const devReleasePleaseConfig = { "include-component-in-tag": false, "package-name": "@catenarycloud/linteffect", prerelease: true, - "prerelease-type": "dev.0", + "prerelease-type": "dev", "release-type": "node", versioning: "prerelease", }, diff --git a/release-please-config.dev.json b/release-please-config.dev.json index 8234ea5..3a0d5d2 100644 --- a/release-please-config.dev.json +++ b/release-please-config.dev.json @@ -7,7 +7,7 @@ "include-component-in-tag": false, "package-name": "@catenarycloud/linteffect", "prerelease": true, - "prerelease-type": "dev.0", + "prerelease-type": "dev", "release-type": "node", "versioning": "prerelease" } From 7a2dec71619d9bb4b2ff699e36d7a592818c11ed Mon Sep 17 00:00:00 2001 From: Roman Naumenko <62673610+OperationalFallacy@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:56:45 -0400 Subject: [PATCH 11/11] feat: calibrate defragmentation guardrails --- configs/core.jsonc | 1 + configs/full.jsonc | 1 + rules/no-fragmented-const-assembly.grit | 26 ++---- rules/no-pipeline-fragment-staging.grit | 11 +++ .../defragmentation/invalid-assembly-chain.ts | 11 +-- .../invalid-constructor-fragment.ts | 29 +++++++ .../invalid-direct-const-fragment.ts | 18 ++--- .../invalid-pipeline-fragment-return.ts | 26 ++++++ ...alid-atom-projection-and-error-adapters.ts | 73 +++++++++++++++++ ...lid-nested-render-callback-composition.tsx | 38 +++++++++ .../valid-pipeline-final-domain-value.ts | 26 ++++++ .../valid-react-presentation-composition.tsx | 81 +++++++++++++++++++ .../no-fragmented-const-assembly.test.ts | 26 ++++++ .../no-pipeline-fragment-staging.test.ts | 78 ++++++++++++++++++ 14 files changed, 407 insertions(+), 38 deletions(-) create mode 100644 rules/no-pipeline-fragment-staging.grit create mode 100644 tests/fixtures/defragmentation/invalid-constructor-fragment.ts create mode 100644 tests/fixtures/defragmentation/invalid-pipeline-fragment-return.ts create mode 100644 tests/fixtures/defragmentation/valid-atom-projection-and-error-adapters.ts create mode 100644 tests/fixtures/defragmentation/valid-nested-render-callback-composition.tsx create mode 100644 tests/fixtures/defragmentation/valid-pipeline-final-domain-value.ts create mode 100644 tests/fixtures/defragmentation/valid-react-presentation-composition.tsx create mode 100644 tests/rules/no-pipeline-fragment-staging.test.ts diff --git a/configs/core.jsonc b/configs/core.jsonc index 4a42180..b1fb32e 100644 --- a/configs/core.jsonc +++ b/configs/core.jsonc @@ -32,6 +32,7 @@ "./node_modules/@catenarycloud/linteffect/rules/no-effect-all-step-sequencing.grit", "./node_modules/@catenarycloud/linteffect/rules/no-effect-step-const-staging.grit", "./node_modules/@catenarycloud/linteffect/rules/no-fragmented-const-assembly.grit", + "./node_modules/@catenarycloud/linteffect/rules/no-pipeline-fragment-staging.grit", "./node_modules/@catenarycloud/linteffect/rules/no-effect-orElse-ladder.grit", "./node_modules/@catenarycloud/linteffect/rules/no-return-in-callback.grit", "./node_modules/@catenarycloud/linteffect/rules/no-try-catch.grit", diff --git a/configs/full.jsonc b/configs/full.jsonc index b35e67e..38b18bf 100644 --- a/configs/full.jsonc +++ b/configs/full.jsonc @@ -36,6 +36,7 @@ "./node_modules/@catenarycloud/linteffect/rules/no-effect-all-step-sequencing.grit", "./node_modules/@catenarycloud/linteffect/rules/no-effect-step-const-staging.grit", "./node_modules/@catenarycloud/linteffect/rules/no-fragmented-const-assembly.grit", + "./node_modules/@catenarycloud/linteffect/rules/no-pipeline-fragment-staging.grit", "./node_modules/@catenarycloud/linteffect/rules/no-effect-orElse-ladder.grit", "./node_modules/@catenarycloud/linteffect/rules/no-render-side-effects.grit", "./node_modules/@catenarycloud/linteffect/rules/no-naked-object-state-update.grit", diff --git a/rules/no-fragmented-const-assembly.grit b/rules/no-fragmented-const-assembly.grit index 0dbc87a..afae24f 100644 --- a/rules/no-fragmented-const-assembly.grit +++ b/rules/no-fragmented-const-assembly.grit @@ -1,27 +1,11 @@ language js(typescript, jsx) -JsVariableStatement() as $statement where { - $statement <: contains `($params) => $expr`, - not $statement <: contains `S.Struct($schemaArgs)`, - not $statement <: contains `S.Literal($schemaArgs)`, - not $statement <: contains `S.Union($schemaArgs)`, - not $statement <: contains `S.Number.pipe($schemaArgs)`, - not $statement <: contains `Schema.Struct($schemaArgs)`, - not $statement <: contains `Schema.Literal($schemaArgs)`, - not $statement <: contains `Schema.Union($schemaArgs)`, - not $statement <: contains `Schema.Number.pipe($schemaArgs)`, - not $statement <: contains `Data.taggedEnum($args)`, - not $statement <: contains `Data.taggedEnum<$type>()`, - or { - $statement <: contains `({ $props })`, - $statement <: contains `$receiver.pipe($steps)`, - $statement <: contains `$constructor({ $props })`, - $statement <: contains `$namespace.$method($args)`, - $statement <: r"Match[.]value|Option[.]match|Either[.]match|[$]match|[.]pipe[(]" - }, +JsObjectExpression(members = $members) as $assembly where { + $members <: contains JsSpread(argument = $argument), + $argument <: `$fragment($args)`, register_diagnostic( - span = $statement, - message = "Rule: avoid fragmented const assembly chains. Why this is wrong: this const function pre-assembles an object/branch/pipeline fragment outside the operation that uses it, so the reader must chase staged pieces to understand the contract. Safe remediation: do not create another wrapper. Move this assembly back into the service/API operation that emits it, keep validation -> decision -> object/Effect construction in one visible block, bind only the final domain-shaped value with an explicit return/domain type, then return/use that value. Only keep a helper if it is a stable domain constructor used by multiple operations and its signature names the domain contract.", + span = $assembly, + message = "Rule: avoid fragmented const assembly chains. Why this is wrong: this object contract is assembled from a helper-produced fragment, so the reader must chase staged pieces to understand the final payload. Safe remediation: keep validation, decision, and final object construction in one visible block, or promote a stable domain constructor whose signature names the complete contract.", severity = "error" ) } diff --git a/rules/no-pipeline-fragment-staging.grit b/rules/no-pipeline-fragment-staging.grit new file mode 100644 index 0000000..2c76fec --- /dev/null +++ b/rules/no-pipeline-fragment-staging.grit @@ -0,0 +1,11 @@ +language js(typescript, jsx) + +JsFunctionBody(statements = $statements) as $block where { + $statements <: contains `const $fragment = pipe($producerArgs)`, + $statements <: contains `return pipe($fragment, $...)`, + register_diagnostic( + span = $block, + message = "Rule: avoid pipeline fragment staging. Why this is wrong: this operation stages an intermediate pipeline fragment and then returns another pipeline that consumes it, so the reader must chase names to understand the final flow. Safe remediation: keep the producer and final selection in one visible pipeline, or promote a complete named domain operation.", + severity = "error" + ) +} diff --git a/tests/fixtures/defragmentation/invalid-assembly-chain.ts b/tests/fixtures/defragmentation/invalid-assembly-chain.ts index 53bd867..70b3951 100644 --- a/tests/fixtures/defragmentation/invalid-assembly-chain.ts +++ b/tests/fixtures/defragmentation/invalid-assembly-chain.ts @@ -8,15 +8,10 @@ type FailureKind = Data.TaggedEnum<{ // assembly fragments, so this const is intentionally allowed. const FailureKind = Data.taggedEnum(); -// Derailment level 1: a loose named fragment is extracted before there is a -// meaningful domain value. -const errorFieldName = "errorMessage"; - -// Derailment level 2: this helper const assembles a partial object from the -// loose fragment above. It is not a standalone domain concept; it is a staged -// piece of the final API error shape. +// Derailment level 1: this helper const assembles a partial object. It is not a +// standalone domain concept; it is a staged piece of the final API error shape. const apiErrorFields = (message: string) => ({ - [errorFieldName]: message, + errorMessage: message, }); // Derailment: the exported object builder now assembles the final contract from diff --git a/tests/fixtures/defragmentation/invalid-constructor-fragment.ts b/tests/fixtures/defragmentation/invalid-constructor-fragment.ts new file mode 100644 index 0000000..bf2eba8 --- /dev/null +++ b/tests/fixtures/defragmentation/invalid-constructor-fragment.ts @@ -0,0 +1,29 @@ +class BoundaryError extends Error { + readonly reason: string; + readonly cause: unknown; + + constructor(input: { + readonly reason: string; + readonly parameterName: string; + readonly cause: unknown; + }) { + super(input.reason); + this.reason = input.reason; + this.cause = input.cause; + } +} + +// Derailment: this helper returns a partial constructor payload instead of a +// named complete error contract. +const errorFields = (cause: unknown) => ({ + reason: "Failed to load config", + cause, +}); + +// Derailment: the constructor payload is split across a helper fragment and the +// final call site. +export const toBoundaryError = (parameterName: string, cause: unknown) => + new BoundaryError({ + parameterName, + ...errorFields(cause), + }); diff --git a/tests/fixtures/defragmentation/invalid-direct-const-fragment.ts b/tests/fixtures/defragmentation/invalid-direct-const-fragment.ts index 15733f7..8ee66e3 100644 --- a/tests/fixtures/defragmentation/invalid-direct-const-fragment.ts +++ b/tests/fixtures/defragmentation/invalid-direct-const-fragment.ts @@ -8,15 +8,15 @@ type FailureKind = Data.TaggedEnum<{ // assembly fragments, so this const is intentionally allowed. const FailureKind = Data.taggedEnum(); -// Derailment: a standalone field fragment is later pulled into an object -// builder const. The contract shape is no longer local to the operation that -// emits it. -const errorFieldName = "errorMessage"; +// Derailment: this helper returns a partial object fragment rather than the +// final API error contract. +const apiErrorFields = (error: { readonly message: string }) => ({ + message: error.message, +}); -// Derailment: this module-level const function assembles its return object from -// the named fragment above. Keep the field choice in the same local operation -// block, or promote a real typed domain constructor. +// Derailment: the exported object builder assembles the final contract by +// spreading a helper-produced fragment. const apiErrorObject = (error: { readonly message: string }) => ({ - field: errorFieldName, - message: error.message, + errorType: "BoundaryFailure", + ...apiErrorFields(error), }); diff --git a/tests/fixtures/defragmentation/invalid-pipeline-fragment-return.ts b/tests/fixtures/defragmentation/invalid-pipeline-fragment-return.ts new file mode 100644 index 0000000..7684cc2 --- /dev/null +++ b/tests/fixtures/defragmentation/invalid-pipeline-fragment-return.ts @@ -0,0 +1,26 @@ +import { Option, pipe } from "effect"; + +// Derailment: query and path extraction are staged as local pipeline fragments, +// then the returned pipeline consumes those fragments to assemble the final +// route symbol flow. +export const resolveRouteSymbol = ( + querySymbol: string | readonly string[] | undefined, + asPath: string, +): Option.Option => { + const symbolFromQuery = pipe( + Option.fromNullable(querySymbol), + Option.filter((value): value is string => typeof value === "string"), + ); + const symbolFromPath = pipe( + Option.fromNullable(asPath.split("?")[0]), + Option.flatMap((path) => + Option.fromNullable(path.match(/^\/securities\/([^/]+)$/)?.[1]), + ), + ); + + return pipe( + symbolFromQuery, + Option.orElse(() => symbolFromPath), + Option.map((value) => decodeURIComponent(value).toUpperCase()), + ); +}; diff --git a/tests/fixtures/defragmentation/valid-atom-projection-and-error-adapters.ts b/tests/fixtures/defragmentation/valid-atom-projection-and-error-adapters.ts new file mode 100644 index 0000000..6007fb4 --- /dev/null +++ b/tests/fixtures/defragmentation/valid-atom-projection-and-error-adapters.ts @@ -0,0 +1,73 @@ +import { Atom, Result } from "@effect-atom/atom-react"; +import { Data, Match, Option } from "effect"; +import * as A from "effect/Array"; + +type Row = { + readonly key: string; + readonly category: string; +}; + +type Collection = { + readonly keys: ReadonlyArray; +}; + +type Projection = { + readonly rows: ReadonlyArray; +}; + +declare const rowAtom: (key: string) => Atom.Atom>; +declare const collectionAtom: Atom.Atom>; + +// Proper usage: this named projection helper traverses keyed atom Results and +// returns a complete domain projection value. +const successfulRows = ( + collection: Collection, + get: Atom.Context, +): ReadonlyArray => + A.filterMap(collection.keys, (key) => + Result.match(get(rowAtom(key)), { + onInitial: () => Option.none(), + onFailure: () => Option.none(), + onSuccess: (success) => Option.some(success.value), + }), + ); + +// Proper usage: this atom projection returns the final projection shape directly. +export const visibleRowsAtom = Atom.make((get) => + Result.map( + get(collectionAtom), + (collection): Projection => ({ + rows: successfulRows(collection, get).filter( + (row) => row.category !== "archived", + ), + }), + ), +); + +class ConfigError extends Data.TaggedError("ConfigError")<{ + readonly reason: string; + readonly parameterName: string; + readonly cause?: unknown; +}> {} + +// Proper usage: this constructs one declared tagged error contract directly. +const toConfigError = + (parameterName: string, reason: string) => + (cause?: unknown): ConfigError => + new ConfigError({ + reason, + parameterName, + cause, + }); + +// Proper usage: this normalizes unknown causes into the declared error contract. +export const ensureConfigError = + (parameterName: string, reason: string) => + (cause: unknown): ConfigError => + Match.value(cause).pipe( + Match.when( + (error: unknown): error is ConfigError => error instanceof ConfigError, + (error) => error, + ), + Match.orElse((error) => toConfigError(parameterName, reason)(error)), + ); diff --git a/tests/fixtures/defragmentation/valid-nested-render-callback-composition.tsx b/tests/fixtures/defragmentation/valid-nested-render-callback-composition.tsx new file mode 100644 index 0000000..8a16350 --- /dev/null +++ b/tests/fixtures/defragmentation/valid-nested-render-callback-composition.tsx @@ -0,0 +1,38 @@ +import { Match, Option, Result } from "effect"; + +type Item = { + readonly id: string; +}; + +type LoadState = Result.Result< + { + readonly items: readonly Item[]; + readonly waiting: boolean; + }, + Error +>; + +export function ItemPanel(props: { readonly loadState: LoadState; readonly id: string }) { + const content = Result.match(props.loadState, { + onFailure: () =>
Unable to load item
, + onInitial: () =>
Loading item
, + onSuccess(success) { + const isWaiting = success.waiting; + const itemOption = Option.fromNullable( + success.items.find((item) => item.id === props.id), + ); + const missingItemView =
Item record not found
; + + return Option.match(itemOption, { + onNone: () => + Match.value(isWaiting).pipe( + Match.when(true, () =>
Loading item
), + Match.orElse(() => missingItemView), + ), + onSome: (item) =>
{item.id}
, + }); + }, + }); + + return content; +} diff --git a/tests/fixtures/defragmentation/valid-pipeline-final-domain-value.ts b/tests/fixtures/defragmentation/valid-pipeline-final-domain-value.ts new file mode 100644 index 0000000..e4bbcad --- /dev/null +++ b/tests/fixtures/defragmentation/valid-pipeline-final-domain-value.ts @@ -0,0 +1,26 @@ +import { Option, pipe } from "effect"; + +type RouteParts = { + readonly path: string; + readonly query: string | readonly string[] | undefined; +}; + +const routeParts = ( + querySymbol: string | readonly string[] | undefined, + asPath: string, +): RouteParts => ({ + path: asPath.split("?")[0] ?? "", + query: querySymbol, +}); + +// Proper usage: the returned pipeline stays continuous and does not consume a +// locally staged pipeline fragment. +export const resolveRouteSymbol = ( + querySymbol: string | readonly string[] | undefined, + asPath: string, +): Option.Option => + pipe( + Option.fromNullable(routeParts(querySymbol, asPath).query), + Option.filter((value): value is string => typeof value === "string"), + Option.map((value) => decodeURIComponent(value).toUpperCase()), + ); diff --git a/tests/fixtures/defragmentation/valid-react-presentation-composition.tsx b/tests/fixtures/defragmentation/valid-react-presentation-composition.tsx new file mode 100644 index 0000000..85778a4 --- /dev/null +++ b/tests/fixtures/defragmentation/valid-react-presentation-composition.tsx @@ -0,0 +1,81 @@ +import { Option, Result } from "effect"; + +type TaskState = "PENDING" | "WORKING" | "FAILED"; + +type TaskView = { + readonly state: TaskState; + readonly failureMessage: string | null; + readonly id: string; +}; + +type TaskPresentation = { + readonly label: string; + readonly message: Option.Option; +}; + +type TaskPresenter = (task: TaskView) => TaskPresentation; + +declare const pendingResult: Result.Result, Error>; +declare const readAtom: (key: string) => boolean; +declare const approveAtom: (key: string) => () => void; + +// Proper usage: a small message adapter returns a primitive presentation value. +const taskFailureMessage = (task: TaskView) => + Option.match(Option.fromNullable(task.failureMessage), { + onNone: () => "No failure reason returned.", + onSome: (message) => message, + }); + +// Proper usage: this table declares the stable UI state mapping in one place. +const taskPresenters = { + PENDING: () => ({ + label: "Pending", + message: Option.some("Waiting for confirmation."), + }), + WORKING: () => ({ + label: "Working", + message: Option.none(), + }), + FAILED: (task) => ({ + label: "Failed", + message: Option.some(taskFailureMessage(task)), + }), +} satisfies Record; + +const taskPresentation = (task: TaskView): TaskPresentation => + taskPresenters[task.state](task); + +export function TaskControl({ fallbackId }: { readonly fallbackId: string }) { + // Proper usage: React hook order can require a decoded key before later hooks. + const taskKey = Result.match(pendingResult, { + onFailure: () => fallbackId, + onSuccess: (taskOption) => + Option.match(taskOption, { + onNone: () => fallbackId, + onSome: (task) => task.id, + }), + }); + const submitting = readAtom(taskKey); + const approve = approveAtom(taskKey); + + // Proper usage: a local render helper keeps conditional JSX readable. + const renderApprovalAction = () => + Option.match(Option.some(submitting), { + onNone: () => null, + onSome: (isSubmitting) => ( + + ), + }); + + return ( +
+ {Option.match(Result.getOrElse(pendingResult, () => Option.none()), { + onNone: () => null, + onSome: (task) => taskPresentation(task).label, + })} + {renderApprovalAction()} +
+ ); +} diff --git a/tests/rules/no-fragmented-const-assembly.test.ts b/tests/rules/no-fragmented-const-assembly.test.ts index 2b19be8..7bd2cc7 100644 --- a/tests/rules/no-fragmented-const-assembly.test.ts +++ b/tests/rules/no-fragmented-const-assembly.test.ts @@ -67,9 +67,35 @@ describe("no-fragmented-const-assembly", () => { expect(result.output).toContain(diagnosticMessage); }); + it("It catches helper fragments spread into constructor payloads", () => { + const result = lintWithRule( + path.join(fixtureRoot, "invalid-constructor-fragment.ts"), + ); + + expect(result.status).toBe(1); + expect(result.output).toContain(diagnosticMessage); + }); + it("It allows Data tagged enum declarations and explicit final object contracts", () => { const result = lintWithRule(path.join(fixtureRoot, "valid-const-assembly.ts")); expect(result.status).toBe(0); }); + + it("It allows React presentation tables, hook key derivation, and local JSX render helpers", () => { + const result = lintWithRule( + path.join(fixtureRoot, "valid-react-presentation-composition.tsx"), + ); + + expect(result.status).toBe(0); + }); + + it("It allows atom projection helpers and stable service error adapters", () => { + const result = lintWithRule( + path.join(fixtureRoot, "valid-atom-projection-and-error-adapters.ts"), + ); + + expect(result.status).toBe(0); + }); + }); diff --git a/tests/rules/no-pipeline-fragment-staging.test.ts b/tests/rules/no-pipeline-fragment-staging.test.ts new file mode 100644 index 0000000..c5edfe5 --- /dev/null +++ b/tests/rules/no-pipeline-fragment-staging.test.ts @@ -0,0 +1,78 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const fixtureRoot = path.join(repoRoot, "tests", "fixtures", "defragmentation"); + +function resolveBiomeBin() { + const biomePackagePath = require.resolve("@biomejs/biome/package.json"); + const biomePackage = require(biomePackagePath); + const biomeBinRelative = + typeof biomePackage.bin === "string" ? biomePackage.bin : biomePackage.bin.biome; + return path.resolve(path.dirname(biomePackagePath), biomeBinRelative); +} + +function lintWithRule(fixtureFile: string) { + const tempDir = mkdtempSync(path.join(tmpdir(), "linteffect-rule-test-")); + const configPath = path.join(tempDir, "biome.json"); + const rulePath = path.join(repoRoot, "rules", "no-pipeline-fragment-staging.grit"); + + writeFileSync(configPath, `${JSON.stringify({ plugins: [rulePath] }, null, 2)}\n`, "utf8"); + + try { + const result = spawnSync( + process.execPath, + [ + resolveBiomeBin(), + "lint", + "--reporter=json", + "--max-diagnostics=none", + `--config-path=${configPath}`, + fixtureFile, + ], + { cwd: repoRoot, encoding: "utf8" }, + ); + + return { + status: result.status ?? 1, + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + }; + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } +} + +const diagnosticMessage = "Rule: avoid pipeline fragment staging."; + +describe("no-pipeline-fragment-staging", () => { + it("It catches local pipeline fragments consumed by returned pipelines", () => { + const result = lintWithRule( + path.join(fixtureRoot, "invalid-pipeline-fragment-return.ts"), + ); + + expect(result.status).toBe(1); + expect(result.output).toContain(diagnosticMessage); + }); + + it("It allows final domain values before continuous returned pipelines", () => { + const result = lintWithRule( + path.join(fixtureRoot, "valid-pipeline-final-domain-value.ts"), + ); + + expect(result.status).toBe(0); + }); + + it("It allows nested render callback composition inside result matching", () => { + const result = lintWithRule( + path.join(fixtureRoot, "valid-nested-render-callback-composition.tsx"), + ); + + expect(result.status).toBe(0); + }); +});