@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 +9 -5
- package/dist/bin/sentinel.js +179 -12
- package/dist/chunk-4EIY7TTR.js +89 -0
- package/dist/chunk-4EIY7TTR.js.map +1 -0
- package/dist/{chunk-JHLNNIZC.js → chunk-5267A6X3.js} +1400 -272
- package/dist/{chunk-WBDBRGLV.js → chunk-7E6YTTAK.js} +8 -1
- package/dist/chunk-7E6YTTAK.js.map +1 -0
- package/dist/index.d.ts +31 -1
- package/dist/index.js +3 -2
- package/dist/roles/env/config.d.ts +90 -0
- package/dist/roles/env/config.js +7 -0
- package/dist/roles/env/config.js.map +1 -0
- package/dist/roles/static-analysis/config.js +1 -1
- package/dist/roles/test/setup/jest-parity.js +4 -4
- package/docs/env-adoption.md +268 -0
- package/docs/typescript-traces.md +258 -0
- package/docs/using-sentinel.md +11 -10
- package/docs/validating-a-change.md +114 -0
- package/package.json +5 -1
- package/dist/chunk-WBDBRGLV.js.map +0 -1
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. **
|
|
545
|
-
8. **
|
|
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
|
|
package/dist/bin/sentinel.js
CHANGED
|
@@ -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-
|
|
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-
|
|
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 ? [
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
766
|
+
const moduleRun = await analyse({
|
|
705
767
|
verb: shaped,
|
|
706
|
-
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(
|
|
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
|
|
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":[]}
|