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

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,
@@ -20,8 +21,7 @@ import {
20
21
  registerAdapters,
21
22
  resolve,
22
23
  resolveBin
23
- } from "../chunk-FLJ2QNFF.js";
24
- import "../chunk-XVDOQ3G3.js";
24
+ } from "../chunk-SA2RMTVI.js";
25
25
 
26
26
  // bin/sentinel.ts
27
27
  import { program } from "commander";
@@ -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 {
@@ -223,7 +217,7 @@ function binSearchPath(cwd) {
223
217
 
224
218
  // src/roles/build/plan.ts
225
219
  import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
226
- import { join as join7 } from "path";
220
+ import { basename as basename2, join as join7 } from "path";
227
221
 
228
222
  // src/core/config/existing-command.ts
229
223
  import { readFileSync as readFileSync3 } from "fs";
@@ -384,7 +378,85 @@ function keepsOtherCommands(script, sentinelCommand) {
384
378
  return script.split(SEGMENT_SEPARATOR).filter((_, index) => !isSeparatorAt(index)).some((segment) => segment.trim() !== "" && segment.trim() !== sentinelCommand);
385
379
  }
386
380
 
381
+ // src/roles/build/presets/requirements.json
382
+ var requirements_default = {
383
+ svelte: {
384
+ why: "sentinel ships Vite 8, and @sveltejs/vite-plugin-svelte caps at Vite 6 until its major 7",
385
+ requires: {
386
+ "@sveltejs/vite-plugin-svelte": "7.0.0",
387
+ svelte: "5.46.4"
388
+ }
389
+ }
390
+ };
391
+
392
+ // src/roles/build/presets/toolchain.json
393
+ var toolchain_default = {
394
+ $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.",
395
+ owned: [
396
+ {
397
+ package: "vite",
398
+ specifier: "vite",
399
+ named: ["defineConfig", "loadEnv", "mergeConfig"],
400
+ types: [
401
+ "Plugin",
402
+ "PluginOption",
403
+ "SassPreprocessorOptions",
404
+ "UserConfig",
405
+ "UserConfigExport"
406
+ ],
407
+ 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."
408
+ },
409
+ {
410
+ package: "@vitejs/plugin-react",
411
+ specifier: "@vitejs/plugin-react",
412
+ default: "react",
413
+ why: "the React transform. Bound to the Vite that runs it, so it has to come from the same install."
414
+ },
415
+ {
416
+ package: "@tailwindcss/vite",
417
+ specifier: "@tailwindcss/vite",
418
+ default: "tailwindcss",
419
+ why: "the Tailwind pipeline, identical in all three front apps."
420
+ },
421
+ {
422
+ package: "vite-plugin-svgr",
423
+ specifier: "vite-plugin-svgr",
424
+ default: "svgr",
425
+ why: "SVG as components, identical in all three front apps."
426
+ },
427
+ {
428
+ package: "nitro",
429
+ specifier: "nitro/vite",
430
+ named: ["nitro"],
431
+ 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."
432
+ }
433
+ ],
434
+ add: [
435
+ {
436
+ package: "@hublo/sentinel",
437
+ 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."
438
+ }
439
+ ],
440
+ $notOwned: {
441
+ "@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.",
442
+ "@locator/babel-jsx": "same, and same owners.",
443
+ "@tanstack/react-start": "a framework the apps code against: 48 source files import it directly.",
444
+ "@tanstack/router-plugin": "generates the app's own route tree.",
445
+ "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."
446
+ }
447
+ };
448
+
449
+ // src/roles/build/preset-data.ts
450
+ var REQUIREMENTS = requirements_default;
451
+ var OWNED_PACKAGES = toolchain_default.owned;
452
+
387
453
  // src/roles/build/config-policy.ts
454
+ var COMPANION_CONFIG_FILES = [
455
+ "vitest.config.ts",
456
+ "vitest.config.mts",
457
+ "vitest.config.js",
458
+ "vitest.config.mjs"
459
+ ];
388
460
  var BUILD_CONFIG_FILES = [
389
461
  "vite.config.ts",
390
462
  "vite.config.mts",
@@ -404,13 +476,9 @@ function buildTarget() {
404
476
  }
405
477
  };
406
478
  }
407
- var SENTINEL_OWNED_BUILD_PACKAGES = [
408
- "vite",
409
- "@vitejs/plugin-react",
410
- "@tailwindcss/vite",
411
- "vite-plugin-svgr",
412
- "nitro"
413
- ];
479
+ var SENTINEL_OWNED_BUILD_PACKAGES = OWNED_PACKAGES.map(
480
+ (entry) => entry.package
481
+ );
414
482
 
415
483
  // src/roles/build/dev-script.ts
416
484
  var CHAIN = " -- ";
@@ -418,17 +486,17 @@ var VITE_TOKEN = /(^|\s|\/)vite(\s|$)/;
418
486
  var DEV_SUBCOMMANDS = /* @__PURE__ */ new Set(["dev", "serve"]);
419
487
  function appOptions(rest) {
420
488
  const tokens = (rest ?? "").trim().split(/\s+/).filter(Boolean);
421
- const kept = tokens.filter((token, at2) => !(at2 === 0 && DEV_SUBCOMMANDS.has(token)));
489
+ const kept = tokens.filter((token, at) => !(at === 0 && DEV_SUBCOMMANDS.has(token)));
422
490
  return kept.join(" ");
423
491
  }
424
492
  function composeDevScript(existing, command) {
425
493
  if (existing === void 0 || existing.trim() === "") return command;
426
494
  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|$)/, "");
