@hublo/sentinel 1.5.1-alpha.2 → 1.6.0-alpha.1

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
@@ -141,7 +141,7 @@ The per-tool knowledge (eslint → `eslint.config.js`, tsc → `tsconfig`, …)
141
141
 
142
142
  **Registry: public npm**, under `@hublo`. We started on **GitHub Packages (private)** and moved off it: it authenticates every consumer, including a one-off run, which is incompatible with the zero-setup adoption above. Public npm needs no consumer auth, so a module adopts sentinel without any `.npmrc` or token. Releases go out through the repo's `publish` workflow, never from a laptop; a prerelease is published under its prerelease dist-tag (`alpha`) and a stable one under `latest`.
143
143
 
144
- **Node.** sentinel needs **Node >= 24.15**, which is the version the monorepo it serves pins in its own `.node-version`. The floor follows the consumer deliberately: a lower one means testing a runtime nobody runs, and it twice let an API through that the types allowed and the runtime did not. It fails fast with a clear message on an older runtime rather than crashing. If a project runs on an older Node (e.g. a legacy app on Node 10), run sentinel with a modern Node via `fnm`/`nvm`; you do not need to change the project's own Node.
144
+ **Node.** sentinel needs **Node >= 24.15**, which is the version the monorepo it serves pins in its own `.node-version`. The floor follows the consumer deliberately: a lower one means testing a runtime nobody runs, and it twice let an API through that the types allowed and the runtime did not. It fails fast with a clear message on an older runtime rather than crashing. That message comes from sentinel itself, so `npx` must run on Node >= 24.15 too: the npm 10 that Node 20 and 22 bundle fails while installing sentinel (`Cannot read properties of null (reading 'edgesOut')`), before sentinel's own check can run. If a project runs on an older Node (e.g. a legacy app on Node 10), run sentinel with a modern Node via `fnm`/`nvm`; you do not need to change the project's own Node.
145
145
 
146
146
  **Try it without installing.** A one-off run needs no auth and touches nothing:
147
147
 
