Skip to content
Closed
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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ ci-build:
.PHONY: ci-lint
ci-lint:
npx nx run-many -t ci:lint
node scripts/ci/ratchet.mjs

.PHONY: dev-int-down
dev-int-down:
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"dev:compose": "sh -c '. ./scripts/dev-env.sh && docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml'",
"knip": "knip",
"knip:ci": "knip --reporter json",
"ratchet": "node scripts/ci/ratchet.mjs",
"ratchet:update": "node scripts/ci/ratchet.mjs --update",
"lint": "npx nx run-many -t ci:lint",
"lint:fix": "npx nx run-many -t lint:fix",
"version": "make version",
Expand Down
27 changes: 27 additions & 0 deletions scripts/ci/ratchet-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"app": {
"as-any": 217,
"ts-ignore": 14,
"eslint-disable": 120
},
"api": {
"as-any": 97,
"ts-ignore": 4,
"eslint-disable": 26
},
"common-utils": {
"as-any": 69,
"ts-ignore": 5,
"eslint-disable": 32
},
"cli": {
"as-any": 0,
"ts-ignore": 0,
"eslint-disable": 3
},
"hdx-eval": {
"as-any": 0,
"ts-ignore": 0,
"eslint-disable": 1
}
}
80 changes: 80 additions & 0 deletions scripts/ci/ratchet.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Ratchet: counts of tracked escape hatches may only go down.
* Above baseline -> fail (you added one; remove it).
* Below baseline -> warn only (run `yarn ratchet:update` to lock the
* improvement in). Non-fatal so an improvement can never turn `main` red —
* e.g. when two count-lowering PRs merge close together and `main`'s
* committed baseline briefly sits above the real count.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';

const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const BASELINE = path.join(ROOT, 'scripts/ci/ratchet-baseline.json');
const PACKAGES = ['app', 'api', 'common-utils', 'cli', 'hdx-eval'];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded package list requires manual maintenance

PACKAGES lists every tracked package by name. When a new package is added to the monorepo it will be silently skipped until someone remembers to update both this array and ratchet-baseline.json. Auto-discovering packages from the packages/ directory (e.g. reading its subdirectories or package.json workspaces) would make the ratchet self-updating, though it would also require that new packages always start with a baseline entry.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional for now: the explicit PACKAGES list is mirrored 1:1 by the keys in ratchet-baseline.json, so the two stay in lockstep and a new package is a deliberate, reviewed addition rather than silently folded in. Auto-discovery via packages/*/src is a reasonable follow-up but would need the baseline generation to track it too — out of scope for this PR.

const PATTERNS = {
'as-any': 'as any',
'ts-ignore': '@ts-ignore',
'eslint-disable': 'eslint-disable',
};
Comment on lines +23 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 eslint-disable pattern matches eslint-disable-next-line and eslint-disable-line

Because grep -Eo does prefix matching, the literal eslint-disable pattern matches all three directive forms (block-disable, eslint-disable-next-line, eslint-disable-line) and counts each separately. This is probably the intended behaviour — all three are escape hatches — but it means the reported eslint-disable count is not the number of block-level suppressions, which may surprise maintainers trying to interpret it. A brief comment clarifying this is intentional would help.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — block eslint-disable, eslint-disable-next-line and eslint-disable-line are all escape hatches we want the ratchet to count, so prefix matching catching all three is the desired behaviour, not a bug.


function count(pattern, dir) {
// A renamed/removed package (or a new PACKAGES entry added before its source
// exists) would make grep exit 2, not 1; treat a missing dir as zero rather
// than crashing the whole ratchet with an opaque stack trace.
if (!existsSync(dir)) return 0;
try {
const out = execFileSync(
'grep',
['-rEo', pattern, dir, '--include=*.ts', '--include=*.tsx'],
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 },
);
return out.split('\n').filter(Boolean).length;
} catch (err) {
if (err.status === 1) return 0; // grep exit 1 = no matches
throw err;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

const current = {};
for (const pkg of PACKAGES) {
const dir = path.join(ROOT, 'packages', pkg, 'src');
current[pkg] = Object.fromEntries(
Object.entries(PATTERNS).map(([name, re]) => [name, count(re, dir)]),
);
}

if (process.argv.includes('--update')) {
writeFileSync(BASELINE, JSON.stringify(current, null, 2) + '\n');
console.log(`ratchet baseline written to ${path.relative(ROOT, BASELINE)}`);
process.exit(0);
}

const baseline = JSON.parse(readFileSync(BASELINE, 'utf8'));
let failed = false;
for (const pkg of PACKAGES) {
for (const name of Object.keys(PATTERNS)) {
const now = current[pkg][name];
const max = baseline[pkg]?.[name] ?? 0;
if (now > max) {
failed = true;
console.error(
`x ${pkg}/${name}: ${now} > baseline ${max} — remove the new occurrence(s)`,
);
} else if (now < max) {
// Non-fatal: an improvement must never fail CI (it would red `main` and
// every open PR until someone re-baselines). Just nudge to lock it in.
console.warn(
`! ${pkg}/${name}: ${now} < baseline ${max} — run \`yarn ratchet:update\` to lock the improvement in`,
);
}
}
}
if (failed) process.exit(1);
console.log('ratchet ok: all escape-hatch counts at baseline');
Loading