@hublo/sentinel 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/registry.ts","../src/core/base-adapter.ts","../src/shared/package-json.ts","../src/roles/typescript/adapters/tsc/tsc.adapter.ts","../src/shared/jsonc.ts","../src/shared/resolve-bin.ts","../src/roles/typescript/config-policy.ts","../src/roles/typescript/presets.ts","../src/roles/typescript/resolve-tsconfig-target.ts","../src/roles/typescript/register.ts","../src/adapters.ts","../src/core/detect-framework.ts","../src/shared/text.ts","../src/core/apply-plan.ts","../src/shared/deep-merge.ts","../src/core/dispatch.ts"],"sourcesContent":["/**\n * Adapter registry. Tool branches register their adapters here; the CLI resolves\n * an adapter by (target, flavour, runner). A default runner per target keeps the\n * common invocation tool-agnostic (`sentinel --lint`), while `--runner` overrides\n * it. Resolution is flavour-aware: candidates are filtered by `appliesTo(flavour)`,\n * so a React-only adapter is never picked for a Nest project, and two adapters can\n * share a (target, runner) if they specialise different flavours.\n */\nimport type { Flavour, Target } from './domain.js'\nimport type { Adapter } from './types.js'\n\nconst adapters: Adapter[] = []\n\n/** Default runner per target, so `--runner` stays optional. */\nconst defaultRunner: Partial<Record<Target, string>> = {\n // Filled in by tool branches, e.g. lint: 'eslint', typescript: 'tsc'.\n}\n\n/**\n * Register a tool adapter (called from each role's registration, wired into the\n * bootstrap in `src/adapters.ts`). No (target, runner) uniqueness guard here: two\n * adapters may share one for different flavours; a genuine clash (same target,\n * runner AND flavour) is caught at resolve time, where the flavour is known.\n */\nexport function register(adapter: Adapter): void {\n adapters.push(adapter)\n}\n\n/** Set the default runner for a target. */\nexport function setDefaultRunner(target: Target, runner: string): void {\n defaultRunner[target] = runner\n}\n\n/** All registered adapters (for `--all`, listing, and reports). */\nexport function all(): readonly Adapter[] {\n return adapters\n}\n\n/**\n * Resolve one adapter by target, honouring an explicit `--runner` or the target's\n * default. When a `flavour` is known it also filters by `appliesTo`, so a\n * React-only adapter is never picked for Nest; when it is absent (sentinel does\n * not detect it), every adapter for the target is a candidate. Throws with an\n * actionable message for each failure mode: no adapter for the target (yet), none\n * that handles the flavour, an ambiguous choice, an unknown runner, or two\n * adapters claiming the same (target, runner, flavour).\n */\nexport function resolve(target: Target, flavour?: Flavour, runner?: string): Adapter {\n const forTarget = adapters.filter((a) => a.target === target)\n if (forTarget.length === 0) {\n throw new Error(\n `No adapter registered for target \"${target}\" yet (it ships in a later ticket).`,\n )\n }\n\n const candidates = flavour ? forTarget.filter((a) => a.appliesTo(flavour)) : forTarget\n if (candidates.length === 0) {\n throw new Error(`No adapter for target \"${target}\" handles flavour \"${flavour}\".`)\n }\n\n const wanted = runner ?? defaultRunner[target]\n const available = candidates.map((a) => a.runner).join(', ')\n\n // No runner asked for and no default: only unambiguous when there is exactly one.\n if (!wanted) {\n const [first, ...rest] = candidates\n if (first && rest.length === 0) return first\n throw new Error(\n `Multiple runners for target \"${target}\" (${available}); pass --runner or set a default.`,\n )\n }\n\n const matching = candidates.filter((a) => a.runner === wanted)\n if (matching.length === 0) {\n throw new Error(\n `No runner \"${wanted}\" for target \"${target}\" (flavour \"${flavour}\"). Available: ${available}.`,\n )\n }\n if (matching.length > 1) {\n throw new Error(\n `Ambiguous: ${matching.length} adapters claim target \"${target}\", runner \"${wanted}\", flavour \"${flavour}\".`,\n )\n }\n return matching[0] as Adapter\n}\n","/**\n * `BaseAdapter`: an optional convenience base class for adapters. This is the\n * runtime part of the contract (the types live in `types.ts`); it provides\n * default `inspect`/`report` that throw \"not implemented yet\", so an adapter can\n * extend it and implement only what it needs.\n */\nimport type { Flavour, Target } from './domain.js'\nimport type { Adapter, AdapterResult, RunContext, UpdateContext, UpdatePlan } from './types.js'\n\nexport abstract class BaseAdapter implements Adapter {\n abstract readonly target: Target\n abstract readonly runner: string\n abstract appliesTo(flavour: Flavour): boolean\n abstract plan(context: UpdateContext): UpdatePlan | Promise<UpdatePlan>\n abstract run(ctx: RunContext): Promise<AdapterResult>\n\n inspect(_ctx: RunContext): Promise<unknown> {\n throw new Error(`${this.runner}: --inspect not implemented yet`)\n }\n\n report(_ctx: RunContext): Promise<AdapterResult> {\n throw new Error(`${this.runner}: --report not implemented yet`)\n }\n}\n","/**\n * package.json helpers, shared by the CLI and the engine.\n */\nimport { existsSync, readFileSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\n/**\n * sentinel's OWN version, for `--version`. We must NOT use\n * `process.env.npm_package_version`, that is the version of whatever script\n * invoked us (an app's package.json, or nothing). Instead walk up from this\n * module to the nearest package.json, which is always sentinel's own, whether\n * running from source or the bundled dist.\n */\nexport function readOwnVersion(): string {\n let dir = dirname(fileURLToPath(import.meta.url))\n for (;;) {\n const pkgPath = join(dir, 'package.json')\n if (existsSync(pkgPath)) {\n try {\n const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }\n if (typeof pkg.version === 'string') return pkg.version\n } catch {\n // Malformed package.json; keep walking up.\n }\n }\n const parent = dirname(dir)\n if (parent === dir) return '0.0.0' // reached the filesystem root\n dir = parent\n }\n}\n\n/**\n * Read a module's package.json for framework detection; tolerant of a missing or\n * malformed file (returns `{}`, so detection falls through to the `node` default).\n */\nexport function readProjectPackageJson(dir: string): {\n dependencies?: Record<string, string>\n devDependencies?: Record<string, string>\n} {\n const path = join(dir, 'package.json')\n if (!existsSync(path)) return {}\n try {\n return JSON.parse(readFileSync(path, 'utf8')) as Record<string, never>\n } catch {\n process.stderr.write(`sentinel: could not parse ${path}; ignoring for detection.\\n`)\n return {}\n }\n}\n\n/**\n * The nx project name for a module, read from its `project.json`, or undefined if\n * there is none/unreadable. Used when `--update` has to scaffold a `package.json`\n * for a module that only has a `project.json` (common for nx apps/services): the\n * scaffolded file borrows this name so it is a valid, uniquely-named workspace\n * package. Reusable by any tool that scaffolds, not just TypeScript.\n */\nexport function readNxProjectName(dir: string): string | undefined {\n const path = join(dir, 'project.json')\n if (!existsSync(path)) return undefined\n try {\n const parsed = JSON.parse(readFileSync(path, 'utf8')) as { name?: unknown }\n return typeof parsed.name === 'string' ? parsed.name : undefined\n } catch {\n return undefined\n }\n}\n","/**\n * TypeScript adapter (runner: `tsc`). Maps the four verbs onto tsc for one module:\n * - plan (--update) → thin conformant tsconfig stub extending the preset\n * - run (--run) → `tsc --noEmit`, the module's own tsc\n * - inspect (--inspect) → the module's resolved config (preset, target, flavour)\n * - report (--report) → type-error + implicit-any counts (the conformance signal)\n */\nimport { spawnSync } from 'node:child_process'\nimport { existsSync, readFileSync } from 'node:fs'\nimport { basename, join } from 'node:path'\n\nimport { BaseAdapter } from '../../../../core/base-adapter.js'\nimport type { Flavour, Target } from '../../../../core/domain.js'\nimport type {\n AdapterResult,\n FileOperation,\n RunContext,\n UpdateContext,\n UpdatePlan,\n} from '../../../../core/types.js'\nimport { parseJsonc } from '../../../../shared/jsonc.js'\nimport { readNxProjectName } from '../../../../shared/package-json.js'\nimport { resolveBin } from '../../../../shared/resolve-bin.js'\nimport { presetOwnedKeys } from '../../config-policy.js'\nimport { hasShippedPreset } from '../../presets.js'\nimport { resolveTsconfigTarget } from '../../resolve-tsconfig-target.js'\n\nconst TYPECHECK_SCRIPT = { typecheck: 'sentinel --run --typescript' }\n\nexport class TscAdapter extends BaseAdapter {\n readonly target: Target = 'typescript'\n readonly runner = 'tsc'\n\n /**\n * The tsc adapter drives type-checking for any flavour: `--run`/`--report`/\n * `--inspect` just execute tsc against the module's existing config, which is\n * meaningful regardless of flavour. `--update` is the exception, it only WRITES a\n * preset for flavours that ship one (gated inside `plan`), so svelte is not\n * clobbered with a non-existent preset.\n */\n appliesTo(_flavour: Flavour): boolean {\n return true\n }\n\n /**\n * Plan `--update`: make the module extend the sentinel preset with a THIN,\n * conformant stub, and route type-checking through the CLI. The engine applies\n * the ops; ensuring the `@hublo/sentinel` dependency is an adoption step\n * (`pnpm add`), not a file write.\n *\n * Per resolved case:\n * - extends-base: set `extends` + strip preset-owned `compilerOptions` (drift),\n * keeping the project's own paths/include (the allowlist).\n * - none: create a fresh thin `tsconfig.json`.\n * - other-chain (svelte): skip, its config extends a different base.\n */\n plan(context: UpdateContext): UpdatePlan {\n // Only write a preset for a flavour that actually ships one. A declared-but-\n // unshipped flavour (e.g. svelte) is skipped, never pointed at a preset that\n // does not exist, which would break the module's typecheck.\n if (!hasShippedPreset(context.flavour)) {\n return {\n operations: [],\n notes: [`skipped: no TypeScript preset for flavour \"${context.flavour}\" yet`],\n }\n }\n const target = resolveTsconfigTarget(context.cwd)\n const preset = `@hublo/sentinel/tsconfig/${context.flavour}`\n const addScript = this.typecheckScriptOperation(context.cwd)\n\n if (target.reason === 'other-chain') {\n return {\n operations: [],\n notes: [`skipped: ${target.path} extends a non-base config; handled separately`],\n }\n }\n\n if (target.reason === 'none') {\n const contents = JSON.stringify({ extends: preset, include: ['src'] }, null, 2) + '\\n'\n return {\n operations: [{ kind: 'write', path: target.path, contents }, addScript],\n notes: [`created ${target.path} (no tsconfig found)`],\n }\n }\n\n // extends-base: set the preset, strip drift, keep the project's own fields.\n const existing = parseJsonc<{ compilerOptions?: Record<string, unknown> }>(\n readFileSync(join(context.cwd, target.path), 'utf8'),\n target.path,\n )\n const drift = presetOwnedKeys(existing.compilerOptions)\n const operations: FileOperation[] = [\n { kind: 'merge-json', path: target.path, value: { extends: preset } },\n ]\n const notes: string[] = []\n if (drift.length > 0) {\n operations.push({\n kind: 'remove-json-keys',\n path: target.path,\n keys: drift.map((key) => ['compilerOptions', key]),\n })\n notes.push(`stripped preset-owned compilerOptions: ${drift.join(', ')}`)\n }\n operations.push(addScript)\n return { operations, notes }\n }\n\n /**\n * The op that routes type-checking through the CLI. If the module already has a\n * `package.json`, merge the script in and leave the rest untouched. If it does\n * NOT (common for nx apps/services that carry only a `project.json`), scaffold a\n * minimal, workspace-valid one, its nx name + `private: true`, so pnpm accepts it\n * and it can then receive the `@hublo/sentinel` devDep (added via `pnpm add` at\n * adoption, never written here, so the lockfile stays authoritative).\n */\n private typecheckScriptOperation(cwd: string): FileOperation {\n if (existsSync(join(cwd, 'package.json'))) {\n return { kind: 'merge-json', path: 'package.json', value: { scripts: TYPECHECK_SCRIPT } }\n }\n const name = readNxProjectName(cwd) ?? basename(cwd)\n return {\n kind: 'merge-json',\n path: 'package.json',\n value: { name, private: true, scripts: TYPECHECK_SCRIPT },\n }\n }\n\n /**\n * Run `tsc --noEmit` on the module's type-check config (the one that extends the\n * base/preset), using the module's own tsc. No tsconfig to check is a pass.\n */\n async run(ctx: RunContext): Promise<AdapterResult> {\n const target = resolveTsconfigTarget(ctx.cwd)\n if (target.reason === 'none') {\n process.stderr.write('sentinel typescript(tsc): no tsconfig to check\\n')\n return { ok: true, code: 0 }\n }\n const tsc = resolveBin(ctx.cwd, 'tsc') ?? 'tsc'\n const result = spawnSync(tsc, ['--noEmit', '--project', target.path], {\n cwd: ctx.cwd,\n stdio: 'inherit',\n })\n if (result.error) {\n process.stderr.write(\n `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?\\n`,\n )\n return { ok: false, code: 1 }\n }\n const code = result.status ?? 1\n return { ok: code === 0, code }\n }\n\n /** The module's resolved TypeScript config: which preset, which file, and how. */\n async inspect(ctx: RunContext): Promise<unknown> {\n const target = resolveTsconfigTarget(ctx.cwd)\n return {\n module: ctx.module,\n target: 'typescript',\n flavour: ctx.flavour,\n configFile: target.path,\n configState: target.reason,\n preset: target.reason === 'none' ? null : `@hublo/sentinel/tsconfig/${ctx.flavour}`,\n }\n }\n\n /**\n * Report conformance for the module: run tsc and count total type errors and the\n * implicit-`any` subset (TS7006), the signal that drives the noImplicitAny\n * migration. No tsconfig is a clean, empty report.\n */\n async report(ctx: RunContext): Promise<AdapterResult> {\n const target = resolveTsconfigTarget(ctx.cwd)\n if (target.reason === 'none') {\n return { ok: true, code: 0, metrics: { errors: 0, implicitAny: 0 } }\n }\n const tsc = resolveBin(ctx.cwd, 'tsc') ?? 'tsc'\n const result = spawnSync(tsc, ['--noEmit', '--project', target.path], {\n cwd: ctx.cwd,\n encoding: 'utf8',\n })\n if (result.error) {\n process.stderr.write(\n `sentinel typescript(tsc): could not run tsc (${result.error.message}); is TypeScript installed in the module?\\n`,\n )\n return { ok: false, code: 1, metrics: { error: 'tsc not available' } }\n }\n const output = `${result.stdout ?? ''}${result.stderr ?? ''}`\n const errors = (output.match(/error TS\\d+/g) ?? []).length\n const implicitAny = (output.match(/error TS7006/g) ?? []).length\n return { ok: errors === 0, code: result.status ?? 0, metrics: { errors, implicitAny } }\n }\n}\n","/**\n * JSONC (JSON with comments + trailing commas) helpers. tsconfig files are JSONC,\n * so reading them with plain `JSON.parse` throws on real projects (e.g.\n * host-admin's tsconfig.app.json has comments). Backed by jsonc-parser (the VS\n * Code library), which also underpins content-preserving edits (added with the\n * `--update` merge).\n */\nimport { parse, printParseErrorCode, type ParseError } from 'jsonc-parser'\n\n/**\n * Parse JSONC text into a value. Throws with a clear message listing the parse\n * errors, so a malformed config fails loudly rather than silently mis-reading.\n */\nexport function parseJsonc<T = unknown>(text: string, source = 'config'): T {\n const errors: ParseError[] = []\n const value = parse(text, errors, { allowTrailingComma: true }) as T\n if (errors.length > 0) {\n const details = errors.map((error) => printParseErrorCode(error.error)).join(', ')\n throw new Error(`${source}: malformed JSONC (${details}).`)\n }\n return value\n}\n","/**\n * Find a tool binary the way node/npm would: walk up from a directory looking for\n * `node_modules/.bin/<name>`. Used so `--run` invokes the MODULE's own tool version\n * (its `tsc`), not sentinel's. Returns undefined if not found (caller falls back to\n * the name on PATH).\n */\nimport { existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\n\nexport function resolveBin(fromDir: string, name: string): string | undefined {\n let dir = fromDir\n for (;;) {\n const candidate = join(dir, 'node_modules', '.bin', name)\n if (existsSync(candidate)) return candidate\n const parent = dirname(dir)\n if (parent === dir) return undefined\n dir = parent\n }\n}\n","/**\n * The allowlist: which tsconfig `compilerOptions` a module may keep locally. Only\n * genuinely project-specific settings, everything else is owned by the sentinel\n * preset and stripped by `--update`, so every migrated module is conformant from\n * the start. A sanctioned exception would be added here (visible + reviewed).\n */\nexport const PERMITTED_COMPILER_OPTIONS: readonly string[] = [\n 'paths',\n 'baseUrl',\n 'rootDir',\n 'outDir',\n 'tsBuildInfoFile',\n]\n\n/**\n * The `compilerOptions` keys the preset owns: present in the project but not in the\n * allowlist. These are the drift `--update` strips.\n */\nexport function presetOwnedKeys(compilerOptions: Record<string, unknown> | undefined): string[] {\n if (!compilerOptions) return []\n return Object.keys(compilerOptions).filter((key) => !PERMITTED_COMPILER_OPTIONS.includes(key))\n}\n","/**\n * The flavours whose TypeScript preset actually ships: one `flavours/<flavour>.ts`\n * source, flattened to `dist/tsconfig/<flavour>.json` by `scripts/build-presets.ts`.\n *\n * Single source of truth, shared by the build script and the adapter's `appliesTo`,\n * so a flavour is only ever offered when its preset exists. A declared flavour with\n * no preset yet (e.g. `svelte`) is deliberately absent: `--update` skips it rather\n * than writing an `extends` to a module that does not exist. Adding a preset is one\n * new file here plus its entry in this list.\n */\nimport type { Flavour } from '../../core/domain.js'\n\nexport const SHIPPED_FLAVOURS = ['react', 'nest', 'node'] as const satisfies readonly Flavour[]\n\n/** Whether a flavour's TypeScript preset is available. */\nexport function hasShippedPreset(flavour: Flavour): boolean {\n return (SHIPPED_FLAVOURS as readonly Flavour[]).includes(flavour)\n}\n","/**\n * Resolve which tsconfig file `--update --typescript` should write in a module.\n *\n * The rule (from the monorepo audit): target the file that currently `extends` the\n * shared base config, that is the entry point sentinel's preset replaces. Two\n * shapes exist:\n * - Pattern A (libs, nest services): `tsconfig.json` extends the base.\n * - Pattern B (Vite React apps): `tsconfig.json` is references-only and\n * `tsconfig.app.json` extends the base.\n * We check `tsconfig.app.json` before `tsconfig.json` so B is found first. The\n * `reason` distinguishes the three cases `--update` must handle differently:\n * - `extends-base`: found the file to migrate.\n * - `other-chain`: a tsconfig exists but extends something else (svelte's own\n * `.svelte-kit` chain), don't clobber it; handled separately.\n * - `none`: no tsconfig at all; `--update` creates one.\n *\n * The \"find the file that extends a shared root\" pattern is reusable; it will be\n * lifted to core when a second tool needs its own version.\n */\nimport { existsSync, readFileSync } from 'node:fs'\nimport { join } from 'node:path'\n\nimport { parseJsonc } from '../../shared/jsonc.js'\n\n/**\n * A file is the target if its `extends` references either the monorepo base (still\n * to migrate) or a sentinel preset (already migrated, so re-`update` re-strips any\n * new drift, keeping it idempotent and self-cleaning).\n */\nconst TARGET_EXTENDS_MARKERS = ['tsconfig.base.json', '@hublo/sentinel/tsconfig/'] as const\n\n/** Candidate entry points, most-specific first (Pattern B before Pattern A). */\nconst CANDIDATES = ['tsconfig.app.json', 'tsconfig.json'] as const\n\nexport interface TsconfigTarget {\n /** The tsconfig file to write, relative to the module root. */\n path: string\n /** How it was chosen, for observability and the per-case `--update` behaviour. */\n reason: 'extends-base' | 'other-chain' | 'none'\n}\n\n/**\n * The `extends` targets of a tsconfig as a list. `extends` may be a string or, since\n * TypeScript 5.0, an array of strings; both are normalised here (absent/unreadable\n * or non-string entries yield an empty list).\n */\nfunction readExtends(absolutePath: string): string[] {\n let parsed: { extends?: unknown }\n try {\n parsed = parseJsonc(readFileSync(absolutePath, 'utf8'), absolutePath)\n } catch {\n return [] // malformed candidate: skip it, try the next\n }\n if (typeof parsed.extends === 'string') return [parsed.extends]\n if (Array.isArray(parsed.extends)) {\n return parsed.extends.filter((entry): entry is string => typeof entry === 'string')\n }\n return []\n}\n\nexport function resolveTsconfigTarget(moduleDir: string): TsconfigTarget {\n let existing: string | undefined\n for (const candidate of CANDIDATES) {\n const absolutePath = join(moduleDir, candidate)\n if (!existsSync(absolutePath)) continue\n existing ??= candidate // remember the first tsconfig we saw\n const extendsValues = readExtends(absolutePath)\n const extendsBase = extendsValues.some((value) =>\n TARGET_EXTENDS_MARKERS.some((marker) => value.includes(marker)),\n )\n if (extendsBase) {\n return { path: candidate, reason: 'extends-base' }\n }\n }\n if (existing) return { path: existing, reason: 'other-chain' }\n return { path: 'tsconfig.json', reason: 'none' }\n}\n","/**\n * TypeScript role registration. The single entry the bootstrap\n * (`src/adapters.ts`) imports, so wiring stays greppable and the CLI never\n * changes. Adds the tsc adapter and makes it the default runner for `--typescript`.\n */\nimport { register, setDefaultRunner } from '../../core/registry.js'\nimport { TscAdapter } from './adapters/tsc/tsc.adapter.js'\n\nexport function registerTypescript(): void {\n register(new TscAdapter())\n setDefaultRunner('typescript', 'tsc')\n}\n","/**\n * Adapter bootstrap: the single place adapters are wired into the CLI.\n *\n * A `register(new MyAdapter())` call only runs if its module is imported, and the\n * CLI must not import every tool by hand, that would make the promise \"one tool =\n * one adapter, the CLI never changes\" false. So the CLI calls `registerAdapters()`\n * once at startup, and each tool ticket adds exactly ONE line here (its role's\n * registration), never touching the CLI entry point (`bin/sentinel.ts`) or the\n * registry.\n *\n * Empty until the first tool ticket lands. A tool ticket adds, e.g. (an explicit\n * module path, not a barrel, so the wiring stays greppable):\n *\n * import { registerTypescript } from './roles/typescript/register.js'\n * export function registerAdapters(): void {\n * registerTypescript()\n * }\n */\nimport { registerTypescript } from './roles/typescript/register.js'\n\nexport function registerAdapters(): void {\n registerTypescript()\n}\n","/**\n * Framework detection from a module's package.json dependencies. DETERMINISTIC:\n * a dependency maps to exactly one flavour, and a module with no framework\n * dependency is a plain TypeScript library (`node`). This is not the old silent\n * guessing (there is no \"assume react\" fallback); `node` is a real preset.\n *\n * Reusable across tools: every tool's `--update` needs the module's flavour to\n * pick its preset, so this lives in core, not in the TypeScript role.\n */\nimport type { Flavour } from './domain.js'\n\n/** The slice of a package.json we read for detection. */\nexport interface PackageDependencies {\n dependencies?: Record<string, string>\n devDependencies?: Record<string, string>\n}\n\n/**\n * A framework signal in priority order: the first whose package is present wins.\n * Ordered most-specific-first so a backend (`@nestjs/core`) is never shadowed by a\n * transitive `react`. Adding a framework is one entry here.\n */\nconst FRAMEWORK_SIGNALS: ReadonlyArray<{ flavour: Flavour; dependency: string }> = [\n { flavour: 'nest', dependency: '@nestjs/core' },\n { flavour: 'svelte', dependency: 'svelte' },\n { flavour: 'react', dependency: 'react' },\n]\n\n/** The flavour for a module, from its dependencies. `node` when no framework. */\nexport function detectFramework(packageJson: PackageDependencies): Flavour {\n const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies }\n for (const { flavour, dependency } of FRAMEWORK_SIGNALS) {\n if (dependency in dependencies) return flavour\n }\n return 'node'\n}\n","/**\n * Small text helpers: `ensure-lines` for the engine, and a line diff for the\n * `--update --dry-run` preview.\n */\n\n/**\n * Append any of `lines` not already present in `current` (matched as a full,\n * trimmed line). Idempotent: running it twice adds nothing the second time.\n * Preserves a trailing newline and never duplicates existing lines.\n */\nexport function ensureLines(current: string, lines: string[]): string {\n const present = new Set(current.split('\\n').map((line) => line.trim()))\n const missing = lines.filter((line) => !present.has(line.trim()))\n if (missing.length === 0) return current\n const prefix = current.length === 0 || current.endsWith('\\n') ? current : current + '\\n'\n return prefix + missing.join('\\n') + '\\n'\n}\n\n/** Split into lines for diffing, dropping a single trailing newline (not content). */\nfunction toLines(text: string): string[] {\n if (text.length === 0) return []\n return text.replace(/\\n$/, '').split('\\n')\n}\n\n/**\n * A minimal line-level diff (LCS-based) between `before` and `after`. Returns one\n * string per line, prefixed `- ` (removed), `+ ` (added) or ` ` (unchanged), so a\n * dry-run can show exactly what a write would change instead of dumping the result.\n */\nexport function diffLines(before: string, after: string): string[] {\n const from = toLines(before)\n const to = toLines(after)\n // Longest common subsequence length table, filled bottom-up. `lcs[i][j]` is the\n // LCS length of from[i:] and to[j:]; reads past the edge are 0 (the base case).\n const lcs: number[][] = Array.from({ length: from.length + 1 }, () =>\n new Array<number>(to.length + 1).fill(0),\n )\n const cell = (i: number, j: number): number => lcs[i]?.[j] ?? 0\n for (let i = from.length - 1; i >= 0; i--) {\n const row = lcs[i]\n if (!row) continue\n for (let j = to.length - 1; j >= 0; j--) {\n row[j] = from[i] === to[j] ? cell(i + 1, j + 1) + 1 : Math.max(cell(i + 1, j), cell(i, j + 1))\n }\n }\n\n const out: string[] = []\n let i = 0\n let j = 0\n while (i < from.length && j < to.length) {\n if (from[i] === to[j]) {\n out.push(` ${from[i] ?? ''}`)\n i++\n j++\n } else if (cell(i + 1, j) >= cell(i, j + 1)) {\n out.push(`- ${from[i] ?? ''}`)\n i++\n } else {\n out.push(`+ ${to[j] ?? ''}`)\n j++\n }\n }\n while (i < from.length) out.push(`- ${from[i++] ?? ''}`)\n while (j < to.length) out.push(`+ ${to[j++] ?? ''}`)\n return out\n}\n","/**\n * Plan application: the engine's filesystem port.\n *\n * Adapters return a pure, declarative `UpdatePlan` (see `FileOperation`); this is\n * the ONE place that touches the disk. It resolves paths against the module root,\n * reads existing files, does the generic read/merge/write mechanics, and writes.\n * Keeping all IO here is what lets adapters stay pure and decoupled from the repo\n * layout: they say WHAT to change, the engine knows HOW and WHERE.\n *\n * Three guarantees, so `--update` is safe to run on real projects:\n * - CONTENT-PRESERVING merge: `merge-json` edits the file in place via\n * jsonc-parser, so a tsconfig's comments, key order and formatting survive.\n * - PREPARED-THEN-WRITTEN: the whole plan is computed before any write, so an\n * invalid operation fails before touching disk, and each file is written\n * atomically (temp file + rename) so an interrupted write never leaves a\n * truncated file. (Across multiple files the writes are sequential, not one\n * transaction: a crash mid-plan can leave earlier files written, recoverable\n * via git; a single file is always all-or-nothing.)\n * - CONFINED: every path is resolved and rejected if it escapes the module root.\n */\nimport { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'\nimport { resolve, sep } from 'node:path'\n\nimport { applyEdits, modify } from 'jsonc-parser'\n\nimport { isPlainObject } from '../shared/deep-merge.js'\nimport { ensureLines } from '../shared/text.js'\nimport type { FileOperation, UpdatePlan } from './types.js'\n\n/** Resolve a plan-relative path and reject anything escaping the module root. */\nfunction resolveWithinRoot(cwd: string, relativePath: string): string {\n const root = resolve(cwd)\n const absolutePath = resolve(root, relativePath)\n if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {\n throw new Error(`Refusing to write outside the module root: \"${relativePath}\".`)\n }\n return absolutePath\n}\n\nfunction readIfExists(absolutePath: string): string | undefined {\n return existsSync(absolutePath) ? readFileSync(absolutePath, 'utf8') : undefined\n}\n\n/** Yield every leaf ([path, value]) of a nested object, for deep in-place edits. */\nfunction* leaves(\n value: Record<string, unknown>,\n prefix: string[] = [],\n): Generator<[path: string[], leaf: unknown]> {\n for (const [key, keyValue] of Object.entries(value)) {\n const path = [...prefix, key]\n if (isPlainObject(keyValue)) yield* leaves(keyValue, path)\n else yield [path, keyValue]\n }\n}\n\n/**\n * Deep-merge `value` into JSONC `current` IN PLACE (preserving comments/format).\n * Each leaf is set at its own path, so sibling keys, including a project's own\n * `paths`/`include`, survive untouched.\n */\nfunction mergeJsonc(current: string, value: Record<string, unknown>): string {\n let text = current.trim().length > 0 ? current : '{}\\n'\n for (const [path, leaf] of leaves(value)) {\n const edits = modify(text, path, leaf, {\n formattingOptions: { insertSpaces: true, tabSize: 2 },\n })\n text = applyEdits(text, edits)\n }\n return text.endsWith('\\n') ? text : text + '\\n'\n}\n\n/** Remove each key path from JSONC `current`, preserving comments/formatting. */\nfunction removeJsoncKeys(current: string, keys: string[][]): string {\n let text = current.trim().length > 0 ? current : '{}\\n'\n for (const path of keys) {\n const edits = modify(text, path, undefined, {\n formattingOptions: { insertSpaces: true, tabSize: 2 },\n })\n text = applyEdits(text, edits)\n }\n return text.endsWith('\\n') ? text : text + '\\n'\n}\n\n/** Apply one operation to the current file content (pure transform). */\nfunction applyOperationTo(current: string, operation: FileOperation): string {\n switch (operation.kind) {\n case 'write':\n return operation.contents\n case 'merge-json':\n return mergeJsonc(current, operation.value)\n case 'ensure-lines':\n return ensureLines(current, operation.lines)\n case 'remove-json-keys':\n return removeJsoncKeys(current, operation.keys)\n default: {\n // Exhaustiveness: a new FileOperation kind without a case here fails to compile.\n const unreachable: never = operation\n throw new Error(`Unknown file operation: ${JSON.stringify(unreachable)}`)\n }\n }\n}\n\n/** A file the plan would write: its original content and the computed result. */\nexport interface PreparedFile {\n path: string\n absolutePath: string\n before: string\n after: string\n}\n\n/**\n * Compute what a plan WOULD write, without touching the disk (for `--dry-run`).\n * All-or-nothing (throws before returning anything on a bad op), and multiple\n * operations on the SAME file chain in order, so `before` is the original content\n * and `after` is the final result.\n */\nexport function preparePlan(cwd: string, plan: UpdatePlan): PreparedFile[] {\n const prepared = new Map<string, PreparedFile>()\n for (const operation of plan.operations) {\n const absolutePath = resolveWithinRoot(cwd, operation.path)\n const existing = prepared.get(operation.path)\n const before = existing?.before ?? readIfExists(absolutePath) ?? ''\n const current = existing?.after ?? before\n prepared.set(operation.path, {\n path: operation.path,\n absolutePath,\n before,\n after: applyOperationTo(current, operation),\n })\n }\n return [...prepared.values()]\n}\n\n/**\n * Write `contents` to `absolutePath` atomically: write a sibling temp file, then\n * rename it over the target. rename is atomic on a single filesystem, so a reader\n * (or an interrupted run) never sees a half-written file, only the old or new one.\n */\nfunction writeFileAtomic(absolutePath: string, contents: string): void {\n const tempPath = `${absolutePath}.sentinel-${process.pid}.tmp`\n writeFileSync(tempPath, contents)\n renameSync(tempPath, absolutePath)\n}\n\n/**\n * Apply a plan against the module root; return the paths written. Prepares the\n * whole plan before the first write, then writes each file atomically.\n */\nexport function applyPlan(cwd: string, plan: UpdatePlan): string[] {\n const prepared = preparePlan(cwd, plan)\n for (const file of prepared) writeFileAtomic(file.absolutePath, file.after)\n return prepared.map((file) => file.path)\n}\n","/**\n * Generic deep-merge for plain JSON objects. Used by the engine to apply a\n * `merge-json` op (pin the keys sentinel owns while preserving a project's own).\n */\n\n/** True for a mergeable plain object (not null, not an array). */\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Deep-merge `patch` into `base`, preserving keys `patch` does not mention.\n * Scalars and arrays from `patch` replace wholesale (no array concat surprises).\n */\nexport function deepMerge(\n base: Record<string, unknown>,\n patch: Record<string, unknown>,\n): Record<string, unknown> {\n const out: Record<string, unknown> = { ...base }\n for (const [key, value] of Object.entries(patch)) {\n const existing = out[key]\n out[key] = isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value\n }\n return out\n}\n","/**\n * Verb dispatch. Maps the 4 CLI verbs onto adapter methods, uniformly for every\n * target/runner. This is the whole \"engine\": resolve an adapter, build a\n * normalized context, call the matching method. Tool branches only add adapters.\n *\n * The engine owns IO: for `--update`/`--inspect` it resolves the flavour (explicit\n * `--flavour`, else `detectFramework` on the module's package.json) and hands the\n * adapter a normalized context; the adapter plans, the engine applies (`applyPlan`).\n * `--run`/`--report` hand the adapter a `RunContext` to point its tool at the module.\n */\nimport { readProjectPackageJson } from '../shared/package-json.js'\nimport { diffLines } from '../shared/text.js'\nimport { applyPlan, preparePlan } from './apply-plan.js'\nimport { detectFramework } from './detect-framework.js'\nimport type { Flavour, Target, Verb } from './domain.js'\nimport { resolve } from './registry.js'\nimport type { RunContext, UpdateContext, UpdatePlan } from './types.js'\n\nexport type { Verb }\n\nexport interface DispatchOptions {\n verb: Verb\n target: Target\n runner?: string\n module: string\n cwd: string\n /**\n * Declared flavour, when known (`--flavour`). `--run`/`--report` don't need it;\n * `--update`/`--inspect` resolve it, falling back to `detectFramework`.\n */\n flavour?: Flavour\n ci: boolean\n fix: boolean\n /** `--dry-run`: for `--update`, preview the plan instead of writing anything. */\n dryRun?: boolean\n /** `--json`: emit the dry-run preview as machine-readable JSON. */\n json?: boolean\n}\n\n/** The flavour for update/inspect: the explicit one, else detected from deps. */\nfunction resolveFlavour(opts: DispatchOptions): Flavour {\n return opts.flavour ?? detectFramework(readProjectPackageJson(opts.cwd))\n}\n\n/**\n * `--update --dry-run`: show what the plan WOULD change and write nothing. Prints a\n * per-file line diff (or JSON with `--json`), plus the adapter's notes. An empty\n * plan is reported as \"nothing to change\" so the developer gets a clear signal\n * rather than silence. Always exits 0: previewing never fails a build.\n */\nfunction previewPlan(opts: DispatchOptions, plan: UpdatePlan): number {\n // Only files the plan would actually change: an idempotent re-run computes an\n // `after` equal to `before`, which is a no-op, not a change to preview.\n const changed = preparePlan(opts.cwd, plan).filter((file) => file.before !== file.after)\n if (opts.json) {\n process.stdout.write(\n JSON.stringify(\n {\n dryRun: true,\n notes: plan.notes ?? [],\n files: changed.map(({ path, before, after }) => ({\n path,\n action: before.length === 0 ? 'create' : 'update',\n before,\n after,\n })),\n },\n null,\n 2,\n ) + '\\n',\n )\n return 0\n }\n\n process.stderr.write(' dry run: no files written\\n')\n for (const note of plan.notes ?? []) process.stderr.write(` ${note}\\n`)\n if (changed.length === 0) {\n process.stderr.write(' nothing to change\\n')\n return 0\n }\n for (const { path, before, after } of changed) {\n const action = before.length === 0 ? 'create' : 'update'\n process.stdout.write(`\\n ${action} ${path}\\n`)\n for (const line of diffLines(before, after)) process.stdout.write(` ${line}\\n`)\n }\n return 0\n}\n\nexport async function dispatch(opts: DispatchOptions): Promise<number> {\n const adapter = resolve(opts.target, opts.flavour, opts.runner)\n const flavour = resolveFlavour(opts)\n const ctx: RunContext = {\n module: opts.module,\n cwd: opts.cwd,\n flavour,\n ci: opts.ci,\n fix: opts.fix,\n }\n\n switch (opts.verb) {\n case 'run': {\n const res = await adapter.run(ctx)\n return res.code\n }\n case 'inspect': {\n const config = await adapter.inspect(ctx)\n process.stdout.write(JSON.stringify(config, null, 2) + '\\n')\n return 0\n }\n case 'update': {\n // Engine resolves + normalizes the context; the adapter plans, the engine\n // applies the operations against the module root.\n const context: UpdateContext = { cwd: opts.cwd, flavour }\n const plan = await adapter.plan(context)\n if (opts.dryRun) {\n // Preview only: compute what WOULD be written, touch nothing on disk.\n return previewPlan(opts, plan)\n }\n const written = applyPlan(opts.cwd, plan)\n for (const path of written) process.stderr.write(` wrote ${path}\\n`)\n for (const note of plan.notes ?? []) process.stderr.write(` ${note}\\n`)\n return 0\n }\n case 'report': {\n const res = await adapter.report(ctx)\n if (res.metrics) {\n process.stdout.write(JSON.stringify(res.metrics, null, 2) + '\\n')\n }\n return res.code\n }\n default: {\n // Exhaustiveness: every Verb is handled above. If this line ever fails to\n // compile, a new verb was added without a case here.\n const unreachable: never = opts.verb\n throw new Error(`Unknown verb: ${String(unreachable)}`)\n }\n }\n}\n"],"mappings":"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","names":["existsSync","readFileSync","join","existsSync","dirname","join","existsSync","readFileSync","join","readFileSync","join","existsSync","readFileSync","join","existsSync","i","j","existsSync","readFileSync","resolve","resolve","existsSync","readFileSync"]}
@@ -0,0 +1,243 @@
1
+ declare function registerAdapters(): void;
2
+
3
+ /**
4
+ * The domain vocabulary: the fixed sets of verbs, targets, and flavours, and the
5
+ * types derived from them. This is the model, and the extension point: adding a
6
+ * verb / target / flavour is a one-line edit to a list here, and because the types
7
+ * are DERIVED (`(typeof LIST)[number]`), the compiler forces every switch/handler
8
+ * to cover the new member.
9
+ *
10
+ * Tunable behaviour (defaults, detection signals, marker filenames) lives in
11
+ * `settings.ts`, not here.
12
+ */
13
+ /** Verbs: what to do. Each maps to an adapter method in dispatch. */
14
+ declare const VERBS: readonly ["run", "inspect", "update", "report"];
15
+ type Verb = (typeof VERBS)[number];
16
+ /** Targets: the kind of check. The CLI `--<target>` flags map 1:1 to these. */
17
+ declare const TARGETS: readonly ["lint", "format", "typescript", "build", "test", "static-analysis", "runtime-analysis", "arch"];
18
+ type Target = (typeof TARGETS)[number];
19
+ /** Flavours: the stack preset a project resolves to (strict by default). */
20
+ declare const FLAVOURS: readonly ["react", "nest", "svelte", "node"];
21
+ type Flavour = (typeof FLAVOURS)[number];
22
+
23
+ /**
24
+ * The adapter contract, as PURE types (no runtime). Every tool (eslint, biome,
25
+ * oxlint, tsc, tsgo, vite, vitest, ...) plugs into sentinel by implementing the
26
+ * `Adapter` interface. Kept type-only and separate from `base-adapter.ts` (the
27
+ * class) so a module that needs only types never pulls in runtime code.
28
+ *
29
+ * sentinel does NOT reimplement tools. An adapter describes config as a declarative
30
+ * plan and shells out to the tool's real binary; the engine owns all IO.
31
+ */
32
+
33
+ /**
34
+ * Normalized context for `--update`, built and guaranteed by the engine. The
35
+ * adapter TRUSTS this shape (no re-validation): `cwd` is the absolute module root,
36
+ * `flavour` is resolved (explicit `--flavour`, else detected). The adapter may READ
37
+ * the module through bounded resolvers to plan, but never writes, the engine
38
+ * applies the returned plan.
39
+ */
40
+ interface UpdateContext {
41
+ /** Absolute path to the module root. */
42
+ cwd: string;
43
+ /** The resolved, validated flavour. */
44
+ flavour: Flavour;
45
+ }
46
+ /** Resolved context for a single `--run`/`--report` against one module. */
47
+ interface RunContext {
48
+ /** Module name (app / lib / backend / bff / …; the nx project name). */
49
+ module: string;
50
+ /** Absolute path to the module root. */
51
+ cwd: string;
52
+ /** The resolved flavour (explicit `--flavour`, else detected). Guaranteed set. */
53
+ flavour: Flavour;
54
+ /** CI mode: adapters must exit non-zero on failure. */
55
+ ci: boolean;
56
+ /** Apply auto-fixes where the tool supports it. */
57
+ fix: boolean;
58
+ }
59
+ /**
60
+ * A single, DECLARATIVE file operation an `--update` wants to happen. Adapters
61
+ * describe intent in tool terms; they never touch the filesystem. The engine
62
+ * (which owns IO and the repo layout) executes these against the project root.
63
+ *
64
+ * The split matters: an adapter says WHAT and WHY (tool meaning); the engine does
65
+ * the read/merge/write mechanics (format-agnostic plumbing). So an adapter stays
66
+ * a pure function of the flavour, with no coupling to where files live.
67
+ */
68
+ type FileOperation =
69
+ /** Create or replace a file the adapter fully owns (the thin stub). */
70
+ {
71
+ kind: 'write';
72
+ path: string;
73
+ contents: string;
74
+ }
75
+ /**
76
+ * Deep-merge these keys into an existing JSON config, PRESERVING everything
77
+ * else. This is how a project's own tsconfig `paths`/`include` survive: the
78
+ * adapter supplies only the keys sentinel owns; the engine reads, merges, writes.
79
+ */
80
+ | {
81
+ kind: 'merge-json';
82
+ path: string;
83
+ value: Record<string, unknown>;
84
+ }
85
+ /**
86
+ * Idempotently ensure each line is present in a file (e.g. add an import to an
87
+ * existing test setup). The engine reads, adds only what is missing, writes.
88
+ */
89
+ | {
90
+ kind: 'ensure-lines';
91
+ path: string;
92
+ lines: string[];
93
+ }
94
+ /**
95
+ * Remove keys from an existing JSON/JSONC file (each `keys` entry is a path,
96
+ * e.g. `['compilerOptions', 'strict']`), preserving comments and formatting.
97
+ * Used to strip preset-owned overrides so a migrated stub stays thin.
98
+ */
99
+ | {
100
+ kind: 'remove-json-keys';
101
+ path: string;
102
+ keys: string[][];
103
+ };
104
+ /**
105
+ * The result of planning an `--update`: an ordered list of declarative operations,
106
+ * plus optional human notes (what was created/stripped/skipped) surfaced by the
107
+ * CLI. Multi-operation on purpose; the drift guard and golden tests diff the plan.
108
+ */
109
+ interface UpdatePlan {
110
+ operations: FileOperation[];
111
+ notes?: string[];
112
+ }
113
+ /** Outcome of `run` / `report`. */
114
+ interface AdapterResult {
115
+ /** True if the check passed (no violations / build ok). */
116
+ ok: boolean;
117
+ /** Process exit code from the underlying tool. */
118
+ code: number;
119
+ /** Structured metrics for `--report` (adapter-defined). */
120
+ metrics?: Record<string, unknown>;
121
+ }
122
+ /**
123
+ * One tool implementation for one target (and, optionally, a subset of flavours,
124
+ * see `appliesTo`). The 4 CLI verbs map onto these: `--run` -> run, `--inspect`
125
+ * -> inspect, `--update` -> plan(+write), `--report` -> report.
126
+ */
127
+ interface Adapter {
128
+ /** The target this adapter serves (e.g. 'lint'). */
129
+ readonly target: Target;
130
+ /** The runner name, selectable via `--runner` (e.g. 'eslint'). */
131
+ readonly runner: string;
132
+ /**
133
+ * Whether this adapter handles the given flavour. Resolution filters candidates
134
+ * by this, so a React-only adapter is never picked for a Nest project, and two
135
+ * adapters can share a (target, runner) if they specialise different flavours.
136
+ */
137
+ appliesTo(flavour: Flavour): boolean;
138
+ /**
139
+ * Plan what `--update` should write, as declarative operations (see
140
+ * `FileOperation`). Given a normalized `UpdateContext`, the adapter may READ the
141
+ * module (through bounded resolvers) to decide the ops, but never writes, the
142
+ * engine applies them and owns all IO. The drift guard diffs against this plan.
143
+ */
144
+ plan(context: UpdateContext): UpdatePlan | Promise<UpdatePlan>;
145
+ /** Execute the tool against the target project. Used by `--run`. */
146
+ run(ctx: RunContext): Promise<AdapterResult>;
147
+ /**
148
+ * The module's resolved configuration, for `--inspect`: what preset it uses and
149
+ * how it was resolved. Takes the run context so it can report the config as
150
+ * actually resolved in the module, not just a generic preset.
151
+ */
152
+ inspect(ctx: RunContext): Promise<unknown>;
153
+ /** Metrics/health for `--report` (bundle size, error counts, complexity, ...). */
154
+ report(ctx: RunContext): Promise<AdapterResult>;
155
+ }
156
+
157
+ /**
158
+ * `BaseAdapter`: an optional convenience base class for adapters. This is the
159
+ * runtime part of the contract (the types live in `types.ts`); it provides
160
+ * default `inspect`/`report` that throw "not implemented yet", so an adapter can
161
+ * extend it and implement only what it needs.
162
+ */
163
+
164
+ declare abstract class BaseAdapter implements Adapter {
165
+ abstract readonly target: Target;
166
+ abstract readonly runner: string;
167
+ abstract appliesTo(flavour: Flavour): boolean;
168
+ abstract plan(context: UpdateContext): UpdatePlan | Promise<UpdatePlan>;
169
+ abstract run(ctx: RunContext): Promise<AdapterResult>;
170
+ inspect(_ctx: RunContext): Promise<unknown>;
171
+ report(_ctx: RunContext): Promise<AdapterResult>;
172
+ }
173
+
174
+ /**
175
+ * Framework detection from a module's package.json dependencies. DETERMINISTIC:
176
+ * a dependency maps to exactly one flavour, and a module with no framework
177
+ * dependency is a plain TypeScript library (`node`). This is not the old silent
178
+ * guessing (there is no "assume react" fallback); `node` is a real preset.
179
+ *
180
+ * Reusable across tools: every tool's `--update` needs the module's flavour to
181
+ * pick its preset, so this lives in core, not in the TypeScript role.
182
+ */
183
+
184
+ /** The slice of a package.json we read for detection. */
185
+ interface PackageDependencies {
186
+ dependencies?: Record<string, string>;
187
+ devDependencies?: Record<string, string>;
188
+ }
189
+ /** The flavour for a module, from its dependencies. `node` when no framework. */
190
+ declare function detectFramework(packageJson: PackageDependencies): Flavour;
191
+
192
+ interface DispatchOptions {
193
+ verb: Verb;
194
+ target: Target;
195
+ runner?: string;
196
+ module: string;
197
+ cwd: string;
198
+ /**
199
+ * Declared flavour, when known (`--flavour`). `--run`/`--report` don't need it;
200
+ * `--update`/`--inspect` resolve it, falling back to `detectFramework`.
201
+ */
202
+ flavour?: Flavour;
203
+ ci: boolean;
204
+ fix: boolean;
205
+ /** `--dry-run`: for `--update`, preview the plan instead of writing anything. */
206
+ dryRun?: boolean;
207
+ /** `--json`: emit the dry-run preview as machine-readable JSON. */
208
+ json?: boolean;
209
+ }
210
+ declare function dispatch(opts: DispatchOptions): Promise<number>;
211
+
212
+ /**
213
+ * Adapter registry. Tool branches register their adapters here; the CLI resolves
214
+ * an adapter by (target, flavour, runner). A default runner per target keeps the
215
+ * common invocation tool-agnostic (`sentinel --lint`), while `--runner` overrides
216
+ * it. Resolution is flavour-aware: candidates are filtered by `appliesTo(flavour)`,
217
+ * so a React-only adapter is never picked for a Nest project, and two adapters can
218
+ * share a (target, runner) if they specialise different flavours.
219
+ */
220
+
221
+ /**
222
+ * Register a tool adapter (called from each role's registration, wired into the
223
+ * bootstrap in `src/adapters.ts`). No (target, runner) uniqueness guard here: two
224
+ * adapters may share one for different flavours; a genuine clash (same target,
225
+ * runner AND flavour) is caught at resolve time, where the flavour is known.
226
+ */
227
+ declare function register(adapter: Adapter): void;
228
+ /** Set the default runner for a target. */
229
+ declare function setDefaultRunner(target: Target, runner: string): void;
230
+ /** All registered adapters (for `--all`, listing, and reports). */
231
+ declare function all(): readonly Adapter[];
232
+ /**
233
+ * Resolve one adapter by target, honouring an explicit `--runner` or the target's
234
+ * default. When a `flavour` is known it also filters by `appliesTo`, so a
235
+ * React-only adapter is never picked for Nest; when it is absent (sentinel does
236
+ * not detect it), every adapter for the target is a candidate. Throws with an
237
+ * actionable message for each failure mode: no adapter for the target (yet), none
238
+ * that handles the flavour, an ambiguous choice, an unknown runner, or two
239
+ * adapters claiming the same (target, runner, flavour).
240
+ */
241
+ declare function resolve(target: Target, flavour?: Flavour, runner?: string): Adapter;
242
+
243
+ export { type Adapter, type AdapterResult, BaseAdapter, type DispatchOptions, type FileOperation, type Flavour, type PackageDependencies, type RunContext, type Target, type UpdateContext, type UpdatePlan, type Verb, all, detectFramework, dispatch, register, registerAdapters, resolve, setDefaultRunner };
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import {
2
+ BaseAdapter,
3
+ all,
4
+ detectFramework,
5
+ dispatch,
6
+ register,
7
+ registerAdapters,
8
+ resolve,
9
+ setDefaultRunner
10
+ } from "./chunk-D6QBEHMF.js";
11
+ export {
12
+ BaseAdapter,
13
+ all,
14
+ detectFramework,
15
+ dispatch,
16
+ register,
17
+ registerAdapters,
18
+ resolve,
19
+ setDefaultRunner
20
+ };
21
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "noImplicitAny": true,
6
+ "noUnusedLocals": true,
7
+ "noUnusedParameters": true,
8
+ "noFallthroughCasesInSwitch": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "target": "ES2022",
13
+ "module": "commonjs",
14
+ "moduleResolution": "node",
15
+ "lib": [
16
+ "ES2022"
17
+ ],
18
+ "types": [
19
+ "node"
20
+ ],
21
+ "experimentalDecorators": true,
22
+ "emitDecoratorMetadata": true,
23
+ "declaration": true,
24
+ "sourceMap": true
25
+ }
26
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "noImplicitAny": true,
6
+ "noUnusedLocals": true,
7
+ "noUnusedParameters": true,
8
+ "noFallthroughCasesInSwitch": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "target": "ES2022",
13
+ "module": "NodeNext",
14
+ "moduleResolution": "NodeNext",
15
+ "lib": [
16
+ "ES2022"
17
+ ],
18
+ "types": [
19
+ "node"
20
+ ],
21
+ "declaration": true
22
+ }
23
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "noImplicitAny": true,
6
+ "noUnusedLocals": true,
7
+ "noUnusedParameters": true,
8
+ "noFallthroughCasesInSwitch": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "target": "ES2023",
13
+ "lib": [
14
+ "ES2023",
15
+ "DOM",
16
+ "DOM.Iterable"
17
+ ],
18
+ "module": "ESNext",
19
+ "moduleResolution": "bundler",
20
+ "jsx": "react-jsx",
21
+ "useDefineForClassFields": true,
22
+ "isolatedModules": true,
23
+ "moduleDetection": "force",
24
+ "noEmit": true,
25
+ "noUncheckedSideEffectImports": true
26
+ }
27
+ }
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@hublo/sentinel",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Hublo",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/hublo/sentinel.git"
11
+ },
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "bin": {
16
+ "sentinel": "./dist/bin/sentinel.js"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ },
23
+ "./tsconfig/react": {
24
+ "default": "./dist/tsconfig/react.json"
25
+ },
26
+ "./tsconfig/nest": {
27
+ "default": "./dist/tsconfig/nest.json"
28
+ },
29
+ "./tsconfig/node": {
30
+ "default": "./dist/tsconfig/node.json"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist"
35
+ ],
36
+ "dependencies": {
37
+ "commander": "^13.0.0",
38
+ "jsonc-parser": "^3.3.1"
39
+ },
40
+ "devDependencies": {
41
+ "@changesets/cli": "^2.27.0",
42
+ "@eslint/js": "^10.0.1",
43
+ "@types/node": "^22.0.0",
44
+ "eslint": "^10.8.0",
45
+ "eslint-config-prettier": "^10.1.8",
46
+ "nx": "^23.1.0",
47
+ "prettier": "^3.9.6",
48
+ "tsup": "^8.3.0",
49
+ "tsx": "^4.19.0",
50
+ "typescript": "^5.9.0",
51
+ "typescript-eslint": "^8.65.0",
52
+ "vitest": "^2.1.0"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "scripts": {
58
+ "build": "tsup && tsx scripts/build-presets.ts",
59
+ "dev": "tsup --watch",
60
+ "typecheck": "tsc --noEmit",
61
+ "lint": "eslint .",
62
+ "lint:fix": "eslint . --fix",
63
+ "format": "prettier --write .",
64
+ "format:check": "prettier --check .",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest",
67
+ "test:e2e": "vitest run tests/e2e",
68
+ "cli": "node --import tsx bin/sentinel.ts",
69
+ "changeset": "changeset",
70
+ "release": "pnpm build && changeset publish"
71
+ }
72
+ }