@hublo/sentinel 1.5.1-alpha.2 → 1.6.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/bin/sentinel.js +172 -12
- package/dist/{chunk-WBDBRGLV.js → chunk-7E6YTTAK.js} +8 -1
- package/dist/chunk-7E6YTTAK.js.map +1 -0
- package/dist/chunk-NAFI7OLI.js +75 -0
- package/dist/chunk-NAFI7OLI.js.map +1 -0
- package/dist/{chunk-JHLNNIZC.js → chunk-PC5R4DX5.js} +1368 -272
- 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 +229 -0
- package/docs/typescript-traces.md +258 -0
- package/docs/using-sentinel.md +11 -10
- package/docs/validating-a-change.md +103 -0
- package/package.json +5 -1
- package/dist/chunk-WBDBRGLV.js.map +0 -1
package/README.md
CHANGED
|
@@ -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-PC5R4DX5.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-NAFI7OLI.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,50 @@ async function analyse(params) {
|
|
|
177
183
|
}
|
|
178
184
|
return { results, worstCode };
|
|
179
185
|
}
|
|
186
|
+
async function analyseRepository(params) {
|
|
187
|
+
const results = [];
|
|
188
|
+
let worstCode = 0;
|
|
189
|
+
const project = basename(params.root);
|
|
190
|
+
for (const target of params.targets) {
|
|
191
|
+
const ctx = {
|
|
192
|
+
module: project,
|
|
193
|
+
cwd: params.root,
|
|
194
|
+
preset: "node",
|
|
195
|
+
ci: params.ci,
|
|
196
|
+
fix: false,
|
|
197
|
+
output: params.output,
|
|
198
|
+
envLevel: params.envLevel,
|
|
199
|
+
githubActions: params.githubActions
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
const adapter = resolve(target, void 0, params.runner);
|
|
203
|
+
const result = params.verb === "run" ? await adapter.run(ctx) : await adapter.report(ctx);
|
|
204
|
+
const metrics = result.metrics ?? {};
|
|
205
|
+
results.push({
|
|
206
|
+
project,
|
|
207
|
+
target,
|
|
208
|
+
flavour: "repository",
|
|
209
|
+
ok: result.ok,
|
|
210
|
+
status: result.ok ? "ok" : "failed",
|
|
211
|
+
// A run row carries the verdict's two inputs and nothing more: the report itself was just
|
|
212
|
+
// printed above it, and repeating it inline would print it twice.
|
|
213
|
+
data: params.verb === "run" ? { level: metrics.level, blocking: metrics.blocking } : metrics
|
|
214
|
+
});
|
|
215
|
+
worstCode = Math.max(worstCode, result.code);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
results.push({
|
|
218
|
+
project,
|
|
219
|
+
target,
|
|
220
|
+
flavour: "repository",
|
|
221
|
+
ok: false,
|
|
222
|
+
status: "failed",
|
|
223
|
+
data: { error: error instanceof Error ? error.message : String(error) }
|
|
224
|
+
});
|
|
225
|
+
worstCode = Math.max(worstCode, 1);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { results, worstCode };
|
|
229
|
+
}
|
|
180
230
|
function generateSummary(result) {
|
|
181
231
|
const { project, target, flavour, ok, status, data } = result;
|
|
182
232
|
const details = data && typeof data === "object" ? data : { value: data };
|
|
@@ -221,7 +271,7 @@ import {
|
|
|
221
271
|
writeFileSync
|
|
222
272
|
} from "node:fs";
|
|
223
273
|
import { tmpdir } from "node:os";
|
|
224
|
-
import { basename, dirname, join, relative, sep } from "node:path";
|
|
274
|
+
import { basename as basename2, dirname, join, relative, sep } from "node:path";
|
|
225
275
|
var NOT_COPIED = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
226
276
|
function contents(root) {
|
|
227
277
|
const found = /* @__PURE__ */ new Map();
|
|
@@ -242,7 +292,7 @@ function contents(root) {
|
|
|
242
292
|
}
|
|
243
293
|
function stage(moduleDir, scratch) {
|
|
244
294
|
const root = findWorkspaceRoot(moduleDir);
|
|
245
|
-
const segments = root === void 0 ? [
|
|
295
|
+
const segments = root === void 0 ? [basename2(moduleDir)] : relative(root, moduleDir).split(sep).filter((segment) => segment !== "");
|
|
246
296
|
let real = root ?? dirname(moduleDir);
|
|
247
297
|
let shadow = scratch;
|
|
248
298
|
for (const segment of segments) {
|
|
@@ -257,7 +307,7 @@ function stage(moduleDir, scratch) {
|
|
|
257
307
|
rmSync(shadow, { recursive: true, force: true });
|
|
258
308
|
cpSync(moduleDir, shadow, {
|
|
259
309
|
recursive: true,
|
|
260
|
-
filter: (source) => source === moduleDir || !NOT_COPIED.has(
|
|
310
|
+
filter: (source) => source === moduleDir || !NOT_COPIED.has(basename2(source)) && !lstatSync(source).isSymbolicLink()
|
|
261
311
|
});
|
|
262
312
|
if (existsSync(join(moduleDir, "node_modules"))) {
|
|
263
313
|
symlinkSync(join(moduleDir, "node_modules"), join(shadow, "node_modules"), "dir");
|
|
@@ -294,7 +344,7 @@ async function rehearseAfterInit(moduleDir, roles) {
|
|
|
294
344
|
const before = contents(copy);
|
|
295
345
|
const failure = await role.adapter.rehearseAfterInit?.(
|
|
296
346
|
{
|
|
297
|
-
module:
|
|
347
|
+
module: basename2(moduleDir),
|
|
298
348
|
cwd: copy,
|
|
299
349
|
preset: role.preset,
|
|
300
350
|
ci: false,
|
|
@@ -541,6 +591,9 @@ async function runValidate(ctx) {
|
|
|
541
591
|
return outcomes.every((outcome) => outcome.ok) ? 0 : 1;
|
|
542
592
|
}
|
|
543
593
|
|
|
594
|
+
// src/cli/run-verb.ts
|
|
595
|
+
import { basename as basename3 } from "node:path";
|
|
596
|
+
|
|
544
597
|
// src/core/render.ts
|
|
545
598
|
var HEAD_KEYS = /* @__PURE__ */ new Set([
|
|
546
599
|
"project",
|
|
@@ -660,7 +713,9 @@ function workspacePrep(cwd2) {
|
|
|
660
713
|
return root ? inspectWorkspacePrep(root) : [];
|
|
661
714
|
}
|
|
662
715
|
async function runVerb(ctx) {
|
|
663
|
-
const
|
|
716
|
+
const repositoryTargets = ctx.targets.filter((target) => REPOSITORY_TARGETS.includes(target));
|
|
717
|
+
const moduleTargets = ctx.targets.filter((target) => !REPOSITORY_TARGETS.includes(target));
|
|
718
|
+
const { modules, scope } = moduleTargets.length > 0 ? resolveContext(ctx.cwd, { module: ctx.module, ci: ctx.ci }) : { modules: [] };
|
|
664
719
|
const out = palette(process.stdout);
|
|
665
720
|
const err = palette(process.stderr);
|
|
666
721
|
const longRunning = ctx.targets.filter((target) => LONG_RUNNING_TARGETS.includes(target));
|
|
@@ -701,9 +756,9 @@ async function runVerb(ctx) {
|
|
|
701
756
|
);
|
|
702
757
|
}
|
|
703
758
|
const started = Date.now();
|
|
704
|
-
const
|
|
759
|
+
const moduleRun = await analyse({
|
|
705
760
|
verb: shaped,
|
|
706
|
-
targets:
|
|
761
|
+
targets: moduleTargets,
|
|
707
762
|
modules,
|
|
708
763
|
runner: ctx.runner,
|
|
709
764
|
preset: ctx.preset,
|
|
@@ -721,6 +776,18 @@ async function runVerb(ctx) {
|
|
|
721
776
|
onProgress: process.stderr.isTTY ? (done, total, name) => process.stderr.write(err.dim(` [${done}/${total}] ${name}
|
|
722
777
|
`)) : void 0
|
|
723
778
|
});
|
|
779
|
+
const repositoryRun = repositoryTargets.length > 0 && ctx.repositoryRoot !== void 0 ? await analyseRepository({
|
|
780
|
+
verb: shaped === "report" ? "report" : "run",
|
|
781
|
+
targets: repositoryTargets,
|
|
782
|
+
root: ctx.repositoryRoot,
|
|
783
|
+
runner: ctx.runner,
|
|
784
|
+
ci: ctx.ci,
|
|
785
|
+
output: ctx.output,
|
|
786
|
+
envLevel: ctx.envLevel,
|
|
787
|
+
githubActions: ctx.githubActions
|
|
788
|
+
}) : { results: [], worstCode: 0 };
|
|
789
|
+
const results = [...moduleRun.results, ...repositoryRun.results];
|
|
790
|
+
const worstCode = Math.max(moduleRun.worstCode, repositoryRun.worstCode);
|
|
724
791
|
const summary = generateSummaries(results, ctx.targets);
|
|
725
792
|
if (ctx.json) {
|
|
726
793
|
const base = ctx.verb === "status" || ctx.verb === "inspect" ? {
|
|
@@ -756,8 +823,11 @@ async function runVerb(ctx) {
|
|
|
756
823
|
}
|
|
757
824
|
}
|
|
758
825
|
process.stderr.write(
|
|
759
|
-
err.dim(
|
|
760
|
-
`)
|
|
826
|
+
err.dim(
|
|
827
|
+
moduleTargets.length > 0 ? ` ${modules.length} module(s) [${scope}] in ${Date.now() - started}ms
|
|
828
|
+
` : ` 1 repository [${basename3(ctx.repositoryRoot ?? ctx.cwd)}] in ${Date.now() - started}ms
|
|
829
|
+
`
|
|
830
|
+
)
|
|
761
831
|
);
|
|
762
832
|
if (ctx.targetsExplicit && summary.skipped.length > 0) {
|
|
763
833
|
const avail = availableTargets();
|
|
@@ -771,6 +841,35 @@ async function runVerb(ctx) {
|
|
|
771
841
|
return ctx.verb === "run" || ctx.ci ? worstCode : 0;
|
|
772
842
|
}
|
|
773
843
|
|
|
844
|
+
// src/core/repository-root.ts
|
|
845
|
+
import { execFileSync } from "node:child_process";
|
|
846
|
+
import { realpathSync as realpathSync2 } from "node:fs";
|
|
847
|
+
function findRepositoryRoot(cwd2) {
|
|
848
|
+
try {
|
|
849
|
+
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
850
|
+
cwd: cwd2,
|
|
851
|
+
encoding: "utf8",
|
|
852
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
853
|
+
}).trim();
|
|
854
|
+
return root === "" ? void 0 : realpathSync2(root);
|
|
855
|
+
} catch {
|
|
856
|
+
return void 0;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
function isRepositoryRoot(cwd2, root) {
|
|
860
|
+
try {
|
|
861
|
+
return realpathSync2(cwd2) === root;
|
|
862
|
+
} catch {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function locateRepository(cwd2) {
|
|
867
|
+
const root = findRepositoryRoot(cwd2);
|
|
868
|
+
if (root === void 0) return { ok: false, reason: "not-a-repository" };
|
|
869
|
+
if (!isRepositoryRoot(cwd2, root)) return { ok: false, reason: "not-the-root", root };
|
|
870
|
+
return { ok: true, root };
|
|
871
|
+
}
|
|
872
|
+
|
|
774
873
|
// src/shared/node-version.ts
|
|
775
874
|
var MIN_NODE = "24.15.0";
|
|
776
875
|
function parts(version) {
|
|
@@ -807,6 +906,7 @@ var TARGET_HELP = {
|
|
|
807
906
|
dev: "dev server (long-running: never swept by an unqualified --run)",
|
|
808
907
|
test: "tests",
|
|
809
908
|
"static-analysis": "duplication, dead code, complexity, import graph, file size, bundle weight",
|
|
909
|
+
env: "env files, contracts and Dockerfiles, for the whole repository (run from its root)",
|
|
810
910
|
"runtime-analysis": "Lighthouse, web vitals",
|
|
811
911
|
arch: "architecture boundaries"
|
|
812
912
|
};
|
|
@@ -929,7 +1029,64 @@ if (opts.all && namedTargets.length > 0) {
|
|
|
929
1029
|
`--all runs every target; drop the specific one(s): ${namedTargets.map((t) => `--${t}`).join(", ")}.`
|
|
930
1030
|
);
|
|
931
1031
|
}
|
|
932
|
-
var
|
|
1032
|
+
var RUNNING_VERBS = ["run", "report"];
|
|
1033
|
+
var runsRepository = RUNNING_VERBS.includes(verb);
|
|
1034
|
+
var sweeping = Boolean(opts.all) || namedTargets.length === 0;
|
|
1035
|
+
var sweptRoot = sweeping && runsRepository ? locateRepository(cwd) : void 0;
|
|
1036
|
+
var repositoryRoot = sweptRoot?.ok ? sweptRoot.root : void 0;
|
|
1037
|
+
var targets = sweeping ? SWEEPABLE_TARGETS.filter(
|
|
1038
|
+
(target) => !REPOSITORY_TARGETS.includes(target) || repositoryRoot !== void 0
|
|
1039
|
+
) : namedTargets;
|
|
1040
|
+
var namedRepository = namedTargets.filter((target) => REPOSITORY_TARGETS.includes(target));
|
|
1041
|
+
if (namedRepository.length > 0) {
|
|
1042
|
+
const flags = namedRepository.map((target) => `--${target}`).join(", ");
|
|
1043
|
+
if (!runsRepository) {
|
|
1044
|
+
program.error(
|
|
1045
|
+
`sentinel: ${flags} reads the repository and writes no config, so it only runs: \`sentinel --run ${flags}\`. It has no --${verb}.`
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
if (opts.module !== void 0) {
|
|
1049
|
+
program.error(
|
|
1050
|
+
`sentinel: ${flags} checks the whole repository, so --module does not apply to it. Drop --module, or run ${flags} on its own.`
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
if (opts.fix) {
|
|
1054
|
+
program.error(`sentinel: ${flags} reports and fixes nothing; drop --fix.`);
|
|
1055
|
+
}
|
|
1056
|
+
if (toolArgs.length > 0) {
|
|
1057
|
+
program.error(
|
|
1058
|
+
`sentinel: ${flags} runs no external tool, so options after \`--\` have nowhere to go.`
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
const location = locateRepository(cwd);
|
|
1062
|
+
if (!location.ok) {
|
|
1063
|
+
program.error(
|
|
1064
|
+
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"}.`
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
repositoryRoot = location.root;
|
|
1068
|
+
const asked = typeof opts.output === "string" ? opts.output.split(",").map((one) => one.trim()) : [];
|
|
1069
|
+
const unwritten = asked.filter(
|
|
1070
|
+
(one) => one !== "" && !ENV_SHAPES.includes(one)
|
|
1071
|
+
);
|
|
1072
|
+
if (targets.every((target) => REPOSITORY_TARGETS.includes(target)) && unwritten.length > 0) {
|
|
1073
|
+
program.error(
|
|
1074
|
+
`sentinel: ${flags} writes ${ENV_SHAPES.join(", ")}; not ${unwritten.join(", ")}.`
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
function parseEnvLevel(value) {
|
|
1079
|
+
const asked = (value ?? "").trim().toLowerCase();
|
|
1080
|
+
if (asked === "") return DEFAULT_ENV_LEVEL;
|
|
1081
|
+
if (!ENV_LEVELS.includes(asked)) {
|
|
1082
|
+
return program.error(
|
|
1083
|
+
`sentinel: unknown SENTINEL_ENV_LEVEL ${JSON.stringify(value)}. Supported: ${ENV_LEVELS.join(", ")} (${DEFAULT_ENV_LEVEL} when unset).`
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
return asked;
|
|
1087
|
+
}
|
|
1088
|
+
var envLevel = targets.some((target) => REPOSITORY_TARGETS.includes(target)) ? parseEnvLevel(process.env.SENTINEL_ENV_LEVEL) : DEFAULT_ENV_LEVEL;
|
|
1089
|
+
var githubActions = process.env.GITHUB_ACTIONS === "true";
|
|
933
1090
|
var targetsExplicit = !(opts.all || namedTargets.length === 0);
|
|
934
1091
|
if (namedTargets.includes("static-analysis") && (verb === "report" || verb === "run" && opts.json)) {
|
|
935
1092
|
program.error(
|
|
@@ -991,6 +1148,9 @@ async function main() {
|
|
|
991
1148
|
output: parseOutput(opts.output, Boolean(opts.json)) ?? (opts.json ? "json" : void 0),
|
|
992
1149
|
dryRun: Boolean(opts.dryRun),
|
|
993
1150
|
rehearse: Boolean(opts.rehearse),
|
|
1151
|
+
repositoryRoot,
|
|
1152
|
+
envLevel,
|
|
1153
|
+
githubActions,
|
|
994
1154
|
fail: (message) => program.error(message)
|
|
995
1155
|
};
|
|
996
1156
|
const exitCode = verb === "init" ? await runInit(context) : verb === "validate" ? await runValidate(context) : await runVerb(context);
|
|
@@ -13,15 +13,19 @@ var TARGETS = [
|
|
|
13
13
|
"dev",
|
|
14
14
|
"test",
|
|
15
15
|
"static-analysis",
|
|
16
|
+
"env",
|
|
16
17
|
"runtime-analysis",
|
|
17
18
|
"arch"
|
|
18
19
|
];
|
|
20
|
+
var REPOSITORY_TARGETS = ["env"];
|
|
19
21
|
var LONG_RUNNING_TARGETS = ["dev"];
|
|
20
22
|
var SWEEPABLE_TARGETS = TARGETS.filter(
|
|
21
23
|
(target) => !LONG_RUNNING_TARGETS.includes(target)
|
|
22
24
|
);
|
|
23
25
|
var PRESET_NAMES = ["react", "nest", "svelte", "node", "tools"];
|
|
24
26
|
var OUTPUT_SHAPES = ["console", "json", "html", "markdown"];
|
|
27
|
+
var ENV_LEVELS = ["info", "warning", "error"];
|
|
28
|
+
var DEFAULT_ENV_LEVEL = "warning";
|
|
25
29
|
|
|
26
30
|
// src/roles/static-analysis/presets/shared.json
|
|
27
31
|
var shared_default = {
|
|
@@ -197,10 +201,13 @@ function defineStaticAnalysis(input = {}) {
|
|
|
197
201
|
export {
|
|
198
202
|
VERBS,
|
|
199
203
|
TARGETS,
|
|
204
|
+
REPOSITORY_TARGETS,
|
|
200
205
|
LONG_RUNNING_TARGETS,
|
|
201
206
|
SWEEPABLE_TARGETS,
|
|
202
207
|
PRESET_NAMES,
|
|
203
208
|
OUTPUT_SHAPES,
|
|
209
|
+
ENV_LEVELS,
|
|
210
|
+
DEFAULT_ENV_LEVEL,
|
|
204
211
|
isPlainObject,
|
|
205
212
|
REPORT_DIR,
|
|
206
213
|
TARGET,
|
|
@@ -211,4 +218,4 @@ export {
|
|
|
211
218
|
overridableSettings,
|
|
212
219
|
defineStaticAnalysis
|
|
213
220
|
};
|
|
214
|
-
//# sourceMappingURL=chunk-
|
|
221
|
+
//# sourceMappingURL=chunk-7E6YTTAK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shared/deep-merge.ts","../src/core/domain.ts","../src/roles/static-analysis/presets/shared.json","../src/roles/static-analysis/preset-data.ts","../src/roles/static-analysis/configs/define.ts"],"sourcesContent":["/**\n * Is this a plain JSON object?\n *\n * The file is named for a `deepMerge` that no longer exists. It was written when the engine\n * merged JSON in memory and wrote the result; that approach was replaced by editing the\n * document IN PLACE with jsonc-parser, so a project's comments, key order and formatting\n * survive a `merge-json` op. Nothing has called `deepMerge` since, except its own recursion,\n * so it went.\n *\n * What remains is the predicate the in-place merge still needs, to decide whether a value is\n * a branch to walk into or a leaf to set.\n */\n\n/** True for a mergeable plain object (not null, not an array). */\nexport function isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n","/**\n * The domain vocabulary: the fixed sets of verbs, targets, and presets, and the\n * types derived from them. This is the model, and the extension point: adding a\n * verb / target / preset is a one-line edit to a list here, and because the types\n * are DERIVED (`(typeof LIST)[number]`), the compiler forces every switch/handler\n * to cover the new member.\n *\n * Tunable behaviour (defaults, detection signals, marker filenames) lives in\n * `settings.ts`, not here.\n */\n\n/**\n * Verbs: what to do.\n *\n * The axis is not \"who runs it\" but **what survives the run**, which is the distinction that\n * outlives any sharing of the work:\n *\n * | verb | writes | what remains |\n * | --- | --- | --- |\n * | `init` | a module's config + the workspace prep it needs | the module's new state, that IS the deliverable |\n * | `migrate` | changes an already-initialized setup | same; reserved, not wired yet |\n * | `validate` | instrumentation, if it needs any | **nothing**, the project returns to what it was |\n * | `run` / `inspect` / `report` / `status` | nothing | they execute or introspect |\n *\n * `validate` is the one that judges. Héla, 2026-09-25: it does not carry the migration's\n * strictness, so it MAY write into the project under validation, a `data-testid` to make something\n * addressable for instance, provided it takes it back out. That freedom is what makes its\n * dependability the thing to design: idempotent, reversible on error, recoverable after a kill,\n * and never silent about instrumentation it failed to remove.\n */\nexport const VERBS = ['run', 'inspect', 'init', 'migrate', 'validate', 'report', 'status'] as const\nexport type Verb = (typeof VERBS)[number]\n\n/** Targets: the kind of check. The CLI `--<target>` flags map 1:1 to these. */\nexport const TARGETS = [\n 'lint',\n 'format',\n 'typescript',\n 'build',\n 'dev',\n 'test',\n 'static-analysis',\n 'env',\n 'runtime-analysis',\n 'arch',\n] as const\nexport type Target = (typeof TARGETS)[number]\n\n/**\n * Targets that check the REPOSITORY, not a module, and therefore run once, at its root.\n *\n * Every other target is a question about one module, so the engine asks it per module. `env` is a\n * question about the repository as a whole: which env files git tracks, which contract declares a\n * variable the code reads in another app, whether a Dockerfile copies a path that exists. Asked per\n * module, it would scan the same tree 441 times on the monorepo and report every finding 441 times,\n * and in a repository that is not an nx workspace there are no modules to ask at all.\n *\n * So the location rule is different, and it is the only difference:\n *\n * - it runs from the repository ROOT, the git top-level, found by git and never by nx;\n * - a sweep (`sentinel --run`) includes it once, and only when run from that root, so a sweep inside\n * a module stays about the module and its output does not change;\n * - named from anywhere else, it is REFUSED with the root's path, rather than scoped to a\n * subdirectory: a contract in one app and a reader in another is exactly the finding a scoped\n * scan cannot see, so a scoped report would be a smaller report that looks like a clean one.\n *\n * It answers `--run` only. It writes no config, so it has nothing to `--init`, `--inspect` or\n * `--validate`, and those verbs never sweep it.\n */\nexport const REPOSITORY_TARGETS: readonly Target[] = ['env']\n\n/**\n * Targets that do not TERMINATE, and are therefore never swept.\n *\n * Every other target is a check: it runs, it answers, it exits. `dev` starts a server and\n * stays up until someone stops it. That difference matters exactly once, and it matters a lot:\n * `sentinel --run` with no named target means \"every wired target\", so without this a sweep\n * would launch a dev server per module and never come back — on a 441-module workspace, from a\n * command whose whole purpose is to answer a question.\n *\n * So a long-running target is reachable only when NAMED. `sentinel --run --dev` starts the\n * server; `sentinel --run` never does.\n *\n * It is a target rather than a flag on `--build` because it is the app's second invocation of\n * the same toolchain: `build` stopped calling `vite build` directly, and `serve` still called\n * `vite` directly. Same migration, same tool, one script left behind.\n */\nexport const LONG_RUNNING_TARGETS: readonly Target[] = ['dev']\n\n/** The targets an unqualified `--run` / `--all` covers: everything that terminates. */\nexport const SWEEPABLE_TARGETS: readonly Target[] = TARGETS.filter(\n (target) => !LONG_RUNNING_TARGETS.includes(target),\n)\n\n/** Presets: the stack preset a project resolves to (strict by default). */\nexport const PRESET_NAMES = ['react', 'nest', 'svelte', 'node', 'tools'] as const\nexport type PresetName = (typeof PRESET_NAMES)[number]\n\n/**\n * The shapes `--output` can ask a report-writing role for. `console` writes nothing to disk.\n *\n * ⛔ Here rather than in the role that happens to implement them today, for the same reason\n * `PRESET_NAMES` is here: `--output` is a GLOBAL flag, so the CLI has to validate it before any\n * role is chosen. It used to be validated inside the static-analysis adapter, which runs on\n * `--run` only, and the measured consequence was that `--inspect`, `--status` and `--init`\n * accepted `--output xml` silently and exited 0. A flag that is refused by one verb out of four\n * is worse than one that is never refused: it teaches you it was checked.\n */\nexport const OUTPUT_SHAPES = ['console', 'json', 'html', 'markdown'] as const\nexport type OutputShape = (typeof OUTPUT_SHAPES)[number]\n\n/**\n * What the env role does with what it finds, from `SENTINEL_ENV_LEVEL`.\n *\n * | level | reports | in GitHub Actions | exit |\n * | --- | --- | --- | --- |\n * | `info` | yes | nothing more | 0 |\n * | `warning`, the default | yes | a `::warning` annotation per finding | 0 |\n * | `error` | yes | `::error` on a blocking finding, `::warning` on the rest | 1 on a blocking finding |\n *\n * An environment variable and not a flag, because the same command runs in a developer's shell and\n * in every repository's CI, and what changes between them is the policy, not the command: a team\n * moves from `warning` to `error` by setting one variable in its workflow, without editing the step.\n *\n * ⛔ Here and not in the role for the reason `OUTPUT_SHAPES` is: it is read and VALIDATED at the CLI\n * edge, once, before any role runs, and handed to the role as data. A role that read the\n * environment itself would be the one place in this package whose behaviour a test cannot pin by\n * its inputs.\n */\nexport const ENV_LEVELS = ['info', 'warning', 'error'] as const\nexport type EnvLevel = (typeof ENV_LEVELS)[number]\nexport const DEFAULT_ENV_LEVEL: EnvLevel = 'warning'\n\n/**\n * The outcome of one module × target check. `unsupported` is a distinct third state\n * (a requested target has no adapter yet): it must never read as a pass, and an\n * explicitly-requested unsupported target is a usage error (non-zero exit), while an\n * unsupported target swept in by `--all` is just skipped and shown.\n */\nexport type ResultStatus = 'ok' | 'failed' | 'unsupported'\n\n/**\n * @deprecated Renamed to `PresetName`. Kept so an existing `import type { Flavour }` keeps\n * compiling: the CLI kept `--flavour` working for the same reason, and breaking the type\n * while sparing the flag would be an odd place to draw the line.\n */\nexport type Flavour = PresetName\n","{\n \"entry\": [\n \"**/*.integration-spec.ts\",\n \"**/*.steps.ts\",\n \"src/client.tsx\",\n \"src/start.ts\",\n \"src/router.tsx\",\n \"src/RouterContext.tsx\"\n ],\n \"ignore\": [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/coverage/**\",\n \"**/.next/**\",\n \"**/__mocks__/**\",\n \"**/mocks/**\",\n \"**/_mock_/**\",\n \"**/**Mock**/**\",\n \"**/__fixtures__/**\",\n \"**/fixtures/**\",\n \"**/public/**\",\n \"**/.husky/**\",\n \"**/.vscode/**\",\n \"**/.idea/**\",\n \"**/.github/**\",\n \"**/*.d.ts\",\n \"**/*.gen.ts\",\n \"**/*.generated.ts\"\n ],\n \"languages\": [\"javascript\", \"typescript\", \"jsx\", \"tsx\", \"css\", \"scss\"],\n \"journeys\": {},\n \"duplication\": {\n \"minTokens\": 50,\n \"minLines\": 5,\n \"kinds\": [\"exact\"],\n \"tests\": [\n \"*.{test,spec,steps,e2e-spec,integration-spec,stories}.{ts,tsx,js,jsx}\",\n \"*.{msw,mock,test-data}.{ts,tsx,js,jsx}\",\n \"{tests,__tests__,test,testing,stubs}/**/*.{ts,tsx,js,jsx}\"\n ]\n },\n \"deadCode\": {\n \"minConfidence\": 60,\n \"categories\": [\"unused-file\", \"unused-export\", \"unused-symbol\", \"unused-import\"]\n },\n \"graph\": {\n \"resolution\": 1\n },\n \"tools\": {\n \"jscpd\": {}\n }\n}\n","/**\n * The static analysis preset: the role's settings, as data.\n *\n * One shared layer and no per-stack preset yet, because nothing measured so far varies by stack:\n * duplication, dead code and complexity are the same questions in Nest and in React. The split\n * that is already foreseen is `LCOM4`, which is meaningless on a functional front end and real on\n * nest's 16 568 classes; when it arrives it becomes `nest.json` beside this layer, which is why\n * the values live here as JSON rather than inline in the code.\n *\n * ## Why these values are the role's and not the module's\n *\n * A team declares FACTS about its own domain (which journeys exist, which files are entry points);\n * it does not declare the values a number is judged by. Two files with the same measurement\n * getting different verdicts in two modules would make every figure incomparable, and the first\n * thing this role has to be able to say is that a number means the same thing everywhere.\n *\n * That is a default, not a wall: a module CAN move any of them (see `configs/define.ts`), and when\n * it does the report prints `(module)` next to the value. The seam exists from the first commit\n * because retrofitting one later means rewriting every module's file.\n *\n * ⛔ There are no thresholds here, and their absence is a decision rather than an omission.\n *\n * A threshold was written first, at 5% of duplicated lines, and then removed. Measured over all 228\n * nx projects: 5% flags 65 of the 201 measurable modules, a third of the repository, and moving the\n * figure until the count felt manageable is not calibration, it is fitting the instrument to the\n * answer. Calibration is legitimate when a bench is polluted and the noise is removed, or when the\n * reference frame changes; neither applies to a share of duplicated lines.\n *\n * And there is nothing to calibrate AGAINST: of the sources in `docs/static-analysis-study.md`\n * section 5, NDepend, Lattix, ArchUnit, CodeScene and Arcan, none publishes a threshold for\n * duplicated lines. One invented here would travel as if it were published.\n *\n * So the role reports the measurement. What makes a single figure readable is the distribution it\n * sits in, which only a workspace-wide run can know, so that belongs to the sweep and to the\n * migration page, not to a module's own report. The first defensible warning will not be an absolute\n * number either: a module that got WORSE than its own previous measurement is a signal that needs no\n * invented bar, which is what a baseline gives, and that was already deferred for its own reasons.\n *\n * ## Where each value comes from, so nobody has to guess later\n *\n * - `entry` is the single most consequential line in the file. Measured on\n * `apps/nest/microservices/network` with jscpd 5.4.0: with nothing declared the dead-code pass\n * reports **33.4%** of lines dead (79 657 lines, 232 files, 13 entry points); with these two\n * patterns declared it reports **1.0%** (2 462 lines, 207 entry points). The cause is a naming\n * convention and not a bug: our integration tests are `foo.integration-spec.ts` with a HYPHEN,\n * so they do not end in `.spec.ts` and the tool's own test-file detection never sees them, and\n * the 115 `.steps.ts` BDD definitions are tests by no convention it knows. A module ADDS to\n * this list rather than replacing it, precisely so no team can lose these two by declaring a\n * third.\n *\n * ⭐ The four `src/` files are the FRONT bootstrap, and they are the most consequential thing\n * measured on this pass. The tool recognises around fifty frameworks and TanStack Start is not\n * one of them, so nothing in the repository imports these files: the framework finds them by\n * convention at build time. Measured without them declared, `apps/front/console` reports\n * `src/client.tsx`, `src/router.tsx` and `src/start.ts` among its 5 dead files, which are its\n * hydration entry, its router factory and its Start config. `host-admin` reports `client.tsx`\n * too and `career` reports `router.tsx` and `RouterContext.tsx`. Three of console's five\n * findings were its own bootstrap, and the first team to see that stops trusting the tool on\n * the spot. They exist in 2 to 3 front apps each.\n *\n * ⛔ `scripts/**` is deliberately NOT here, although 8 modules have one and\n * `apps/nest/microservices/contract` reports two `scripts/e2e-*.ts` as dead. No `package.json`\n * script invokes them, so whether they run at all is a fact only the team holds. Declaring them\n * would hide a finding that may well be true, and that is the opposite of this role's job.\n *\n * - `languages` is this role's name for what the tool calls `format`, a word that already names\n * another sentinel role and means something else there. Stating it has a measured cost either\n * way: on `apps/front/console`, naming `typescript` alone scans 300 of 523 files because `.tsx`\n * is a SEPARATE language to the tool, naming all four scans 522 (the one left out being\n * `styles.css`), and naming nothing scans every one of its 224 languages, json and markdown\n * included. Stylesheets are in deliberately: measured on `libs/front` they duplicate at 9.64%\n * against 3.50% for the code, a signal that only survives because the report is broken down per\n * language.\n *\n * - `duplication.minTokens` and `minLines` are the detection window, and the window decides what\n * is INVISIBLE: a fragment shorter than it is not reported at all, so a reader comparing two\n * runs has to know both values. They are the tool's own defaults, written out rather than\n * inherited for that reason.\n *\n * - `duplication.kinds` is `exact` alone, and that is a correctness constraint rather than\n * caution. Measured: with a second kind enabled, `duplicatedLines` adds up overlapping clones.\n * Two 9-line files reported 13 duplicated lines out of 18, 72%, because an `exact` clone of\n * lines 1-4 and a `similar` clone of lines 1-9 containing it were both counted; the real extent\n * is 9 of 18, 50%. The tool's own answer is to cap the display at 100%, which does not make a\n * threshold meaningful. Opening another kind therefore requires computing the share ourselves\n * from the UNION of the line ranges.\n *\n * - `duplication.tests` is the glob that splits the pass in two, and the split is the finding that\n * forced it. Measured across 8 modules of the three families, **71% of all duplicated lines are\n * between two test files**: `libs/nest/auth` reports 35.77% and its product code is at 5.96%,\n * `apps/front/host-admin` 7.91% against 3.59%. A single blended figure is therefore mostly a\n * measure of spec duplication wearing the name of the module's duplication, and the number\n * travels while that nuance does not. So the role reports the two side by side, the way it\n * already refuses to blend languages, and neither is hidden: a module whose specs carry 93% of\n * its clones is saying something about its specs that excluding them would erase.\n *\n * ⚠️ The two passes PARTITION the files and therefore lose a clone that spans them: a fragment\n * shared between a test and a product file has no partner in either scan. Measured on the same\n * 8 modules, that is 42 entries of 5117, 0.8%, 650 lines. Stated in the caveat rather than\n * silently accepted, and it is the price of the split.\n *\n * Two patterns and not one, because a suffix convention does not cover a DIRECTORY convention,\n * and the directory one is not a quirk: 635 files across 26 modules live under `tests/` or\n * `__tests__/` without a name this role's suffixes recognise, `apps/nest/microservices/recruitment`\n * alone holding 160. They are step definitions, in-memory doubles and stubs, which is test-side\n * code by any reading. A role default rather than 26 identical overrides.\n *\n * ⚠️ The patterns are RELATIVE here, and the adapter prefixes and composes them, because the\n * tool's `--pattern` takes exactly one glob. Verified on four modules that the two passes\n * partition exactly: console 479 + 87 = 566, contract 399 + 151 = 550, backends-for-frontends/admin\n * 2062 + 773 = 2835, recruitment 936 + 621 = 1557.\n *\n * ⚠️ And a trap that cost a wrong table before it was caught: these globs contain commas, so they\n * survive the config FILE and are destroyed by the `-i` command-line flag, which splits on\n * commas and cuts `{test,spec}` in half. The adapter writes a file, which is one more reason it\n * does.\n *\n * - `deadCode.minConfidence` is the tool's own default of 60. It scores every finding and names\n * what lowered it (a file calling `eval`, an unrecognised decorator, a name appearing in a\n * string literal, a file in the scan that did not parse). Measured on sentinel: 60 yields 20\n * files, 10 exports and 22 symbols; 90 yields 18 files; 100 yields nothing.\n *\n * - `deadCode.categories` leaves out `unused-member` deliberately: without type information\n * `x.render()` could reach any `render` in the project, which the tool itself calls its least\n * certain rule.\n *\n * - `ignore` excludes generated, vendored and fixture code, which duplicates by nature and buries\n * the clones a developer can act on. It is load-bearing rather than tidy: measured on this\n * package, scanning the module root with no ignore list reports 18 186 files and 32 153 clones,\n * **31 969** of them touching `node_modules`; with the list, 408 files and zero.\n *\n * - `graph.resolution` is Louvain's, and it is 1 because that is the value the papers use and\n * graphology's own default. It decides how large a community must be to survive, so it changes\n * both the community count and `Q`, and graphology's documentation warns in its own words that\n * it is finicky to tweak. `code-health-meter` ships 0.74, arrived at by trying values until the\n * partition looked right; this role declines to do that, for the same reason it ships no\n * duplication threshold. A module that has a reason can move it, and the report prints the\n * value every figure was produced with.\n *\n * ⚠️ What is NOT tunable is the random seed, and that is deliberate. Louvain is greedy, so its\n * answer depends on the order it visits nodes, which graphology draws from `Math.random`.\n * Measured over 20 runs per module: `console` produced 16 distinct values of `Q` and between 12\n * and 14 communities, `recruitment` 19 distinct values spanning 0.0357. With the role's own\n * generator all five modules produce one value. A figure that moves on its own between two runs\n * is not a figure a team can act on, so this one is the role's and nobody's to change.\n *\n * - `tools.jscpd` is passed to the tool verbatim, and it is **sentinel's own** escape hatch, not a\n * module's: `defineStaticAnalysis` excludes it from what a module can override. Everything above\n * uses OUR key names, translated on the way out, so that replacing the tool one day does not\n * oblige a single module to edit its config file, and a module only ever knows the ROLE. This\n * block is where that guarantee stops, which is why it stays on this side and why it is empty:\n * the first thing to put in it would be an option a single preset needs, not a workaround.\n */\nimport { OUTPUT_SHAPES } from '../../core/domain.js'\n\nimport sharedData from './presets/shared.json'\n\n/**\n * Where the json and html reports are written, relative to the module.\n *\n * Named for the role rather than left on the tool's default `report/`, and added to `.gitignore`\n * by `--init`. Both halves matter: the format role once shipped a `lint/` directory that nothing\n * ignored, and every adopting module grew an untracked directory nobody could explain.\n */\nexport const REPORT_DIR = 'static-analysis-report'\n\n/** The target this role serves, which is also the npm script a module runs and the nx target. */\nexport const TARGET = 'static-analysis'\n\n/**\n * The output shapes `--run` can produce. `console` writes nothing to disk.\n *\n * Re-exported from `core/domain` rather than declared here: `--output` is a global CLI flag and\n * the CLI validates it before a role is chosen, so one list has to serve both. This spelling\n * stays because the role and its report read it everywhere.\n */\nexport const SHAPES = OUTPUT_SHAPES\n\n/**\n * The npm scripts a module gets: one per shape a developer runs, which is three of the four.\n *\n * Here rather than in the adapter because the REPORT needs them too, to tell a reader of the\n * console summary where the detail is, and two spellings of one script name is drift nobody\n * notices until a developer types the one that does not exist.\n */\nexport const SCRIPTS = {\n console: TARGET,\n json: `${TARGET}:json`,\n html: `${TARGET}:html`,\n // ⛔ No entry for the `markdown` shape, deliberately, and this is NOT a gap: these are the npm\n // scripts a module gets, and markdown is the recap a workflow posts on a pull request, built\n // from one file per affected module. A developer who wants it by hand gets the console shape,\n // which says the same thing in less space and writes nothing. A fourth script here would be a\n // command nobody runs, carried in 198 package.json files.\n} as const\n\n/**\n * What each language the role scans is called on disk.\n *\n * ⚠️ Two lists that MUST agree, and they were maintained separately: the role declares the\n * languages it asks the tool to scan, and the adapter declared the extensions it looks for when\n * deciding whether a module has anything to measure at all. A module that narrowed `languages`\n * would still be probed for the extensions it had removed, and a language added to the preset\n * would never reach the probe. Derived from one source now, with a test that every language maps.\n */\nconst EXTENSIONS_BY_LANGUAGE: Record<string, string[]> = {\n javascript: ['.js', '.mjs', '.cjs'],\n typescript: ['.ts'],\n jsx: ['.jsx'],\n tsx: ['.tsx'],\n css: ['.css'],\n scss: ['.scss'],\n}\n\n/** The file extensions of the languages given, for deciding whether a module has anything to read. */\nexport function extensionsOf(languages: readonly string[]): string[] {\n return [...new Set(languages.flatMap((language) => EXTENSIONS_BY_LANGUAGE[language] ?? []))]\n}\n\n/** Every language the mapping knows, so a test can hold it against the preset. */\nexport const MAPPED_LANGUAGES = Object.keys(EXTENSIONS_BY_LANGUAGE)\n\nexport type StaticDefaults = {\n entry: string[]\n ignore: string[]\n languages: string[]\n journeys: Record<string, string[]>\n duplication: { minTokens: number; minLines: number; kinds: string[]; tests: string[] }\n deadCode: { minConfidence: number; categories: string[] }\n graph: { resolution: number }\n tools: { jscpd: Record<string, unknown> }\n}\n\n/** The role's settings. Read-only here; `defineStaticAnalysis` resolves a module against them. */\nexport const defaults: StaticDefaults = sharedData as StaticDefaults\n\n/**\n * Keys a module declares DIRECTLY rather than through `overrides`, and how each one combines.\n *\n * They are apart because the verb differs: these two are added to and merged, so a team cannot\n * lose what the role ships by declaring its own. Everything else replaces.\n */\nconst DECLARED: Record<string, string> = {\n entry: 'added to the patterns the role ships, never replacing them',\n journeys: \"merged by name with the role's\",\n}\n\n/**\n * ⛔ Not a module's to set, and the catalogue says so rather than leaving it absent: a key that is\n * simply missing from a list reads as an oversight, and someone will try it. Passing it is dropped\n * with a message, see `configs/define.ts`.\n */\nconst NOT_YOURS: Record<string, string> = {\n tools: \"sentinel's own passthrough to the tool underneath; a module never names the vendor\",\n // ⛔ Listed as movable until it was tried: the catalogue said \"what this module can move\" and\n // moving it exits 1, because any kind beyond `exact` overlaps the first and the duplicated-line\n // share then counts the same lines twice. A setting that is refused at run time belongs here,\n // where the reason is readable, rather than in the list of things to turn.\n 'duplication.kinds': 'only `exact` is supported: any other kind overlaps it and double-counts',\n}\n\n/** A value as a reader compares it: short lists in full, long ones as a count. */\nfunction render(value: unknown): string {\n if (Array.isArray(value)) {\n const joined = value.join(', ')\n return value.length <= 6 && joined.length <= 72 ? joined : `${value.length} patterns`\n }\n if (value && typeof value === 'object') return `${Object.keys(value).length} entries`\n return String(value)\n}\n\n/** One documented key: its path, the role's value, and the note a reader needs. */\nexport interface OverridableSetting {\n /** The dotted path a module writes, e.g. `duplication.minTokens`. */\n key: string\n /** The role's current value, rendered. */\n value: string\n /** How it combines, or why it is not available. */\n note: string\n /** Where it goes: directly in the config, inside `overrides`, or nowhere. */\n where: 'declared' | 'overrides' | 'refused'\n}\n\n/**\n * Every setting a module can touch, DERIVED from the values rather than written beside them.\n *\n * Héla's ask, and the reason it is generated: a hand-written list is a second copy of the preset,\n * and the first setting somebody adds makes it a lie that nobody notices, because a stale comment\n * fails no test. Here a new key appears in the next module's config file on its own, with its real\n * value, and `tests/role-layout.test.ts` refuses a key that reaches none of the three groups.\n *\n * It is a snapshot: `--init` leaves an EXISTING config alone, by design, so a module that adopted\n * earlier keeps the catalogue of that day. The report is the live answer, since it prints the value\n * every figure was produced with.\n */\nexport function overridableSettings(from: StaticDefaults = defaults): OverridableSetting[] {\n const rows: OverridableSetting[] = []\n\n for (const [key, value] of Object.entries(from)) {\n if (key in NOT_YOURS) {\n rows.push({ key, value: render(value), note: NOT_YOURS[key] ?? '', where: 'refused' })\n continue\n }\n if (key in DECLARED) {\n rows.push({ key, value: render(value), note: DECLARED[key] ?? '', where: 'declared' })\n continue\n }\n // A group is documented by its leaves, because `duplication` whole is not what anyone writes.\n const leaves =\n value && typeof value === 'object' && !Array.isArray(value)\n ? Object.entries(value).map(([leaf, own]) => ({ key: `${key}.${leaf}`, own }))\n : [{ key, own: value }]\n for (const leaf of leaves) {\n /*\n * ⛔ A LEAF can be refused too, and this check was missing. `NOT_YOURS` holds the dotted key\n * `duplication.kinds`, but the test above only ran on top-level keys: `duplication` is not\n * in `NOT_YOURS`, so every one of its leaves fell through to here and the catalogue printed\n * `duplication.kinds` under \"What this module can move\".\n *\n * ⚠️ A developer who followed it got exit 1 on the next run, with a message refusing the\n * very setting the generated config had invited them to write. The test that was supposed\n * to cover this passed because the key DID reach a group, just the wrong one.\n */\n if (leaf.key in NOT_YOURS) {\n rows.push({\n key: leaf.key,\n value: render(leaf.own),\n note: NOT_YOURS[leaf.key] ?? '',\n where: 'refused',\n })\n continue\n }\n rows.push({ key: leaf.key, value: render(leaf.own), note: 'replaced', where: 'overrides' })\n }\n }\n\n return rows\n}\n","/**\n * `defineStaticAnalysis`: what a module imports to declare its own static analysis settings.\n *\n * ```js\n * // static-analysis.config.mjs\n * import { defineStaticAnalysis } from '@hublo/sentinel/static-analysis'\n *\n * export default defineStaticAnalysis({\n * root: import.meta.dirname,\n * entry: ['**\\/*.steps.ts'],\n * journeys: { planning: ['src/planning/**'] },\n * })\n * ```\n *\n * ## Why a function and not `{ ...baseConfig, ... }`\n *\n * A spread is SHALLOW, so a module writing `deadCode: { minConfidence: 80 }` would replace the\n * whole block and silently lose `categories`. And the flattening happens in the module's file,\n * before this package ever sees the object, so nothing here could tell \"set one key, keep the\n * rest\" from \"set one key, drop the rest\". A function receives the module's INTENT, the keys it\n * actually passed, and merges them here. Second reason: the defaults are never copied into a\n * module, so changing one reaches every module at the next release instead of leaving 108 stale\n * copies behind.\n *\n * Same shape as `reactTestConfig` in the test role, for the same reasons.\n *\n * ## Two semantics, one obvious and one deliberate\n *\n * `entry` and `journeys` are FACTS a team declares, and they ADD to what this role ships: no\n * module can lose `**\\/*.integration-spec.ts` by declaring a third pattern, which matters\n * because that one pattern is the difference between 33.4% and 1.0% of dead code on a real\n * service (see `defaults.ts`).\n *\n * `overrides` REPLACES, including those lists. It is the seam for the day a team has a real\n * reason, it is not documented anywhere a developer would meet it, and every path it sets is\n * recorded in `moved` so the report can print `(module)` next to the value. Without that trace\n * two modules stop being comparable and nobody knows. It reaches every setting EXCEPT the vendor\n * passthrough, for the reason stated on the field itself.\n */\nimport { isPlainObject } from '../../../shared/deep-merge.js'\nimport { defaults, type StaticDefaults } from '../preset-data.js'\n\ntype DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }\n\n/** What a module's config file passes. Everything is optional, including the file itself. */\nexport interface StaticAnalysisInput {\n /** The module's own directory, normally `import.meta.dirname`. */\n root?: string\n /** Files that run although nothing imports them. ADDED to the shipped patterns. */\n entry?: string[]\n /**\n * Named journeys, as `{ name: [globs] }`. MERGED by name with any shipped ones.\n *\n * ⛔ **No pass reads this yet, and the generated config stub no longer asks for it.** It is the\n * seam for the product axis, where a figure is reported per journey rather than per module, and\n * that is V2 work tracked in issue #98. It stays accepted so a module that already declared one\n * is not broken, and `--inspect` echoes it back, but nothing measures against it.\n *\n * It was in the stub for a while, under a comment telling every adopting team to fill it in. A\n * team that did would have waited for a breakdown that never came and concluded their globs were\n * wrong. Asking for something nothing consumes is worse than not offering it.\n */\n journeys?: Record<string, string[]>\n /**\n * The seam. Replaces whatever it names, and every path it sets lands in `moved`.\n *\n * ⛔ `tools` is excluded, and the reason is the whole point of translating the vocabulary: a\n * module knows the ROLE, not the tool underneath it. A key named after the vendor would mean\n * nothing to the person reading their own config file, and showing it would invite them to learn\n * a name we are working to keep out of their world. The passthrough still exists, on sentinel's\n * side, for a preset that needs a vendor option; a module that genuinely needs one gets a key in\n * OUR vocabulary, which is the right friction because it goes through a review instead of\n * spreading silently.\n */\n overrides?: DeepPartial<Omit<StaticDefaults, 'tools'>>\n}\n\nexport interface ResolvedStaticConfig extends StaticDefaults {\n /** The module's directory, or null when its config file did not say. */\n root: string | null\n /**\n * Dotted paths the module set through `overrides`, so the report can mark them. Sorted, so\n * two runs on the same config produce the same report.\n */\n moved: string[]\n}\n\n/**\n * Merge `patch` into `base`, recursively, patch winning.\n *\n * Not the `deepMerge` this repo once had: that one existed for WRITING a module's json and was\n * replaced by an in-place jsonc edit, which is why only `isPlainObject` survived. This is the\n * read side, which has no file to preserve and only has to resolve two objects into one.\n *\n * Arrays are replaced, never concatenated. Additive behaviour is a decision about a specific\n * key, which `defineStaticAnalysis` makes explicitly for `entry`, rather than a property of the\n * merge: a merge that silently concatenates cannot express \"replace this list\", and a team\n * reaching for `overrides` is asking for exactly that.\n */\nfunction mergeInto<T>(base: T, patch: unknown): T {\n if (!isPlainObject(patch)) return base\n if (!isPlainObject(base)) return patch as T\n const out: Record<string, unknown> = { ...base }\n for (const [key, value] of Object.entries(patch)) {\n out[key] = isPlainObject(value) ? mergeInto(out[key], value) : value\n }\n return out as T\n}\n\n/** Every leaf path in `patch`, dotted, so a reader can be told what the module decided. */\nfunction leafPaths(patch: unknown, prefix = ''): string[] {\n if (!isPlainObject(patch)) return prefix ? [prefix] : []\n return Object.entries(patch).flatMap(([key, value]) =>\n leafPaths(value, prefix ? `${prefix}.${key}` : key),\n )\n}\n\n/** The same list with duplicates dropped, keeping the first occurrence's order. */\nfunction unique(values: string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * Drop the vendor passthrough from what a module sent.\n *\n * ⚠️ Enforced here and not by the type, because the type cannot: a module's config file is `.mjs`,\n * so nothing type-checks it, and `Omit` on the input would be documentation that compiles away. A\n * `tools` key written there is dropped and NAMED on stderr rather than ignored, so a developer who\n * found the key somewhere learns why it did nothing instead of concluding the role is broken.\n */\nfunction withoutVendorKeys(overrides: unknown): unknown {\n if (!isPlainObject(overrides) || !('tools' in overrides)) return overrides\n const { tools: _vendor, ...rest } = overrides\n process.stderr.write(\n 'sentinel static-analysis: ignoring `overrides.tools` from this module. The tool underneath ' +\n \"the role is not part of a module's surface; if a setting is missing, it needs a key of \" +\n 'its own.\\n',\n )\n return rest\n}\n\n/** Resolve a module's declaration against this role's defaults. */\nexport function defineStaticAnalysis(input: StaticAnalysisInput = {}): ResolvedStaticConfig {\n const declared: StaticDefaults = {\n ...defaults,\n entry: unique([...defaults.entry, ...(input.entry ?? [])]),\n journeys: { ...defaults.journeys, ...(input.journeys ?? {}) },\n }\n const overrides = withoutVendorKeys(input.overrides)\n\n return {\n ...mergeInto(declared, overrides),\n root: input.root ?? null,\n moved: leafPaths(overrides).sort(),\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,75 @@
|
|
|
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 stringListProblems(path, value) {
|
|
26
|
+
if (value === void 0) return [];
|
|
27
|
+
if (!Array.isArray(value)) return [`${path} must be an array of strings`];
|
|
28
|
+
return value.some((entry) => typeof entry !== "string" || entry === "") ? [`${path} must hold non-empty strings only`] : [];
|
|
29
|
+
}
|
|
30
|
+
function problemsOf(input) {
|
|
31
|
+
const problems = [];
|
|
32
|
+
for (const [key, value] of Object.entries(input)) {
|
|
33
|
+
if (!TOP_LEVEL_KEYS.has(key)) {
|
|
34
|
+
problems.push(`unknown key "${key}"`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const nested = NESTED_KEYS[key];
|
|
38
|
+
if (nested === void 0) {
|
|
39
|
+
if (key === "knownExternalPatterns") {
|
|
40
|
+
if (value !== void 0 && !Array.isArray(value)) {
|
|
41
|
+
problems.push(`${key} must be an array of RegExp or strings`);
|
|
42
|
+
} else if (Array.isArray(value) && value.some((entry) => !(entry instanceof RegExp) && typeof entry !== "string")) {
|
|
43
|
+
problems.push(`${key} must hold RegExp or strings only`);
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
problems.push(...stringListProblems(key, value));
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (value === void 0) continue;
|
|
51
|
+
if (!isRecord(value)) {
|
|
52
|
+
problems.push(`${key} must be an object`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
for (const [inner, innerValue] of Object.entries(value)) {
|
|
56
|
+
if (!nested.has(inner)) problems.push(`unknown key "${key}.${inner}"`);
|
|
57
|
+
else problems.push(...stringListProblems(`${key}.${inner}`, innerValue));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return problems;
|
|
61
|
+
}
|
|
62
|
+
function defineEnv(input = {}) {
|
|
63
|
+
if (!isRecord(input)) throw new Error("env config must export an object");
|
|
64
|
+
const problems = problemsOf(input);
|
|
65
|
+
if (problems.length > 0) {
|
|
66
|
+
throw new Error(`invalid env config:
|
|
67
|
+
- ${problems.join("\n - ")}`);
|
|
68
|
+
}
|
|
69
|
+
return input;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export {
|
|
73
|
+
defineEnv
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=chunk-NAFI7OLI.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/** 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`]\n return value.some((entry) => typeof entry !== 'string' || entry === '')\n ? [`${path} must hold non-empty strings only`]\n : []\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 if (value !== undefined && !Array.isArray(value)) {\n problems.push(`${key} must be an array of RegExp or strings`)\n } else if (\n Array.isArray(value) &&\n value.some((entry) => !(entry instanceof RegExp) && typeof entry !== 'string')\n ) {\n problems.push(`${key} must hold RegExp or strings only`)\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`)\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')\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":";AAwEA,IAAM,iBAAiB,oBAAI,IAAY;AAAA,EACrC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AACD,IAAM,aAAa,oBAAI,IAAI,CAAC,SAAS,OAAO,mBAAmB,aAAa,CAAC;AAC7E,IAAM,cAA6D;AAAA,EACjE,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,SAAS,oBAAI,IAAI,CAAC,YAAY,UAAU,CAAC;AAAA,EACzC,QAAQ,oBAAI,IAAI,CAAC,aAAa,cAAc,CAAC;AAC/C;AAEA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAGA,SAAS,mBAAmB,MAAc,OAA0B;AAClE,MAAI,UAAU,OAAW,QAAO,CAAC;AACjC,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO,CAAC,GAAG,IAAI,8BAA8B;AACxE,SAAO,MAAM,KAAK,CAAC,UAAU,OAAO,UAAU,YAAY,UAAU,EAAE,IAClE,CAAC,GAAG,IAAI,mCAAmC,IAC3C,CAAC;AACP;AAGA,SAAS,WAAW,OAA0C;AAC5D,QAAM,WAAqB,CAAC;AAC5B,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,CAAC,eAAe,IAAI,GAAG,GAAG;AAC5B,eAAS,KAAK,gBAAgB,GAAG,GAAG;AACpC;AAAA,IACF;AACA,UAAM,SAAS,YAAY,GAAG;AAC9B,QAAI,WAAW,QAAW;AACxB,UAAI,QAAQ,yBAAyB;AACnC,YAAI,UAAU,UAAa,CAAC,MAAM,QAAQ,KAAK,GAAG;AAChD,mBAAS,KAAK,GAAG,GAAG,wCAAwC;AAAA,QAC9D,WACE,MAAM,QAAQ,KAAK,KACnB,MAAM,KAAK,CAAC,UAAU,EAAE,iBAAiB,WAAW,OAAO,UAAU,QAAQ,GAC7E;AACA,mBAAS,KAAK,GAAG,GAAG,mCAAmC;AAAA,QACzD;AAAA,MACF,OAAO;AACL,iBAAS,KAAK,GAAG,mBAAmB,KAAK,KAAK,CAAC;AAAA,MACjD;AACA;AAAA,IACF;AACA,QAAI,UAAU,OAAW;AACzB,QAAI,CAAC,SAAS,KAAK,GAAG;AACpB,eAAS,KAAK,GAAG,GAAG,oBAAoB;AACxC;AAAA,IACF;AACA,eAAW,CAAC,OAAO,UAAU,KAAK,OAAO,QAAQ,KAAK,GAAG;AACvD,UAAI,CAAC,OAAO,IAAI,KAAK,EAAG,UAAS,KAAK,gBAAgB,GAAG,IAAI,KAAK,GAAG;AAAA,UAChE,UAAS,KAAK,GAAG,mBAAmB,GAAG,GAAG,IAAI,KAAK,IAAI,UAAU,CAAC;AAAA,IACzE;AAAA,EACF;AACA,SAAO;AACT;AAcO,SAAS,UAAU,QAAkB,CAAC,GAAa;AACxD,MAAI,CAAC,SAAS,KAAK,EAAG,OAAM,IAAI,MAAM,kCAAkC;AACxE,QAAM,WAAW,WAAW,KAAK;AACjC,MAAI,SAAS,SAAS,GAAG;AACvB,UAAM,IAAI,MAAM;AAAA,MAA4B,SAAS,KAAK,QAAQ,CAAC,EAAE;AAAA,EACvE;AACA,SAAO;AACT;","names":[]}
|