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
9 changes: 9 additions & 0 deletions .changeset/tv2-1-zone-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@sentiness/core": patch
---

Add the internal `resolveZones` zone resolver (`packages/core/src/zones`), the
foundation for Phase V2 per-zone execution. It maps a v2 config's `zones` into
rooted, option-merged placements (`absRoot = join(repoRoot, path)`; catalog
options deep-merged with per-zone overrides). No public API change — the module is
internal to `core` and not re-exported from `index.ts`.
9 changes: 9 additions & 0 deletions .changeset/tv2-3-checkcontext-reporoot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@sentiness/check-sdk": minor
---

Add `CheckContext.repoRoot` so checks can reach the repository root independently
of `cwd`. In a polyglot monorepo `cwd` is the zone root (e.g. `crates/engine`)
while `repoRoot` is the repository root, for the rare check that needs repo-level
context. Additive and backward-compatible for the documented consumer pattern
(checks read the context); single-zone runs set `repoRoot === cwd`.
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@ on:
pull_request:

jobs:
changeset:
# Standard guardrail: every PR must consciously decide the release question.
# `changeset status` fails when a publishable package changed without a
# changeset; if the change needs no release, add an empty one
# (`pnpm changeset add --empty`). Runs only on PRs — pushes to main consume
# changesets via the release workflow's "Version Packages" PR.
name: Changeset
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Fetch base branch
run: git fetch origin main:refs/remotes/origin/main

- name: Enable Corepack
run: corepack enable

- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts

# Skipped on the Changesets "Version Packages" PR, which consumes the
# changeset files (so the gate would otherwise fail and block releases).
# The job still runs and reports success, satisfying the required check.
- name: Require a changeset (or an explicit empty one)
if: github.head_ref != 'changeset-release/main'
run: pnpm changeset status --since=origin/main

verify:
runs-on: ubuntu-latest
steps:
Expand Down
28 changes: 28 additions & 0 deletions docs/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,34 @@ the repo root. All existing check `makeContext` test helpers gained
`pnpm -r typecheck`, `pnpm -r test` (core 209, SDK 13 incl. the new `repoRoot`
type test, all checks + cli green), and `pnpm lint` (219 files) are clean.

### TV2.1 zone resolver — done (2026-06-17)

New pure module `packages/core/src/zones/zones.ts` exporting `resolveZones(config,
repoRoot)`, the resolver Phase V2 per-zone execution (TV2.2) builds on. It maps the
v2 `ResolvedConfig.zones` into `ResolvedZone[]`, each carrying `path`, `absRoot`
(`join(repoRoot, path)`), and `ResolvedCheckPlacement[]`:

- `tier` = zone override → catalog entry → fallback. **Deviation flagged:** the
spec types `placement.tier` as a required `Tier`, but `resolveZones` is pure over
config and cannot see a check's `defaultTier`; the config tier is optional. Since
`init` always writes a `tier` per catalog entry, the resolver uses
`zoneOverride.tier ?? catalogEntry.tier ?? 'standard'`. The `'standard'` fallback
only bites hand-written configs that omit tier; TV2.2 can refine the effective
tier against `check.defaultTier` where the `Check` instance is in scope.
- `options` = catalog options deep-merged with the zone override (zone wins;
`thresholds` and nested plain objects merge recursively, arrays/primitives
replace). Resolution metadata (`version`, `path`, `tier`) never leaks into
`options`; `id`/`tier` are stripped from the override.
- Single-root configs need no special case — `resolveConfig` already normalizes an
absent `zones` to one zone at `'.'` with every catalog id, so the root zone's
`absRoot === repoRoot`.

Internal to `core` (the runner imports it directly; not re-exported from
`index.ts`). `pnpm --filter @sentiness/core typecheck`, the 6-case `zones.test.ts`
(root normalization, absRoot join, multi-zone, override/threshold deep-merge,
bare-id catalog tier, repeated check across zones), the full core suite (215
tests), and `pnpm lint` (221 files) are green.