@@ -176,6 +176,7 @@ throwaway run wants. Once a module has adopted, nothing is fetched any more: the
176
176
  - [`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.
177
177
  - [`docs/test-adoption.md`](docs/test-adoption.md) — migrating a module from jest to Vitest: the two steps, what the migration rewrites and what it refuses BY NAME, how the baseline gate proves nothing was lost, and the failures worth recognising.
178
178
  - [`docs/static-analysis-adoption.md`](docs/static-analysis-adoption.md) — running the static analysis role on a module: the two steps, the four output shapes, how to read each row and where its threshold comes from, declaring an entry point, and the two limits worth knowing before trusting a number.
179
+ - [`docs/env-adoption.md`](docs/env-adoption.md) — checking a whole repository's env files, contracts and Dockerfiles from its root: the ten rules and which block, the three levels of `SENTINEL_ENV_LEVEL`, the optional `env.config.mjs`, and what changed from the monorepo's `tools/repo-env`.
179
180
  - [`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`.
180
181
 
181
182
  ## Architecture: `target → runner → preset`
@@ -184,7 +185,7 @@ Every check is described by three layers:
184
185
 
185
186
  | Layer | Flag | What it is | Examples |
186
187
  | -------------------- | --------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
187
- | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `dev` `test` `static-analysis` `runtime-analysis` |
188
+ | **target** (role) | `--lint`, `--typescript`, … | the _kind_ of check, stable | `lint` `format` `typescript` `build` `dev` `test` `static-analysis` `env` `runtime-analysis` |
188
189
  | **runner** (adapter) | `--runner=<tool>` | the _tool_ behind the target, swappable | lint: `eslint`/`biome`/`oxlint` · types: `tsc`/`tsgo` · build: `vite` · test: `vitest` |
189
190
  | **shape** (output) | `--output=<shape>` | what a reporting role WRITES, not what it measures | `console` (default, writes nothing) `html` `json` `markdown`; roles that do not report ignore it |
190
191
  | **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) |
@@ -402,8 +403,10 @@ VERBS --run execute the target's tool (--json for metrics + diagnosti
402
403
  --status deprecated → --inspect
403
404
 
404
405
  TYPES --lint --format --typescript --build --dev --test
405
- --static-analysis --runtime-analysis --arch
406
+ --static-analysis --env --runtime-analysis --arch
406
407
  (omit a type → every type that TERMINATES; --dev never sweeps)
408
+ --env checks the whole REPOSITORY from its git root, --run only;
409
+ a sweep includes it once, and only from that root
407
410
  --dev answers --run and --init only: a server is started, not read,
408
411
  so --inspect/--report/--status refuse it and point at --build
409
412
 
@@ -541,8 +544,9 @@ This scaffold is the foundation; each tool is added one at a time on top of it:
541
544
  4. **Build** (`--build`) — `vite`.
542
545
  5. **Test** (`--test`) — `vitest`, migration from jest proven module by module against a name-level baseline. **✅ shipped in `1.4.0-alpha.x`**; a11y / w3c setups still to come.
543
546
  6. **Static analysis** (`--static-analysis`) — cycles, complexity, duplication, centrality.
544
- 7. **Runtime analysis** (`--runtime-analysis`) — bundle, Lighthouse, web vitals.
545
- 8. **Unified CI workflow + `--report --all` dashboard.**
547
+ 7. **Env conformance** (`--env`) — the monorepo's `tools/repo-env`, ported to run on any repository from its root.
548
+ 8. **Runtime analysis** (`--runtime-analysis`) — bundle, Lighthouse, web vitals.
549
+ 9. **Unified CI workflow + `--report --all` dashboard.**
546
550
 
547
551
  ## Open decisions & risks being validated
548
552
 
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ ENV_SHAPES,
3
4
  PresetUnsupportedError,
4
5
  availableTargets,
5
6
  buildConfigFile,
@@ -11,7 +12,7 @@ import {
11
12
  presetNotInstalledYet,
12
13
  registerAdapters,
13
14
  resolve
14
- } from "../chunk-JHLNNIZC.js";
15
+ } from "../chunk-5267A6X3.js";
15
16
  import {
16
17
  resolveContext
17
18
  } from "../chunk-P7NYSMLS.js";
@@ -23,13 +24,17 @@ import {
23
24
  readProjectPackageJson
24
25
  } from "../chunk-JRZI5IIN.js";
25
26
  import {
27
+ DEFAULT_ENV_LEVEL,
28
+ ENV_LEVELS,
26
29
  LONG_RUNNING_TARGETS,
27
30
  OUTPUT_SHAPES,
28
31
  PRESET_NAMES,
32
+ REPOSITORY_TARGETS,
29
33
  SWEEPABLE_TARGETS,
30
34
  TARGETS,
31
35
  VERBS
32
- } from "../chunk-WBDBRGLV.js";
36
+ } from "../chunk-7E6YTTAK.js";
37
+ import "../chunk-4EIY7TTR.js";
33
38
  import "../chunk-O7REVMOC.js";
34
39
  import "../chunk-QXFCZON7.js";
35
40
 
@@ -37,6 +42,7 @@ import "../chunk-QXFCZON7.js";
37
42
  import { program } from "commander";
38
43
 
39
44
  // src/core/orchestrate.ts
45
+ import { basename } from "node:path";
40
46
  async function analyse(params) {
41
47
  const results = [];
42
48
  let worstCode = 0;
@@ -177,6 +183,57 @@ async function analyse(params) {
177
183
  }
178
184
  return { results, worstCode };
179
185
  }
186
+ function definedOnly(source, keys) {
187
+ return Object.fromEntries(
188
+ keys.flatMap((key) => source[key] === void 0 ? [] : [[key, source[key]]])
189
+ );
190
+ }
191
+ async function analyseRepository(params) {
192
+ const results = [];
193
+ let worstCode = 0;
194
+ const project = basename(params.root);
195
+ for (const target of params.targets) {
196
+ const ctx = {
197
+ module: project,
198
+ cwd: params.root,
199
+ preset: "node",
200
+ ci: params.ci,
201
+ fix: false,
202
+ output: params.output,
203
+ envLevel: params.envLevel,
204
+ githubActions: params.githubActions
205
+ };
206
+ try {
207
+ const adapter = resolve(target, void 0, params.runner);
208
+ const result = params.verb === "run" ? await adapter.run(ctx) : await adapter.report(ctx);
209
+ const metrics = result.metrics ?? {};
210
+ results.push({
211
+ project,
212
+ target,
213
+ flavour: "repository",
214
+ ok: result.ok,
215
+ status: result.ok ? "ok" : "failed",
216
+ // A run row carries the verdict's two inputs and nothing more: the report itself was just
217
+ // printed above it, and repeating it inline would print it twice. A run that could not
218
+ // start has no blocking count, and carries its `error` instead: a key is left out when it
219
+ // has no value, never printed as `undefined`.
220
+ data: params.verb === "run" ? definedOnly(metrics, ["level", "blocking", "error"]) : metrics
221
+ });
222
+ worstCode = Math.max(worstCode, result.code);
223
+ } catch (error) {
224
+ results.push({
225
+ project,
226
+ target,
227
+ flavour: "repository",
228
+ ok: false,
229
+ status: "failed",
230
+ data: { error: error instanceof Error ? error.message : String(error) }
231
+ });
232
+ worstCode = Math.max(worstCode, 1);
233
+ }
234
+ }
235
+ return { results, worstCode };
236
+ }
180
237
  function generateSummary(result) {
181
238
  const { project, target, flavour, ok, status, data } = result;
182
239
  const details = data && typeof data === "object" ? data : { value: data };
@@ -221,7 +278,7 @@ import {
221
278
  writeFileSync
222
279
  } from "node:fs";
223
280
  import { tmpdir } from "node:os";
224
- import { basename, dirname, join, relative, sep } from "node:path";
281
+ import { basename as basename2, dirname, join, relative, sep } from "node:path";
225
282
  var NOT_COPIED = /* @__PURE__ */ new Set(["node_modules", ".git"]);
226
283
  function contents(root) {
227
284
  const found = /* @__PURE__ */ new Map();
@@ -242,7 +299,7 @@ function contents(root) {
242
299
  }
243
300
  function stage(moduleDir, scratch) {
244
301
  const root = findWorkspaceRoot(moduleDir);
245
- const segments = root === void 0 ? [basename(moduleDir)] : relative(root, moduleDir).split(sep).filter((segment) => segment !== "");
302
+ const segments = root === void 0 ? [basename2(moduleDir)] : relative(root, moduleDir).split(sep).filter((segment) => segment !== "");
246
303
  let real = root ?? dirname(moduleDir);
247
304
  let shadow = scratch;
248
305
  for (const segment of segments) {
@@ -257,7 +314,7 @@ function stage(moduleDir, scratch) {
257
314
  rmSync(shadow, { recursive: true, force: true });
258
315
  cpSync(moduleDir, shadow, {
259
316
  recursive: true,
260
- filter: (source) => source === moduleDir || !NOT_COPIED.has(basename(source)) && !lstatSync(source).isSymbolicLink()
317
+ filter: (source) => source === moduleDir || !NOT_COPIED.has(basename2(source)) && !lstatSync(source).isSymbolicLink()
261
318
  });
262
319
  if (existsSync(join(moduleDir, "node_modules"))) {
263
320
  symlinkSync(join(moduleDir, "node_modules"), join(shadow, "node_modules"), "dir");
@@ -294,7 +351,7 @@ async function rehearseAfterInit(moduleDir, roles) {
294
351
  const before = contents(copy);
295
352
  const failure = await role.adapter.rehearseAfterInit?.(
296
353
  {
297
- module: basename(moduleDir),
354
+ module: basename2(moduleDir),
298
355
  cwd: copy,
299
356
  preset: role.preset,
300
357
  ci: false,
@@ -541,6 +598,9 @@ async function runValidate(ctx) {
541
598
  return outcomes.every((outcome) => outcome.ok) ? 0 : 1;
542
599
  }
543
600
 
601
+ // src/cli/run-verb.ts
602
+ import { basename as basename3 } from "node:path";
603
+
544
604
  // src/core/render.ts
545
605
  var HEAD_KEYS = /* @__PURE__ */ new Set([
546
606
  "project",
@@ -660,7 +720,9 @@ function workspacePrep(cwd2) {
660
720
  return root ? inspectWorkspacePrep(root) : [];
661
721
  }
662
722
  async function runVerb(ctx) {
663
- const { modules, scope } = resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci });
723
+ const repositoryTargets = ctx.targets.filter((target) => REPOSITORY_TARGETS.includes(target));
724
+ const moduleTargets = ctx.targets.filter((target) => !REPOSITORY_TARGETS.includes(target));
725
+ const { modules, scope } = moduleTargets.length > 0 ? resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci }) : { modules: [] };
664
726
  const out = palette(process.stdout);
665
727
  const err = palette(process.stderr);
666
728
  const longRunning = ctx.targets.filter((target) => LONG_RUNNING_TARGETS.includes(target));
@@ -701,9 +763,9 @@ async function runVerb(ctx) {
701
763
  );
702
764
  }
703
765
  const started = Date.now();
704
- const { results, worstCode } = await analyse({
766
+ const moduleRun = await analyse({
705
767
  verb: shaped,
706
- targets: ctx.targets,
768
+ targets: moduleTargets,
707
769
  modules,
708
770
  runner: ctx.runner,
709
771
  preset: ctx.preset,
@@ -721,6 +783,18 @@ async function runVerb(ctx) {
721
783
  onProgress: process.stderr.isTTY ? (done, total, name) => process.stderr.write(err.dim(` [${done}/${total}] ${name}
722
784
  `)) : void 0
723
785
  });
786
+ const repositoryRun = repositoryTargets.length > 0 && ctx.repositoryRoot !== void 0 ? await analyseRepository({
787
+ verb: shaped === "report" ? "report" : "run",
788
+ targets: repositoryTargets,
789
+ root: ctx.repositoryRoot,
790
+ runner: ctx.runner,
791
+ ci: ctx.ci,
792
+ output: ctx.output,
793
+ envLevel: ctx.envLevel,
794
+ githubActions: ctx.githubActions
795
+ }) : { results: [], worstCode: 0 };
796
+ const results = [...moduleRun.results, ...repositoryRun.results];
797
+ const worstCode = Math.max(moduleRun.worstCode, repositoryRun.worstCode);
724
798
  const summary = generateSummaries(results, ctx.targets);
725
799
  if (ctx.json) {
726
800
  const base = ctx.verb === "status" || ctx.verb === "inspect" ? {
@@ -756,8 +830,11 @@ async function runVerb(ctx) {
756
830
  }
757
831
  }
758
832
  process.stderr.write(
759
- err.dim(` ${modules.length} module(s) [${scope}] in ${Date.now() - started}ms
760
- `)
833
+ err.dim(
834
+ moduleTargets.length > 0 ? ` ${modules.length} module(s) [${scope}] in ${Date.now() - started}ms
835
+ ` : ` 1 repository [${basename3(ctx.repositoryRoot ?? ctx.cwd)}] in ${Date.now() - started}ms
836
+ `
837
+ )
761
838
  );
762
839
  if (ctx.targetsExplicit && summary.skipped.length > 0) {
763
840
  const avail = availableTargets();
@@ -771,6 +848,35 @@ async function runVerb(ctx) {
771
848
  return ctx.verb === "run" || ctx.ci ? worstCode : 0;
772
849
  }
773
850
 
851
+ // src/core/repository-root.ts
852
+ import { execFileSync } from "node:child_process";
853
+ import { realpathSync as realpathSync2 } from "node:fs";
854
+ function findRepositoryRoot(cwd2) {
855
+ try {
856
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
857
+ cwd: cwd2,
858
+ encoding: "utf8",
859
+ stdio: ["ignore", "pipe", "ignore"]
860
+ }).trim();
861
+ return root === "" ? void 0 : realpathSync2(root);
862
+ } catch {
863
+ return void 0;
864
+ }
865
+ }
866
+ function isRepositoryRoot(cwd2, root) {
867
+ try {
868
+ return realpathSync2(cwd2) === root;
869
+ } catch {
870
+ return false;
871
+ }
872
+ }
873
+ function locateRepository(cwd2) {
874
+ const root = findRepositoryRoot(cwd2);
875
+ if (root === void 0) return { ok: false, reason: "not-a-repository" };
876
+ if (!isRepositoryRoot(cwd2, root)) return { ok: false, reason: "not-the-root", root };
877
+ return { ok: true, root };
878
+ }
879
+
774
880
  // src/shared/node-version.ts
775
881
  var MIN_NODE = "24.15.0";
776
882
  function parts(version) {
@@ -807,6 +913,7 @@ var TARGET_HELP = {
807
913
  dev: "dev server (long-running: never swept by an unqualified --run)",
808
914
  test: "tests",
809
915
  "static-analysis": "duplication, dead code, complexity, import graph, file size, bundle weight",
916
+ env: "env files, contracts and Dockerfiles, for the whole repository (run from its root)",
810
917
  "runtime-analysis": "Lighthouse, web vitals",
811
918
  arch: "architecture boundaries"
812
919
  };
@@ -929,7 +1036,64 @@ if (opts.all && namedTargets.length > 0) {
929
1036
  `--all runs every target; drop the specific one(s): ${namedTargets.map((t) => `--${t}`).join(", ")}.`
930
1037
  );
931
1038
  }
932
- var targets = opts.all || namedTargets.length === 0 ? [...SWEEPABLE_TARGETS] : namedTargets;
1039
+ var RUNNING_VERBS = ["run", "report"];
1040
+ var runsRepository = RUNNING_VERBS.includes(verb);
1041
+ var sweeping = Boolean(opts.all) || namedTargets.length === 0;
1042
+ var sweptRoot = sweeping && runsRepository ? locateRepository(cwd) : void 0;
1043
+ var repositoryRoot = sweptRoot?.ok ? sweptRoot.root : void 0;
1044
+ var targets = sweeping ? SWEEPABLE_TARGETS.filter(
1045
+ (target) => !REPOSITORY_TARGETS.includes(target) || repositoryRoot !== void 0
1046
+ ) : namedTargets;
1047
+ var namedRepository = namedTargets.filter((target) => REPOSITORY_TARGETS.includes(target));
1048
+ if (namedRepository.length > 0) {
1049
+ const flags = namedRepository.map((target) => `--${target}`).join(", ");
1050
+ if (!runsRepository) {
1051
+ program.error(
1052
+ `sentinel: ${flags} reads the repository and writes no config, so it only runs: \`sentinel --run ${flags}\`. It has no --${verb}.`
1053
+ );
1054
+ }
1055
+ if (opts.module !== void 0) {
1056
+ program.error(
1057
+ `sentinel: ${flags} checks the whole repository, so --module does not apply to it. Drop --module, or run ${flags} on its own.`
1058
+ );
1059
+ }
1060
+ if (opts.fix) {
1061
+ program.error(`sentinel: ${flags} reports and fixes nothing; drop --fix.`);
1062
+ }
1063
+ if (toolArgs.length > 0) {
1064
+ program.error(
1065
+ `sentinel: ${flags} runs no external tool, so options after \`--\` have nowhere to go.`
1066
+ );
1067
+ }
1068
+ const location = locateRepository(cwd);
1069
+ if (!location.ok) {
1070
+ program.error(
1071
+ location.reason === "not-a-repository" ? `sentinel: ${flags} reads the repository through git, and ${cwd} is not inside a git repository (or git cannot run here).` : `sentinel: ${flags} checks the whole repository, so it runs from its root rather than from a directory inside it. Run it from ${location.root ?? "the git top-level"}.`
1072
+ );
1073
+ }
1074
+ repositoryRoot = location.root;
1075
+ const asked = typeof opts.output === "string" ? opts.output.split(",").map((one) => one.trim()) : [];
1076
+ const unwritten = asked.filter(
1077
+ (one) => one !== "" && !ENV_SHAPES.includes(one)
1078
+ );
1079
+ if (targets.every((target) => REPOSITORY_TARGETS.includes(target)) && unwritten.length > 0) {
1080
+ program.error(
1081
+ `sentinel: ${flags} writes ${ENV_SHAPES.join(", ")}; not ${unwritten.join(", ")}.`
1082
+ );
1083
+ }
1084
+ }
1085
+ function parseEnvLevel(value) {
1086
+ const asked = (value ?? "").trim().toLowerCase();
1087
+ if (asked === "") return DEFAULT_ENV_LEVEL;
1088
+ if (!ENV_LEVELS.includes(asked)) {
1089
+ return program.error(
1090
+ `sentinel: unknown SENTINEL_ENV_LEVEL ${JSON.stringify(value)}. Supported: ${ENV_LEVELS.join(", ")} (${DEFAULT_ENV_LEVEL} when unset).`
1091
+ );
1092
+ }
1093
+ return asked;
1094
+ }
1095
+ var envLevel = targets.some((target) => REPOSITORY_TARGETS.includes(target)) ? parseEnvLevel(process.env.SENTINEL_ENV_LEVEL) : DEFAULT_ENV_LEVEL;
1096
+ var githubActions = process.env.GITHUB_ACTIONS === "true";
933
1097
  var targetsExplicit = !(opts.all || namedTargets.length === 0);
