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
27 changes: 27 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/scripts/agent-lint-feedback.mjs\" --format=claude --event=edit",
"timeout": 30
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/scripts/agent-lint-feedback.mjs\" --format=claude --event=stop",
"timeout": 120
}
]
}
]
}
}
27 changes: 27 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "apply_patch",
"hooks": [
{
"type": "command",
"command": "node \"$(git rev-parse --show-toplevel)/scripts/agent-lint-feedback.mjs\" --format=codex --event=edit",
"timeout": 30
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"$(git rev-parse --show-toplevel)/scripts/agent-lint-feedback.mjs\" --format=codex --event=stop",
"timeout": 120
}
]
}
]
}
}
29 changes: 29 additions & 0 deletions .gemini/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"hooks": {
"AfterTool": [
{
"matcher": "write_file|replace",
"hooks": [
{
"type": "command",
"name": "oracle-lint-edit",
"command": "node \"$GEMINI_PROJECT_DIR/scripts/agent-lint-feedback.mjs\" --format=gemini --event=edit",
"timeout": 30000
}
]
}
],
"AfterAgent": [
{
"hooks": [
{
"type": "command",
"name": "oracle-lint-turn",
"command": "node \"$GEMINI_PROJECT_DIR/scripts/agent-lint-feedback.mjs\" --format=gemini --event=stop",
"timeout": 120000
}
]
}
]
}
}
20 changes: 20 additions & 0 deletions .github/hooks/oracle-lint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"version": 1,
"hooks": {
"postToolUse": [
{
"type": "command",
"matcher": "edit|create",
"bash": "node scripts/agent-lint-feedback.mjs --format=copilot --event=edit",
"timeoutSec": 30
}
],
"agentStop": [
{
"type": "command",
"bash": "node scripts/agent-lint-feedback.mjs --format=copilot --event=stop",
"timeoutSec": 120
}
]
}
}
4 changes: 4 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ jobs:
uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3
- name: Check lint
run: pnpm run lint:check
- name: Check oracle lint without inline directives
run: pnpm run lint:oracles
- name: Test oracle lint rules
run: pnpm run test:lint-rules
minified-db:
name: Minified DB public API
runs-on: ubuntu-latest
Expand Down
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,20 @@ includes this block:
- **Limits:** the states, paths, and observations that the evidence does not
cover.

### Lint feedback while you work

The `oracle/` lint rules in `scripts/eslint-plugin-oracle/` report the
shortcuts that a syntactic check can see. The repository's agent hooks
(`.claude/`, `.codex/`, `.gemini/`, and `.github/hooks/`) return the reports
after each edit and at the end of each turn. A hook sees only the edits that
the harness's own edit tools make. After an edit through a shell command, or
in a harness without these hooks, run `pnpm lint:changed` before you finish.

Fix a report. Do not turn the rule off. The hooks, `pnpm lint:changed`, the
pre-commit hook, and CI ignore `eslint-disable` comments for these rules. A
true exception needs a maintainer to change the rule in a reviewed pull
request.

## Required reading: live-query materialization