## Implementation approach

The implementation should progress in usable slices, not by completing the whole specification before the CLI can run.
Expand Down
131 changes: 131 additions & 0 deletions packages/core/src/zones/zones.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { asCheckId } from '@sentiness/check-sdk';
import { describe, expect, it } from 'vitest';
import { resolveConfig, type SentinessConfigV2, validateConfig } from '../config/config.js';
import { resolveZones } from './zones.js';

function makeConfig(overrides: Partial<SentinessConfigV2>): ReturnType<typeof resolveConfig> {
return resolveConfig(
validateConfig({
schemaVersion: '2.0',
engine: '2.0.0',
checks: {},
...overrides,
}),
);
}

describe('resolveZones', () => {
it('normalizes a single-root config to one zone at "." with every catalog check', () => {
const config = makeConfig({
checks: {
biome: { version: '*', tier: 'fast' },
knip: { version: '*', tier: 'standard' },
},
});

const zones = resolveZones(config, '/repo');

expect(zones).toHaveLength(1);
const [root] = zones;
expect(root?.path).toBe('.');
expect(root?.absRoot).toBe('/repo');
expect(root?.checks.map((c) => c.id)).toEqual([asCheckId('biome'), asCheckId('knip')]);
expect(root?.checks.map((c) => c.tier)).toEqual(['fast', 'standard']);
});

it('roots each zone at repoRoot joined with the zone path', () => {
const config = makeConfig({
checks: { biome: { version: '*', tier: 'fast' } },
zones: [{ path: 'apps/web', checks: ['biome'] }],
});

const zones = resolveZones(config, '/repo');

expect(zones).toHaveLength(1);
expect(zones[0]?.path).toBe('apps/web');
expect(zones[0]?.absRoot).toBe('/repo/apps/web');
});

it('resolves multiple zones, each owning its own checks', () => {
const config = makeConfig({
checks: {
biome: { version: '*', tier: 'fast' },
knip: { version: '*', tier: 'standard' },
clippy: { version: '*', tier: 'fast' },
},
zones: [
{ path: 'apps/web', checks: ['biome', 'knip'] },
{ path: 'crates/engine', checks: ['clippy'] },
],
});

const zones = resolveZones(config, '/repo');

expect(zones).toHaveLength(2);
expect(zones[0]?.checks.map((c) => c.id)).toEqual([asCheckId('biome'), asCheckId('knip')]);
expect(zones[1]?.absRoot).toBe('/repo/crates/engine');
expect(zones[1]?.checks.map((c) => c.id)).toEqual([asCheckId('clippy')]);
});

it('merges per-zone overrides over the catalog entry (zone wins; thresholds deep-merge)', () => {
const config = makeConfig({
checks: {
biome: {
version: '*',
tier: 'fast',
thresholds: { a: 1, b: 2 },
extraArgs: ['--from-catalog'],
},
},
zones: [
{
path: 'apps/web',
checks: [{ id: 'biome', tier: 'standard', thresholds: { b: 9 } }],
},
],
});

const [placement] = resolveZones(config, '/repo')[0]?.checks ?? [];

expect(placement?.tier).toBe('standard');
// thresholds deep-merge: catalog `a` survives, zone `b` overrides.
expect(placement?.options.thresholds).toEqual({ a: 1, b: 9 });
// catalog-only option survives.
expect(placement?.options.extraArgs).toEqual(['--from-catalog']);
// resolution metadata never leaks into check options.
expect(placement?.options).not.toHaveProperty('version');
expect(placement?.options).not.toHaveProperty('path');
expect(placement?.options).not.toHaveProperty('tier');
});

it('takes the catalog tier when a zone references a check by bare id', () => {
const config = makeConfig({
checks: { biome: { version: '*', tier: 'slow' } },
zones: [{ path: 'apps/web', checks: ['biome'] }],
});

const [placement] = resolveZones(config, '/repo')[0]?.checks ?? [];

expect(placement?.tier).toBe('slow');
});

it('yields one placement per zone for a check shared across zones, with catalog options', () => {
const config = makeConfig({
checks: { biome: { version: '*', tier: 'fast', thresholds: { a: 1 } } },
zones: [
{ path: 'apps/web', checks: ['biome'] },
{ path: 'apps/admin', checks: ['biome'] },
],
});

const zones = resolveZones(config, '/repo');

expect(zones).toHaveLength(2);
const web = zones[0]?.checks[0];
const admin = zones[1]?.checks[0];
expect(web?.id).toBe(asCheckId('biome'));
expect(admin?.id).toBe(asCheckId('biome'));
expect(web?.options).toEqual({ thresholds: { a: 1 } });
expect(admin?.options).toEqual({ thresholds: { a: 1 } });
});
});
96 changes: 96 additions & 0 deletions packages/core/src/zones/zones.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { join } from 'node:path';
import { asCheckId, type CheckId, type Tier } from '@sentiness/check-sdk';
import type { CatalogCheckEntry, ResolvedConfig, ZoneCheckOverride } from '../config/config.js';

