Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
{ "name": "anti-slop", "specifier": "./tools/oxlint/anti-slop/index.ts" }
],
"rules": {
"anti-slop/no-array-filter-map": "error",
"anti-slop/no-chained-type-assertions": "error",
"anti-slop/no-conditional-empty-object-spread": "error",
"anti-slop/no-known-value-widening": "error",
"anti-slop/no-module-mocking": "error",
"anti-slop/no-object-parameters": "error",
"anti-slop/no-reduce-accumulator-copy": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-reflect-get": "error",
"anti-slop/no-runtime-typeof": "error",
Expand All @@ -33,6 +35,8 @@
"anti-slop/no-unknown-type-aliases": "error",
"anti-slop/no-unsafe-dictionary-type": "error",
"anti-slop/no-widen-then-assert": "error",
"anti-slop/require-safety-comment-for-type-assertion": "error"
"anti-slop/require-readable-spacing": "error",
"anti-slop/require-safety-comment-for-type-assertion": "error",
"oxc/no-accumulating-spread": "error"
}
}
6 changes: 6 additions & 0 deletions build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@ import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"

const root = dirname(fileURLToPath(import.meta.url))

const source = join(root, "tps.tsx")

const outDir = join(root, "dist")

const out = join(outDir, "tui.js")

const code = await readFile(source, "utf8")