495
+ const at = chunks.map((chunk) => VITE_TOKEN.test(chunk)).lastIndexOf(true);
496
+ if (at === -1) return existing;
497
+ const rest = (chunks[at] ?? "").replace(/^.*?(^|\s|\/)vite(\s|$)/, "");
430
498
  const options = appOptions(rest);
431
- chunks[at2] = options === "" ? command : `${command} -- ${options}`;
499
+ chunks[at] = options === "" ? command : `${command} -- ${options}`;
432
500
  return chunks.join(CHAIN);
433
501
  }
434
502
 
@@ -502,9 +570,9 @@ function atLeast(declared, needed) {
502
570
  const parse2 = (value) => value.replace(/^[\^~>=<\s]+/, "").split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
503
571
  const left = parse2(declared);
504
572
  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;
573
+ for (let at = 0; at < Math.max(left.length, right.length); at++) {
574
+ const a = left[at] ?? 0;
575
+ const b = right[at] ?? 0;
508
576
  if (a !== b) return a > b;
509
577
  }
510
578
  return true;
@@ -525,43 +593,103 @@ function describeUnmet(preset, unmet) {
525
593
  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
594
  }
527
595
 
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
- `;
596
+ // src/roles/build/rewrite-imports.ts
597
+ import { parseSync } from "oxc-parser";
598
+ var MOVED_SPECIFIERS = new Set(OWNED_PACKAGES.map((entry) => entry.specifier));
599
+ function esmBlocker(fileName, packageType) {
600
+ if (/\.(mts|mjs)$/.test(fileName)) return void 0;
601
+ if (packageType === "module") return void 0;
602
+ 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.`;
603
+ }
604
+ var DEFAULT_EXPORT_NAMES = Object.fromEntries(
605
+ OWNED_PACKAGES.filter((entry) => entry.default !== void 0).map((entry) => [
606
+ entry.specifier,
607
+ entry.default
608
+ ])
609
+ );
610
+ function renderImport(bindings) {
611
+ const render = (list) => list.map((b) => b.exported === b.local ? b.exported : `${b.exported} as ${b.local}`).sort((left, right) => left.localeCompare(right)).join(", ");
612
+ const values = bindings.filter((b) => !b.typeOnly);
613
+ const types = bindings.filter((b) => b.typeOnly);
614
+ const lines = [];
615
+ if (values.length > 0) lines.push(`import { ${render(values)} } from '${BUILD_PRESET_SPECIFIER}'`);
616
+ if (types.length > 0) {
617
+ lines.push(`import type { ${render(types)} } from '${BUILD_PRESET_SPECIFIER}'`);
618
+ }
619
+ return lines.join("\n");
620
+ }
621
+ function rewriteBuildImports(source, fileName = "vite.config.ts") {
622
+ const { program, errors } = parseSync(fileName, source, { sourceType: "module" });
623
+ if (errors.length > 0) {
624
+ return {
625
+ kind: "blocked",
626
+ why: `${fileName} does not parse: ${errors[0]?.message ?? "unknown"}`
627
+ };
628
+ }
629
+ const imports = program.body.filter(
630
+ (node) => node.type === "ImportDeclaration"
631
+ );
632
+ if (imports.some((node) => node.source.value === BUILD_PRESET_SPECIFIER)) {
633
+ return { kind: "unchanged", why: "this config already imports its toolchain from sentinel" };
634
+ }
635
+ const moving = imports.filter((node) => MOVED_SPECIFIERS.has(node.source.value));
636
+ if (moving.length === 0) {
637
+ return {
638
+ kind: "unchanged",
639
+ why: "this config imports none of the packages sentinel owns, so there is nothing to move"
640
+ };
641
+ }
642
+ const bindings = [];
643
+ for (const node of moving) {
644
+ const from = node.source.value;
645
+ const specifiers = node.specifiers ?? [];
646
+ if (specifiers.length === 0) {
647
+ return { kind: "blocked", why: `\`import '${from}'\` has no bindings to move` };
648
+ }
649
+ for (const spec of specifiers) {
650
+ const typeOnly = node.importKind === "type" || spec.importKind === "type";
651
+ if (spec.type === "ImportDefaultSpecifier") {
652
+ const exported = DEFAULT_EXPORT_NAMES[from];
653
+ if (!exported) {
654
+ return {
655
+ kind: "blocked",
656
+ why: `no sentinel export for the default import of \`${from}\``
657
+ };
658
+ }
659
+ bindings.push({ exported, local: spec.local.name, typeOnly });
660
+ continue;
661
+ }
662
+ if (spec.type === "ImportSpecifier") {
663
+ bindings.push({
664
+ exported: spec.imported?.name ?? spec.imported?.value ?? spec.local.name,
665
+ local: spec.local.name,
666
+ typeOnly
667
+ });
668
+ continue;
669
+ }
670
+ return {
671
+ kind: "blocked",
672
+ why: `\`import * as ${spec.local.name} from '${from}'\` cannot be mapped onto sentinel's named exports`
673
+ };
674
+ }
675
+ }
676
+ const spans = moving.map((node) => ({ start: node.start, end: node.end })).sort((left, right) => left.start - right.start);
677
+ const first = spans[0];
678
+ const rest = spans.slice(1);
679
+ let out = source;
680
+ for (const span of rest.slice().reverse()) {
681
+ const after = out.slice(span.end).match(/^\r?\n/)?.[0].length ?? 0;
682
+ out = out.slice(0, span.start) + out.slice(span.end + after);
683
+ }
684
+ out = out.slice(0, first.start) + renderImport(bindings) + out.slice(first.end);
685
+ return {
686
+ kind: "rewritten",
687
+ source: out,
688
+ moved: [...new Set(moving.map((node) => node.source.value))].sort()
689
+ };
564
690
  }