934
1098
  if (namedTargets.includes("static-analysis") && (verb === "report" || verb === "run" && opts.json)) {
935
1099
  program.error(
@@ -991,6 +1155,9 @@ async function main() {
991
1155
  output: parseOutput(opts.output, Boolean(opts.json)) ?? (opts.json ? "json" : void 0),
992
1156
  dryRun: Boolean(opts.dryRun),
993
1157
  rehearse: Boolean(opts.rehearse),
1158
+ repositoryRoot,
1159
+ envLevel,
1160
+ githubActions,
994
1161
  fail: (message) => program.error(message)
995
1162
  };
996
1163
  const exitCode = verb === "init" ? await runInit(context) : verb === "validate" ? await runValidate(context) : await runVerb(context);
@@ -0,0 +1,89 @@
1
+ // src/roles/env/configs/define.ts
2
+ var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
3
+ "externallyProvided",
4
+ "knownExternalVars",
5
+ "knownExternalPatterns",
6
+ "allowNonBlankedSecrets",
7
+ "allowedCommittedEnvFiles",
8
+ "envFiles",
9
+ "contracts",
10
+ "scan",
11
+ "references",
12
+ "readers",
13
+ "docker"
14
+ ]);
15
+ var SCOPE_KEYS = /* @__PURE__ */ new Set(["roots", "ext", "excludeSuffixes", "excludeDirs"]);
16
+ var NESTED_KEYS = {
17
+ scan: SCOPE_KEYS,
18
+ references: SCOPE_KEYS,
19
+ readers: /* @__PURE__ */ new Set(["required", "optional"]),
20
+ docker: /* @__PURE__ */ new Set(["allowRoot", "excludePaths"])
21
+ };
22
+ function isRecord(value) {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+ function kindOf(value) {
26
+ if (value === null) return "null";
27
+ if (Array.isArray(value)) return "an array";
28
+ if (value instanceof RegExp) return "a RegExp";
29
+ if (value === "") return "an empty string";
30
+ const type = typeof value;
31
+ return type === "object" ? "an object" : `a ${type}`;
32
+ }
33
+ function badEntry(path, list, valid) {
34
+ const index = list.findIndex((entry) => !valid(entry));
35
+ return index === -1 ? void 0 : `${path}[${index}] is ${kindOf(list[index])}`;
36
+ }
37
+ function stringListProblems(path, value) {
38
+ if (value === void 0) return [];
39
+ if (!Array.isArray(value)) return [`${path} must be an array of strings, got ${kindOf(value)}`];
40
+ const bad = badEntry(path, value, (entry) => typeof entry === "string" && entry !== "");
41
+ return bad ? [`${path} must hold non-empty strings only: ${bad}`] : [];
42
+ }
43
+ function problemsOf(input) {
44
+ const problems = [];
45
+ for (const [key, value] of Object.entries(input)) {
46
+ if (!TOP_LEVEL_KEYS.has(key)) {
47
+ problems.push(`unknown key "${key}"`);
48
+ continue;
49
+ }
50
+ const nested = NESTED_KEYS[key];
51
+ if (nested === void 0) {
52
+ if (key === "knownExternalPatterns") {
53
+ const bad = Array.isArray(value) ? badEntry(key, value, (entry) => entry instanceof RegExp || typeof entry === "string") : void 0;
54
+ if (value !== void 0 && !Array.isArray(value)) {
55
+ problems.push(`${key} must be an array of RegExp or strings, got ${kindOf(value)}`);
56
+ } else if (bad) {
57
+ problems.push(`${key} must hold RegExp or strings only: ${bad}`);
58
+ }
59
+ } else {
60
+ problems.push(...stringListProblems(key, value));
61
+ }
62
+ continue;
63
+ }
64
+ if (value === void 0) continue;
65
+ if (!isRecord(value)) {
66
+ problems.push(`${key} must be an object, got ${kindOf(value)}`);
67
+ continue;
68
+ }
69
+ for (const [inner, innerValue] of Object.entries(value)) {
70
+ if (!nested.has(inner)) problems.push(`unknown key "${key}.${inner}"`);
71
+ else problems.push(...stringListProblems(`${key}.${inner}`, innerValue));
72
+ }
73
+ }
74
+ return problems;
75
+ }
76
+ function defineEnv(input = {}) {
77
+ if (!isRecord(input)) throw new Error(`env config must export an object, got ${kindOf(input)}`);
78
+ const problems = problemsOf(input);
79
+ if (problems.length > 0) {
80
+ throw new Error(`invalid env config:
81
+ - ${problems.join("\n - ")}`);
82
+ }
83
+ return input;
84
+ }
85
+
86
+ export {
87
+ defineEnv
88
+ };
89
+ //# sourceMappingURL=chunk-4EIY7TTR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/roles/env/configs/define.ts"],"sourcesContent":["/**\n * `defineEnv`: what a repository writes to declare its own env facts, all of them optional.\n *\n * ```js\n * // env.config.mjs, at the repository root\n * import { defineEnv } from '@hublo/sentinel/env'\n *\n * export default defineEnv({\n * externallyProvided: ['NODE_ENV'],\n * allowNonBlankedSecrets: ['apps/front/x/.env.example:LEGACY_API_KEY'],\n * })\n * ```\n *\n * A repository that does not install sentinel, and runs it with `npx`, cannot import this module:\n * its own `node_modules` has no `@hublo/sentinel`. So the file may export the same object PLAIN,\n * and the role passes it through this function itself. Both spellings mean the same thing, and the\n * function exists for the one that can import it: it validates at the moment the file is written,\n * in an editor that knows the types.\n *\n * ## What a repository declares, and what stays sentinel's\n *\n * A repository declares FACTS about itself: which variables its CI injects, which committed value\n * was reviewed and is not a secret, which Dockerfile must run as root, where its code lives. It\n * does not redefine what a secret NAME looks like or how code reads env, which are the same in\n * every repository and ship in `presets/shared.json`.\n *\n * Two semantics, stated once:\n *\n * - `readers`, `envFiles` and `contracts` ADD to what sentinel ships, so no repository loses\n * `getOrFail` or `**\\/.env` by declaring a reader or a glob of its own;\n * - `scan` and `references` REPLACE, key by key: a repository naming its `roots` says where its\n * code is, and keeping `.` beside them would scan everything anyway.\n *\n * ⛔ An unknown key is refused, by name. The file is `.mjs`, so nothing type-checks it, and a key\n * spelled `allowNonBlankedSecret` would otherwise be ignored in silence while the finding it was\n * meant to downgrade kept failing the build.\n */\n\n/** A source scope a repository may narrow, every key optional. */\nexport interface EnvScopeInput {\n roots?: string[]\n ext?: string[]\n excludeSuffixes?: string[]\n excludeDirs?: string[]\n}\n\n/** What `env.config.mjs` exports. */\nexport interface EnvInput {\n /** Required variables in no contract because CI injects them or a secret store provides them. */\n externallyProvided?: string[]\n /** Declared variables consumed outside scannable source, so never reported dead (rule 4). */\n knownExternalVars?: string[]\n /** The same, by pattern, for a whole category such as `/_DATABASE_URL$/`. */\n knownExternalPatterns?: (RegExp | string)[]\n /** `<file>:<VAR>` locations whose committed value was reviewed and is not a secret (rule 6). */\n allowNonBlankedSecrets?: string[]\n /** Committed real env files permitted anyway, such as a test fixture (rule 2). */\n allowedCommittedEnvFiles?: string[]\n /** Globs of env files to read, ADDED to `**\\/.env`, `**\\/.env.*` and `**\\/*.env`. */\n envFiles?: string[]\n /** Globs of contracts, ADDED to `**\\/*.env*.example`. */\n contracts?: string[]\n /** Where the named readers are looked for (rules 1 and 3). Replaces, key by key. */\n scan?: EnvScopeInput\n /** Where direct `process.env` reads are looked for (rules 3 and 4). Replaces, key by key. */\n references?: EnvScopeInput\n /** Reader names ADDED to sentinel's: `required` throw when absent, `optional` default. */\n readers?: { required?: string[]; optional?: string[] }\n /** Docker rules: Dockerfiles allowed to run as root, and path prefixes not checked at all. */\n docker?: { allowRoot?: string[]; excludePaths?: string[] }\n}\n\nconst TOP_LEVEL_KEYS = new Set<string>([\n 'externallyProvided',\n 'knownExternalVars',\n 'knownExternalPatterns',\n 'allowNonBlankedSecrets',\n 'allowedCommittedEnvFiles',\n 'envFiles',\n 'contracts',\n 'scan',\n 'references',\n 'readers',\n 'docker',\n])\nconst SCOPE_KEYS = new Set(['roots', 'ext', 'excludeSuffixes', 'excludeDirs'])\nconst NESTED_KEYS: Readonly<Record<string, ReadonlySet<string>>> = {\n scan: SCOPE_KEYS,\n references: SCOPE_KEYS,\n readers: new Set(['required', 'optional']),\n docker: new Set(['allowRoot', 'excludePaths']),\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * What a value IS, in a word, for a message that says what was found instead of what was wanted.\n * Its kind, never its content: a config line can hold a secret's location, and a refusal has no\n * reason to repeat it.\n */\nfunction kindOf(value: unknown): string {\n if (value === null) return 'null'\n if (Array.isArray(value)) return 'an array'\n if (value instanceof RegExp) return 'a RegExp'\n if (value === '') return 'an empty string'\n const type = typeof value\n return type === 'object' ? 'an object' : `a ${type}`\n}\n\n/** The first entry of a list that fails `valid`, named by its index and its kind. */\nfunction badEntry(path: string, list: readonly unknown[], valid: (entry: unknown) => boolean) {\n const index = list.findIndex((entry) => !valid(entry))\n return index === -1 ? undefined : `${path}[${index}] is ${kindOf(list[index])}`\n}\n\n/** Every problem with one list: it must be an array of non-empty strings. */\nfunction stringListProblems(path: string, value: unknown): string[] {\n if (value === undefined) return []\n if (!Array.isArray(value)) return [`${path} must be an array of strings, got ${kindOf(value)}`]\n const bad = badEntry(path, value, (entry) => typeof entry === 'string' && entry !== '')\n return bad ? [`${path} must hold non-empty strings only: ${bad}`] : []\n}\n\n/** Every problem with the input, each naming the key, so one run lists them all. */\nfunction problemsOf(input: Record<string, unknown>): string[] {\n const problems: string[] = []\n for (const [key, value] of Object.entries(input)) {\n if (!TOP_LEVEL_KEYS.has(key)) {\n problems.push(`unknown key \"${key}\"`)\n continue\n }\n const nested = NESTED_KEYS[key]\n if (nested === undefined) {\n if (key === 'knownExternalPatterns') {\n const bad = Array.isArray(value)\n ? badEntry(key, value, (entry) => entry instanceof RegExp || typeof entry === 'string')\n : undefined\n if (value !== undefined && !Array.isArray(value)) {\n problems.push(`${key} must be an array of RegExp or strings, got ${kindOf(value)}`)\n } else if (bad) {\n problems.push(`${key} must hold RegExp or strings only: ${bad}`)\n }\n } else {\n problems.push(...stringListProblems(key, value))\n }\n continue\n }\n if (value === undefined) continue\n if (!isRecord(value)) {\n problems.push(`${key} must be an object, got ${kindOf(value)}`)\n continue\n }\n for (const [inner, innerValue] of Object.entries(value)) {\n if (!nested.has(inner)) problems.push(`unknown key \"${key}.${inner}\"`)\n else problems.push(...stringListProblems(`${key}.${inner}`, innerValue))\n }\n }\n return problems\n}\n\n/**\n * Validate a repository's env declaration and return it unchanged.\n *\n * The defaults are NOT merged in here. They ship with sentinel and are applied when the role runs,\n * so changing one reaches every repository at its next sentinel version instead of leaving a copy\n * frozen in each config. That also makes this function idempotent, which is what lets the role\n * pass a plain exported object through it a second time.\n *\n * @param input the declaration; omitted, the repository declares nothing\n * @returns the same declaration\n * @throws naming every unknown key and every malformed value at once\n */\nexport function defineEnv(input: EnvInput = {}): EnvInput {\n if (!isRecord(input)) throw new Error(`env config must export an object, got ${kindOf(input)}`)\n const problems = problemsOf(input)\n if (problems.length > 0) {\n throw new Error(`invalid env config:\\n - ${problems.join('\\n - ')}`)\n }\n return input\n}\n"],"mappings":"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","names":[]}