const result = await transformAsync(code, {
filename: source,
configFile: false,
Expand All @@ -40,5 +44,7 @@ if (!result?.code) throw new Error("babel produced no output")
const output = `${result.code}\n`

await mkdir(outDir, { recursive: true })

await writeFile(out, output, "utf8")

console.log(`built ${out} (${Buffer.byteLength(output)} bytes)`)
6 changes: 6 additions & 0 deletions check-compatibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,25 @@ import { readFileSync } from "node:fs"
import { resolve } from "node:path"

const packageJson = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"))

const packages = ["@opencode/cli", "@opencode/plugin", "@opencode/theme"]

const versions = packages.map((name) => packageJson.devDependencies[name])

const version = versions[0]

if (!/^0\.0\.0-beta-\d{5,6}$/.test(version)) {
throw new Error(`OpenCode 2 compatibility version has an unexpected format: ${version}`)
}

if (!versions.every((candidate) => candidate === version)) {
throw new Error(`OpenCode 2 packages must use one exact version: ${versions.join(", ")}`)
}

const executable = resolve("node_modules", ".bin", process.platform === "win32" ? "opencode2.cmd" : "opencode2")

const reported = execFileSync(executable, ["--version"], { encoding: "utf8" }).trim()

if (reported !== `opencode2 v${version}`) {
throw new Error(`Expected opencode2 v${version}, got ${reported}`)
}
Expand Down
12 changes: 12 additions & 0 deletions entrypoint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { Plugin } from "@opencode/plugin/tui"
import type { TpsOptionsInput } from "./tps.tsx"

const root = fileURLToPath(new URL(".", import.meta.url))

const distEntry = new URL("./dist/tui.js", import.meta.url).href

// 95 bytes / 4.75 bytes-per-token = 20 estimated tokens.
Expand All @@ -26,6 +27,7 @@ beforeAll(async () => {
// — so every run tests a fresh dist/tui.js built exactly as the package
// ships, even from a clean checkout and across watch-mode reruns.
const build = spawnSync("node", ["build.mjs"], { cwd: root, encoding: "utf8" })

if (build.status !== 0) throw new Error(`node build.mjs failed:\n${build.stderr || build.stdout}`)
plugin = (await import(distEntry)).default
})
Expand Down Expand Up @@ -81,6 +83,7 @@ function start(context: FakeContext): (() => void) | void {
// a production cast.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions
const setup = plugin.setup as unknown as FakeSetup

return setup(context)
}

Expand Down Expand Up @@ -109,8 +112,10 @@ function createHarness(options: TpsOptionsInput = {}): Harness {
// without leaving a live interval behind.
const handle = realSetInterval(() => {}, 60_000)
realClearInterval(handle)

return handle
}

globalThis.clearInterval = () => {
flush = undefined
}
Expand All @@ -127,12 +132,14 @@ function createHarness(options: TpsOptionsInput = {}): Harness {
const list = handlers.get(type) ?? []
list.push(handler)
handlers.set(type, list)

return () => handlers.delete(type)
},
},
ui: {
slot: (claim: Claim) => {
claims.push(claim)

return () => {}
},
},
Expand Down Expand Up @@ -165,6 +172,7 @@ describe("built entrypoint", () => {
test("renders the live label through text, reasoning, and tool-input streaming", async () => {
const h = createHarness()
const app = await openApp(h, "ses_test")

try {
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("tok")
Expand Down Expand Up @@ -198,6 +206,7 @@ describe("built entrypoint", () => {
test("settles exactly and freezes after completion", async () => {
const h = createHarness()
const app = await openApp(h, "ses_test")

try {
const t0 = Date.now()
h.emit("session.execution.started", { sessionID: "ses_test" }, t0)
Expand Down Expand Up @@ -236,6 +245,7 @@ describe("built entrypoint", () => {
test("resets the figure when a new prompt starts", async () => {
const h = createHarness()
const app = await openApp(h, "ses_test")

try {
const t0 = Date.now()
h.emit("session.execution.started", { sessionID: "ses_test" }, t0)
Expand Down Expand Up @@ -269,6 +279,7 @@ describe("built entrypoint", () => {

test("keeps orchestrator and sub-agent sessions independent", async () => {
const h = createHarness()

const app = await testRender(
() => (
<box flexDirection="column">
Expand All @@ -278,6 +289,7 @@ describe("built entrypoint", () => {
),
{ width: 60, height: 8 },
)

try {
const now = Date.now()
h.emit("session.execution.started", { sessionID: "ses_sub" }, now)
Expand Down
13 changes: 13 additions & 0 deletions tools/oxlint/anti-slop/UPSTREAM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Vendored anti-slop plugin

Source: [dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop), path `skills/install-anti-slop/assets/anti-slop`.

- Previous baseline: `e8100a10da49858cfa8d26883d170e9cc8281988`
- Current baseline: `c44ef22ca116d0ba62a3ff663a0bd13a3f3fa40b`
- Adoption: complete incoming snapshot; no local rule modifications.

This directory matches the upstream snapshot above except for this file. Rule policy (enabled rules and severities) lives in the repository root `.oxlintrc.json`, not here.

The Effect plugin source is copied but intentionally not registered: the repository has no direct `effect` dependency, so `anti-slop-effect` is absent from `jsPlugins`. The bundled `vendor/eslint-stylistic/` code carries its own provenance and license in `vendor/eslint-stylistic/UPSTREAM.md`.

To update: stage a new upstream revision beside the live tree, diff against the current baseline, apply reviewed changes, and update this record.
21 changes: 21 additions & 0 deletions tools/oxlint/anti-slop/effect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { eslintCompatPlugin } from "@oxlint/plugins";

import { noManualEffectErrorTagRule } from "./rules/no-manual-effect-error-tag.ts";
import { noManualTagComparisonRule } from "./rules/no-manual-tag-comparison.ts";
import { noManualTaggedConstructionRule } from "./rules/no-manual-tagged-construction.ts";
import { noServiceConstructorImportsRule } from "./rules/no-service-constructor-imports.ts";
import { preferEffectMatchRule } from "./rules/prefer-effect-match.ts";

/** Opt-in Oxlint rules for Effect service and Layer architecture. */
const antiSlopEffectPlugin = eslintCompatPlugin({
meta: { name: "anti-slop-effect" },
rules: {
"no-manual-effect-error-tag": noManualEffectErrorTagRule,
"no-manual-tag-comparison": noManualTagComparisonRule,
"no-manual-tagged-construction": noManualTaggedConstructionRule,
"no-service-constructor-imports": noServiceConstructorImportsRule,
"prefer-effect-match": preferEffectMatchRule,
},
});

export default antiSlopEffectPlugin;
52 changes: 52 additions & 0 deletions tools/oxlint/anti-slop/effect/rules/no-manual-effect-error-tag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { defineRule } from "@oxlint/plugins";

import {
isInsideBroadEffectHandler,
isReasonTagMember,
isTagMember,
tagMemberFromComparison,
} from "../shared/tagged-values.ts";

export const noManualEffectErrorTagRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Use Effect tagged error handlers instead of manually branching on `_tag` in a catch handler.",
},
messages: {
tag: "Use Effect.catchTag or Effect.catchTags instead of manually discriminating a tagged error in a broad Effect catch handler.",
reason:
"Use Effect.catchReason or Effect.catchReasons instead of manually discriminating a tagged `reason` in a broad Effect catch handler.",
},
},
createOnce(context) {
return {
BinaryExpression(node) {
const tagMember = tagMemberFromComparison(node);
if (
tagMember === undefined ||
!isInsideBroadEffectHandler(node)
) {
return;
}
context.report({
node,
messageId: isReasonTagMember(tagMember) ? "reason" : "tag",
});
},
SwitchStatement(node) {
if (
!isTagMember(node.discriminant) ||
!isInsideBroadEffectHandler(node)
) {
return;
}
context.report({
node,
messageId: isReasonTagMember(node.discriminant) ? "reason" : "tag",
});
},
};
},
});
45 changes: 45 additions & 0 deletions tools/oxlint/anti-slop/effect/rules/no-manual-tag-comparison.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { defineRule } from "@oxlint/plugins";

import {
isInsideBroadEffectHandler,
isTagMember,
tagMemberFromComparison,
} from "../shared/tagged-values.ts";

export const noManualTagComparisonRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Use Effect Match or Predicate helpers instead of manually branching on `_tag`.",
},
messages: {
manualComparison:
"Use Match.tag/Match.tags for tagged-value branching, or Predicate.isTagged for a simple reusable predicate.",
manualSwitch:
"Use Match.value(value).pipe(Match.tag/Match.tags/Match.tagsExhaustive) or the tagged enum `$match` helper instead of switching on `_tag`.",
},
},
createOnce(context) {
return {
BinaryExpression(node) {
if (
tagMemberFromComparison(node) === undefined ||
isInsideBroadEffectHandler(node)
) {
return;
}
context.report({ node, messageId: "manualComparison" });
},
SwitchStatement(node) {
if (
!isTagMember(node.discriminant) ||
isInsideBroadEffectHandler(node)
) {
return;
}
context.report({ node, messageId: "manualSwitch" });
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { defineRule } from "@oxlint/plugins";

import {
isMatchPatternObject,
isStringLiteral,
propertyName,
} from "../shared/tagged-values.ts";

export const noManualTaggedConstructionRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Construct tagged values with their existing Effect constructor instead of writing `_tag` manually.",
},
messages: {
manualConstruction:
"Use the existing Schema tagged `.make`, tagged class/error constructor, or Data.taggedEnum variant constructor instead of writing a literal `_tag` object.",
},
},
createOnce(context) {
return {
ObjectExpression(node) {
if (isMatchPatternObject(node)) return;
const tag = node.properties.find(
(property) =>
property.type === "Property" &&
propertyName(property) === "_tag" &&
isStringLiteral(property.value),
);
if (tag !== undefined) {
context.report({ node: tag, messageId: "manualConstruction" });
}
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { defineRule } from "@oxlint/plugins";

import type { ESTree } from "@oxlint/plugins";

const SERVICE_CONSTRUCTOR_NAME = /^make[A-Z]/u;
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;

function isProjectLocalImport(source: string): boolean {
return source.startsWith("./") || source.startsWith("../");
}

function getImportedName(specifier: ESTree.ImportSpecifier): string {
if (specifier.imported.type === "Identifier") return specifier.imported.name;
return specifier.imported.value;
}

/** Keep dependency-bearing Effect service constructors local to their owning capability modules. */
export const noServiceConstructorImportsRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow project-local make<CapabilityName> imports outside test and spec files.",
},
messages: {
serviceConstructorImport:
'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.',
},
},
create(context) {
const isTestFile = TEST_FILE.test(context.filename.replaceAll("\\", "/"));

return {
ImportDeclaration(node) {
if (isTestFile || !isProjectLocalImport(node.source.value)) return;

for (const specifier of node.specifiers) {
if (specifier.type !== "ImportSpecifier") continue;

const importedName = getImportedName(specifier);
if (!SERVICE_CONSTRUCTOR_NAME.test(importedName)) continue;

context.report({
node: specifier,
messageId: "serviceConstructorImport",
data: { name: importedName },
});
}
},
};
},
});
Loading