Before reading, analyzing, or modifying correlated live-query materialization
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ If you have been assigned to fix an issue or develop a new feature, please follo
pnpm lint
```

`pnpm lint:changed` runs only the `oracle/` rules, on the oracle files that
your branch changes. It does not replace `pnpm lint`. If you work with a
coding agent, the repository's hooks return oracle lint reports to it as it
edits. Codex and Gemini CLI run project hooks only after you
trust them in `/hooks`; review them again when they change.

- Implement your changes and tests in the relevant package or example.
- For a production change that adds, removes, or changes observable behavior,
extend the primary oracle when it can express the governing law. A focused
Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/oracle-coverage.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { tanstackConfig } from '@tanstack/eslint-config'
import oraclePlugin from './scripts/eslint-plugin-oracle/index.js'

export default [
...tanstackConfig,
Expand Down Expand Up @@ -45,4 +46,5 @@ export default [
'import/no-cycle': `error`,
},
},
oraclePlugin.configs.recommended,
]
21 changes: 21 additions & 0 deletions lint-staged.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// ESLint ran out of memory on a commit of more than about 100 files, so
// staged files are linted in chunks, one ESLint process each. lint-staged
// splits each command without a shell or escape sequences, so plain double
// quotes keep Windows backslashes intact.
const chunkSize = 40

export default {
// The oracle rules, without inline directives. lint-staged appends the
// staged oracle files as arguments.
'*oracle*.{ts,tsx}': `node scripts/agent-lint-feedback.mjs`,
'*.{ts,tsx}': (files) => {
const commands = []
for (let start = 0; start < files.length; start += chunkSize) {
const chunk = files.slice(start, start + chunkSize)
commands.push(
`eslint --fix ${chunk.map((file) => `"${file}"`).join(` `)}`,
)
}
return commands
},
}
8 changes: 3 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"format": "prettier --experimental-cli --ignore-unknown '**/*' --write",
"lint": "pnpm --filter \"./packages/**\" lint",
"lint:check": "pnpm --filter \"./packages/**\" lint --fix=false",
"lint:changed": "node scripts/agent-lint-feedback.mjs",
"lint:oracles": "node scripts/agent-lint-feedback.mjs --all",
"test:lint-rules": "node --test \"scripts/eslint-plugin-oracle/*.test.js\"",
"lint-all": "eslint . --fix",
"prepare": "husky",
"test": "pnpm --filter \"./packages/**\" test && pnpm --filter @tanstack/db test:indexed-db:package",
Expand Down Expand Up @@ -67,10 +70,5 @@
"vite": "^7.3.0",
"vitest": "^3.2.4",
"zod": "^3.25.76"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix"
]
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { fc } from '@fast-check/vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
oraclePropertyOptions,
oracleReplayRequested,
oracleRuns,
readOracleRunConfig,
} from '../../db/tests/oracle-config'
import type {
BrowserOPFSWorkerRequest,
BrowserOPFSWorkerResponse,
Expand Down Expand Up @@ -142,19 +148,31 @@ const diagnosticInput = fc.record({
),
})

const replaySeed = process.env.TANSTACK_DB_OPFS_DIAGNOSTIC_ORACLE_SEED
const diagnosticSeed = Number(replaySeed ?? 1846)
const diagnosticRuns = Number(
process.env.TANSTACK_DB_OPFS_DIAGNOSTIC_ORACLE_RUNS ?? 40,
)
const diagnosticPath = process.env.TANSTACK_DB_OPFS_DIAGNOSTIC_ORACLE_PATH
const replayRequested = replaySeed !== undefined || diagnosticPath !== undefined
const campaigns = replayRequested
? [{ name: `replay`, seed: diagnosticSeed }]
: [
{ name: `fixed`, seed: diagnosticSeed },
{ name: `random`, seed: undefined },
]
const diagnosticProperty = `opfs-worker.diagnostics`
const diagnosticRuns = 40
// A path replay runs only the selected property's recorded history, once. A
// seed without a path reseeds the random campaign and keeps the fixed one.
const replay = readOracleRunConfig()
const campaigns =
replay.replayPath === undefined
? [
{
name: `fixed`,
options: { seed: 1846, numRuns: oracleRuns(diagnosticRuns) },
},
{
name: `random`,
options: oraclePropertyOptions(diagnosticRuns, diagnosticProperty),
},
]
: replay.replayProperty === diagnosticProperty
? [
{
name: `replay`,
options: oraclePropertyOptions(diagnosticRuns, diagnosticProperty),
},
]
: []

const diagnosticExamples: Array<[DiagnosticInput]> = [
[
Expand Down Expand Up @@ -243,18 +261,19 @@ cause; the absent-cause examples reject an invented cause. Pinned examples run
separately so both generated campaigns share one property and budget, and a
reported shrink path selects its generated case directly. Verbose FastCheck
output retains original/reduced traces. Set
TANSTACK_DB_OPFS_DIAGNOSTIC_ORACLE_SEED and
TANSTACK_DB_OPFS_DIAGNOSTIC_ORACLE_PATH for direct replay.
TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, and
TANSTACK_DB_ORACLE_PROPERTY=opfs-worker.diagnostics for direct replay.
TANSTACK_DB_ORACLE_RUNS_MULTIPLIER scales the generated campaign.
*/
describe(`OPFS worker diagnostics oracle`, () => {
if (!replayRequested) {
if (!oracleReplayRequested()) {
it.each(diagnosticExamples)(
`preserves the pinned open-failure diagnostic (%#)`,
checkDiagnostic,
)
}

