@hublo/sentinel 1.2.0-alpha.1 → 1.2.0-alpha.3

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.
package/README.md CHANGED
@@ -151,7 +151,7 @@ pnpm dlx @hublo/sentinel@<exact-version> --inspect --typescript --module <name>
151
151
  - [`docs/using-sentinel.md`](docs/using-sentinel.md) — every verb for every role, scoping a run to some files, what a fully adopted module looks like, and how versions move between modules.
152
152
  - [`docs/lint-adoption.md`](docs/lint-adoption.md) — migrating a module from ESLint to Oxlint: the two steps, what `--init` writes and removes, the two react tiers, and the failures worth recognising.
153
153
  - [`docs/format-adoption.md`](docs/format-adoption.md) — migrating a module from Prettier to oxfmt: why this config is materialized rather than a stub, how a module keeps its own formatting, and what did not survive the move.
154
- - [`docs/build-adoption.md`](docs/build-adoption.md) — moving a React app's Vite toolchain into sentinel: what the config becomes, what sentinel owns versus what stays the app's, why overriding merges instead of spreading, and why an existing config is not rewritten.
154
+ - [`docs/build-adoption.md`](docs/build-adoption.md) — moving a React app's Vite toolchain into sentinel: adoption changes the config's IMPORTS and nothing else, what sentinel owns versus what stays the app's, and why reading nothing is what makes losing nothing a guarantee.
155
155
  - [`docs/typescript-adoption.md`](docs/typescript-adoption.md) — the adoption cheat sheet: the two adoption steps, the command model (verb x type x location), options, reading a report, and troubleshooting.
156
156
  - [`docs/typescript-traces.md`](docs/typescript-traces.md) — a **generated, versioned** reference of live command + output traces (every verb, option, config result and edge case) against the mock monorepo. Regenerate after CLI changes with `pnpm docs:traces`.
157
157
 
@@ -161,7 +161,7 @@ Every check is described by three layers:
161
161
 
162
162
  | Layer | Flag | What it is | Examples |
163
163
  | -------------------- | --------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
164
- | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `test` `static-analysis` `runtime-analysis` |
164
+ | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `dev` `test` `static-analysis` `runtime-analysis` |
165
165
  | **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
166
166
  | **preset** (preset) | detected / `--preset` | the _variant_ per stack (strict by default) | `react` `nest` `node` `svelte` (svelte: lint + format; no tsconfig preset yet, so the TypeScript role stands aside on those modules and the other two adopt) |
167
167
 
@@ -1 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import '@tailwindcss/vite';
3
+ import '@vitejs/plugin-react';
4
+ import 'nitro/vite';
5
+ import 'vite-plugin-svgr';
6
+ import 'vite';
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ LONG_RUNNING_TARGETS,
3
4
  PRESET_NAMES,
4
5
  PresetUnsupportedError,
5
6
  SWEEPABLE_TARGETS,
@@ -13,22 +14,21 @@ import {
13
14
  ensureWorkspacePrep,
14
15
  findWorkspaceRoot,
15
16
  inspectWorkspacePrep,
17
+ moduleName,
16
18
  palette,
17
- readNxProjectName,
18
19
  readOwnVersion,
19
20
  readProjectPackageJson,
20
21
  registerAdapters,
21
22
  resolve,
22
23
  resolveBin
23
- } from "../chunk-FLJ2QNFF.js";
24
- import "../chunk-XVDOQ3G3.js";
24
+ } from "../chunk-YZ4EKBK7.js";
25
25
 
26
26
  // bin/sentinel.ts
27
27
  import { program } from "commander";
28
28
 
29
29
  // src/core/context.ts
30
30
  import { existsSync } from "fs";
31
- import { basename, join as join2 } from "path";
31
+ import { join as join2 } from "path";
32
32
 
33
33
  // src/core/discover-modules.ts
34
34
  import { execFileSync } from "child_process";
@@ -96,7 +96,7 @@ function resolveContext(cwd2, opts2) {
96
96
  if (opts2.ci) {
97
97
  throw new Error("--ci selects the affected set from the workspace root; run it there.");
98
98
  }
99
- const name = readNxProjectName(cwd2) ?? basename(cwd2);
99
+ const name = moduleName(cwd2);
100
100
  return { modules: [{ name, root: cwd2 }], scope: "cwd-module" };
101
101
  }
102
102
  if (atRoot) {
@@ -463,6 +463,15 @@ async function runVerb(ctx) {
463
463
  const { modules, scope } = resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci });
464
464
  const out = palette(process.stdout);
465
465
  const err = palette(process.stderr);
466
+ const longRunning = ctx.targets.filter((target) => LONG_RUNNING_TARGETS.includes(target));
467
+ if (longRunning.length > 0 && modules.length > 1) {
468
+ process.stderr.write(
469
+ err.fail(
470
+ `sentinel: ${longRunning.map((t) => `--${t}`).join(", ")} starts a server that does not return, so it runs against ONE module. This resolved to ${modules.length} [${scope}]. Run it from inside the module's directory, or pass --module <name>.`
471
+ ) + "\n"
472
+ );
473
+ return 1;
474
+ }
466
475
  const shaped = ctx.verb === "run" && ctx.json ? "report" : ctx.verb;
467
476
  const replacement = {
468
477
  report: "--run --json",
@@ -579,6 +588,17 @@ registerAdapters();
579
588
  var separator = process.argv.indexOf("--");
580
589
  var toolArgs = separator === -1 ? [] : process.argv.slice(separator + 1);
581
590
  var cliArgv = separator === -1 ? process.argv : process.argv.slice(0, separator);
591
+ var TARGET_HELP = {
592
+ lint: "linting",
593
+ format: "formatting",
594
+ typescript: "type checking",
595
+ build: "build",
596
+ dev: "dev server (long-running: never swept by an unqualified --run)",
597
+ test: "tests",
598
+ "static-analysis": "cycles, complexity, duplication, centrality",
599
+ "runtime-analysis": "bundle, Lighthouse, web vitals",
600
+ arch: "architecture boundaries"
601
+ };
582
602
  program.name("sentinel").description("One CLI that guards code health: presets, analysis, and arch checks.").version(readOwnVersion()).configureHelp({ sortOptions: false }).showSuggestionAfterError(true).showHelpAfterError('(run "sentinel --help" for usage)').addHelpText(
583
603
  "before",
584
604
  [
@@ -589,7 +609,7 @@ program.name("sentinel").description("One CLI that guards code health: presets,
589
609
  " --ci (affected), or all modules. --init targets one module only.",
590
610
  ""
591
611
  ].join("\n")
592
- ).option("--run", "execute the target tool").option("--inspect", "show the resolved configuration").option("--init", "set up a module: write its config stubs + the workspace prep it needs").option("--migrate", "planned: change an already-initialized setup (not available yet)").option("--report", "deprecated: use --run --json").option("--status", "deprecated: use --inspect").option("--lint", "linting").option("--format", "formatting").option("--typescript", "type checking").option("--build", "build").option("--test", "tests").option("--static-analysis", "cycles, complexity, duplication, centrality").option("--runtime-analysis", "bundle, Lighthouse, web vitals").option("--arch", "architecture boundaries").option("--all", "every target").option(
612
+ ).option("--run", "execute the target tool").option("--inspect", "show the resolved configuration").option("--init", "set up a module: write its config stubs + the workspace prep it needs").option("--migrate", "planned: change an already-initialized setup (not available yet)").option("--report", "deprecated: use --run --json").option("--status", "deprecated: use --inspect").option(
593
613
  "--module <name>",
594
614
  "from the workspace root: scope to one module (omit = all; inside a module dir, drop this)"
595
615
  ).option("--preset <name>", `the stack preset to apply (${PRESET_NAMES.join(", ")})`).option("--flavour <name>", "deprecated: use --preset").option("--runner <tool>", "override the default runner (e.g. eslint, biome)").option("--ci", "CI mode: from the root, only the affected modules; non-zero exit on failure").option("--fix", "auto-fix where applicable").option("--dry-run", "preview the changes without writing (--init)").option("--json", "machine-readable output, for every verb").option(
@@ -621,7 +641,10 @@ program.name("sentinel").description("One CLI that guards code health: presets,
621
641
  `Available now: ${available.length ? available.map((t) => `--${t}`).join(", ") : "(none yet)"}`,
622
642
  planned.length ? `Planned (ship in later tickets): ${planned.map((t) => `--${t}`).join(", ")}` : ""
623
643
  ].filter(Boolean).join("\n");
624
- }).parse(cliArgv);
644
+ });
645
+ for (const target of TARGETS) program.option(`--${target}`, TARGET_HELP[target]);
646
+ program.option("--all", "every target");
647
+ program.parse(cliArgv);
625
648
  var opts = program.opts();