691
+
692
+ // src/roles/build/plan.ts
565
693
  function ownedBuildDependencies(cwd) {
566
694
  const manifest = readProjectPackageJson(cwd);
567
695
  const keys = [];
@@ -590,9 +718,10 @@ function buildScripts(cwd) {
590
718
  };
591
719
  const existingDev = existingCommand(cwd, DEV_SCRIPT_NAME);
592
720
  if (existingDev !== void 0) {
721
+ const own2 = basename2(cwd);
593
722
  scripts[DEV_SCRIPT_NAME] = composeDevScript(
594
723
  existingDev,
595
- selfCommand(cwd, "--run --dev") ?? SENTINEL_DEV_COMMAND
724
+ `${selfCommand(cwd, "--run --dev") ?? SENTINEL_DEV_COMMAND} --module ${own2}`
596
725
  );
597
726
  }
598
727
  return scripts;
@@ -616,14 +745,41 @@ function plan(context) {
616
745
  const operations = [];
617
746
  const configFile = buildConfigFile(context.cwd);
618
747
  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
- );
748
+ return {
749
+ operations: [],
750
+ 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.`
751
+ };
752
+ } else {
753
+ const notEsm = esmBlocker(configFile, readProjectPackageJson(context.cwd).type);
754
+ if (notEsm !== void 0) return { operations: [], skipped: notEsm };
755
+ const rewrite = rewriteBuildImports(readConfigSource(context.cwd, configFile), configFile);
756
+ if (rewrite.kind === "blocked") {
757
+ return {
758
+ operations: [],
759
+ 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.`
760
+ };
761
+ }
762
+ if (rewrite.kind === "rewritten") {
763
+ operations.push({ kind: "write", path: configFile, contents: rewrite.source });
764
+ }
765
+ for (const name of COMPANION_CONFIG_FILES) {
766
+ const source = readConfigSource(context.cwd, name);
767
+ if (source === "") continue;
768
+ const companion = rewriteBuildImports(source, name);
769
+ if (companion.kind === "rewritten") {
770
+ operations.push({ kind: "write", path: name, contents: companion.source });
771
+ notes.push(`${name} now imports ${companion.moved.join(", ")} from sentinel too.`);
772
+ } else if (companion.kind === "blocked") {
773
+ notes.push(
774
+ `${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.`
775
+ );
776
+ }
777
+ }
778
+ if (rewrite.kind === "rewritten") {
779
+ notes.push(
780
+ `${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.`
781
+ );
782
+ }
627
783
  }
