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
5 changes: 4 additions & 1 deletion .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ jobs:
- name: Install
run: bun install --frozen-lockfile

- name: Lint (Biome)
- name: Format (Oxfmt)
run: bun run format:check

- name: Lint and assist (Biome)
run: bun run lint

- name: Structure governance (folder-structure)
Expand Down
6 changes: 3 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,18 @@ __dk_review_baseline_gate() {
}

# devkit:biome-format
# Format staged files with biome, then re-stage exactly those (scoped — never a blanket
# Format staged files with Oxfmt, then re-stage exactly those (scoped — never a blanket
# `git add -u`, which would sweep unrelated working-tree changes into the commit). Only
# re-add files with NO unstaged edits, so partially-staged files commit exactly as staged.
STAGED_FMT=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(tsx?|jsx?|css|json|jsonc|mjs)$' || true)
STAGED_FMT=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^((cli|gate-engine)/.*\.(tsx?|jsx?|css|jsonc?|mjs|mts)|(tsconfig|biome)/.*\.jsonc?|skills/.*\.mjs|\.co-occurrence-allowlist\.json|\.fallowrc\.jsonc|\.oxfmtrc\.json|biome\.jsonc|eslint\.config\.mjs|guard\.config(\.example)?\.json|package\.json|search-code\.config\.json|tsconfig(\.build)?\.json|vitest(\.e2e)?\.config\.mjs|vitest\.setup\.mjs)$' || true)
if [ -n "$STAGED_FMT" ]; then
UNSTAGED_FMT_FILE=$(mktemp)
git diff --name-only | sort -u >"$UNSTAGED_FMT_FILE"
FMT_SAFE=$(printf '%s\n' "$STAGED_FMT" | grep -Fxvf "$UNSTAGED_FMT_FILE" || true)
rm -f "$UNSTAGED_FMT_FILE"
if [ -n "$FMT_SAFE" ]; then
echo "🎨 Formatting staged files..."
echo "$FMT_SAFE" | xargs bunx biome format --write 2>/dev/null || true
echo "$FMT_SAFE" | xargs node_modules/.bin/oxfmt --threads 1 --write || exit 1
echo "$FMT_SAFE" | xargs git add -f
fi
fi
Expand Down
24 changes: 24 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"useTabs": false,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"jsxSingleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "all",
"semi": true,
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"sortPackageJson": false,
"overrides": [
{
"files": ["**/*.json", "**/*.jsonc"],
"options": {
"trailingComma": "none"
}
}
],
"ignorePatterns": ["node_modules", "templates", "bun.lock", "dist"]
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ bun install --frozen-lockfile
bun run devkit -- <command>
bun run test:run
bun run typecheck
bun run format:check
bun run lint
bun run lint:structure
bun run benchmarks:check
Expand Down
86 changes: 82 additions & 4 deletions cli/__tests__/self-host.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { extractGuardBlock, replaceGuardBlock } from '../lib/husky/husky-block.mts';
import { buildFullHook, extractGuardBlock, replaceGuardBlock } from '../lib/husky/husky-block.mts';
import { DK_NO_GIT_ENV_HELPER } from '../lib/husky/review-fragments.mts';
import {
buildSelfHostBlock,
Expand Down Expand Up @@ -51,15 +53,22 @@ describe('self-host bin rewrite', () => {
expect(() => sourceBinFor(ROOT, 'guard-nope')).toThrow(/no bin/);
});

it('toSelfHost rewrites `bunx guard-*` to `node <source>` and leaves `bunx biome` alone', () => {
it('toSelfHost rewrites source gates and the self-host formatter without changing consumers', () => {
const input =
'bunx guard-review --gate\nbunx biome format --write\nbunx guard-deterministic --hook x';
const out = toSelfHost(input, ROOT);
expect(out).toContain('node gate-engine/review/cli.mts --gate');
expect(out).toContain('node gate-engine/deterministic/run.mts --hook x');
expect(out).toContain('bunx biome format --write'); // real devDep — untouched
expect(out).toContain('node_modules/.bin/oxfmt --threads 1 --write');
expect(out).not.toContain('bunx biome format --write');
expect(out).not.toContain('bunx guard-');
});

it('leaves the generic consumer hook on Biome until that repository proves parity', () => {
const hook = buildFullHook({ biome: true, guards: [] });
expect(hook).toContain('bunx biome format --write');
expect(hook).not.toContain('node_modules/.bin/oxfmt');
});
});

describe('selfHostSelection', () => {
Expand Down Expand Up @@ -111,10 +120,79 @@ describe('buildSelfHostHook', () => {
expect(hook).toContain('--extra "lint=bun run lint"');
expect(hook).toContain('--extra "benchmarks=bun run benchmarks:check -- --mode staged"');
expect(hook).toContain('--structure "bun run lint:structure"');
expect(hook).toContain('node_modules/.bin/oxfmt --threads 1 --write');
expect(hook).toContain('(cli|gate-engine)/');
expect(hook).toContain('skills/.*\\.mjs');
expect(hook).toContain('node_modules/.bin/oxfmt --threads 1 --write || exit 1');
expect(hook).not.toContain('oxfmt --threads 1 --write 2>/dev/null || true');
expect(hook).not.toContain("grep -E '\\.(tsx?|jsx?|css|json|jsonc|mjs|mts)$'");
expect(hook).not.toContain('bunx biome format --write');
expect(hook).not.toMatch(/bunx guard-/);
expect(hook).not.toContain('@norvalbv/devkit');
});

it('formats and re-stages only files inside the proven self-host scope', () => {
const root = mkdtempSync(join(tmpdir(), 'self-host-oxfmt-'));
execFileSync('git', ['init', '-q'], { cwd: root });
symlinkSync(join(ROOT, 'node_modules'), join(root, 'node_modules'), 'dir');
mkdirSync(join(root, 'cli'), { recursive: true });
mkdirSync(join(root, 'docs', 'benchmarks'), { recursive: true });
writeFileSync(join(root, '.oxfmtrc.json'), '{}\n');
writeFileSync(join(root, 'cli', 'sample.mts'), 'const value={answer:42}\n');
writeFileSync(join(root, 'cli', 'partial.mts'), 'const partial={staged:true}\n');
writeFileSync(join(root, 'docs', 'benchmarks', 'catalog.json'), '{"evidence":true}\n');
execFileSync(
'git',
['add', '.oxfmtrc.json', 'cli/sample.mts', 'cli/partial.mts', 'docs/benchmarks/catalog.json'],
{ cwd: root },
);
writeFileSync(join(root, 'cli', 'partial.mts'), 'const partial={working:true}\n');

const fragment = buildSelfHostHook(HOOK_SEL, '', ROOT).match(
/# devkit:biome-format[\s\S]*?# \/devkit:biome-format/,
)?.[0];
expect(fragment).toBeDefined();
execFileSync('sh', ['-c', fragment ?? 'exit 1'], { cwd: root });

const formatted = 'const value = { answer: 42 };\n';
expect(readFileSync(join(root, 'cli', 'sample.mts'), 'utf8')).toBe(formatted);
expect(execFileSync('git', ['show', ':cli/sample.mts'], { cwd: root, encoding: 'utf8' })).toBe(
formatted,
);
const evidence = '{"evidence":true}\n';
expect(readFileSync(join(root, 'docs', 'benchmarks', 'catalog.json'), 'utf8')).toBe(evidence);
expect(
execFileSync('git', ['show', ':docs/benchmarks/catalog.json'], {
cwd: root,
encoding: 'utf8',
}),
).toBe(evidence);
expect(readFileSync(join(root, 'cli', 'partial.mts'), 'utf8')).toBe(
'const partial={working:true}\n',
);
expect(execFileSync('git', ['show', ':cli/partial.mts'], { cwd: root, encoding: 'utf8' })).toBe(
'const partial={staged:true}\n',
);
});

it('blocks the self-host hook when Oxfmt fails', () => {
const root = mkdtempSync(join(tmpdir(), 'self-host-oxfmt-failure-'));
execFileSync('git', ['init', '-q'], { cwd: root });
mkdirSync(join(root, 'cli'), { recursive: true });
mkdirSync(join(root, 'node_modules', '.bin'), { recursive: true });
const oxfmt = join(root, 'node_modules', '.bin', 'oxfmt');
writeFileSync(oxfmt, '#!/bin/sh\nexit 7\n');
chmodSync(oxfmt, 0o755);
writeFileSync(join(root, 'cli', 'sample.mts'), 'const value={answer:42}\n');
execFileSync('git', ['add', 'cli/sample.mts'], { cwd: root });

const fragment = buildSelfHostHook(HOOK_SEL, '', ROOT).match(
/# devkit:biome-format[\s\S]*?# \/devkit:biome-format/,
)?.[0];
expect(fragment).toBeDefined();
expect(() => execFileSync('sh', ['-c', fragment ?? 'exit 1'], { cwd: root })).toThrow();
});

// The `--extra` above is only as hard as the script it names, and biome exits 0 when every
// diagnostic is warn-severity. A bare `biome check .` therefore PRINTS its findings into the gate
// log — indistinguishable from a real failure to the reader — and passes the commit anyway (a
Expand All @@ -126,7 +204,7 @@ describe('buildSelfHostHook', () => {
readFileSync(join(ROOT, 'package.json'), 'utf8'),
);
expect(SELF_HOST_EXTRAS).toContainEqual({ label: 'lint', cmd: 'bun run lint' });
expect(pkg.scripts?.lint).toContain('--error-on-warnings');
expect(pkg.scripts?.lint).toBe('biome check --formatter-enabled=false --error-on-warnings .');
});

it('preserves the advisory fallow-audit gate INSIDE the block (never blocks, survives re-run)', () => {
Expand Down
36 changes: 31 additions & 5 deletions cli/lib/husky/self-host.mts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ import {
} from './husky-block.mts';

// devkit's own structure-lint command (package.json `lint:structure` = `eslint cli gate-engine`)
// and its hard biome-lint gate (`lint` = `biome check --error-on-warnings .` — biome exits 0 when
// and its hard Biome lint/assist gate (`lint` disables Biome's formatter explicitly — Biome exits 0 when
// every diagnostic is warn-severity, so without that flag the gate PRINTS its findings into the log
// and passes the commit anyway). The hard-lint is folded into the deterministic orchestrator via
// `--extra` (any non-zero blocks); both run via real devDeps
// (eslint/biome), so toSelfHost leaves them untouched. Together with the advisory fallow fragment
// (eslint/biome). Formatting is the one self-host-only rewrite beyond guard bins: Devkit has proven
// its pinned Oxfmt output over its own authored scope, while consumer hooks stay on Biome until each
// consumer completes the same parity exercise. Together with the advisory fallow fragment
// below, the self-host hook preserves every gate the pre-self-host hand hook ran AND adds review + dup/clone.
export const SELF_HOST_STRUCTURE_CMD = 'bun run lint:structure';
export const SELF_HOST_EXTRAS: Array<{ label: string; cmd: string }> = [
Expand Down Expand Up @@ -74,8 +76,21 @@ type SelfHostHookInput = Selection & {
};

// Matches the `bunx guard-<x>` bins the generator emits. `guard-qavis-advisory` (double hyphen) is
// covered by `[a-z-]+`. `bunx biome` has no `guard-` prefix → correctly left alone.
// covered by `[a-z-]+`. The formatter has its own exact rewrite below so consumer output is not
// affected by the self-host-only Oxfmt adoption.
const BUNX_GUARD_RE = /\bbunx (guard-[a-z-]+)\b/g;
const BUNX_BIOME_FORMAT_RE = /\bbunx biome format --write\b/g;
const BIOME_FORMAT_COMMENT_RE = /Format staged files with biome/g;
const BIOME_FORMAT_EXTENSIONS = '\\.(tsx?|jsx?|css|json|jsonc|mjs)$';
const BIOME_FORMAT_FILTER = `grep -E '${BIOME_FORMAT_EXTENSIONS}'`;
const SELF_HOST_OXFMT_BEST_EFFORT =
'node_modules/.bin/oxfmt --threads 1 --write 2>/dev/null || true';
const SELF_HOST_OXFMT_HARD = 'node_modules/.bin/oxfmt --threads 1 --write || exit 1';
// Keep the staged hook on the same authored-file boundary as package.json's format scripts. A
// broad extension-only filter would let Oxfmt rewrite evidence, fixtures, vendored sources, or
// generated output that the adopted 558-file parity experiment never selected.
const SELF_HOST_FORMAT_FILTER =
"grep -E '^((cli|gate-engine)/.*\\.(tsx?|jsx?|css|jsonc?|mjs|mts)|(tsconfig|biome)/.*\\.jsonc?|skills/.*\\.mjs|\\.co-occurrence-allowlist\\.json|\\.fallowrc\\.jsonc|\\.oxfmtrc\\.json|biome\\.jsonc|eslint\\.config\\.mjs|guard\\.config(\\.example)?\\.json|package\\.json|search-code\\.config\\.json|tsconfig(\\.build)?\\.json|vitest(\\.e2e)?\\.config\\.mjs|vitest\\.setup\\.mjs)$'";
// The `./dist/<...>.mjs` → `<...>.mts` transform pieces (hoisted — useTopLevelRegex).
const DIST_PREFIX_RE = /^\.\/dist\//;
const MJS_EXT_RE = /\.mjs$/;
Expand All @@ -100,9 +115,20 @@ export function sourceBinFor(cwd: string, binName: string): string {
return distPath.replace(DIST_PREFIX_RE, '').replace(MJS_EXT_RE, '.mts');
}

/** Rewrite every `bunx guard-<x>` in a generated hook to `node <source .mts>`. */
/** Rewrite generated consumer commands to Devkit's self-host source/pinned-runtime equivalents. */
export function toSelfHost(hookText: string, cwd: string): string {
return hookText.replace(BUNX_GUARD_RE, (_m, bin: string) => `node ${sourceBinFor(cwd, bin)}`);
return (
hookText
.replace(BUNX_GUARD_RE, (_m, bin: string) => `node ${sourceBinFor(cwd, bin)}`)
.replace(BUNX_BIOME_FORMAT_RE, 'node_modules/.bin/oxfmt --threads 1 --write')
.replace(BIOME_FORMAT_COMMENT_RE, 'Format staged files with Oxfmt')
// Formatting is a hard self-host responsibility now that Biome lint runs with formatting off.
// Generic consumer hooks retain their existing best-effort Biome behavior.
.replace(SELF_HOST_OXFMT_BEST_EFFORT, SELF_HOST_OXFMT_HARD)
// A replacement callback keeps the regex's terminal `$'` literal; replacement strings treat
// `$'` as the special token for the unmatched suffix.
.replace(BIOME_FORMAT_FILTER, () => SELF_HOST_FORMAT_FILTER)
);
}

/**
Expand Down
1 change: 1 addition & 0 deletions dist/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ bun install --frozen-lockfile
bun run devkit -- <command>
bun run test:run
bun run typecheck
bun run format:check
bun run lint
bun run lint:structure
bun run benchmarks:check
Expand Down
32 changes: 27 additions & 5 deletions dist/cli/lib/husky/self-host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ import { readJson } from "../fs-helpers.mjs";
import { markEnd } from "./husky.mjs";
import { buildFullHook, buildGuardBlock, extractGuardBlock, replaceGuardBlock, } from "./husky-block.mjs";
// devkit's own structure-lint command (package.json `lint:structure` = `eslint cli gate-engine`)
// and its hard biome-lint gate (`lint` = `biome check --error-on-warnings .` — biome exits 0 when
// and its hard Biome lint/assist gate (`lint` disables Biome's formatter explicitly — Biome exits 0 when
// every diagnostic is warn-severity, so without that flag the gate PRINTS its findings into the log
// and passes the commit anyway). The hard-lint is folded into the deterministic orchestrator via
// `--extra` (any non-zero blocks); both run via real devDeps
// (eslint/biome), so toSelfHost leaves them untouched. Together with the advisory fallow fragment
// (eslint/biome). Formatting is the one self-host-only rewrite beyond guard bins: Devkit has proven
// its pinned Oxfmt output over its own authored scope, while consumer hooks stay on Biome until each
// consumer completes the same parity exercise. Together with the advisory fallow fragment
// below, the self-host hook preserves every gate the pre-self-host hand hook ran AND adds review + dup/clone.
export const SELF_HOST_STRUCTURE_CMD = 'bun run lint:structure';
export const SELF_HOST_EXTRAS = [
Expand Down Expand Up @@ -58,8 +60,19 @@ else
fi
# /devkit:fallow-advisory`;
// Matches the `bunx guard-<x>` bins the generator emits. `guard-qavis-advisory` (double hyphen) is
// covered by `[a-z-]+`. `bunx biome` has no `guard-` prefix → correctly left alone.
// covered by `[a-z-]+`. The formatter has its own exact rewrite below so consumer output is not
// affected by the self-host-only Oxfmt adoption.
const BUNX_GUARD_RE = /\bbunx (guard-[a-z-]+)\b/g;
const BUNX_BIOME_FORMAT_RE = /\bbunx biome format --write\b/g;
const BIOME_FORMAT_COMMENT_RE = /Format staged files with biome/g;
const BIOME_FORMAT_EXTENSIONS = '\\.(tsx?|jsx?|css|json|jsonc|mjs)$';
const BIOME_FORMAT_FILTER = `grep -E '${BIOME_FORMAT_EXTENSIONS}'`;
const SELF_HOST_OXFMT_BEST_EFFORT = 'node_modules/.bin/oxfmt --threads 1 --write 2>/dev/null || true';
const SELF_HOST_OXFMT_HARD = 'node_modules/.bin/oxfmt --threads 1 --write || exit 1';
// Keep the staged hook on the same authored-file boundary as package.json's format scripts. A
// broad extension-only filter would let Oxfmt rewrite evidence, fixtures, vendored sources, or
// generated output that the adopted 558-file parity experiment never selected.
const SELF_HOST_FORMAT_FILTER = "grep -E '^((cli|gate-engine)/.*\\.(tsx?|jsx?|css|jsonc?|mjs|mts)|(tsconfig|biome)/.*\\.jsonc?|skills/.*\\.mjs|\\.co-occurrence-allowlist\\.json|\\.fallowrc\\.jsonc|\\.oxfmtrc\\.json|biome\\.jsonc|eslint\\.config\\.mjs|guard\\.config(\\.example)?\\.json|package\\.json|search-code\\.config\\.json|tsconfig(\\.build)?\\.json|vitest(\\.e2e)?\\.config\\.mjs|vitest\\.setup\\.mjs)$'";
// The `./dist/<...>.mjs` → `<...>.mts` transform pieces (hoisted — useTopLevelRegex).
const DIST_PREFIX_RE = /^\.\/dist\//;
const MJS_EXT_RE = /\.mjs$/;
Expand All @@ -82,9 +95,18 @@ export function sourceBinFor(cwd, binName) {
throw new Error(`self-host: no bin "${binName}" in ${join(cwd, 'package.json')}`);
return distPath.replace(DIST_PREFIX_RE, '').replace(MJS_EXT_RE, '.mts');
}
/** Rewrite every `bunx guard-<x>` in a generated hook to `node <source .mts>`. */
/** Rewrite generated consumer commands to Devkit's self-host source/pinned-runtime equivalents. */
export function toSelfHost(hookText, cwd) {
return hookText.replace(BUNX_GUARD_RE, (_m, bin) => `node ${sourceBinFor(cwd, bin)}`);
return (hookText
.replace(BUNX_GUARD_RE, (_m, bin) => `node ${sourceBinFor(cwd, bin)}`)
.replace(BUNX_BIOME_FORMAT_RE, 'node_modules/.bin/oxfmt --threads 1 --write')
.replace(BIOME_FORMAT_COMMENT_RE, 'Format staged files with Oxfmt')
// Formatting is a hard self-host responsibility now that Biome lint runs with formatting off.
// Generic consumer hooks retain their existing best-effort Biome behavior.
.replace(SELF_HOST_OXFMT_BEST_EFFORT, SELF_HOST_OXFMT_HARD)
// A replacement callback keeps the regex's terminal `$'` literal; replacement strings treat
// `$'` as the special token for the unmatched suffix.
.replace(BIOME_FORMAT_FILTER, () => SELF_HOST_FORMAT_FILTER));
}
/**
* The canonical devkit-dogfood selection: every recommended component + guard, PLUS `review` (the
Expand Down
5 changes: 3 additions & 2 deletions dist/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,14 @@
"test:run": "vitest run",
"test:e2e": "vitest run -c vitest.e2e.config.mjs",
"playground": "bun scripts/playground.mts",
"lint": "biome check --error-on-warnings .",
"lint": "biome check --formatter-enabled=false --error-on-warnings .",
"lint:structure": "eslint cli gate-engine",
"benchmarks:check": "bun gate-engine/eval/cli.mts check",
"benchmarks:render": "bun gate-engine/eval/cli.mts render",
"benchmarks:typecheck": "tsc -p gate-engine/eval/tsconfig.json",
"search-eval:check": "node gate-engine/search-tool/eval/eval.mts --fail",
"format": "biome check --write .",
"format": "oxfmt --write 'cli/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'gate-engine/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'tsconfig/**/*.{json,jsonc}' 'biome/**/*.{json,jsonc}' 'skills/**/*.mjs' .co-occurrence-allowlist.json .fallowrc.jsonc .oxfmtrc.json biome.jsonc eslint.config.mjs guard.config.example.json guard.config.json package.json search-code.config.json tsconfig.build.json tsconfig.json vitest.config.mjs vitest.e2e.config.mjs vitest.setup.mjs",
"format:check": "oxfmt --check 'cli/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'gate-engine/**/*.{ts,tsx,js,jsx,mts,mjs,css,json,jsonc}' 'tsconfig/**/*.{json,jsonc}' 'biome/**/*.{json,jsonc}' 'skills/**/*.mjs' .co-occurrence-allowlist.json .fallowrc.jsonc .oxfmtrc.json biome.jsonc eslint.config.mjs guard.config.example.json guard.config.json package.json search-code.config.json tsconfig.build.json tsconfig.json vitest.config.mjs vitest.e2e.config.mjs vitest.setup.mjs",
"typecheck": "tsc -p tsconfig.json",
"prepare": "husky",
"guard:freeze": "node gate-engine/ratchets/folder-fanout.mjs freeze && node gate-engine/ratchets/size-disable.mjs freeze",
Expand Down
Loading
Loading