/**
* One check placed inside one zone, with its tier and options already resolved
* from the catalog entry plus any per-zone override.
*/
export type ResolvedCheckPlacement = {
readonly id: CheckId;
/** Catalog tier, overridden by the zone entry; `'standard'` if neither sets one. */
readonly tier: Tier;
/** Check-specific options (catalog merged with the zone override, zone winning). */
readonly options: Readonly<Record<string, unknown>>;
};

/** A resolved zone: a repo subdirectory and the checks rooted at it. */
export type ResolvedZone = {
readonly path: string; // repo-relative ('.' for the root zone)
readonly absRoot: string; // repoRoot joined with path
readonly checks: readonly ResolvedCheckPlacement[];
};

// Keys on a catalog entry that drive package/tier resolution rather than the
// check's runtime behavior; they must never leak into a placement's `options`.
const RESOLUTION_KEYS: ReadonlySet<string> = new Set(['version', 'path', 'tier']);

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/** Recursively merges plain objects; arrays and primitives are replaced wholesale. */
function deepMerge(
base: Readonly<Record<string, unknown>>,
override: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
const existing = result[key];
result[key] =
isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value;
}
return result;
}

function catalogOptions(entry: CatalogCheckEntry): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(entry)) {
if (!RESOLUTION_KEYS.has(key)) {
out[key] = value;
}
}
return out;
}

function overrideOptions(override: ZoneCheckOverride): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(override)) {
if (key !== 'id' && key !== 'tier') {
out[key] = value;
}
}
return out;
}

/**
* Pure resolution of a v2 config's zones into rooted, option-merged placements.
*
* A single-root config (no `zones`) is already normalized by `resolveConfig` to
* one zone at `'.'` carrying every catalog check, so this function does not need
* a special case for it. A check id may appear in several zones — each yields its
* own placement; the same catalog version applies (one version per repo by
* design). The function never touches the filesystem.
*/
export function resolveZones(config: ResolvedConfig, repoRoot: string): readonly ResolvedZone[] {
return config.zones.map((zone) => {
const checks = zone.checks.map((entry): ResolvedCheckPlacement => {
const isBareId = typeof entry === 'string';
const id = isBareId ? entry : entry.id;
const catalogEntry = config.checks[id];
const base = catalogEntry ? catalogOptions(catalogEntry) : {};
const override = isBareId ? {} : overrideOptions(entry);
const tier: Tier = (isBareId ? undefined : entry.tier) ?? catalogEntry?.tier ?? 'standard';
return {
id: asCheckId(id),
tier,
options: deepMerge(base, override),
};
});
return {
path: zone.path,
absRoot: join(repoRoot, zone.path),
checks,
};
});
}