Skip to content

Commit fd179b0

Browse files
committed
feat(tooling): enforce the #4251 slot-lookup ratchet with a counted baseline
The sweep list shipped as a bare array of paths in eslint.config.mjs under a comment reading "NEVER add an entry" — a promise nothing checked, which is the exact shape this work line keeps finding (#4320's options configured a block that never ran). ESLint cannot express a ratchet: an ignored file is ignored COMPLETELY, so three moves were invisible. The list becomes scripts/slot-lookup-baseline.json — file -> site count — and is the single source: its keys ARE the config's ignores, its values are what check:slot-lookup enforces. The script re-runs ESLint with the grandfathering lifted and matches reports by the rule's exact message (exported from the config), so the counter cannot drift from the rule; change a selector and it re-measures against it. Each caught move was verified by injection: A. new erasure in a listed file -> count grew 1 -> 2 (pnpm lint: silent) B. listed file cleared, entry kept -> ratchet DOWN, run --update C/D. a file ADDED to the list to mute it -> rejected against the merge base with main. D is the decisive one: a violating new file baselined at its TRUE count passes every count check, and only the key-set comparison catches it. When the merge base is unreadable (shallow clone, baseline new on the branch) the run says so rather than reading as verified — so lint.yml checks out with fetch-depth: 0. 171 unswept sites in 40 files today. Batches sweep files, then --update ratchets the baseline down; it never grows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWW5xEALZNU5VBXfGyWPGz
1 parent b07d829 commit fd179b0

5 files changed

Lines changed: 279 additions & 58 deletions

File tree

.github/workflows/lint.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ jobs:
2626
steps:
2727
- name: Checkout repository
2828
uses: actions/checkout@v7
29+
with:
30+
# The slot-lookup ratchet compares the baseline against its state at
31+
# the merge base with main — the only way to see a file being ADDED
32+
# to the grandfather list. A shallow clone has no merge base, and the
33+
# check would degrade to "not verified" on every run.
34+
fetch-depth: 0
2935

3036
- name: Setup Node.js
3137
uses: actions/setup-node@v7
@@ -57,6 +63,15 @@ jobs:
5763
- name: ESLint
5864
run: pnpm lint
5965

66+
# Slot-lookup sweep ratchet (#4251). `pnpm lint` above bans erasing a
67+
# service-lookup result to `any` across packages/, but the files still
68+
# holding pre-existing sites are grandfathered by path — and an ignored
69+
# file is ignored COMPLETELY, so new erasures added to one ride the old
70+
# entry in silence. This re-measures those files with the grandfathering
71+
# lifted and holds them to a per-file count, so the list can only shrink.
72+
- name: Slot-lookup ratchet
73+
run: pnpm check:slot-lookup
74+
6075
# Raw NUL guard (#3127): one literal U+0000 byte makes grep/ripgrep treat
6176
# the whole file as binary and silently return ZERO matches — the file drops
6277
# out of code search and out of every grep-based lint, with no error saying

eslint.config.mjs

Lines changed: 31 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import { readFileSync } from 'node:fs';
4+
35
import tsParser from '@typescript-eslint/parser';
46

57
// Flat ESLint config — guards against memory-bloating import patterns.
@@ -73,7 +75,10 @@ const SLOT_LOOKUPS = ['resolveService', 'getService', 'getRequestKernelService']
7375
// callers read only `getPort()`.
7476
const UNCONTRACTED_SLOTS = ['protocol', 'mcp', 'kernel-resolver', 'scope-manager', 'http\\.server'].join('|');
7577

76-
const SLOT_LOOKUP_ANY_MESSAGE =
78+
// Exported so `scripts/check-slot-lookup-ratchet.mjs` can identify THIS rule's
79+
// reports among the other `no-restricted-syntax` rules, by exact message —
80+
// the counter and the rule must never be able to disagree about what counts.
81+
export const SLOT_LOOKUP_ANY_MESSAGE =
7782
'Do not erase a service-lookup result to `any` (`: any`, `as any`, or a ' +
7883
'`getService<any>(…)` type argument) — the lookup already returns the slot\'s ' +
7984
'contract (#4168/#4176/#4202), and this switches that checking off ' +
@@ -86,62 +91,29 @@ const SLOT_LOOKUP_ANY_MESSAGE =
8691
'note, so the exemption is reviewed once and visible in one place — see ' +
8792
'issues #4127 and #4251.';
8893

89-
// [#4251] The sweep ratchet. These files hold pre-existing lookup-erasure
90-
// sites — `getService<any>(…)`, `: any`, or `as any` — that predate the rule
91-
// reaching them: the rule's scope was packages/runtime only until #4251
92-
// widened it, and the type-argument selector did not exist. Enumerated by
93-
// running this config with this list emptied: 180 sites in 44 files (the
94-
// issue's 80 was the non-test `<any>` form alone; the annotation forms and
95-
// test files the old selectors would have caught under a wider scope roughly
96-
// double it). They are grandfathered BY FILE, here, for the same reason
97-
// UNCONTRACTED_SLOTS is central: `--no-inline-config` means the escape must
98-
// live in config, and a shrinking list in one place is the ratchet made
99-
// visible. Batches remove entries as they sweep (see #4214 for the batch
100-
// pattern and its yield — these sites are where the erased contracts live).
101-
// NEVER add an entry: a new file starts covered, and a new violation in a
102-
// listed file rides an existing entry only until its batch.
103-
const SLOT_LOOKUP_UNSWEPT = [
104-
'packages/cli/src/commands/migrate/files-to-references.ts',
105-
'packages/cli/src/commands/migrate/value-shapes.ts',
106-
'packages/cli/src/commands/serve.ts',
107-
'packages/client/src/client.hono.test.ts',
108-
'packages/cloud-connection/src/cloud-connection-plugin.ts',
109-
'packages/cloud-connection/src/marketplace-install-local-plugin.ts',
110-
'packages/core/examples/kernel-features-example.ts',
111-
'packages/metadata-protocol/src/plugin.ts',
112-
'packages/metadata/src/plugin.ts',
113-
'packages/objectql/src/plugin.integration.test.ts',
114-
'packages/objectql/src/plugin.ts',
115-
'packages/plugins/plugin-approvals/src/approvals-plugin.ts',
116-
'packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts',
117-
'packages/plugins/plugin-audit/src/audit-plugin.ts',
118-
'packages/plugins/plugin-auth/src/auth-plugin.ts',
119-
'packages/plugins/plugin-email/src/email-plugin.ts',
120-
'packages/plugins/plugin-hono-server/src/current-user-endpoints.ts',
121-
'packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts',
122-
'packages/plugins/plugin-reports/src/reports-plugin.ts',
123-
'packages/plugins/plugin-security/src/security-plugin.ts',
124-
'packages/plugins/plugin-sharing/src/sharing-plugin.ts',
125-
'packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts',
126-
'packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts',
127-
'packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts',
128-
'packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts',
129-
'packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts',
130-
'packages/rest/src/external-datasource-routes.ts',
131-
'packages/rest/src/rest-api-plugin.ts',
132-
'packages/services/service-datasource/src/admin-routes.ts',
133-
'packages/services/service-job/src/job-service-plugin.ts',
134-
'packages/services/service-messaging/src/messaging-service-plugin.test.ts',
135-
'packages/services/service-messaging/src/messaging-service-plugin.ts',
136-
'packages/services/service-queue/src/queue-service-plugin.ts',
137-
'packages/services/service-realtime/src/realtime-service-plugin.ts',
138-
'packages/services/service-settings/src/settings-service-plugin.ts',
139-
'packages/services/service-sms/src/sms-plugin.ts',
140-
'packages/services/service-storage/src/storage-service-plugin.ts',
141-
'packages/triggers/trigger-record-change/src/formula-context.test.ts',
142-
'packages/triggers/trigger-record-change/src/multilookup-context.test.ts',
143-
'packages/triggers/trigger-record-change/src/record-change-integration.test.ts',
144-
];
94+
// [#4251] The sweep ratchet, read from `scripts/slot-lookup-baseline.json`.
95+
//
96+
// Those files hold pre-existing lookup-erasure sites — `getService<any>(…)`,
97+
// `: any`, or `as any` — that predate the rule reaching them: the rule's scope
98+
// was packages/runtime only until #4251 widened it, and the type-argument
99+
// selector did not exist. 171 sites in 40 files at the widening; they are
100+
// grandfathered BY FILE for the same reason UNCONTRACTED_SLOTS is central —
101+
// `--no-inline-config` means the escape must live in config, and one shrinking
102+
// list is the ratchet made visible. Batches remove entries as they sweep (see
103+
// #4214 for the batch pattern and its yield — these sites are where the erased
104+
// contracts live).
105+
//
106+
// The baseline is the SINGLE SOURCE: its keys are these ignores and its values
107+
// are the per-file counts `pnpm check:slot-lookup` enforces. That coupling is
108+
// the point (#4320 was found the same way — a promise nothing checked). A bare
109+
// file list made three moves invisible: adding a file to silence lint, adding
110+
// NEW violations to an already-listed file (they rode the entry silently), and
111+
// clearing a file without dropping its entry (the list stops meaning anything).
112+
// The counted baseline fails all three, and `--update` is the only way to move
113+
// it — downward.
114+
const SLOT_LOOKUP_UNSWEPT = Object.keys(JSON.parse(
115+
readFileSync(new URL('./scripts/slot-lookup-baseline.json', import.meta.url), 'utf8'),
116+
));
145117

146118
export default [
147119
{
@@ -271,7 +243,8 @@ export default [
271243
// held 77 of the 80 known sites, an unlinted majority that looked covered.
272244
// Per-package curation would recreate that gap one package at a time, so the
273245
// scope is total and the not-yet-swept files are grandfathered individually
274-
// in SLOT_LOOKUP_UNSWEPT above — a shrinking list, not a silent boundary.
246+
// in the counted baseline above — a shrinking list under `check:slot-lookup`,
247+
// not a silent boundary.
275248
//
276249
// KNOWN RESIDUAL: a wrapper whose own return type is annotated
277250
// (`const getEngine = async (): Promise<any> => …resolveService(…)`) erases

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"check:role-word": "node scripts/check-role-word.mjs",
3636
"check:org-identifier": "node scripts/check-org-identifier.mjs",
3737
"check:authz-resolver": "node scripts/check-single-authz-resolver.mjs",
38+
"check:slot-lookup": "node scripts/check-slot-lookup-ratchet.mjs",
3839
"check:service-providers": "node scripts/check-service-providers.mjs",
3940
"check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs",
4041
"check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs",
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
#!/usr/bin/env node
2+
// check-slot-lookup-ratchet — the #4251 sweep ratchet, enforced.
3+
//
4+
// `eslint.config.mjs` bans erasing a service-lookup result to `any`
5+
// (#4127/#4214/#4251) across all of packages/, and grandfathers the files that
6+
// still hold pre-existing sites by listing them in
7+
// scripts/slot-lookup-baseline.json. ESLint's `ignores` alone cannot express a
8+
// ratchet: an ignored file is ignored completely, so NEW erasures added to a
9+
// listed file ride the existing entry in total silence — the same
10+
// declared-but-unchecked shape this whole work line keeps finding (#4320's
11+
// options configured a block that never ran; nothing checked the promise).
12+
//
13+
// So the baseline carries COUNTS, and this script is what makes them mean
14+
// something. It fails when:
15+
// • a file NOT in the baseline reports a site (that already fails `pnpm lint`
16+
// — reported here too so one command explains the whole picture), or
17+
// • a baselined file's count INCREASES (new erasure hiding behind an old
18+
// entry — the invisible move), or
19+
// • a baselined file's count DECREASED or the file is clean/gone (progress!)
20+
// — run with --update to ratchet the baseline down and commit it.
21+
//
22+
// node scripts/check-slot-lookup-ratchet.mjs [--update]
23+
//
24+
// The counts are produced by running ESLint itself with the baseline's
25+
// `ignores` lifted, and reports are matched by the rule's exact message
26+
// (imported from the config). The counter therefore cannot drift from the
27+
// rule: change the selectors and this re-measures against them.
28+
//
29+
// Sweeping a file means typing its lookups (pass the slot's contract), then
30+
// `--update` to drop or shrink its entry. Entries only ever go down; a batch
31+
// that adds one is doing the opposite of the job.
32+
import { execFileSync } from 'node:child_process';
33+
import { readFileSync, writeFileSync } from 'node:fs';
34+
import { dirname, resolve, relative } from 'node:path';
35+
import { fileURLToPath } from 'node:url';
36+
37+
import { ESLint } from 'eslint';
38+
39+
import eslintConfig, { SLOT_LOOKUP_ANY_MESSAGE } from '../eslint.config.mjs';
40+
41+
const __dirname = dirname(fileURLToPath(import.meta.url));
42+
const repoRoot = resolve(__dirname, '..');
43+
const BASELINE_PATH = 'scripts/slot-lookup-baseline.json';
44+
45+
const update = process.argv.includes('--update');
46+
const baseline = JSON.parse(readFileSync(resolve(repoRoot, BASELINE_PATH), 'utf8'));
47+
const baselinedFiles = new Set(Object.keys(baseline));
48+
49+
// The lint config with the grandfathering removed — every baselined file is
50+
// measured as if it were already swept. Only the block that carries this rule
51+
// is touched; every other config entry passes through untouched so the run
52+
// stays byte-identical to `pnpm lint` in all other respects.
53+
const carriesRule = (entry) => {
54+
const rule = entry?.rules?.['no-restricted-syntax'];
55+
return Array.isArray(rule) && rule.some((r) => r?.message === SLOT_LOOKUP_ANY_MESSAGE);
56+
};
57+
58+
const measuringConfig = eslintConfig.map((entry) =>
59+
carriesRule(entry)
60+
? { ...entry, ignores: (entry.ignores ?? []).filter((p) => !baselinedFiles.has(p)) }
61+
: entry,
62+
);
63+
64+
if (!eslintConfig.some(carriesRule)) {
65+
console.error(
66+
'check-slot-lookup-ratchet: no config block carries the slot-lookup rule.\n' +
67+
'The rule was renamed, removed, or its message changed without updating\n' +
68+
'SLOT_LOOKUP_ANY_MESSAGE — refusing to report "clean" for a rule that is\n' +
69+
'no longer being measured.',
70+
);
71+
process.exit(2);
72+
}
73+
74+
const eslint = new ESLint({
75+
cwd: repoRoot,
76+
overrideConfigFile: true,
77+
baseConfig: measuringConfig,
78+
// Match the root `lint` script: this repo lints with --no-inline-config on
79+
// purpose, so an eslint-disable comment must not shrink a count here either.
80+
allowInlineConfig: false,
81+
});
82+
83+
const results = await eslint.lintFiles(['packages/**/*.{ts,tsx,mts,cts}']);
84+
85+
const current = {};
86+
for (const result of results) {
87+
const hits = result.messages.filter((m) => m.message === SLOT_LOOKUP_ANY_MESSAGE).length;
88+
if (hits > 0) current[relative(repoRoot, result.filePath).replace(/\\/g, '/')] = hits;
89+
}
90+
91+
const sorted = Object.fromEntries(Object.entries(current).sort(([a], [b]) => a.localeCompare(b)));
92+
93+
if (update) {
94+
writeFileSync(resolve(repoRoot, BASELINE_PATH), JSON.stringify(sorted, null, 2) + '\n');
95+
const files = Object.keys(sorted).length;
96+
const sites = Object.values(sorted).reduce((a, b) => a + b, 0);
97+
console.log(`slot-lookup baseline updated: ${sites} site(s) in ${files} file(s).`);
98+
process.exit(0);
99+
}
100+
101+
const errors = [];
102+
for (const [file, count] of Object.entries(sorted)) {
103+
const allowed = baseline[file];
104+
if (allowed === undefined) {
105+
errors.push(
106+
`${file}: NEW service-lookup erasure (${count} site(s)). Pass the slot's ` +
107+
`contract type instead of \`any\` — see eslint.config.mjs and issue #4251. ` +
108+
`This file is not grandfathered, and the baseline never grows.`,
109+
);
110+
} else if (count > allowed) {
111+
errors.push(
112+
`${file}: erasure count grew ${allowed}${count}. The file is grandfathered ` +
113+
`for its EXISTING sites only; new ones must carry the slot's contract type.`,
114+
);
115+
}
116+
}
117+
for (const [file, allowed] of Object.entries(baseline)) {
118+
const now = sorted[file];
119+
if (now === undefined) {
120+
errors.push(
121+
`${file}: baselined file is clean/gone (was ${allowed}) — ratchet DOWN: run ` +
122+
`\`pnpm check:slot-lookup --update\` and commit the baseline.`,
123+
);
124+
} else if (now < allowed) {
125+
errors.push(
126+
`${file}: erasure count fell ${allowed}${now} — ratchet DOWN: run ` +
127+
`\`pnpm check:slot-lookup --update\` and commit the baseline.`,
128+
);
129+
}
130+
}
131+
132+
// The key set must only ever SHRINK. Counts alone cannot see the last move:
133+
// a genuinely-erasing NEW file added to the baseline matches its own count and
134+
// sails through, which would turn the grandfather list into a general-purpose
135+
// mute button. The reference is the baseline as it stands on the merge base
136+
// with origin/main — on a sweep branch keys only disappear, and on main the
137+
// merge base is HEAD, so the comparison is a no-op there.
138+
let monotonicity = null;
139+
try {
140+
const git = (...args) =>
141+
execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
142+
let base;
143+
for (const ref of ['origin/main', 'main']) {
144+
try { base = git('merge-base', 'HEAD', ref); break; } catch { /* try the next ref */ }
145+
}
146+
if (base) {
147+
const previous = JSON.parse(git('show', `${base}:${BASELINE_PATH}`));
148+
const added = Object.keys(baseline).filter((f) => !(f in previous));
149+
monotonicity = { base: base.slice(0, 7), added };
150+
}
151+
} catch {
152+
// No git, a shallow clone without the base, or the baseline is new on this
153+
// branch (`git show` fails). Reported below rather than passed over — a
154+
// check that cannot run must not read as a check that passed.
155+
}
156+
157+
if (monotonicity?.added.length) {
158+
for (const file of monotonicity.added) {
159+
errors.push(
160+
`${file}: ADDED to the baseline (not present at ${monotonicity.base}). The ` +
161+
`grandfather list is not a mute button — it only ever shrinks. Type this ` +
162+
`file's lookups instead; see issue #4251.`,
163+
);
164+
}
165+
}
166+
167+
const totalSites = Object.values(sorted).reduce((a, b) => a + b, 0);
168+
const totalFiles = Object.keys(sorted).length;
169+
170+
if (errors.length > 0) {
171+
console.error(`✗ slot-lookup ratchet (${errors.length} problem(s)):\n`);
172+
for (const e of errors) console.error(` • ${e}`);
173+
console.error(
174+
`\nUnswept: ${totalSites} site(s) in ${totalFiles} file(s). ` +
175+
`Sweeping is #4251's batch work — see SLOT_LOOKUP_UNSWEPT in eslint.config.mjs.`,
176+
);
177+
process.exit(1);
178+
}
179+
180+
console.log(
181+
`✓ slot-lookup ratchet holds: ${totalSites} unswept site(s) in ${totalFiles} file(s), ` +
182+
`none new. Every other file under packages/ is covered by \`pnpm lint\`.`,
183+
);
184+
console.log(
185+
monotonicity
186+
? ` baseline key set verified against ${monotonicity.base}: no files added.`
187+
: ` NOT verified: could not read the baseline at the merge base with main ` +
188+
`(no git, shallow clone, or the baseline is new here), so "no files added" ` +
189+
`is unchecked this run.`,
190+
);

scripts/slot-lookup-baseline.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"packages/cli/src/commands/migrate/files-to-references.ts": 1,
3+
"packages/cli/src/commands/migrate/value-shapes.ts": 1,
4+
"packages/cli/src/commands/serve.ts": 7,
5+
"packages/client/src/client.hono.test.ts": 2,
6+
"packages/cloud-connection/src/cloud-connection-plugin.ts": 3,
7+
"packages/cloud-connection/src/marketplace-install-local-plugin.ts": 11,
8+
"packages/core/examples/kernel-features-example.ts": 5,
9+
"packages/metadata-protocol/src/plugin.ts": 1,
10+
"packages/metadata/src/plugin.ts": 1,
11+
"packages/objectql/src/plugin.integration.test.ts": 23,
12+
"packages/objectql/src/plugin.ts": 6,
13+
"packages/plugins/plugin-approvals/src/approvals-plugin.ts": 7,
14+
"packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts": 2,
15+
"packages/plugins/plugin-audit/src/audit-plugin.ts": 1,
16+
"packages/plugins/plugin-auth/src/auth-plugin.ts": 20,
17+
"packages/plugins/plugin-email/src/email-plugin.ts": 3,
18+
"packages/plugins/plugin-hono-server/src/current-user-endpoints.ts": 10,
19+
"packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts": 2,
20+
"packages/plugins/plugin-reports/src/reports-plugin.ts": 5,
21+
"packages/plugins/plugin-security/src/security-plugin.ts": 1,
22+
"packages/plugins/plugin-sharing/src/sharing-plugin.ts": 7,
23+
"packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts": 1,
24+
"packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts": 1,
25+
"packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts": 1,
26+
"packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts": 1,
27+
"packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts": 1,
28+
"packages/rest/src/external-datasource-routes.ts": 1,
29+
"packages/rest/src/rest-api-plugin.ts": 14,
30+
"packages/services/service-datasource/src/admin-routes.ts": 1,
31+
"packages/services/service-job/src/job-service-plugin.ts": 2,
32+
"packages/services/service-messaging/src/messaging-service-plugin.test.ts": 2,
33+
"packages/services/service-messaging/src/messaging-service-plugin.ts": 1,
34+
"packages/services/service-queue/src/queue-service-plugin.ts": 2,
35+
"packages/services/service-realtime/src/realtime-service-plugin.ts": 1,
36+
"packages/services/service-settings/src/settings-service-plugin.ts": 2,
37+
"packages/services/service-sms/src/sms-plugin.ts": 1,
38+
"packages/services/service-storage/src/storage-service-plugin.ts": 5,
39+
"packages/triggers/trigger-record-change/src/formula-context.test.ts": 2,
40+
"packages/triggers/trigger-record-change/src/multilookup-context.test.ts": 2,
41+
"packages/triggers/trigger-record-change/src/record-change-integration.test.ts": 11
42+
}

0 commit comments

Comments
 (0)