626
649
  function toCamel(flag) {
627
650
  return flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
@@ -1,9 +1,3 @@
1
- import {
2
- REACT_APP_DEFAULTS,
3
- REQUIREMENTS,
4
- isPlainObject
5
- } from "./chunk-XVDOQ3G3.js";
6
-
7
1
  // src/core/registry.ts
8
2
  var adapters = [];
9
3
  var PresetUnsupportedError = class extends Error {
@@ -88,7 +82,7 @@ var BaseAdapter = class {
88
82
 
89
83
  // src/shared/package-json.ts
90
84
  import { existsSync, readFileSync } from "fs";
91
- import { dirname, join } from "path";
85
+ import { basename, dirname, join } from "path";
92
86
  import { fileURLToPath } from "url";
93
87
  function readOwnPackage() {
94
88
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -133,6 +127,10 @@ function readNxProjectName(dir) {
133
127
  return void 0;
134
128
  }
135
129
  }
130
+ function moduleName(dir) {
131
+ const declared = readProjectPackageJson(dir).name;
132
+ return readNxProjectName(dir) ?? (declared === void 0 ? basename(dir) : declared);
133
+ }
136
134
 
137
135
  // src/core/domain.ts
138
136
  var VERBS = ["run", "inspect", "init", "migrate", "report", "status"];
@@ -243,7 +241,7 @@ function parseJsonc(text, source = "config") {
243
241
 
244
242
  // src/core/config/manifest.ts
245
243
  import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
246
- import { basename, join as join3 } from "path";
244
+ import { basename as basename2, join as join3 } from "path";
247
245
  function moduleScripts(cwd) {
248
246
  try {
249
247
  const pkg = JSON.parse(readFileSync2(join3(cwd, "package.json"), "utf8"));
@@ -252,7 +250,7 @@ function moduleScripts(cwd) {
252
250
  return {};
253
251
  }
254
252
  }
255
- function moduleName(cwd) {
253
+ function moduleName2(cwd) {
256
254
  try {
257
255
  const pkg = JSON.parse(readFileSync2(join3(cwd, "package.json"), "utf8"));
258
256
  return pkg.name;
@@ -261,7 +259,7 @@ function moduleName(cwd) {
261
259
  }
262
260
  }
263
261
  function isSelfAdoption(cwd) {
264
- return moduleName(cwd) === readOwnPackage().name;
262
+ return moduleName2(cwd) === readOwnPackage().name;
265
263
  }
266
264
  function selfCommand(cwd, flags) {
267
265
  const own = readOwnPackage();
@@ -280,7 +278,7 @@ function manifestOperation(cwd, scripts) {
280
278
  return {
281
279
  kind: "merge-json",
282
280
  path: "package.json",
283
- value: { name: readNxProjectName(cwd) ?? basename(cwd), private: true, ...value }
281
+ value: { name: readNxProjectName(cwd) ?? basename2(cwd), private: true, ...value }
284
282
  };
285
283
  }
286
284
 
@@ -384,7 +382,85 @@ function keepsOtherCommands(script, sentinelCommand) {
384
382
  return script.split(SEGMENT_SEPARATOR).filter((_, index) => !isSeparatorAt(index)).some((segment) => segment.trim() !== "" && segment.trim() !== sentinelCommand);
385
383
  }
386
384
 
385
+ // src/roles/build/presets/requirements.json
386
+ var requirements_default = {
387
+ svelte: {
388
+ why: "sentinel ships Vite 8, and @sveltejs/vite-plugin-svelte caps at Vite 6 until its major 7",
389
+ requires: {
390
+ "@sveltejs/vite-plugin-svelte": "7.0.0",
391
+ svelte: "5.46.4"
392
+ }
393
+ }
394
+ };
395
+
396
+ // src/roles/build/presets/toolchain.json
397
+ var toolchain_default = {
398
+ $comment: "The build toolchain sentinel owns. One table, because three places used to hold parts of it: the list to remove from a module's package.json, the list of import specifiers to rewrite, and the map from a default import to the name sentinel re-exports it under. A package added to one and not the others gives a removal with no rewrite, or an import pointing at a package the module no longer declares.",
399
+ owned: [
400
+ {
401
+ package: "vite",
402
+ specifier: "vite",
403
+ named: ["defineConfig", "loadEnv", "mergeConfig"],
404
+ types: [
405
+ "Plugin",
406
+ "PluginOption",
407
+ "SassPreprocessorOptions",
408
+ "UserConfig",
409
+ "UserConfigExport"
410
+ ],
411
+ why: "the bundler itself. `defineConfig`, `loadEnv` and `mergeConfig` are what every config in this repo imports from it, and the types travel with them: a config importing PluginOption from a package it no longer declares builds but does not typecheck."
412
+ },
413
+ {
414
+ package: "@vitejs/plugin-react",
415
+ specifier: "@vitejs/plugin-react",
416
+ default: "react",
417
+ why: "the React transform. Bound to the Vite that runs it, so it has to come from the same install."
418
+ },
419
+ {
420
+ package: "@tailwindcss/vite",
421
+ specifier: "@tailwindcss/vite",
422
+ default: "tailwindcss",
423
+ why: "the Tailwind pipeline, identical in all three front apps."
424
+ },
425
+ {
426
+ package: "vite-plugin-svgr",
427
+ specifier: "vite-plugin-svgr",
428
+ default: "svgr",
429
+ why: "SVG as components, identical in all three front apps."
430
+ },
431
+ {
432
+ package: "nitro",
433
+ specifier: "nitro/vite",
434
+ named: ["nitro"],
435
+ why: "the SSR server. Note the package and the import specifier differ, which is exactly the kind of split that made three separate lists disagree."
436
+ }
437
+ ],
438
+ add: [
439
+ {
440
+ package: "@hublo/sentinel",
441
+ why: "the module now gets its toolchain from here, so it declares it. Pinned by the installer rather than by us: `--init` writes the dependency, `pnpm install` resolves it."
442
+ }
443
+ ],
444
+ $notOwned: {
445
+ "@rolldown/plugin-babel": "declared by career and host-admin themselves, not at the root. It supports LocatorJS, a development convenience gated on LOCATORJS=true, not shared build infrastructure. Removing it would remove a feature from two teams, which is their call and must not ride along inside an adoption.",
446
+ "@locator/babel-jsx": "same, and same owners.",
447
+ "@tanstack/react-start": "a framework the apps code against: 48 source files import it directly.",
448
+ "@tanstack/router-plugin": "generates the app's own route tree.",
449
+ "vite-bundle-analyzer": "at the root at 1.3.7 and imported nowhere, the only mention being a commented-out line in host-admin. Not a candidate to re-export, a candidate to delete from the root."
450
+ }
451
+ };
452
+
453
+ // src/roles/build/preset-data.ts
454
+ var REQUIREMENTS = requirements_default;
455
+ var OWNED_PACKAGES = toolchain_default.owned;
456
+
387
457
  // src/roles/build/config-policy.ts
458
+ var COMPANION_CONFIG_FILES = [
459
+ "vitest.config.ts",
460
+ "vitest.config.mts",
461
+ "vitest.config.js",
462
+ "vitest.config.mjs"
463
+ ];
388
464
  var BUILD_CONFIG_FILES = [
389
465
  "vite.config.ts",
390
466
  "vite.config.mts",
@@ -404,13 +480,9 @@ function buildTarget() {
404
480
  }
405
481
  };
406
482
  }
407
- var SENTINEL_OWNED_BUILD_PACKAGES = [
408
- "vite",
409
- "@vitejs/plugin-react",
410
- "@tailwindcss/vite",
411
- "vite-plugin-svgr",
412
- "nitro"
413
- ];
483
+ var SENTINEL_OWNED_BUILD_PACKAGES = OWNED_PACKAGES.map(
484
+ (entry) => entry.package
485
+ );
414
486
 
415
487
  // src/roles/build/dev-script.ts
416
488
  var CHAIN = " -- ";
@@ -418,17 +490,17 @@ var VITE_TOKEN = /(^|\s|\/)vite(\s|$)/;
418
490
  var DEV_SUBCOMMANDS = /* @__PURE__ */ new Set(["dev", "serve"]);
419
491
  function appOptions(rest) {
420
492
  const tokens = (rest ?? "").trim().split(/\s+/).filter(Boolean);
421
- const kept = tokens.filter((token, at2) => !(at2 === 0 && DEV_SUBCOMMANDS.has(token)));
493
+ const kept = tokens.filter((token, at) => !(at === 0 && DEV_SUBCOMMANDS.has(token)));
422
494
  return kept.join(" ");
423
495
  }
424
496
  function composeDevScript(existing, command) {
425
497
  if (existing === void 0 || existing.trim() === "") return command;
426
498
  const chunks = existing.trim().split(CHAIN);
427
- const at2 = chunks.map((chunk) => VITE_TOKEN.test(chunk)).lastIndexOf(true);
428
- if (at2 === -1) return existing;
429
- const rest = (chunks[at2] ?? "").replace(/^.*?(^|\s|\/)vite(\s|$)/, "");
499
+ const at = chunks.map((chunk) => VITE_TOKEN.test(chunk)).lastIndexOf(true);
500
+ if (at === -1) return existing;
501
+ const rest = (chunks[at] ?? "").replace(/^.*?(^|\s|\/)vite(\s|$)/, "");
430
502
  const options = appOptions(rest);
431
- chunks[at2] = options === "" ? command : `${command} -- ${options}`;
503
+ chunks[at] = options === "" ? command : `${command} -- ${options}`;
432
504
  return chunks.join(CHAIN);
433
505
  }
434
506
 
@@ -458,6 +530,7 @@ var REACT_PLUGIN_SPECIFIERS = ["@vitejs/plugin-react", "@tanstack/react-start"];
458
530
  function declaredBuildPreset(cwd) {
459
531
  const source = readBuildConfigSource(cwd);
460
532
  if (source === void 0) return void 0;
533
+ if (importsPreset(source)) return "react";
461
534
  return REACT_PLUGIN_SPECIFIERS.some((specifier) => importsSpecifier(source, specifier)) ? "react" : void 0;
462
535
  }
463
536
  function readBuildConfigSource(cwd) {
@@ -502,9 +575,9 @@ function atLeast(declared, needed) {
502
575
  const parse2 = (value) => value.replace(/^[\^~>=<\s]+/, "").split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
503
576
  const left = parse2(declared);
504
577
  const right = parse2(needed);
505
- for (let at2 = 0; at2 < Math.max(left.length, right.length); at2++) {
506
- const a = left[at2] ?? 0;
507
- const b = right[at2] ?? 0;
578
+ for (let at = 0; at < Math.max(left.length, right.length); at++) {
579
+ const a = left[at] ?? 0;
580
+ const b = right[at] ?? 0;
508
581
  if (a !== b) return a > b;
509
582
  }
510
583
  return true;
@@ -525,43 +598,103 @@ function describeUnmet(preset, unmet) {
525
598
  return `the build role does not apply to this module yet: ${list}. ${entry?.why ?? ""}. Nothing was written. Raise those versions, check the module still builds and its tests still pass, then re-run \`sentinel --init --build\`.`;
526
599
  }
527
600
 
528
- // src/roles/build/plan.ts
529
- function scaffold() {
530
- return `import { defineConfig, reactApp } from '${BUILD_PRESET_SPECIFIER}'
531
- import { tanstackStart } from '@tanstack/react-start/plugin/vite'
532
- import { tanstackRouter } from '@tanstack/router-plugin/vite'
533
- import path from 'node:path'
534
-
535
- /*
536
- * Composition comes from sentinel: which plugins run under test and which under a real build,
537
- * the order they run in, and how the env is read. The values below are this app's own.
538
- *
539
- * TanStack is passed IN rather than imported by sentinel, because the app codes against it
540
- * directly. \`alias\` is passed through verbatim and never read, so nothing in it can be lost.
541
- * Anything sentinel does not set goes in \`overrides\`, which merges over the preset last.
542
- */
543
- export default defineConfig(({ mode }) =>
544
- reactApp({
545
- root: __dirname,
546
- mode,
547
- base: '/',
548
- port: 3000,
549
- // Explicit, NOT port + 1: the three apps in this repo disagree, one of them goes down.
550
- hmrPort: 3001,
551
- router: {
552
- routesDirectory: path.resolve(__dirname, 'src/routes'),
553
- generatedRouteTree: path.resolve(__dirname, 'src/routeTree.gen.ts'),
554
- },
555
- tanstack: { start: tanstackStart, router: tanstackRouter },
556
- alias: [],
557
- }),
558
- )
559
-
560
- // If this app reads environment variables, use \`appEnv(__dirname, mode)\` rather than Vite's
561
- // \`loadEnv\`: it applies the production -> prd normalisation this repo deploys under, which
562
- // every app was otherwise retyping.
563
- `;
601
+ // src/roles/build/rewrite-imports.ts
602
+ import { parseSync } from "oxc-parser";
603
+ var MOVED_SPECIFIERS = new Set(OWNED_PACKAGES.map((entry) => entry.specifier));
604
+ function esmBlocker(fileName, packageType) {
605
+ if (/\.(mts|mjs)$/.test(fileName)) return void 0;
606
+ if (packageType === "module") return void 0;
607
+ return `sentinel is ESM only, and this module does not declare \`"type": "module"\`, so Vite would load ${fileName} as CommonJS and fail to require it. Add \`"type": "module"\` to this module's package.json, or rename the config to \`${fileName.replace(/\.(ts|js)$/, ".m$1")}\`, then run this again.`;
608
+ }
609
+ var DEFAULT_EXPORT_NAMES = Object.fromEntries(
610
+ OWNED_PACKAGES.filter((entry) => entry.default !== void 0).map((entry) => [
611
+ entry.specifier,
612
+ entry.default
613
+ ])
614
+ );
615
+ function renderImport(bindings) {
616
+ const render = (list) => list.map((b) => b.exported === b.local ? b.exported : `${b.exported} as ${b.local}`).sort((left, right) => left.localeCompare(right)).join(", ");
617
+ const values = bindings.filter((b) => !b.typeOnly);
618
+ const types = bindings.filter((b) => b.typeOnly);
619
+ const lines = [];
620
+ if (values.length > 0) lines.push(`import { ${render(values)} } from '${BUILD_PRESET_SPECIFIER}'`);
621
+ if (types.length > 0) {
622
+ lines.push(`import type { ${render(types)} } from '${BUILD_PRESET_SPECIFIER}'`);
623
+ }
624
+ return lines.join("\n");
625
+ }
626
+ function rewriteBuildImports(source, fileName = "vite.config.ts") {
627
+ const { program, errors } = parseSync(fileName, source, { sourceType: "module" });
628
+ if (errors.length > 0) {
629
+ return {
630
+ kind: "blocked",
631
+ why: `${fileName} does not parse: ${errors[0]?.message ?? "unknown"}`
632
+ };
633
+ }
634
+ const imports = program.body.filter(
635
+ (node) => node.type === "ImportDeclaration"
636
+ );
637
+ if (imports.some((node) => node.source.value === BUILD_PRESET_SPECIFIER)) {
638
+ return { kind: "unchanged", why: "this config already imports its toolchain from sentinel" };
639
+ }
640
+ const moving = imports.filter((node) => MOVED_SPECIFIERS.has(node.source.value));
641
+ if (moving.length === 0) {
642
+ return {
643
+ kind: "unchanged",
644
+ why: "this config imports none of the packages sentinel owns, so there is nothing to move"
645
+ };
646
+ }
647
+ const bindings = [];
648
+ for (const node of moving) {
649
+ const from = node.source.value;
650
+ const specifiers = node.specifiers ?? [];
651
+ if (specifiers.length === 0) {
652
+ return { kind: "blocked", why: `\`import '${from}'\` has no bindings to move` };
653
+ }
654
+ for (const spec of specifiers) {
655
+ const typeOnly = node.importKind === "type" || spec.importKind === "type";
656
+ if (spec.type === "ImportDefaultSpecifier") {
657
+ const exported = DEFAULT_EXPORT_NAMES[from];
658
+ if (!exported) {
659
+ return {
660
+ kind: "blocked",
661
+ why: `no sentinel export for the default import of \`${from}\``
662
+ };
663
+ }
664
+ bindings.push({ exported, local: spec.local.name, typeOnly });
665
+ continue;
666
+ }
667
+ if (spec.type === "ImportSpecifier") {
668
+ bindings.push({
669
+ exported: spec.imported?.name ?? spec.imported?.value ?? spec.local.name,
670
+ local: spec.local.name,
671
+ typeOnly
672
+ });
673
+ continue;
674
+ }
675
+ return {
676
+ kind: "blocked",
677
+ why: `\`import * as ${spec.local.name} from '${from}'\` cannot be mapped onto sentinel's named exports`
678
+ };
679
+ }
680
+ }
681
+ const spans = moving.map((node) => ({ start: node.start, end: node.end })).sort((left, right) => left.start - right.start);
682
+ const first = spans[0];
683
+ const rest = spans.slice(1);
684
+ let out = source;
685
+ for (const span of rest.slice().reverse()) {
686
+ const after = out.slice(span.end).match(/^\r?\n/)?.[0].length ?? 0;
687
+ out = out.slice(0, span.start) + out.slice(span.end + after);
688
+ }
689
+ out = out.slice(0, first.start) + renderImport(bindings) + out.slice(first.end);
690
+ return {
691
+ kind: "rewritten",
692
+ source: out,
693
+ moved: [...new Set(moving.map((node) => node.source.value))].sort()
694
+ };
564
695
  }
696
+
697
+ // src/roles/build/plan.ts
565
698
  function ownedBuildDependencies(cwd) {
566
699
  const manifest = readProjectPackageJson(cwd);
567
700
  const keys = [];
@@ -590,9 +723,10 @@ function buildScripts(cwd) {
590
723
  };
591
724
  const existingDev = existingCommand(cwd, DEV_SCRIPT_NAME);
592
725
  if (existingDev !== void 0) {
726
+ const own2 = moduleName(cwd);
593
727
  scripts[DEV_SCRIPT_NAME] = composeDevScript(
594
728
  existingDev,
595
- selfCommand(cwd, "--run --dev") ?? SENTINEL_DEV_COMMAND
729
+ `${selfCommand(cwd, "--run --dev") ?? SENTINEL_DEV_COMMAND} --module ${own2}`
596
730
  );
597
731
  }
598
732
  return scripts;
@@ -616,14 +750,41 @@ function plan(context) {
616
750
  const operations = [];
617
751
  const configFile = buildConfigFile(context.cwd);
618
752
  if (configFile === void 0) {
619
- operations.push({ kind: "write", path: BUILD_CONFIG_FILES[0], contents: scaffold() });
620
- notes.push(
621
- `wrote ${BUILD_CONFIG_FILES[0]} as a starting point. Fill in this app's own values (base, ports, routes); sentinel owns the composition, not the data.`
622
- );
623
- } else if (!readsPreset(context.cwd, configFile)) {
624
- notes.push(
625
- `${configFile} is this app's own, so it was left alone. To adopt: import \`reactApp\` from \`${BUILD_PRESET_SPECIFIER}\` and pass it this app's values, keeping \`alias\` and your own plugins verbatim. \`sentinel --inspect --build\` reports what the resolved config departs from and what it adds, before and after.`
626
- );
753
+ return {
754
+ operations: [],
755
+ skipped: `no Vite config in this module, so there is nothing to point at sentinel. This role moves an existing config's imports; it does not introduce a bundler. If this module builds another way (38 projects here build through \`@nx/webpack\`), the build role does not apply to it.`
756
+ };
757
+ } else {
758
+ const notEsm = esmBlocker(configFile, readProjectPackageJson(context.cwd).type);
759
+ if (notEsm !== void 0) return { operations: [], skipped: notEsm };
760
+ const rewrite = rewriteBuildImports(readConfigSource(context.cwd, configFile), configFile);
761
+ if (rewrite.kind === "blocked") {
762
+ return {
763
+ operations: [],
764
+ skipped: `${configFile} could not be pointed at sentinel, so nothing was written: ${rewrite.why}. Nothing here is broken; this needs a look before the role applies.`
765
+ };
766
+ }
767
+ if (rewrite.kind === "rewritten") {
768
+ operations.push({ kind: "write", path: configFile, contents: rewrite.source });
769
+ }
770
+ for (const name of COMPANION_CONFIG_FILES) {
771
+ const source = readConfigSource(context.cwd, name);
772
+ if (source === "") continue;
773
+ const companion = rewriteBuildImports(source, name);
774
+ if (companion.kind === "rewritten") {
775
+ operations.push({ kind: "write", path: name, contents: companion.source });
776
+ notes.push(`${name} now imports ${companion.moved.join(", ")} from sentinel too.`);
777
+ } else if (companion.kind === "blocked") {
778
+ notes.push(
779
+ `${name} imports the toolchain and could NOT be pointed at sentinel: ${companion.why}. It will stop resolving when the workspace root drops these packages, and it will fail your TESTS rather than your build.`
780
+ );
781
+ }
782
+ }
783
+ if (rewrite.kind === "rewritten") {
784
+ notes.push(
785
+ `${configFile} now imports ${rewrite.moved.join(", ")} from \`${BUILD_PRESET_SPECIFIER}\`. Only the import lines changed: every attribute, alias and path is untouched, so this build produces what it produced before.`
786
+ );
787
+ }
627
788
  }
628
789
  operations.push(manifestOperation(context.cwd, buildScripts(context.cwd)));
629
790
  operations.push(...nxTargetOperations({ cwd: context.cwd, targets: buildTarget() }));
@@ -636,12 +797,12 @@ function plan(context) {
636
797
  }
637
798
  return { operations, notes };
638
799
  }
639
- function readsPreset(cwd, configFile) {
640
- if (!existsSync7(join7(cwd, configFile))) return false;
800
+ function readConfigSource(cwd, configFile) {
801
+ if (!existsSync7(join7(cwd, configFile))) return "";
641
802
  try {
642
- return readFileSync5(join7(cwd, configFile), "utf8").includes(BUILD_PRESET_SPECIFIER);
803
+ return readFileSync5(join7(cwd, configFile), "utf8");
643
804
  } catch {
644
- return false;
805
+ return "";
645
806
  }
646
807
  }
647
808
 
@@ -773,6 +934,24 @@ var ViteDevAdapter = class extends ViteRoleAdapter {
773
934
  get label() {
774
935
  return "dev";
775
936
  }
937
+ /**
938
+ * There is no such thing as adopting the dev server on its own.
939
+ *
940
+ * `ViteRoleAdapter.plan()` is shared with `--build` because everything else about the two
941
+ * targets is shared, and inheriting it here made `--init --dev` perform the FULL build
942
+ * adoption, silently, under a message that only ever mentioned the build. Exiting 0 on that
943
+ * is the worst of the options: it teaches that dev can be adopted by itself, and that belief
944
+ * is what produces the half-migrated module this role exists to prevent (see the class
945
+ * comment above, and `registerBuild`).
946
+ *
947
+ * A single plan writes both scripts, so the honest answer is to name the command that does
948
+ * it rather than to do it under a different name.
949
+ */
950
+ plan() {
951
+ throw new Error(
952
+ "the dev server is adopted together with the build, never on its own. Run --init --build: one plan writes both the `build` and the `serve` scripts."
953
+ );
954
+ }
776
955
  /**
777
956
  * Start the dev server. It does not return until stopped, so there is no verdict to report
778
957
  * beyond the exit code the developer's own Ctrl-C produces.
@@ -791,48 +970,6 @@ var ViteDevAdapter = class extends ViteRoleAdapter {
791
970
  }
792
971
  };
793
972
 
794
- // src/roles/build/owned-paths.ts
795
- var PRESET_OWNED_KEYS = [
796
- "base",
797
- "root",
798
- "define",
799
- "server",
800
- "build",
801
- "nitro",
802
- "resolve",
803
- "plugins"
804
- ];
805
- function describeDepartures(config, defaults) {
806
- const departures = [];
807
- const build = asRecord(config.build);
808
- const target = build?.target;
809
- if (typeof target === "string" && target !== defaults.target) {
810
- departures.push({
811
- rule: "build.target",
812
- reason: `${target}, where the convention is ${defaults.target}`
813
- });
814
- }
815
- const server = asRecord(config.server);
816
- const hosts = server?.allowedHosts;
817
- if (Array.isArray(hosts) && !sameStrings(hosts, defaults.allowedHosts)) {
818
- departures.push({
819
- rule: "server.allowedHosts",
820
- reason: `${JSON.stringify(hosts)}, where the convention is ${JSON.stringify(defaults.allowedHosts)}`
821
- });
822
- }
823
- return departures;
824
- }
825
- function appAdditions(config) {
826
- const owned = new Set(PRESET_OWNED_KEYS);
827
- return Object.keys(config).filter((key) => !owned.has(key)).sort();
828
- }
829
- function asRecord(value) {
830
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
831
- }
832
- function sameStrings(a, b) {
833
- return a.length === b.length && a.every((value, at2) => value === b[at2]);
834
- }
835
-
836
973
  // src/roles/build/prerequisite.ts
837
974
  import { spawnSync as spawnSync2 } from "child_process";
838
975
  var PREBUILD_SCRIPT_NAME = `pre${BUILD_SCRIPT_NAME}`;
@@ -859,23 +996,6 @@ function runPrerequisite(cwd, env = process.env) {
859
996
  return { kind: "ran" };
860
997
  }
861
998
 
862
- // src/roles/build/resolve-config.ts
863
- async function resolveBuildConfig(cwd) {
864
- try {
865
- const { loadConfigFromFile } = await import("vite");
866
- const loaded = await loadConfigFromFile(
867
- { command: "build", mode: "production" },
868
- void 0,
869
- cwd,
870
- "silent"
871
- );
872
- if (!loaded) return { kind: "failed", reason: "Vite found no config file in this module" };
873
- return { kind: "loaded", config: loaded.config, from: loaded.path };
874
- } catch (error) {
875
- return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
876
- }
877
- }
878
-
879
999
  // src/roles/build/adapters/vite/vite.adapter.ts
880
1000
  var ViteAdapter = class extends ViteRoleAdapter {
881
1001
  target = "build";
@@ -901,25 +1021,30 @@ var ViteAdapter = class extends ViteRoleAdapter {
901
1021
  return this.spawnVite(ctx, ready.vite, ["build"], 1);
902
1022
  }
903
1023
  /**
904
- * What this module builds with, without building it.
1024
+ * What this module builds with, WITHOUT loading its config.
905
1025
  *
906
- * `--inspect` reads the committed config for every role, and the moment `--build` was wired
907
- * it joined the sweep so leaving this to throw would have broken `sentinel --inspect` on
908
- * every React module, for a role that had only just arrived. Cheap and honest is the bar.
1026
+ * Everything reported here is read from the manifest and from the config's text: whether the
1027
+ * module imports its toolchain from sentinel, which Vite install would answer, what it still
1028
+ * declares of its own, and whether it has a `prebuild` step.
909
1029
  *
910
- * `vite` is the interesting field and the one nothing else reports: an adopted config takes
911
- * its plugins from sentinel, so a module answering `module` here is one build away from the
912
- * two-copies failure, and this is where that is visible before it happens.
1030
+ * It used to also load the config through Vite and report where it departed from a preset
1031
+ * and what it added on top. Both were dropped with the preset itself. Adoption changes a
1032
+ * config's IMPORTS and nothing else, so there is no preset the app signed up to and no
1033
+ * distance from it to measure; and computing it meant EXECUTING an app's config, which is
1034
+ * expensive and can fail on its own terms (career throws without `VITE_BRAND_ID`). Reading
1035
+ * a file to report on it must not be able to break.
913
1036
  *
914
- * The resolved config comes from Vite's own loader rather than from parsing the file. See
915
- * `resolve-config` for why that is the only honest answer here, and what it costs.
1037
+ * They come back with the shared base, when there is again something an app has chosen to
1038
+ * adopt and can therefore depart from.
916
1039
  */
917
1040
  async inspect(ctx) {
918
1041
  const adoption = readBuildAdoption(ctx.cwd);
919
- const base = {
1042
+ return {
920
1043
  runner: "vite",
921
1044
  configFile: adoption.configFile,
922
1045
  ...adoption.unreadable ? { unreadable: adoption.unreadable } : {},
1046
+ // The field nothing else reports: an adopted config takes its plugins from sentinel, so
1047
+ // a module answering `module` here is one build away from the two-copies failure.
923
1048
  vite: viteOrigin(ctx.cwd),
924
1049
  // Rendered as a labelled block by the shared renderer, the same shape the lint role's
925
1050
  // parked rules and the format role's overrides already use.
@@ -929,36 +1054,6 @@ var ViteAdapter = class extends ViteRoleAdapter {
929
1054
  })),
930
1055
  prerequisite: moduleScripts(ctx.cwd)[PREBUILD_SCRIPT_NAME] ?? null
931
1056
  };
932
- if (adoption.configFile === null) return base;
933
- const resolved = await resolveBuildConfig(ctx.cwd);
934
- if (resolved.kind === "failed") {
935
- return { ...base, configError: resolved.reason };
936
- }
937
- const config = resolved.config;
938
- return {
939
- ...base,
940
- // What the app departs from, and what it adds, are different facts. A DEPARTURE is a
941
- // disagreement with an opinion the preset holds; an ADDITION is the app needing
942
- // something the preset never claimed, which is the preset working rather than being
943
- // worked around.
944
- departures: describeDepartures(config, REACT_APP_DEFAULTS),
945
- additions: appAdditions(config),
946
- resolved: {
947
- base: at(config, "base") ?? null,
948
- target: at(config, "build", "target") ?? null,
949
- sourcemap: at(config, "build", "sourcemap") ?? null,
950
- port: at(config, "server", "port") ?? null,
951
- // The one number nothing else reports and that no formula predicts: career goes DOWN
952
- // to 9998 where the others go up, which is why it is data rather than `port + 1`.
953
- hmrPort: at(config, "server", "hmr", "port") ?? null,
954
- // Counted, not listed. It is 56-67% of every config today, and printing 296 entries
955
- // would bury everything above it. The count is what tells you whether it moved.
956
- aliases: countOf(at(config, "resolve", "alias")),
957
- // Flattened without a depth limit: Vite lets a plugin be an arbitrarily nested array,
958
- // and a magic number here would silently undercount the day someone nests one deeper.
959
- plugins: countOf(at(config, "plugins"))
960
- }
961
- };
962
1057
  }
963
1058
  /**
964
1059
  * Adoption as data, WITHOUT building.
@@ -984,17 +1079,6 @@ var ViteAdapter = class extends ViteRoleAdapter {
984
1079
  };
985
1080
  }
986
1081
  };
987
- function at(config, ...path) {
988
- let value = config;
989
- for (const key of path) {
990
- if (typeof value !== "object" || value === null) return void 0;
991
- value = value[key];
992
- }
993
- return value;
994
- }
995
- function countOf(value) {
996
- return Array.isArray(value) ? value.flat(Number.POSITIVE_INFINITY).length : 0;
997
- }
998
1082
 
999
1083
  // src/roles/build/register.ts
1000
1084
  function registerBuild() {
@@ -4180,8 +4264,7 @@ import { join as join23 } from "path";
4180
4264
  // src/roles/lint/eslint-ignores.ts
4181
4265
  import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
4182
4266
  import { join as join21 } from "path";
4183
- var IGNORE_BLOCKS = [/\bignores\s*:\s*\[([^\]]*)\]/g, /\bglobalIgnores\s*\(\s*\[([^\]]*)\]/g];
4184
- var STRING_LITERAL = /['"`]([^'"`]+)['"`]/g;
4267
+ import { parseSync as parseSync2 } from "oxc-parser";
4185
4268
  var OPAQUE_SOURCE = /\b(includeIgnoreFile)\s*\([^)]*\)/g;
4186
4269
  function toOxlintPattern(pattern) {
4187
4270
  return pattern.replace(/^\.\//, "");
@@ -4201,15 +4284,62 @@ function readEslintIgnores(cwd) {
4201
4284
  return { patterns: [], unresolved: [] };
4202
4285
  }
4203
4286
  const patterns = /* @__PURE__ */ new Set();
4204
- for (const block of IGNORE_BLOCKS) {
4205
- for (const [, body] of source.matchAll(block)) {
4206
- for (const [, literal] of (body ?? "").matchAll(STRING_LITERAL)) {
4207
- if (literal) patterns.add(toOxlintPattern(literal));
4287
+ const unresolved = new Set([...source.matchAll(OPAQUE_SOURCE)].map((match) => match[0]));
4288
+ const { program, errors } = parseSync2(config, source, { sourceType: "module" });
4289
+ if (errors.length > 0) {
4290
+ return { patterns: [], unresolved: [`${config} could not be parsed`] };
4291
+ }
4292
+ walk(program, (node) => {
4293
+ if (node.type === "CallExpression" && calleeName(node) === "globalIgnores") {
4294
+ for (const literal of stringLiterals(firstArrayArgument(node))) {
4295
+ patterns.add(toOxlintPattern(literal));
4208
4296
  }
4297
+ return;
4298
+ }
4299
+ if (node.type !== "ObjectExpression") return;
4300
+ const keys = objectKeys(node);
4301
+ if (!keys.includes("ignores")) return;
4302
+ const values = arrayValues(node, "ignores");
4303
+ if (keys.length > 1) {
4304
+ unresolved.add(
4305
+ `ignores scoped to one config block (with ${keys.filter((k) => k !== "ignores").join(", ")}): ${values.join(", ")} \u2014 exempt from those RULES, not excluded from linting`
4306
+ );
4307
+ return;
4209
4308
  }
4309
+ for (const literal of values) patterns.add(toOxlintPattern(literal));
4310
+ });
4311
+ return { patterns: [...patterns], unresolved: [...unresolved] };
4312
+ }
4313
+ function walk(node, visit) {
4314
+ if (!node || typeof node !== "object") return;
4315
+ const current = node;
4316
+ if (typeof current.type === "string") visit(current);
4317
+ for (const key of Object.keys(current)) {
4318
+ const value = current[key];
4319
+ if (Array.isArray(value)) for (const entry of value) walk(entry, visit);
4320
+ else if (value && typeof value === "object") walk(value, visit);
4210
4321
  }
4211
- const unresolved = [...source.matchAll(OPAQUE_SOURCE)].map((match) => match[0]);
4212
- return { patterns: [...patterns], unresolved: [...new Set(unresolved)] };
4322
+ }
4323
+ function calleeName(node) {
4324
+ const callee = node.callee;
4325
+ return callee?.type === "Identifier" ? callee.name : void 0;
4326
+ }
4327
+ function firstArrayArgument(node) {
4328
+ const args = node.arguments ?? [];
4329
+ return args.find((argument) => argument?.type === "ArrayExpression");
4330
+ }
4331
+ function stringLiterals(array) {
4332
+ const elements = array?.elements ?? [];
4333
+ return elements.filter((element) => element?.type === "Literal" && typeof element.value === "string").map((element) => element.value);
4334
+ }
4335
+ function objectKeys(node) {
4336
+ const properties = node.properties ?? [];
4337
+ return properties.filter((property) => property?.type === "Property" && !property.computed).map((property) => property.key?.name ?? String(property.key?.value ?? "")).filter(Boolean);
4338
+ }
4339
+ function arrayValues(node, name) {
4340
+ const properties = node.properties ?? [];
4341
+ const found = properties.find((property) => property.key?.name === name);
4342
+ return stringLiterals(found?.value);
4213
4343
  }
4214
4344
 
4215
4345
  // src/roles/lint/lint-script.ts
@@ -4755,7 +4885,7 @@ ${result.stderr ?? ""}`);
4755
4885
  } else {
4756
4886
  rmSync(baselinePath, { force: true });
4757
4887
  }
4758
- if (next.length !== current.length || next.some((entry, at2) => entry !== current[at2])) {
4888
+ if (next.length !== current.length || next.some((entry, at) => entry !== current[at])) {
4759
4889
  config.extends = next;
4760
4890
  writeFileSync2(configPath, `${JSON.stringify(config, null, 2)}
4761
4891
  `);
@@ -5717,7 +5847,7 @@ function detectFramework(packageJson) {
5717
5847
  }
5718
5848
 
5719
5849
  // src/core/dispatch.ts
5720
- import { basename as basename2 } from "path";
5850
+ import { basename as basename3 } from "path";
5721
5851
 
5722
5852
  // src/shared/text.ts
5723
5853
  function ensureLines(current, lines) {
@@ -5776,6 +5906,13 @@ function replaceLines(current, replacements) {
5776
5906
  import { existsSync as existsSync22, readFileSync as readFileSync22, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
5777
5907
  import { resolve as resolve4, sep } from "path";
5778
5908
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
5909
+
5910
+ // src/shared/deep-merge.ts
5911
+ function isPlainObject(value) {
5912
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5913
+ }
5914
+
5915
+ // src/core/apply-plan.ts
5779
5916
  function resolveWithinRoot(cwd, relativePath) {
5780
5917
  const root = resolve4(cwd);
5781
5918
  const absolutePath = resolve4(root, relativePath);
@@ -6086,7 +6223,7 @@ async function dispatch(opts) {
6086
6223
  process.stderr.write(` fixing what ${opts.target} can fix automatically...
6087
6224
  `);
6088
6225
  await adapter.afterInit({
6089
- module: basename2(opts.cwd),
6226
+ module: basename3(opts.cwd),
6090
6227
  cwd: opts.cwd,
6091
6228
  preset,
6092
6229
  ci: false,
@@ -6108,9 +6245,10 @@ export {
6108
6245
  resolveBin,
6109
6246
  readOwnVersion,
6110
6247
  readProjectPackageJson,
6111
- readNxProjectName,
6248
+ moduleName,
6112
6249
  VERBS,
6113
6250
  TARGETS,
6251
+ LONG_RUNNING_TARGETS,
6114
6252
  SWEEPABLE_TARGETS,
6115
6253
  PRESET_NAMES,
6116
6254
  WORKSPACE_ROOT_MARKER,
@@ -6123,4 +6261,4 @@ export {
6123
6261
  detectFramework,
6124
6262
  dispatch
6125
6263
  };
6126
- //# sourceMappingURL=chunk-FLJ2QNFF.js.map
6264
+ //# sourceMappingURL=chunk-YZ4EKBK7.js.map
package/dist/index.js CHANGED
@@ -7,8 +7,7 @@ import {
7
7
  registerAdapters,
8
8
  resolve,
9
9
  setDefaultRunner
10
- } from "./chunk-FLJ2QNFF.js";
11
- import "./chunk-XVDOQ3G3.js";
10
+ } from "./chunk-YZ4EKBK7.js";
12
11
  export {
13
12
  BaseAdapter,
14
13
  all,
@@ -0,0 +1,5 @@
1
+ export { default as tailwindcss } from '@tailwindcss/vite';
2
+ export { default as react } from '@vitejs/plugin-react';
3
+ export { nitro } from 'nitro/vite';
4
+ export { default as svgr } from 'vite-plugin-svgr';
5
+ export { Plugin, PluginOption, SassPreprocessorOptions, UserConfig, UserConfigExport, defineConfig, loadEnv, mergeConfig } from 'vite';
@@ -0,0 +1,16 @@
1
+ // src/roles/build/toolchain.ts
2
+ import { default as default2 } from "@tailwindcss/vite";
3
+ import { default as default3 } from "@vitejs/plugin-react";
4
+ import { nitro } from "nitro/vite";
5
+ import { default as default4 } from "vite-plugin-svgr";
6
+ import { defineConfig, loadEnv, mergeConfig } from "vite";
7
+ export {
8
+ defineConfig,
9
+ loadEnv,
10
+ mergeConfig,
11
+ nitro,
12
+ default3 as react,
13
+ default4 as svgr,
14
+ default2 as tailwindcss
15
+ };
16
+ //# sourceMappingURL=toolchain.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hublo/sentinel",
3
- "version": "1.2.0-alpha.1",
3
+ "version": "1.2.0-alpha.3",
4
4
  "description": "One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,8 +39,8 @@
39
39
  "default": "./dist/tsconfig/node.json"
40
40
  },
41
41
  "./build/react": {
42
- "types": "./dist/roles/build/react-app.d.ts",
43
- "import": "./dist/roles/build/react-app.js"
42
+ "types": "./dist/roles/build/toolchain.d.ts",
43
+ "import": "./dist/roles/build/toolchain.js"
44
44
  }
45
45
  },
46
46
  "files": [
@@ -62,6 +62,7 @@
62
62
  "eslint-plugin-styled-components-a11y": "2.2.0",
63
63
  "jsonc-parser": "^3.3.1",
64
64
  "nitro": "3.0.260415-beta",
65
+ "oxc-parser": "0.147.0",
65
66
  "oxfmt": "0.63.0",
66
67
  "oxlint": "1.77.0",
67
68
  "oxlint-tsgolint": "7.0.2001",
@@ -1,39 +0,0 @@
1
- // src/roles/build/presets/requirements.json
2
- var requirements_default = {
3
- svelte: {
4
- why: "sentinel ships Vite 8, and @sveltejs/vite-plugin-svelte caps at Vite 6 until its major 7",
5
- requires: {
6
- "@sveltejs/vite-plugin-svelte": "7.0.0",
7
- svelte: "5.46.4"
8
- }
9
- }
10
- };
11
-
12
- // src/roles/build/presets/shared.json
13
- var shared_default = {
14
- target: "chrome89",
15
- allowedHosts: [".hubpreprod.com", ".playground-hublo.com"],
16
- publicAssets: [
17
- {
18
- dir: "public/branding",
19
- baseURL: "/branding",
20
- maxAge: 31536e3
21
- }
22
- ]
23
- };
24
-
25
- // src/roles/build/preset-data.ts
26
- var REACT_APP_DEFAULTS = shared_default;
27
- var REQUIREMENTS = requirements_default;
28
-
29
- // src/shared/deep-merge.ts
30
- function isPlainObject(value) {
31
- return typeof value === "object" && value !== null && !Array.isArray(value);
32
- }
33
-
34
- export {
35
- REACT_APP_DEFAULTS,
36
- REQUIREMENTS,
37
- isPlainObject
38
- };
39
- //# sourceMappingURL=chunk-XVDOQ3G3.js.map
@@ -1,112 +0,0 @@
1
- import { PluginOption, UserConfig } from 'vite';
2
- export { defineConfig, loadEnv } from 'vite';
3
-
4
- /** The convention values, in one place so `--inspect` can report what an app departs from. */
5
- declare const REACT_APP_DEFAULTS: {
6
- target: string;
7
- allowedHosts: string[];
8
- publicAssets: {
9
- dir: string;
10
- baseURL: string;
11
- maxAge: number;
12
- }[];
13
- };
14
-
15
- /** Where the router's routes live and where it writes its generated tree. */
16
- interface RouterPaths {
17
- routesDirectory: string;
18
- generatedRouteTree: string;
19
- /** Extra lines appended to the generated tree. Two of the three apps use it. */
20
- routeTreeFileFooter?: string[];
21
- }
22
- /**
23
- * The TanStack factories, supplied by the app.
24
- *
25
- * Typed loosely on purpose: sentinel decides WHEN to call them and with what, and has no
26
- * business asserting their return shape. Narrowing this would only couple sentinel to a
27
- * version of a package it does not own.
28
- */
29
- interface TanstackFactories {
30
- start: (options: {
31
- router: RouterPaths;
32
- }) => PluginOption[];
33
- router: (options: RouterPaths & {
34
- target: string;
35
- autoCodeSplitting: boolean;
36
- }) => PluginOption;
37
- }
38
- /** Everything an app declares. */
39
- interface ReactAppOptions {
40
- /** The app's own directory. Always `__dirname`. */
41
- root: string;
42
- /** Vite's `mode`, from the config callback. */
43
- mode: string;
44
- /** Served under, e.g. `/console/`. */
45
- base: string;
46
- /** Dev server port. */
47
- port: number;
48
- /** HMR port. Explicit, NOT `port + 1`: career goes DOWN to 9998 where the others go up. */
49
- hmrPort: number;
50
- router: RouterPaths;
51
- tanstack: TanstackFactories;
52
- /**
53
- * Sourcemaps: a boolean, however the app decided it.
54
- *
55
- * The three apps disagree on how — two use a mode allowlist, one reads an env var. That is
56
- * an inconsistency in the repo rather than a requirement, and encoding both would make
57
- * sentinel the owner of a decision it should not hold.
58
- */
59
- sourcemap?: boolean;
60
- /** Passed through verbatim, never read. See the note above. */
61
- alias?: unknown[];
62
- /** The app's own plugins (branding, babel). Appended AFTER the base set. */
63
- plugins?: PluginOption[];
64
- /** Conventions the app may override. */
65
- target?: string;
66
- allowedHosts?: string[];
67
- /**
68
- * Anything else this app needs, merged over the preset LAST.
69
- *
70
- * The preset is a starting point, not a cage. Two things an app must be able to do and
71
- * could not before: override a NESTED attribute without losing its siblings, and add an
72
- * attribute the preset knows nothing about.
73
- *
74
- * Objects merge, everything else replaces — see `merge-config` for why arrays replace
75
- * rather than concatenate. Whatever lands here is reported by `--inspect --build`, so an
76
- * override is a visible, countable decision instead of something found by reading a config.
77
- *
78
- * Real cases today: console needs `ssr`, `optimizeDeps` and `preview`; career needs
79
- * `css.preprocessorOptions` for Sass; all three need their own `nitro.publicAssets`.
80
- */
81
- overrides?: Record<string, unknown>;
82
- }
83
-
84
- /**
85
- * The environment, loaded the way all three apps already load it.
86
- *
87
- * `production` is normalised to `prd` because that is this repository's deployment tag: it is
88
- * the `.env.prd` the build reads and the SSM path segment the values come from. Not a
89
- * preference, a fact about the repo — which is exactly why it belongs in the preset rather
90
- * than being retyped in every config.
91
- */
92
- declare function appEnv(root: string, mode: string): Record<string, string>;
93
- /** True when this run is a test rather than a real build. */
94
- declare function isTestMode(mode: string, env?: NodeJS.ProcessEnv): boolean;
95
- /**
96
- * The plugin names in the order they run, which is what `--inspect` reports.
97
- *
98
- * `extras` is how many plugins the app appends of its own; they are named positionally
99
- * because sentinel has no way to name a function someone else passed in.
100
- */
101
- declare function pluginPlan(isTest: boolean, extras?: number): string[];
102
- /**
103
- * The Vite config for a React app, minus the plugins.
104
- *
105
- * Pure and separately testable: everything here is data in, data out, so the composition
106
- * rules can be asserted without a bundler anywhere near the test.
107
- */
108
- declare function reactAppConfig(options: ReactAppOptions): Omit<UserConfig, 'plugins'>;
109
- /** The config an app spreads into `defineConfig`. */
110
- declare function reactApp(options: ReactAppOptions): UserConfig;
111
-
112
- export { REACT_APP_DEFAULTS, type ReactAppOptions, type RouterPaths, type TanstackFactories, appEnv, isTestMode, pluginPlan, reactApp, reactAppConfig };
@@ -1,126 +0,0 @@
1
- import {
2
- REACT_APP_DEFAULTS,
3
- isPlainObject
4
- } from "../../chunk-XVDOQ3G3.js";
5
-
6
- // src/roles/build/react-app.ts
7
- import tailwindcss from "@tailwindcss/vite";
8
- import react from "@vitejs/plugin-react";
9
- import { nitro } from "nitro/vite";
10
- import { loadEnv } from "vite";
11
- import svgr from "vite-plugin-svgr";
12
-
13
- // src/roles/build/merge-config.ts
14
- function mergeConfig(base, overrides) {
15
- if (!overrides) return base;
16
- const out = { ...base };
17
- for (const [key, value] of Object.entries(overrides)) {
18
- if (value === void 0) {
19
- delete out[key];
20
- continue;
21
- }
22
- const current = out[key];
23
- out[key] = isPlainObject(current) && isPlainObject(value) ? mergeConfig(current, value) : value;
24
- }
25
- return out;
26
- }
27
-
28
- // src/roles/build/react-app.ts
29
- import { defineConfig, loadEnv as loadEnv2 } from "vite";
30
- function appEnv(root, mode) {
31
- return loadEnv(mode === "production" ? "prd" : mode, root, "");
32
- }
33
- function isTestMode(mode, env = process.env) {
34
- return mode === "test" || env.VITEST === "true";
35
- }
36
- function pluginEntries(options, isTest) {
37
- const { tanstack, router } = options;
38
- return [
39
- { name: "tailwindcss", build: () => tailwindcss() },
40
- ...isTest ? [
41
- {
42
- name: "tanstackRouter",
43
- build: () => tanstack.router({ ...router, target: "react", autoCodeSplitting: true })
44
- }
45
- ] : [
46
- { name: "tanstackStart", build: () => tanstack.start({ router }) },
47
- { name: "nitro", build: () => nitro() }
48
- ],
49
- { name: "react", build: () => react() },
50
- { name: "svgr", build: () => svgr() }
51
- ];
52
- }
53
- function pluginPlan(isTest, extras = 0) {
54
- const entries = pluginEntries(
55
- { tanstack: { start: () => [], router: () => null }, router: STUB_ROUTER },
56
- isTest
57
- );
58
- return [
59
- ...entries.map((entry) => entry.name),
60
- ...Array.from({ length: extras }, (_, at) => `app:${at}`)
61
- ];
62
- }
63
- var STUB_ROUTER = { routesDirectory: "", generatedRouteTree: "" };
64
- function basePlugins(options, isTest) {
65
- return [
66
- ...pluginEntries(options, isTest).flatMap((entry) => entry.build()),
67
- ...options.plugins ?? []
68
- ];
69
- }
70
- function reactAppConfig(options) {
71
- const preset = {
72
- base: options.base,
73
- root: options.root,
74
- define: {
75
- // Development only. In a build this would leak a browser global into SSR output.
76
- ...options.mode === "development" ? { global: "window" } : {}
77
- },
78
- server: {
79
- port: options.port,
80
- allowedHosts: options.allowedHosts ?? [...REACT_APP_DEFAULTS.allowedHosts],
81
- hmr: { host: "localhost", protocol: "ws", port: options.hmrPort }
82
- },
83
- build: {
84
- target: options.target ?? REACT_APP_DEFAULTS.target,
85
- sourcemap: options.sourcemap ?? false
86
- },
87
- /*
88
- * Nitro's two settings are sentinel's, and both were measured across the three apps rather
89
- * than assumed.
90
- *
91
- * `baseURL` is always the app's `base`, without exception: `/console/`, `/` and `/admin/`.
92
- * It is derived, so an app cannot get them out of step.
93
- *
94
- * `publicAssets` is byte-identical in all three — the branding directory, its URL and a
95
- * one-year max-age. That is a convention about where branding lives, not data about an
96
- * app, and it was sitting in three `overrides` blocks saying the same thing.
97
- *
98
- * An app that needs MORE from nitro overrides the key: career adds `externals` to keep the
99
- * SSR sanitisation chain out of the server bundle. `mergeConfig` merges objects, so adding
100
- * one key does not take these two away.
101
- */
102
- nitro: {
103
- baseURL: options.base,
104
- publicAssets: [...REACT_APP_DEFAULTS.publicAssets]
105
- },
106
- ...options.alias ? { resolve: { alias: options.alias } } : {}
107
- };
108
- return mergeConfig(preset, options.overrides);
109
- }
110
- function reactApp(options) {
111
- return {
112
- ...reactAppConfig(options),
113
- plugins: basePlugins(options, isTestMode(options.mode))
114
- };
115
- }
116
- export {
117
- REACT_APP_DEFAULTS,
118
- appEnv,
119
- defineConfig,
120
- isTestMode,
121
- loadEnv2 as loadEnv,
122
- pluginPlan,
123
- reactApp,
124
- reactAppConfig
125
- };
126
- //# sourceMappingURL=react-app.js.map