it.skipIf(replayRequested)(
it.skipIf(oracleReplayRequested())(
`rejects a diagnostic that drops an exposed VFS cause`,
async () => {
const input = diagnosticExamples[4]![0]
Expand All @@ -274,11 +293,9 @@ describe(`OPFS worker diagnostics oracle`, () => {

it.each(campaigns)(
`matches the independent diagnostic formatter ($name campaign)`,
async ({ seed }) => {
async ({ options }) => {
await fc.assert(fc.asyncProperty(diagnosticInput, checkDiagnostic), {
...(seed === undefined ? {} : { seed }),
numRuns: diagnosticRuns,
...(diagnosticPath ? { path: diagnosticPath } : {}),
...options,
verbose: true,
})
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5001,10 +5001,13 @@ packages/db/src/query/live/ARCHITECTURE.md) for a follower tab. A follower
routes on-demand demand it cannot serve to the elected owner. Its load, and so
its live query's first ready, settles only when the rows the owner fetched for
it are visible in the follower; if applying the owner's commit fails here, the
load rejects and the follower's source enters error. The single-runtime lanes
`persistence.follower-remote-demand` and `persistence.follower-generation-notice`
in db-sqlite-persistence-core/tests/persisted-oracle.test.ts own the runtime
mechanism with a coordinator double. This lane is their receiving witness
load rejects and the follower's source enters error. The single-runtime
`persistence.follower-remote-demand` lane and the follower generation-notice
cells in db-sqlite-persistence-core/tests/persisted-oracle.test.ts own the
runtime mechanism with a coordinator double. The generation-notice cells are
fixed histories: their rotation cell is pinned as failing under the known gap
#2139, and the fix adds their generated campaign back. This lane is their
receiving witness
across the real boundary: two tabs, each with its own real
BrowserCollectionCoordinator over Node's real BroadcastChannel and Web Locks,
and one real SQLite file opened by both tabs.
Expand Down Expand Up @@ -5433,8 +5436,9 @@ describe(`follower remote-demand settlement across real tabs`, () => {
// source. The model owes the same applied settlement there: the rows it
// already showed stay visible, and a new demand settles with its rows.
// This topology never routes to another tab's owner, so the
// elected-owner notice path stays covered by the single-runtime
// `persistence.follower-generation-notice` lane.
// elected-owner notice path stays with the single-runtime follower
// generation-notice cells in persisted-oracle.test.ts. Their rotation cell
// is pinned as failing under the known gap #2139.
it(
`keeps a rotated follower's rows and serves its new demand from its own generation`,
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,10 +462,7 @@ describe(`structuralIdentity`, () => {
)
if (options.seed === undefined)
await fc.assert(property, { numRuns: options.numRuns, seed: 20261014 })
await fc.assert(
property,
options.path === undefined ? options : { ...options, numRuns: 1 },
)
await fc.assert(property, options)
})

// Named replays of counterexamples the random campaign found. Nested `deep`
Expand Down Expand Up @@ -625,9 +622,6 @@ describe(`structuralIdentity`, () => {
)
if (options.seed === undefined)
await fc.assert(property, { numRuns: options.numRuns, seed: 1158727847 })
await fc.assert(
property,
options.path === undefined ? options : { ...options, numRuns: 1 },
)
await fc.assert(property, options)
})
})
17 changes: 4 additions & 13 deletions packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,6 @@ const describeUnlessOracleReplay =
requestedOracleReplayProperty === undefined ||
requestedOracleReplayProperty === `persistence.retained-demand` ||
requestedOracleReplayProperty === `persistence.generation-notice` ||
requestedOracleReplayProperty === `persistence.follower-generation-notice` ||
requestedOracleReplayProperty === `persistence.follower-remote-demand`
? describe
: describe.skip
Expand Down Expand Up @@ -26043,7 +26042,10 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => {
),
})
// Known gap: see #2139. A rotated follower stays in scoped recovery, so
// the owner's rows in the new generation never become visible.
// the owner's rows in the new generation never become visible. Some
// generated histories pass today, so a random draw cannot be pinned as
// failing; this fixed seed pins the gap. The fix for #2139 adds the random
// and replayed campaign back.
it.fails(
`checks generated follower generation-notice histories (fixed)`,
() =>
Expand All @@ -26055,17 +26057,6 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => {
{ seed: 2128, numRuns: oracleRuns(12) },
),
)
// Known gap: see #2139. Some generated histories pass today, so a random
// draw cannot be pinned as failing; the fixed seed above pins the gap.
it.skip(`checks generated follower generation-notice histories (random or replayed)`, () =>
fc.assert(
fc.asyncProperty(
followerGenerationNoticeHistory,
assertFollowerGenerationNoticeHistory,
),
oraclePropertyOptions(12, `persistence.follower-generation-notice`),
))

/**
* A follower routes demand it cannot serve to the elected owner. The owner
* loads the rows upstream, commits them durably, and broadcasts the commit
Expand Down
Loading
Loading