diff --git a/.changeset/tv2-1-zone-resolver.md b/.changeset/tv2-1-zone-resolver.md new file mode 100644 index 0000000..6094532 --- /dev/null +++ b/.changeset/tv2-1-zone-resolver.md @@ -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`. diff --git a/.changeset/tv2-3-checkcontext-reporoot.md b/.changeset/tv2-3-checkcontext-reporoot.md new file mode 100644 index 0000000..46835d1 --- /dev/null +++ b/.changeset/tv2-3-checkcontext-reporoot.md @@ -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`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f69ebbf..3e28741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/docs/progress.md b/docs/progress.md index 3be9925..c5d6ea6 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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. diff --git a/packages/core/src/zones/zones.test.ts b/packages/core/src/zones/zones.test.ts new file mode 100644 index 0000000..98da9c2 --- /dev/null +++ b/packages/core/src/zones/zones.test.ts @@ -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): ReturnType { + 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 } }); + }); +}); diff --git a/packages/core/src/zones/zones.ts b/packages/core/src/zones/zones.ts new file mode 100644 index 0000000..be80b67 --- /dev/null +++ b/packages/core/src/zones/zones.ts @@ -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>; +}; + +/** 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 = new Set(['version', 'path', 'tier']); + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Recursively merges plain objects; arrays and primitives are replaced wholesale. */ +function deepMerge( + base: Readonly>, + override: Readonly>, +): Record { + const result: Record = { ...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 { + const out: Record = {}; + for (const [key, value] of Object.entries(entry)) { + if (!RESOLUTION_KEYS.has(key)) { + out[key] = value; + } + } + return out; +} + +function overrideOptions(override: ZoneCheckOverride): Record { + const out: Record = {}; + 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, + }; + }); +}