628
784
  operations.push(manifestOperation(context.cwd, buildScripts(context.cwd)));
629
785
  operations.push(...nxTargetOperations({ cwd: context.cwd, targets: buildTarget() }));
@@ -636,12 +792,12 @@ function plan(context) {
636
792
  }
637
793
  return { operations, notes };
638
794
  }
639
- function readsPreset(cwd, configFile) {
640
- if (!existsSync7(join7(cwd, configFile))) return false;
795
+ function readConfigSource(cwd, configFile) {
796
+ if (!existsSync7(join7(cwd, configFile))) return "";
641
797
  try {
642
- return readFileSync5(join7(cwd, configFile), "utf8").includes(BUILD_PRESET_SPECIFIER);
798
+ return readFileSync5(join7(cwd, configFile), "utf8");
643
799
  } catch {
644
- return false;
800
+ return "";
645
801
  }
646
802
  }
647
803
 
@@ -791,48 +947,6 @@ var ViteDevAdapter = class extends ViteRoleAdapter {
791
947
  }
792
948
  };
793
949
 
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
950
  // src/roles/build/prerequisite.ts
837
951
  import { spawnSync as spawnSync2 } from "child_process";
838
952
  var PREBUILD_SCRIPT_NAME = `pre${BUILD_SCRIPT_NAME}`;
@@ -859,23 +973,6 @@ function runPrerequisite(cwd, env = process.env) {
859
973
  return { kind: "ran" };
860
974
  }
861
975
 
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
976
  // src/roles/build/adapters/vite/vite.adapter.ts
880
977
  var ViteAdapter = class extends ViteRoleAdapter {
881
978
  target = "build";
@@ -901,25 +998,30 @@ var ViteAdapter = class extends ViteRoleAdapter {
901
998
  return this.spawnVite(ctx, ready.vite, ["build"], 1);
902
999
  }
903
1000
  /**
904
- * What this module builds with, without building it.
1001
+ * What this module builds with, WITHOUT loading its config.
905
1002
  *
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.
1003
+ * Everything reported here is read from the manifest and from the config's text: whether the
1004
+ * module imports its toolchain from sentinel, which Vite install would answer, what it still
1005
+ * declares of its own, and whether it has a `prebuild` step.
909
1006
  *
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.
1007
+ * It used to also load the config through Vite and report where it departed from a preset
1008
+ * and what it added on top. Both were dropped with the preset itself. Adoption changes a
1009
+ * config's IMPORTS and nothing else, so there is no preset the app signed up to and no
1010
+ * distance from it to measure; and computing it meant EXECUTING an app's config, which is
1011
+ * expensive and can fail on its own terms (career throws without `VITE_BRAND_ID`). Reading
1012
+ * a file to report on it must not be able to break.
913
1013
  *
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.
1014
+ * They come back with the shared base, when there is again something an app has chosen to
1015
+ * adopt and can therefore depart from.
916
1016
  */
917
1017
  async inspect(ctx) {
918
1018
  const adoption = readBuildAdoption(ctx.cwd);
919
- const base = {
1019
+ return {
920
1020
  runner: "vite",
921
1021
  configFile: adoption.configFile,
922
1022
  ...adoption.unreadable ? { unreadable: adoption.unreadable } : {},
1023
+ // The field nothing else reports: an adopted config takes its plugins from sentinel, so
1024
+ // a module answering `module` here is one build away from the two-copies failure.
923
1025
  vite: viteOrigin(ctx.cwd),
924
1026
  // Rendered as a labelled block by the shared renderer, the same shape the lint role's
925
1027
  // parked rules and the format role's overrides already use.
@@ -929,36 +1031,6 @@ var ViteAdapter = class extends ViteRoleAdapter {
929
1031
  })),
930
1032
  prerequisite: moduleScripts(ctx.cwd)[PREBUILD_SCRIPT_NAME] ?? null
931
1033
  };
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
1034
  }
