Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion .github/workflows/release.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 80 additions & 1 deletion .projenrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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`,
);
3 changes: 3 additions & 0 deletions configs/core.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions configs/full.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions release-please-config.dev.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
18 changes: 18 additions & 0 deletions rules/no-effect-step-const-staging.grit
Original file line number Diff line number Diff line change
@@ -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"
)
}
11 changes: 11 additions & 0 deletions rules/no-fragmented-const-assembly.grit
Original file line number Diff line number Diff line change
@@ -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"
)
}
11 changes: 11 additions & 0 deletions rules/no-pipeline-fragment-staging.grit
Original file line number Diff line number Diff line change
@@ -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"
)
}
23 changes: 23 additions & 0 deletions tests/fixtures/defragmentation/invalid-assembly-chain.ts
Original file line number Diff line number Diff line change
@@ -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<FailureKind>();

// 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])),
});
29 changes: 29 additions & 0 deletions tests/fixtures/defragmentation/invalid-constructor-fragment.ts
Original file line number Diff line number Diff line change
@@ -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),
});
22 changes: 22 additions & 0 deletions tests/fixtures/defragmentation/invalid-direct-const-fragment.ts
Original file line number Diff line number Diff line change
@@ -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<FailureKind>();

// 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),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Effect } from "effect";

type PolicyResult = {
readonly decision: "ALLOW" | "DENY";
};

export const evaluatePolicy = (
policyText: string,
): Effect.Effect<PolicyResult, Error> => {
// 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;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Effect, pipe } from "effect";

type PolicyResult = {
readonly decision: "ALLOW" | "DENY";
};

export const evaluatePolicy = (
policyText: string,
): Effect.Effect<PolicyResult, Error> => {
// 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;
};
Loading