diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdf76df..2805133 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 @@ -16,10 +20,25 @@ 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 + 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@v5 + 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,41 @@ 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: github.ref_name == '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: Test + run: yarn test + - name: Publish dev if missing + env: + NPM_CONFIG_PROVENANCE: "true" + 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 86eff9b..3e65877 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 @@ -240,10 +244,25 @@ 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 + 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@v5 + 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,8 +290,68 @@ 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: github.ref_name == '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: Test + run: yarn test + - name: Publish dev if missing + env: + NPM_CONFIG_PROVENANCE: "true" + 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); 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", + versioning: "prerelease", + }, + }, +}; + +fs.writeFileSync( + devReleasePleaseConfigPath, + `${JSON.stringify(devReleasePleaseConfig, null, 2)}\n`, +); diff --git a/configs/core.jsonc b/configs/core.jsonc index 7b96efc..b1fb32e 100644 --- a/configs/core.jsonc +++ b/configs/core.jsonc @@ -30,6 +30,9 @@ "./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-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 648abff..38b18bf 100644 --- a/configs/full.jsonc +++ b/configs/full.jsonc @@ -34,6 +34,9 @@ "./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-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/release-please-config.dev.json b/release-please-config.dev.json new file mode 100644 index 0000000..3a0d5d2 --- /dev/null +++ b/release-please-config.dev.json @@ -0,0 +1,15 @@ +{ + "$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", + "versioning": "prerelease" + } + } +} 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..afae24f --- /dev/null +++ b/rules/no-fragmented-const-assembly.grit @@ -0,0 +1,11 @@ +language js(typescript, jsx) + +JsObjectExpression(members = $members) as $assembly where { + $members <: contains JsSpread(argument = $argument), + $argument <: `$fragment($args)`, + register_diagnostic( + 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 new file mode 100644 index 0000000..70b3951 --- /dev/null +++ b/tests/fixtures/defragmentation/invalid-assembly-chain.ts @@ -0,0 +1,23 @@ +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: 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) => ({ + errorMessage: 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-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 new file mode 100644 index 0000000..8ee66e3 --- /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: this helper returns a partial object fragment rather than the +// final API error contract. +const apiErrorFields = (error: { readonly message: string }) => ({ + message: error.message, +}); + +// Derailment: the exported object builder assembles the final contract by +// spreading a helper-produced fragment. +const apiErrorObject = (error: { readonly message: string }) => ({ + errorType: "BoundaryFailure", + ...apiErrorFields(error), +}); 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/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-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/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-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..7bd2cc7 --- /dev/null +++ b/tests/rules/no-fragmented-const-assembly.test.ts @@ -0,0 +1,101 @@ +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 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); + }); +});