963
1035
  /**
964
1036
  * Adoption as data, WITHOUT building.
@@ -984,17 +1056,6 @@ var ViteAdapter = class extends ViteRoleAdapter {
984
1056
  };
985
1057
  }
986
1058
  };
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
1059
 
999
1060
  // src/roles/build/register.ts
1000
1061
  function registerBuild() {
@@ -4180,8 +4241,7 @@ import { join as join23 } from "path";
4180
4241
  // src/roles/lint/eslint-ignores.ts
4181
4242
  import { existsSync as existsSync18, readFileSync as readFileSync16 } from "fs";
4182
4243
  import { join as join21 } from "path";
4183
- var IGNORE_BLOCKS = [/\bignores\s*:\s*\[([^\]]*)\]/g, /\bglobalIgnores\s*\(\s*\[([^\]]*)\]/g];
4184
- var STRING_LITERAL = /['"`]([^'"`]+)['"`]/g;
4244
+ import { parseSync as parseSync2 } from "oxc-parser";
4185
4245
  var OPAQUE_SOURCE = /\b(includeIgnoreFile)\s*\([^)]*\)/g;
4186
4246
  function toOxlintPattern(pattern) {
4187
4247
  return pattern.replace(/^\.\//, "");
@@ -4201,15 +4261,62 @@ function readEslintIgnores(cwd) {
4201
4261
  return { patterns: [], unresolved: [] };
4202
4262
  }
4203
4263
  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));
4264
+ const unresolved = new Set([...source.matchAll(OPAQUE_SOURCE)].map((match) => match[0]));
4265
+ const { program, errors } = parseSync2(config, source, { sourceType: "module" });
4266
+ if (errors.length > 0) {
4267
+ return { patterns: [], unresolved: [`${config} could not be parsed`] };
4268
+ }
4269
+ walk(program, (node) => {
4270
+ if (node.type === "CallExpression" && calleeName(node) === "globalIgnores") {
4271
+ for (const literal of stringLiterals(firstArrayArgument(node))) {
4272
+ patterns.add(toOxlintPattern(literal));
4208
4273
  }
4274
+ return;
4209
4275
  }
4276
+ if (node.type !== "ObjectExpression") return;
4277
+ const keys = objectKeys(node);
4278
+ if (!keys.includes("ignores")) return;
4279
+ const values = arrayValues(node, "ignores");
4280
+ if (keys.length > 1) {
4281
+ unresolved.add(
4282
+ `ignores scoped to one config block (with ${keys.filter((k) => k !== "ignores").join(", ")}): ${values.join(", ")} \u2014 exempt from those RULES, not excluded from linting`
4283
+ );
4284
+ return;
4285
+ }
4286
+ for (const literal of values) patterns.add(toOxlintPattern(literal));
4287
+ });
4288
+ return { patterns: [...patterns], unresolved: [...unresolved] };
4289
+ }
4290
+ function walk(node, visit) {
4291
+ if (!node || typeof node !== "object") return;
4292
+ const current = node;
4293
+ if (typeof current.type === "string") visit(current);
4294
+ for (const key of Object.keys(current)) {
4295
+ const value = current[key];
4296
+ if (Array.isArray(value)) for (const entry of value) walk(entry, visit);
4297
+ else if (value && typeof value === "object") walk(value, visit);
4210
4298
  }
4211
- const unresolved = [...source.matchAll(OPAQUE_SOURCE)].map((match) => match[0]);
4212
- return { patterns: [...patterns], unresolved: [...new Set(unresolved)] };
4299
+ }
4300
+ function calleeName(node) {
4301
+ const callee = node.callee;
4302
+ return callee?.type === "Identifier" ? callee.name : void 0;
4303
+ }
4304
+ function firstArrayArgument(node) {
4305
+ const args = node.arguments ?? [];
4306
+ return args.find((argument) => argument?.type === "ArrayExpression");
4307
+ }
4308
+ function stringLiterals(array) {
4309
+ const elements = array?.elements ?? [];
4310
+ return elements.filter((element) => element?.type === "Literal" && typeof element.value === "string").map((element) => element.value);
4311
+ }
4312
+ function objectKeys(node) {
4313
+ const properties = node.properties ?? [];
4314
+ return properties.filter((property) => property?.type === "Property" && !property.computed).map((property) => property.key?.name ?? String(property.key?.value ?? "")).filter(Boolean);
4315
+ }
4316
+ function arrayValues(node, name) {
4317
+ const properties = node.properties ?? [];
4318
+ const found = properties.find((property) => property.key?.name === name);
4319
+ return stringLiterals(found?.value);
4213
4320
  }
4214
4321
 
4215
4322
  // src/roles/lint/lint-script.ts
@@ -4755,7 +4862,7 @@ ${result.stderr ?? ""}`);
4755
4862
  } else {
4756
4863
  rmSync(baselinePath, { force: true });
4757
4864
  }
4758
- if (next.length !== current.length || next.some((entry, at2) => entry !== current[at2])) {
4865
+ if (next.length !== current.length || next.some((entry, at) => entry !== current[at])) {
4759
4866
  config.extends = next;
4760
4867
  writeFileSync2(configPath, `${JSON.stringify(config, null, 2)}
4761
4868
  `);
@@ -5717,7 +5824,7 @@ function detectFramework(packageJson) {
5717
5824
  }
5718
5825
 
5719
5826
  // src/core/dispatch.ts
5720
- import { basename as basename2 } from "path";
5827
+ import { basename as basename3 } from "path";
5721
5828
 
5722
5829
  // src/shared/text.ts
5723
5830
  function ensureLines(current, lines) {
@@ -5776,6 +5883,13 @@ function replaceLines(current, replacements) {
5776
5883
  import { existsSync as existsSync22, readFileSync as readFileSync22, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
5777
5884
  import { resolve as resolve4, sep } from "path";
5778
5885
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
5886
+
5887
+ // src/shared/deep-merge.ts
5888
+ function isPlainObject(value) {
5889
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5890
+ }
5891
+
5892
+ // src/core/apply-plan.ts
5779
5893
  function resolveWithinRoot(cwd, relativePath) {
5780
5894
  const root = resolve4(cwd);
5781
5895
  const absolutePath = resolve4(root, relativePath);
@@ -6086,7 +6200,7 @@ async function dispatch(opts) {
6086
6200
  process.stderr.write(` fixing what ${opts.target} can fix automatically...
6087
6201
  `);
6088
6202
  await adapter.afterInit({
6089
- module: basename2(opts.cwd),
6203
+ module: basename3(opts.cwd),
6090
6204
  cwd: opts.cwd,
6091
6205
  preset,
6092
6206
  ci: false,
@@ -6111,6 +6225,7 @@ export {
6111
6225
  readNxProjectName,
6112
6226
  VERBS,
6113
6227
  TARGETS,
6228
+ LONG_RUNNING_TARGETS,
6114
6229
  SWEEPABLE_TARGETS,
6115
6230
  PRESET_NAMES,
6116
6231
  WORKSPACE_ROOT_MARKER,
@@ -6123,4 +6238,4 @@ export {
6123
6238
  detectFramework,
6124
6239
  dispatch
6125
6240
  };
6126
- //# sourceMappingURL=chunk-FLJ2QNFF.js.map
6241
+ //# sourceMappingURL=chunk-SA2RMTVI.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-SA2RMTVI.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.2",
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